263 lines
13 KiB
Dart
263 lines
13 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:fl_chart/fl_chart.dart';
|
|
import '../../core/app_colors.dart';
|
|
import '../../database/db_helper.dart';
|
|
|
|
class AdminStatsScreen extends StatefulWidget {
|
|
const AdminStatsScreen({super.key});
|
|
@override State<AdminStatsScreen> createState() => _AdminStatsScreenState();
|
|
}
|
|
|
|
class _AdminStatsScreenState extends State<AdminStatsScreen> {
|
|
Map<String, dynamic> _stats = {};
|
|
List<Map<String, dynamic>> _byColonia = [];
|
|
List<Map<String, dynamic>> _byRoute = [];
|
|
List<Map<String, dynamic>> _byWeek = [];
|
|
bool _loading = true;
|
|
|
|
@override void initState() { super.initState(); _load(); }
|
|
|
|
Future<void> _load() async {
|
|
final s = await DbHelper.getAdminStats();
|
|
final bc = await DbHelper.getReportesByColonia();
|
|
final br = await DbHelper.getIncidentesByRoute();
|
|
final bw = await DbHelper.getRatingByWeek();
|
|
if (mounted) setState(() {
|
|
_stats = s; _byColonia = bc; _byRoute = br; _byWeek = bw; _loading = false;
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Scaffold(
|
|
backgroundColor: AppColors.grisFondo,
|
|
appBar: AppBar(
|
|
backgroundColor: AppColors.verdeAdmin, foregroundColor: Colors.white,
|
|
title: const Text('Dashboard de Estadisticas'),
|
|
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())
|
|
: SingleChildScrollView(padding: const EdgeInsets.all(14), child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
|
|
// KPIs
|
|
Row(children: [
|
|
_KpiCard('Reportes', '${_stats['total_reportes']}',
|
|
Icons.report, AppColors.naranjaAlerta),
|
|
const SizedBox(width: 8),
|
|
_KpiCard('Calificacion Prom.',
|
|
(_stats['avg_rating'] as double? ?? 0).toStringAsFixed(1),
|
|
Icons.star, Colors.amber),
|
|
]),
|
|
const SizedBox(height: 8),
|
|
Row(children: [
|
|
_KpiCard('Alertas Activas', '${_stats['alertas_activas']}',
|
|
Icons.warning, AppColors.rojoError),
|
|
const SizedBox(width: 8),
|
|
_KpiCard('Conductores', '${_stats['total_conductores']}',
|
|
Icons.person, AppColors.moradoConductor),
|
|
]),
|
|
const SizedBox(height: 20),
|
|
|
|
// Calificacion por semana (línea)
|
|
if (_byWeek.isNotEmpty) ...[
|
|
_SectionTitle('Calificacion promedio semanal'),
|
|
const SizedBox(height: 8),
|
|
Card(child: Padding(padding: const EdgeInsets.all(16),
|
|
child: SizedBox(height: 180,
|
|
child: LineChart(LineChartData(
|
|
minY: 1, maxY: 5,
|
|
titlesData: FlTitlesData(
|
|
leftTitles: AxisTitles(sideTitles: SideTitles(
|
|
showTitles: true, interval: 1,
|
|
getTitlesWidget: (v,_) => Text(v.toInt().toString(),
|
|
style: const TextStyle(fontSize: 10)))),
|
|
bottomTitles: AxisTitles(sideTitles: SideTitles(
|
|
showTitles: true,
|
|
getTitlesWidget: (v, _) {
|
|
final idx = v.toInt();
|
|
if (idx < 0 || idx >= _byWeek.length) return const SizedBox();
|
|
return Text('S${_byWeek.length - idx}',
|
|
style: const TextStyle(fontSize: 9));
|
|
})),
|
|
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
),
|
|
gridData: FlGridData(drawHorizontalLine: true, horizontalInterval: 1),
|
|
borderData: FlBorderData(show: true,
|
|
border: Border.all(color: Colors.grey.shade300)),
|
|
lineBarsData: [LineChartBarData(
|
|
spots: _byWeek.reversed.toList().asMap().entries.map((e) =>
|
|
FlSpot(e.key.toDouble(),
|
|
(e.value['promedio'] as num? ?? 0).toDouble().clamp(1.0, 5.0))).toList(),
|
|
isCurved: true,
|
|
color: AppColors.verdeAdmin,
|
|
barWidth: 3,
|
|
belowBarData: BarAreaData(show: true,
|
|
color: AppColors.verdeAdmin.withOpacity(0.1)),
|
|
dotData: const FlDotData(show: true),
|
|
)],
|
|
))))),
|
|
const SizedBox(height: 20),
|
|
],
|
|
|
|
// Reportes por colonia (barras horizontales)
|
|
if (_byColonia.isNotEmpty) ...[
|
|
_SectionTitle('Reportes por colonia (Top 10)'),
|
|
const SizedBox(height: 8),
|
|
Card(child: Padding(padding: const EdgeInsets.all(16),
|
|
child: SizedBox(height: 240,
|
|
child: BarChart(BarChartData(
|
|
alignment: BarChartAlignment.spaceAround,
|
|
maxY: (_byColonia.map((c) => (c['total'] as int? ?? 0).toDouble())
|
|
.reduce((a,b)=>a>b?a:b) * 1.2),
|
|
titlesData: FlTitlesData(
|
|
bottomTitles: AxisTitles(sideTitles: SideTitles(
|
|
showTitles: true, reservedSize: 32,
|
|
getTitlesWidget: (v, _) {
|
|
final i = v.toInt();
|
|
if (i < 0 || i >= _byColonia.length) return const SizedBox();
|
|
final name = (_byColonia[i]['colonia'] as String? ?? '');
|
|
return Transform.rotate(angle: -0.5,
|
|
child: Text(name.length > 8 ? '${name.substring(0,8)}.' : name,
|
|
style: const TextStyle(fontSize: 8)));
|
|
})),
|
|
leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: true,
|
|
getTitlesWidget: (v,_) => Text(v.toInt().toString(),
|
|
style: const TextStyle(fontSize: 9)))),
|
|
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
),
|
|
barGroups: _byColonia.asMap().entries.map((e) => BarChartGroupData(
|
|
x: e.key,
|
|
barRods: [
|
|
BarChartRodData(
|
|
toY: (e.value['total'] as int? ?? 0).toDouble(),
|
|
color: AppColors.guindaPrimary,
|
|
width: 16, borderRadius: BorderRadius.circular(4)),
|
|
BarChartRodData(
|
|
toY: (e.value['resueltos'] as int? ?? 0).toDouble(),
|
|
color: AppColors.verdeExito,
|
|
width: 16, borderRadius: BorderRadius.circular(4)),
|
|
],
|
|
)).toList(),
|
|
gridData: const FlGridData(drawHorizontalLine: true),
|
|
borderData: FlBorderData(show: true,
|
|
border: Border.all(color: Colors.grey.shade300)),
|
|
))))),
|
|
Padding(padding: const EdgeInsets.symmetric(vertical: 6),
|
|
child: Row(children: [
|
|
_Legend(AppColors.guindaPrimary, 'Total reportes'),
|
|
const SizedBox(width: 16),
|
|
_Legend(AppColors.verdeExito, 'Resueltos'),
|
|
])),
|
|
const SizedBox(height: 20),
|
|
],
|
|
|
|
// Rutas con más incidentes
|
|
if (_byRoute.isNotEmpty) ...[
|
|
_SectionTitle('Rutas con mas incidentes'),
|
|
const SizedBox(height: 8),
|
|
Card(child: Padding(padding: const EdgeInsets.all(16),
|
|
child: SizedBox(height: 200,
|
|
child: BarChart(BarChartData(
|
|
alignment: BarChartAlignment.spaceAround,
|
|
maxY: (_byRoute.map((r) => (r['total'] as int? ?? 0).toDouble())
|
|
.reduce((a,b)=>a>b?a:b) * 1.3),
|
|
titlesData: FlTitlesData(
|
|
bottomTitles: AxisTitles(sideTitles: SideTitles(
|
|
showTitles: true, reservedSize: 28,
|
|
getTitlesWidget: (v, _) {
|
|
final i = v.toInt();
|
|
if (i < 0 || i >= _byRoute.length) return const SizedBox();
|
|
return Text((_byRoute[i]['route_id'] as String? ?? '').replaceAll('RUTA-','R'),
|
|
style: const TextStyle(fontSize: 9));
|
|
})),
|
|
leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: true,
|
|
getTitlesWidget: (v,_) => Text(v.toInt().toString(),
|
|
style: const TextStyle(fontSize: 9)))),
|
|
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
),
|
|
barGroups: _byRoute.asMap().entries.map((e) => BarChartGroupData(
|
|
x: e.key,
|
|
barRods: [BarChartRodData(
|
|
toY: (e.value['total'] as int? ?? 0).toDouble(),
|
|
gradient: const LinearGradient(
|
|
colors: [AppColors.naranjaAlerta, AppColors.rojoError],
|
|
begin: Alignment.bottomCenter, end: Alignment.topCenter),
|
|
width: 20, borderRadius: BorderRadius.circular(4))],
|
|
)).toList(),
|
|
gridData: const FlGridData(drawHorizontalLine: true),
|
|
borderData: FlBorderData(show: true,
|
|
border: Border.all(color: Colors.grey.shade300)),
|
|
))))),
|
|
const SizedBox(height: 20),
|
|
],
|
|
|
|
// Colonias más problemáticas (lista)
|
|
_SectionTitle('Colonias mas problematicas'),
|
|
const SizedBox(height: 8),
|
|
Card(child: Column(children: [
|
|
..._byColonia.take(5).map((c) {
|
|
final total = (c['total'] as int? ?? 0);
|
|
final resueltos = (c['resueltos'] as int? ?? 0);
|
|
final pct = total > 0 ? resueltos / total : 0.0;
|
|
return ListTile(dense: true,
|
|
leading: CircleAvatar(radius: 16,
|
|
backgroundColor: total > 3 ? AppColors.rojoError.withOpacity(0.15)
|
|
: AppColors.naranjaAlerta.withOpacity(0.15),
|
|
child: Text('$total', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold,
|
|
color: total > 3 ? AppColors.rojoError : AppColors.naranjaAlerta))),
|
|
title: Text(c['colonia'] as String? ?? '',
|
|
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
|
|
subtitle: LinearProgressIndicator(value: pct,
|
|
backgroundColor: Colors.grey.shade200,
|
|
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.verdeExito)),
|
|
trailing: Text('${(pct*100).toInt()}% resuelto',
|
|
style: const TextStyle(fontSize: 10, color: AppColors.grisTexto)),
|
|
);
|
|
}),
|
|
])),
|
|
const SizedBox(height: 30),
|
|
])),
|
|
);
|
|
}
|
|
|
|
class _KpiCard extends StatelessWidget {
|
|
final String label, value; final IconData icon; final Color color;
|
|
const _KpiCard(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: 22, backgroundColor: color.withOpacity(0.12),
|
|
child: Icon(icon, color: color, size: 22)),
|
|
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: 10, color: AppColors.grisTexto)),
|
|
]),
|
|
]))));
|
|
}
|
|
|
|
class _SectionTitle extends StatelessWidget {
|
|
final String title;
|
|
const _SectionTitle(this.title);
|
|
@override
|
|
Widget build(BuildContext context) => Text(title,
|
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15, color: AppColors.verdeAdmin));
|
|
}
|
|
|
|
class _Legend extends StatelessWidget {
|
|
final Color color; final String label;
|
|
const _Legend(this.color, this.label);
|
|
@override
|
|
Widget build(BuildContext context) => Row(mainAxisSize: MainAxisSize.min, children: [
|
|
Container(width: 12, height: 12, decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(2))),
|
|
const SizedBox(width: 4),
|
|
Text(label, style: const TextStyle(fontSize: 11, color: AppColors.grisTexto)),
|
|
]);
|
|
}
|