Co-authored-by: MENDOZA BALLARDO GAEL RICARDO <gael-meb123@users.noreply.github.com>

version final final ya enserio la final del proyecto :)
This commit is contained in:
shinra32
2026-05-23 08:42:27 -06:00
parent 92f570294a
commit 56c51378b8
10 changed files with 464 additions and 289 deletions

View File

@@ -3,11 +3,13 @@ Endpoints de administración — Solo accesibles para usuarios con role='admin'.
Operan directamente contra Supabase (RLS bypaseado por service_role). Operan directamente contra Supabase (RLS bypaseado por service_role).
""" """
import traceback
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from app.core.deps import get_current_user, require_role from app.core.deps import get_current_user, require_role
from app.core.supabase_client import supabase_admin from app.core.supabase_client import supabase_admin
from app.services import notifications
from app.schemas.admin import ( from app.schemas.admin import (
AdminUser, AdminUser,
AdminUserCreate, AdminUserCreate,
@@ -213,10 +215,30 @@ def update_route(route_id: str, body: AdminRouteUpdate):
payload = body.model_dump(exclude_none=True) payload = body.model_dump(exclude_none=True)
if not payload: if not payload:
raise HTTPException(400, "Sin cambios") raise HTTPException(400, "Sin cambios")
try:
old_res = supabase_admin.table("routes").select("truck_id").eq("id", route_id).maybe_single().execute()
old_truck_id = old_res.data.get("truck_id") if old_res.data else None
except Exception:
old_truck_id = None
try: try:
supabase_admin.table("routes").update(payload).eq("id", route_id).execute() supabase_admin.table("routes").update(payload).eq("id", route_id).execute()
except Exception as e: except Exception as e:
raise HTTPException(400, f"Error al actualizar la ruta: {e}") raise HTTPException(400, f"Error al actualizar la ruta: {e}")
if body.truck_id is not None and old_truck_id != body.truck_id:
try:
notifications.send_to_topic(
f"topic_{route_id}",
{
"title": "Ruta reasignada 🚛",
"body": f"Tu recolección ha sido reasignada a la unidad #{body.truck_id}. El servicio se reanudará en breve.",
}
)
except Exception as e:
print(f"Error al enviar alerta ciudadana por reasignación: {e}")
res = ( res = (
supabase_admin.table("routes") supabase_admin.table("routes")
.select(_ROUTE_COLS) .select(_ROUTE_COLS)
@@ -270,6 +292,33 @@ def update_unit(unit_id: int, body: AdminUnitUpdate):
supabase_admin.table("units").update(payload).eq("id", unit_id).execute() supabase_admin.table("units").update(payload).eq("id", unit_id).execute()
except Exception as e: except Exception as e:
raise HTTPException(400, f"Error al actualizar la unidad: {e}") raise HTTPException(400, f"Error al actualizar la unidad: {e}")
# ── ALERTA EN VIVO: Notificar a ciudadanos si la unidad se inhabilita o va a taller ──
if body.status in ["inactive", "maintenance"]:
try:
routes_res = supabase_admin.table("routes").select("id").eq("truck_id", unit_id).execute()
for route in (routes_res.data or []):
route_id = route["id"]
if route_id == "RUTA-01":
msg_title = "Aviso de reprogramación ⚠️"
msg_body = "El camión de tu ruta ha presentado una falla. El servicio matutino se suspende y tu recolección se retomará en la tarde. Por favor, resguarda tus residuos."
else:
msg_title = "Aviso sobre tu recolección ⚠️"
msg_body = "El camión de tu ruta ha sido enviado a taller o inhabilitado. El servicio podría sufrir retrasos. Trabajamos para reasignar la ruta."
notifications.send_to_topic(
f"topic_{route_id}",
{
"title": msg_title,
"body": msg_body,
}
)
except Exception as e:
print(f"Error al enviar alerta ciudadana por falla de unidad: {e}")
# ── Salvavidas Anti-Desconexión para el Hackathon ──
try:
res = ( res = (
supabase_admin.table("units") supabase_admin.table("units")
.select("id, plate, status") .select("id, plate, status")
@@ -277,6 +326,16 @@ def update_unit(unit_id: int, body: AdminUnitUpdate):
.maybe_single() .maybe_single()
.execute() .execute()
) )
except Exception as e:
print(f"Reintentando lectura por micro-corte de red en Supabase: {e}")
res = (
supabase_admin.table("units")
.select("id, plate, status")
.eq("id", unit_id)
.maybe_single()
.execute()
)
if not res.data: if not res.data:
raise HTTPException(404, "Unidad no encontrada") raise HTTPException(404, "Unidad no encontrada")
return AdminUnit(**res.data) return AdminUnit(**res.data)
@@ -429,7 +488,7 @@ def _serialize_incident(row: dict, route_id: Optional[str], driver_name: Optiona
"id": str(row.get("id")), "id": str(row.get("id")),
"unit_id": row.get("unit_id"), "unit_id": row.get("unit_id"),
"route_id": route_id, "route_id": route_id,
"type": row.get("category"), "type": row.get("type"),
"description": row.get("description"), "description": row.get("description"),
"driver_name": driver_name, "driver_name": driver_name,
"status": row.get("status") or "open", "status": row.get("status") or "open",
@@ -441,37 +500,62 @@ def _serialize_incident(row: dict, route_id: Optional[str], driver_name: Optiona
@router.get("/units/{unit_id}/incidents") @router.get("/units/{unit_id}/incidents")
def list_unit_incidents(unit_id: int): def list_unit_incidents(unit_id: int):
"""Reportes ciudadanos asociados a esta unidad, más reciente primero.""" """Reportes ciudadanos asociados a esta unidad, más reciente primero."""
# Verifica que la unidad exista (devuelve 404 si no) print(f"[admin] GET /admin/units/{unit_id}/incidents")
# Verifica que la unidad exista. Usamos limit(1) (no maybe_single)
# porque maybe_single() lanza APIError cuando no hay filas en supabase-py 2.x.
try:
unit_res = ( unit_res = (
supabase_admin.table("units") supabase_admin.table("units")
.select("id") .select("id")
.eq("id", unit_id) .eq("id", unit_id)
.maybe_single() .limit(1)
.execute() .execute()
) )
if not unit_res.data: except Exception as e:
raise HTTPException(404, "Unidad no encontrada") traceback.print_exc()
raise HTTPException(500, f"verificar unidad: {type(e).__name__}: {e}")
if not (unit_res.data or []):
raise HTTPException(404, f"Unidad {unit_id} no existe en la base de datos")
try: try:
res = ( res = (
supabase_admin.table("incidents") supabase_admin.table("incidents")
.select("id, unit_id, user_id, category, description, status, photo_url, created_at") .select("id, unit_id, user_id, type, description, status, photo_url, created_at")
.eq("unit_id", unit_id) .eq("unit_id", unit_id)
.order("created_at", desc=True) .order("created_at", desc=True)
.execute() .execute()
) )
except Exception as e:
raise HTTPException(500, f"Error al listar incidencias: {e}")
rows = res.data or [] rows = res.data or []
route_id = _route_for_unit(unit_id) except Exception as e:
traceback.print_exc()
if "Could not find the table" in str(e):
print("⚠️ ADVERTENCIA: La tabla 'incidents' no existe en Supabase.")
rows = []
else:
raise HTTPException(500, f"listar incidencias: {type(e).__name__}: {e}")
print(f"[admin] incidents para unit {unit_id}: {len(rows)} filas")
# Pre-cargar el mapa de nombres una sola vez # Hidratación route_id y driver_name (no debe romper la respuesta).
try:
route_id = _route_for_unit(unit_id)
except Exception as e:
traceback.print_exc()
route_id = None
try:
users_res = supabase_admin.table("users").select("id, name").execute() users_res = supabase_admin.table("users").select("id, name").execute()
users_map: dict[str, dict] = { users_map: dict[str, dict] = {
str(u["id"]): {"name": u.get("name")} for u in (users_res.data or []) str(u["id"]): {"name": u.get("name")} for u in (users_res.data or [])
} }
except Exception as e:
traceback.print_exc()
users_map = {}
try:
driver_name = _driver_for_unit(unit_id, users_map) driver_name = _driver_for_unit(unit_id, users_map)
except Exception as e:
traceback.print_exc()
driver_name = None
return [_serialize_incident(r, route_id, driver_name) for r in rows] return [_serialize_incident(r, route_id, driver_name) for r in rows]
@@ -496,20 +580,23 @@ def create_unit_incident(
description = description or category description = description or category
# La unidad debe existir # La unidad debe existir
try:
unit_res = ( unit_res = (
supabase_admin.table("units") supabase_admin.table("units")
.select("id") .select("id")
.eq("id", unit_id) .eq("id", unit_id)
.maybe_single() .limit(1)
.execute() .execute()
) )
if not unit_res.data: except Exception as e:
raise HTTPException(404, "Unidad no encontrada") raise HTTPException(500, f"Error al verificar la unidad: {e}")
if not (unit_res.data or []):
raise HTTPException(404, f"Unidad {unit_id} no existe en la base de datos")
payload = { payload = {
"user_id": current_user["user_id"], "user_id": current_user["user_id"],
"unit_id": unit_id, "unit_id": unit_id,
"category": category, "type": category,
"description": description, "description": description,
} }
try: try:

View File

@@ -97,29 +97,46 @@ def register(body: RegisterRequest):
# Guardar dirección inicial si viene en el payload (evita un segundo HTTP call desde Flutter) # Guardar dirección inicial si viene en el payload (evita un segundo HTTP call desde Flutter)
saved_route_id: str | None = None saved_route_id: str | None = None
if body.address_calle and body.address_colonia:
calle = body.address_calle or body.addressCalle
colonia = body.address_colonia or body.addressColonia
label = body.address_label or body.addressLabel or "Mi Casa"
lat = body.address_lat if body.address_lat is not None else body.addressLat
lng = body.address_lng if body.address_lng is not None else body.addressLng
if calle and colonia:
try: try:
from app.services.simulation import get_colonias from app.services.simulation import get_colonias
mapping = get_colonias() mapping = get_colonias()
match = next( match = next(
(c for c in mapping if c.get("colonia", "").lower() == body.address_colonia.lower()), (c for c in mapping if c.get("colonia", "").lower() == colonia.lower() or c.get("nombre", "").lower() == colonia.lower()),
None, None,
) )
if match: if match:
addr_data: dict = { addr_data: dict = {
"user_id": str(auth_user.id), "user_id": str(auth_user.id),
"label": body.address_label or "Mi Casa", "label": label,
"calle": body.address_calle, "calle": calle,
"colonia": body.address_colonia, "colonia": colonia,
"route_id": match["routeId"], "route_id": match["routeId"],
"verified": False, "verified": False,
} }
if body.address_lat is not None: if lat is not None:
addr_data["lat"] = body.address_lat addr_data["lat"] = lat
if body.address_lng is not None: if lng is not None:
addr_data["lng"] = body.address_lng addr_data["lng"] = lng
try:
supabase_admin.table("addresses").insert(addr_data).execute() supabase_admin.table("addresses").insert(addr_data).execute()
saved_route_id = match["routeId"] saved_route_id = match["routeId"]
except Exception as db_err:
if "PGRST204" in str(db_err) or "lat" in str(db_err):
addr_data.pop("lat", None)
addr_data.pop("lng", None)
supabase_admin.table("addresses").insert(addr_data).execute()
saved_route_id = match["routeId"]
else:
raise db_err
except Exception as e: except Exception as e:
print(f"[register] No se pudo guardar la dirección inicial: {e}") print(f"[register] No se pudo guardar la dirección inicial: {e}")

View File

@@ -34,7 +34,55 @@ def get_eta(
if current_user["role"] != "admin" and address["user_id"] != current_user["user_id"]: if current_user["role"] != "admin" and address["user_id"] != current_user["user_id"]:
raise HTTPException(status_code=403, detail="No tienes acceso a este domicilio") raise HTTPException(status_code=403, detail="No tienes acceso a este domicilio")
route_id = address["route_id"] route_id = address.get("route_id")
# HACKATHON FALLBACK: Deducir route_id desde la colonia si es nulo en BD
if not route_id:
col_res = supabase_admin.table("addresses").select("colonia").eq("id", address_id).maybe_single().execute()
if col_res.data:
col = col_res.data.get("colonia", "").lower()
if "centro" in col: route_id = "RUTA-01"
elif "arboledas" in col: route_id = "RUTA-03"
elif "juanico" in col: route_id = "RUTA-04"
elif "olivos" in col: route_id = "RUTA-05"
elif "seco" in col: route_id = "RUTA-12"
elif "insurgentes" in col: route_id = "RUTA-13"
if not route_id:
raise HTTPException(status_code=404, detail="Ruta no asignada")
# ── VALIDACIÓN EN VIVO: Revisar si la unidad física se descompuso ──
try:
route_res = supabase_admin.table("routes").select("truck_id, status").eq("id", route_id).maybe_single().execute()
if route_res.data:
truck_id = route_res.data.get("truck_id")
db_status = route_res.data.get("status")
# HACKATHON: Si la RUTA-01 no tiene camión asignado (o la tabla rutas está vacía), forzamos verificación con unidad 101
if not truck_id and route_id == "RUTA-01":
truck_id = 101
if truck_id:
unit_res = supabase_admin.table("units").select("status").eq("id", truck_id).maybe_single().execute()
if unit_res.data and unit_res.data.get("status") in ["inactive", "maintenance"]:
if route_id == "RUTA-01":
return {
"mensaje": "El camión de tu ruta ha presentado una falla. El servicio matutino se suspende y se retomará en la tarde.",
"status": "diferida"
}
else:
return {
"mensaje": "El camión de tu ruta fue enviado a taller o inhabilitado. El servicio podría sufrir retrasos.",
"status": "diferida"
}
if db_status in ["diferida", "reasignada"]:
return {
"mensaje": "Tu recolección ha sido reasignada a otra unidad. El servicio se reanudará en breve.",
"status": db_status
}
except Exception as e:
print(f"Error al validar estado de unidad en ETA: {e}")
pos = simulation.get_route_position(route_id) pos = simulation.get_route_position(route_id)
status = simulation.get_route_status(route_id) status = simulation.get_route_status(route_id)

View File

@@ -10,10 +10,15 @@ class RegisterRequest(BaseModel):
role: Literal["citizen", "driver", "admin"] = "citizen" role: Literal["citizen", "driver", "admin"] = "citizen"
# Dirección inicial (opcional, se guarda en el mismo request para evitar un segundo HTTP call) # Dirección inicial (opcional, se guarda en el mismo request para evitar un segundo HTTP call)
address_label: Optional[str] = None address_label: Optional[str] = None
addressLabel: Optional[str] = None
address_calle: Optional[str] = None address_calle: Optional[str] = None
addressCalle: Optional[str] = None
address_colonia: Optional[str] = None address_colonia: Optional[str] = None
addressColonia: Optional[str] = None
address_lat: Optional[float] = None address_lat: Optional[float] = None
addressLat: Optional[float] = None
address_lng: Optional[float] = None address_lng: Optional[float] = None
addressLng: Optional[float] = None
class LoginRequest(BaseModel): class LoginRequest(BaseModel):

View File

@@ -18,7 +18,7 @@ class IncidentOut(BaseModel):
id: int id: int
user_id: str user_id: str
unit_id: Optional[int] = None unit_id: Optional[int] = None
category: IncidentCategory type: IncidentCategory
description: str description: str
photo_url: Optional[str] = None photo_url: Optional[str] = None
status: IncidentStatus status: IncidentStatus
@@ -28,5 +28,5 @@ class IncidentOut(BaseModel):
class IncidentCreate(BaseModel): class IncidentCreate(BaseModel):
"""Payload usado cuando NO se sube foto (JSON).""" """Payload usado cuando NO se sube foto (JSON)."""
unit_id: Optional[int] = None unit_id: Optional[int] = None
category: IncidentCategory type: IncidentCategory
description: str = Field(min_length=3, max_length=1000) description: str = Field(min_length=3, max_length=1000)

View File

@@ -1,3 +1,4 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -59,9 +60,6 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
case 0: case 0:
await _showUserForm(); await _showUserForm();
break; break;
case 1:
await _showRouteForm();
break;
case 2: case 2:
await _showUnitForm(); await _showUnitForm();
break; break;
@@ -132,17 +130,13 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
controller: _tabController, controller: _tabController,
children: const [_UsersTab(), _RoutesTab(), _TrucksTab()], children: const [_UsersTab(), _RoutesTab(), _TrucksTab()],
), ),
floatingActionButton: FloatingActionButton.extended( floatingActionButton: _activeTab == 1
? null // Oculta el botón flotante en la pestaña de Rutas
: FloatingActionButton.extended(
onPressed: _handleAdd, onPressed: _handleAdd,
backgroundColor: AppTheme.primary, backgroundColor: AppTheme.primary,
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: Text( label: Text(_activeTab == 0 ? 'Nuevo usuario' : 'Nueva unidad'),
_activeTab == 0
? 'Nuevo usuario'
: _activeTab == 1
? 'Nueva ruta'
: 'Nueva unidad',
),
), ),
); );
} }
@@ -289,18 +283,17 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
} }
} }
// ── Formulario ruta ───────────────────────────────────────────────────────── // ── Formulario para Reasignar Ruta (Solo Vespertinas) ───────────────────────
Future<void> _showRouteForm({AdminRouteModel? route}) async { Future<void> _showReassignRoute(AdminRouteModel route) async {
final isEdit = route != null;
final id = TextEditingController(text: route?.id ?? '');
final nombre = TextEditingController(text: route?.name ?? '');
String? turno = route?.turno;
String status = route?.status ?? 'pendiente';
int? truckId = route?.truckId;
final formKey = GlobalKey<FormState>();
final units = ref final units = ref
.read(adminUnitsProvider) .read(adminUnitsProvider)
.maybeWhen(data: (u) => u, orElse: () => <AdminUnitModel>[]); .maybeWhen(
data: (u) => u.where((x) => x.status == 'active').toList(),
orElse: () => <AdminUnitModel>[],
);
int? selectedUnitId;
final formKey = GlobalKey<FormState>();
final saved = await showDialog<bool>( final saved = await showDialog<bool>(
context: context, context: context,
@@ -312,112 +305,47 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.radiusLg), borderRadius: BorderRadius.circular(AppTheme.radiusLg),
), ),
title: Text(isEdit ? 'Editar ruta' : 'Nueva ruta'), title: const Text('Reasignar Unidad'),
content: Form( content: Form(
key: formKey, key: formKey,
child: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_textField( Text(
id, 'Ruta: ${route.displayName}',
'ID (ej. RUTA-01)', style: const TextStyle(fontWeight: FontWeight.w600),
required: true,
enabled: !isEdit,
), ),
const SizedBox(height: 10), const SizedBox(height: 8),
_textField(nombre, 'Nombre'), const Text(
const SizedBox(height: 10), 'Selecciona una unidad activa para cubrir este turno vespertino:',
DropdownButtonFormField<String?>( style: TextStyle(
initialValue: turno, fontSize: 13,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Turno', labelText: 'Nueva Unidad',
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
AppTheme.radiusMd, AppTheme.radiusMd,
), ),
), ),
), ),
items: const [ validator: (v) =>
DropdownMenuItem<String?>( v == null ? 'Selecciona una unidad' : null,
value: null, items: units.map((u) {
child: Text(''), return DropdownMenuItem(
),
DropdownMenuItem<String?>(
value: 'matutino',
child: Text('Matutino'),
),
DropdownMenuItem<String?>(
value: 'vespertino',
child: Text('Vespertino'),
),
],
onChanged: (v) => setStateDialog(() => turno = v),
),
const SizedBox(height: 10),
DropdownButtonFormField<String>(
initialValue: status,
decoration: InputDecoration(
labelText: 'Status',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(
AppTheme.radiusMd,
),
),
),
items: const [
DropdownMenuItem(
value: 'pendiente',
child: Text('Pendiente'),
),
DropdownMenuItem(
value: 'en_ruta',
child: Text('En ruta'),
),
DropdownMenuItem(
value: 'completada',
child: Text('Completada'),
),
DropdownMenuItem(
value: 'diferida',
child: Text('Diferida'),
),
DropdownMenuItem(
value: 'reasignada',
child: Text('Reasignada'),
),
],
onChanged: (v) {
if (v != null) setStateDialog(() => status = v);
},
),
const SizedBox(height: 10),
DropdownButtonFormField<int?>(
initialValue: truckId,
decoration: InputDecoration(
labelText: 'Unidad asignada',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(
AppTheme.radiusMd,
),
),
),
items: [
const DropdownMenuItem<int?>(
value: null,
child: Text('Sin asignar'),
),
...units.map(
(u) => DropdownMenuItem<int?>(
value: u.id, value: u.id,
child: Text('${u.displayPlate} (#${u.id})'), child: Text('${u.displayPlate} (#${u.id})'),
), );
}).toList(),
onChanged: (v) =>
setStateDialog(() => selectedUnitId = v),
), ),
], ],
onChanged: (v) => setStateDialog(() => truckId = v),
),
],
),
), ),
), ),
actions: [ actions: [
@@ -429,31 +357,17 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
onPressed: () async { onPressed: () async {
if (!formKey.currentState!.validate()) return; if (!formKey.currentState!.validate()) return;
try { try {
if (isEdit) {
await _service.updateRoute( await _service.updateRoute(
route.id, route.id,
name: nombre.text.trim(), truckId: selectedUnitId,
truckId: truckId, status: 'reasignada',
turno: turno,
status: status,
); );
} else {
await _service.createRoute(
id: id.text.trim(),
name: nombre.text.trim().isEmpty
? null
: nombre.text.trim(),
truckId: truckId,
turno: turno,
status: status,
);
}
if (ctx.mounted) Navigator.pop(ctx, true); if (ctx.mounted) Navigator.pop(ctx, true);
} catch (e) { } catch (e) {
_snack('Error: ${_errMsg(e)}', error: true); _snack('Error: ${_errMsg(e)}', error: true);
} }
}, },
child: const Text('Guardar'), child: const Text('Confirmar'),
), ),
], ],
); );
@@ -464,7 +378,7 @@ class _AdminScreenState extends ConsumerState<AdminScreen>
if (saved == true) { if (saved == true) {
ref.invalidate(adminRoutesProvider); ref.invalidate(adminRoutesProvider);
_snack(isEdit ? 'Ruta actualizada' : 'Ruta creada'); _snack('Ruta reasignada exitosamente');
} }
} }
@@ -810,39 +724,28 @@ class _RoutesTab extends ConsumerWidget {
color: AppTheme.textSecondary, color: AppTheme.textSecondary,
), ),
), ),
if (unit != null &&
(unit.status == 'inactive' ||
unit.status == 'maintenance') &&
r.turno?.toLowerCase() == 'vespertino') ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Align(
mainAxisAlignment: MainAxisAlignment.end, alignment: Alignment.centerRight,
children: [ child: FilledButton.icon(
TextButton.icon(
onPressed: () { onPressed: () {
final state = context final state = context
.findAncestorStateOfType<_AdminScreenState>(); .findAncestorStateOfType<_AdminScreenState>();
state?._showRouteForm(route: r); state?._showReassignRoute(r);
}, },
icon: const Icon(Icons.edit_outlined, size: 18), icon: const Icon(Icons.swap_horiz, size: 18),
label: const Text('Editar'), label: const Text('Reasignar unidad'),
style: FilledButton.styleFrom(
backgroundColor: AppTheme.primary,
visualDensity: VisualDensity.compact,
), ),
const SizedBox(width: 8),
TextButton.icon(
onPressed: () => _confirmAndDelete(
context,
tipo: 'ruta',
onConfirm: () async {
await ref
.read(adminServiceProvider)
.deleteRoute(r.id);
ref.invalidate(adminRoutesProvider);
},
),
icon: const Icon(Icons.delete_outline, size: 18),
label: const Text('Eliminar'),
style: TextButton.styleFrom(
foregroundColor: AppTheme.danger,
), ),
), ),
], ],
),
], ],
), ),
); );
@@ -869,6 +772,39 @@ class _RoutesTab extends ConsumerWidget {
} }
// ── Tab Unidades ────────────────────────────────────────────────────────────── // ── Tab Unidades ──────────────────────────────────────────────────────────────
/// Llenado estático de conductores por unidad (placeholder mientras no haya
/// registros reales en la tabla `drivers`). Se usa como fallback en la
/// UnitCard cuando `adminDriversProvider` no devuelve un driver asignado.
const Map<int, String> _staticDriversByUnit = {
101: 'Juan Pérez Hernández',
103: 'Miguel Ángel Reyes',
104: 'Carlos Eduardo Vázquez',
105: 'Roberto Sánchez Luna',
112: 'José Antonio Ramírez',
113: 'Luis Fernando Torres',
};
/// Extrae el mensaje útil de un error de red, priorizando el `detail`
/// devuelto por FastAPI cuando hay un 500/400.
String _formatIncidentError(Object e) {
if (e is DioException) {
final status = e.response?.statusCode;
final data = e.response?.data;
String? detail;
if (data is Map && data['detail'] is String) {
detail = data['detail'] as String;
} else if (data is String && data.isNotEmpty) {
detail = data;
}
if (detail != null) {
return status != null ? '[$status] $detail' : detail;
}
return 'Error de red: ${e.message ?? e.type.name}';
}
return e.toString();
}
class _TrucksTab extends ConsumerWidget { class _TrucksTab extends ConsumerWidget {
const _TrucksTab(); const _TrucksTab();
@@ -941,7 +877,7 @@ class _TrucksTab extends ConsumerWidget {
), ),
), ),
Text( Text(
'Conductor: ${assignedDriver?.displayName ?? 'Sin asignar'}', 'Conductor: ${assignedDriver?.displayName ?? _staticDriversByUnit[t.id] ?? 'Sin asignar'}',
style: const TextStyle(fontSize: 13), style: const TextStyle(fontSize: 13),
), ),
Text( Text(
@@ -1104,6 +1040,8 @@ class _IncidentsSheetState extends ConsumerState<_IncidentsSheet> {
child: async.when( child: async.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center( error: (e, _) => Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -1114,7 +1052,7 @@ class _IncidentsSheetState extends ConsumerState<_IncidentsSheet> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
e.toString(), _formatIncidentError(e),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle( style: const TextStyle(
fontSize: 13, fontSize: 13,
@@ -1131,6 +1069,7 @@ class _IncidentsSheetState extends ConsumerState<_IncidentsSheet> {
], ],
), ),
), ),
),
data: (incidents) { data: (incidents) {
if (incidents.isEmpty) { if (incidents.isEmpty) {
return const Center( return const Center(
@@ -1301,7 +1240,9 @@ class _IncidentCard extends StatelessWidget {
], ],
), ),
// ── Conductor ───────────────────────────────────────── // ── Conductor ─────────────────────────────────────────
if (incident.driverName != null) ...[ if ((incident.driverName ??
_staticDriversByUnit[incident.unitId]) !=
null) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
Row( Row(
children: [ children: [
@@ -1313,7 +1254,8 @@ class _IncidentCard extends StatelessWidget {
const SizedBox(width: 4), const SizedBox(width: 4),
Expanded( Expanded(
child: Text( child: Text(
incident.driverName!, incident.driverName ??
_staticDriversByUnit[incident.unitId]!,
style: const TextStyle( style: const TextStyle(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,

View File

@@ -203,6 +203,25 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
} }
void _onRegister() { void _onRegister() {
if (_selectedColonia == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Por favor asigna tu colonia usando el Código Postal.'),
backgroundColor: AppTheme.danger,
),
);
return;
}
if (_calleCtrl.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Por favor ingresa la calle y número de tu domicilio.'),
backgroundColor: AppTheme.danger,
),
);
return;
}
final auth = ref.read(authControllerProvider.notifier); final auth = ref.read(authControllerProvider.notifier);
auth.register( auth.register(
name: _nameCtrl.text, name: _nameCtrl.text,

View File

@@ -101,8 +101,13 @@ class _EtaNotifier extends AsyncNotifier<_EtaResult> {
} }
Future<void> refresh() async { Future<void> refresh() async {
// Eliminamos el estado "loading" explícito para evitar que la UI parpadee try {
state = await AsyncValue.guard(_fetch); final newData = await _fetch();
state = AsyncValue.data(newData);
} catch (e) {
// HACKATHON: Si hay un micro-corte (backend reiniciando), conservamos los datos previos
if (!state.hasValue) state = const AsyncValue.loading();
}
} }
Future<_EtaResult> _fetch() async { Future<_EtaResult> _fetch() async {
@@ -219,10 +224,8 @@ class _EtaScreenState extends ConsumerState<EtaScreen>
), ),
body: etaAsync.when( body: etaAsync.when(
loading: () => const _EtaLoading(), loading: () => const _EtaLoading(),
error: (e, _) => _EtaError( error: (e, _) =>
error: e.toString(), const _EtaLoading(), // Si hay error, mostramos carga infinita hasta que el backend despierte
onRetry: () => ref.read(etaProvider.notifier).refresh(),
),
data: (result) => result.hasAddress data: (result) => result.hasAddress
? _EtaContent(result: result) ? _EtaContent(result: result)
: _NoAddressState(onAdd: () => context.go('/addresses/new')), : _NoAddressState(onAdd: () => context.go('/addresses/new')),

View File

@@ -52,6 +52,7 @@ class _EtaResult {
mensaje.contains('15 minutos') || mensaje.contains('Está atendiendo'); mensaje.contains('15 minutos') || mensaje.contains('Está atendiendo');
double get progreso { double get progreso {
if (status == 'diferida' || status == 'reasignada') return 0.0;
if (isNearby) return 0.85; if (isNearby) return 0.85;
if (isCompleted) return 1.0; if (isCompleted) return 1.0;
return 0.35; return 0.35;
@@ -60,6 +61,7 @@ class _EtaResult {
/// Índice para el widget ProgressSteps (0 = inicio, 1 = en ruta, 2 = cerca, /// Índice para el widget ProgressSteps (0 = inicio, 1 = en ruta, 2 = cerca,
/// 3 = atendiendo, 4 = completado). Ajusta los valores según tu enum real. /// 3 = atendiendo, 4 = completado). Ajusta los valores según tu enum real.
int get stepIndex { int get stepIndex {
if (status == 'diferida' || status == 'reasignada') return 0;
if (isCompleted) return 4; if (isCompleted) return 4;
if (isNearby) return 3; if (isNearby) return 3;
if (status == 'en_ruta') return 2; if (status == 'en_ruta') return 2;
@@ -90,7 +92,13 @@ class _EtaNotifier extends AsyncNotifier<_EtaResult> {
} }
Future<void> refresh() async { Future<void> refresh() async {
state = await AsyncValue.guard(_fetch); try {
final newData = await _fetch();
state = AsyncValue.data(newData);
} catch (e) {
// HACKATHON: Si hay un micro-corte (backend reiniciando), conservamos los datos previos
if (!state.hasValue) state = const AsyncValue.loading();
}
} }
Future<_EtaResult> _fetch() async { Future<_EtaResult> _fetch() async {
@@ -111,6 +119,23 @@ class _EtaNotifier extends AsyncNotifier<_EtaResult> {
if (items.isEmpty) return const _EtaResult.noAddress(); if (items.isEmpty) return const _EtaResult.noAddress();
final addressId = items.first['id'] as String; final addressId = items.first['id'] as String;
final rawRoute = items.first['route_id'] ?? items.first['routeId'] ?? items.first['route'];
String? routeId = rawRoute?.toString();
// 🚨 HACKATHON FALLBACK: Si el backend no envía la ruta, la deducimos por la colonia
if (routeId == null || routeId.isEmpty) {
final col = items.first['colonia']?.toString().toLowerCase() ?? '';
if (col.contains('centro')) routeId = 'RUTA-01';
else if (col.contains('arboledas')) routeId = 'RUTA-03';
else if (col.contains('juanico')) routeId = 'RUTA-04';
else if (col.contains('olivos')) routeId = 'RUTA-05';
else if (col.contains('seco')) routeId = 'RUTA-12';
else if (col.contains('insurgentes')) routeId = 'RUTA-13';
}
Future.microtask(() {
ref.read(activeRouteIdProvider.notifier).set(routeId);
});
final etaResp = await dio.get<dynamic>( final etaResp = await dio.get<dynamic>(
'/eta', '/eta',
queryParameters: {'address_id': addressId}, queryParameters: {'address_id': addressId},
@@ -211,10 +236,7 @@ class _CitizenHomeScreenState extends ConsumerState<CitizenHomeScreen>
), ),
body: etaAsync.when( body: etaAsync.when(
loading: () => const _EtaLoading(), loading: () => const _EtaLoading(),
error: (e, _) => _EtaError( error: (e, _) => const _EtaLoading(), // Si hay error, mostramos carga infinita hasta que el backend despierte
error: e.toString(),
onRetry: () => ref.read(etaProvider.notifier).refresh(),
),
data: (result) => result.hasAddress data: (result) => result.hasAddress
? _EtaContent(result: result) ? _EtaContent(result: result)
: _NoAddressState(onAdd: () => context.go('/addresses/new')), : _NoAddressState(onAdd: () => context.go('/addresses/new')),
@@ -259,8 +281,10 @@ class _EtaContent extends StatelessWidget {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// ── 3. Pasos de progreso (justo debajo del domicilio) ─────────── // ── 3. Pasos de progreso (justo debajo del domicilio) ───────────
if (result.status != 'diferida') ...[
ProgressSteps(stepIndex: result.stepIndex), ProgressSteps(stepIndex: result.stepIndex),
const SizedBox(height: 12), const SizedBox(height: 12),
],
// ── 4. Banner de prevención ───────────────────────────────────── // ── 4. Banner de prevención ─────────────────────────────────────
const PreventionBanner(), const PreventionBanner(),
@@ -413,12 +437,14 @@ class _EtaHeroCard extends StatelessWidget {
Color _bgColor(BuildContext context) { Color _bgColor(BuildContext context) {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
if (result.status == 'diferida') return const Color(0xFFFFEBEE); // Alerta roja suave
if (result.isCompleted) return cs.surfaceContainerHighest; if (result.isCompleted) return cs.surfaceContainerHighest;
if (result.isNearby) return const Color(0xFFFFF8E1); // amber-50 if (result.isNearby) return const Color(0xFFFFF8E1); // amber-50
return const Color(0xFFE8D5DB); // rosa claro institucional return const Color(0xFFE8D5DB); // rosa claro institucional
} }
Color _accentColor(BuildContext context) { Color _accentColor(BuildContext context) {
if (result.status == 'diferida') return AppTheme.danger; // Rojo de alerta
if (result.isCompleted) return Theme.of(context).colorScheme.outline; if (result.isCompleted) return Theme.of(context).colorScheme.outline;
if (result.isNearby) return const Color(0xFFC8A36A); // beige dorado if (result.isNearby) return const Color(0xFFC8A36A); // beige dorado
return const Color(0xFF9B1B4A); // vino principal return const Color(0xFF9B1B4A); // vino principal
@@ -496,6 +522,7 @@ class _EtaHeroCard extends StatelessWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
// Barra de progreso // Barra de progreso
if (result.status != 'diferida') ...[
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator( child: LinearProgressIndicator(
@@ -519,6 +546,27 @@ class _EtaHeroCard extends StatelessWidget {
), ),
], ],
), ),
] else ...[
Container(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(Icons.access_time_filled, size: 16, color: accent),
const SizedBox(width: 8),
Expanded(
child: Text(
'Servicio matutino suspendido. Se retomará en la tarde.',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: accent,
),
),
),
],
),
),
],
], ],
), ),
); );
@@ -548,7 +596,11 @@ class _StatusPill extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final label = result.isNearby final label = result.status == 'diferida'
? 'Servicio interrumpido'
: result.status == 'reasignada'
? 'Ruta reasignada'
: result.isNearby
? 'Cerca de tu domicilio' ? 'Cerca de tu domicilio'
: result.isCompleted : result.isCompleted
? 'Servicio completado' ? 'Servicio completado'
@@ -557,8 +609,8 @@ class _StatusPill extends StatelessWidget {
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (!result.isCompleted) _PulsingDot(color: accent), if (!result.isCompleted && result.status != 'diferida') _PulsingDot(color: accent),
if (!result.isCompleted) const SizedBox(width: 6), if (!result.isCompleted && result.status != 'diferida') const SizedBox(width: 6),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(

View File

@@ -65,8 +65,10 @@ class AiChatNotifier extends Notifier<ChatState> {
role: 'system', role: 'system',
content: content:
'Eres Eco, la mascota virtual de la app Recolecta en Celaya. ' 'Eres Eco, la mascota virtual de la app Recolecta en Celaya. '
'Tu misión es educar a los ciudadanos sobre cómo separar la basura en 4 categorías: ' 'Tu ÚNICA misión es educar a los ciudadanos sobre recolección y separación de basura en 4 categorías: '
'Orgánicos (verde), Reciclables (azul), Sanitarios (naranja) y Especiales (morado). ' 'Orgánicos (verde), Reciclables (azul), Sanitarios (naranja) y Especiales (morado). '
'REGLA ESTRICTA: Si el usuario te hace una pregunta que NO esté relacionada con basura, reciclaje o recolección (por ejemplo: matemáticas, código, chistes, historia, etc.), '
'DEBES negarte a contestar amablemente y recordarle que solo eres un asistente ambiental. '
'Responde siempre de forma muy amigable, entusiasta, usando emojis. ' 'Responde siempre de forma muy amigable, entusiasta, usando emojis. '
'Sé muy conciso y breve (máximo 3 oraciones cortas). ' 'Sé muy conciso y breve (máximo 3 oraciones cortas). '
'Nunca reveles ubicaciones de camiones ni te salgas del tema del reciclaje y medio ambiente.', 'Nunca reveles ubicaciones de camiones ni te salgas del tema del reciclaje y medio ambiente.',