Avance del programa

This commit is contained in:
2026-05-23 07:39:29 -06:00
parent c6a1a67469
commit ebce0badde
6 changed files with 1098 additions and 430 deletions

View File

@@ -9,6 +9,7 @@ import '../../models/models.dart';
import '../../data/routes_data.dart';
import '../../models/route_model.dart' show ColonyModel;
import 'create_route_screen.dart';
import 'admin_reporte_detalle_screen.dart';
import 'admin_stats_screen.dart';
import 'manage_conductors_screen.dart';
import 'export_pdf_screen.dart';
@@ -29,14 +30,13 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
final last = sim.lastNotification;
final tabs = [
_AdminHomeTab(sim:sim, auth:auth),
_AdminMapTab(sim:sim),
_AdminReportesTab(),
_AdminConductoresTab(),
_AdminAssignmentsTab(),
_AdminAlertasTab(sim:sim),
_AdminRoutesTab(),
_AdminReviewsTab(),
_AdminHomeTab(sim:sim, auth:auth), // 0 Panel
_AdminMapTab(sim:sim), // 1 Mapa
_AdminReportesTab(), // 2 Reportes
_AdminAssignmentsTab(), // 3 Asignar
_AdminAlertasTab(sim:sim), // 4 Alertas
_AdminRoutesTab(), // 5 Rutas
_AdminReviewsTab(), // 6 Reseñas
];
return Scaffold(
@@ -57,8 +57,8 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
selectedIcon:Icon(Icons.map,color:AppColors.verdeAdmin),label:'Mapa'),
NavigationDestination(icon:Icon(Icons.report_outlined),
selectedIcon:Icon(Icons.report,color:AppColors.verdeAdmin),label:'Reportes'),
NavigationDestination(icon:Icon(Icons.calendar_month_outlined),
selectedIcon:Icon(Icons.calendar_month,color:AppColors.verdeAdmin),label:'Asignar'),
NavigationDestination(icon:Icon(Icons.people_alt_outlined),
selectedIcon:Icon(Icons.people_alt,color:AppColors.verdeAdmin),label:'Asignar'),
NavigationDestination(icon:Icon(Icons.warning_outlined),
selectedIcon:Icon(Icons.warning,color:AppColors.verdeAdmin),label:'Alertas'),
NavigationDestination(icon:Icon(Icons.route_outlined),
@@ -404,419 +404,131 @@ class _AdminMapTab extends StatelessWidget {
}
// ── TAB 3: Reportes ciudadanos ────────────────────────────────────────────
// ── TAB 2: Reportes ciudadanos ───────────────────────────────────────────
class _AdminReportesTab extends StatefulWidget {
@override State<_AdminReportesTab> createState() => _AdminReportesTabState();
}
class _AdminReportesTabState extends State<_AdminReportesTab> {
List<Map<String,dynamic>> _reportes = [];
List<Map<String, dynamic>> _reportes = [];
bool _loading = true;
String _filtroEstado = 'TODOS';
static const _estados = ['TODOS','PENDIENTE','EN_REVISION','EN_PROCESO','RESUELTO','COMPLETADO'];
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final r = await DbHelper.getReportesConUsuario();
if (mounted) setState(() { _reportes=r; _loading=false; });
if (mounted) setState(() { _reportes = r; _loading = false; });
}
static const _tipos = {
'CAMION_NO_PASO':'🚛 No pasó','RETRASO':'⏱️ Retraso',
'RESIDUOS_NO_RECOGIDOS':'🗑️ No recogidos','OTRO':'📝 Otro',
};
List<Map<String,dynamic>> get _filtered => _filtroEstado == 'TODOS'
? _reportes
: _reportes.where((r) => (r['estado'] as String?) == _filtroEstado).toList();
@override
Widget build(BuildContext context) => Scaffold(
appBar:AppBar(automaticallyImplyLeading:false,
backgroundColor:AppColors.verdeAdmin,foregroundColor:Colors.white,
title:Text('Reportes Ciudadanos (${_reportes.length})'),
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
child:Container(height:4,color:AppColors.dorado)),
actions:[IconButton(icon:const Icon(Icons.refresh),onPressed:_load)]),
body:_loading?const Center(child:CircularProgressIndicator())
:_reportes.isEmpty?const Center(child:Text('Sin reportes'))
:ListView.builder(padding:const EdgeInsets.all(12),
itemCount:_reportes.length,
itemBuilder:(_,i){
final r = _reportes[i];
final tipo = r['tipo']??'';
final calif = r['calificacion']??5;
final nombre = r['user_nombre']??'Usuario desconocido';
final email = r['user_email']??'';
final colonia = r['colonia']??'';
final routeId = r['route_id']??'';
final estado = r['estado']??'PENDIENTE';
final id = r['id'] as int?;
final fotoPath = r['foto_path'] as String?;
return Card(margin:const EdgeInsets.only(bottom:8),
child:Padding(padding:const EdgeInsets.all(12),child:Column(
crossAxisAlignment:CrossAxisAlignment.start, children:[
// Quién reportó
Row(children:[
const Icon(Icons.person,color:AppColors.verdeAdmin,size:14),
const SizedBox(width:4),
Expanded(child:Text('$nombre ($email)',
style:const TextStyle(fontWeight:FontWeight.bold,fontSize:12,color:AppColors.verdeAdmin))),
Container(padding:const EdgeInsets.symmetric(horizontal:6,vertical:2),
decoration:BoxDecoration(color:_estadoColor(estado).withOpacity(0.15),
borderRadius:BorderRadius.circular(10)),
child:Text(estado,style:TextStyle(fontSize:9,color:_estadoColor(estado),
fontWeight:FontWeight.bold))),
]),
const SizedBox(height:4),
Row(children:[
const Icon(Icons.location_city,color:AppColors.grisTexto,size:12),
const SizedBox(width:4),
Text('$colonia$routeId',style:const TextStyle(color:AppColors.grisTexto,fontSize:11)),
]),
const SizedBox(height:6),
Text(_tipos[tipo]??tipo,style:const TextStyle(fontWeight:FontWeight.w600,fontSize:13)),
Text(r['descripcion']??'',style:const TextStyle(fontSize:12,color:AppColors.grisTexto)),
if (fotoPath != null && fotoPath.isNotEmpty) ...[
const SizedBox(height:6),
ClipRRect(borderRadius:BorderRadius.circular(6),
child:Image.file(File(fotoPath), height:100, width:double.infinity,
fit:BoxFit.cover)),
],
const SizedBox(height:6),
Row(children:[
Text(''*calif,style:const TextStyle(fontSize:11)),
const Spacer(),
PopupMenuButton<String>(
child:Text(estado,style:TextStyle(fontSize:11,color:_estadoColor(estado),
fontWeight:FontWeight.bold,decoration:TextDecoration.underline)),
onSelected:(v)async{
if(id!=null) await DbHelper.updateReporteEstado(id,v);
await _load();
},
itemBuilder:(_)=>['PENDIENTE','EN_REVISION','RESUELTO','DESESTIMADO']
.map((e)=>PopupMenuItem(value:e,child:Text(e))).toList()),
]),
])));
}),
);
Color _estadoColor(String e){
switch(e){case'RESUELTO':return AppColors.verdeExito;
case'EN_REVISION':return AppColors.azulInfo;
case'DESESTIMADO':return AppColors.grisTexto;
default:return AppColors.naranjaAlerta;}
}
}
// ── TAB 4: Asignaciones ───────────────────────────────────────────────────
// ── TAB 4: Asignaciones LMV / MJS ────────────────────────────────────────
class _AdminAssignmentsTab extends StatefulWidget {
@override State<_AdminAssignmentsTab> createState() => _AdminAssignmentsTabState();
}
class _AdminAssignmentsTabState extends State<_AdminAssignmentsTab> {
List<UserModel> _conductores = [];
UserModel? _sel;
List<AssignmentModel> _asigs = [];
// Grupos fijos de días
static const _grupoA = ['LUNES','MIERCOLES','VIERNES'];
static const _grupoB = ['MARTES','JUEVES','SABADO'];
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final c = await DbHelper.getUsersByRol('CONDUCTOR');
if (mounted) setState(() => _conductores = c);
}
Future<void> _loadAsigs(int id) async {
final a = await DbHelper.getAsignacionesByConductor(id);
if (mounted) setState(() => _asigs = a);
}
// Obtener asignación de un grupo (busca cualquier día del grupo)
AssignmentModel? _getGrupo(List<String> dias) {
for (final dia in dias) {
try { return _asigs.firstWhere((a) => a.diaSemana == dia); } catch (_) {}
Color _estadoColor(String s) {
switch(s) {
case 'COMPLETADO': return AppColors.verdeExito;
case 'RESUELTO': return Colors.teal;
case 'EN_PROCESO': return AppColors.azulInfo;
case 'EN_REVISION': return AppColors.naranjaAlerta;
default: return AppColors.grisTexto;
}
return null;
}
// Guardar asignación para todos los días del grupo
Future<void> _saveGrupo(List<String> dias, String routeId, String turno) async {
for (final dia in dias) {
await DbHelper.upsertAsignacion(AssignmentModel(
conductorId: _sel!.id!, routeId: routeId,
diaSemana: dia, turno: turno));
}
await _loadAsigs(_sel!.id!);
}
String _estadoLabel(String s) => s.replaceAll('_', ' ');
String _folio(int id) => 'RPT-${id.toString().padLeft(5, "0")}';
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(automaticallyImplyLeading: false,
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
title: const Text('Asignar Rutas a Conductores'),
title: Text('Reportes Ciudadanos (${_filtered.length})'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado))),
body: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [
// Info de esquema
Container(padding: const EdgeInsets.all(10), margin: const EdgeInsets.only(bottom:12),
decoration: BoxDecoration(color: Colors.blue.shade50, borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.shade200)),
child: const Text(
'📅 Cada conductor opera en uno de dos bloques:\n'
' Grupo A — Lunes, Miércoles y Viernes\n'
' Grupo B — Martes, Jueves y Sábado',
style: TextStyle(fontSize: 12, color: AppColors.azulInfo))),
DropdownButtonFormField<UserModel>(
decoration: const InputDecoration(labelText: 'Selecciona conductor',
border: OutlineInputBorder(), filled: true, fillColor: Colors.white),
hint: const Text('Conductor...'),
value: _sel,
items: _conductores.map((c) => DropdownMenuItem(value: c,
child: Text(c.nombre, style: const TextStyle(fontSize: 13)))).toList(),
onChanged: (c) { setState(() => _sel = c); if (c != null) _loadAsigs(c.id!); }),
if (_sel != null) ...[
const SizedBox(height: 20),
// GRUPO A
_GrupoRow(
label: 'Grupo A — Lunes, Miércoles y Viernes',
icon: Icons.wb_sunny_outlined,
color: Colors.blue,
current: _getGrupo(_grupoA),
routeIds: routesData.map((r) => r.routeId).toList(),
onSave: (rid, turno) => _saveGrupo(_grupoA, rid, turno),
),
const SizedBox(height: 12),
// GRUPO B
_GrupoRow(
label: 'Grupo B — Martes, Jueves y Sábado',
icon: Icons.wb_twilight,
color: Colors.deepPurple,
current: _getGrupo(_grupoB),
routeIds: routesData.map((r) => r.routeId).toList(),
onSave: (rid, turno) => _saveGrupo(_grupoB, rid, turno),
),
// Resumen actual
if (_asigs.isNotEmpty) ...[
const SizedBox(height: 20),
const Text('Resumen actual', style: TextStyle(fontWeight: FontWeight.bold,
color: AppColors.verdeAdmin, fontSize: 14)),
const SizedBox(height: 8),
Card(child: Padding(padding: const EdgeInsets.all(12), child: Column(children: [
..._asigs.map((a) => Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(children: [
SizedBox(width: 100, child: Text(AppDias.label(a.diaSemana),
style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 12))),
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(color: AppColors.verdeAdmin.withOpacity(0.1),
borderRadius: BorderRadius.circular(10)),
child: Text('${a.routeId}${a.turno}',
style: const TextStyle(fontSize: 11, color: AppColors.verdeAdmin))),
]))),
]))),
],
],
])),
child: Container(height: 4, color: AppColors.dorado)),
actions: [IconButton(icon: const Icon(Icons.refresh), onPressed: _load)]),
body: Column(children: [
Container(color: Colors.white, height: 44,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
itemCount: _estados.length,
itemBuilder: (_, i) {
final e = _estados[i];
final sel = _filtroEstado == e;
return Padding(padding: const EdgeInsets.only(right: 6),
child: FilterChip(
label: Text(e == 'TODOS' ? 'Todos' : _estadoLabel(e),
style: TextStyle(fontSize: 11,
color: sel ? Colors.white : AppColors.negroTexto)),
selected: sel,
selectedColor: e == 'TODOS' ? AppColors.verdeAdmin : _estadoColor(e),
checkmarkColor: Colors.white,
onSelected: (_) => setState(() => _filtroEstado = e)));
})),
Expanded(child: _loading
? const Center(child: CircularProgressIndicator())
: _filtered.isEmpty
? const Center(child: Text('Sin reportes',
style: TextStyle(color: AppColors.grisTexto)))
: RefreshIndicator(onRefresh: _load,
child: ListView.builder(
padding: const EdgeInsets.all(10),
itemCount: _filtered.length,
itemBuilder: (ctx, i) {
final r = _filtered[i];
final estado = r['estado'] as String? ?? 'PENDIENTE';
final id = r['id'] as int? ?? 0;
final folio = _folio(id);
final fotoPath = r['foto_path'] as String?;
final nombre = r['user_nombre'] as String? ?? 'Ciudadano';
final colonia = r['colonia'] as String? ?? '';
final desc = r['descripcion'] as String? ?? '';
return Card(margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10),
side: BorderSide(color: _estadoColor(estado).withOpacity(0.3))),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () async {
await Navigator.push(ctx, MaterialPageRoute(
builder: (_) => AdminReporteDetalleScreen(reporte: r)));
_load();
},
child: Padding(padding: const EdgeInsets.all(12), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Text(folio, style: const TextStyle(fontWeight: FontWeight.bold,
fontSize: 13, color: AppColors.verdeAdmin)),
const SizedBox(width: 8),
Container(padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: _estadoColor(estado).withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: _estadoColor(estado).withOpacity(0.4))),
child: Text(_estadoLabel(estado),
style: TextStyle(fontSize: 9, fontWeight: FontWeight.bold,
color: _estadoColor(estado)))),
const Spacer(),
if (fotoPath != null && fotoPath.isNotEmpty)
const Icon(Icons.photo_camera, size: 14, color: AppColors.azulInfo),
const SizedBox(width: 4),
const Icon(Icons.chevron_right, color: AppColors.grisTexto, size: 18),
]),
const SizedBox(height: 4),
Text('$nombre$colonia',
style: const TextStyle(color: AppColors.grisTexto, fontSize: 11)),
const SizedBox(height: 2),
Text(desc, style: const TextStyle(fontSize: 12),
maxLines: 2, overflow: TextOverflow.ellipsis),
]))));
}))),
]),
);
}
// Fila de asignación por grupo (LMV o MJS)
class _GrupoRow extends StatefulWidget {
final String label;
final IconData icon;
final Color color;
final AssignmentModel? current;
final List<String> routeIds;
final Function(String, String) onSave;
const _GrupoRow({required this.label, required this.icon, required this.color,
required this.current, required this.routeIds, required this.onSave});
@override State<_GrupoRow> createState() => _GrupoRowState();
}
class _GrupoRowState extends State<_GrupoRow> {
String? _route;
String _turno = 'MATUTINO';
@override void initState() {
super.initState();
_route = widget.current?.routeId;
_turno = widget.current?.turno ?? 'MATUTINO';
}
@override
Widget build(BuildContext context) => Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10),
side: BorderSide(color: widget.color.withOpacity(0.3))),
child: Padding(padding: const EdgeInsets.all(14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(widget.icon, color: widget.color, size: 18),
const SizedBox(width: 8),
Expanded(child: Text(widget.label,
style: TextStyle(fontWeight: FontWeight.bold, color: widget.color, fontSize: 13))),
]),
const SizedBox(height: 12),
Row(children: [
Expanded(child: DropdownButtonFormField<String>(
value: _route,
decoration: const InputDecoration(labelText: 'Ruta',
border: OutlineInputBorder(), isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 10)),
hint: const Text('Sin ruta', style: TextStyle(fontSize: 12)),
items: widget.routeIds.map((r) => DropdownMenuItem(value: r,
child: Text(r, style: const TextStyle(fontSize: 12)))).toList(),
onChanged: (v) => setState(() => _route = v))),
const SizedBox(width: 8),
SizedBox(width: 140, child: DropdownButtonFormField<String>(
value: _turno,
decoration: const InputDecoration(labelText: 'Turno',
border: OutlineInputBorder(), isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 10)),
items: const [
DropdownMenuItem(value:'MATUTINO', child:Text('🌄 Matutino',style:TextStyle(fontSize:12))),
DropdownMenuItem(value:'VESPERTINO',child:Text('🌅 Vespertino',style:TextStyle(fontSize:12))),
DropdownMenuItem(value:'NOCTURNO', child:Text('🌙 Nocturno',style:TextStyle(fontSize:12))),
],
onChanged: (v) => setState(() => _turno = v!))),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _route == null ? null : () => widget.onSave(_route!, _turno),
style: ElevatedButton.styleFrom(
backgroundColor: widget.color, foregroundColor: Colors.white,
minimumSize: const Size(50, 42), padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
child: const Icon(Icons.save, size: 18)),
]),
])));
}
class _AdminAlertasTab extends StatefulWidget {
final RouteSimulatorService sim;
const _AdminAlertasTab({required this.sim});
@override State<_AdminAlertasTab> createState() => _AdminAlertasTabState();
}
class _AdminAlertasTabState extends State<_AdminAlertasTab> {
List<AlertaModel> _alertas = [];
bool _soloActivas = false;
@override void initState(){ super.initState(); _load(); }
Future<void> _load() async {
final a = await DbHelper.getAlertas(soloNoResueltas:_soloActivas);
if (mounted) setState(()=>_alertas=a);
}
IconData _icon(String tipo){
if(tipo.startsWith('INCIDENTE_')) return Icons.build;
switch(tipo){
case'GPS_PERDIDO': return Icons.gps_off;
case'CAMION_DETENIDO': return Icons.warning_amber;
default: return Icons.info;
}
}
Color _color(String tipo){
if(tipo.startsWith('INCIDENTE_')) return AppColors.moradoConductor;
switch(tipo){
case'GPS_PERDIDO': return AppColors.rojoError;
case'CAMION_DETENIDO': return AppColors.naranjaAlerta;
case'RUTA_CANCELADA': return AppColors.rojoError;
default: return AppColors.azulInfo;
}
}
@override
Widget build(BuildContext context) => Scaffold(
appBar:AppBar(automaticallyImplyLeading:false,
backgroundColor:AppColors.verdeAdmin,foregroundColor:Colors.white,
title:Text('Alertas (${_alertas.where((a)=>!a.resuelta).length} activas)'),
bottom:PreferredSize(preferredSize:const Size.fromHeight(4),
child:Container(height:4,color:AppColors.dorado)),
actions:[
Switch(value:_soloActivas,onChanged:(v){setState(()=>_soloActivas=v);_load();},
activeColor:AppColors.dorado),
IconButton(icon:const Icon(Icons.refresh),onPressed:_load),
]),
body:_alertas.isEmpty
?Center(child:Column(mainAxisAlignment:MainAxisAlignment.center,children:[
const Icon(Icons.check_circle,color:AppColors.verdeExito,size:48),
const SizedBox(height:8),
Text(_soloActivas?'Sin alertas activas':'Sin alertas registradas',
style:const TextStyle(color:AppColors.grisTexto))]))
:ListView.builder(padding:const EdgeInsets.all(12),
itemCount:_alertas.length,
itemBuilder:(_,i){
final a = _alertas[i];
final esIncidente = a.tipo.startsWith('INCIDENTE_');
return Card(margin:const EdgeInsets.only(bottom:8),
color:a.resuelta?Colors.grey.shade50:null,
child:ListTile(
leading:CircleAvatar(backgroundColor:a.resuelta?Colors.grey:_color(a.tipo),
child:Icon(_icon(a.tipo),color:Colors.white,size:18)),
title:Row(children:[
if(esIncidente) Container(margin:const EdgeInsets.only(right:6),
padding:const EdgeInsets.symmetric(horizontal:6,vertical:2),
decoration:BoxDecoration(color:AppColors.moradoConductor.withOpacity(0.1),
borderRadius:BorderRadius.circular(8)),
child:const Text('CONDUCTOR',style:TextStyle(fontSize:9,color:AppColors.moradoConductor,fontWeight:FontWeight.bold))),
Expanded(child:Text('${a.tipo.replaceAll('_',' ')}${a.routeId}',
style:TextStyle(fontSize:12,fontWeight:FontWeight.bold,
color:a.resuelta?AppColors.grisTexto:AppColors.negroTexto))),
]),
subtitle:Text(a.mensaje,style:const TextStyle(fontSize:11)),
trailing:a.resuelta
?const Icon(Icons.check_circle,color:AppColors.verdeExito,size:20)
:TextButton(
onPressed:()async{ await DbHelper.resolverAlerta(a.id!); await _load(); },
style:TextButton.styleFrom(foregroundColor:AppColors.verdeAdmin),
child:const Text('Resolver',style:TextStyle(fontSize:11))),
));
}),
);
}
// ── Widgets ───────────────────────────────────────────────────────────────
class _Stat extends StatelessWidget {
final String label,value; final IconData icon; final Color color;
const _Stat(this.label,this.value,this.icon,this.color);
@override
Widget build(BuildContext context) => Expanded(child:Card(
child:Padding(padding:const EdgeInsets.all(14),child:Row(children:[
Icon(icon,color:color,size:28),
const SizedBox(width:10),
Column(crossAxisAlignment:CrossAxisAlignment.start,children:[
Text(value,style:TextStyle(fontSize:22,fontWeight:FontWeight.bold,color:color)),
Text(label,style:const TextStyle(fontSize:11,color:AppColors.grisTexto)),
]),
]))));
}
class _AdminBanner extends StatelessWidget {
final AppNotification notif; final VoidCallback onDismiss;
const _AdminBanner({required this.notif,required this.onDismiss});
@override
Widget build(BuildContext context) => Material(color:Colors.transparent,
child:Container(margin:const EdgeInsets.all(10),
decoration:BoxDecoration(
color:notif.event==NotifEvent.routeCancelled?AppColors.rojoError:AppColors.rojoError,
borderRadius:BorderRadius.circular(12),
boxShadow:const[BoxShadow(color:Colors.black26,blurRadius:6)]),
child:Padding(padding:const EdgeInsets.all(12),child:Row(children:[
const Icon(Icons.admin_panel_settings,color:Colors.white,size:22),
const SizedBox(width:8),
Expanded(child:Column(crossAxisAlignment:CrossAxisAlignment.start,mainAxisSize:MainAxisSize.min,children:[
Text(notif.title,style:const TextStyle(color:Colors.white,fontWeight:FontWeight.bold,fontSize:13)),
Text(notif.body,style:const TextStyle(color:Colors.white70,fontSize:11),
maxLines:2,overflow:TextOverflow.ellipsis),
])),
IconButton(icon:const Icon(Icons.close,color:Colors.white,size:18),onPressed:onDismiss),
]))));
}
// ── TAB Conductores (delega a ManageConductorsScreen) ────────────────────
class _AdminConductoresTab extends StatelessWidget {
@override
Widget build(BuildContext context) => Scaffold(
@@ -1068,3 +780,273 @@ class _AdminReviewsTabState extends State<_AdminReviewsTab> {
});
}
}
// ── TAB 3: Asignaciones LMV / MJS ────────────────────────────────────────
class _AdminAssignmentsTab extends StatefulWidget {
@override State<_AdminAssignmentsTab> createState() => _AdminAssignmentsTabState();
}
class _AdminAssignmentsTabState extends State<_AdminAssignmentsTab> {
List<UserModel> _conductores = [];
UserModel? _sel;
List<AssignmentModel> _asigs = [];
static const _grupoA = ['LUNES', 'MIERCOLES', 'VIERNES'];
static const _grupoB = ['MARTES', 'JUEVES', 'SABADO'];
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final c = await DbHelper.getUsersByRol('CONDUCTOR');
if (mounted) setState(() => _conductores = c);
}
Future<void> _loadAsigs(int id) async {
final a = await DbHelper.getAsignacionesByConductor(id);
if (mounted) setState(() => _asigs = a);
}
AssignmentModel? _getGrupo(List<String> dias) {
for (final dia in dias) {
try { return _asigs.firstWhere((a) => a.diaSemana == dia); } catch (_) {}
}
return null;
}
Future<void> _saveGrupo(List<String> dias, String routeId, String turno) async {
for (final dia in dias) {
await DbHelper.upsertAsignacion(AssignmentModel(
conductorId: _sel!.id!, routeId: routeId, diaSemana: dia, turno: turno));
}
await _loadAsigs(_sel!.id!);
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(automaticallyImplyLeading: false,
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
title: const Text('Asignar Rutas a Conductores'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado))),
body: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [
Container(padding: const EdgeInsets.all(10), margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(color: Colors.blue.shade50, borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.shade200)),
child: const Text(
'Cada conductor opera en un bloque:\n'
'Grupo A — Lunes, Miercoles y Viernes\n'
'Grupo B — Martes, Jueves y Sabado',
style: TextStyle(fontSize: 12, color: AppColors.azulInfo))),
DropdownButtonFormField<UserModel>(
decoration: const InputDecoration(labelText: 'Selecciona conductor',
border: OutlineInputBorder(), filled: true, fillColor: Colors.white),
hint: const Text('Conductor...'),
value: _sel,
items: _conductores.map((c) => DropdownMenuItem(value: c,
child: Text(c.nombre, style: const TextStyle(fontSize: 13)))).toList(),
onChanged: (c) { setState(() => _sel = c); if (c != null) _loadAsigs(c.id!); }),
if (_sel != null) ...[
const SizedBox(height: 20),
_GrupoRow(label: 'Grupo A — Lunes, Miercoles y Viernes',
icon: Icons.wb_sunny_outlined, color: Colors.blue,
current: _getGrupo(_grupoA), routeIds: routesData.map((r) => r.routeId).toList(),
onSave: (rid, turno) => _saveGrupo(_grupoA, rid, turno)),
const SizedBox(height: 12),
_GrupoRow(label: 'Grupo B — Martes, Jueves y Sabado',
icon: Icons.wb_twilight, color: Colors.deepPurple,
current: _getGrupo(_grupoB), routeIds: routesData.map((r) => r.routeId).toList(),
onSave: (rid, turno) => _saveGrupo(_grupoB, rid, turno)),
if (_asigs.isNotEmpty) ...[
const SizedBox(height: 20),
const Text('Resumen actual', style: TextStyle(fontWeight: FontWeight.bold,
color: AppColors.verdeAdmin, fontSize: 14)),
const SizedBox(height: 8),
Card(child: Padding(padding: const EdgeInsets.all(12), child: Column(children: [
..._asigs.map((a) => Padding(padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(children: [
SizedBox(width: 100, child: Text(AppDias.label(a.diaSemana),
style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 12))),
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(color: AppColors.verdeAdmin.withOpacity(0.1),
borderRadius: BorderRadius.circular(10)),
child: Text('${a.routeId}${a.turno}',
style: const TextStyle(fontSize: 11, color: AppColors.verdeAdmin))),
]))),
]))),
],
],
])),
);
}
class _GrupoRow extends StatefulWidget {
final String label; final IconData icon; final Color color;
final AssignmentModel? current; final List<String> routeIds;
final Function(String, String) onSave;
const _GrupoRow({required this.label, required this.icon, required this.color,
required this.current, required this.routeIds, required this.onSave});
@override State<_GrupoRow> createState() => _GrupoRowState();
}
class _GrupoRowState extends State<_GrupoRow> {
String? _route;
String _turno = 'MATUTINO';
@override void initState() { super.initState();
_route = widget.current?.routeId; _turno = widget.current?.turno ?? 'MATUTINO'; }
@override
Widget build(BuildContext context) => Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10),
side: BorderSide(color: widget.color.withOpacity(0.3))),
child: Padding(padding: const EdgeInsets.all(14), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(widget.icon, color: widget.color, size: 18), const SizedBox(width: 8),
Expanded(child: Text(widget.label, style: TextStyle(
fontWeight: FontWeight.bold, color: widget.color, fontSize: 13))),
]),
const SizedBox(height: 12),
Row(children: [
Expanded(child: DropdownButtonFormField<String>(
value: _route,
decoration: const InputDecoration(labelText: 'Ruta', border: OutlineInputBorder(),
isDense: true, contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 10)),
hint: const Text('Sin ruta', style: TextStyle(fontSize: 12)),
items: widget.routeIds.map((r) => DropdownMenuItem(value: r,
child: Text(r, style: const TextStyle(fontSize: 12)))).toList(),
onChanged: (v) => setState(() => _route = v))),
const SizedBox(width: 8),
SizedBox(width: 130, child: DropdownButtonFormField<String>(
value: _turno,
decoration: const InputDecoration(labelText: 'Turno', border: OutlineInputBorder(),
isDense: true, contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 10)),
items: const [
DropdownMenuItem(value: 'MATUTINO', child: Text('Matutino', style: TextStyle(fontSize: 12))),
DropdownMenuItem(value: 'VESPERTINO', child: Text('Vespertino', style: TextStyle(fontSize: 12))),
DropdownMenuItem(value: 'NOCTURNO', child: Text('Nocturno', style: TextStyle(fontSize: 12))),
],
onChanged: (v) => setState(() => _turno = v!))),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _route == null ? null : () => widget.onSave(_route!, _turno),
style: ElevatedButton.styleFrom(backgroundColor: widget.color,
foregroundColor: Colors.white, minimumSize: const Size(50, 42),
padding: EdgeInsets.zero, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
child: const Icon(Icons.save, size: 18)),
]),
])));
}
// ── TAB 4: Alertas del sistema ────────────────────────────────────────────
class _AdminAlertasTab extends StatefulWidget {
final RouteSimulatorService sim;
const _AdminAlertasTab({required this.sim});
@override State<_AdminAlertasTab> createState() => _AdminAlertasTabState();
}
class _AdminAlertasTabState extends State<_AdminAlertasTab> {
List<AlertaModel> _alertas = [];
bool _loading = true;
@override void initState() { super.initState(); _load(); }
Future<void> _load() async {
final a = await DbHelper.getAlertas();
if (mounted) setState(() { _alertas = a; _loading = false; });
}
Color _alertaColor(String tipo) {
if (tipo.startsWith('INCIDENTE')) return AppColors.naranjaAlerta;
if (tipo == 'GPS_PERDIDO') return AppColors.rojoError;
if (tipo == 'CAMION_DETENIDO') return AppColors.naranjaAlerta;
if (tipo.startsWith('RUTA_')) return AppColors.rojoError;
return AppColors.azulInfo;
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(automaticallyImplyLeading: false,
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
title: Text('Alertas del Sistema (${_alertas.length})'),
bottom: PreferredSize(preferredSize: const Size.fromHeight(4),
child: Container(height: 4, color: AppColors.dorado)),
actions: [IconButton(icon: const Icon(Icons.refresh), onPressed: _load)]),
body: _loading
? const Center(child: CircularProgressIndicator())
: _alertas.isEmpty
? const Center(child: Text('Sin alertas',
style: TextStyle(color: AppColors.grisTexto)))
: ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: _alertas.length,
itemBuilder: (_, i) {
final a = _alertas[i];
final c = _alertaColor(a.tipo);
return Card(margin: const EdgeInsets.only(bottom: 6),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8),
side: BorderSide(color: c.withOpacity(0.3))),
child: ListTile(dense: true,
leading: CircleAvatar(radius: 18, backgroundColor: c.withOpacity(0.12),
child: Icon(a.resuelta ? Icons.check : Icons.warning,
color: a.resuelta ? AppColors.verdeExito : c, size: 16)),
title: Text(a.tipo.replaceAll('_', ' '),
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold,
color: a.resuelta ? AppColors.grisTexto : c)),
subtitle: Text(a.mensaje, maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11)),
trailing: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Text(a.routeId, style: const TextStyle(
fontSize: 10, color: AppColors.grisTexto)),
if (!a.resuelta) TextButton(
onPressed: () async {
await DbHelper.resolverAlerta(a.id!); _load();
},
style: TextButton.styleFrom(
padding: EdgeInsets.zero, minimumSize: Size.zero,
foregroundColor: AppColors.verdeExito),
child: const Text('Resolver', style: TextStyle(fontSize: 10))),
])));
}),
);
}
// ── Widgets auxiliares ────────────────────────────────────────────────────
class _Stat extends StatelessWidget {
final String label, value; final IconData icon; final Color color;
const _Stat(this.label, this.value, this.icon, this.color);
@override
Widget build(BuildContext context) => Expanded(child: Card(child: Padding(
padding: const EdgeInsets.all(14), child: Row(children: [
CircleAvatar(radius: 20, backgroundColor: color.withOpacity(0.12),
child: Icon(icon, color: color, size: 20)),
const SizedBox(width: 10),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(value, style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: color)),
Text(label, style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
]),
]))));
}
class _AdminBanner extends StatelessWidget {
final AppNotification notif; final VoidCallback onDismiss;
const _AdminBanner({required this.notif, required this.onDismiss});
@override
Widget build(BuildContext context) => Material(color: Colors.transparent,
child: Container(margin: const EdgeInsets.all(10),
decoration: BoxDecoration(color: AppColors.verdeAdmin, borderRadius: BorderRadius.circular(10),
boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 6)]),
child: Padding(padding: const EdgeInsets.all(10), child: Row(children: [
const Icon(Icons.notifications, color: Colors.white, size: 20),
const SizedBox(width: 8),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, children: [
Text(notif.title, style: const TextStyle(color: Colors.white,
fontWeight: FontWeight.bold, fontSize: 12)),
Text(notif.body, style: const TextStyle(color: Colors.white70, fontSize: 10),
maxLines: 1, overflow: TextOverflow.ellipsis),
])),
IconButton(icon: const Icon(Icons.close, color: Colors.white, size: 16),
onPressed: onDismiss),
]))));
}