feat: Add feedback for administrator

This commit is contained in:
Diego Mireles
2026-05-23 09:30:38 -06:00
parent 5833063053
commit ad1bf1af3d
3 changed files with 497 additions and 42 deletions

View File

@@ -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."

View File

@@ -24,9 +24,12 @@ import { COLORS } from "../constants/colors";
import { useApp } from "../context/AppContext"; import { useApp } from "../context/AppContext";
import { import {
cancelRoute, cancelRoute,
listAllFeedback,
listAllRoutes, listAllRoutes,
resumeRoute, resumeRoute,
type AdminFeedbackItem,
type AdminRouteItem, type AdminRouteItem,
type FeedbackType,
} from "../services/admin.service"; } from "../services/admin.service";
const arrivalLabel: Record<string, { label: string; color: string }> = { const arrivalLabel: Record<string, { label: string; color: string }> = {
@@ -39,28 +42,36 @@ const arrivalLabel: Record<string, { label: string; color: string }> = {
export default function AdminScreen() { export default function AdminScreen() {
const { user, logout } = useApp(); const { user, logout } = useApp();
const [routes, setRoutes] = useState<AdminRouteItem[]>([]); const [routes, setRoutes] = useState<AdminRouteItem[]>([]);
const [feedback, setFeedback] = useState<AdminFeedbackItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const fetchRoutes = useCallback(async () => { const fetchAll = useCallback(async () => {
try { try {
const data = await listAllRoutes(); const [routesData, feedbackData] = await Promise.all([
setRoutes(data); listAllRoutes(),
listAllFeedback(),
]);
setRoutes(routesData);
setFeedback(feedbackData);
} catch (err) { } catch (err) {
Alert.alert( Alert.alert(
"Error", "Error",
err instanceof Error ? err.message : "No se pudieron cargar las rutas", err instanceof Error ? err.message : "No se pudieron cargar los datos",
); );
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, []);
// Alias para compatibilidad con el resto del archivo
const fetchRoutes = fetchAll;
useEffect(() => { useEffect(() => {
if (!user || user.role !== "ADMIN") return; if (!user || user.role !== "ADMIN") return;
void fetchRoutes(); void fetchAll();
const interval = setInterval(() => void fetchRoutes(), 15_000); const interval = setInterval(() => void fetchAll(), 15_000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [user, fetchRoutes]); }, [user, fetchAll]);
if (!user) return <Redirect href="/login" />; if (!user) return <Redirect href="/login" />;
if (user.role !== "ADMIN") return <Redirect href="/" />; if (user.role !== "ADMIN") return <Redirect href="/" />;
@@ -259,12 +270,127 @@ export default function AdminScreen() {
</View> </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> </ScrollView>
)} )}
</SafeAreaView> </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({ const styles = StyleSheet.create({
headerRow: { headerRow: {
flexDirection: "row", flexDirection: "row",
@@ -372,4 +498,75 @@ const styles = StyleSheet.create({
marginTop: 2, marginTop: 2,
textAlign: "center", 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,
},
}); });

View File

@@ -12,9 +12,30 @@ export interface AdminRouteItem {
updatedAt?: 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 = () => export const listAllRoutes = () =>
apiFetch<AdminRouteItem[]>("/api/admin/routes"); apiFetch<AdminRouteItem[]>("/api/admin/routes");
export const listAllFeedback = () =>
apiFetch<AdminFeedbackItem[]>("/api/admin/feedback");
export const cancelRoute = (routeId: string, reason?: string) => export const cancelRoute = (routeId: string, reason?: string) =>
apiFetch<{ message: string; routeId: string }>( apiFetch<{ message: string; routeId: string }>(
`/api/admin/routes/${routeId}/cancel`, `/api/admin/routes/${routeId}/cancel`,