diff --git a/frontend/src/app/_layout.tsx b/frontend/src/app/_layout.tsx index 394c415..8f919b4 100644 --- a/frontend/src/app/_layout.tsx +++ b/frontend/src/app/_layout.tsx @@ -1,14 +1,14 @@ import { View } from "react-native"; import { Tabs } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; -import { AppProvider } from "../context/AppContext"; +import { AppProvider, useApp } from "../context/AppContext"; import NotificationToast from "../components/NotificationToast"; -export default function Layout() { +function AppTabs() { + const { user } = useApp(); + const isAdmin = user?.role === "ADMIN"; + return ( - - - + {/* Tabs de USER — escondidas para ADMIN */} ( ( ( ( + {/* Tab de ADMIN — solo visible si el user logueado es admin */} ( + + ), + }} /> - - - - - + {/* Pantallas siempre escondidas del tab bar */} + + + + - + ); +} + +export default function Layout() { + return ( + + + + + ); } diff --git a/frontend/src/app/admin.tsx b/frontend/src/app/admin.tsx new file mode 100644 index 0000000..95c636e --- /dev/null +++ b/frontend/src/app/admin.tsx @@ -0,0 +1,375 @@ +/** + * 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, + listAllRoutes, + resumeRoute, + type AdminRouteItem, +} from "../services/admin.service"; + +const arrivalLabel: Record = { + 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([]); + const [loading, setLoading] = useState(true); + + const fetchRoutes = useCallback(async () => { + try { + const data = await listAllRoutes(); + setRoutes(data); + } catch (err) { + Alert.alert( + "Error", + err instanceof Error ? err.message : "No se pudieron cargar las rutas", + ); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (!user || user.role !== "ADMIN") return; + void fetchRoutes(); + const interval = setInterval(() => void fetchRoutes(), 15_000); + return () => clearInterval(interval); + }, [user, fetchRoutes]); + + if (!user) return ; + if (user.role !== "ADMIN") return ; + + 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 ( + + + + Panel de Administración + + {user.email} · {routes.length} rutas + + + + + + + + {loading ? ( + + ) : ( + + } + > + {/* === Reportes del día === */} + REPORTES DEL DÍA + + {(() => { + 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) => ( + + + + + + {it.value} + + {it.label} + + )); + })()} + + + {/* === Lista de rutas === */} + RUTAS + {routes.map((r) => { + const arrival = arrivalLabel[r.arrivalResult] ?? arrivalLabel.PENDING; + return ( + + + + {r.routeId} + {r.name} + + + + {arrival.label} + + + + + + + Camión #{r.truckId} · Posición {r.currentPositionId}/8 ·{" "} + {r.status} + + + + {r.cancelled ? ( + handleResume(r.routeId)} + > + + Reanudar + + ) : ( + handleCancel(r.routeId)} + > + + Cancelar ruta + + )} + + {r.cancelReason ? ( + + Motivo: {r.cancelReason} + + ) : null} + + ); + })} + + )} + + ); +} + +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", + }, +}); diff --git a/frontend/src/app/index.tsx b/frontend/src/app/index.tsx index a53adc1..6831c53 100644 --- a/frontend/src/app/index.tsx +++ b/frontend/src/app/index.tsx @@ -34,22 +34,47 @@ export default function HomeScreen() { }, [user]); if (!user) return ; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (user.role === "ADMIN") return ; const minutes = eta ? Math.max(0, eta.etaMinutes) : null; const windowText = eta?.arrivalWindow ? `${eta.arrivalWindow.from} - ${eta.arrivalWindow.to} a.m.` : "Calculando..."; - const statusOK = route?.status === "EN_RUTA" || route?.status === "ESPERA"; - const statusColor = statusOK ? "#22C55E" : "#EF4444"; - const statusLabel = - route?.status === "EN_RUTA" - ? "ESTABLE" - : route?.status === "FALLA" - ? "CON FALLA" - : route?.status === "DETENIDO" - ? "DETENIDO" + // 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[] = [ { @@ -155,11 +180,7 @@ export default function HomeScreen() { ESTADO DE RUTA: {statusLabel} - - {statusOK - ? "Tu recolección sigue en horario normal." - : "Hay incidencias en la ruta."} - + {statusBody} { initialRefreshDoneRef.current = false; }, []); - // Polling controlado: arranca al loguearse, se detiene al deslogearse. + // 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) { + if (!user || user.role === "ADMIN") { if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; diff --git a/frontend/src/services/admin.service.ts b/frontend/src/services/admin.service.ts new file mode 100644 index 0000000..f9ea032 --- /dev/null +++ b/frontend/src/services/admin.service.ts @@ -0,0 +1,34 @@ +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 const listAllRoutes = () => + apiFetch("/api/admin/routes"); + +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({}), + }, + ); diff --git a/frontend/src/services/auth.service.ts b/frontend/src/services/auth.service.ts index 867caa6..be55165 100644 --- a/frontend/src/services/auth.service.ts +++ b/frontend/src/services/auth.service.ts @@ -4,6 +4,7 @@ export interface AuthUser { id: number; email: string; name: string; + role: string; } export const register = (data: { name: string; email: string; password: string }) => diff --git a/frontend/src/services/tracking.service.ts b/frontend/src/services/tracking.service.ts index 3fee547..28f42e5 100644 --- a/frontend/src/services/tracking.service.ts +++ b/frontend/src/services/tracking.service.ts @@ -54,6 +54,8 @@ export interface InboxNotification { createdAt: string; } +export type ArrivalResult = "PENDING" | "ARRIVED" | "FAILED" | "CANCELLED"; + export interface UserStatusResponse { user: { id: number; name: string; colonia: string }; route: { @@ -62,6 +64,8 @@ export interface UserStatusResponse { status: string; updatedAt: string; horarioEstimado: string | null; + arrivalResult: ArrivalResult; + cancelled: boolean; }; eta: EtaResult | null; notifications: InboxNotification[];