Compare commits
14 Commits
notificati
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
295bb0a6b8 | ||
|
|
01f01ebd0a | ||
|
|
ad1bf1af3d | ||
|
|
5833063053 | ||
|
|
5b8711cdf0 | ||
|
|
b6addb411a | ||
|
|
7de53482b1 | ||
|
|
d280b3865e | ||
|
|
131eeacbd2 | ||
|
|
59fcad643a | ||
|
|
53c345d984 | ||
|
|
39bd572955 | ||
|
|
3297f3d9fa | ||
|
|
829aaf82a6 |
313
backend/README.md
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
# Backend — OptiRuta
|
||||||
|
|
||||||
|
API REST en **Node.js + Express + TypeScript** con **Prisma 7** sobre **PostgreSQL**.
|
||||||
|
|
||||||
|
> Para el README general del proyecto, ver [../README.md](../README.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tabla de contenidos
|
||||||
|
|
||||||
|
- [Stack](#stack)
|
||||||
|
- [Estructura](#estructura)
|
||||||
|
- [Arquitectura Clean](#arquitectura-clean)
|
||||||
|
- [Variables de entorno](#variables-de-entorno)
|
||||||
|
- [Modelo de datos (Prisma)](#modelo-de-datos-prisma)
|
||||||
|
- [Cómo correr](#cómo-correr)
|
||||||
|
- [Módulos](#módulos)
|
||||||
|
- [El simulador](#el-simulador)
|
||||||
|
- [Reglas de arquitectura](#reglas-de-arquitectura)
|
||||||
|
- [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **TypeScript** estricto (`exactOptionalPropertyTypes: true`)
|
||||||
|
- **Node.js 20+**, modules ESM
|
||||||
|
- **Express 5** para HTTP
|
||||||
|
- **Prisma 7** (cliente generado en `src/generated/prisma`)
|
||||||
|
- **PostgreSQL 15** vía Docker Compose
|
||||||
|
- **jsonwebtoken** para JWT, **bcryptjs** para hashes
|
||||||
|
- **tsx** como dev runner (sin Babel, sin reload manual)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estructura
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── docker-compose.yml ← Postgres en 5433:5432
|
||||||
|
├── prisma/
|
||||||
|
│ ├── schema.prisma
|
||||||
|
│ └── migrations/
|
||||||
|
├── prisma.config.ts
|
||||||
|
└── src/
|
||||||
|
├── app.ts ← entry point: conecta DB, seed, lanza simulador, server
|
||||||
|
├── config/
|
||||||
|
│ ├── env.ts ← lectura de variables del .env
|
||||||
|
│ ├── jwt.ts ← JwtAdapter (sign / validate)
|
||||||
|
│ └── bcrypt.ts ← BcryptAdapter (hash / compare)
|
||||||
|
├── data/
|
||||||
|
│ ├── cache/ ← caches en memoria (notification-cache, route-state, inbox)
|
||||||
|
│ ├── mocks/ ← rutas, colonias, users de prueba
|
||||||
|
│ ├── postgres/ ← Prisma client singleton
|
||||||
|
│ ├── repositories/ ← impls reales (Prisma)
|
||||||
|
│ ├── seed/ ← seed del admin
|
||||||
|
│ └── simulation/ ← RouteSimulator (cron interno)
|
||||||
|
├── domain/
|
||||||
|
│ ├── dtos/ ← DTOs con validators estáticos
|
||||||
|
│ ├── errors/ ← CustomError
|
||||||
|
│ ├── repositories/ ← interfaces abstractas
|
||||||
|
│ └── use-cases/ ← lógica de negocio
|
||||||
|
└── presentation/
|
||||||
|
├── admin/ ← controller + routes
|
||||||
|
├── auth/
|
||||||
|
├── addresses/
|
||||||
|
├── feedback/
|
||||||
|
├── middlewares/ ← AuthMiddleware (validate + requireAdmin)
|
||||||
|
├── tracking/
|
||||||
|
├── routes.ts ← AppRoutes: une todos los módulos
|
||||||
|
└── server.ts ← clase Server (Express setup)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitectura Clean
|
||||||
|
|
||||||
|
```
|
||||||
|
HTTP request
|
||||||
|
▼
|
||||||
|
presentation (Express controller)
|
||||||
|
▼
|
||||||
|
domain (use-case + DTO validator)
|
||||||
|
▼
|
||||||
|
data (repository impl / mock / cache)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reglas
|
||||||
|
|
||||||
|
1. El **controller** solo recibe `req`, llama al use-case, responde JSON, maneja errores con `handleError()`.
|
||||||
|
2. Los **use-cases** llevan toda la lógica de negocio.
|
||||||
|
3. Los **DTOs** validan datos de entrada con métodos estáticos `static validate(data: unknown)`.
|
||||||
|
4. Los **repositories** son interfaces en `domain/repositories/`; las implementaciones reales viven en `data/`.
|
||||||
|
5. Los **errores** se manejan con `CustomError`: `badRequest`, `unauthorized`, `forbidden`, `notFound`, `conflict`, `internalServer`.
|
||||||
|
6. JWT solo se maneja desde `JwtAdapter`. Bcrypt solo desde `BcryptAdapter`.
|
||||||
|
7. Variables de entorno solo en `config/env.ts` (excepción: `.env`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Variables de entorno
|
||||||
|
|
||||||
|
Archivo `.env` (no se sube al repo):
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
PORT=8080
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
DATABASE_URL=postgresql://user:password@localhost:5433/optihack
|
||||||
|
POSTGRES_USER=user
|
||||||
|
POSTGRES_PASSWORD=password
|
||||||
|
POSTGRES_DB=optihack
|
||||||
|
|
||||||
|
JWT_SEED=your_super_secret_jwt_seed_here_change_in_production
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
BCRYPT_ROUNDS=10
|
||||||
|
```
|
||||||
|
|
||||||
|
| Variable | Default | Notas |
|
||||||
|
|---|---|---|
|
||||||
|
| `PORT` | `3000` | Aquí usamos `8080` porque otra app ocupa 3000 |
|
||||||
|
| `DATABASE_URL` | — | Apunta a Postgres en 5433 (Docker mapping) |
|
||||||
|
| `JWT_SEED` | — | Requerido. Cambiar en producción |
|
||||||
|
| `JWT_EXPIRES_IN` | `7d` | Duración del token |
|
||||||
|
| `BCRYPT_ROUNDS` | `10` | Costo del hash |
|
||||||
|
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | — | Usados por `docker-compose.yml` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modelo de datos (Prisma)
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
enum UserRole { USER ADMIN }
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String
|
||||||
|
email String @unique
|
||||||
|
password String
|
||||||
|
phone String? @unique
|
||||||
|
role UserRole @default(USER)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
addresses Address[]
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Address {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
userId Int
|
||||||
|
label String
|
||||||
|
street String
|
||||||
|
neighborhood String?
|
||||||
|
postalCode String?
|
||||||
|
latitude Float?
|
||||||
|
longitude Float?
|
||||||
|
isDefault Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
@@map("addresses")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> En el MVP solo usamos `User`. `Address` está modelada pero los domicilios se guardan en mocks por ahora — se puede migrar sin tocar los controllers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cómo correr
|
||||||
|
|
||||||
|
### 1. Postgres con Docker
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose up -d
|
||||||
|
docker ps # verifica que postgres:15.3 esté Up
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Migraciones + cliente
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm install
|
||||||
|
npx prisma migrate deploy
|
||||||
|
npx prisma generate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Dev
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Output esperado:
|
||||||
|
|
||||||
|
```
|
||||||
|
Database connected
|
||||||
|
Seed admin: creado admin@test.com con password "admin123" (cambiar en producción)
|
||||||
|
RouteSimulator started — tick every 30s
|
||||||
|
Server is running on port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Production build
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run build # compila a dist/
|
||||||
|
npm start # corre node dist/app.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scripts disponibles
|
||||||
|
|
||||||
|
| Script | Hace |
|
||||||
|
|---|---|
|
||||||
|
| `npm run dev` | `tsx watch src/app.ts` — hot reload |
|
||||||
|
| `npm run build` | `tsc` — emite a `dist/` |
|
||||||
|
| `npm start` | `node dist/app.js` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Módulos
|
||||||
|
|
||||||
|
### Auth (`/api/auth/*`)
|
||||||
|
|
||||||
|
- **Registro**: hashea password con `BcryptAdapter`, crea con Prisma, agrega al mock de service con `routeId` default = `RUTA-01`. Siempre rol `USER`.
|
||||||
|
- **Login**: compara password con bcrypt, genera JWT con `JwtAdapter.generate({ id, email })`. El login response incluye el `role` del DB.
|
||||||
|
- **GetMe**: protegido con `AuthMiddleware.validate`, devuelve datos del user logueado.
|
||||||
|
- Si un user legacy no estaba en el mock, el login lo agrega (idempotente con `upsertUser`).
|
||||||
|
|
||||||
|
### Tracking (`/api/tracking/*`)
|
||||||
|
|
||||||
|
- **POST `/gps-update`**: punto de entrada del simulador (o admin manual). Calcula ETA, evalúa notificaciones, escribe en `route-state` + `inbox`. Respeta cancelación.
|
||||||
|
- **GET `/status`**: protegido. Devuelve la **visión de túnel** del user — solo su ruta, su ETA, sus notificaciones.
|
||||||
|
- **POST `/reset-demo`**: limpia caches y estados en memoria sin reiniciar el server.
|
||||||
|
|
||||||
|
### Direcciones (`/api/addresses/*`)
|
||||||
|
|
||||||
|
- **GET `/colonias`**: catálogo público (mock).
|
||||||
|
- **GET `/me`**: dirección actual del user.
|
||||||
|
- **PUT `/me`**: cambia la colonia del user. Valida contra el catálogo; rechaza colonias inexistentes (cumple "validación de zona permitida" del reto).
|
||||||
|
|
||||||
|
### Feedback (`/api/feedback/*`)
|
||||||
|
|
||||||
|
- **POST**: envía feedback con tipo + mensaje + rating opcional. Tipos: `TRUCK_DID_NOT_PASS`, `RATING`, `SUGGESTION`, `OTHER`.
|
||||||
|
- **GET `/me`**: feedbacks del user logueado (RBAC).
|
||||||
|
|
||||||
|
### Admin (`/api/admin/*`)
|
||||||
|
|
||||||
|
Todos requieren `AuthMiddleware.requireAdmin`.
|
||||||
|
|
||||||
|
- **GET `/routes`**: lista todas las rutas con estado actual (status, currentPositionId, arrivalResult, cancelled).
|
||||||
|
- **POST `/routes/:routeId/cancel`**: cancela la ruta. El simulador la pausa. Los users de esa ruta reciben notificación.
|
||||||
|
- **POST `/routes/:routeId/resume`**: reanuda. Simulator vuelve a positionId 1 y se limpia el dedup cache.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## El simulador
|
||||||
|
|
||||||
|
`data/simulation/route-simulator.ts`
|
||||||
|
|
||||||
|
- Corre `setInterval` cada **30 segundos**
|
||||||
|
- Solo simula `RUTA-01` por defecto (configurable en `SIMULATED_ROUTE_IDS`)
|
||||||
|
- Cada tick: avanza el positionIndex de la ruta, dispara `ProcessGpsUpdateUseCase` con la posición correspondiente del mock
|
||||||
|
- Cuando el ciclo se completa (positionIndex wrap a 0), limpia el dedup cache para que la siguiente vuelta vuelva a emitir notificaciones
|
||||||
|
- Respeta el flag `cancelled` del estado de ruta: si está cancelada, no avanza
|
||||||
|
- El admin puede llamar `resetPosition(routeId)` para volver el ciclo a 0
|
||||||
|
|
||||||
|
```
|
||||||
|
positionId 1 → (sin notif, salida del relleno)
|
||||||
|
positionId 2 → ROUTE_START "¡Ruta Iniciada!"
|
||||||
|
positionId 3 → (sin notif, en camino)
|
||||||
|
positionId 4 → TRUCK_PROXIMITY "Camión Cercano" (≈ 12 min de llegada)
|
||||||
|
positionId 5 → TRUCK_ARRIVED "El camión ya está aquí" (ETA = 0, arrivalResult = ARRIVED)
|
||||||
|
positionId 8 → ROUTE_COMPLETED "Servicio Finalizado"
|
||||||
|
wrap → cache cleared, loop back
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reglas de arquitectura
|
||||||
|
|
||||||
|
Las reglas que mantenemos a lo largo del proyecto:
|
||||||
|
|
||||||
|
1. **No lógica pesada en controllers.** Solo: recibir, validar, llamar use-case, responder.
|
||||||
|
2. **Lógica de negocio en use-cases.**
|
||||||
|
3. **Prisma solo en `data/repositories` o `data/postgres`.**
|
||||||
|
4. **Use-cases dependen de interfaces** (`domain/repositories/*`), nunca de Prisma.
|
||||||
|
5. **DTOs validan entrada** con `static validate()`.
|
||||||
|
6. **Errores con `CustomError`**.
|
||||||
|
7. **JWT solo via `JwtAdapter`. Bcrypt solo via `BcryptAdapter`**.
|
||||||
|
8. **No regresar passwords en ninguna respuesta**.
|
||||||
|
9. **Las rutas en `presentation`**. Middlewares en `presentation/middlewares`.
|
||||||
|
10. **Variables de entorno solo en `config/env.ts`**.
|
||||||
|
11. **No editar archivos generados** (`src/generated/prisma/*`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Síntoma | Causa | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| `Can't reach database server at localhost:5432` | DATABASE_URL apunta a 5432 pero Docker mapea 5433 | Cambiar puerto en `.env` a `5433` |
|
||||||
|
| `Prisma client not found` | Cliente no generado | `npx prisma generate` |
|
||||||
|
| Server arranca pero `/api/...` da 404 | Olvidaste registrar la ruta en `presentation/routes.ts` | Agregar `router.use('/api/X', XRoutes.routes)` |
|
||||||
|
| Login devuelve 400 con error de Prisma | Tabla `users` no existe | `npx prisma migrate deploy` |
|
||||||
|
| Admin tab no aparece en el front | Login devolvió role distinto a "ADMIN" | Verifica que el seed haya corrido (mira el log de arranque) |
|
||||||
|
| Notificaciones repetidas no salen | Dedup cache | Esperar al wrap del simulador o llamar `POST /api/tracking/reset-demo` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Para extender
|
||||||
|
|
||||||
|
- Añadir nuevo módulo X: copia la estructura de `feedback/` (DTO → repo → use-case → controller → route → registrar en `AppRoutes`)
|
||||||
|
- Migrar un mock a DB real: cambia solo la **impl** en `data/`, las interfaces y use-cases no se tocan
|
||||||
|
- Conectar Redis: nueva impl de `NotificationCacheRepository` y `RouteStateRepository`, cambia el `new InMemory...` por `new Redis...` en el controller
|
||||||
@@ -3,18 +3,31 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { env } from "./config/env.js";
|
import { env } from "./config/env.js";
|
||||||
import { AppRoutes } from "./presentation/routes.js";
|
import { AppRoutes } from "./presentation/routes.js";
|
||||||
|
import { TrackingRoutes } from "./presentation/tracking/routes.js";
|
||||||
import { Server } from "./presentation/server.js";
|
import { Server } from "./presentation/server.js";
|
||||||
import { prisma } from "./data/postgres/index.js";
|
import { prisma } from "./data/postgres/index.js";
|
||||||
|
import { seedAdmin } from "./data/seed/seed-admin.js";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
try {
|
try {
|
||||||
await prisma.$connect();
|
await prisma.$connect();
|
||||||
console.log("Database connected");
|
console.log("Database connected");
|
||||||
|
|
||||||
|
// Garantiza que exista la cuenta admin del sistema
|
||||||
|
await seedAdmin();
|
||||||
|
|
||||||
|
// AppRoutes.routes debe leerse ANTES de pedir el simulator para que el
|
||||||
|
// controller singleton ya esté construido.
|
||||||
|
const routes = AppRoutes.routes;
|
||||||
|
|
||||||
|
const trackingController = TrackingRoutes.controllerInstance;
|
||||||
|
if (trackingController) {
|
||||||
|
trackingController.buildSimulator().start();
|
||||||
|
}
|
||||||
|
|
||||||
const server = new Server({
|
const server = new Server({
|
||||||
port: env.PORT,
|
port: env.PORT,
|
||||||
routes: AppRoutes.routes,
|
routes,
|
||||||
});
|
});
|
||||||
|
|
||||||
await server.start();
|
await server.start();
|
||||||
|
|||||||
32
backend/src/data/cache/notification-inbox.impl.ts
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* notification-inbox.impl.ts
|
||||||
|
* Implementación en memoria del buzón de notificaciones por usuario.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
InboxNotification,
|
||||||
|
NotificationInboxRepository,
|
||||||
|
} from "../../domain/repositories/notification-inbox.repository.js";
|
||||||
|
|
||||||
|
export class InMemoryNotificationInbox implements NotificationInboxRepository {
|
||||||
|
private readonly inbox = new Map<number, InboxNotification[]>();
|
||||||
|
|
||||||
|
async addForUser(notification: InboxNotification): Promise<void> {
|
||||||
|
const list = this.inbox.get(notification.userId) ?? [];
|
||||||
|
list.unshift(notification);
|
||||||
|
this.inbox.set(notification.userId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getForUser(userId: number): Promise<InboxNotification[]> {
|
||||||
|
return this.inbox.get(userId) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearForUser(userId: number): Promise<void> {
|
||||||
|
this.inbox.delete(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper para reset de demo. */
|
||||||
|
async clearAll(): Promise<void> {
|
||||||
|
this.inbox.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
79
backend/src/data/cache/route-state.impl.ts
vendored
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* route-state.impl.ts
|
||||||
|
* Implementación en memoria de RouteStateRepository.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ArrivalResult,
|
||||||
|
RouteState,
|
||||||
|
RouteStateRepository,
|
||||||
|
} from "../../domain/repositories/route-state.repository.js";
|
||||||
|
|
||||||
|
const emptyEta = {
|
||||||
|
etaMinutes: -1,
|
||||||
|
arrivalWindow: { from: "--:--", to: "--:--" },
|
||||||
|
message: "Sin datos",
|
||||||
|
};
|
||||||
|
|
||||||
|
export class InMemoryRouteStateRepository implements RouteStateRepository {
|
||||||
|
private readonly states = new Map<string, RouteState>();
|
||||||
|
|
||||||
|
async get(routeId: string): Promise<RouteState | null> {
|
||||||
|
return this.states.get(routeId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(state: RouteState): Promise<void> {
|
||||||
|
this.states.set(state.routeId, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAll(): Promise<RouteState[]> {
|
||||||
|
return Array.from(this.states.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearAll(): Promise<void> {
|
||||||
|
this.states.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(routeId: string, reason?: string): Promise<void> {
|
||||||
|
const existing = this.states.get(routeId);
|
||||||
|
const base: RouteState = existing ?? {
|
||||||
|
routeId,
|
||||||
|
currentPositionId: 0,
|
||||||
|
eta: emptyEta,
|
||||||
|
status: "ESPERA",
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
arrivalResult: "PENDING",
|
||||||
|
cancelled: false,
|
||||||
|
};
|
||||||
|
const next: RouteState = {
|
||||||
|
...base,
|
||||||
|
cancelled: true,
|
||||||
|
status: "CANCELADO",
|
||||||
|
arrivalResult: "CANCELLED",
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
if (reason !== undefined) next.cancelReason = reason;
|
||||||
|
this.states.set(routeId, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resume(routeId: string): Promise<void> {
|
||||||
|
const existing = this.states.get(routeId);
|
||||||
|
if (!existing) return;
|
||||||
|
const next: RouteState = {
|
||||||
|
...existing,
|
||||||
|
cancelled: false,
|
||||||
|
status: "EN_RUTA",
|
||||||
|
arrivalResult: "PENDING",
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
delete next.cancelReason;
|
||||||
|
this.states.set(routeId, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper para actualizar el arrivalResult sin reescribir el resto. */
|
||||||
|
async updateArrival(routeId: string, result: ArrivalResult): Promise<void> {
|
||||||
|
const existing = this.states.get(routeId);
|
||||||
|
if (!existing) return;
|
||||||
|
this.states.set(routeId, { ...existing, arrivalResult: result });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,4 +14,9 @@ export const colonias: Colonia[] = [
|
|||||||
{ colonia: "Los Olivos", routeId: "RUTA-04", horarioEstimado: "Matutino (07:00 - 07:40)" },
|
{ colonia: "Los Olivos", routeId: "RUTA-04", horarioEstimado: "Matutino (07:00 - 07:40)" },
|
||||||
{ colonia: "Rancho Seco", routeId: "RUTA-05", horarioEstimado: "Vespertino (14:15 - 15:00)" },
|
{ colonia: "Rancho Seco", routeId: "RUTA-05", horarioEstimado: "Vespertino (14:15 - 15:00)" },
|
||||||
{ colonia: "Las Insurgentes", routeId: "RUTA-12", horarioEstimado: "Matutino (06:35 - 07:10)" },
|
{ colonia: "Las Insurgentes", routeId: "RUTA-12", horarioEstimado: "Matutino (06:35 - 07:10)" },
|
||||||
|
|
||||||
|
// Colonias con calendario de separación específico (RUTA-40/65/80)
|
||||||
|
{ colonia: "Industrial Norte", routeId: "RUTA-40", horarioEstimado: "Matutino (06:00 - 07:00)" },
|
||||||
|
{ colonia: "Plaza Mayor", routeId: "RUTA-65", horarioEstimado: "Matutino (07:30 - 08:30)" },
|
||||||
|
{ colonia: "Fracc. Pinos Sur", routeId: "RUTA-80", horarioEstimado: "Matutino (07:00 - 08:00)" },
|
||||||
];
|
];
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
export const NotificationType = {
|
export const NotificationType = {
|
||||||
ROUTE_START: "ROUTE_START",
|
ROUTE_START: "ROUTE_START",
|
||||||
TRUCK_PROXIMITY: "TRUCK_PROXIMITY",
|
TRUCK_PROXIMITY: "TRUCK_PROXIMITY",
|
||||||
|
TRUCK_ARRIVED: "TRUCK_ARRIVED",
|
||||||
ROUTE_COMPLETED: "ROUTE_COMPLETED",
|
ROUTE_COMPLETED: "ROUTE_COMPLETED",
|
||||||
DELAY: "DELAY",
|
DELAY: "DELAY",
|
||||||
MECHANICAL_FAILURE: "MECHANICAL_FAILURE",
|
MECHANICAL_FAILURE: "MECHANICAL_FAILURE",
|
||||||
@@ -25,6 +26,10 @@ export const notificationPayloads: Record<NotificationType, NotificationPayload>
|
|||||||
title: "Camión Cercano",
|
title: "Camión Cercano",
|
||||||
body: "El camión está a menos de 15 minutos de tu domicilio. Es momento de sacar tus bolsas a la acera.",
|
body: "El camión está a menos de 15 minutos de tu domicilio. Es momento de sacar tus bolsas a la acera.",
|
||||||
},
|
},
|
||||||
|
TRUCK_ARRIVED: {
|
||||||
|
title: "¡El camión ya está aquí!",
|
||||||
|
body: "El camión recolector llegó a tu zona. Saca tus residuos a la acera ahora.",
|
||||||
|
},
|
||||||
ROUTE_COMPLETED: {
|
ROUTE_COMPLETED: {
|
||||||
title: "Servicio Finalizado",
|
title: "Servicio Finalizado",
|
||||||
body: "El camión de tu sector ha concluido su jornada de recolección diaria.",
|
body: "El camión de tu sector ha concluido su jornada de recolección diaria.",
|
||||||
@@ -43,5 +48,6 @@ export const notificationPayloads: Record<NotificationType, NotificationPayload>
|
|||||||
export const positionTriggers: Record<number, NotificationType> = {
|
export const positionTriggers: Record<number, NotificationType> = {
|
||||||
2: NotificationType.ROUTE_START,
|
2: NotificationType.ROUTE_START,
|
||||||
4: NotificationType.TRUCK_PROXIMITY,
|
4: NotificationType.TRUCK_PROXIMITY,
|
||||||
|
5: NotificationType.TRUCK_ARRIVED,
|
||||||
8: NotificationType.ROUTE_COMPLETED,
|
8: NotificationType.ROUTE_COMPLETED,
|
||||||
};
|
};
|
||||||
@@ -256,4 +256,68 @@ export const routes: MockRoute[] = [
|
|||||||
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 41, timestamp: "2026-05-22T07:54:00Z" },
|
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 41, timestamp: "2026-05-22T07:54:00Z" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
routeId: "RUTA-80",
|
||||||
|
name: "El Campanario - Galaxias del Parque",
|
||||||
|
truckId: 180,
|
||||||
|
status: "EN_RUTA",
|
||||||
|
positions: [
|
||||||
|
{ positionId: 1, lat: 20.5111, lng: -100.9037, speed: 0, timestamp: "2026-05-22T06:05:00Z" },
|
||||||
|
{ positionId: 2, lat: 20.5425, lng: -100.8720, speed: 42, timestamp: "2026-05-22T06:18:00Z" },
|
||||||
|
{ positionId: 3, lat: 20.5510, lng: -100.8550, speed: 28, timestamp: "2026-05-22T06:32:00Z" },
|
||||||
|
{ positionId: 4, lat: 20.5545, lng: -100.8480, speed: 16, timestamp: "2026-05-22T06:46:00Z" },
|
||||||
|
{ positionId: 5, lat: 20.5580, lng: -100.8410, speed: 0, timestamp: "2026-05-22T07:00:00Z" },
|
||||||
|
{ positionId: 6, lat: 20.5540, lng: -100.8360, speed: 15, timestamp: "2026-05-22T07:12:00Z" },
|
||||||
|
{ positionId: 7, lat: 20.5470, lng: -100.8480, speed: 26, timestamp: "2026-05-22T07:25:00Z" },
|
||||||
|
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 40, timestamp: "2026-05-22T07:48:00Z" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
routeId: "RUTA-65",
|
||||||
|
name: "Arboledas 3ra Seccion - Av. Constituyentes",
|
||||||
|
truckId: 165,
|
||||||
|
status: "EN_RUTA",
|
||||||
|
positions: [
|
||||||
|
{ positionId: 1, lat: 20.5111, lng: -100.9037, speed: 0, timestamp: "2026-05-22T06:12:00Z" },
|
||||||
|
{ positionId: 2, lat: 20.4980, lng: -100.8790, speed: 36, timestamp: "2026-05-22T06:25:00Z" },
|
||||||
|
{ positionId: 3, lat: 20.4920, lng: -100.8620, speed: 25, timestamp: "2026-05-22T06:38:00Z" },
|
||||||
|
{ positionId: 4, lat: 20.4890, lng: -100.8500, speed: 14, timestamp: "2026-05-22T06:51:00Z" },
|
||||||
|
{ positionId: 5, lat: 20.4860, lng: -100.8380, speed: 0, timestamp: "2026-05-22T07:03:00Z" },
|
||||||
|
{ positionId: 6, lat: 20.4895, lng: -100.8340, speed: 18, timestamp: "2026-05-22T07:15:00Z" },
|
||||||
|
{ positionId: 7, lat: 20.4950, lng: -100.8480, speed: 24, timestamp: "2026-05-22T07:28:00Z" },
|
||||||
|
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 38, timestamp: "2026-05-22T07:50:00Z" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
routeId: "RUTA-40",
|
||||||
|
name: "Frac. Parque Central Alameda - Bosques de la Alameda",
|
||||||
|
truckId: 140,
|
||||||
|
status: "EN_RUTA",
|
||||||
|
positions: [
|
||||||
|
{ positionId: 1, lat: 20.5111, lng: -100.9037, speed: 0, timestamp: "2026-05-22T06:08:00Z" },
|
||||||
|
{ positionId: 2, lat: 20.5295, lng: -100.7650, speed: 48, timestamp: "2026-05-22T06:20:00Z" },
|
||||||
|
{ positionId: 3, lat: 20.5340, lng: -100.7500, speed: 30, timestamp: "2026-05-22T06:33:00Z" },
|
||||||
|
{ positionId: 4, lat: 20.5365, lng: -100.7420, speed: 18, timestamp: "2026-05-22T06:46:00Z" },
|
||||||
|
{ positionId: 5, lat: 20.5390, lng: -100.7350, speed: 0, timestamp: "2026-05-22T06:59:00Z" },
|
||||||
|
{ positionId: 6, lat: 20.5350, lng: -100.7380, speed: 16, timestamp: "2026-05-22T07:11:00Z" },
|
||||||
|
{ positionId: 7, lat: 20.5300, lng: -100.7520, speed: 28, timestamp: "2026-05-22T07:24:00Z" },
|
||||||
|
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 45, timestamp: "2026-05-22T07:49:00Z" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
routeId: "RUTA-80",
|
||||||
|
name: "Sector Residencial Sur - Fracc. Pinos Sur",
|
||||||
|
truckId: 180,
|
||||||
|
status: "EN_RUTA",
|
||||||
|
positions: [
|
||||||
|
{ positionId: 1, lat: 20.5111, lng: -100.9037, speed: 0, timestamp: "2026-05-22T07:00:00Z" },
|
||||||
|
{ positionId: 2, lat: 20.5050, lng: -100.8550, speed: 42, timestamp: "2026-05-22T07:12:00Z" },
|
||||||
|
{ positionId: 3, lat: 20.4980, lng: -100.8200, speed: 25, timestamp: "2026-05-22T07:25:00Z" },
|
||||||
|
{ positionId: 4, lat: 20.4920, lng: -100.8050, speed: 15, timestamp: "2026-05-22T07:38:00Z" },
|
||||||
|
{ positionId: 5, lat: 20.4880, lng: -100.7990, speed: 0, timestamp: "2026-05-22T07:50:00Z" },
|
||||||
|
{ positionId: 6, lat: 20.4910, lng: -100.7960, speed: 18, timestamp: "2026-05-22T08:05:00Z" },
|
||||||
|
{ positionId: 7, lat: 20.4955, lng: -100.8030, speed: 22, timestamp: "2026-05-22T08:18:00Z" },
|
||||||
|
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 38, timestamp: "2026-05-22T08:40:00Z" },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
@@ -8,6 +8,11 @@ export interface MockUser {
|
|||||||
routeId: string;
|
routeId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lista de usuarios para el demo. Es mutable porque cuando un user nuevo
|
||||||
|
* se registra desde la app, se agrega aquí en memoria (con RUTA-01 default).
|
||||||
|
* En Bloque B1 (domicilios) cada user elegirá su colonia real.
|
||||||
|
*/
|
||||||
export const users: MockUser[] = [
|
export const users: MockUser[] = [
|
||||||
{ id: 1, name: "Ana López", email: "ana@test.com", colonia: "Zona Centro", routeId: "RUTA-01" },
|
{ id: 1, name: "Ana López", email: "ana@test.com", colonia: "Zona Centro", routeId: "RUTA-01" },
|
||||||
{ id: 2, name: "Carlos Pérez", email: "carlos@test.com",colonia: "Las Arboledas", routeId: "RUTA-01" },
|
{ id: 2, name: "Carlos Pérez", email: "carlos@test.com",colonia: "Las Arboledas", routeId: "RUTA-01" },
|
||||||
@@ -17,3 +22,16 @@ export const users: MockUser[] = [
|
|||||||
{ id: 6, name: "Pedro Sánchez", email: "pedro@test.com", colonia: "Rancho Seco", routeId: "RUTA-05" },
|
{ id: 6, name: "Pedro Sánchez", email: "pedro@test.com", colonia: "Rancho Seco", routeId: "RUTA-05" },
|
||||||
{ id: 7, name: "Sofía Gómez", email: "sofia@test.com", colonia: "Las Insurgentes", routeId: "RUTA-12" },
|
{ id: 7, name: "Sofía Gómez", email: "sofia@test.com", colonia: "Las Insurgentes", routeId: "RUTA-12" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_ROUTE_ID = "RUTA-01";
|
||||||
|
export const DEFAULT_COLONIA = "Zona Centro";
|
||||||
|
|
||||||
|
/** Agrega un user al mock (idempotente por id). */
|
||||||
|
export const upsertUser = (user: MockUser): void => {
|
||||||
|
const existing = users.findIndex((u) => u.id === user.id);
|
||||||
|
if (existing >= 0) {
|
||||||
|
users[existing] = user;
|
||||||
|
} else {
|
||||||
|
users.push(user);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -16,17 +16,30 @@ export class AuthRepositoryImpl implements AuthRepository {
|
|||||||
email: true,
|
email: true,
|
||||||
password: true,
|
password: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
role: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(data: { name: string; email: string; password: string }) {
|
async create(data: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
role?: string;
|
||||||
|
}) {
|
||||||
return prisma.user.create({
|
return prisma.user.create({
|
||||||
data,
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
email: data.email,
|
||||||
|
password: data.password,
|
||||||
|
// role solo si fue pasado explícitamente; default = USER (en Prisma)
|
||||||
|
...(data.role ? { role: data.role as "USER" | "ADMIN" } : {}),
|
||||||
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
email: true,
|
email: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
role: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
25
backend/src/data/repositories/feedback.repository.impl.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* feedback.repository.impl.ts
|
||||||
|
* Implementación en memoria del FeedbackRepository.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FeedbackItem,
|
||||||
|
FeedbackRepository,
|
||||||
|
} from "../../domain/repositories/feedback.repository.js";
|
||||||
|
|
||||||
|
export class InMemoryFeedbackRepository implements FeedbackRepository {
|
||||||
|
private readonly items: FeedbackItem[] = [];
|
||||||
|
|
||||||
|
async save(item: FeedbackItem): Promise<void> {
|
||||||
|
this.items.unshift(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listForUser(userId: number): Promise<FeedbackItem[]> {
|
||||||
|
return this.items.filter((i) => i.userId === userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAll(): Promise<FeedbackItem[]> {
|
||||||
|
return [...this.items];
|
||||||
|
}
|
||||||
|
}
|
||||||
51
backend/src/data/seed/seed-admin.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* seed-admin.ts
|
||||||
|
*
|
||||||
|
* Crea (o repara) el usuario admin del sistema al arrancar el server.
|
||||||
|
*
|
||||||
|
* - Si NO existe admin@test.com → lo crea con role=ADMIN
|
||||||
|
* - Si SÍ existe pero su rol fue degradado → lo eleva de nuevo a ADMIN
|
||||||
|
*
|
||||||
|
* Idempotente: se puede ejecutar en cada arranque sin efectos secundarios.
|
||||||
|
*
|
||||||
|
* Para producción: mover las credenciales a env vars y/o ejecutar este seed
|
||||||
|
* solo una vez via comando manual, no en cada startup.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { prisma } from "../postgres/index.js";
|
||||||
|
import { BcryptAdapter } from "../../config/bcrypt.js";
|
||||||
|
|
||||||
|
const ADMIN_EMAIL = "admin@test.com";
|
||||||
|
const ADMIN_PASSWORD = "admin123";
|
||||||
|
const ADMIN_NAME = "Administrador";
|
||||||
|
|
||||||
|
export async function seedAdmin(): Promise<void> {
|
||||||
|
const existing = await prisma.user.findUnique({
|
||||||
|
where: { email: ADMIN_EMAIL },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.role !== "ADMIN") {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { role: "ADMIN" },
|
||||||
|
});
|
||||||
|
console.log(`Seed admin: promovido ${ADMIN_EMAIL} a ADMIN`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = await BcryptAdapter.hash(ADMIN_PASSWORD);
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
name: ADMIN_NAME,
|
||||||
|
email: ADMIN_EMAIL,
|
||||||
|
password: hash,
|
||||||
|
role: "ADMIN",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Seed admin: creado ${ADMIN_EMAIL} con password "${ADMIN_PASSWORD}" (cambiar en producción)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
105
backend/src/data/simulation/route-simulator.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* route-simulator.ts
|
||||||
|
* Simulador automático: cada N segundos avanza un positionId en cada ruta
|
||||||
|
* y dispara el ProcessGpsUpdateUseCase. Reemplaza al "pull to refresh" del
|
||||||
|
* frontend para que el flujo sea más realista en la demo.
|
||||||
|
*
|
||||||
|
* Cuando un camión termina su ciclo (positionId=8 → vuelve a 1), se limpia
|
||||||
|
* el cache de dedup para que las notificaciones se vuelvan a disparar en
|
||||||
|
* la siguiente vuelta. El inbox NO se limpia para conservar el historial.
|
||||||
|
*
|
||||||
|
* Solo simula RUTA-01 por defecto para enfocar la demo.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { routes } from "../mocks/routes.mock.js";
|
||||||
|
import type { ProcessGpsUpdateUseCase } from "../../domain/use-cases/notifications/process-gps-update.use-case.js";
|
||||||
|
import type { NotificationCacheRepository } from "../../domain/repositories/notification-cache.repository.js";
|
||||||
|
import type { RouteStateRepository } from "../../domain/repositories/route-state.repository.js";
|
||||||
|
|
||||||
|
const TICK_INTERVAL_MS = 30_000; // 30 segundos
|
||||||
|
const SIMULATED_ROUTE_IDS = ["RUTA-01", "RUTA-80"];
|
||||||
|
|
||||||
|
export class RouteSimulator {
|
||||||
|
private timer: NodeJS.Timeout | null = null;
|
||||||
|
private positionIndexByRoute = new Map<string, number>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly processGpsUpdate: ProcessGpsUpdateUseCase,
|
||||||
|
private readonly dedupCache: NotificationCacheRepository,
|
||||||
|
private readonly routeState: RouteStateRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.timer) return;
|
||||||
|
|
||||||
|
// Inicializa cada ruta en positionId=1 inmediatamente
|
||||||
|
void this.tick();
|
||||||
|
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
void this.tick();
|
||||||
|
}, TICK_INTERVAL_MS);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`RouteSimulator started — tick every ${TICK_INTERVAL_MS / 1000}s`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this.timer) {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reinicia el índice de posición de una ruta al inicio (positionId 1).
|
||||||
|
* Usado por el admin al reanudar una ruta cancelada para que el ciclo
|
||||||
|
* vuelva a comenzar desde cero.
|
||||||
|
*/
|
||||||
|
resetPosition(routeId: string): void {
|
||||||
|
this.positionIndexByRoute.set(routeId, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tick() {
|
||||||
|
for (const routeId of SIMULATED_ROUTE_IDS) {
|
||||||
|
const route = routes.find((r) => r.routeId === routeId);
|
||||||
|
if (!route) continue;
|
||||||
|
|
||||||
|
// Si el admin canceló esta ruta, no avanzamos
|
||||||
|
const state = await this.routeState.get(routeId);
|
||||||
|
if (state?.cancelled) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = this.positionIndexByRoute.get(routeId) ?? 0;
|
||||||
|
const position = route.positions[index];
|
||||||
|
if (!position) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.processGpsUpdate.execute({
|
||||||
|
truckId: String(route.truckId),
|
||||||
|
routeId: route.routeId,
|
||||||
|
lat: position.lat,
|
||||||
|
lng: position.lng,
|
||||||
|
speed: position.speed,
|
||||||
|
status: route.status,
|
||||||
|
positionId: position.positionId,
|
||||||
|
timestamp: position.timestamp,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Simulator tick failed for ${routeId}:`, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avanza al siguiente; al terminar el array vuelve al inicio Y limpia
|
||||||
|
// el cache de dedup para que el próximo ciclo sí re-emita notificaciones.
|
||||||
|
const nextIndex = (index + 1) % route.positions.length;
|
||||||
|
if (nextIndex < index) {
|
||||||
|
await this.dedupCache.clear();
|
||||||
|
console.log(
|
||||||
|
`RouteSimulator: ${routeId} completó ciclo — cache de dedup limpiado`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.positionIndexByRoute.set(routeId, nextIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
backend/src/domain/dtos/addresses/set-address.dto.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* set-address.dto.ts
|
||||||
|
* DTO para asignar la colonia del domicilio del usuario.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SetAddressDto {
|
||||||
|
colonia: string;
|
||||||
|
street?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SetAddressDtoValidator {
|
||||||
|
static validate(data: unknown): SetAddressDto {
|
||||||
|
if (typeof data !== "object" || data === null) {
|
||||||
|
throw new Error("Invalid address payload");
|
||||||
|
}
|
||||||
|
const obj = data as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (typeof obj.colonia !== "string" || obj.colonia.trim().length === 0) {
|
||||||
|
throw new Error("colonia is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: SetAddressDto = { colonia: obj.colonia.trim() };
|
||||||
|
if (typeof obj.street === "string" && obj.street.trim().length > 0) {
|
||||||
|
result.street = obj.street.trim();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
63
backend/src/domain/dtos/feedback/submit-feedback.dto.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* submit-feedback.dto.ts
|
||||||
|
* DTO para enviar retroalimentación sobre el servicio.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type FeedbackType =
|
||||||
|
| "TRUCK_DID_NOT_PASS"
|
||||||
|
| "RATING"
|
||||||
|
| "SUGGESTION"
|
||||||
|
| "OTHER";
|
||||||
|
|
||||||
|
export interface SubmitFeedbackDto {
|
||||||
|
type: FeedbackType;
|
||||||
|
message: string;
|
||||||
|
rating?: number; // 1-5, sólo cuando type === "RATING"
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_TYPES: FeedbackType[] = [
|
||||||
|
"TRUCK_DID_NOT_PASS",
|
||||||
|
"RATING",
|
||||||
|
"SUGGESTION",
|
||||||
|
"OTHER",
|
||||||
|
];
|
||||||
|
|
||||||
|
export class SubmitFeedbackDtoValidator {
|
||||||
|
static validate(data: unknown): SubmitFeedbackDto {
|
||||||
|
if (typeof data !== "object" || data === null) {
|
||||||
|
throw new Error("Invalid feedback payload");
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = data as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof obj.type !== "string" ||
|
||||||
|
!VALID_TYPES.includes(obj.type as FeedbackType)
|
||||||
|
) {
|
||||||
|
throw new Error(`type must be one of: ${VALID_TYPES.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof obj.message !== "string" || obj.message.trim().length === 0) {
|
||||||
|
throw new Error("message is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
let rating: number | undefined;
|
||||||
|
if (obj.rating !== undefined) {
|
||||||
|
if (
|
||||||
|
typeof obj.rating !== "number" ||
|
||||||
|
obj.rating < 1 ||
|
||||||
|
obj.rating > 5
|
||||||
|
) {
|
||||||
|
throw new Error("rating must be between 1 and 5");
|
||||||
|
}
|
||||||
|
rating = obj.rating;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: SubmitFeedbackDto = {
|
||||||
|
type: obj.type as FeedbackType,
|
||||||
|
message: obj.message.trim(),
|
||||||
|
};
|
||||||
|
if (rating !== undefined) result.rating = rating;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
export * from './auth/register-user.dto.js';
|
export * from './auth/register-user.dto.js';
|
||||||
export * from './auth/login-user.dto.js';
|
export * from './auth/login-user.dto.js';
|
||||||
export * from './tracking/gps-update.dto.js';
|
export * from './tracking/gps-update.dto.js';
|
||||||
|
export * from './feedback/submit-feedback.dto.js';
|
||||||
|
export * from './addresses/set-address.dto.js';
|
||||||
@@ -17,6 +17,10 @@ export class CustomError extends Error {
|
|||||||
return new CustomError(message, 401);
|
return new CustomError(message, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static forbidden(message: string = "Forbidden"): CustomError {
|
||||||
|
return new CustomError(message, 403);
|
||||||
|
}
|
||||||
|
|
||||||
static notFound(message: string): CustomError {
|
static notFound(message: string): CustomError {
|
||||||
return new CustomError(message, 404);
|
return new CustomError(message, 404);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
|
|
||||||
export interface AuthRepository {
|
export interface AuthRepository {
|
||||||
findByEmail(email: string): Promise<{ id: number; email: string; password: string; name: string } | null>;
|
findByEmail(email: string): Promise<{ id: number; email: string; password: string; name: string; role: string } | null>;
|
||||||
create(data: { name: string; email: string; password: string }): Promise<{ id: number; email: string; name: string }>;
|
create(data: { name: string; email: string; password: string; role?: string }): Promise<{ id: number; email: string; name: string; role: string }>;
|
||||||
findById(id: number): Promise<{ id: number; email: string; name: string; role: string } | null>;
|
findById(id: number): Promise<{ id: number; email: string; name: string; role: string } | null>;
|
||||||
}
|
}
|
||||||
27
backend/src/domain/repositories/feedback.repository.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* feedback.repository.ts
|
||||||
|
* Buzón de retroalimentación.
|
||||||
|
* In-memory para el MVP. Cuando se migre a DB, se cambia la impl.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { FeedbackType } from "../dtos/feedback/submit-feedback.dto.js";
|
||||||
|
|
||||||
|
export interface FeedbackItem {
|
||||||
|
id: string;
|
||||||
|
userId: number;
|
||||||
|
/** routeId del usuario en el momento del reporte. */
|
||||||
|
routeId: string | null;
|
||||||
|
/** Nombre + colonia del usuario para que el admin no haga lookup extra. */
|
||||||
|
userName?: string;
|
||||||
|
colonia?: string;
|
||||||
|
type: FeedbackType;
|
||||||
|
message: string;
|
||||||
|
rating?: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackRepository {
|
||||||
|
save(item: FeedbackItem): Promise<void>;
|
||||||
|
listForUser(userId: number): Promise<FeedbackItem[]>;
|
||||||
|
listAll(): Promise<FeedbackItem[]>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* notification-inbox.repository.ts
|
||||||
|
* Buzón de notificaciones por usuario.
|
||||||
|
* El simulador empuja notificaciones aquí; el endpoint GET /status las lee.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { NotificationType } from "../../data/mocks/notification-types.mock.js";
|
||||||
|
|
||||||
|
export interface InboxNotification {
|
||||||
|
id: string;
|
||||||
|
userId: number;
|
||||||
|
type: NotificationType;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationInboxRepository {
|
||||||
|
addForUser(notification: InboxNotification): Promise<void>;
|
||||||
|
getForUser(userId: number): Promise<InboxNotification[]>;
|
||||||
|
clearForUser(userId: number): Promise<void>;
|
||||||
|
}
|
||||||
42
backend/src/domain/repositories/route-state.repository.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* route-state.repository.ts
|
||||||
|
* Guarda el estado operativo actual de cada ruta:
|
||||||
|
* - en qué positionId va el camión
|
||||||
|
* - cuál fue la última ETA calculada
|
||||||
|
* - cuándo fue la última actualización
|
||||||
|
*
|
||||||
|
* Esto vive en memoria para el MVP. Más adelante se puede mover a Redis.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { EtaResult } from "../use-cases/notifications/calculate-eta.use-case.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resultado de llegada del camión a la zona del usuario:
|
||||||
|
* PENDING - aún no llega o no se ha evaluado
|
||||||
|
* ARRIVED - el camión sí pasó (positionId >= 5)
|
||||||
|
* FAILED - hubo falla mecánica antes de llegar
|
||||||
|
* CANCELLED - admin canceló la ruta
|
||||||
|
*/
|
||||||
|
export type ArrivalResult = "PENDING" | "ARRIVED" | "FAILED" | "CANCELLED";
|
||||||
|
|
||||||
|
export interface RouteState {
|
||||||
|
routeId: string;
|
||||||
|
currentPositionId: number;
|
||||||
|
eta: EtaResult;
|
||||||
|
status: string;
|
||||||
|
updatedAt: string;
|
||||||
|
arrivalResult: ArrivalResult;
|
||||||
|
/** True si el admin canceló esta ruta — el simulador la pausa. */
|
||||||
|
cancelled: boolean;
|
||||||
|
cancelReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteStateRepository {
|
||||||
|
get(routeId: string): Promise<RouteState | null>;
|
||||||
|
set(state: RouteState): Promise<void>;
|
||||||
|
getAll(): Promise<RouteState[]>;
|
||||||
|
/** Cancela la ruta (la pausa). El simulador deja de avanzarla. */
|
||||||
|
cancel(routeId: string, reason?: string): Promise<void>;
|
||||||
|
/** Reactiva una ruta previamente cancelada. */
|
||||||
|
resume(routeId: string): Promise<void>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* get-my-address.use-case.ts
|
||||||
|
* Devuelve la dirección activa del usuario logueado.
|
||||||
|
* Se infiere del mock de servicio (colonia + routeId).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CustomError } from "../../errors/custom.error.js";
|
||||||
|
import { users } from "../../../data/mocks/users.mock.js";
|
||||||
|
import { colonias } from "../../../data/mocks/colonias.mock.js";
|
||||||
|
|
||||||
|
export interface MyAddressResponse {
|
||||||
|
colonia: string;
|
||||||
|
routeId: string;
|
||||||
|
horarioEstimado: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetMyAddressUseCase {
|
||||||
|
execute(userId: number): MyAddressResponse {
|
||||||
|
const user = users.find((u) => u.id === userId);
|
||||||
|
if (!user) {
|
||||||
|
throw CustomError.notFound("User not found in service mock");
|
||||||
|
}
|
||||||
|
|
||||||
|
const colonia = colonias.find((c) => c.colonia === user.colonia);
|
||||||
|
|
||||||
|
return {
|
||||||
|
colonia: user.colonia,
|
||||||
|
routeId: user.routeId,
|
||||||
|
horarioEstimado: colonia?.horarioEstimado ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* list-colonias.use-case.ts
|
||||||
|
* Devuelve las colonias disponibles para el catálogo del frontend.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { colonias, type Colonia } from "../../../data/mocks/colonias.mock.js";
|
||||||
|
|
||||||
|
export class ListColoniasUseCase {
|
||||||
|
execute(): Colonia[] {
|
||||||
|
return colonias;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* set-my-address.use-case.ts
|
||||||
|
* Asigna una colonia al usuario logueado. Valida que la colonia exista
|
||||||
|
* en el catálogo y resuelve el routeId asociado.
|
||||||
|
*
|
||||||
|
* Cumple "validación de domicilio dentro de zona permitida" del reto:
|
||||||
|
* si la colonia no está en el catálogo, no se acepta.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SetAddressDtoValidator } from "../../dtos/index.js";
|
||||||
|
import { CustomError } from "../../errors/custom.error.js";
|
||||||
|
import { colonias } from "../../../data/mocks/colonias.mock.js";
|
||||||
|
import { upsertUser, users } from "../../../data/mocks/users.mock.js";
|
||||||
|
|
||||||
|
export interface SetMyAddressResponse {
|
||||||
|
colonia: string;
|
||||||
|
routeId: string;
|
||||||
|
horarioEstimado: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SetMyAddressUseCase {
|
||||||
|
execute(
|
||||||
|
userId: number,
|
||||||
|
userName: string,
|
||||||
|
userEmail: string,
|
||||||
|
data: unknown,
|
||||||
|
): SetMyAddressResponse {
|
||||||
|
const dto = SetAddressDtoValidator.validate(data);
|
||||||
|
|
||||||
|
const match = colonias.find(
|
||||||
|
(c) => c.colonia.toLowerCase() === dto.colonia.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (!match) {
|
||||||
|
throw CustomError.badRequest(
|
||||||
|
`La colonia "${dto.colonia}" no está dentro de la zona de cobertura`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = users.find((u) => u.id === userId);
|
||||||
|
upsertUser({
|
||||||
|
id: userId,
|
||||||
|
name: existing?.name ?? userName,
|
||||||
|
email: existing?.email ?? userEmail,
|
||||||
|
colonia: match.colonia,
|
||||||
|
routeId: match.routeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
colonia: match.colonia,
|
||||||
|
routeId: match.routeId,
|
||||||
|
horarioEstimado: match.horarioEstimado,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
47
backend/src/domain/use-cases/admin/cancel-route.use-case.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* cancel-route.use-case.ts
|
||||||
|
* El admin cancela una ruta. El simulador pausa esa ruta y se notifica
|
||||||
|
* a todos los usuarios suscritos a la misma.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CustomError } from "../../errors/custom.error.js";
|
||||||
|
import { routes as routeCatalog } from "../../../data/mocks/routes.mock.js";
|
||||||
|
import { users } from "../../../data/mocks/users.mock.js";
|
||||||
|
import type { RouteStateRepository } from "../../repositories/route-state.repository.js";
|
||||||
|
import type {
|
||||||
|
InboxNotification,
|
||||||
|
NotificationInboxRepository,
|
||||||
|
} from "../../repositories/notification-inbox.repository.js";
|
||||||
|
|
||||||
|
export class CancelRouteUseCase {
|
||||||
|
constructor(
|
||||||
|
private readonly routeState: RouteStateRepository,
|
||||||
|
private readonly inbox: NotificationInboxRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(routeId: string, reason?: string): Promise<void> {
|
||||||
|
const route = routeCatalog.find((r) => r.routeId === routeId);
|
||||||
|
if (!route) {
|
||||||
|
throw CustomError.notFound(`Route ${routeId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.routeState.cancel(routeId, reason);
|
||||||
|
|
||||||
|
// Avisa a todos los usuarios de esa ruta
|
||||||
|
const reasonText = reason ? ` Motivo: ${reason}` : "";
|
||||||
|
const message = `La ruta matutina de tu sector fue cancelada.${reasonText} Se reprogramará para la tarde.`;
|
||||||
|
|
||||||
|
const subscribers = users.filter((u) => u.routeId === routeId);
|
||||||
|
for (const user of subscribers) {
|
||||||
|
const item: InboxNotification = {
|
||||||
|
id: `${user.id}-${routeId}-CANCELLED-${Date.now()}`,
|
||||||
|
userId: user.id,
|
||||||
|
type: "ROUTE_COMPLETED", // reusamos el tipo "info" del mock
|
||||||
|
title: "Ruta cancelada",
|
||||||
|
body: message,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await this.inbox.addForUser(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* list-all-feedback.use-case.ts
|
||||||
|
* Solo accesible por admin. Devuelve TODOS los reportes que han mandado
|
||||||
|
* los ciudadanos a través del buzón, ordenados del más reciente al más
|
||||||
|
* antiguo.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FeedbackItem,
|
||||||
|
FeedbackRepository,
|
||||||
|
} from "../../repositories/feedback.repository.js";
|
||||||
|
|
||||||
|
export class ListAllFeedbackUseCase {
|
||||||
|
constructor(private readonly repository: FeedbackRepository) {}
|
||||||
|
|
||||||
|
async execute(): Promise<FeedbackItem[]> {
|
||||||
|
return this.repository.listAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
45
backend/src/domain/use-cases/admin/list-routes.use-case.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* list-routes.use-case.ts
|
||||||
|
* Lista TODAS las rutas con su estado operativo actual.
|
||||||
|
* Solo accesible por administradores.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { routes as routeCatalog } from "../../../data/mocks/routes.mock.js";
|
||||||
|
import type { RouteStateRepository } from "../../repositories/route-state.repository.js";
|
||||||
|
|
||||||
|
export interface AdminRouteItem {
|
||||||
|
routeId: string;
|
||||||
|
name: string;
|
||||||
|
truckId: number;
|
||||||
|
status: string;
|
||||||
|
currentPositionId: number;
|
||||||
|
arrivalResult: string;
|
||||||
|
cancelled: boolean;
|
||||||
|
cancelReason?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListRoutesUseCase {
|
||||||
|
constructor(private readonly routeState: RouteStateRepository) {}
|
||||||
|
|
||||||
|
async execute(): Promise<AdminRouteItem[]> {
|
||||||
|
const states = await this.routeState.getAll();
|
||||||
|
const stateMap = new Map(states.map((s) => [s.routeId, s]));
|
||||||
|
|
||||||
|
return routeCatalog.map((r) => {
|
||||||
|
const s = stateMap.get(r.routeId);
|
||||||
|
const item: AdminRouteItem = {
|
||||||
|
routeId: r.routeId,
|
||||||
|
name: r.name,
|
||||||
|
truckId: r.truckId,
|
||||||
|
status: s?.status ?? "ESPERA",
|
||||||
|
currentPositionId: s?.currentPositionId ?? 0,
|
||||||
|
arrivalResult: s?.arrivalResult ?? "PENDING",
|
||||||
|
cancelled: s?.cancelled ?? false,
|
||||||
|
};
|
||||||
|
if (s?.cancelReason) item.cancelReason = s.cancelReason;
|
||||||
|
if (s?.updatedAt) item.updatedAt = s.updatedAt;
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
42
backend/src/domain/use-cases/admin/resume-route.use-case.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* resume-route.use-case.ts
|
||||||
|
* El admin reanuda una ruta cancelada.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CustomError } from "../../errors/custom.error.js";
|
||||||
|
import { routes as routeCatalog } from "../../../data/mocks/routes.mock.js";
|
||||||
|
import { users } from "../../../data/mocks/users.mock.js";
|
||||||
|
import type { RouteStateRepository } from "../../repositories/route-state.repository.js";
|
||||||
|
import type {
|
||||||
|
InboxNotification,
|
||||||
|
NotificationInboxRepository,
|
||||||
|
} from "../../repositories/notification-inbox.repository.js";
|
||||||
|
|
||||||
|
export class ResumeRouteUseCase {
|
||||||
|
constructor(
|
||||||
|
private readonly routeState: RouteStateRepository,
|
||||||
|
private readonly inbox: NotificationInboxRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(routeId: string): Promise<void> {
|
||||||
|
const route = routeCatalog.find((r) => r.routeId === routeId);
|
||||||
|
if (!route) {
|
||||||
|
throw CustomError.notFound(`Route ${routeId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.routeState.resume(routeId);
|
||||||
|
|
||||||
|
const subscribers = users.filter((u) => u.routeId === routeId);
|
||||||
|
for (const user of subscribers) {
|
||||||
|
const item: InboxNotification = {
|
||||||
|
id: `${user.id}-${routeId}-RESUMED-${Date.now()}`,
|
||||||
|
userId: user.id,
|
||||||
|
type: "ROUTE_START",
|
||||||
|
title: "Ruta reanudada",
|
||||||
|
body: "El servicio de recolección de tu sector fue reanudado.",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await this.inbox.addForUser(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,12 +5,19 @@ import { AuthRepository } from "../../repositories/auth.repository.js";
|
|||||||
import { CustomError } from "../../errors/custom.error.js";
|
import { CustomError } from "../../errors/custom.error.js";
|
||||||
import { BcryptAdapter } from "../../../config/bcrypt.js";
|
import { BcryptAdapter } from "../../../config/bcrypt.js";
|
||||||
import { JwtAdapter } from "../../../config/jwt.js";
|
import { JwtAdapter } from "../../../config/jwt.js";
|
||||||
|
import {
|
||||||
|
upsertUser,
|
||||||
|
users,
|
||||||
|
DEFAULT_ROUTE_ID,
|
||||||
|
DEFAULT_COLONIA,
|
||||||
|
} from "../../../data/mocks/users.mock.js";
|
||||||
|
|
||||||
export interface LoginUserResponse {
|
export interface LoginUserResponse {
|
||||||
user: {
|
user: {
|
||||||
id: number;
|
id: number;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
role: string;
|
||||||
};
|
};
|
||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
@@ -34,6 +41,20 @@ export class LoginUserUseCase {
|
|||||||
throw CustomError.unauthorized("Invalid credentials");
|
throw CustomError.unauthorized("Invalid credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Si el user normal no estaba en el mock de servicio, lo agregamos con
|
||||||
|
// valores default. Los admins NO se agregan al mock porque no reciben
|
||||||
|
// notificaciones de ruta. Idempotente.
|
||||||
|
const inMock = users.find((u) => u.id === user.id);
|
||||||
|
if (!inMock && user.role !== "ADMIN") {
|
||||||
|
upsertUser({
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
colonia: DEFAULT_COLONIA,
|
||||||
|
routeId: DEFAULT_ROUTE_ID,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Generar JWT
|
// Generar JWT
|
||||||
const token = JwtAdapter.generate({
|
const token = JwtAdapter.generate({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -45,6 +66,7 @@ export class LoginUserUseCase {
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
},
|
},
|
||||||
token,
|
token,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { AuthRepository } from "../../repositories/auth.repository.js";
|
|||||||
import { CustomError } from "../../errors/custom.error.js";
|
import { CustomError } from "../../errors/custom.error.js";
|
||||||
import { BcryptAdapter } from "../../../config/bcrypt.js";
|
import { BcryptAdapter } from "../../../config/bcrypt.js";
|
||||||
import { JwtAdapter } from "../../../config/jwt.js";
|
import { JwtAdapter } from "../../../config/jwt.js";
|
||||||
|
import {
|
||||||
|
upsertUser,
|
||||||
|
DEFAULT_ROUTE_ID,
|
||||||
|
DEFAULT_COLONIA,
|
||||||
|
} from "../../../data/mocks/users.mock.js";
|
||||||
|
|
||||||
export interface RegisterUserResponse {
|
export interface RegisterUserResponse {
|
||||||
user: {
|
user: {
|
||||||
@@ -38,6 +43,17 @@ export class RegisterUserUseCase {
|
|||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Registrar al user en el "mock de servicio" con ruta default,
|
||||||
|
// así el simulador le manda notificaciones desde el primer momento.
|
||||||
|
// (En Bloque B1 el user elegirá su colonia/ruta real.)
|
||||||
|
upsertUser({
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
colonia: DEFAULT_COLONIA,
|
||||||
|
routeId: DEFAULT_ROUTE_ID,
|
||||||
|
});
|
||||||
|
|
||||||
// Generar JWT
|
// Generar JWT
|
||||||
const token = JwtAdapter.generate({
|
const token = JwtAdapter.generate({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* list-my-feedback.use-case.ts
|
||||||
|
* Devuelve solo los feedbacks del usuario logueado (RBAC por userId).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FeedbackItem,
|
||||||
|
FeedbackRepository,
|
||||||
|
} from "../../repositories/feedback.repository.js";
|
||||||
|
|
||||||
|
export class ListMyFeedbackUseCase {
|
||||||
|
constructor(private readonly repository: FeedbackRepository) {}
|
||||||
|
|
||||||
|
async execute(userId: number): Promise<FeedbackItem[]> {
|
||||||
|
return this.repository.listForUser(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* submit-feedback.use-case.ts
|
||||||
|
* Recibe el DTO, lo valida y guarda el feedback.
|
||||||
|
* El userId viene del JWT (req.user.id), no del body — RBAC.
|
||||||
|
* Adicionalmente, el use-case busca la ruta y colonia del user para que
|
||||||
|
* el admin pueda ver de qué ruta proviene cada reporte.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SubmitFeedbackDtoValidator } from "../../dtos/index.js";
|
||||||
|
import type {
|
||||||
|
FeedbackItem,
|
||||||
|
FeedbackRepository,
|
||||||
|
} from "../../repositories/feedback.repository.js";
|
||||||
|
import { users } from "../../../data/mocks/users.mock.js";
|
||||||
|
|
||||||
|
export class SubmitFeedbackUseCase {
|
||||||
|
constructor(private readonly repository: FeedbackRepository) {}
|
||||||
|
|
||||||
|
async execute(userId: number, data: unknown): Promise<FeedbackItem> {
|
||||||
|
const dto = SubmitFeedbackDtoValidator.validate(data);
|
||||||
|
|
||||||
|
// Lookup contextual del user para enriquecer el reporte
|
||||||
|
const user = users.find((u) => u.id === userId);
|
||||||
|
|
||||||
|
const item: FeedbackItem = {
|
||||||
|
id: `${userId}-${Date.now()}`,
|
||||||
|
userId,
|
||||||
|
routeId: user?.routeId ?? null,
|
||||||
|
type: dto.type,
|
||||||
|
message: dto.message,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
if (user?.name) item.userName = user.name;
|
||||||
|
if (user?.colonia) item.colonia = user.colonia;
|
||||||
|
if (dto.rating !== undefined) item.rating = dto.rating;
|
||||||
|
|
||||||
|
await this.repository.save(item);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,9 @@ export interface EtaResult {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROXIMITY_POSITION_ID = 4;
|
// El destino real (cuando el camión está en tu colonia) es positionId=5,
|
||||||
|
// donde speed=0. positionId=4 es el "punto de alerta de proximidad" (~12 min antes).
|
||||||
|
const ARRIVAL_POSITION_ID = 5;
|
||||||
const WINDOW_MARGIN_MIN = 5;
|
const WINDOW_MARGIN_MIN = 5;
|
||||||
|
|
||||||
export class CalculateEtaUseCase {
|
export class CalculateEtaUseCase {
|
||||||
@@ -28,11 +30,11 @@ export class CalculateEtaUseCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const target = route.positions.find(
|
const target = route.positions.find(
|
||||||
(p) => p.positionId === PROXIMITY_POSITION_ID,
|
(p) => p.positionId === ARRIVAL_POSITION_ID,
|
||||||
);
|
);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
throw CustomError.internalServer(
|
throw CustomError.internalServer(
|
||||||
`Route ${route.routeId} has no proximity position`,
|
`Route ${route.routeId} has no arrival position`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +52,8 @@ export class CalculateEtaUseCase {
|
|||||||
let message: string;
|
let message: string;
|
||||||
if (etaMinutes < 0) {
|
if (etaMinutes < 0) {
|
||||||
message = `El camión ya pasó por tu zona (aprox. ${arrivalWindow.from} - ${arrivalWindow.to}).`;
|
message = `El camión ya pasó por tu zona (aprox. ${arrivalWindow.from} - ${arrivalWindow.to}).`;
|
||||||
|
} else if (etaMinutes === 0) {
|
||||||
|
message = "El camión ya está en tu zona. Saca tus residuos ahora.";
|
||||||
} else if (etaMinutes <= 15) {
|
} else if (etaMinutes <= 15) {
|
||||||
message = `Llega en aproximadamente ${etaMinutes} minutos.`;
|
message = `Llega en aproximadamente ${etaMinutes} minutos.`;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -3,9 +3,14 @@ import { CalculateEtaUseCase, type EtaResult } from "./calculate-eta.use-case.js
|
|||||||
import {
|
import {
|
||||||
EvaluateNotificationUseCase,
|
EvaluateNotificationUseCase,
|
||||||
type EvaluatedNotification,
|
type EvaluatedNotification,
|
||||||
} from "../notifications/evaluate-notification.use-case.js";
|
} from "./evaluate-notification.use-case.js";
|
||||||
import { routes } from "../../../data/mocks/routes.mock.js";
|
import { routes } from "../../../data/mocks/routes.mock.js";
|
||||||
import { CustomError } from "../../errors/custom.error.js";
|
import { CustomError } from "../../errors/custom.error.js";
|
||||||
|
import type { RouteStateRepository } from "../../repositories/route-state.repository.js";
|
||||||
|
import type {
|
||||||
|
InboxNotification,
|
||||||
|
NotificationInboxRepository,
|
||||||
|
} from "../../repositories/notification-inbox.repository.js";
|
||||||
|
|
||||||
export interface ProcessGpsUpdateResponse {
|
export interface ProcessGpsUpdateResponse {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -18,6 +23,8 @@ export class ProcessGpsUpdateUseCase {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly calculateEta: CalculateEtaUseCase,
|
private readonly calculateEta: CalculateEtaUseCase,
|
||||||
private readonly evaluateNotification: EvaluateNotificationUseCase,
|
private readonly evaluateNotification: EvaluateNotificationUseCase,
|
||||||
|
private readonly routeState: RouteStateRepository,
|
||||||
|
private readonly inbox: NotificationInboxRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(data: unknown): Promise<ProcessGpsUpdateResponse> {
|
async execute(data: unknown): Promise<ProcessGpsUpdateResponse> {
|
||||||
@@ -28,9 +35,57 @@ export class ProcessGpsUpdateUseCase {
|
|||||||
throw CustomError.notFound(`Route ${dto.routeId} not found`);
|
throw CustomError.notFound(`Route ${dto.routeId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Si el admin canceló esta ruta, no procesamos GPS updates
|
||||||
|
const existingState = await this.routeState.get(route.routeId);
|
||||||
|
if (existingState?.cancelled) {
|
||||||
|
return {
|
||||||
|
message: "Route cancelled — GPS update ignored",
|
||||||
|
truck: {
|
||||||
|
truckId: route.truckId,
|
||||||
|
routeId: route.routeId,
|
||||||
|
status: "CANCELADO",
|
||||||
|
},
|
||||||
|
eta: existingState.eta,
|
||||||
|
notifications: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const eta = this.calculateEta.execute(dto);
|
const eta = this.calculateEta.execute(dto);
|
||||||
const notifications = await this.evaluateNotification.execute(dto);
|
const notifications = await this.evaluateNotification.execute(dto);
|
||||||
|
|
||||||
|
// Derivar arrivalResult según positionId y status:
|
||||||
|
// - FALLA → FAILED
|
||||||
|
// - positionId >= 5 → ARRIVED (camión ya llegó a tu zona)
|
||||||
|
// - resto → PENDING
|
||||||
|
let arrivalResult: "PENDING" | "ARRIVED" | "FAILED" | "CANCELLED" =
|
||||||
|
"PENDING";
|
||||||
|
if (dto.status === "FALLA") arrivalResult = "FAILED";
|
||||||
|
else if ((dto.positionId ?? 0) >= 5) arrivalResult = "ARRIVED";
|
||||||
|
|
||||||
|
// 1) Guardar el estado actual de la ruta
|
||||||
|
await this.routeState.set({
|
||||||
|
routeId: route.routeId,
|
||||||
|
currentPositionId: dto.positionId ?? 0,
|
||||||
|
eta,
|
||||||
|
status: dto.status,
|
||||||
|
updatedAt: dto.timestamp ?? new Date().toISOString(),
|
||||||
|
arrivalResult,
|
||||||
|
cancelled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2) Empujar cada notificación al buzón del usuario destinatario
|
||||||
|
for (const n of notifications) {
|
||||||
|
const item: InboxNotification = {
|
||||||
|
id: `${n.userId}-${route.routeId}-${n.type}-${Date.now()}`,
|
||||||
|
userId: n.userId,
|
||||||
|
type: n.type,
|
||||||
|
title: n.title,
|
||||||
|
body: n.body,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await this.inbox.addForUser(item);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: "GPS update processed",
|
message: "GPS update processed",
|
||||||
truck: {
|
truck: {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* get-user-status.use-case.ts
|
||||||
|
* Devuelve la "visión de túnel" del usuario logueado:
|
||||||
|
* - SOLO la ruta asignada a su domicilio
|
||||||
|
* - SOLO sus propias notificaciones
|
||||||
|
* No expone rutas ajenas ni telemetría completa.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CustomError } from "../../errors/custom.error.js";
|
||||||
|
import type { EtaResult } from "../notifications/calculate-eta.use-case.js";
|
||||||
|
import type { RouteStateRepository } from "../../repositories/route-state.repository.js";
|
||||||
|
import type {
|
||||||
|
InboxNotification,
|
||||||
|
NotificationInboxRepository,
|
||||||
|
} from "../../repositories/notification-inbox.repository.js";
|
||||||
|
import { users } from "../../../data/mocks/users.mock.js";
|
||||||
|
import { colonias } from "../../../data/mocks/colonias.mock.js";
|
||||||
|
|
||||||
|
export interface UserStatusResponse {
|
||||||
|
user: { id: number; name: string; colonia: string };
|
||||||
|
route: {
|
||||||
|
routeId: string;
|
||||||
|
currentPositionId: number;
|
||||||
|
status: string;
|
||||||
|
updatedAt: string;
|
||||||
|
horarioEstimado: string | null;
|
||||||
|
arrivalResult: "PENDING" | "ARRIVED" | "FAILED" | "CANCELLED";
|
||||||
|
cancelled: boolean;
|
||||||
|
};
|
||||||
|
eta: EtaResult | null;
|
||||||
|
notifications: InboxNotification[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetUserStatusUseCase {
|
||||||
|
constructor(
|
||||||
|
private readonly routeState: RouteStateRepository,
|
||||||
|
private readonly inbox: NotificationInboxRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(userId: number): Promise<UserStatusResponse> {
|
||||||
|
const user = users.find((u) => u.id === userId);
|
||||||
|
if (!user) {
|
||||||
|
throw CustomError.notFound("User not registered in service mock");
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await this.routeState.get(user.routeId);
|
||||||
|
const notifications = await this.inbox.getForUser(userId);
|
||||||
|
const colonia = colonias.find((c) => c.colonia === user.colonia);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: { id: user.id, name: user.name, colonia: user.colonia },
|
||||||
|
route: {
|
||||||
|
routeId: user.routeId,
|
||||||
|
currentPositionId: state?.currentPositionId ?? 0,
|
||||||
|
status: state?.status ?? "ESPERA",
|
||||||
|
updatedAt: state?.updatedAt ?? new Date().toISOString(),
|
||||||
|
horarioEstimado: colonia?.horarioEstimado ?? null,
|
||||||
|
arrivalResult: state?.arrivalResult ?? "PENDING",
|
||||||
|
cancelled: state?.cancelled ?? false,
|
||||||
|
},
|
||||||
|
eta: state?.eta ?? null,
|
||||||
|
notifications,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
66
backend/src/presentation/addresses/controller.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* controller.ts (addresses)
|
||||||
|
*
|
||||||
|
* GET /api/addresses/colonias → catálogo público de colonias
|
||||||
|
* GET /api/addresses/me → dirección activa del usuario logueado
|
||||||
|
* PUT /api/addresses/me → asignar colonia al usuario logueado
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { AuthRequest } from "../middlewares/auth.middleware.js";
|
||||||
|
import { CustomError } from "../../domain/errors/custom.error.js";
|
||||||
|
import { ListColoniasUseCase } from "../../domain/use-cases/addresses/list-colonias.use-case.js";
|
||||||
|
import { GetMyAddressUseCase } from "../../domain/use-cases/addresses/get-my-address.use-case.js";
|
||||||
|
import { SetMyAddressUseCase } from "../../domain/use-cases/addresses/set-my-address.use-case.js";
|
||||||
|
|
||||||
|
export class AddressesController {
|
||||||
|
private listColonias = new ListColoniasUseCase();
|
||||||
|
private getMyAddress = new GetMyAddressUseCase();
|
||||||
|
private setMyAddress = new SetMyAddressUseCase();
|
||||||
|
|
||||||
|
colonias = (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const result = this.listColonias.execute();
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error, res);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
myAddress = (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) throw CustomError.unauthorized("User not authenticated");
|
||||||
|
const result = this.getMyAddress.execute(req.user.id);
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error, res);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setAddress = (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) throw CustomError.unauthorized("User not authenticated");
|
||||||
|
const result = this.setMyAddress.execute(
|
||||||
|
req.user.id,
|
||||||
|
req.user.email, // como fallback de nombre
|
||||||
|
req.user.email,
|
||||||
|
req.body,
|
||||||
|
);
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error, res);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleError(error: unknown, res: Response): void {
|
||||||
|
if (error instanceof CustomError) {
|
||||||
|
res.status(error.statusCode).json({ error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
res.status(400).json({ error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: "Internal server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
16
backend/src/presentation/addresses/routes.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { AddressesController } from "./controller.js";
|
||||||
|
import { AuthMiddleware } from "../middlewares/auth.middleware.js";
|
||||||
|
|
||||||
|
export class AddressesRoutes {
|
||||||
|
static get routes(): Router {
|
||||||
|
const router = Router();
|
||||||
|
const controller = new AddressesController();
|
||||||
|
|
||||||
|
router.get("/colonias", controller.colonias);
|
||||||
|
router.get("/me", AuthMiddleware.validate, controller.myAddress);
|
||||||
|
router.put("/me", AuthMiddleware.validate, controller.setAddress);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
}
|
||||||
100
backend/src/presentation/admin/controller.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* controller.ts (admin)
|
||||||
|
*
|
||||||
|
* GET /api/admin/routes - listar todas las rutas + estado
|
||||||
|
* POST /api/admin/routes/:id/cancel - cancelar ruta (con motivo opcional)
|
||||||
|
* POST /api/admin/routes/:id/resume - reanudar ruta cancelada
|
||||||
|
*
|
||||||
|
* Todos los endpoints requieren AuthMiddleware.validate + requireAdmin.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Response } from "express";
|
||||||
|
import { AuthRequest } from "../middlewares/auth.middleware.js";
|
||||||
|
import { CustomError } from "../../domain/errors/custom.error.js";
|
||||||
|
import { ListRoutesUseCase } from "../../domain/use-cases/admin/list-routes.use-case.js";
|
||||||
|
import { CancelRouteUseCase } from "../../domain/use-cases/admin/cancel-route.use-case.js";
|
||||||
|
import { ResumeRouteUseCase } from "../../domain/use-cases/admin/resume-route.use-case.js";
|
||||||
|
import { ListAllFeedbackUseCase } from "../../domain/use-cases/admin/list-all-feedback.use-case.js";
|
||||||
|
import { TrackingController } from "../tracking/controller.js";
|
||||||
|
import { FeedbackController } from "../feedback/controller.js";
|
||||||
|
|
||||||
|
export class AdminController {
|
||||||
|
private listRoutes = new ListRoutesUseCase(
|
||||||
|
TrackingController.getRouteStateRepository(),
|
||||||
|
);
|
||||||
|
private cancelRoute = new CancelRouteUseCase(
|
||||||
|
TrackingController.getRouteStateRepository(),
|
||||||
|
TrackingController.getNotificationInbox(),
|
||||||
|
);
|
||||||
|
private resumeRoute = new ResumeRouteUseCase(
|
||||||
|
TrackingController.getRouteStateRepository(),
|
||||||
|
TrackingController.getNotificationInbox(),
|
||||||
|
);
|
||||||
|
private listAllFeedback = new ListAllFeedbackUseCase(
|
||||||
|
FeedbackController.getRepository(),
|
||||||
|
);
|
||||||
|
|
||||||
|
routes = async (_req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const result = await this.listRoutes.execute();
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error, res);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
feedback = async (_req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const result = await this.listAllFeedback.execute();
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error, res);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
cancel = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const routeId = String(req.params.routeId ?? "");
|
||||||
|
if (!routeId) throw CustomError.badRequest("routeId is required");
|
||||||
|
const reason =
|
||||||
|
typeof req.body?.reason === "string" ? req.body.reason : undefined;
|
||||||
|
await this.cancelRoute.execute(routeId, reason);
|
||||||
|
|
||||||
|
// Resetea el índice del simulador para que al reanudar vuelva a 1
|
||||||
|
TrackingController.getSimulator()?.resetPosition(routeId);
|
||||||
|
|
||||||
|
res.status(200).json({ message: "Route cancelled", routeId });
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error, res);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
resume = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const routeId = String(req.params.routeId ?? "");
|
||||||
|
if (!routeId) throw CustomError.badRequest("routeId is required");
|
||||||
|
await this.resumeRoute.execute(routeId);
|
||||||
|
|
||||||
|
// Al reanudar: simulator vuelve a positionId 1 + limpia dedup cache
|
||||||
|
// para que las notificaciones del ciclo nuevo se vuelvan a emitir.
|
||||||
|
TrackingController.getSimulator()?.resetPosition(routeId);
|
||||||
|
await TrackingController.getDedupCache().clear();
|
||||||
|
|
||||||
|
res.status(200).json({ message: "Route resumed", routeId });
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error, res);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleError(error: unknown, res: Response): void {
|
||||||
|
if (error instanceof CustomError) {
|
||||||
|
res.status(error.statusCode).json({ error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
res.status(400).json({ error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: "Internal server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
20
backend/src/presentation/admin/routes.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { AdminController } from "./controller.js";
|
||||||
|
import { AuthMiddleware } from "../middlewares/auth.middleware.js";
|
||||||
|
|
||||||
|
export class AdminRoutes {
|
||||||
|
static get routes(): Router {
|
||||||
|
const router = Router();
|
||||||
|
const controller = new AdminController();
|
||||||
|
|
||||||
|
router.use(AuthMiddleware.validate, AuthMiddleware.requireAdmin);
|
||||||
|
|
||||||
|
router.get("/routes", controller.routes);
|
||||||
|
router.post("/routes/:routeId/cancel", controller.cancel);
|
||||||
|
router.post("/routes/:routeId/resume", controller.resume);
|
||||||
|
|
||||||
|
router.get("/feedback", controller.feedback);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
backend/src/presentation/feedback/controller.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* controller.ts (feedback)
|
||||||
|
* POST /api/feedback → enviar retroalimentación (auth)
|
||||||
|
* GET /api/feedback/me → listar mis feedbacks (auth)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Response } from "express";
|
||||||
|
import { AuthRequest } from "../middlewares/auth.middleware.js";
|
||||||
|
import { CustomError } from "../../domain/errors/custom.error.js";
|
||||||
|
import { SubmitFeedbackUseCase } from "../../domain/use-cases/feedback/submit-feedback.use-case.js";
|
||||||
|
import { ListMyFeedbackUseCase } from "../../domain/use-cases/feedback/list-my-feedback.use-case.js";
|
||||||
|
import { InMemoryFeedbackRepository } from "../../data/repositories/feedback.repository.impl.js";
|
||||||
|
|
||||||
|
export class FeedbackController {
|
||||||
|
private static repository = new InMemoryFeedbackRepository();
|
||||||
|
|
||||||
|
private submit = new SubmitFeedbackUseCase(FeedbackController.repository);
|
||||||
|
private listMine = new ListMyFeedbackUseCase(FeedbackController.repository);
|
||||||
|
|
||||||
|
/** Expone el repo singleton al módulo admin. */
|
||||||
|
static getRepository() {
|
||||||
|
return FeedbackController.repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
create = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) throw CustomError.unauthorized("User not authenticated");
|
||||||
|
const result = await this.submit.execute(req.user.id, req.body);
|
||||||
|
res.status(201).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error, res);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
myFeedback = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) throw CustomError.unauthorized("User not authenticated");
|
||||||
|
const result = await this.listMine.execute(req.user.id);
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error, res);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleError(error: unknown, res: Response): void {
|
||||||
|
if (error instanceof CustomError) {
|
||||||
|
res.status(error.statusCode).json({ error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
res.status(400).json({ error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: "Internal server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
15
backend/src/presentation/feedback/routes.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { FeedbackController } from "./controller.js";
|
||||||
|
import { AuthMiddleware } from "../middlewares/auth.middleware.js";
|
||||||
|
|
||||||
|
export class FeedbackRoutes {
|
||||||
|
static get routes(): Router {
|
||||||
|
const router = Router();
|
||||||
|
const controller = new FeedbackController();
|
||||||
|
|
||||||
|
router.post("/", AuthMiddleware.validate, controller.create);
|
||||||
|
router.get("/me", AuthMiddleware.validate, controller.myFeedback);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,23 @@
|
|||||||
|
|
||||||
|
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { JwtAdapter, JwtPayload } from "../../config/jwt.js";
|
import { JwtAdapter } from "../../config/jwt.js";
|
||||||
import { AuthRepositoryImpl } from "../../data/repositories/auth.repository.impl.js";
|
import { AuthRepositoryImpl } from "../../data/repositories/auth.repository.impl.js";
|
||||||
import { CustomError } from "../../domain/errors/custom.error.js";
|
import { CustomError } from "../../domain/errors/custom.error.js";
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthRequest extends Request {
|
export interface AuthRequest extends Request {
|
||||||
user?: JwtPayload;
|
user?: AuthUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AuthMiddleware {
|
export class AuthMiddleware {
|
||||||
static async validate(req: AuthRequest, res: Response, next: NextFunction) {
|
static async validate(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||||
throw CustomError.unauthorized("Missing or invalid Authorization header");
|
throw CustomError.unauthorized("Missing or invalid Authorization header");
|
||||||
@@ -20,21 +25,19 @@ export class AuthMiddleware {
|
|||||||
|
|
||||||
const token = authHeader.substring(7);
|
const token = authHeader.substring(7);
|
||||||
|
|
||||||
|
|
||||||
const payload = JwtAdapter.validate(token);
|
const payload = JwtAdapter.validate(token);
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
throw CustomError.unauthorized("Invalid token");
|
throw CustomError.unauthorized("Invalid token");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const repository = new AuthRepositoryImpl();
|
const repository = new AuthRepositoryImpl();
|
||||||
const user = await repository.findById(payload.id);
|
const user = await repository.findById(payload.id);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw CustomError.unauthorized("User not found");
|
throw CustomError.unauthorized("User not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exponemos id + email + role (no name) en req.user.
|
||||||
req.user = payload;
|
req.user = { id: user.id, email: user.email, role: user.role };
|
||||||
|
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -44,4 +47,16 @@ export class AuthMiddleware {
|
|||||||
res.status(500).json({ error: "Internal server error" });
|
res.status(500).json({ error: "Internal server error" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Requiere que el usuario logueado tenga role=ADMIN. Debe encadenarse
|
||||||
|
* después de `validate`. */
|
||||||
|
static requireAdmin(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
if (req.user.role !== "ADMIN") {
|
||||||
|
return res.status(403).json({ error: "Admin role required" });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { AuthRoutes } from "./auth/routes.js";
|
import { AuthRoutes } from "./auth/routes.js";
|
||||||
import { TrackingRoutes } from "./tracking/routes.js";
|
import { TrackingRoutes } from "./tracking/routes.js";
|
||||||
|
import { FeedbackRoutes } from "./feedback/routes.js";
|
||||||
|
import { AddressesRoutes } from "./addresses/routes.js";
|
||||||
|
import { AdminRoutes } from "./admin/routes.js";
|
||||||
|
|
||||||
export class AppRoutes {
|
export class AppRoutes {
|
||||||
static get routes(): Router {
|
static get routes(): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use('/api/auth', AuthRoutes.routes);
|
router.use('/api/auth', AuthRoutes.routes);
|
||||||
|
// Importante: el módulo admin DEPENDE de los singletons de tracking
|
||||||
|
// (route-state, inbox). Por eso tracking debe registrarse ANTES que admin.
|
||||||
router.use('/api/tracking', TrackingRoutes.routes);
|
router.use('/api/tracking', TrackingRoutes.routes);
|
||||||
|
router.use('/api/admin', AdminRoutes.routes);
|
||||||
|
router.use('/api/feedback', FeedbackRoutes.routes);
|
||||||
|
router.use('/api/addresses', AddressesRoutes.routes);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* controller.ts (tracking)
|
||||||
|
*
|
||||||
|
* Dos endpoints:
|
||||||
|
* POST /gps-update → interno (simulador / admin). No expone datos del user.
|
||||||
|
* GET /status → protegido por AuthMiddleware. Devuelve SOLO la
|
||||||
|
* información del usuario logueado (visión de túnel).
|
||||||
|
*/
|
||||||
|
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
|
import { AuthRequest } from "../middlewares/auth.middleware.js";
|
||||||
import { CustomError } from "../../domain/errors/custom.error.js";
|
import { CustomError } from "../../domain/errors/custom.error.js";
|
||||||
import { CalculateEtaUseCase } from "../../domain/use-cases/notifications/calculate-eta.use-case.js";
|
import { CalculateEtaUseCase } from "../../domain/use-cases/notifications/calculate-eta.use-case.js";
|
||||||
import { EvaluateNotificationUseCase } from "../../domain/use-cases/notifications/evaluate-notification.use-case.js";
|
import { EvaluateNotificationUseCase } from "../../domain/use-cases/notifications/evaluate-notification.use-case.js";
|
||||||
import { ProcessGpsUpdateUseCase } from "../../domain/use-cases/notifications/process-gps-update.use-case.js";
|
import { ProcessGpsUpdateUseCase } from "../../domain/use-cases/notifications/process-gps-update.use-case.js";
|
||||||
|
import { GetUserStatusUseCase } from "../../domain/use-cases/tracking/get-user-status.use-case.js";
|
||||||
import { InMemoryNotificationCache } from "../../data/cache/notification-cache.impl.js";
|
import { InMemoryNotificationCache } from "../../data/cache/notification-cache.impl.js";
|
||||||
|
import { InMemoryRouteStateRepository } from "../../data/cache/route-state.impl.js";
|
||||||
|
import { InMemoryNotificationInbox } from "../../data/cache/notification-inbox.impl.js";
|
||||||
|
import { RouteSimulator } from "../../data/simulation/route-simulator.js";
|
||||||
|
|
||||||
export class TrackingController {
|
export class TrackingController {
|
||||||
private cache = new InMemoryNotificationCache();
|
// Singletons del módulo (viven mientras corre el server)
|
||||||
|
private static dedupCache = new InMemoryNotificationCache();
|
||||||
|
private static routeState = new InMemoryRouteStateRepository();
|
||||||
|
private static inbox = new InMemoryNotificationInbox();
|
||||||
|
|
||||||
private calculateEta = new CalculateEtaUseCase();
|
private calculateEta = new CalculateEtaUseCase();
|
||||||
private evaluateNotification = new EvaluateNotificationUseCase(this.cache);
|
private evaluateNotification = new EvaluateNotificationUseCase(
|
||||||
|
TrackingController.dedupCache,
|
||||||
|
);
|
||||||
private processGpsUpdate = new ProcessGpsUpdateUseCase(
|
private processGpsUpdate = new ProcessGpsUpdateUseCase(
|
||||||
this.calculateEta,
|
this.calculateEta,
|
||||||
this.evaluateNotification,
|
this.evaluateNotification,
|
||||||
|
TrackingController.routeState,
|
||||||
|
TrackingController.inbox,
|
||||||
);
|
);
|
||||||
|
private getUserStatus = new GetUserStatusUseCase(
|
||||||
|
TrackingController.routeState,
|
||||||
|
TrackingController.inbox,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Referencia al simulador para que el módulo admin pueda reiniciar posiciones. */
|
||||||
|
private static simulatorInstance: RouteSimulator | null = null;
|
||||||
|
|
||||||
|
/** Permite que app.ts lance el simulador usando estos mismos singletons. */
|
||||||
|
buildSimulator(): RouteSimulator {
|
||||||
|
const sim = new RouteSimulator(
|
||||||
|
this.processGpsUpdate,
|
||||||
|
TrackingController.dedupCache,
|
||||||
|
TrackingController.routeState,
|
||||||
|
);
|
||||||
|
TrackingController.simulatorInstance = sim;
|
||||||
|
return sim;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expone el route-state singleton al módulo admin. */
|
||||||
|
static getRouteStateRepository() {
|
||||||
|
return TrackingController.routeState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expone el inbox singleton al módulo admin. */
|
||||||
|
static getNotificationInbox() {
|
||||||
|
return TrackingController.inbox;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expone el dedup cache al módulo admin (para limpiarlo en resume). */
|
||||||
|
static getDedupCache() {
|
||||||
|
return TrackingController.dedupCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expone el simulador (puede ser null si aún no se construyó). */
|
||||||
|
static getSimulator(): RouteSimulator | null {
|
||||||
|
return TrackingController.simulatorInstance;
|
||||||
|
}
|
||||||
|
|
||||||
gpsUpdate = async (req: Request, res: Response) => {
|
gpsUpdate = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -23,6 +83,35 @@ export class TrackingController {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
myStatus = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) throw CustomError.unauthorized("User not authenticated");
|
||||||
|
const result = await this.getUserStatus.execute(req.user.id);
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error, res);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /reset-demo
|
||||||
|
* Borra cache de dedup, inbox y estado de rutas.
|
||||||
|
* Permite repetir la demo sin reiniciar el server. Solo para hackathon.
|
||||||
|
*/
|
||||||
|
resetDemo = async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
await TrackingController.dedupCache.clear();
|
||||||
|
await TrackingController.inbox.clearAll();
|
||||||
|
await TrackingController.routeState.clearAll();
|
||||||
|
|
||||||
|
res
|
||||||
|
.status(200)
|
||||||
|
.json({ message: "Demo state reset — simulator continues running" });
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error, res);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
private handleError(error: unknown, res: Response): void {
|
private handleError(error: unknown, res: Response): void {
|
||||||
if (error instanceof CustomError) {
|
if (error instanceof CustomError) {
|
||||||
res.status(error.statusCode).json({ error: error.message });
|
res.status(error.statusCode).json({ error: error.message });
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { TrackingController } from "./controller.js";
|
import { TrackingController } from "./controller.js";
|
||||||
|
import { AuthMiddleware } from "../middlewares/auth.middleware.js";
|
||||||
|
|
||||||
export class TrackingRoutes {
|
export class TrackingRoutes {
|
||||||
|
static controllerInstance: TrackingController | null = null;
|
||||||
|
|
||||||
static get routes(): Router {
|
static get routes(): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const controller = new TrackingController();
|
const controller = new TrackingController();
|
||||||
|
TrackingRoutes.controllerInstance = controller;
|
||||||
|
|
||||||
|
// Interno: simulador o admin. Sin auth para que el demo sea simple.
|
||||||
router.post("/gps-update", controller.gpsUpdate);
|
router.post("/gps-update", controller.gpsUpdate);
|
||||||
|
|
||||||
|
// Visión de túnel: SOLO con JWT, SOLO datos del usuario logueado.
|
||||||
|
router.get("/status", AuthMiddleware.validate, controller.myStatus);
|
||||||
|
|
||||||
|
// Reset del estado en memoria (solo para repetir la demo).
|
||||||
|
router.post("/reset-demo", controller.resetDemo);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
409
frontend/INTEGRATION.md
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
# Guía Frontend — Integración con el Backend
|
||||||
|
|
||||||
|
App: Expo Router + React Native + TypeScript.
|
||||||
|
Backend: Express en `http://localhost:3000` (ya está listo, no hay que tocarlo).
|
||||||
|
|
||||||
|
> Esta guía está pensada para probar en **emulador de Android**.
|
||||||
|
> Si prueban en otro entorno, sólo cambien la `API_URL` (sección 1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Endpoints del backend
|
||||||
|
|
||||||
|
| Método | Endpoint | Para qué |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/api/auth/register` | Crear cuenta |
|
||||||
|
| POST | `/api/auth/login` | Iniciar sesión y obtener JWT |
|
||||||
|
| GET | `/api/auth/me` | Datos del usuario (requiere token) |
|
||||||
|
| POST | `/api/tracking/gps-update` | Recibe posición del camión, regresa ETA + notificaciones |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Configurar la URL del backend
|
||||||
|
|
||||||
|
Crear `frontend/src/config/api.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// IPs según el entorno:
|
||||||
|
// Android Emulator → http://10.0.2.2:3000 (default aquí)
|
||||||
|
// iOS simulator → http://localhost:3000
|
||||||
|
// Dispositivo real → http://<IP-de-tu-laptop>:3000 (ej. 192.168.1.20)
|
||||||
|
// Expo Go web → http://localhost:3000
|
||||||
|
export const API_URL = "http://10.0.2.2:3000";
|
||||||
|
```
|
||||||
|
|
||||||
|
> `10.0.2.2` es la dirección especial del emulador de Android para alcanzar el `localhost` de la laptop. No usen `localhost` desde el emulador, no funciona.
|
||||||
|
|
||||||
|
> Si prueban en un celular físico, sáquen la IP de la laptop con `ipconfig` (Windows) y pongan algo como `http://192.168.1.20:3000`. La laptop y el celular tienen que estar en el **mismo Wi-Fi**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Cliente HTTP simple
|
||||||
|
|
||||||
|
Crear `frontend/src/lib/api.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { API_URL } from "../config/api";
|
||||||
|
|
||||||
|
let authToken: string | null = null;
|
||||||
|
|
||||||
|
export const setAuthToken = (token: string | null) => {
|
||||||
|
authToken = token;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiFetch = async <T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
): Promise<T> => {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(options.headers as Record<string, string>),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (authToken) {
|
||||||
|
headers.Authorization = `Bearer ${authToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${API_URL}${path}`, { ...options, headers });
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return body as T;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Servicios por feature
|
||||||
|
|
||||||
|
### `frontend/src/services/auth.service.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { apiFetch, setAuthToken } from "../lib/api";
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const register = (data: { name: string; email: string; password: string }) =>
|
||||||
|
apiFetch<{ id: number; email: string; name: string }>("/api/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const login = async (data: { email: string; password: string }) => {
|
||||||
|
const res = await apiFetch<{ user: AuthUser; token: string }>("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
setAuthToken(res.token);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMe = () =>
|
||||||
|
apiFetch<AuthUser & { role: string }>("/api/auth/me");
|
||||||
|
```
|
||||||
|
|
||||||
|
### `frontend/src/services/tracking.service.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { apiFetch } from "../lib/api";
|
||||||
|
|
||||||
|
export type NotificationType =
|
||||||
|
| "ROUTE_START"
|
||||||
|
| "TRUCK_PROXIMITY"
|
||||||
|
| "ROUTE_COMPLETED"
|
||||||
|
| "DELAY"
|
||||||
|
| "MECHANICAL_FAILURE";
|
||||||
|
|
||||||
|
export interface GpsUpdatePayload {
|
||||||
|
truckId: string;
|
||||||
|
routeId: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
speed: number;
|
||||||
|
status: "EN_RUTA" | "DETENIDO" | "FINALIZADO" | "FALLA";
|
||||||
|
positionId?: number;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EtaResult {
|
||||||
|
etaMinutes: number;
|
||||||
|
arrivalWindow: { from: string; to: string };
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendNotification {
|
||||||
|
userId: number;
|
||||||
|
type: NotificationType;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GpsUpdateResponse {
|
||||||
|
message: string;
|
||||||
|
truck: { truckId: number; routeId: string; status: string };
|
||||||
|
eta: EtaResult;
|
||||||
|
notifications: BackendNotification[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendGpsUpdate = (payload: GpsUpdatePayload) =>
|
||||||
|
apiFetch<GpsUpdateResponse>("/api/tracking/gps-update", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Mapear los tipos del backend al componente `AlertItem`
|
||||||
|
|
||||||
|
`AlertItem` ya acepta `started | near | danger | completed`. Crear `frontend/src/lib/notification-mapper.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { NotificationType } from "../services/tracking.service";
|
||||||
|
|
||||||
|
export const notificationTypeToAlertType = (
|
||||||
|
type: NotificationType,
|
||||||
|
): "started" | "near" | "danger" | "completed" => {
|
||||||
|
switch (type) {
|
||||||
|
case "ROUTE_START": return "started";
|
||||||
|
case "TRUCK_PROXIMITY": return "near";
|
||||||
|
case "ROUTE_COMPLETED": return "completed";
|
||||||
|
case "DELAY": return "danger";
|
||||||
|
case "MECHANICAL_FAILURE": return "danger";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Pantalla de Login (`src/app/login.tsx`)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState } from "react";
|
||||||
|
import { View, Text, StyleSheet, Alert } from "react-native";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import InputField from "../components/InputField";
|
||||||
|
import PrimaryButton from "../components/PrimaryButton";
|
||||||
|
import { login } from "../services/auth.service";
|
||||||
|
|
||||||
|
export default function LoginScreen() {
|
||||||
|
const [email, setEmail] = useState("ana@test.com");
|
||||||
|
const [password, setPassword] = useState("123456");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await login({ email, password });
|
||||||
|
router.replace("/");
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert("Error", err instanceof Error ? err.message : "Login failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>Iniciar sesión</Text>
|
||||||
|
<InputField placeholder="Email" value={email} onChangeText={setEmail} />
|
||||||
|
<InputField placeholder="Password" value={password} onChangeText={setPassword} secureTextEntry />
|
||||||
|
<PrimaryButton title={loading ? "Cargando..." : "Entrar"} onPress={handleLogin} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, padding: 24, justifyContent: "center" },
|
||||||
|
title: { fontSize: 24, fontWeight: "bold", marginBottom: 20 },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Pantalla principal con ETA en tiempo real (`src/app/index.tsx`)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ScrollView, RefreshControl, View } from "react-native";
|
||||||
|
import EtaCard from "../components/EtaCard";
|
||||||
|
import {
|
||||||
|
sendGpsUpdate,
|
||||||
|
type GpsUpdateResponse,
|
||||||
|
type BackendNotification,
|
||||||
|
} from "../services/tracking.service";
|
||||||
|
|
||||||
|
// Para la demo: el frontend simula el avance del camión.
|
||||||
|
// En producción esto vendría de un GPS real → backend → app vía websockets/polling.
|
||||||
|
const SIMULATION_STEPS = [
|
||||||
|
{ positionId: 1, lat: 20.5111, lng: -100.9037, speed: 0, timestamp: "2026-05-22T06:00:00Z" },
|
||||||
|
{ positionId: 2, lat: 20.5185, lng: -100.8450, speed: 45, timestamp: "2026-05-22T06:12:00Z" },
|
||||||
|
{ positionId: 3, lat: 20.5215, lng: -100.8142, speed: 22, timestamp: "2026-05-22T06:25:00Z" },
|
||||||
|
{ positionId: 4, lat: 20.5212, lng: -100.8175, speed: 15, timestamp: "2026-05-22T06:38:00Z" },
|
||||||
|
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 40, timestamp: "2026-05-22T07:40:00Z" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function HomeScreen() {
|
||||||
|
const [stepIndex, setStepIndex] = useState(0);
|
||||||
|
const [data, setData] = useState<GpsUpdateResponse | null>(null);
|
||||||
|
const [notifications, setNotifications] = useState<BackendNotification[]>([]);
|
||||||
|
|
||||||
|
const fetchNext = async () => {
|
||||||
|
const step = SIMULATION_STEPS[stepIndex % SIMULATION_STEPS.length];
|
||||||
|
try {
|
||||||
|
const res = await sendGpsUpdate({
|
||||||
|
truckId: "101",
|
||||||
|
routeId: "RUTA-01",
|
||||||
|
status: "EN_RUTA",
|
||||||
|
...step,
|
||||||
|
});
|
||||||
|
setData(res);
|
||||||
|
setNotifications((prev) => [...res.notifications, ...prev]);
|
||||||
|
setStepIndex((i) => i + 1);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { fetchNext(); }, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView refreshControl={<RefreshControl refreshing={false} onRefresh={fetchNext} />}>
|
||||||
|
<EtaCard
|
||||||
|
minutes={data?.eta.etaMinutes ?? 0}
|
||||||
|
status={data?.eta.message ?? "Cargando..."}
|
||||||
|
/>
|
||||||
|
<View>
|
||||||
|
{/* Mapear notifications a <AlertItem /> aquí si quieren mostrarlas en la home también */}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Truco del demo:** cada "pull to refresh" avanza el camión un paso. Sin tocar el backend, pueden mostrar las 5 fases del recorrido.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Pantalla de Alertas (`src/app/alerts.tsx`)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { FlatList, View } from "react-native";
|
||||||
|
import AlertItem from "../components/Alertltem";
|
||||||
|
import { notificationTypeToAlertType } from "../lib/notification-mapper";
|
||||||
|
import type { BackendNotification } from "../services/tracking.service";
|
||||||
|
|
||||||
|
type Props = { notifications: BackendNotification[] };
|
||||||
|
|
||||||
|
export default function AlertsScreen({ notifications }: Props) {
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, padding: 16 }}>
|
||||||
|
<FlatList
|
||||||
|
data={notifications}
|
||||||
|
keyExtractor={(_, i) => String(i)}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<AlertItem
|
||||||
|
title={item.title}
|
||||||
|
description={item.body}
|
||||||
|
time="ahora"
|
||||||
|
type={notificationTypeToAlertType(item.type)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Compartir notificaciones entre pantallas:** lo más simple para el demo es subirlas a un `Context` o Zustand. Si tienen tiempo. Para hackathon, pueden duplicar la lógica o pasarlas por params.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Cómo probar el flujo completo en el emulador de Android
|
||||||
|
|
||||||
|
### Pre-requisitos
|
||||||
|
|
||||||
|
- Backend corriendo: `npm run dev` dentro de `/backend`. Debe imprimir `Server is running on port 3000`.
|
||||||
|
- Android Studio con AVD (emulador) abierto.
|
||||||
|
- Frontend con dependencias instaladas: `npm install` dentro de `/frontend`.
|
||||||
|
|
||||||
|
### Pasos
|
||||||
|
|
||||||
|
1. **Levantar el frontend:**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd frontend
|
||||||
|
npm run android
|
||||||
|
```
|
||||||
|
|
||||||
|
Eso abre Expo y lanza la app en el emulador.
|
||||||
|
|
||||||
|
2. **Crear un usuario de prueba** (sólo la primera vez). Desde Postman:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST http://localhost:3000/api/auth/register
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "name": "Ana López", "email": "ana@test.com", "password": "123456" }
|
||||||
|
```
|
||||||
|
|
||||||
|
(Ana ya está en el mock con `routeId: "RUTA-01"`, así que recibirá las notificaciones del camión 101.)
|
||||||
|
|
||||||
|
3. **En la app del emulador**, iniciar sesión con:
|
||||||
|
- Email: `ana@test.com`
|
||||||
|
- Password: `123456`
|
||||||
|
|
||||||
|
4. **En la pantalla principal**, hacer **pull-to-refresh** repetidamente. Cada pull avanza el camión:
|
||||||
|
|
||||||
|
| Paso | positionId | Notificación esperada |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 1 | (ninguna) |
|
||||||
|
| 2 | 2 | "¡Ruta Iniciada!" |
|
||||||
|
| 3 | 3 | (ninguna) |
|
||||||
|
| 4 | 4 | "Camión Cercano" |
|
||||||
|
| 5 | 8 | "Servicio Finalizado" |
|
||||||
|
|
||||||
|
5. **Pantalla de alertas** debe mostrar las notificaciones acumuladas con los iconos y colores correctos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Problemas comunes en Android emulator
|
||||||
|
|
||||||
|
| Síntoma | Causa | Solución |
|
||||||
|
|---|---|---|
|
||||||
|
| `Network request failed` | Apunta a `localhost` | Usar `http://10.0.2.2:3000` en `API_URL` |
|
||||||
|
| `TypeError: Failed to fetch` (en web) | Falta CORS | Avisar al del backend para que active `cors()` |
|
||||||
|
| 401 al hacer login | Usuario no existe | Registrar primero con Postman (sección 8, paso 2) |
|
||||||
|
| `notifications: []` aunque cambies positionId | El cache ya envió esa noti antes | Reiniciar el backend (`Ctrl+C` y `npm run dev`) |
|
||||||
|
| ETA negativo o raro | El `timestamp` que envías está atrás del target | Es normal, el mensaje sale como "El camión ya pasó..." |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Checklist mínimo
|
||||||
|
|
||||||
|
- [ ] `src/config/api.ts` con `http://10.0.2.2:3000`
|
||||||
|
- [ ] `src/lib/api.ts` (fetch wrapper)
|
||||||
|
- [ ] `src/services/auth.service.ts`
|
||||||
|
- [ ] `src/services/tracking.service.ts`
|
||||||
|
- [ ] `src/lib/notification-mapper.ts`
|
||||||
|
- [ ] Pantalla de login funcionando con `ana@test.com`
|
||||||
|
- [ ] Pantalla principal mostrando `EtaCard` con datos reales
|
||||||
|
- [ ] Pantalla de alertas mostrando `AlertItem` con notificaciones reales
|
||||||
|
- [ ] Probar las 5 fases del recorrido con pull-to-refresh
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Lo que NO necesitan hacer
|
||||||
|
|
||||||
|
- ❌ Push notifications reales (Firebase / Expo Notifications) — sólo lista in-app
|
||||||
|
- ❌ Mapas con GPS real — los datos vienen del mock del backend
|
||||||
|
- ❌ AsyncStorage para el token — para el demo basta con estado en memoria
|
||||||
|
- ❌ Modificar nada del `/backend`
|
||||||
@@ -1,56 +1,293 @@
|
|||||||
# Welcome to your Expo app 👋
|
# Frontend — OptiRuta
|
||||||
|
|
||||||
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
|
App móvil en **Expo SDK 56 + React Native 0.85 + TypeScript** con **expo-router**.
|
||||||
|
|
||||||
## Get started
|
> Para el README general, ver [../README.md](../README.md).
|
||||||
|
> Para la guía de integración con el backend (compañeros), ver [INTEGRATION.md](INTEGRATION.md).
|
||||||
|
|
||||||
1. Install dependencies
|
---
|
||||||
|
|
||||||
```bash
|
## Tabla de contenidos
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Start the app
|
- [Stack](#stack)
|
||||||
|
- [Estructura](#estructura)
|
||||||
|
- [Cómo correr](#cómo-correr)
|
||||||
|
- [Configurar la URL del backend](#configurar-la-url-del-backend)
|
||||||
|
- [Pantallas](#pantallas)
|
||||||
|
- [Estado global (AppContext)](#estado-global-appcontext)
|
||||||
|
- [Comunicación con el backend](#comunicación-con-el-backend)
|
||||||
|
- [Componentes reutilizables](#componentes-reutilizables)
|
||||||
|
- [Roles y navegación](#roles-y-navegación)
|
||||||
|
- [Notificaciones](#notificaciones)
|
||||||
|
- [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
```bash
|
---
|
||||||
npx expo start
|
|
||||||
```
|
|
||||||
|
|
||||||
In the output, you'll find options to open the app in a
|
## Stack
|
||||||
|
|
||||||
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
|
- **Expo SDK 56** (Sept 2025)
|
||||||
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
|
- **React Native 0.85** + **React 19**
|
||||||
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
|
- **TypeScript** estricto
|
||||||
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
|
- **expo-router 56** — navegación basada en archivos
|
||||||
|
- **@expo/vector-icons** — iconos Ionicons
|
||||||
|
- **expo-image** — imágenes optimizadas
|
||||||
|
- Sin librerías de estado externas (solo Context)
|
||||||
|
- Sin librerías de UI externas (StyleSheet nativo)
|
||||||
|
|
||||||
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
|
---
|
||||||
|
|
||||||
## Get a fresh project
|
## Estructura
|
||||||
|
|
||||||
When you're ready, run:
|
```
|
||||||
|
frontend/
|
||||||
```bash
|
├── app.json ← config de Expo (name="OptiRuta", slug, etc.)
|
||||||
npm run reset-project
|
├── tsconfig.json ← paths "@/*" → "./src/*"
|
||||||
|
├── assets/
|
||||||
|
│ ├── images/ ← icons del SO, splash
|
||||||
|
│ └── illustrations/ ← ilustraciones del UI (camión, botes, etc.)
|
||||||
|
└── src/
|
||||||
|
├── app/ ← rutas de expo-router (cada archivo = pantalla)
|
||||||
|
│ ├── _layout.tsx ← Tabs + AppProvider + Toast
|
||||||
|
│ ├── index.tsx ← Home (ETA + estado de ruta)
|
||||||
|
│ ├── login.tsx
|
||||||
|
│ ├── register.tsx
|
||||||
|
│ ├── addresses.tsx ← validación de domicilio
|
||||||
|
│ ├── alerts.tsx ← timeline de notificaciones
|
||||||
|
│ ├── feedback.tsx ← buzón de retro
|
||||||
|
│ ├── guide.tsx ← calendario + guía de separación
|
||||||
|
│ ├── admin.tsx ← panel admin
|
||||||
|
│ └── profile.tsx ← perfil + ajustes
|
||||||
|
├── components/
|
||||||
|
│ ├── EtaCard.tsx
|
||||||
|
│ ├── Alertltem.tsx ← (sic) typo histórico, no romper
|
||||||
|
│ ├── InputField.tsx
|
||||||
|
│ ├── PrimaryButton.tsx
|
||||||
|
│ ├── QuickAction.tsx
|
||||||
|
│ ├── SectionTitle.tsx
|
||||||
|
│ ├── StatusBadge.tsx
|
||||||
|
│ ├── CollectionCalendar.tsx
|
||||||
|
│ └── NotificationToast.tsx
|
||||||
|
├── config/
|
||||||
|
│ └── api.ts ← URL del backend (cambia según dispositivo)
|
||||||
|
├── constants/
|
||||||
|
│ ├── colors.ts
|
||||||
|
│ └── theme.ts
|
||||||
|
├── context/
|
||||||
|
│ └── AppContext.tsx ← estado global + polling
|
||||||
|
├── data/
|
||||||
|
│ └── mocks/
|
||||||
|
│ └── routes.mock.ts ← catálogo de routeId/name (para el calendario)
|
||||||
|
├── hooks/
|
||||||
|
├── lib/
|
||||||
|
│ ├── api.ts ← apiFetch (wrapper de fetch + auth token)
|
||||||
|
│ ├── notifications.ts ← (preparado para Dev Build; no se importa en Go)
|
||||||
|
│ └── notification-mapper.ts
|
||||||
|
└── services/
|
||||||
|
├── auth.service.ts
|
||||||
|
├── tracking.service.ts
|
||||||
|
├── addresses.service.ts
|
||||||
|
├── feedback.service.ts
|
||||||
|
└── admin.service.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
|
---
|
||||||
|
|
||||||
### Other setup steps
|
## Cómo correr
|
||||||
|
|
||||||
- To set up ESLint for linting, run `npx expo lint`, or follow our guide on ["Using ESLint and Prettier"](https://docs.expo.dev/guides/using-eslint/)
|
```powershell
|
||||||
- If you'd like to set up unit testing, follow our guide on ["Unit Testing with Jest"](https://docs.expo.dev/develop/unit-testing/)
|
cd frontend
|
||||||
- Learn more about the TypeScript setup in this template in our guide on ["Using TypeScript"](https://docs.expo.dev/guides/typescript/)
|
npm install
|
||||||
|
npx expo start -c # -c limpia caché de Metro
|
||||||
|
```
|
||||||
|
|
||||||
## Learn more
|
Después:
|
||||||
|
- `a` → Android emulator
|
||||||
|
- `i` → iOS simulator (solo Mac)
|
||||||
|
- `w` → web
|
||||||
|
- Escanear QR con Expo Go en celular físico
|
||||||
|
|
||||||
To learn more about developing your project with Expo, look at the following resources:
|
---
|
||||||
|
|
||||||
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
|
## Configurar la URL del backend
|
||||||
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
|
|
||||||
|
|
||||||
## Join the community
|
Edita `src/config/api.ts`:
|
||||||
|
|
||||||
Join our community of developers creating universal apps.
|
```ts
|
||||||
|
// Para Android emulator local:
|
||||||
|
export const API_URL = "http://10.0.2.2:8080";
|
||||||
|
|
||||||
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
|
// Para celular físico (mismo Wi-Fi o hotspot):
|
||||||
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
|
// export const API_URL = "http://192.168.X.X:8080";
|
||||||
|
```
|
||||||
|
|
||||||
|
| Entorno | URL |
|
||||||
|
|---|---|
|
||||||
|
| Android Studio emulator | `http://10.0.2.2:8080` |
|
||||||
|
| iOS Simulator | `http://localhost:8080` |
|
||||||
|
| Celular físico Android/iOS (mismo Wi-Fi) | `http://<IP-LAN>:8080` |
|
||||||
|
| Celular físico (hotspot) | `http://<IP-LAN-hotspot>:8080` |
|
||||||
|
| Web | `http://localhost:8080` |
|
||||||
|
|
||||||
|
> Si estás en red de escuela/oficina con AP isolation, usa hotspot del celular.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pantallas
|
||||||
|
|
||||||
|
| Ruta | Archivo | Rol | Descripción |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/` | `index.tsx` | USER | Home: título + hero del camión + card ETA + banner estado de ruta + banner preventivo + 4 acciones rápidas + consejo |
|
||||||
|
| `/login` | `login.tsx` | público | Login con email + password |
|
||||||
|
| `/register` | `register.tsx` | público | Registro con nombre, email, password ≥ 6 caracteres |
|
||||||
|
| `/addresses` | `addresses.tsx` | USER | Lista de colonias + selector + guardar (PUT /me) |
|
||||||
|
| `/alerts` | `alerts.tsx` | USER | Hero + estado de ruta + timeline vertical con dots de color por severidad |
|
||||||
|
| `/guide` | `guide.tsx` | USER | Calendario semanal + 4 cards (Orgánicos, Reciclables, Sanitarios, Especiales) + Recuerda |
|
||||||
|
| `/feedback` | `feedback.tsx` | USER | Tipo de mensaje (4 opciones) + estrellas si es rating + textarea |
|
||||||
|
| `/profile` | `profile.tsx` | USER | Avatar circular + cards Tu zona/Ruta + opciones (Mi domicilio, Buzón, Reiniciar demo, Ayuda, Cerrar sesión) + Tu impacto |
|
||||||
|
| `/admin` | `admin.tsx` | ADMIN | Reportes del día + lista de rutas + cancelar/reanudar |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estado global (AppContext)
|
||||||
|
|
||||||
|
`src/context/AppContext.tsx`
|
||||||
|
|
||||||
|
Proveedor único que expone:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface AppContextValue {
|
||||||
|
user: AuthUser | null; // { id, name, email, role }
|
||||||
|
eta: EtaResult | null;
|
||||||
|
notifications: InboxNotification[];
|
||||||
|
route: RouteState | null;
|
||||||
|
loading: boolean;
|
||||||
|
toast: InboxNotification | null;
|
||||||
|
dismissToast: () => void;
|
||||||
|
login: (email, password) => Promise<void>;
|
||||||
|
register: (name, email, password) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
refreshStatus: () => Promise<void>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Polling
|
||||||
|
|
||||||
|
- Arranca cuando `user` ≠ null Y `user.role` ≠ "ADMIN"
|
||||||
|
- Cada **30 segundos** llama `GET /api/tracking/status`
|
||||||
|
- Limpia el interval cuando el user cierra sesión
|
||||||
|
- Detecta notificaciones nuevas (vía Set de IDs ya vistos) y dispara el `toast`
|
||||||
|
|
||||||
|
### Logout
|
||||||
|
- Borra token con `setAuthToken(null)`
|
||||||
|
- Resetea user, eta, route, notifications, toast
|
||||||
|
- Limpia el Set de IDs ya vistos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comunicación con el backend
|
||||||
|
|
||||||
|
### `lib/api.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
let authToken: string | null = null;
|
||||||
|
|
||||||
|
export const setAuthToken = (token: string | null) => { authToken = token; };
|
||||||
|
|
||||||
|
export const apiFetch = async <T>(path: string, options: RequestInit = {}): Promise<T> => {
|
||||||
|
const headers = { "Content-Type": "application/json", ...options.headers };
|
||||||
|
if (authToken) headers.Authorization = `Bearer ${authToken}`;
|
||||||
|
const res = await fetch(`${API_URL}${path}`, { ...options, headers });
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||||
|
return body as T;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
El token se setea automáticamente tras `login` o `register` (dentro de `auth.service.ts`).
|
||||||
|
|
||||||
|
### Services (capa fina sobre apiFetch)
|
||||||
|
|
||||||
|
- **auth.service.ts** — register, login, getMe
|
||||||
|
- **tracking.service.ts** — getMyStatus, sendGpsUpdate, resetDemo
|
||||||
|
- **addresses.service.ts** — listColonias, getMyAddress, setMyAddress
|
||||||
|
- **feedback.service.ts** — submitFeedback, listMyFeedback
|
||||||
|
- **admin.service.ts** — listAllRoutes, cancelRoute, resumeRoute
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Componentes reutilizables
|
||||||
|
|
||||||
|
| Componente | Para qué |
|
||||||
|
|---|---|
|
||||||
|
| `EtaCard` | Card grande del ETA con "X min" gigante + ventana |
|
||||||
|
| `AlertItem` (`Alertltem.tsx`) | Item de notificación con icon + título + body + tiempo + badge por tipo |
|
||||||
|
| `InputField` | Wrap de TextInput con estilos del proyecto |
|
||||||
|
| `PrimaryButton` | Botón principal verde |
|
||||||
|
| `QuickAction` | Card cuadrada con icon circular + título (usada en home) |
|
||||||
|
| `SectionTitle` | Título grande de sección |
|
||||||
|
| `StatusBadge` | (no usado actualmente) |
|
||||||
|
| `CollectionCalendar` | Calendario mensual con colores por día según ruta del user |
|
||||||
|
| `NotificationToast` | Banner verde animado que cae cuando llega una notif nueva |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roles y navegación
|
||||||
|
|
||||||
|
### USER
|
||||||
|
- Ve las tabs: Inicio, Alertas, Guía, Perfil
|
||||||
|
- Polling activo
|
||||||
|
- Al loguearse, lo redirige a `/` (Home)
|
||||||
|
- Si entra a `/` sin login → redirect a `/login`
|
||||||
|
- Si entra a una pantalla protegida sin domicilio validado → algunas muestran CTA "Validar domicilio"
|
||||||
|
|
||||||
|
### ADMIN
|
||||||
|
- Ve solo la tab: **Admin**
|
||||||
|
- Las demás tabs del user están ocultas (`href: null`)
|
||||||
|
- NO hace polling de `/status`
|
||||||
|
- Al loguearse desde `/login` aterriza en `/` y desde ahí redirect a `/admin`
|
||||||
|
- Sus acciones (cancelar / reanudar) refrescan el listado cada 15 s
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notificaciones
|
||||||
|
|
||||||
|
### En foreground (Toast in-app)
|
||||||
|
- Cuando llega una notificación nueva del polling, se setea `toast` en el AppContext
|
||||||
|
- El componente `NotificationToast` anima un banner verde que cae desde arriba
|
||||||
|
- Se autodescarta a los 5 segundos
|
||||||
|
- Funciona en cualquier pantalla porque vive en `_layout.tsx`
|
||||||
|
|
||||||
|
### Push del SO con app cerrada
|
||||||
|
- **No funciona en Expo Go SDK 53+** — Expo removió el soporte
|
||||||
|
- El módulo `expo-notifications` está instalado y el helper `lib/notifications.ts` está listo
|
||||||
|
- Para activarlo en producción → crear Development Build con EAS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Síntoma | Causa | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| `Network request failed` | `API_URL` apunta a dirección inalcanzable desde el dispositivo | Usa `10.0.2.2:8080` (emulador) o IP LAN (físico) |
|
||||||
|
| `Failed to fetch` en navegador web | CORS | Activar `cors()` middleware en el backend |
|
||||||
|
| 401 al cargar `/status` | Token vencido o no enviado | Logout + login otra vez |
|
||||||
|
| "Cannot find module ../config/api.js" | Importaste con `.js` (estilo backend) | En frontend, importa sin extensión: `from "../config/api"` |
|
||||||
|
| `Unable to resolve module` | Metro caché stale | `npx expo start -c` |
|
||||||
|
| App muestra "12 min / En Camino" hardcoded | Te perdiste de actualizar `EtaCard` con props del backend | Verificar que home pasa `minutes` y `status` |
|
||||||
|
| Versión de SDK incompatible | Expo Go diferente al del proyecto (56) | Actualizar Expo Go en App Store / Play Store |
|
||||||
|
| Tab Admin no aparece | Login devolvió role ≠ "ADMIN" | Verifica logueado con `admin@test.com / admin123` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Para extender
|
||||||
|
|
||||||
|
- **Nueva pantalla**: crea archivo en `src/app/X.tsx`. expo-router la detecta. Si no quieres que aparezca en el tab bar, agrégala con `options={{ href: null }}` en `_layout.tsx`.
|
||||||
|
- **Nuevo service**: crea `services/X.service.ts` que use `apiFetch`. Importa donde lo necesites.
|
||||||
|
- **Nuevo dato global**: agrega al `AppContext` y expón en el value.
|
||||||
|
- **Imágenes nuevas**: ponlas en `assets/illustrations/` y úsalas con `require()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frase de arquitectura para la exposición
|
||||||
|
|
||||||
|
> "El frontend hace polling controlado cada 30 s al endpoint `/api/tracking/status`. Esto evita sobrecargar el servidor y es predecible. El AppContext detecta notificaciones nuevas con un Set de IDs ya vistos y dispara un Toast in-app. Para push del SO con app cerrada migraríamos a Development Build y expo-notifications real, pero la decisión de Expo Go nos limita en esta etapa."
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"expo": {
|
"expo": {
|
||||||
"name": "frontend",
|
"name": "OptiRuta",
|
||||||
"slug": "frontend",
|
"slug": "optiruta",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
|
|||||||
BIN
frontend/assets/illustrations/avatar-ana.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
frontend/assets/illustrations/battery-meds.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
frontend/assets/illustrations/bins-cute.png
Normal file
|
After Width: | Height: | Size: 598 KiB |
BIN
frontend/assets/illustrations/eco-bins.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
frontend/assets/illustrations/garbage-truck.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
frontend/assets/illustrations/impact-leaf.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
frontend/assets/illustrations/organic-food.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
frontend/assets/illustrations/pet-bottles.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
frontend/assets/illustrations/recycling.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
frontend/assets/illustrations/sanitary.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
frontend/assets/illustrations/trees-banner.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/assets/illustrations/truck-banner.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
frontend/assets/illustrations/truck-city.png
Normal file
|
After Width: | Height: | Size: 964 KiB |
34
frontend/package-lock.json
generated
@@ -17,6 +17,7 @@
|
|||||||
"expo-glass-effect": "~56.0.4",
|
"expo-glass-effect": "~56.0.4",
|
||||||
"expo-image": "~56.0.8",
|
"expo-image": "~56.0.8",
|
||||||
"expo-linking": "~56.0.11",
|
"expo-linking": "~56.0.11",
|
||||||
|
"expo-notifications": "~56.0.12",
|
||||||
"expo-router": "~56.2.5",
|
"expo-router": "~56.2.5",
|
||||||
"expo-splash-screen": "~56.0.9",
|
"expo-splash-screen": "~56.0.9",
|
||||||
"expo-status-bar": "~56.0.4",
|
"expo-status-bar": "~56.0.4",
|
||||||
@@ -3094,6 +3095,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/badgin": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
@@ -3836,6 +3843,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-application": {
|
||||||
|
"version": "56.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-56.0.3.tgz",
|
||||||
|
"integrity": "sha512-DdGGPlMuM6cSTeKhbvh6OeLr2O/+EI5BHKYrD+Do8sJPYgLwzGrgESELfyjJCpEhFzT+TgKIdmLmWXhNUQnHiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-asset": {
|
"node_modules/expo-asset": {
|
||||||
"version": "56.0.13",
|
"version": "56.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-56.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-56.0.13.tgz",
|
||||||
@@ -4000,6 +4016,24 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-notifications": {
|
||||||
|
"version": "56.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-56.0.12.tgz",
|
||||||
|
"integrity": "sha512-ZGFeA6vs1dt+9IcFtriIf2sEgBSEXGZ6OnWIYzUkdYqKpJFv1/zigUyquAMEvGbAAjGC0Uwf8qXNYJc1pyxFfA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/image-utils": "^0.10.0",
|
||||||
|
"abort-controller": "^3.0.0",
|
||||||
|
"badgin": "^1.1.5",
|
||||||
|
"expo-application": "~56.0.3",
|
||||||
|
"expo-constants": "~56.0.14"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-router": {
|
"node_modules/expo-router": {
|
||||||
"version": "56.2.5",
|
"version": "56.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-56.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-56.2.5.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"expo-glass-effect": "~56.0.4",
|
"expo-glass-effect": "~56.0.4",
|
||||||
"expo-image": "~56.0.8",
|
"expo-image": "~56.0.8",
|
||||||
"expo-linking": "~56.0.11",
|
"expo-linking": "~56.0.11",
|
||||||
|
"expo-notifications": "~56.0.12",
|
||||||
"expo-router": "~56.2.5",
|
"expo-router": "~56.2.5",
|
||||||
"expo-splash-screen": "~56.0.9",
|
"expo-splash-screen": "~56.0.9",
|
||||||
"expo-status-bar": "~56.0.4",
|
"expo-status-bar": "~56.0.4",
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
|
import { View } from "react-native";
|
||||||
import { Tabs } from "expo-router";
|
import { Tabs } from "expo-router";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { AppProvider, useApp } from "../context/AppContext";
|
||||||
|
import NotificationToast from "../components/NotificationToast";
|
||||||
|
|
||||||
|
function AppTabs() {
|
||||||
|
const { user } = useApp();
|
||||||
|
const isAdmin = user?.role === "ADMIN";
|
||||||
|
|
||||||
export default function Layout() {
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
@@ -15,26 +21,15 @@ export default function Layout() {
|
|||||||
bottom: 5,
|
bottom: 5,
|
||||||
left: 20,
|
left: 20,
|
||||||
right: 20,
|
right: 20,
|
||||||
|
|
||||||
height: 82,
|
height: 82,
|
||||||
|
|
||||||
borderRadius: 25,
|
borderRadius: 25,
|
||||||
|
|
||||||
backgroundColor: "#FFFFFF",
|
backgroundColor: "#FFFFFF",
|
||||||
|
|
||||||
borderTopWidth: 0,
|
borderTopWidth: 0,
|
||||||
|
|
||||||
shadowColor: "#000",
|
shadowColor: "#000",
|
||||||
shadowOffset: {
|
shadowOffset: { width: 0, height: 10 },
|
||||||
width: 0,
|
|
||||||
height: 10,
|
|
||||||
},
|
|
||||||
|
|
||||||
shadowOpacity: 0.08,
|
shadowOpacity: 0.08,
|
||||||
shadowRadius: 10,
|
shadowRadius: 10,
|
||||||
|
|
||||||
elevation: 10,
|
elevation: 10,
|
||||||
|
|
||||||
paddingHorizontal: 10,
|
paddingHorizontal: 10,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -53,10 +48,12 @@ export default function Layout() {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Tabs de USER — escondidas para ADMIN */}
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="index"
|
name="index"
|
||||||
options={{
|
options={{
|
||||||
title: "Inicio",
|
title: "Inicio",
|
||||||
|
href: isAdmin ? null : undefined,
|
||||||
tabBarIcon: ({ focused, color }) => (
|
tabBarIcon: ({ focused, color }) => (
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={focused ? "home" : "home-outline"}
|
name={focused ? "home" : "home-outline"}
|
||||||
@@ -71,6 +68,7 @@ export default function Layout() {
|
|||||||
name="alerts"
|
name="alerts"
|
||||||
options={{
|
options={{
|
||||||
title: "Alertas",
|
title: "Alertas",
|
||||||
|
href: isAdmin ? null : undefined,
|
||||||
tabBarIcon: ({ focused, color }) => (
|
tabBarIcon: ({ focused, color }) => (
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={focused ? "notifications" : "notifications-outline"}
|
name={focused ? "notifications" : "notifications-outline"}
|
||||||
@@ -85,6 +83,7 @@ export default function Layout() {
|
|||||||
name="guide"
|
name="guide"
|
||||||
options={{
|
options={{
|
||||||
title: "Guía",
|
title: "Guía",
|
||||||
|
href: isAdmin ? null : undefined,
|
||||||
tabBarIcon: ({ focused, color }) => (
|
tabBarIcon: ({ focused, color }) => (
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={focused ? "leaf" : "leaf-outline"}
|
name={focused ? "leaf" : "leaf-outline"}
|
||||||
@@ -99,6 +98,7 @@ export default function Layout() {
|
|||||||
name="profile"
|
name="profile"
|
||||||
options={{
|
options={{
|
||||||
title: "Perfil",
|
title: "Perfil",
|
||||||
|
href: isAdmin ? null : undefined,
|
||||||
tabBarIcon: ({ focused, color }) => (
|
tabBarIcon: ({ focused, color }) => (
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={focused ? "person" : "person-outline"}
|
name={focused ? "person" : "person-outline"}
|
||||||
@@ -108,6 +108,39 @@ export default function Layout() {
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Tab de ADMIN — solo visible si el user logueado es admin */}
|
||||||
|
<Tabs.Screen
|
||||||
|
name="admin"
|
||||||
|
options={{
|
||||||
|
title: "Admin",
|
||||||
|
href: isAdmin ? undefined : null,
|
||||||
|
tabBarIcon: ({ focused, color }) => (
|
||||||
|
<Ionicons
|
||||||
|
name={focused ? "shield-checkmark" : "shield-checkmark-outline"}
|
||||||
|
size={focused ? 28 : 24}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Pantallas siempre escondidas del tab bar */}
|
||||||
|
<Tabs.Screen name="login" options={{ href: null }} />
|
||||||
|
<Tabs.Screen name="register" options={{ href: null }} />
|
||||||
|
<Tabs.Screen name="feedback" options={{ href: null }} />
|
||||||
|
<Tabs.Screen name="addresses" options={{ href: null }} />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<AppProvider>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<NotificationToast />
|
||||||
|
<AppTabs />
|
||||||
|
</View>
|
||||||
|
</AppProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
196
frontend/src/app/addresses.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
ScrollView,
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
Pressable,
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
} from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Redirect, useRouter } from "expo-router";
|
||||||
|
|
||||||
|
import { COLORS } from "../constants/colors";
|
||||||
|
import SectionTitle from "../components/SectionTitle";
|
||||||
|
import PrimaryButton from "../components/PrimaryButton";
|
||||||
|
|
||||||
|
import { useApp } from "../context/AppContext";
|
||||||
|
import {
|
||||||
|
listColonias,
|
||||||
|
getMyAddress,
|
||||||
|
setMyAddress,
|
||||||
|
type Colonia,
|
||||||
|
type MyAddress,
|
||||||
|
} from "../services/addresses.service";
|
||||||
|
|
||||||
|
export default function AddressesScreen() {
|
||||||
|
const { user, refreshStatus } = useApp();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [colonias, setColonias] = useState<Colonia[]>([]);
|
||||||
|
const [myAddress, setMy] = useState<MyAddress | null>(null);
|
||||||
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const [c, mine] = await Promise.all([listColonias(), getMyAddress()]);
|
||||||
|
setColonias(c);
|
||||||
|
setMy(mine);
|
||||||
|
setSelected(mine.colonia);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
if (!user) return <Redirect href="/login" />;
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!selected) {
|
||||||
|
Alert.alert("Selecciona una colonia.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
const result = await setMyAddress(selected);
|
||||||
|
setMy(result);
|
||||||
|
await refreshStatus();
|
||||||
|
Alert.alert(
|
||||||
|
"Domicilio validado",
|
||||||
|
`Tu zona es ${result.colonia}. Horario: ${result.horarioEstimado}`,
|
||||||
|
);
|
||||||
|
router.replace("/");
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert(
|
||||||
|
"No se pudo guardar",
|
||||||
|
err instanceof Error ? err.message : "Error desconocido",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: COLORS.background }}>
|
||||||
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ padding: 20, paddingBottom: 120 }}
|
||||||
|
>
|
||||||
|
<SectionTitle title="Mi domicilio" />
|
||||||
|
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
Selecciona la colonia donde quieres recibir avisos de recolección.
|
||||||
|
Solo verás la ruta que cubre tu zona.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{myAddress && (
|
||||||
|
<View style={styles.currentBox}>
|
||||||
|
<Text style={styles.currentLabel}>Domicilio actual</Text>
|
||||||
|
<Text style={styles.currentValue}>{myAddress.colonia}</Text>
|
||||||
|
<Text style={styles.currentMeta}>
|
||||||
|
Ruta {myAddress.routeId}
|
||||||
|
{myAddress.horarioEstimado
|
||||||
|
? ` • ${myAddress.horarioEstimado}`
|
||||||
|
: ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={styles.label}>Colonias disponibles</Text>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="#0E8A61" style={{ marginTop: 20 }} />
|
||||||
|
) : (
|
||||||
|
colonias.map((c) => {
|
||||||
|
const active = selected === c.colonia;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={c.colonia}
|
||||||
|
onPress={() => setSelected(c.colonia)}
|
||||||
|
style={[
|
||||||
|
styles.optionRow,
|
||||||
|
active && {
|
||||||
|
borderColor: "#0E8A61",
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={active ? "radio-button-on" : "radio-button-off"}
|
||||||
|
size={22}
|
||||||
|
color={active ? "#0E8A61" : "#9CA3AF"}
|
||||||
|
/>
|
||||||
|
<View style={{ marginLeft: 12, flex: 1 }}>
|
||||||
|
<Text style={styles.optionTitle}>{c.colonia}</Text>
|
||||||
|
<Text style={styles.optionMeta}>
|
||||||
|
Ruta {c.routeId} • {c.horarioEstimado}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={{ height: 16 }} />
|
||||||
|
<PrimaryButton
|
||||||
|
title={saving ? "Guardando..." : "Validar domicilio"}
|
||||||
|
onPress={handleSave}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
subtitle: {
|
||||||
|
color: "#6B7280",
|
||||||
|
fontSize: 14,
|
||||||
|
marginBottom: 16,
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
|
currentBox: {
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 18,
|
||||||
|
marginHorizontal: 2,
|
||||||
|
borderLeftWidth: 4,
|
||||||
|
borderLeftColor: "#0E8A61",
|
||||||
|
},
|
||||||
|
currentLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#6B7280",
|
||||||
|
fontWeight: "700",
|
||||||
|
marginBottom: 4,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
},
|
||||||
|
currentValue: { fontSize: 18, fontWeight: "800", color: "#1F2937" },
|
||||||
|
currentMeta: { fontSize: 13, color: "#6B7280", marginTop: 4 },
|
||||||
|
label: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#374151",
|
||||||
|
marginBottom: 10,
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
|
optionRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 14,
|
||||||
|
marginBottom: 10,
|
||||||
|
borderColor: "transparent",
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
optionTitle: { fontSize: 15, fontWeight: "700", color: "#1F2937" },
|
||||||
|
optionMeta: { fontSize: 12, color: "#6B7280", marginTop: 2 },
|
||||||
|
});
|
||||||
572
frontend/src/app/admin.tsx
Normal file
@@ -0,0 +1,572 @@
|
|||||||
|
/**
|
||||||
|
* admin.tsx
|
||||||
|
*
|
||||||
|
* Panel de administración: lista las rutas y permite cancelarlas /
|
||||||
|
* reanudarlas. Solo accesible para usuarios con role=ADMIN.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
ScrollView,
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
Alert,
|
||||||
|
ActivityIndicator,
|
||||||
|
RefreshControl,
|
||||||
|
} from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Redirect } from "expo-router";
|
||||||
|
|
||||||
|
import { COLORS } from "../constants/colors";
|
||||||
|
import { useApp } from "../context/AppContext";
|
||||||
|
import {
|
||||||
|
cancelRoute,
|
||||||
|
listAllFeedback,
|
||||||
|
listAllRoutes,
|
||||||
|
resumeRoute,
|
||||||
|
type AdminFeedbackItem,
|
||||||
|
type AdminRouteItem,
|
||||||
|
type FeedbackType,
|
||||||
|
} from "../services/admin.service";
|
||||||
|
|
||||||
|
const arrivalLabel: Record<string, { label: string; color: string }> = {
|
||||||
|
PENDING: { label: "Pendiente", color: "#9CA3AF" },
|
||||||
|
ARRIVED: { label: "Llegó", color: "#22C55E" },
|
||||||
|
FAILED: { label: "Falla", color: "#EF4444" },
|
||||||
|
CANCELLED: { label: "Cancelada", color: "#F59E0B" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminScreen() {
|
||||||
|
const { user, logout } = useApp();
|
||||||
|
const [routes, setRoutes] = useState<AdminRouteItem[]>([]);
|
||||||
|
const [feedback, setFeedback] = useState<AdminFeedbackItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchAll = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [routesData, feedbackData] = await Promise.all([
|
||||||
|
listAllRoutes(),
|
||||||
|
listAllFeedback(),
|
||||||
|
]);
|
||||||
|
setRoutes(routesData);
|
||||||
|
setFeedback(feedbackData);
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert(
|
||||||
|
"Error",
|
||||||
|
err instanceof Error ? err.message : "No se pudieron cargar los datos",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Alias para compatibilidad con el resto del archivo
|
||||||
|
const fetchRoutes = fetchAll;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || user.role !== "ADMIN") return;
|
||||||
|
void fetchAll();
|
||||||
|
const interval = setInterval(() => void fetchAll(), 15_000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [user, fetchAll]);
|
||||||
|
|
||||||
|
if (!user) return <Redirect href="/login" />;
|
||||||
|
if (user.role !== "ADMIN") return <Redirect href="/" />;
|
||||||
|
|
||||||
|
const handleCancel = (routeId: string) => {
|
||||||
|
Alert.alert(
|
||||||
|
"Cancelar ruta",
|
||||||
|
`¿Cancelar ${routeId}? Los usuarios suscritos serán notificados.`,
|
||||||
|
[
|
||||||
|
{ text: "No", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Sí, cancelar",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await cancelRoute(routeId, "Cancelada por administración");
|
||||||
|
void fetchRoutes();
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert(
|
||||||
|
"Error",
|
||||||
|
err instanceof Error ? err.message : "No se pudo cancelar",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResume = (routeId: string) => {
|
||||||
|
Alert.alert("Reanudar ruta", `¿Reactivar ${routeId}?`, [
|
||||||
|
{ text: "No", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Sí",
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await resumeRoute(routeId);
|
||||||
|
void fetchRoutes();
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert(
|
||||||
|
"Error",
|
||||||
|
err instanceof Error ? err.message : "No se pudo reanudar",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView
|
||||||
|
style={{ flex: 1, backgroundColor: COLORS.background }}
|
||||||
|
edges={["top"]}
|
||||||
|
>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.title}>Panel de Administración</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
{user.email} · {routes.length} rutas
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable onPress={logout} style={styles.logoutBtn} hitSlop={10}>
|
||||||
|
<Ionicons name="log-out-outline" size={22} color="#EF4444" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="#0E8A61" style={{ marginTop: 40 }} />
|
||||||
|
) : (
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: 130 }}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={loading} onRefresh={fetchRoutes} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* === Reportes del día === */}
|
||||||
|
<Text style={styles.sectionTitle}>REPORTES DEL DÍA</Text>
|
||||||
|
<View style={styles.reportsGrid}>
|
||||||
|
{(() => {
|
||||||
|
const arrived = routes.filter(
|
||||||
|
(r) => r.arrivalResult === "ARRIVED",
|
||||||
|
).length;
|
||||||
|
const failed = routes.filter(
|
||||||
|
(r) => r.arrivalResult === "FAILED",
|
||||||
|
).length;
|
||||||
|
const cancelled = routes.filter(
|
||||||
|
(r) => r.cancelled || r.arrivalResult === "CANCELLED",
|
||||||
|
).length;
|
||||||
|
const pending = routes.filter(
|
||||||
|
(r) => r.arrivalResult === "PENDING" && !r.cancelled,
|
||||||
|
).length;
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
key: "arrived",
|
||||||
|
label: "Llegaron",
|
||||||
|
value: arrived,
|
||||||
|
icon: "checkmark-circle" as const,
|
||||||
|
color: "#22C55E",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "pending",
|
||||||
|
label: "En curso",
|
||||||
|
value: pending,
|
||||||
|
icon: "time-outline" as const,
|
||||||
|
color: "#3B82F6",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "failed",
|
||||||
|
label: "Con falla",
|
||||||
|
value: failed,
|
||||||
|
icon: "warning" as const,
|
||||||
|
color: "#EF4444",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "cancelled",
|
||||||
|
label: "Canceladas",
|
||||||
|
value: cancelled,
|
||||||
|
icon: "close-circle" as const,
|
||||||
|
color: "#F59E0B",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return items.map((it) => (
|
||||||
|
<View key={it.key} style={styles.reportCard}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.reportIcon,
|
||||||
|
{ backgroundColor: `${it.color}20` },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons name={it.icon} size={22} color={it.color} />
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.reportValue, { color: it.color }]}>
|
||||||
|
{it.value}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.reportLabel}>{it.label}</Text>
|
||||||
|
</View>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* === Lista de rutas === */}
|
||||||
|
<Text style={styles.sectionTitle}>RUTAS</Text>
|
||||||
|
{routes.map((r) => {
|
||||||
|
const arrival = arrivalLabel[r.arrivalResult] ?? arrivalLabel.PENDING;
|
||||||
|
return (
|
||||||
|
<View key={r.routeId} style={styles.card}>
|
||||||
|
<View style={styles.cardHeader}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.routeId}>{r.routeId}</Text>
|
||||||
|
<Text style={styles.routeName}>{r.name}</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.badge,
|
||||||
|
{ backgroundColor: `${arrival.color}25` },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[styles.badgeText, { color: arrival.color }]}
|
||||||
|
>
|
||||||
|
{arrival.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.metaRow}>
|
||||||
|
<Text style={styles.metaText}>
|
||||||
|
Camión #{r.truckId} · Posición {r.currentPositionId}/8 ·{" "}
|
||||||
|
{r.status}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{r.cancelled ? (
|
||||||
|
<Pressable
|
||||||
|
style={[styles.actionBtn, { backgroundColor: "#22C55E" }]}
|
||||||
|
onPress={() => handleResume(r.routeId)}
|
||||||
|
>
|
||||||
|
<Ionicons name="play" size={16} color="#FFFFFF" />
|
||||||
|
<Text style={styles.actionText}>Reanudar</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<Pressable
|
||||||
|
style={[styles.actionBtn, { backgroundColor: "#EF4444" }]}
|
||||||
|
onPress={() => handleCancel(r.routeId)}
|
||||||
|
>
|
||||||
|
<Ionicons name="close-circle" size={16} color="#FFFFFF" />
|
||||||
|
<Text style={styles.actionText}>Cancelar ruta</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{r.cancelReason ? (
|
||||||
|
<Text style={styles.reasonText}>
|
||||||
|
Motivo: {r.cancelReason}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* === Reportes de ciudadanos === */}
|
||||||
|
<Text style={[styles.sectionTitle, { marginTop: 14 }]}>
|
||||||
|
REPORTES DE CIUDADANOS ({feedback.length})
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{feedback.length === 0 ? (
|
||||||
|
<View style={styles.emptyFeedback}>
|
||||||
|
<Ionicons name="chatbubbles-outline" size={28} color="#9CA3AF" />
|
||||||
|
<Text style={styles.emptyFeedbackText}>
|
||||||
|
Aún no hay reportes ciudadanos. Aparecerán aquí cuando los
|
||||||
|
usuarios usen el buzón.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
feedback.map((f) => {
|
||||||
|
const meta = feedbackMeta[f.type] ?? feedbackMeta.OTHER;
|
||||||
|
const when = new Date(f.createdAt).toLocaleString("es-MX", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<View key={f.id} style={styles.feedbackCard}>
|
||||||
|
<View style={styles.feedbackHeader}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.feedbackIcon,
|
||||||
|
{ backgroundColor: `${meta.color}20` },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={meta.icon}
|
||||||
|
size={18}
|
||||||
|
color={meta.color}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.feedbackType}>{meta.label}</Text>
|
||||||
|
<Text style={styles.feedbackMeta}>
|
||||||
|
{f.userName ?? `Usuario #${f.userId}`}
|
||||||
|
{f.colonia ? ` · ${f.colonia}` : ""} · {when}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{f.routeId ? (
|
||||||
|
<View style={styles.routeChip}>
|
||||||
|
<Ionicons
|
||||||
|
name="bus-outline"
|
||||||
|
size={11}
|
||||||
|
color="#0E8A61"
|
||||||
|
/>
|
||||||
|
<Text style={styles.routeChipText}>{f.routeId}</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.routeChip,
|
||||||
|
{ backgroundColor: "#F3F4F6" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[styles.routeChipText, { color: "#9CA3AF" }]}
|
||||||
|
>
|
||||||
|
Sin ruta
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{f.rating ? (
|
||||||
|
<View style={styles.starsRow}>
|
||||||
|
{[1, 2, 3, 4, 5].map((n) => (
|
||||||
|
<Ionicons
|
||||||
|
key={n}
|
||||||
|
name={n <= (f.rating ?? 0) ? "star" : "star-outline"}
|
||||||
|
size={14}
|
||||||
|
color="#F59E0B"
|
||||||
|
style={{ marginRight: 2 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Text style={styles.feedbackMessage}>{f.message}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedbackMeta: Record<
|
||||||
|
FeedbackType,
|
||||||
|
{ label: string; icon: keyof typeof Ionicons.glyphMap; color: string }
|
||||||
|
> = {
|
||||||
|
TRUCK_DID_NOT_PASS: {
|
||||||
|
label: "El camión no pasó",
|
||||||
|
icon: "alert-circle-outline",
|
||||||
|
color: "#EF4444",
|
||||||
|
},
|
||||||
|
RATING: {
|
||||||
|
label: "Calificación del servicio",
|
||||||
|
icon: "star-outline",
|
||||||
|
color: "#F59E0B",
|
||||||
|
},
|
||||||
|
SUGGESTION: {
|
||||||
|
label: "Sugerencia",
|
||||||
|
icon: "bulb-outline",
|
||||||
|
color: "#3B82F6",
|
||||||
|
},
|
||||||
|
OTHER: {
|
||||||
|
label: "Otro",
|
||||||
|
icon: "chatbubble-outline",
|
||||||
|
color: "#6B7280",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 20,
|
||||||
|
paddingBottom: 12,
|
||||||
|
},
|
||||||
|
title: { fontSize: 24, fontWeight: "800", color: "#0F172A" },
|
||||||
|
subtitle: { fontSize: 12, color: "#6B7280", marginTop: 2 },
|
||||||
|
logoutBtn: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: "#FEF2F2",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 14,
|
||||||
|
marginBottom: 12,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 6,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
cardHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
routeId: { fontSize: 16, fontWeight: "800", color: "#0F172A" },
|
||||||
|
routeName: { fontSize: 12, color: "#6B7280", marginTop: 2 },
|
||||||
|
badge: {
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
badgeText: { fontSize: 11, fontWeight: "700" },
|
||||||
|
metaRow: { marginBottom: 12 },
|
||||||
|
metaText: { fontSize: 12, color: "#374151" },
|
||||||
|
actionBtn: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
actionText: {
|
||||||
|
color: "#FFFFFF",
|
||||||
|
fontWeight: "700",
|
||||||
|
fontSize: 13,
|
||||||
|
marginLeft: 6,
|
||||||
|
},
|
||||||
|
reasonText: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#F59E0B",
|
||||||
|
marginTop: 8,
|
||||||
|
fontStyle: "italic",
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: "800",
|
||||||
|
color: "#6B7280",
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginBottom: 10,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
reportsGrid: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 18,
|
||||||
|
},
|
||||||
|
reportCard: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 10,
|
||||||
|
marginHorizontal: 3,
|
||||||
|
alignItems: "center",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
reportIcon: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
reportValue: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "800",
|
||||||
|
},
|
||||||
|
reportLabel: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#374151",
|
||||||
|
marginTop: 2,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
emptyFeedback: {
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 20,
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
emptyFeedbackText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#6B7280",
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
feedbackCard: {
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 10,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.04,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 1,
|
||||||
|
},
|
||||||
|
feedbackHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
feedbackIcon: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginRight: 10,
|
||||||
|
},
|
||||||
|
feedbackType: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "800",
|
||||||
|
color: "#0F172A",
|
||||||
|
},
|
||||||
|
feedbackMeta: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: "#6B7280",
|
||||||
|
marginTop: 1,
|
||||||
|
},
|
||||||
|
routeChip: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#ECFDF5",
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 999,
|
||||||
|
marginLeft: 6,
|
||||||
|
},
|
||||||
|
routeChipText: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: "800",
|
||||||
|
color: "#0E8A61",
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
starsRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
feedbackMessage: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#374151",
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,15 +1,392 @@
|
|||||||
import { View, Text } from "react-native";
|
import { ScrollView, View, Text, StyleSheet, Image } from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Redirect } from "expo-router";
|
||||||
|
|
||||||
|
import { COLORS } from "../constants/colors";
|
||||||
|
import { useApp } from "../context/AppContext";
|
||||||
|
import type { NotificationType } from "../services/tracking.service";
|
||||||
|
|
||||||
|
type Severity = "info" | "attention" | "important" | "critical";
|
||||||
|
|
||||||
|
const meta: Record<
|
||||||
|
NotificationType,
|
||||||
|
{
|
||||||
|
severity: Severity;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
color: string;
|
||||||
|
bg: string;
|
||||||
|
badge: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
ROUTE_START: {
|
||||||
|
severity: "info",
|
||||||
|
icon: "navigate",
|
||||||
|
color: "#3B82F6",
|
||||||
|
bg: "#EFF6FF",
|
||||||
|
badge: "Informativo",
|
||||||
|
},
|
||||||
|
TRUCK_PROXIMITY: {
|
||||||
|
severity: "attention",
|
||||||
|
icon: "time-outline",
|
||||||
|
color: "#F59E0B",
|
||||||
|
bg: "#FEF3C7",
|
||||||
|
badge: "Atención",
|
||||||
|
},
|
||||||
|
TRUCK_ARRIVED: {
|
||||||
|
severity: "important",
|
||||||
|
icon: "notifications",
|
||||||
|
color: "#22C55E",
|
||||||
|
bg: "#ECFDF5",
|
||||||
|
badge: "Importante",
|
||||||
|
},
|
||||||
|
ROUTE_COMPLETED: {
|
||||||
|
severity: "info",
|
||||||
|
icon: "checkmark-done",
|
||||||
|
color: "#6B7280",
|
||||||
|
bg: "#F3F4F6",
|
||||||
|
badge: "Informativo",
|
||||||
|
},
|
||||||
|
DELAY: {
|
||||||
|
severity: "critical",
|
||||||
|
icon: "warning-outline",
|
||||||
|
color: "#EF4444",
|
||||||
|
bg: "#FEF2F2",
|
||||||
|
badge: "Crítico",
|
||||||
|
},
|
||||||
|
MECHANICAL_FAILURE: {
|
||||||
|
severity: "critical",
|
||||||
|
icon: "warning",
|
||||||
|
color: "#EF4444",
|
||||||
|
bg: "#FEF2F2",
|
||||||
|
badge: "Crítico",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<string, { label: string; color: string }> = {
|
||||||
|
EN_RUTA: { label: "ACTIVA", color: "#22C55E" },
|
||||||
|
DETENIDO: { label: "DETENIDO", color: "#F59E0B" },
|
||||||
|
FALLA: { label: "FALLA", color: "#EF4444" },
|
||||||
|
FINALIZADO: { label: "FINALIZADO", color: "#6B7280" },
|
||||||
|
ESPERA: { label: "EN ESPERA", color: "#6B7280" },
|
||||||
|
};
|
||||||
|
|
||||||
export default function AlertsScreen() {
|
export default function AlertsScreen() {
|
||||||
|
const { user, notifications, route } = useApp();
|
||||||
|
|
||||||
|
if (!user) return <Redirect href="/login" />;
|
||||||
|
|
||||||
|
const statusInfo =
|
||||||
|
STATUS_LABEL[route?.status ?? "ESPERA"] ?? STATUS_LABEL.ESPERA;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<SafeAreaView
|
||||||
style={{
|
style={{ flex: 1, backgroundColor: COLORS.background }}
|
||||||
flex: 1,
|
edges={["top"]}
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Text>Alertas</Text>
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ paddingBottom: 130 }}
|
||||||
|
>
|
||||||
|
{/* Hero con título + count + camion image en banner azul */}
|
||||||
|
<View style={styles.heroSection}>
|
||||||
|
<View style={styles.heroTextWrap}>
|
||||||
|
<Text style={styles.headerTitle}>Alertas</Text>
|
||||||
|
<Text style={styles.headerCount}>
|
||||||
|
{notifications.length}{" "}
|
||||||
|
{notifications.length === 1
|
||||||
|
? "notificación"
|
||||||
|
: "notificaciones"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.heroBanner}>
|
||||||
|
<Image
|
||||||
|
source={require("../../assets/illustrations/truck-city.png")}
|
||||||
|
style={styles.truckImage}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Estado de ruta */}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusBanner,
|
||||||
|
{ backgroundColor: `${statusInfo.color}15` },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusIcon,
|
||||||
|
{ backgroundColor: statusInfo.color },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons name="bus-outline" size={20} color="#FFFFFF" />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={[styles.statusTitle, { color: statusInfo.color }]}>
|
||||||
|
ESTADO DE RUTA: {statusInfo.label}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.statusBody}>
|
||||||
|
{statusInfo.label === "ACTIVA"
|
||||||
|
? "El servicio de recolección sigue en curso."
|
||||||
|
: "Sin servicio activo en este momento."}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Timeline de notificaciones */}
|
||||||
|
{notifications.length === 0 ? (
|
||||||
|
<View style={styles.emptyBox}>
|
||||||
|
<Ionicons name="leaf-outline" size={40} color="#9CA3AF" />
|
||||||
|
<Text style={styles.emptyText}>
|
||||||
|
Aún no hay alertas. Las recibirás cuando el camión avance en
|
||||||
|
tu ruta.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={{ paddingHorizontal: 20, marginTop: 12 }}>
|
||||||
|
{notifications.map((n, idx) => {
|
||||||
|
const m = meta[n.type];
|
||||||
|
const isLast = idx === notifications.length - 1;
|
||||||
|
const timeStr = new Date(n.createdAt).toLocaleTimeString(
|
||||||
|
"es-MX",
|
||||||
|
{ hour: "2-digit", minute: "2-digit" },
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<View key={n.id} style={styles.timelineRow}>
|
||||||
|
{/* Columna izquierda: dot + línea */}
|
||||||
|
<View style={styles.timelineLeft}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.timelineDot,
|
||||||
|
{ backgroundColor: m.color },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons name={m.icon} size={14} color="#FFFFFF" />
|
||||||
|
</View>
|
||||||
|
{!isLast && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.timelineLine,
|
||||||
|
{ backgroundColor: `${m.color}50` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text style={styles.timelineTime}>{timeStr}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.notifCard,
|
||||||
|
{ backgroundColor: m.bg },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.notifHeader}>
|
||||||
|
<Text style={styles.notifTitle}>{n.title}</Text>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.badge,
|
||||||
|
{ backgroundColor: `${m.color}25` },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.badgeText, { color: m.color }]}>
|
||||||
|
{m.badge}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.notifBody}>{n.body}</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Consejo del día */}
|
||||||
|
<View style={styles.tipCard}>
|
||||||
|
<View style={styles.tipIcon}>
|
||||||
|
<Ionicons name="leaf" size={20} color="#0E8A61" />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.tipTitle}>Consejo del día</Text>
|
||||||
|
<Text style={styles.tipBody}>
|
||||||
|
Separa correctamente tus residuos. Tu acción hace la diferencia.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Image
|
||||||
|
source={require("../../assets/illustrations/bins-cute.png")}
|
||||||
|
style={styles.tipImage}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
heroSection: {
|
||||||
|
backgroundColor: "#DBEAFE",
|
||||||
|
paddingBottom: 0,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
heroTextWrap: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 12,
|
||||||
|
paddingBottom: 8,
|
||||||
|
},
|
||||||
|
heroBanner: {
|
||||||
|
height: 150,
|
||||||
|
width: "100%",
|
||||||
|
backgroundColor: "#DBEAFE",
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: "800",
|
||||||
|
color: "#0F172A",
|
||||||
|
},
|
||||||
|
headerCount: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#6B7280",
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
truckImage: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
},
|
||||||
|
statusBanner: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginHorizontal: 20,
|
||||||
|
padding: 14,
|
||||||
|
borderRadius: 14,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
statusIcon: {
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
borderRadius: 19,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
statusTitle: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "800",
|
||||||
|
},
|
||||||
|
statusBody: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#374151",
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
emptyBox: {
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 40,
|
||||||
|
marginHorizontal: 20,
|
||||||
|
marginTop: 16,
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
borderRadius: 14,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#6B7280",
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
timelineRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
marginBottom: 14,
|
||||||
|
},
|
||||||
|
timelineLeft: {
|
||||||
|
width: 56,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
timelineDot: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
timelineLine: {
|
||||||
|
flex: 1,
|
||||||
|
width: 2,
|
||||||
|
marginTop: 2,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
timelineTime: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
notifCard: {
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 14,
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
notifHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
notifTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "800",
|
||||||
|
color: "#0F172A",
|
||||||
|
flex: 1,
|
||||||
|
paddingRight: 8,
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
badgeText: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
notifBody: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#374151",
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
tipCard: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#ECFDF5",
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 14,
|
||||||
|
marginHorizontal: 20,
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
tipIcon: {
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
borderRadius: 21,
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
tipTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "800",
|
||||||
|
color: "#065F46",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
tipBody: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#065F46",
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
tipImage: {
|
||||||
|
width: 70,
|
||||||
|
height: 70,
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
221
frontend/src/app/feedback.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
ScrollView,
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
StyleSheet,
|
||||||
|
Pressable,
|
||||||
|
Alert,
|
||||||
|
} from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Redirect, useRouter } from "expo-router";
|
||||||
|
|
||||||
|
import { COLORS } from "../constants/colors";
|
||||||
|
import SectionTitle from "../components/SectionTitle";
|
||||||
|
import PrimaryButton from "../components/PrimaryButton";
|
||||||
|
|
||||||
|
import { useApp } from "../context/AppContext";
|
||||||
|
import {
|
||||||
|
submitFeedback,
|
||||||
|
type FeedbackType,
|
||||||
|
} from "../services/feedback.service";
|
||||||
|
|
||||||
|
type Option = {
|
||||||
|
type: FeedbackType;
|
||||||
|
label: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OPTIONS: Option[] = [
|
||||||
|
{
|
||||||
|
type: "TRUCK_DID_NOT_PASS",
|
||||||
|
label: "El camión no pasó",
|
||||||
|
icon: "alert-circle-outline",
|
||||||
|
color: "#EF4444",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "RATING",
|
||||||
|
label: "Calificar el servicio",
|
||||||
|
icon: "star-outline",
|
||||||
|
color: "#F59E0B",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "SUGGESTION",
|
||||||
|
label: "Sugerencia",
|
||||||
|
icon: "bulb-outline",
|
||||||
|
color: "#3B82F6",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "OTHER",
|
||||||
|
label: "Otro",
|
||||||
|
icon: "chatbubble-outline",
|
||||||
|
color: "#6B7280",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function FeedbackScreen() {
|
||||||
|
const { user } = useApp();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [selectedType, setSelectedType] = useState<FeedbackType>(
|
||||||
|
"TRUCK_DID_NOT_PASS",
|
||||||
|
);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [rating, setRating] = useState<number | undefined>(undefined);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
if (!user) return <Redirect href="/login" />;
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!message.trim()) {
|
||||||
|
Alert.alert("Mensaje vacío", "Cuéntanos qué pasó.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
const payload: { type: FeedbackType; message: string; rating?: number } =
|
||||||
|
{ type: selectedType, message };
|
||||||
|
if (selectedType === "RATING" && rating) payload.rating = rating;
|
||||||
|
await submitFeedback(payload);
|
||||||
|
Alert.alert("Gracias", "Tu retroalimentación fue enviada.");
|
||||||
|
setMessage("");
|
||||||
|
setRating(undefined);
|
||||||
|
router.back();
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert("Error", err instanceof Error ? err.message : "No se pudo enviar.");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: COLORS.background }}>
|
||||||
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ padding: 20, paddingBottom: 120 }}
|
||||||
|
>
|
||||||
|
<SectionTitle title="Buzón de retroalimentación" />
|
||||||
|
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
Reporta incidencias o califica el servicio. Tu mensaje llega
|
||||||
|
directamente al equipo operativo.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Tipo de mensaje</Text>
|
||||||
|
<View style={styles.optionsGrid}>
|
||||||
|
{OPTIONS.map((opt) => {
|
||||||
|
const active = selectedType === opt.type;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={opt.type}
|
||||||
|
onPress={() => setSelectedType(opt.type)}
|
||||||
|
style={[
|
||||||
|
styles.optionCard,
|
||||||
|
active && { borderColor: opt.color, borderWidth: 2 },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons name={opt.icon} size={24} color={opt.color} />
|
||||||
|
<Text style={styles.optionLabel}>{opt.label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{selectedType === "RATING" && (
|
||||||
|
<>
|
||||||
|
<Text style={styles.label}>Calificación</Text>
|
||||||
|
<View style={styles.starsRow}>
|
||||||
|
{[1, 2, 3, 4, 5].map((n) => (
|
||||||
|
<Pressable key={n} onPress={() => setRating(n)}>
|
||||||
|
<Ionicons
|
||||||
|
name={rating && n <= rating ? "star" : "star-outline"}
|
||||||
|
size={34}
|
||||||
|
color="#F59E0B"
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={styles.label}>Mensaje</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.textArea}
|
||||||
|
placeholder="Cuéntanos qué pasó..."
|
||||||
|
placeholderTextColor="#9CA3AF"
|
||||||
|
value={message}
|
||||||
|
onChangeText={setMessage}
|
||||||
|
multiline
|
||||||
|
numberOfLines={5}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={{ height: 16 }} />
|
||||||
|
<PrimaryButton
|
||||||
|
title={submitting ? "Enviando..." : "Enviar"}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
subtitle: {
|
||||||
|
color: "#6B7280",
|
||||||
|
fontSize: 14,
|
||||||
|
marginBottom: 20,
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#374151",
|
||||||
|
marginTop: 8,
|
||||||
|
marginBottom: 10,
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
|
optionsGrid: {
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
optionCard: {
|
||||||
|
width: "48%",
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 14,
|
||||||
|
marginBottom: 12,
|
||||||
|
borderColor: "transparent",
|
||||||
|
borderWidth: 2,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 3 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 6,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
optionLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#1F2937",
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
starsRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
marginLeft: 12,
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
textArea: {
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 14,
|
||||||
|
minHeight: 110,
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#1F2937",
|
||||||
|
textAlignVertical: "top",
|
||||||
|
marginHorizontal: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,15 +1,313 @@
|
|||||||
import { View, Text } from "react-native";
|
import { ScrollView, View, Text, StyleSheet, Image } from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
import { COLORS } from "../constants/colors";
|
||||||
|
import CollectionCalendar from "../components/CollectionCalendar";
|
||||||
|
|
||||||
|
type Category = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
color: string;
|
||||||
|
bg: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
image: any;
|
||||||
|
examples: string[];
|
||||||
|
tip: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORIES: Category[] = [
|
||||||
|
{
|
||||||
|
key: "organico",
|
||||||
|
title: "Orgánicos",
|
||||||
|
color: "#22C55E",
|
||||||
|
bg: "#ECFDF5",
|
||||||
|
icon: "leaf",
|
||||||
|
image: require("../../assets/illustrations/organic-food.png"),
|
||||||
|
examples: [
|
||||||
|
"Restos de comida (frutas, verduras, cáscaras)",
|
||||||
|
"Bolsitas de té, residuos de café",
|
||||||
|
"Hojas, ramas y poda de jardín",
|
||||||
|
],
|
||||||
|
tip: "Escurre líquidos antes de tirar. Idealmente compostable.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "reciclable",
|
||||||
|
title: "Reciclables",
|
||||||
|
color: "#3B82F6",
|
||||||
|
bg: "#EFF6FF",
|
||||||
|
icon: "refresh-circle",
|
||||||
|
image: require("../../assets/illustrations/pet-bottles.png"),
|
||||||
|
examples: [
|
||||||
|
"Botellas y envases PET limpios",
|
||||||
|
"Cartón y papel seco",
|
||||||
|
"Latas de aluminio, vidrio sin romper",
|
||||||
|
],
|
||||||
|
tip: "Limpia y aplasta los envases para ocupar menos espacio.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "sanitario",
|
||||||
|
title: "Sanitarios",
|
||||||
|
color: "#6B7280",
|
||||||
|
bg: "#F3F4F6",
|
||||||
|
icon: "medkit",
|
||||||
|
image: require("../../assets/illustrations/sanitary.png"),
|
||||||
|
examples: [
|
||||||
|
"Pañales y toallas femeninas",
|
||||||
|
"Papel higiénico usado",
|
||||||
|
"Curaciones y cubrebocas",
|
||||||
|
],
|
||||||
|
tip: "Sepáralos siempre. NO van con orgánicos ni reciclables.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "especial",
|
||||||
|
title: "Especiales",
|
||||||
|
color: "#EF4444",
|
||||||
|
bg: "#FEF2F2",
|
||||||
|
icon: "warning",
|
||||||
|
image: require("../../assets/illustrations/battery-meds.png"),
|
||||||
|
examples: [
|
||||||
|
"Pilas y baterías",
|
||||||
|
"Electrónicos viejos",
|
||||||
|
"Aceite de cocina usado",
|
||||||
|
"Medicamentos caducados",
|
||||||
|
],
|
||||||
|
tip: "NO los tires con la basura común. Llévalos a un centro de acopio.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function GuideScreen() {
|
export default function GuideScreen() {
|
||||||
return (
|
return (
|
||||||
<View
|
<SafeAreaView
|
||||||
style={{
|
style={{ flex: 1, backgroundColor: COLORS.background }}
|
||||||
flex: 1,
|
edges={["top"]}
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Text>Guía</Text>
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ padding: 20, paddingBottom: 130 }}
|
||||||
|
>
|
||||||
|
{/* Calendario de recolección (primer elemento) */}
|
||||||
|
<CollectionCalendar />
|
||||||
|
|
||||||
|
{/* Header con título grande + imagen */}
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<View style={{ flex: 1, paddingRight: 8 }}>
|
||||||
|
<Text style={styles.headerTitle}>Guía de{"\n"}separación</Text>
|
||||||
|
<Text style={styles.headerSubtitle}>
|
||||||
|
Separar correctamente reduce contaminación y ayuda a la ruta de
|
||||||
|
recolección.
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.headerOffline}>Funciona sin conexión.</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<Image
|
||||||
|
source={require("../../assets/illustrations/recycling.png")}
|
||||||
|
style={styles.heroImage}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{CATEGORIES.map((cat) => (
|
||||||
|
<View key={cat.key} style={styles.card}>
|
||||||
|
{/* Icono circular */}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.iconWrap,
|
||||||
|
{ backgroundColor: cat.bg },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons name={cat.icon} size={28} color={cat.color} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Contenido */}
|
||||||
|
<View style={styles.cardContent}>
|
||||||
|
<View style={styles.cardTitleRow}>
|
||||||
|
<Text style={[styles.cardTitle, { color: cat.color }]}>
|
||||||
|
{cat.title}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={18} color="#9CA3AF" />
|
||||||
|
</View>
|
||||||
|
{cat.examples.map((ex, i) => (
|
||||||
|
<Text key={i} style={styles.example}>
|
||||||
|
• {ex}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.tipBox,
|
||||||
|
{ backgroundColor: cat.bg },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons name="bulb" size={14} color={cat.color} />
|
||||||
|
<Text style={[styles.tipText, { color: cat.color }]}>
|
||||||
|
{cat.tip}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Imagen ilustrativa por categoría */}
|
||||||
|
<Image
|
||||||
|
source={cat.image}
|
||||||
|
style={styles.categoryImage}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Recuerda */}
|
||||||
|
<View style={styles.preventiveBox}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<View style={styles.preventiveHeader}>
|
||||||
|
<View style={styles.preventiveIcon}>
|
||||||
|
<Ionicons name="notifications" size={20} color="#FFFFFF" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.preventiveTitle}>Recuerda</Text>
|
||||||
|
</View>
|
||||||
|
{[
|
||||||
|
"Saca tu basura sólo dentro del horario.",
|
||||||
|
"No persigas al camión, es peligroso.",
|
||||||
|
"Si tu camión no pasó, usa el buzón.",
|
||||||
|
].map((t, i) => (
|
||||||
|
<View key={i} style={styles.preventiveRow}>
|
||||||
|
<Ionicons name="checkmark-circle" size={14} color="#92400E" />
|
||||||
|
<Text style={styles.preventiveBody}>{t}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<Image
|
||||||
|
source={require("../../assets/illustrations/truck-banner.png")}
|
||||||
|
style={styles.preventiveImage}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 20,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: "800",
|
||||||
|
color: "#0F172A",
|
||||||
|
lineHeight: 34,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
headerSubtitle: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#1E40AF",
|
||||||
|
lineHeight: 18,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
headerOffline: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#0E8A61",
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
heroImage: {
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
borderRadius: 18,
|
||||||
|
padding: 14,
|
||||||
|
marginBottom: 14,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
iconWrap: {
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
borderRadius: 25,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
cardContent: {
|
||||||
|
flex: 1,
|
||||||
|
paddingRight: 4,
|
||||||
|
},
|
||||||
|
cardTitleRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
cardTitle: { fontSize: 18, fontWeight: "800" },
|
||||||
|
example: { fontSize: 12, color: "#4B5563", marginBottom: 2 },
|
||||||
|
tipBox: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: 8,
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
tipText: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: "600",
|
||||||
|
marginLeft: 6,
|
||||||
|
flex: 1,
|
||||||
|
lineHeight: 14,
|
||||||
|
},
|
||||||
|
categoryImage: {
|
||||||
|
width: 64,
|
||||||
|
height: 80,
|
||||||
|
marginLeft: 4,
|
||||||
|
alignSelf: "center",
|
||||||
|
},
|
||||||
|
preventiveBox: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: 8,
|
||||||
|
padding: 14,
|
||||||
|
backgroundColor: "#FEF3C7",
|
||||||
|
borderRadius: 14,
|
||||||
|
},
|
||||||
|
preventiveHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
preventiveIcon: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: "#F59E0B",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginRight: 10,
|
||||||
|
},
|
||||||
|
preventiveTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "800",
|
||||||
|
color: "#92400E",
|
||||||
|
},
|
||||||
|
preventiveRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
preventiveBody: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#92400E",
|
||||||
|
marginLeft: 6,
|
||||||
|
flex: 1,
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
preventiveImage: {
|
||||||
|
width: 90,
|
||||||
|
height: 90,
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,15 +1,494 @@
|
|||||||
import { View, Text } from "react-native";
|
import { useEffect } from "react";
|
||||||
|
import {
|
||||||
|
ScrollView,
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
Image,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
} from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Redirect, useRouter } from "expo-router";
|
||||||
|
|
||||||
|
import { COLORS } from "../constants/colors";
|
||||||
|
import { useApp } from "../context/AppContext";
|
||||||
|
|
||||||
|
type QuickAction = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
color: string;
|
||||||
|
bg: string;
|
||||||
|
onPress: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
|
const { user, eta, route, loading, refreshStatus } = useApp();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && !eta) {
|
||||||
|
void refreshStatus();
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
if (!user) return <Redirect href="/login" />;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
if (user.role === "ADMIN") return <Redirect href={"/admin" as any} />;
|
||||||
|
|
||||||
|
const minutes = eta ? Math.max(0, eta.etaMinutes) : null;
|
||||||
|
const windowText = eta?.arrivalWindow
|
||||||
|
? `${eta.arrivalWindow.from} - ${eta.arrivalWindow.to} a.m.`
|
||||||
|
: "Calculando...";
|
||||||
|
|
||||||
|
// Si la ruta fue cancelada por admin → eso manda
|
||||||
|
// Si el simulador marcó FAILED → falla mecánica
|
||||||
|
// Si el simulador marcó ARRIVED → ya pasó el camión
|
||||||
|
// Si no, mostramos el status genérico
|
||||||
|
const arrival = route?.arrivalResult ?? "PENDING";
|
||||||
|
const cancelled = route?.cancelled ?? false;
|
||||||
|
|
||||||
|
const statusOK = !cancelled && arrival !== "FAILED";
|
||||||
|
const statusColor = cancelled
|
||||||
|
? "#F59E0B"
|
||||||
|
: arrival === "FAILED"
|
||||||
|
? "#EF4444"
|
||||||
|
: arrival === "ARRIVED"
|
||||||
|
? "#22C55E"
|
||||||
|
: "#22C55E";
|
||||||
|
const statusLabel = cancelled
|
||||||
|
? "CANCELADA"
|
||||||
|
: arrival === "FAILED"
|
||||||
|
? "CON FALLA"
|
||||||
|
: arrival === "ARRIVED"
|
||||||
|
? "EL CAMIÓN YA PASÓ"
|
||||||
|
: route?.status === "EN_RUTA"
|
||||||
|
? "ESTABLE"
|
||||||
|
: "EN ESPERA";
|
||||||
|
const statusBody = cancelled
|
||||||
|
? "La ruta matutina fue cancelada. Se reprogramará para la tarde."
|
||||||
|
: arrival === "FAILED"
|
||||||
|
? "El camión presenta una falla mecánica."
|
||||||
|
: arrival === "ARRIVED"
|
||||||
|
? "El camión recolector ya pasó por tu zona."
|
||||||
|
: statusOK
|
||||||
|
? "Tu recolección sigue en horario normal."
|
||||||
|
: "Hay incidencias en la ruta.";
|
||||||
|
|
||||||
|
const quickActions: QuickAction[] = [
|
||||||
|
{
|
||||||
|
key: "alerts",
|
||||||
|
title: "Alertas",
|
||||||
|
icon: "notifications-outline",
|
||||||
|
color: "#22C55E",
|
||||||
|
bg: "#ECFDF5",
|
||||||
|
onPress: () => router.push("/alerts"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "guide",
|
||||||
|
title: "Guía",
|
||||||
|
icon: "leaf-outline",
|
||||||
|
color: "#3B82F6",
|
||||||
|
bg: "#EFF6FF",
|
||||||
|
onPress: () => router.push("/guide"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "feedback",
|
||||||
|
title: "Reportar",
|
||||||
|
icon: "chatbubble-outline",
|
||||||
|
color: "#EF4444",
|
||||||
|
bg: "#FEF2F2",
|
||||||
|
onPress: () => router.push("/feedback"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "address",
|
||||||
|
title: "Domicilio",
|
||||||
|
icon: "home-outline",
|
||||||
|
color: "#8B5CF6",
|
||||||
|
bg: "#F5F3FF",
|
||||||
|
onPress: () => router.push("/addresses"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<SafeAreaView
|
||||||
style={{
|
style={{ flex: 1, backgroundColor: COLORS.background }}
|
||||||
flex: 1,
|
edges={["top"]}
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Text>Inicio</Text>
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ paddingBottom: 130 }}
|
||||||
|
refreshControl={undefined}
|
||||||
|
>
|
||||||
|
{/* Title */}
|
||||||
|
<View style={styles.titleRow}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.title}>OptiRuta</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
Ruta {route?.routeId ?? "—"}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Hero del camión */}
|
||||||
|
<View style={styles.heroBox}>
|
||||||
|
<Image
|
||||||
|
source={require("../../assets/illustrations/truck-city.png")}
|
||||||
|
style={styles.heroTruck}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Card ETA */}
|
||||||
|
<View style={styles.etaCard}>
|
||||||
|
<View style={styles.etaContent}>
|
||||||
|
<View style={styles.etaIcon}>
|
||||||
|
<Ionicons name="time-outline" size={22} color="#0E8A61" />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.etaLabel}>LLEGADA ESTIMADA</Text>
|
||||||
|
<Text style={styles.etaMinutes}>
|
||||||
|
{minutes !== null
|
||||||
|
? `${minutes} min`
|
||||||
|
: loading
|
||||||
|
? "..."
|
||||||
|
: "—"}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.etaWindowLabel}>Ventana de recolección</Text>
|
||||||
|
<View style={styles.windowPill}>
|
||||||
|
<Text style={styles.windowText}>{windowText}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.etaCircle}>
|
||||||
|
<View style={styles.etaCircleInner}>
|
||||||
|
<Ionicons name="bus-outline" size={28} color="#0E8A61" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Estado de ruta */}
|
||||||
|
<View style={styles.statusBanner}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusDot,
|
||||||
|
{ backgroundColor: statusColor },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={[styles.statusTitle, { color: statusColor }]}>
|
||||||
|
ESTADO DE RUTA: {statusLabel}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.statusBody}>{statusBody}</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusShield,
|
||||||
|
{ backgroundColor: `${statusColor}15` },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="shield-checkmark-outline"
|
||||||
|
size={20}
|
||||||
|
color={statusColor}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Mensaje preventivo */}
|
||||||
|
<View style={styles.warningBanner}>
|
||||||
|
<View style={styles.warningIcon}>
|
||||||
|
<Ionicons name="alert" size={20} color="#FFFFFF" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.warningText}>
|
||||||
|
No saques tus residuos fuera del horario{"\n"}y no persigas al camión.
|
||||||
|
</Text>
|
||||||
|
<View style={styles.warningRight}>
|
||||||
|
<Ionicons name="walk-outline" size={20} color="#92400E" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Acciones rápidas */}
|
||||||
|
<Text style={styles.sectionTitle}>ACCIONES RÁPIDAS</Text>
|
||||||
|
<View style={styles.actionsGrid}>
|
||||||
|
{quickActions.map((a) => (
|
||||||
|
<Pressable
|
||||||
|
key={a.key}
|
||||||
|
style={styles.actionCard}
|
||||||
|
onPress={a.onPress}
|
||||||
|
>
|
||||||
|
<View style={[styles.actionIcon, { backgroundColor: a.bg }]}>
|
||||||
|
<Ionicons name={a.icon} size={26} color={a.color} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.actionTitle}>{a.title}</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Consejo rápido */}
|
||||||
|
<View style={styles.tipCard}>
|
||||||
|
<View style={styles.tipIcon}>
|
||||||
|
<Ionicons name="leaf" size={22} color="#0E8A61" />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.tipTitle}>Consejo rápido</Text>
|
||||||
|
<Text style={styles.tipBody}>
|
||||||
|
Compacta cartón y PET para ahorrar espacio y ayudar al medio
|
||||||
|
ambiente.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Image
|
||||||
|
source={require("../../assets/illustrations/pet-bottles.png")}
|
||||||
|
style={styles.tipImage}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
titleRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 8,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: "800",
|
||||||
|
color: "#0E8A61",
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#6B7280",
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
heroBox: {
|
||||||
|
height: 180,
|
||||||
|
marginTop: 8,
|
||||||
|
backgroundColor: "#DBEAFE",
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
heroTruck: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
},
|
||||||
|
etaCard: {
|
||||||
|
marginHorizontal: 20,
|
||||||
|
marginTop: -30,
|
||||||
|
backgroundColor: "#ECFDF5",
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 18,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 6 },
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 10,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
etaContent: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
etaIcon: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
etaLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: "800",
|
||||||
|
color: "#065F46",
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
etaMinutes: {
|
||||||
|
fontSize: 36,
|
||||||
|
fontWeight: "800",
|
||||||
|
color: "#0F172A",
|
||||||
|
marginTop: 4,
|
||||||
|
lineHeight: 38,
|
||||||
|
},
|
||||||
|
etaWindowLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#6B7280",
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
windowPill: {
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
backgroundColor: "#D1FAE5",
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
windowText: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#065F46",
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
etaCircle: {
|
||||||
|
width: 88,
|
||||||
|
height: 88,
|
||||||
|
borderRadius: 44,
|
||||||
|
borderWidth: 4,
|
||||||
|
borderColor: "#22C55E",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
etaCircleInner: {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 32,
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
statusBanner: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginHorizontal: 20,
|
||||||
|
marginTop: 14,
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 14,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
statusDot: {
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 6,
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
statusTitle: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "800",
|
||||||
|
},
|
||||||
|
statusBody: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#374151",
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
statusShield: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
warningBanner: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginHorizontal: 20,
|
||||||
|
marginTop: 12,
|
||||||
|
backgroundColor: "#FEF3C7",
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
warningIcon: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: "#F59E0B",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginRight: 10,
|
||||||
|
},
|
||||||
|
warningText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#92400E",
|
||||||
|
fontWeight: "600",
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
warningRight: {
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "800",
|
||||||
|
color: "#6B7280",
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginTop: 22,
|
||||||
|
marginBottom: 10,
|
||||||
|
marginLeft: 20,
|
||||||
|
},
|
||||||
|
actionsGrid: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
},
|
||||||
|
actionCard: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: "center",
|
||||||
|
marginHorizontal: 4,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
actionIcon: {
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
borderRadius: 25,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
actionTitle: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#0F172A",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
tipCard: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#ECFDF5",
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 14,
|
||||||
|
marginHorizontal: 20,
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
tipIcon: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
tipTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "800",
|
||||||
|
color: "#065F46",
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
tipBody: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#065F46",
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
tipImage: {
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
89
frontend/src/app/login.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { View, Text, StyleSheet, Alert, Pressable } from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
|
||||||
|
import { COLORS } from "../constants/colors";
|
||||||
|
import InputField from "../components/InputField";
|
||||||
|
import PrimaryButton from "../components/PrimaryButton";
|
||||||
|
|
||||||
|
import { useApp } from "../context/AppContext";
|
||||||
|
|
||||||
|
export default function LoginScreen() {
|
||||||
|
const [email, setEmail] = useState("ana@test.com");
|
||||||
|
const [password, setPassword] = useState("123456");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const { login } = useApp();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
await login(email, password);
|
||||||
|
router.replace("/");
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert("Error", err instanceof Error ? err.message : "Login failed");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.title}>Iniciar sesión</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
Accede para ver el estado del camión en tu zona.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<InputField
|
||||||
|
placeholder="Email"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={{ height: 8 }} />
|
||||||
|
<PrimaryButton
|
||||||
|
title={submitting ? "Cargando..." : "Entrar"}
|
||||||
|
onPress={handleLogin}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.replace("/register")}
|
||||||
|
style={styles.linkWrap}
|
||||||
|
>
|
||||||
|
<Text style={styles.link}>
|
||||||
|
¿No tienes cuenta?{" "}
|
||||||
|
<Text style={styles.linkBold}>Regístrate</Text>
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: COLORS.background },
|
||||||
|
content: { flex: 1, padding: 24, justifyContent: "center" },
|
||||||
|
title: {
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: "bold",
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#6B7280",
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: 22,
|
||||||
|
},
|
||||||
|
linkWrap: { marginTop: 18, alignItems: "center" },
|
||||||
|
link: { fontSize: 14, color: "#6B7280" },
|
||||||
|
linkBold: { color: "#0E8A61", fontWeight: "700" },
|
||||||
|
});
|
||||||
@@ -1,15 +1,383 @@
|
|||||||
import { View, Text } from "react-native";
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
Image,
|
||||||
|
Pressable,
|
||||||
|
Alert,
|
||||||
|
} from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Redirect, useRouter } from "expo-router";
|
||||||
|
|
||||||
|
import { COLORS } from "../constants/colors";
|
||||||
|
import { useApp } from "../context/AppContext";
|
||||||
|
import { getMyAddress, type MyAddress } from "../services/addresses.service";
|
||||||
|
import { apiFetch } from "../lib/api";
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
color: string;
|
||||||
|
onPress: () => void;
|
||||||
|
danger?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export default function ProfileScreen() {
|
export default function ProfileScreen() {
|
||||||
|
const { user, logout } = useApp();
|
||||||
|
const router = useRouter();
|
||||||
|
const [myAddress, setMyAddress] = useState<MyAddress | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
void getMyAddress().then(setMyAddress).catch(() => {});
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
if (!user) return <Redirect href="/login" />;
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
router.replace("/login");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
try {
|
||||||
|
await apiFetch<{ message: string }>("/api/tracking/reset-demo", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
Alert.alert("Demo reiniciada", "Las notificaciones se borraron.");
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert(
|
||||||
|
"No se pudo reiniciar",
|
||||||
|
err instanceof Error ? err.message : "Error",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHelp = () => {
|
||||||
|
Alert.alert(
|
||||||
|
"Ayuda",
|
||||||
|
"OptiRuta te avisa cuando el camión recolector está cerca.\n\n" +
|
||||||
|
"• Tu dirección define la ruta que verás.\n" +
|
||||||
|
"• Las alertas se actualizan cada 30 segundos.\n" +
|
||||||
|
"• Usa el buzón si el camión no pasó.",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows: Row[] = [
|
||||||
|
{
|
||||||
|
key: "address",
|
||||||
|
title: "Mi domicilio",
|
||||||
|
subtitle: "Ver y administrar tus domicilios registrados",
|
||||||
|
icon: "home-outline",
|
||||||
|
color: "#0E8A61",
|
||||||
|
onPress: () => router.push("/addresses"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "feedback",
|
||||||
|
title: "Buzón de retroalimentación",
|
||||||
|
subtitle: "Sugerencias, comentarios y reportes",
|
||||||
|
icon: "chatbubble-outline",
|
||||||
|
color: "#0E8A61",
|
||||||
|
onPress: () => router.push("/feedback"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "reset",
|
||||||
|
title: "Reiniciar demo",
|
||||||
|
subtitle: "Restablecer la simulación y los datos",
|
||||||
|
icon: "refresh-outline",
|
||||||
|
color: "#0E8A61",
|
||||||
|
onPress: handleReset,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "help",
|
||||||
|
title: "Ayuda",
|
||||||
|
subtitle: "Preguntas frecuentes y soporte",
|
||||||
|
icon: "help-circle-outline",
|
||||||
|
color: "#0E8A61",
|
||||||
|
onPress: handleHelp,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "logout",
|
||||||
|
title: "Cerrar sesión",
|
||||||
|
subtitle: "Salir de tu cuenta",
|
||||||
|
icon: "log-out-outline",
|
||||||
|
color: "#EF4444",
|
||||||
|
onPress: handleLogout,
|
||||||
|
danger: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const initial = (user.name?.[0] ?? user.email[0]).toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<SafeAreaView
|
||||||
style={{
|
style={{ flex: 1, backgroundColor: COLORS.background }}
|
||||||
flex: 1,
|
edges={["bottom"]}
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Text>Perfil</Text>
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ paddingBottom: 130 }}
|
||||||
|
>
|
||||||
|
{/* Header verde con avatar */}
|
||||||
|
<View style={styles.heroWrap}>
|
||||||
|
<View style={styles.heroBackground} />
|
||||||
|
<View style={styles.avatarOuter}>
|
||||||
|
<View style={styles.avatarInner}>
|
||||||
|
<Text style={styles.avatarText}>{initial}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.identityBox}>
|
||||||
|
<Text style={styles.name}>
|
||||||
|
Hola, {user.name} <Text style={{ color: "#22C55E" }}>🌿</Text>
|
||||||
|
</Text>
|
||||||
|
<View style={styles.emailRow}>
|
||||||
|
<Ionicons name="mail-outline" size={14} color="#6B7280" />
|
||||||
|
<Text style={styles.email}>{user.email}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Zona + ruta */}
|
||||||
|
<Pressable
|
||||||
|
style={styles.zoneRow}
|
||||||
|
onPress={() => router.push("/addresses")}
|
||||||
|
>
|
||||||
|
<View style={styles.zoneCol}>
|
||||||
|
<Ionicons name="location-outline" size={22} color="#0E8A61" />
|
||||||
|
<View style={{ marginLeft: 8, flex: 1 }}>
|
||||||
|
<Text style={styles.zoneLabel}>Tu zona</Text>
|
||||||
|
<Text style={styles.zoneValue} numberOfLines={2}>
|
||||||
|
{myAddress?.colonia ?? "Sin asignar"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.zoneDivider} />
|
||||||
|
<View style={styles.zoneCol}>
|
||||||
|
<Ionicons name="bus-outline" size={22} color="#0E8A61" />
|
||||||
|
<View style={{ marginLeft: 8, flex: 1 }}>
|
||||||
|
<Text style={styles.zoneLabel}>Ruta asignada</Text>
|
||||||
|
<Text style={styles.zoneValue} numberOfLines={1}>
|
||||||
|
{myAddress?.routeId ?? "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-forward"
|
||||||
|
size={18}
|
||||||
|
color="#9CA3AF"
|
||||||
|
style={{ marginLeft: 4 }}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Lista de opciones */}
|
||||||
|
<View style={{ paddingHorizontal: 16, marginTop: 4 }}>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<Pressable key={r.key} style={styles.rowCard} onPress={r.onPress}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.rowIcon,
|
||||||
|
{ backgroundColor: `${r.color}15` },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons name={r.icon} size={22} color={r.color} />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.rowTitle,
|
||||||
|
r.danger && { color: "#EF4444" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{r.title}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.rowSubtitle}>{r.subtitle}</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={18} color="#9CA3AF" />
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Tu impacto cuenta */}
|
||||||
|
<View style={styles.impactCard}>
|
||||||
|
<View style={styles.impactLeaf}>
|
||||||
|
<Image
|
||||||
|
source={require("../../assets/illustrations/impact-leaf.png")}
|
||||||
|
style={{ width: 40, height: 40 }}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.impactTitle}>Tu impacto cuenta</Text>
|
||||||
|
<Text style={styles.impactBody}>
|
||||||
|
Siguiendo los horarios y separando correctamente, haces tu ciudad
|
||||||
|
más limpia.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
heroWrap: {
|
||||||
|
height: 140,
|
||||||
|
backgroundColor: "#D1FAE5",
|
||||||
|
overflow: "visible",
|
||||||
|
marginBottom: 60,
|
||||||
|
},
|
||||||
|
heroBackground: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "#D1FAE5",
|
||||||
|
},
|
||||||
|
avatarOuter: {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: -50,
|
||||||
|
alignSelf: "center",
|
||||||
|
width: 112,
|
||||||
|
height: 112,
|
||||||
|
borderRadius: 56,
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
avatarInner: {
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
borderRadius: 50,
|
||||||
|
backgroundColor: "#0E8A61",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
avatarText: {
|
||||||
|
color: "#FFFFFF",
|
||||||
|
fontSize: 38,
|
||||||
|
fontWeight: "800",
|
||||||
|
},
|
||||||
|
identityBox: {
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 18,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "800",
|
||||||
|
color: "#0F172A",
|
||||||
|
},
|
||||||
|
emailRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#6B7280",
|
||||||
|
marginLeft: 6,
|
||||||
|
},
|
||||||
|
zoneRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#ECFDF5",
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 14,
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
zoneCol: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
zoneDivider: {
|
||||||
|
width: 1,
|
||||||
|
height: 36,
|
||||||
|
backgroundColor: "#A7F3D0",
|
||||||
|
marginHorizontal: 8,
|
||||||
|
},
|
||||||
|
zoneLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#065F46",
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
zoneValue: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#0F172A",
|
||||||
|
fontWeight: "600",
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
rowCard: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 14,
|
||||||
|
marginBottom: 10,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 6,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
rowIcon: {
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
borderRadius: 21,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
rowTitle: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#0F172A",
|
||||||
|
},
|
||||||
|
rowSubtitle: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#6B7280",
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
impactCard: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#ECFDF5",
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 14,
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
impactLeaf: {
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
borderRadius: 25,
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
impactTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "800",
|
||||||
|
color: "#065F46",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
impactBody: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#065F46",
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
103
frontend/src/app/register.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { View, Text, StyleSheet, Alert, Pressable } from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
|
||||||
|
import { COLORS } from "../constants/colors";
|
||||||
|
import InputField from "../components/InputField";
|
||||||
|
import PrimaryButton from "../components/PrimaryButton";
|
||||||
|
|
||||||
|
import { useApp } from "../context/AppContext";
|
||||||
|
|
||||||
|
export default function RegisterScreen() {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const { register } = useApp();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleRegister = async () => {
|
||||||
|
if (!name.trim() || !email.trim() || !password.trim()) {
|
||||||
|
Alert.alert("Datos faltantes", "Completa todos los campos.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 6) {
|
||||||
|
Alert.alert("Password corto", "Debe tener al menos 6 caracteres.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
await register(name, email, password);
|
||||||
|
// Onboarding: tras registrar, pedir que valide su domicilio.
|
||||||
|
router.replace("/addresses");
|
||||||
|
} catch (err) {
|
||||||
|
Alert.alert(
|
||||||
|
"No se pudo crear la cuenta",
|
||||||
|
err instanceof Error ? err.message : "Error desconocido",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.title}>Crear cuenta</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
Registra tu correo para recibir alertas de recolección.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<InputField placeholder="Nombre" value={name} onChangeText={setName} />
|
||||||
|
<InputField
|
||||||
|
placeholder="Email"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
placeholder="Password (mínimo 6 caracteres)"
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={{ height: 8 }} />
|
||||||
|
<PrimaryButton
|
||||||
|
title={submitting ? "Creando..." : "Registrarme"}
|
||||||
|
onPress={handleRegister}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.replace("/login")}
|
||||||
|
style={styles.linkWrap}
|
||||||
|
>
|
||||||
|
<Text style={styles.link}>
|
||||||
|
¿Ya tienes cuenta? <Text style={styles.linkBold}>Inicia sesión</Text>
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: COLORS.background },
|
||||||
|
content: { flex: 1, padding: 24, justifyContent: "center" },
|
||||||
|
title: {
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: "bold",
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#6B7280",
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: 22,
|
||||||
|
},
|
||||||
|
linkWrap: { marginTop: 18, alignItems: "center" },
|
||||||
|
link: { fontSize: 14, color: "#6B7280" },
|
||||||
|
linkBold: { color: "#0E8A61", fontWeight: "700" },
|
||||||
|
});
|
||||||
@@ -1,59 +1,183 @@
|
|||||||
import { View, Text, StyleSheet } from 'react-native';
|
import {
|
||||||
import { Colors, Spacing } from '../constants/theme';
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
import { Colors, Spacing } from "../constants/theme";
|
||||||
|
|
||||||
type AlertItemProps = {
|
type AlertItemProps = {
|
||||||
message: string;
|
title: string;
|
||||||
type?: 'success' | 'warning' | 'danger';
|
description: string;
|
||||||
|
time: string;
|
||||||
|
type?: "started" | "near" | "danger" | "completed";
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AlertItem({
|
export default function AlertItem({
|
||||||
message,
|
title,
|
||||||
type = 'success',
|
description,
|
||||||
|
time,
|
||||||
|
type = "started",
|
||||||
}: AlertItemProps) {
|
}: AlertItemProps) {
|
||||||
|
|
||||||
const theme = Colors.light;
|
const theme = Colors.light;
|
||||||
|
|
||||||
const getColor = () => {
|
const getColor = () => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'success':
|
case "started":
|
||||||
return '#22C55E';
|
return "#B8C4BE";
|
||||||
case 'warning':
|
|
||||||
return '#FACC15';
|
case "near":
|
||||||
case 'danger':
|
return "#22C55E";
|
||||||
return '#EF4444';
|
|
||||||
|
case "danger":
|
||||||
|
return "#EF4444";
|
||||||
|
|
||||||
|
case "completed":
|
||||||
|
return "#B8C4BE";
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return theme.textSecondary;
|
return "#9CA3AF";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getIcon = () => {
|
||||||
|
switch (type) {
|
||||||
|
case "started":
|
||||||
|
return "navigate";
|
||||||
|
|
||||||
|
case "near":
|
||||||
|
return "time-outline";
|
||||||
|
|
||||||
|
case "danger":
|
||||||
|
return "warning-outline";
|
||||||
|
|
||||||
|
case "completed":
|
||||||
|
return "checkmark-done";
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "notifications";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View
|
||||||
<View style={[styles.bar, { backgroundColor: getColor() }]} />
|
style={[
|
||||||
<Text style={[styles.text, { color: theme.text }]}>
|
styles.container,
|
||||||
{message}
|
{
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.iconContainer,
|
||||||
|
{
|
||||||
|
backgroundColor: `${getColor()}20`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={getIcon()}
|
||||||
|
size={22}
|
||||||
|
color={getColor()}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.title,
|
||||||
|
{
|
||||||
|
color: theme.text,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
<Text style={styles.time}>
|
||||||
|
{time}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.description,
|
||||||
|
{
|
||||||
|
color: theme.textSecondary,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flexDirection: 'row',
|
flexDirection: "row",
|
||||||
padding: Spacing.three,
|
|
||||||
marginHorizontal: Spacing.three,
|
padding: 18,
|
||||||
marginVertical: Spacing.one,
|
|
||||||
borderRadius: 12,
|
marginBottom: 16,
|
||||||
backgroundColor: '#F0F0F3',
|
|
||||||
alignItems: 'center',
|
borderRadius: 22,
|
||||||
|
|
||||||
|
alignItems: "flex-start",
|
||||||
|
|
||||||
|
shadowColor: "#000",
|
||||||
|
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 6,
|
||||||
},
|
},
|
||||||
bar: {
|
|
||||||
width: 6,
|
shadowOpacity: 0.05,
|
||||||
height: '100%',
|
|
||||||
borderRadius: 4,
|
shadowRadius: 10,
|
||||||
marginRight: Spacing.two,
|
|
||||||
|
elevation: 4,
|
||||||
},
|
},
|
||||||
text: {
|
|
||||||
fontSize: 14,
|
iconContainer: {
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
|
||||||
|
borderRadius: 21,
|
||||||
|
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
|
||||||
|
marginRight: 14,
|
||||||
|
},
|
||||||
|
|
||||||
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
|
||||||
|
title: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
|
||||||
|
time: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
},
|
||||||
|
|
||||||
|
description: {
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
447
frontend/src/components/CollectionCalendar.tsx
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
/**
|
||||||
|
* CollectionCalendar.tsx
|
||||||
|
*
|
||||||
|
* Calendario mensual de recolección de la ruta ASIGNADA al usuario.
|
||||||
|
* Respeta el principio de "visión de túnel": el ciudadano NO puede ver
|
||||||
|
* rutas que no sean la suya.
|
||||||
|
*
|
||||||
|
* Reglas de color por día (solo para RUTA-40, RUTA-65 y RUTA-80):
|
||||||
|
* L, M, V, S → verde (RESIDUOS ORGÁNICOS)
|
||||||
|
* Mi, J → gris (RESIDUOS SÓLIDOS URBANOS VALORABLES)
|
||||||
|
* Domingo → vacío
|
||||||
|
*
|
||||||
|
* El patrón se aplica TODAS las semanas sin excepción. Las celdas del mes
|
||||||
|
* anterior/siguiente que aparecen al inicio/final también se colorean
|
||||||
|
* (en tono más claro) para que no queden huecos entre semana.
|
||||||
|
*
|
||||||
|
* Otras rutas: días neutros sin etiqueta.
|
||||||
|
*
|
||||||
|
* Si el usuario no ha validado su domicilio, se muestra un CTA.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { View, Text, StyleSheet, Pressable } from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
|
||||||
|
import { useApp } from "../context/AppContext";
|
||||||
|
import { routes as routeCatalog } from "../data/mocks/routes.mock";
|
||||||
|
|
||||||
|
const MONTHS_ES = [
|
||||||
|
"Enero",
|
||||||
|
"Febrero",
|
||||||
|
"Marzo",
|
||||||
|
"Abril",
|
||||||
|
"Mayo",
|
||||||
|
"Junio",
|
||||||
|
"Julio",
|
||||||
|
"Agosto",
|
||||||
|
"Septiembre",
|
||||||
|
"Octubre",
|
||||||
|
"Noviembre",
|
||||||
|
"Diciembre",
|
||||||
|
];
|
||||||
|
|
||||||
|
const DAYS_ES = ["Lu", "Ma", "Mi", "Ju", "Vi", "Sa", "Do"];
|
||||||
|
|
||||||
|
const SPECIAL_ROUTES = new Set(["RUTA-40", "RUTA-65", "RUTA-80"]);
|
||||||
|
|
||||||
|
const COLOR_ORGANIC = "#10b981";
|
||||||
|
const COLOR_SOLID = "#9ca3af";
|
||||||
|
const COLOR_NEUTRAL = "#E5E7EB";
|
||||||
|
const COLOR_EMPTY = "transparent";
|
||||||
|
|
||||||
|
type DayInfo = { day: number; weekIndex: number; inMonth: boolean };
|
||||||
|
|
||||||
|
/** Convierte JS getDay (0=Sun..6=Sat) al índice Mon-first (0=Lun..6=Dom). */
|
||||||
|
const toMondayFirst = (jsDay: number): number => (jsDay === 0 ? 6 : jsDay - 1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devuelve 42 celdas (6 semanas × 7 días). Las celdas del inicio/final
|
||||||
|
* que caen fuera del mes actual también traen su día real del mes vecino
|
||||||
|
* (con inMonth=false) para que el calendario no tenga huecos entre semana.
|
||||||
|
*/
|
||||||
|
function buildMonthGrid(year: number, month: number): DayInfo[] {
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
const firstWeekIndex = toMondayFirst(firstDay.getDay());
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
const daysInPrevMonth = new Date(year, month, 0).getDate();
|
||||||
|
|
||||||
|
const cells: DayInfo[] = [];
|
||||||
|
|
||||||
|
// Días del mes anterior que aparecen al inicio
|
||||||
|
for (let i = firstWeekIndex - 1; i >= 0; i--) {
|
||||||
|
const date = new Date(year, month - 1, daysInPrevMonth - i);
|
||||||
|
cells.push({
|
||||||
|
day: daysInPrevMonth - i,
|
||||||
|
weekIndex: toMondayFirst(date.getDay()),
|
||||||
|
inMonth: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Días del mes actual
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) {
|
||||||
|
const date = new Date(year, month, d);
|
||||||
|
cells.push({
|
||||||
|
day: d,
|
||||||
|
weekIndex: toMondayFirst(date.getDay()),
|
||||||
|
inMonth: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Días del mes siguiente para completar 42 celdas
|
||||||
|
let next = 1;
|
||||||
|
while (cells.length < 42) {
|
||||||
|
const date = new Date(year, month + 1, next);
|
||||||
|
cells.push({
|
||||||
|
day: next,
|
||||||
|
weekIndex: toMondayFirst(date.getDay()),
|
||||||
|
inMonth: false,
|
||||||
|
});
|
||||||
|
next++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Color de fondo según ruta + día de la semana.
|
||||||
|
* - SPECIAL_ROUTES (40/65/80): patrón verde/gris/vacío todas las semanas
|
||||||
|
* - Otras rutas: neutro (salvo domingo que va vacío)
|
||||||
|
*/
|
||||||
|
function colorForDay(routeId: string, weekIndex: number): string {
|
||||||
|
if (!SPECIAL_ROUTES.has(routeId)) {
|
||||||
|
return weekIndex === 6 ? COLOR_EMPTY : COLOR_NEUTRAL;
|
||||||
|
}
|
||||||
|
if (weekIndex === 0 || weekIndex === 1 || weekIndex === 4 || weekIndex === 5)
|
||||||
|
return COLOR_ORGANIC; // Lun, Mar, Vie, Sáb
|
||||||
|
if (weekIndex === 2 || weekIndex === 3) return COLOR_SOLID; // Mié, Jue
|
||||||
|
return COLOR_EMPTY; // Domingo
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CollectionCalendar() {
|
||||||
|
const { route } = useApp();
|
||||||
|
const router = useRouter();
|
||||||
|
const today = new Date();
|
||||||
|
const [year, setYear] = useState(today.getFullYear());
|
||||||
|
const [month, setMonth] = useState(today.getMonth());
|
||||||
|
|
||||||
|
const myRouteId = route?.routeId ?? null;
|
||||||
|
const myRouteName = useMemo(
|
||||||
|
() => routeCatalog.find((r) => r.routeId === myRouteId)?.name ?? "",
|
||||||
|
[myRouteId],
|
||||||
|
);
|
||||||
|
const isSpecial = myRouteId ? SPECIAL_ROUTES.has(myRouteId) : false;
|
||||||
|
|
||||||
|
const grid = useMemo(() => buildMonthGrid(year, month), [year, month]);
|
||||||
|
|
||||||
|
const handlePrev = () => {
|
||||||
|
if (month === 0) {
|
||||||
|
setMonth(11);
|
||||||
|
setYear((y) => y - 1);
|
||||||
|
} else {
|
||||||
|
setMonth((m) => m - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (month === 11) {
|
||||||
|
setMonth(0);
|
||||||
|
setYear((y) => y + 1);
|
||||||
|
} else {
|
||||||
|
setMonth((m) => m + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// CTA: sin ruta asignada
|
||||||
|
if (!myRouteId) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<Ionicons name="calendar-outline" size={22} color="#0E8A61" />
|
||||||
|
<Text style={styles.title}>Calendario de recolección</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.ctaBox}>
|
||||||
|
<Ionicons name="lock-closed-outline" size={28} color="#9CA3AF" />
|
||||||
|
<Text style={styles.ctaText}>
|
||||||
|
Necesitas validar tu domicilio para ver el calendario de tu ruta.
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
style={styles.ctaBtn}
|
||||||
|
onPress={() => router.push("/addresses")}
|
||||||
|
>
|
||||||
|
<Text style={styles.ctaBtnText}>Validar domicilio</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<Ionicons name="calendar-outline" size={22} color="#0E8A61" />
|
||||||
|
<Text style={styles.title}>Calendario de recolección</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.routeBadge}>
|
||||||
|
<Ionicons name="bus-outline" size={14} color="#0E8A61" />
|
||||||
|
<Text style={styles.routeBadgeText}>
|
||||||
|
Tu ruta: <Text style={styles.routeBadgeBold}>{myRouteId}</Text>
|
||||||
|
{myRouteName ? ` · ${myRouteName}` : ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Navegador de mes */}
|
||||||
|
<View style={styles.monthRow}>
|
||||||
|
<Pressable onPress={handlePrev} style={styles.navBtn} hitSlop={10}>
|
||||||
|
<Ionicons name="chevron-back" size={20} color="#0E8A61" />
|
||||||
|
</Pressable>
|
||||||
|
<Text style={styles.monthLabel}>
|
||||||
|
{MONTHS_ES[month]} {year}
|
||||||
|
</Text>
|
||||||
|
<Pressable onPress={handleNext} style={styles.navBtn} hitSlop={10}>
|
||||||
|
<Ionicons name="chevron-forward" size={20} color="#0E8A61" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Encabezado días de la semana */}
|
||||||
|
<View style={styles.weekHeader}>
|
||||||
|
{DAYS_ES.map((d) => (
|
||||||
|
<View key={d} style={styles.weekCell}>
|
||||||
|
<Text style={styles.weekText}>{d}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Grid de días — 6 filas explícitas para evitar el problema de
|
||||||
|
alineación que ocurre con flexWrap + porcentajes en React Native. */}
|
||||||
|
<View>
|
||||||
|
{Array.from({ length: 6 }).map((_, rowIdx) => (
|
||||||
|
<View key={rowIdx} style={styles.gridRow}>
|
||||||
|
{grid.slice(rowIdx * 7, (rowIdx + 1) * 7).map((cell, colIdx) => {
|
||||||
|
const bg = colorForDay(myRouteId, cell.weekIndex);
|
||||||
|
const isToday =
|
||||||
|
cell.inMonth &&
|
||||||
|
cell.day === today.getDate() &&
|
||||||
|
month === today.getMonth() &&
|
||||||
|
year === today.getFullYear();
|
||||||
|
const isColored =
|
||||||
|
bg === COLOR_ORGANIC || bg === COLOR_SOLID;
|
||||||
|
const textColor = isColored ? "#FFFFFF" : "#0F172A";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={colIdx} style={styles.dayCell}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.dayInner,
|
||||||
|
{ backgroundColor: bg },
|
||||||
|
!cell.inMonth && { opacity: 0.4 },
|
||||||
|
isToday && styles.todayBorder,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.dayText, { color: textColor }]}>
|
||||||
|
{cell.day}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Leyenda */}
|
||||||
|
{isSpecial ? (
|
||||||
|
<View style={styles.legend}>
|
||||||
|
<View style={styles.legendRow}>
|
||||||
|
<View
|
||||||
|
style={[styles.legendSwatch, { backgroundColor: COLOR_ORGANIC }]}
|
||||||
|
/>
|
||||||
|
<Text style={styles.legendText}>RESIDUOS ORGÁNICOS</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.legendRow}>
|
||||||
|
<View
|
||||||
|
style={[styles.legendSwatch, { backgroundColor: COLOR_SOLID }]}
|
||||||
|
/>
|
||||||
|
<Text style={styles.legendText}>
|
||||||
|
RESIDUOS SÓLIDOS URBANOS VALORABLES
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.legendNeutral}>
|
||||||
|
<Ionicons
|
||||||
|
name="information-circle-outline"
|
||||||
|
size={16}
|
||||||
|
color="#6B7280"
|
||||||
|
/>
|
||||||
|
<Text style={styles.legendNeutralText}>
|
||||||
|
Tu ruta aún no tiene un calendario de separación específico. Te
|
||||||
|
avisaremos cuando se publique.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
borderRadius: 18,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 18,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.06,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "800",
|
||||||
|
color: "#0F172A",
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
routeBadge: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
backgroundColor: "#ECFDF5",
|
||||||
|
paddingVertical: 6,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
borderRadius: 999,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
routeBadgeText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#065F46",
|
||||||
|
marginLeft: 6,
|
||||||
|
},
|
||||||
|
routeBadgeBold: {
|
||||||
|
fontWeight: "800",
|
||||||
|
color: "#0E8A61",
|
||||||
|
},
|
||||||
|
monthRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: 8,
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
navBtn: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: "#ECFDF5",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
monthLabel: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#0F172A",
|
||||||
|
},
|
||||||
|
weekHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
weekCell: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: 4,
|
||||||
|
paddingHorizontal: 2,
|
||||||
|
},
|
||||||
|
weekText: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#9CA3AF",
|
||||||
|
},
|
||||||
|
gridRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
},
|
||||||
|
dayCell: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 2,
|
||||||
|
aspectRatio: 1,
|
||||||
|
},
|
||||||
|
dayInner: {
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
dayText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
todayBorder: {
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: "#0E8A61",
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
marginTop: 12,
|
||||||
|
paddingTop: 12,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: "#F3F4F6",
|
||||||
|
},
|
||||||
|
legendRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
legendSwatch: {
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 4,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
legendText: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#374151",
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
},
|
||||||
|
legendNeutral: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: 12,
|
||||||
|
paddingTop: 12,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: "#F3F4F6",
|
||||||
|
},
|
||||||
|
legendNeutralText: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#6B7280",
|
||||||
|
marginLeft: 6,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
ctaBox: {
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
ctaText: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#6B7280",
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: 10,
|
||||||
|
marginBottom: 14,
|
||||||
|
},
|
||||||
|
ctaBtn: {
|
||||||
|
backgroundColor: "#0E8A61",
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
ctaBtnText: {
|
||||||
|
color: "#FFFFFF",
|
||||||
|
fontWeight: "700",
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
});
|
||||||
121
frontend/src/components/NotificationToast.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* NotificationToast.tsx
|
||||||
|
*
|
||||||
|
* Banner animado que cae desde arriba cuando llega una notificación
|
||||||
|
* nueva del backend. Se autodescarta a los 5 segundos.
|
||||||
|
*
|
||||||
|
* Limitación: solo se muestra cuando la app está en primer plano. Para
|
||||||
|
* notificaciones del SO con la app cerrada se necesita un Development
|
||||||
|
* Build + expo-notifications (Expo Go SDK 53+ no lo soporta).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
Platform,
|
||||||
|
} from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
import { useApp } from "../context/AppContext";
|
||||||
|
|
||||||
|
const AUTO_DISMISS_MS = 5000;
|
||||||
|
|
||||||
|
export default function NotificationToast() {
|
||||||
|
const { toast, dismissToast } = useApp();
|
||||||
|
const translateY = useRef(new Animated.Value(-180)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!toast) return;
|
||||||
|
|
||||||
|
// Slide in
|
||||||
|
Animated.spring(translateY, {
|
||||||
|
toValue: 0,
|
||||||
|
useNativeDriver: true,
|
||||||
|
damping: 18,
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
// Auto dismiss después de N segundos
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
Animated.timing(translateY, {
|
||||||
|
toValue: -180,
|
||||||
|
duration: 250,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start(() => dismissToast());
|
||||||
|
}, AUTO_DISMISS_MS);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [toast, translateY, dismissToast]);
|
||||||
|
|
||||||
|
if (!toast) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{ transform: [{ translateY }] },
|
||||||
|
]}
|
||||||
|
pointerEvents="box-none"
|
||||||
|
>
|
||||||
|
<Pressable style={styles.card} onPress={dismissToast}>
|
||||||
|
<View style={styles.iconWrap}>
|
||||||
|
<Ionicons name="notifications" size={22} color="#FFFFFF" />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.title} numberOfLines={1}>
|
||||||
|
{toast.title}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.body} numberOfLines={2}>
|
||||||
|
{toast.body}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="close" size={18} color="#FFFFFF" />
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
position: "absolute",
|
||||||
|
top: Platform.OS === "ios" ? 50 : 30,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
zIndex: 9999,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#0E8A61",
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 14,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 6 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 10,
|
||||||
|
elevation: 10,
|
||||||
|
},
|
||||||
|
iconWrap: {
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
borderRadius: 19,
|
||||||
|
backgroundColor: "rgba(255,255,255,0.2)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "800",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: "rgba(255,255,255,0.95)",
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -3,13 +3,15 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from "react-native";
|
||||||
|
|
||||||
import { Colors, Spacing } from '../constants/theme';
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
import { Colors, Spacing } from "../constants/theme";
|
||||||
|
|
||||||
type QuickActionProps = {
|
type QuickActionProps = {
|
||||||
title: string;
|
title: string;
|
||||||
icon: string;
|
icon: any;
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -18,7 +20,6 @@ export default function QuickAction({
|
|||||||
icon,
|
icon,
|
||||||
onPress,
|
onPress,
|
||||||
}: QuickActionProps) {
|
}: QuickActionProps) {
|
||||||
|
|
||||||
const theme = Colors.light;
|
const theme = Colors.light;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -32,9 +33,11 @@ export default function QuickAction({
|
|||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
>
|
>
|
||||||
<View style={styles.iconContainer}>
|
<View style={styles.iconContainer}>
|
||||||
<Text style={styles.icon}>
|
<Ionicons
|
||||||
{icon}
|
name={icon}
|
||||||
</Text>
|
size={30}
|
||||||
|
color="#0E8A61"
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
@@ -53,24 +56,24 @@ export default function QuickAction({
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
width: 100,
|
width: 150,
|
||||||
padding: Spacing.three,
|
|
||||||
borderRadius: 16,
|
paddingVertical: 20,
|
||||||
alignItems: 'center',
|
paddingHorizontal: 12,
|
||||||
justifyContent: 'center',
|
|
||||||
|
borderRadius: 18,
|
||||||
|
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
|
|
||||||
iconContainer: {
|
iconContainer: {
|
||||||
marginBottom: Spacing.two,
|
marginBottom: Spacing.two,
|
||||||
},
|
},
|
||||||
|
|
||||||
icon: {
|
|
||||||
fontSize: 28,
|
|
||||||
},
|
|
||||||
|
|
||||||
title: {
|
title: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: '600',
|
fontWeight: "600",
|
||||||
textAlign: 'center',
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
3
frontend/src/config/api.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// android
|
||||||
|
// export const API_URL = "http://10.0.2.2:8080";
|
||||||
|
export const API_URL = "http://172.20.10.4:8080";
|
||||||
181
frontend/src/context/AppContext.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import { setAuthToken } from "../lib/api";
|
||||||
|
import {
|
||||||
|
login as loginService,
|
||||||
|
register as registerService,
|
||||||
|
type AuthUser,
|
||||||
|
} from "../services/auth.service";
|
||||||
|
import {
|
||||||
|
getMyStatus,
|
||||||
|
type EtaResult,
|
||||||
|
type InboxNotification,
|
||||||
|
type UserStatusResponse,
|
||||||
|
} from "../services/tracking.service";
|
||||||
|
// expo-notifications NO se importa: en Expo Go SDK 53+ se rompe al cargar
|
||||||
|
// porque intenta auto-registrar un push token. Para push real necesitamos
|
||||||
|
// un Development Build. Por ahora usamos un Toast in-app vía el callback
|
||||||
|
// onNewNotification.
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 30_000; // 30s, igual que el simulador del backend
|
||||||
|
|
||||||
|
interface AppContextValue {
|
||||||
|
user: AuthUser | null;
|
||||||
|
eta: EtaResult | null;
|
||||||
|
notifications: InboxNotification[];
|
||||||
|
route: UserStatusResponse["route"] | null;
|
||||||
|
loading: boolean;
|
||||||
|
toast: InboxNotification | null;
|
||||||
|
dismissToast: () => void;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
register: (name: string, email: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
refreshStatus: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppContext = createContext<AppContextValue | null>(null);
|
||||||
|
|
||||||
|
export const useApp = (): AppContextValue => {
|
||||||
|
const ctx = useContext(AppContext);
|
||||||
|
if (!ctx) throw new Error("useApp must be used within AppProvider");
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AppProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
|
const [eta, setEta] = useState<EtaResult | null>(null);
|
||||||
|
const [notifications, setNotifications] = useState<InboxNotification[]>([]);
|
||||||
|
const [route, setRoute] = useState<UserStatusResponse["route"] | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
// IDs de notificaciones que ya disparamos como push local — evita duplicados
|
||||||
|
const shownNotificationIds = useRef<Set<string>>(new Set());
|
||||||
|
// Bandera: en el primer refresh tras login no disparamos el historial
|
||||||
|
// entero como notificaciones; solo lo marcamos como "ya visto".
|
||||||
|
const initialRefreshDoneRef = useRef(false);
|
||||||
|
|
||||||
|
// Última notificación nueva — la consume el Toast in-app
|
||||||
|
const [toast, setToast] = useState<InboxNotification | null>(null);
|
||||||
|
const dismissToast = useCallback(() => setToast(null), []);
|
||||||
|
|
||||||
|
const refreshStatus = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getMyStatus();
|
||||||
|
setEta(res.eta);
|
||||||
|
setRoute(res.route);
|
||||||
|
setNotifications(res.notifications);
|
||||||
|
|
||||||
|
// Detecta notificaciones nuevas (no vistas antes) y muestra la más
|
||||||
|
// reciente como Toast in-app. En el primer refresh no toasteamos
|
||||||
|
// para no inundar al usuario con el historial.
|
||||||
|
const newOnes: InboxNotification[] = [];
|
||||||
|
for (const n of res.notifications) {
|
||||||
|
if (!shownNotificationIds.current.has(n.id)) {
|
||||||
|
shownNotificationIds.current.add(n.id);
|
||||||
|
newOnes.push(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (initialRefreshDoneRef.current && newOnes.length > 0) {
|
||||||
|
setToast(newOnes[0]); // la primera del array es la más reciente
|
||||||
|
}
|
||||||
|
initialRefreshDoneRef.current = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Status refresh failed:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await loginService({ email, password });
|
||||||
|
setUser(res.user);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const register = useCallback(
|
||||||
|
async (name: string, email: string, password: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// 1) crear cuenta
|
||||||
|
await registerService({ name, email, password });
|
||||||
|
// 2) hacer login inmediato para guardar el token y el user
|
||||||
|
const res = await loginService({ email, password });
|
||||||
|
setUser(res.user);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
setAuthToken(null);
|
||||||
|
setUser(null);
|
||||||
|
setEta(null);
|
||||||
|
setRoute(null);
|
||||||
|
setNotifications([]);
|
||||||
|
setToast(null);
|
||||||
|
// Reset del tracking de notificaciones para que la próxima sesión
|
||||||
|
// arranque limpia (sin disparar el historial viejo).
|
||||||
|
shownNotificationIds.current.clear();
|
||||||
|
initialRefreshDoneRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Polling controlado: arranca al loguearse como user normal, se detiene
|
||||||
|
// al deslogearse o si el usuario es admin (los admins no usan /status).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || user.role === "ADMIN") {
|
||||||
|
if (pollingRef.current) {
|
||||||
|
clearInterval(pollingRef.current);
|
||||||
|
pollingRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void refreshStatus();
|
||||||
|
pollingRef.current = setInterval(() => {
|
||||||
|
void refreshStatus();
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (pollingRef.current) {
|
||||||
|
clearInterval(pollingRef.current);
|
||||||
|
pollingRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [user, refreshStatus]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
eta,
|
||||||
|
notifications,
|
||||||
|
route,
|
||||||
|
loading,
|
||||||
|
toast,
|
||||||
|
dismissToast,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
refreshStatus,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AppContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
32
frontend/src/data/mocks/routes.mock.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* routes.mock.ts (frontend)
|
||||||
|
* Lista de rutas para el calendario de recolección.
|
||||||
|
* No incluye coordenadas GPS — eso vive en el backend.
|
||||||
|
* Solo lo necesario para el selector y el calendario.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface MockRoute {
|
||||||
|
routeId: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const routes: MockRoute[] = [
|
||||||
|
{ routeId: "RUTA-01", name: "Zona Centro - Las Arboledas" },
|
||||||
|
{ routeId: "RUTA-02", name: "Sector Norte - Av. Tecnológico" },
|
||||||
|
{ routeId: "RUTA-03", name: "Sector Poniente - San Juanico" },
|
||||||
|
{ routeId: "RUTA-04", name: "Oriente - Los Olivos" },
|
||||||
|
{ routeId: "RUTA-05", name: "Sector Sur - Rancho Seco" },
|
||||||
|
{ routeId: "RUTA-06", name: "Norte Extremo - Rumbos de Roque" },
|
||||||
|
{ routeId: "RUTA-07", name: "Nororiente - Ciudad Industrial" },
|
||||||
|
{ routeId: "RUTA-08", name: "Suroriente - Universidad Latina" },
|
||||||
|
{ routeId: "RUTA-09", name: "Poniente - Hospital General" },
|
||||||
|
{ routeId: "RUTA-10", name: "Eje Juan Pablo II" },
|
||||||
|
{ routeId: "RUTA-11", name: "Zona de Oro - Torres Landa" },
|
||||||
|
{ routeId: "RUTA-12", name: "Nororiente - Las Insurgentes" },
|
||||||
|
{ routeId: "RUTA-13", name: "Sector Norte - Trojes e Irrigación" },
|
||||||
|
{ routeId: "RUTA-14", name: "Sur Poniente - La Toscana" },
|
||||||
|
{ routeId: "RUTA-15", name: "Norponiente - San José de Celaya" },
|
||||||
|
{ routeId: "RUTA-40", name: "Norte Industrial" },
|
||||||
|
{ routeId: "RUTA-65", name: "Centro Comercial" },
|
||||||
|
{ routeId: "RUTA-80", name: "Sector Residencial Sur" },
|
||||||
|
];
|
||||||
30
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { API_URL } from "../config/api";
|
||||||
|
|
||||||
|
let authToken: string | null = null;
|
||||||
|
|
||||||
|
export const setAuthToken = (token: string | null) => {
|
||||||
|
authToken = token;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiFetch = async <T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
): Promise<T> => {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(options.headers as Record<string, string>),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (authToken) {
|
||||||
|
headers.Authorization = `Bearer ${authToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${API_URL}${path}`, { ...options, headers });
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return body as T;
|
||||||
|
};
|
||||||
14
frontend/src/lib/notification-mapper.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { NotificationType } from "../services/tracking.service";
|
||||||
|
|
||||||
|
export const notificationTypeToAlertType = (
|
||||||
|
type: NotificationType,
|
||||||
|
): "started" | "near" | "danger" | "completed" => {
|
||||||
|
switch (type) {
|
||||||
|
case "ROUTE_START": return "started";
|
||||||
|
case "TRUCK_PROXIMITY": return "near";
|
||||||
|
case "TRUCK_ARRIVED": return "near";
|
||||||
|
case "ROUTE_COMPLETED": return "completed";
|
||||||
|
case "DELAY": return "danger";
|
||||||
|
case "MECHANICAL_FAILURE": return "danger";
|
||||||
|
}
|
||||||
|
};
|
||||||
64
frontend/src/lib/notifications.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* notifications.ts
|
||||||
|
*
|
||||||
|
* Wrapper sobre expo-notifications. Maneja:
|
||||||
|
* - Configuración del canal Android
|
||||||
|
* - Solicitud de permisos
|
||||||
|
* - Disparo de notificaciones locales (aparecen en el centro de
|
||||||
|
* notificaciones del sistema incluso si la app está en segundo plano)
|
||||||
|
*
|
||||||
|
* NOTA: Esto usa notificaciones LOCALES, no push remoto. Funciona cuando
|
||||||
|
* la app está en foreground o background (no totalmente cerrada). Para
|
||||||
|
* notificaciones con la app completamente cerrada hace falta un Development
|
||||||
|
* Build + Expo Push Service, fuera del alcance del MVP.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as Notifications from "expo-notifications";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
// Cómo se comportan las notificaciones cuando la app está en primer plano
|
||||||
|
Notifications.setNotificationHandler({
|
||||||
|
handleNotification: async () => ({
|
||||||
|
shouldShowBanner: true,
|
||||||
|
shouldShowList: true,
|
||||||
|
shouldPlaySound: true,
|
||||||
|
shouldSetBadge: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Crea el canal de Android (requerido en Android 8+). Idempotente. */
|
||||||
|
export async function setupAndroidChannel(): Promise<void> {
|
||||||
|
if (Platform.OS !== "android") return;
|
||||||
|
await Notifications.setNotificationChannelAsync("ecoruta", {
|
||||||
|
name: "OptiRuta",
|
||||||
|
importance: Notifications.AndroidImportance.HIGH,
|
||||||
|
vibrationPattern: [0, 250, 250, 250],
|
||||||
|
lightColor: "#0E8A61",
|
||||||
|
sound: "default",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Solicita permiso al usuario (no-op si ya está concedido). */
|
||||||
|
export async function requestPushPermissions(): Promise<boolean> {
|
||||||
|
const settings = await Notifications.getPermissionsAsync();
|
||||||
|
if (settings.granted) return true;
|
||||||
|
const result = await Notifications.requestPermissionsAsync();
|
||||||
|
return result.granted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispara una notificación local inmediata. */
|
||||||
|
export async function fireLocalNotification(
|
||||||
|
title: string,
|
||||||
|
body: string,
|
||||||
|
data: Record<string, unknown> = {},
|
||||||
|
): Promise<void> {
|
||||||
|
await Notifications.scheduleNotificationAsync({
|
||||||
|
content: {
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
data,
|
||||||
|
sound: "default",
|
||||||
|
},
|
||||||
|
trigger: null, // inmediata
|
||||||
|
});
|
||||||
|
}
|
||||||
25
frontend/src/services/addresses.service.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { apiFetch } from "../lib/api";
|
||||||
|
|
||||||
|
export interface Colonia {
|
||||||
|
colonia: string;
|
||||||
|
routeId: string;
|
||||||
|
horarioEstimado: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MyAddress {
|
||||||
|
colonia: string;
|
||||||
|
routeId: string;
|
||||||
|
horarioEstimado: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listColonias = () =>
|
||||||
|
apiFetch<Colonia[]>("/api/addresses/colonias");
|
||||||
|
|
||||||
|
export const getMyAddress = () =>
|
||||||
|
apiFetch<MyAddress>("/api/addresses/me");
|
||||||
|
|
||||||
|
export const setMyAddress = (colonia: string, street?: string) =>
|
||||||
|
apiFetch<MyAddress>("/api/addresses/me", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ colonia, street }),
|
||||||
|
});
|
||||||
55
frontend/src/services/admin.service.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { apiFetch } from "../lib/api";
|
||||||
|
|
||||||
|
export interface AdminRouteItem {
|
||||||
|
routeId: string;
|
||||||
|
name: string;
|
||||||
|
truckId: number;
|
||||||
|
status: string;
|
||||||
|
currentPositionId: number;
|
||||||
|
arrivalResult: "PENDING" | "ARRIVED" | "FAILED" | "CANCELLED";
|
||||||
|
cancelled: boolean;
|
||||||
|
cancelReason?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FeedbackType =
|
||||||
|
| "TRUCK_DID_NOT_PASS"
|
||||||
|
| "RATING"
|
||||||
|
| "SUGGESTION"
|
||||||
|
| "OTHER";
|
||||||
|
|
||||||
|
export interface AdminFeedbackItem {
|
||||||
|
id: string;
|
||||||
|
userId: number;
|
||||||
|
routeId: string | null;
|
||||||
|
userName?: string;
|
||||||
|
colonia?: string;
|
||||||
|
type: FeedbackType;
|
||||||
|
message: string;
|
||||||
|
rating?: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listAllRoutes = () =>
|
||||||
|
apiFetch<AdminRouteItem[]>("/api/admin/routes");
|
||||||
|
|
||||||
|
export const listAllFeedback = () =>
|
||||||
|
apiFetch<AdminFeedbackItem[]>("/api/admin/feedback");
|
||||||
|
|
||||||
|
export const cancelRoute = (routeId: string, reason?: string) =>
|
||||||
|
apiFetch<{ message: string; routeId: string }>(
|
||||||
|
`/api/admin/routes/${routeId}/cancel`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(reason ? { reason } : {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const resumeRoute = (routeId: string) =>
|
||||||
|
apiFetch<{ message: string; routeId: string }>(
|
||||||
|
`/api/admin/routes/${routeId}/resume`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
},
|
||||||
|
);
|
||||||
26
frontend/src/services/auth.service.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { apiFetch, setAuthToken } from "../lib/api";
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const register = (data: { name: string; email: string; password: string }) =>
|
||||||
|
apiFetch<{ id: number; email: string; name: string }>("/api/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const login = async (data: { email: string; password: string }) => {
|
||||||
|
const res = await apiFetch<{ user: AuthUser; token: string }>("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
setAuthToken(res.token);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMe = () =>
|
||||||
|
apiFetch<AuthUser & { role: string }>("/api/auth/me");
|
||||||
31
frontend/src/services/feedback.service.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { apiFetch } from "../lib/api";
|
||||||
|
|
||||||
|
export type FeedbackType =
|
||||||
|
| "TRUCK_DID_NOT_PASS"
|
||||||
|
| "RATING"
|
||||||
|
| "SUGGESTION"
|
||||||
|
| "OTHER";
|
||||||
|
|
||||||
|
export interface FeedbackPayload {
|
||||||
|
type: FeedbackType;
|
||||||
|
message: string;
|
||||||
|
rating?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedbackItem {
|
||||||
|
id: string;
|
||||||
|
userId: number;
|
||||||
|
type: FeedbackType;
|
||||||
|
message: string;
|
||||||
|
rating?: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const submitFeedback = (payload: FeedbackPayload) =>
|
||||||
|
apiFetch<FeedbackItem>("/api/feedback", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listMyFeedback = () =>
|
||||||
|
apiFetch<FeedbackItem[]>("/api/feedback/me");
|
||||||
78
frontend/src/services/tracking.service.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { apiFetch } from "../lib/api";
|
||||||
|
|
||||||
|
export type NotificationType =
|
||||||
|
| "ROUTE_START"
|
||||||
|
| "TRUCK_PROXIMITY"
|
||||||
|
| "TRUCK_ARRIVED"
|
||||||
|
| "ROUTE_COMPLETED"
|
||||||
|
| "DELAY"
|
||||||
|
| "MECHANICAL_FAILURE";
|
||||||
|
|
||||||
|
export interface GpsUpdatePayload {
|
||||||
|
truckId: string;
|
||||||
|
routeId: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
speed: number;
|
||||||
|
status: "EN_RUTA" | "DETENIDO" | "FINALIZADO" | "FALLA";
|
||||||
|
positionId?: number;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EtaResult {
|
||||||
|
etaMinutes: number;
|
||||||
|
arrivalWindow: { from: string; to: string };
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendNotification {
|
||||||
|
userId: number;
|
||||||
|
type: NotificationType;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GpsUpdateResponse {
|
||||||
|
message: string;
|
||||||
|
truck: { truckId: number; routeId: string; status: string };
|
||||||
|
eta: EtaResult;
|
||||||
|
notifications: BackendNotification[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendGpsUpdate = (payload: GpsUpdatePayload) =>
|
||||||
|
apiFetch<GpsUpdateResponse>("/api/tracking/gps-update", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface InboxNotification {
|
||||||
|
id: string;
|
||||||
|
userId: number;
|
||||||
|
type: NotificationType;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ArrivalResult = "PENDING" | "ARRIVED" | "FAILED" | "CANCELLED";
|
||||||
|
|
||||||
|
export interface UserStatusResponse {
|
||||||
|
user: { id: number; name: string; colonia: string };
|
||||||
|
route: {
|
||||||
|
routeId: string;
|
||||||
|
currentPositionId: number;
|
||||||
|
status: string;
|
||||||
|
updatedAt: string;
|
||||||
|
horarioEstimado: string | null;
|
||||||
|
arrivalResult: ArrivalResult;
|
||||||
|
cancelled: boolean;
|
||||||
|
};
|
||||||
|
eta: EtaResult | null;
|
||||||
|
notifications: InboxNotification[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMyStatus = () =>
|
||||||
|
apiFetch<UserStatusResponse>("/api/tracking/status");
|
||||||
|
|
||||||
|
export const resetDemo = () =>
|
||||||
|
apiFetch<{ message: string }>("/api/tracking/reset-demo", { method: "POST" });
|
||||||