feat: Add feedback for administrator
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
- [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)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Expo SDK 56** (Sept 2025)
|
||||||
|
- **React Native 0.85** + **React 19**
|
||||||
|
- **TypeScript** estricto
|
||||||
|
- **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)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estructura
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
├── app.json ← config de Expo (name="OptiRuta", slug, etc.)
|
||||||
|
├── 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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cómo correr
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd frontend
|
||||||
npm install
|
npm install
|
||||||
|
npx expo start -c # -c limpia caché de Metro
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Start the app
|
Después:
|
||||||
|
- `a` → Android emulator
|
||||||
|
- `i` → iOS simulator (solo Mac)
|
||||||
|
- `w` → web
|
||||||
|
- Escanear QR con Expo Go en celular físico
|
||||||
|
|
||||||
```bash
|
---
|
||||||
npx expo start
|
|
||||||
|
## Configurar la URL del backend
|
||||||
|
|
||||||
|
Edita `src/config/api.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Para Android emulator local:
|
||||||
|
export const API_URL = "http://10.0.2.2:8080";
|
||||||
|
|
||||||
|
// Para celular físico (mismo Wi-Fi o hotspot):
|
||||||
|
// export const API_URL = "http://192.168.X.X:8080";
|
||||||
```
|
```
|
||||||
|
|
||||||
In the output, you'll find options to open the app in a
|
| 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` |
|
||||||
|
|
||||||
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
|
> Si estás en red de escuela/oficina con AP isolation, usa hotspot del celular.
|
||||||
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
|
|
||||||
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
|
|
||||||
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
|
|
||||||
|
|
||||||
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
|
## Pantallas
|
||||||
|
|
||||||
When you're ready, run:
|
| 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 |
|
||||||
|
|
||||||
```bash
|
---
|
||||||
npm run reset-project
|
|
||||||
|
## 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>;
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
|
### Polling
|
||||||
|
|
||||||
### Other setup steps
|
- 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`
|
||||||
|
|
||||||
- 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/)
|
### Logout
|
||||||
- If you'd like to set up unit testing, follow our guide on ["Unit Testing with Jest"](https://docs.expo.dev/develop/unit-testing/)
|
- Borra token con `setAuthToken(null)`
|
||||||
- Learn more about the TypeScript setup in this template in our guide on ["Using TypeScript"](https://docs.expo.dev/guides/typescript/)
|
- Resetea user, eta, route, notifications, toast
|
||||||
|
- Limpia el Set de IDs ya vistos
|
||||||
|
|
||||||
## Learn more
|
---
|
||||||
|
|
||||||
To learn more about developing your project with Expo, look at the following resources:
|
## Comunicación con el backend
|
||||||
|
|
||||||
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
|
### `lib/api.ts`
|
||||||
- [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
|
```ts
|
||||||
|
let authToken: string | null = null;
|
||||||
|
|
||||||
Join our community of developers creating universal apps.
|
export const setAuthToken = (token: string | null) => { authToken = token; };
|
||||||
|
|
||||||
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
|
export const apiFetch = async <T>(path: string, options: RequestInit = {}): Promise<T> => {
|
||||||
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
|
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."
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
Reference in New Issue
Block a user