feat: Improve the administrator profile

This commit is contained in:
Diego Mireles
2026-05-23 08:58:23 -06:00
parent 5b8711cdf0
commit 5833063053
8 changed files with 493 additions and 52 deletions

View File

@@ -1,14 +1,14 @@
import { View } from "react-native"; 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 } from "../context/AppContext"; import { AppProvider, useApp } from "../context/AppContext";
import NotificationToast from "../components/NotificationToast"; import NotificationToast from "../components/NotificationToast";
export default function Layout() { function AppTabs() {
const { user } = useApp();
const isAdmin = user?.role === "ADMIN";
return ( return (
<AppProvider>
<View style={{ flex: 1 }}>
<NotificationToast />
<Tabs <Tabs
screenOptions={{ screenOptions={{
headerShown: false, headerShown: false,
@@ -21,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,
}, },
@@ -59,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"}
@@ -77,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"}
@@ -91,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"}
@@ -105,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"}
@@ -115,26 +109,37 @@ export default function Layout() {
}} }}
/> />
{/* Tab de ADMIN — solo visible si el user logueado es admin */}
<Tabs.Screen <Tabs.Screen
name="login" name="admin"
options={{ href: null }} options={{
title: "Admin",
href: isAdmin ? undefined : null,
tabBarIcon: ({ focused, color }) => (
<Ionicons
name={focused ? "shield-checkmark" : "shield-checkmark-outline"}
size={focused ? 28 : 24}
color={color}
/>
),
}}
/> />
<Tabs.Screen {/* Pantallas siempre escondidas del tab bar */}
name="register" <Tabs.Screen name="login" options={{ href: null }} />
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.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> </View>
</AppProvider> </AppProvider>
); );

375
frontend/src/app/admin.tsx Normal file
View File

@@ -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<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 [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 <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>
);
})}
</ScrollView>
)}
</SafeAreaView>
);
}
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",
},
});

View File

@@ -34,22 +34,47 @@ export default function HomeScreen() {
}, [user]); }, [user]);
if (!user) return <Redirect href="/login" />; 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 minutes = eta ? Math.max(0, eta.etaMinutes) : null;
const windowText = eta?.arrivalWindow const windowText = eta?.arrivalWindow
? `${eta.arrivalWindow.from} - ${eta.arrivalWindow.to} a.m.` ? `${eta.arrivalWindow.from} - ${eta.arrivalWindow.to} a.m.`
: "Calculando..."; : "Calculando...";
const statusOK = route?.status === "EN_RUTA" || route?.status === "ESPERA"; // Si la ruta fue cancelada por admin → eso manda
const statusColor = statusOK ? "#22C55E" : "#EF4444"; // Si el simulador marcó FAILED → falla mecánica
const statusLabel = // Si el simulador marcó ARRIVED → ya pasó el camión
route?.status === "EN_RUTA" // Si no, mostramos el status genérico
? "ESTABLE" const arrival = route?.arrivalResult ?? "PENDING";
: route?.status === "FALLA" 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" ? "CON FALLA"
: route?.status === "DETENIDO" : arrival === "ARRIVED"
? "DETENIDO" ? "EL CAMIÓN YA PASÓ"
: route?.status === "EN_RUTA"
? "ESTABLE"
: "EN ESPERA"; : "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[] = [ const quickActions: QuickAction[] = [
{ {
@@ -155,11 +180,7 @@ export default function HomeScreen() {
<Text style={[styles.statusTitle, { color: statusColor }]}> <Text style={[styles.statusTitle, { color: statusColor }]}>
ESTADO DE RUTA: {statusLabel} ESTADO DE RUTA: {statusLabel}
</Text> </Text>
<Text style={styles.statusBody}> <Text style={styles.statusBody}>{statusBody}</Text>
{statusOK
? "Tu recolección sigue en horario normal."
: "Hay incidencias en la ruta."}
</Text>
</View> </View>
<View <View
style={[ style={[

View File

@@ -1,3 +1,3 @@
// android // android
export const API_URL = "http://10.0.2.2:8080"; // export const API_URL = "http://10.0.2.2:8080";
//export const API_URL = "http://172.20.10.4:8080"; export const API_URL = "http://172.20.10.4:8080";

View File

@@ -135,9 +135,10 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
initialRefreshDoneRef.current = false; 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(() => { useEffect(() => {
if (!user) { if (!user || user.role === "ADMIN") {
if (pollingRef.current) { if (pollingRef.current) {
clearInterval(pollingRef.current); clearInterval(pollingRef.current);
pollingRef.current = null; pollingRef.current = null;

View File

@@ -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<AdminRouteItem[]>("/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({}),
},
);

View File

@@ -4,6 +4,7 @@ export interface AuthUser {
id: number; id: number;
email: string; email: string;
name: string; name: string;
role: string;
} }
export const register = (data: { name: string; email: string; password: string }) => export const register = (data: { name: string; email: string; password: string }) =>

View File

@@ -54,6 +54,8 @@ export interface InboxNotification {
createdAt: string; createdAt: string;
} }
export type ArrivalResult = "PENDING" | "ARRIVED" | "FAILED" | "CANCELLED";
export interface UserStatusResponse { export interface UserStatusResponse {
user: { id: number; name: string; colonia: string }; user: { id: number; name: string; colonia: string };
route: { route: {
@@ -62,6 +64,8 @@ export interface UserStatusResponse {
status: string; status: string;
updatedAt: string; updatedAt: string;
horarioEstimado: string | null; horarioEstimado: string | null;
arrivalResult: ArrivalResult;
cancelled: boolean;
}; };
eta: EtaResult | null; eta: EtaResult | null;
notifications: InboxNotification[]; notifications: InboxNotification[];