Files
EME_APP/eme-frontend/lib/screens/responsable_screen.dart
T
2026-07-24 13:41:40 +02:00

2451 lines
78 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.dart";
import "../services/auth_service.dart";
import "../services/responsable_service.dart";
import "../theme/ensup_colors.dart";
import "../utils/file_download.dart";
const _workspaceBackground = Color(0xFFF3F6F9);
const _orange = Color(0xFFD97706);
class ResponsableScreen extends StatefulWidget {
const ResponsableScreen({super.key});
@override
State<ResponsableScreen> createState() => _ResponsableScreenState();
}
class _ResponsableScreenState extends State<ResponsableScreen> {
late Future<_ResponsableData> _future = _charger();
int _onglet = 0;
bool _transitionEnCours = false;
bool _decisionEnCours = false;
bool _exportEnCours = false;
Future<_ResponsableData> _charger() async {
final dashboard = await ResponsableService.dashboard();
final ecarts = await ResponsableService.ecarts();
final emprunts = await ResponsableService.emprunts();
final materiels = await ResponsableService.materiels();
final anomalies = await ResponsableService.anomalies();
final notifications = await ResponsableService.notifications();
final historique = await ResponsableService.historique();
return _ResponsableData(
dashboard: dashboard,
ecarts: ecarts,
emprunts: emprunts,
materiels: materiels,
anomalies: anomalies,
notifications: notifications,
historique: historique,
);
}
void _rafraichir() {
setState(() => _future = _charger());
}
Future<void> _quitter() async {
try {
await AuthService.signOut();
if (mounted) {
Navigator.of(context).pop();
}
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
}
}
Future<void> _changerStatut(Map<String, dynamic> anomalie) async {
final prochain = _prochainStatut(anomalie["statut"] as String?);
if (prochain == null || _transitionEnCours) {
return;
}
setState(() => _transitionEnCours = true);
try {
await ResponsableService.changerStatutAnomalie(
anomalie["id"] as int,
prochain,
);
_rafraichir();
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
} finally {
if (mounted) {
setState(() => _transitionEnCours = false);
}
}
}
Future<void> _examinerEcart(Map<String, dynamic> ecart) async {
if (_decisionEnCours) return;
final decision = await showDialog<_DecisionEcart>(
context: context,
builder: (context) => _EcartDecisionDialog(ecart: ecart),
);
if (decision == null || !mounted) {
return;
}
setState(() => _decisionEnCours = true);
try {
await ResponsableService.deciderEcart(
empruntId: ecart["id"] as int,
decision: decision.decision,
observation: decision.observation,
statutMateriel: decision.statutMateriel,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
decision.decision == "CONFIRMER"
? "Le changement a été confirmé et l'anomalie a été créée."
: "Le changement a été refusé.",
),
),
);
_rafraichir();
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
} finally {
if (mounted) {
setState(() => _decisionEnCours = false);
}
}
}
Future<void> _marquerNotificationsLues() async {
try {
await ResponsableService.marquerNotificationsLues();
_rafraichir();
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
}
}
Future<void> _exporterHistorique() async {
if (_exportEnCours) return;
setState(() => _exportEnCours = true);
try {
final file = await ResponsableService.exporterHistorique();
downloadFile(
bytes: file.bytes,
fileName: file.fileName,
contentType: file.contentType,
);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text("Export CSV téléchargé.")));
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
} finally {
if (mounted) {
setState(() => _exportEnCours = false);
}
}
}
String? _prochainStatut(String? statut) {
return switch (statut) {
"DETECTEE" => "EN_COURS_TRAITEMENT",
"EN_COURS_TRAITEMENT" => "RESOLUE",
"RESOLUE" => "CLOTUREE",
_ => null,
};
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _workspaceBackground,
body: SafeArea(
child: Column(
children: [
_ResponsableTopBar(onExit: _quitter, onRefresh: _rafraichir),
Expanded(
child: FutureBuilder<_ResponsableData>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const _ChargementWorkspace();
}
if (snapshot.hasError) {
return _Erreur(
message: snapshot.error.toString(),
onRetry: _rafraichir,
);
}
return _contenu(snapshot.data!);
},
),
),
],
),
),
);
}
Widget _contenu(_ResponsableData data) {
final kpis = Map<String, dynamic>.from(data.dashboard["kpis"] as Map);
final anomalies = _repartition(kpis["anomalies"]);
final anomaliesActives =
_statutCount(anomalies, "DETECTEE") +
_statutCount(anomalies, "EN_COURS_TRAITEMENT");
final notificationsNonLues = kpis["notificationsNonLues"] as int? ?? 0;
final ecartsEnAttente = data.ecarts.length;
final vues = [
_VueDashboard(
data: data,
onNavigate: (index) => setState(() => _onglet = index),
onRefresh: _rafraichir,
),
_VueListe(
titre: "Écarts à confirmer",
description:
"Contrôlez les changements déclarés avant toute modification de l’état officiel.",
icon: Icons.fact_check_outlined,
items: data.ecarts,
builder: _ecartTile,
searchableText: (item) {
final etudiant = Map<String, dynamic>.from(item["etudiant"] as Map);
final materiel = Map<String, dynamic>.from(item["materiel"] as Map);
return "${item["typeEcart"]} ${materiel["nom"]} ${materiel["reference"]} ${etudiant["prenom"]} ${etudiant["nom"]}";
},
),
_VueListe(
titre: "Emprunts",
description:
"Suivez les prêts en cours, les retards et les retours clôturés.",
icon: Icons.assignment_outlined,
items: data.emprunts,
builder: _empruntTile,
searchableText: (item) {
final etudiant = Map<String, dynamic>.from(item["etudiant"] as Map);
final materiel = Map<String, dynamic>.from(item["materiel"] as Map);
return "${materiel["nom"]} ${materiel["reference"]} ${etudiant["prenom"]} ${etudiant["nom"]} ${item["statut"]}";
},
),
_VueListe(
titre: "Stock matériel",
description:
"Consultez la disponibilité et l’état du parc de votre campus.",
icon: Icons.inventory_2_outlined,
items: data.materiels,
builder: _materielTile,
searchableText: (item) {
final categorie = Map<String, dynamic>.from(item["categorie"] as Map);
return "${item["nom"]} ${item["reference"]} ${item["statut"]} ${categorie["nom"]}";
},
),
_VueListe(
titre: "Anomalies",
description:
"Priorisez et faites avancer les incidents détectés lors des retours.",
icon: Icons.report_problem_outlined,
items: data.anomalies,
builder: _anomalieTile,
searchableText: (item) {
final materiel = Map<String, dynamic>.from(item["materiel"] as Map);
return "${item["type"]} ${item["description"]} ${item["statut"]} ${materiel["nom"]}";
},
),
_VueListe(
titre: "Notifications",
description:
"Centralisez les alertes liées aux opérations et aux anomalies.",
icon: Icons.notifications_none_outlined,
items: data.notifications,
builder: _notificationTile,
action: OutlinedButton.icon(
onPressed: _marquerNotificationsLues,
icon: const Icon(Icons.done_all, size: 16),
label: const Text("Tout marquer lu"),
),
searchableText: (item) => "${item["titre"]} ${item["message"]}",
),
_VueListe(
titre: "Historique",
description:
"Retrouvez la trace complète des actions réalisées sur le campus.",
icon: Icons.history,
items: data.historique,
builder: _historiqueTile,
action: OutlinedButton.icon(
onPressed: _exportEnCours ? null : _exporterHistorique,
icon: _exportEnCours
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download_outlined, size: 16),
label: Text(_exportEnCours ? "Export en cours" : "Export CSV"),
),
searchableText: (item) => "${item["action"]} ${item["description"]}",
),
];
return LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 900;
return Row(
children: [
if (!compact)
_ResponsableSidebar(
index: _onglet,
ecartsEnAttente: ecartsEnAttente,
anomaliesActives: anomaliesActives,
notificationsNonLues: notificationsNonLues,
onChanged: (index) => setState(() => _onglet = index),
onExit: _quitter,
),
Expanded(
child: Column(
children: [
if (compact)
_NavigationCompacte(
index: _onglet,
onChanged: (index) => setState(() => _onglet = index),
),
Expanded(child: vues[_onglet]),
],
),
),
],
);
},
);
}
Widget _empruntTile(Map<String, dynamic> item) {
final etudiant = Map<String, dynamic>.from(item["etudiant"] as Map);
final materiel = Map<String, dynamic>.from(item["materiel"] as Map);
return _InfoTile(
icon: Icons.assignment_outlined,
title: "${materiel["nom"]}",
subtitle:
"${etudiant["prenom"]} ${etudiant["nom"]} · retour prévu ${_date(item["dateRetourPrevue"])}",
meta: "Emprunt #${item["id"]}",
trailing: _StatusBadge("${item["statut"]}"),
);
}
Widget _ecartTile(Map<String, dynamic> item) {
final etudiant = Map<String, dynamic>.from(item["etudiant"] as Map);
final materiel = Map<String, dynamic>.from(item["materiel"] as Map);
final type = item["typeEcart"] == "DEPART" ? "Départ" : "Retour";
return _InfoTile(
icon: Icons.fact_check_outlined,
title: "$type · ${materiel["nom"]}",
subtitle:
"${etudiant["prenom"]} ${etudiant["nom"]} · ${materiel["reference"]}",
meta: "Emprunt #${item["id"]}",
trailing: FilledButton.icon(
onPressed: _decisionEnCours ? null : () => _examinerEcart(item),
icon: const Icon(Icons.visibility_outlined, size: 16),
label: const Text("Examiner"),
style: FilledButton.styleFrom(
backgroundColor: EnsupColors.blue3,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(7)),
),
),
);
}
Widget _materielTile(Map<String, dynamic> item) {
final categorie = Map<String, dynamic>.from(item["categorie"] as Map);
return _InfoTile(
icon: Icons.inventory_2_outlined,
title: "${item["nom"]}",
subtitle:
"${item["reference"]} · ${categorie["nom"]} · ${item["etatGeneral"]}",
meta: "${item["marque"]} ${item["modele"]}",
trailing: _StatusBadge("${item["statut"]}"),
);
}
Widget _anomalieTile(Map<String, dynamic> item) {
final materiel = Map<String, dynamic>.from(item["materiel"] as Map);
final prochain = _prochainStatut(item["statut"] as String?);
return _InfoTile(
icon: Icons.report_problem_outlined,
title: "${item["type"]} · ${materiel["nom"]}",
subtitle: "${item["description"]}",
meta: "Détectée le ${_date(item["dateDetection"])}",
trailing: _AnomalieAction(
statut: "${item["statut"]}",
prochainStatut: prochain,
disabled: _transitionEnCours,
onPressed: () => _changerStatut(item),
),
);
}
Widget _notificationTile(Map<String, dynamic> item) {
return _InfoTile(
icon: item["lu"] == true
? Icons.notifications_none
: Icons.notifications_active_outlined,
title: "${item["titre"]}",
subtitle: "${item["message"]}",
meta: _date(item["dateCreation"]),
trailing: _StatusBadge(item["lu"] == true ? "LUE" : "NON_LUE"),
);
}
Widget _historiqueTile(Map<String, dynamic> item) {
final utilisateur = Map<String, dynamic>.from(item["utilisateur"] as Map);
return _InfoTile(
icon: Icons.history,
title: _actionLabel("${item["action"]}"),
subtitle: "${item["description"]}",
meta:
"${_date(item["dateAction"])} · ${utilisateur["prenom"]} ${utilisateur["nom"]}",
trailing: const Icon(Icons.chevron_right, color: EnsupColors.muted),
);
}
String _date(Object? value) {
if (value == null) return "-";
final parsed = DateTime.tryParse(value.toString());
if (parsed == null) return value.toString();
return "${parsed.day.toString().padLeft(2, "0")}/"
"${parsed.month.toString().padLeft(2, "0")}/${parsed.year}";
}
}
String _statusLabel(String statut) {
return switch (statut) {
"DISPONIBLE" => "Disponible",
"RESERVE" => "Réservé",
"EMPRUNTE" => "Emprunté",
"NON_CONFORME" => "Non conforme",
"DETERIORE" => "Détérioré",
"MAINTENANCE" => "Maintenance",
"INDISPONIBLE" => "Indisponible",
"EN_COURS" => "En cours",
"EN_ATTENTE_VALIDATION_DEPART" => "Écart départ en attente",
"EN_RETARD" => "En retard",
"EN_ATTENTE_VALIDATION_RETOUR" => "Écart retour en attente",
"CLOTURE" => "Clôturé",
"RETOUR_NON_CONFORME" => "Retour non conforme",
"ANNULE" => "Annulé",
"DETECTEE" => "À traiter",
"EN_COURS_TRAITEMENT" => "En traitement",
"RESOLUE" => "Résolue",
"CLOTUREE" => "Clôturée",
"LUE" => "Lue",
"NON_LUE" => "Non lue",
"PRESENT" => "Présent",
"ABSENT" => "Absent",
_ => statut.replaceAll("_", " ").toLowerCase(),
};
}
String _actionLabel(String action) {
return switch (action) {
"CREATION_EMPRUNT" => "Création d'emprunt",
"CREATION_ANOMALIE" => "Création d'anomalie",
"TRAITEMENT_ANOMALIE" => "Traitement d'anomalie",
"TENTATIVE_MODIFICATION_RETOUR" => "Tentative de modification au retour",
"ECART_DEPART_SIGNALE" => "Écart signalé au départ",
"ECART_RETOUR_SIGNALE" => "Écart signalé au retour",
"ECART_DEPART_CONFIRMER" => "Écart de départ confirmé",
"ECART_DEPART_REFUSER" => "Écart de départ refusé",
"ECART_RETOUR_CONFIRMER" => "Écart de retour confirmé",
"ECART_RETOUR_REFUSER" => "Écart de retour refusé",
"EMPRUNT_AUTOMATIQUE" => "Emprunt automatique",
"RESTITUTION_AUTOMATIQUE" => "Restitution automatique",
_ => action.replaceAll("_", " ").toLowerCase(),
};
}
Color _statusColor(String statut) {
return switch (statut) {
"DISPONIBLE" ||
"CLOTURE" ||
"RESOLUE" ||
"CLOTUREE" ||
"LUE" => EnsupColors.green,
"EN_RETARD" ||
"RETOUR_NON_CONFORME" ||
"NON_CONFORME" ||
"DETECTEE" ||
"NON_LUE" => EnsupColors.red,
"EN_COURS" || "EN_COURS_TRAITEMENT" || "EMPRUNTE" => EnsupColors.cyan,
"RESERVE" ||
"EN_ATTENTE_VALIDATION_DEPART" ||
"EN_ATTENTE_VALIDATION_RETOUR" => _orange,
"MAINTENANCE" || "INDISPONIBLE" || "DETERIORE" => EnsupColors.purple,
_ => EnsupColors.blue3,
};
}
class _DecisionEcart {
const _DecisionEcart({
required this.decision,
required this.observation,
this.statutMateriel,
});
final String decision;
final String observation;
final String? statutMateriel;
}
class _EcartDecisionDialog extends StatefulWidget {
const _EcartDecisionDialog({required this.ecart});
final Map<String, dynamic> ecart;
@override
State<_EcartDecisionDialog> createState() => _EcartDecisionDialogState();
}
class _EcartDecisionDialogState extends State<_EcartDecisionDialog> {
final TextEditingController _observationController = TextEditingController();
String _statutMateriel = "NON_CONFORME";
bool get _estRetour => widget.ecart["typeEcart"] == "RETOUR";
@override
void dispose() {
_observationController.dispose();
super.dispose();
}
List<Map<String, dynamic>> _elements(String type) {
final checklists = widget.ecart["checklists"] as List<dynamic>? ?? const [];
for (final checklistValue in checklists) {
final checklist = Map<String, dynamic>.from(checklistValue as Map);
if (checklist["type"] == type) {
return (checklist["elements"] as List<dynamic>? ?? const [])
.map((element) => Map<String, dynamic>.from(element as Map))
.toList();
}
}
return const [];
}
String _etat(Map<String, dynamic> element) {
final quantite = element["quantiteConstatee"] as int? ?? 0;
return "${_statusLabel("${element["etat"]}")} · quantité $quantite";
}
List<Widget> _comparaisons() {
final depart = _elements("DEPART");
if (!_estRetour) {
return depart
.map(
(element) => _EcartComparisonRow(
nom: "${element["nomElement"]}",
reference: "Présent · état de référence",
declaration: _etat(element),
),
)
.toList();
}
final retour = _elements("RETOUR");
final lignes = <Widget>[];
for (final elementDepart in depart) {
Map<String, dynamic>? elementRetour;
for (final candidat in retour) {
if (candidat["nomElement"] == elementDepart["nomElement"]) {
elementRetour = candidat;
break;
}
}
if (elementRetour == null ||
elementRetour["etat"] != elementDepart["etat"] ||
elementRetour["quantiteConstatee"] !=
elementDepart["quantiteConstatee"]) {
lignes.add(
_EcartComparisonRow(
nom: "${elementDepart["nomElement"]}",
reference: _etat(elementDepart),
declaration: elementRetour == null
? "Élément non renseigné"
: _etat(elementRetour),
),
);
}
}
return lignes;
}
void _terminer(String decision) {
Navigator.of(context).pop(
_DecisionEcart(
decision: decision,
observation: _observationController.text,
statutMateriel: _estRetour && decision == "CONFIRMER"
? _statutMateriel
: null,
),
);
}
@override
Widget build(BuildContext context) {
final etudiant = Map<String, dynamic>.from(widget.ecart["etudiant"] as Map);
final materiel = Map<String, dynamic>.from(widget.ecart["materiel"] as Map);
final type = _estRetour ? "retour" : "départ";
return Dialog(
insetPadding: const EdgeInsets.all(20),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720, maxHeight: 760),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 22, 16, 18),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: _orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.fact_check_outlined,
color: _orange,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Contrôle de $type",
style: GoogleFonts.darkerGrotesque(
fontSize: 22,
fontWeight: FontWeight.w900,
color: EnsupColors.text,
),
),
Text(
"${materiel["nom"]} · ${etudiant["prenom"]} ${etudiant["nom"]}",
style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.muted,
),
),
],
),
),
IconButton(
tooltip: "Fermer",
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
),
const Divider(height: 1, color: EnsupColors.line),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
_estRetour
? "Différences avec l’état validé au départ"
: "Différences avec l’état de référence",
style: GoogleFonts.darkerGrotesque(
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 10),
..._comparaisons(),
const SizedBox(height: 20),
if (_estRetour) ...[
DropdownButtonFormField<String>(
initialValue: _statutMateriel,
decoration: const InputDecoration(
labelText: "État final si le changement est confirmé",
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(
value: "DISPONIBLE",
child: Text("Disponible"),
),
DropdownMenuItem(
value: "NON_CONFORME",
child: Text("Non conforme"),
),
DropdownMenuItem(
value: "DETERIORE",
child: Text("Détérioré"),
),
DropdownMenuItem(
value: "MAINTENANCE",
child: Text("Maintenance"),
),
DropdownMenuItem(
value: "INDISPONIBLE",
child: Text("Indisponible"),
),
],
onChanged: (value) {
if (value != null) {
setState(() => _statutMateriel = value);
}
},
),
const SizedBox(height: 14),
],
TextField(
controller: _observationController,
minLines: 2,
maxLines: 4,
decoration: const InputDecoration(
labelText: "Observation",
hintText: "Constat effectué lors du contrôle physique",
border: OutlineInputBorder(),
),
),
],
),
),
),
const Divider(height: 1, color: EnsupColors.line),
Padding(
padding: const EdgeInsets.all(18),
child: Wrap(
alignment: WrapAlignment.end,
spacing: 10,
runSpacing: 10,
children: [
OutlinedButton.icon(
onPressed: () => _terminer("REFUSER"),
icon: const Icon(Icons.close, size: 18),
label: const Text("Refuser le changement"),
),
FilledButton.icon(
onPressed: () => _terminer("CONFIRMER"),
icon: const Icon(Icons.check, size: 18),
label: const Text("Confirmer le changement"),
style: FilledButton.styleFrom(
backgroundColor: EnsupColors.blue3,
),
),
],
),
),
],
),
),
);
}
}
class _EcartComparisonRow extends StatelessWidget {
const _EcartComparisonRow({
required this.nom,
required this.reference,
required this.declaration,
});
final String nom;
final String reference;
final String declaration;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: EnsupColors.line)),
),
child: LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 520;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
nom,
style: GoogleFonts.darkerGrotesque(
fontSize: 15,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 5),
if (compact)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
reference,
style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.text2,
),
),
const SizedBox(height: 4),
Text(
declaration,
style: GoogleFonts.titilliumWeb(
fontSize: 13,
fontWeight: FontWeight.w700,
color: _orange,
),
),
],
)
else
Row(
children: [
Expanded(
child: Text(
reference,
style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.text2,
),
),
),
const Icon(Icons.arrow_forward, size: 16),
Expanded(
child: Text(
declaration,
textAlign: TextAlign.right,
style: GoogleFonts.titilliumWeb(
fontSize: 13,
fontWeight: FontWeight.w700,
color: _orange,
),
),
),
],
),
],
);
},
),
);
}
}
Map<String, dynamic> _repartition(Object? value) {
if (value is! Map) return <String, dynamic>{};
return Map<String, dynamic>.from(value);
}
int _statutCount(Map<String, dynamic> repartition, String statut) {
final parStatut = repartition["parStatut"];
if (parStatut is! Map) return 0;
final value = parStatut[statut];
return value is int ? value : 0;
}
String _dateLongue(DateTime date) {
const jours = [
"lundi",
"mardi",
"mercredi",
"jeudi",
"vendredi",
"samedi",
"dimanche",
];
const mois = [
"janvier",
"février",
"mars",
"avril",
"mai",
"juin",
"juillet",
"août",
"septembre",
"octobre",
"novembre",
"décembre",
];
return "${jours[date.weekday - 1]} ${date.day} ${mois[date.month - 1]} ${date.year}";
}
String _dateHeure(Object? value) {
final date = DateTime.tryParse("$value")?.toLocal();
if (date == null) return "-";
final jour = date.day.toString().padLeft(2, "0");
final mois = date.month.toString().padLeft(2, "0");
final heure = date.hour.toString().padLeft(2, "0");
final minute = date.minute.toString().padLeft(2, "0");
return "$jour/$mois · $heure:$minute";
}
class _ResponsableTopBar extends StatelessWidget {
const _ResponsableTopBar({required this.onExit, required this.onRefresh});
final VoidCallback onExit;
final VoidCallback onRefresh;
@override
Widget build(BuildContext context) {
final profile = AuthService.currentProfile;
final campusTag = profile?.campusTag ?? "Ensitech · Cergy";
final initials = profile?.initials ?? "KB";
final userName = profile?.fullName ?? "Karim Benali";
final roleLabel = profile?.roleLabel ?? "Responsable matériel";
return Container(
height: 68,
padding: const EdgeInsets.symmetric(horizontal: 20),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(bottom: BorderSide(color: EnsupColors.line)),
),
child: LayoutBuilder(
builder: (context, constraints) {
final showCampus = constraints.maxWidth >= 650;
final showName = constraints.maxWidth >= 820;
return Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: EnsupColors.blue3,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.grid_view_rounded,
color: Colors.white,
size: 19,
),
),
const SizedBox(width: 12),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"EME",
style: GoogleFonts.darkerGrotesque(
fontSize: 21,
height: 0.95,
fontWeight: FontWeight.w900,
color: EnsupColors.text,
),
),
Text(
"Emprunt matériel",
style: GoogleFonts.titilliumWeb(
fontSize: 11,
color: EnsupColors.muted,
),
),
],
),
const Spacer(),
if (showCampus) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 7,
),
decoration: BoxDecoration(
color: _workspaceBackground,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: EnsupColors.line),
),
child: Row(
children: [
const Icon(
Icons.location_on_outlined,
size: 15,
color: EnsupColors.blue3,
),
const SizedBox(width: 6),
Text(
campusTag,
style: GoogleFonts.titilliumWeb(
fontSize: 12,
fontWeight: FontWeight.w600,
color: EnsupColors.text2,
),
),
],
),
),
const SizedBox(width: 10),
],
Tooltip(
message: "Actualiser les données",
child: IconButton(
onPressed: onRefresh,
icon: const Icon(Icons.refresh_rounded),
color: EnsupColors.text2,
),
),
const SizedBox(width: 4),
Container(width: 1, height: 28, color: EnsupColors.line),
const SizedBox(width: 12),
Container(
width: 34,
height: 34,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: EnsupColors.purple,
shape: BoxShape.circle,
),
child: Text(
initials,
style: GoogleFonts.darkerGrotesque(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w900,
),
),
),
if (showName) ...[
const SizedBox(width: 9),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
userName,
style: GoogleFonts.titilliumWeb(
fontSize: 13,
fontWeight: FontWeight.w700,
color: EnsupColors.text,
),
),
Text(
roleLabel,
style: GoogleFonts.titilliumWeb(
fontSize: 11,
color: EnsupColors.muted,
),
),
],
),
],
const SizedBox(width: 4),
Tooltip(
message: "Quitter lespace responsable",
child: IconButton(
onPressed: onExit,
icon: const Icon(Icons.logout_rounded),
color: EnsupColors.muted,
),
),
],
);
},
),
);
}
}
class _ResponsableSidebar extends StatelessWidget {
const _ResponsableSidebar({
required this.index,
required this.ecartsEnAttente,
required this.anomaliesActives,
required this.notificationsNonLues,
required this.onChanged,
required this.onExit,
});
final int index;
final int ecartsEnAttente;
final int anomaliesActives;
final int notificationsNonLues;
final ValueChanged<int> onChanged;
final VoidCallback onExit;
static const _destinations = [
("Vue densemble", Icons.space_dashboard_outlined),
("Écarts à confirmer", Icons.fact_check_outlined),
("Emprunts", Icons.assignment_outlined),
("Stock matériel", Icons.inventory_2_outlined),
("Anomalies", Icons.report_problem_outlined),
("Notifications", Icons.notifications_none_outlined),
("Historique", Icons.history),
];
@override
Widget build(BuildContext context) {
return Container(
width: 244,
color: EnsupColors.blue3,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 10),
child: Text(
"SUPERVISION",
style: GoogleFonts.titilliumWeb(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Colors.white.withValues(alpha: 0.58),
),
),
),
...List.generate(_destinations.length, (itemIndex) {
final destination = _destinations[itemIndex];
final badge = switch (itemIndex) {
1 => ecartsEnAttente,
4 => anomaliesActives,
5 => notificationsNonLues,
_ => 0,
};
return _SidebarItem(
label: destination.$1,
icon: destination.$2,
selected: itemIndex == index,
badge: badge,
alert: itemIndex == 1 || itemIndex == 4,
onTap: () => onChanged(itemIndex),
);
}),
const Spacer(),
Padding(
padding: const EdgeInsets.all(16),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.white.withValues(alpha: 0.12)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.verified_user_outlined,
size: 17,
color: Colors.white,
),
const SizedBox(width: 8),
Expanded(
child: Text(
"Session responsable",
style: GoogleFonts.titilliumWeb(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: 4),
Text(
"Accès limité au campus actif",
style: GoogleFonts.titilliumWeb(
color: Colors.white.withValues(alpha: 0.62),
fontSize: 11,
),
),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 18),
child: TextButton.icon(
onPressed: onExit,
style: TextButton.styleFrom(
foregroundColor: Colors.white.withValues(alpha: 0.78),
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
),
icon: const Icon(Icons.logout_rounded, size: 18),
label: const Text("Quitter lespace"),
),
),
],
),
);
}
}
class _SidebarItem extends StatelessWidget {
const _SidebarItem({
required this.label,
required this.icon,
required this.selected,
required this.badge,
required this.alert,
required this.onTap,
});
final String label;
final IconData icon;
final bool selected;
final int badge;
final bool alert;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final foreground = selected
? Colors.white
: Colors.white.withValues(alpha: 0.72);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
child: Material(
color: selected
? Colors.white.withValues(alpha: 0.14)
: Colors.transparent,
borderRadius: BorderRadius.circular(7),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(7),
child: Container(
height: 46,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
border: selected
? const Border(
left: BorderSide(color: EnsupColors.cyan, width: 3),
)
: null,
),
child: Row(
children: [
Icon(icon, size: 19, color: foreground),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
style: GoogleFonts.titilliumWeb(
fontSize: 13,
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
color: foreground,
),
),
),
if (badge > 0)
Container(
constraints: const BoxConstraints(minWidth: 22),
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
alignment: Alignment.center,
decoration: BoxDecoration(
color: alert ? EnsupColors.red : EnsupColors.cyan,
borderRadius: BorderRadius.circular(10),
),
child: Text(
"$badge",
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
),
],
),
),
),
),
);
}
}
class _NavigationCompacte extends StatelessWidget {
const _NavigationCompacte({required this.index, required this.onChanged});
final int index;
final ValueChanged<int> onChanged;
static const _destinations = [
("Vue densemble", Icons.space_dashboard_outlined),
("Écarts", Icons.fact_check_outlined),
("Emprunts", Icons.assignment_outlined),
("Stock", Icons.inventory_2_outlined),
("Anomalies", Icons.report_problem_outlined),
("Notifications", Icons.notifications_none_outlined),
("Historique", Icons.history),
];
@override
Widget build(BuildContext context) {
return Container(
height: 66,
decoration: const BoxDecoration(
color: Colors.white,
border: Border(bottom: BorderSide(color: EnsupColors.line)),
),
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
scrollDirection: Axis.horizontal,
itemCount: _destinations.length,
separatorBuilder: (_, _) => const SizedBox(width: 6),
itemBuilder: (context, itemIndex) {
final selected = itemIndex == index;
final destination = _destinations[itemIndex];
return Material(
color: selected
? EnsupColors.cyan.withValues(alpha: 0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(7),
child: InkWell(
onTap: () => onChanged(itemIndex),
borderRadius: BorderRadius.circular(7),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Icon(
destination.$2,
size: 18,
color: selected ? EnsupColors.blue3 : EnsupColors.muted,
),
const SizedBox(width: 7),
Text(
destination.$1,
style: GoogleFonts.titilliumWeb(
fontSize: 12,
fontWeight: FontWeight.w700,
color: selected ? EnsupColors.blue3 : EnsupColors.muted,
),
),
],
),
),
),
);
},
),
);
}
}
class _ChargementWorkspace extends StatelessWidget {
const _ChargementWorkspace();
@override
Widget build(BuildContext context) {
return const Center(
child: SizedBox(
width: 34,
height: 34,
child: CircularProgressIndicator(strokeWidth: 3),
),
);
}
}
class _ResponsableData {
const _ResponsableData({
required this.dashboard,
required this.ecarts,
required this.emprunts,
required this.materiels,
required this.anomalies,
required this.notifications,
required this.historique,
});
final Map<String, dynamic> dashboard;
final List<Map<String, dynamic>> ecarts;
final List<Map<String, dynamic>> emprunts;
final List<Map<String, dynamic>> materiels;
final List<Map<String, dynamic>> anomalies;
final List<Map<String, dynamic>> notifications;
final List<Map<String, dynamic>> historique;
}
class _VueDashboard extends StatelessWidget {
const _VueDashboard({
required this.data,
required this.onNavigate,
required this.onRefresh,
});
final _ResponsableData data;
final ValueChanged<int> onNavigate;
final VoidCallback onRefresh;
@override
Widget build(BuildContext context) {
final kpis = Map<String, dynamic>.from(data.dashboard["kpis"] as Map);
final materiels = _repartition(kpis["materiels"]);
final emprunts = _repartition(kpis["emprunts"]);
final anomalies = _repartition(kpis["anomalies"]);
final activite = (data.dashboard["activiteRecente"] as List? ?? const [])
.whereType<Map>()
.map((item) => Map<String, dynamic>.from(item))
.toList();
final totalMateriels = materiels["total"] as int? ?? 0;
final disponibles = _statutCount(materiels, "DISPONIBLE");
final empruntsEnCours = _statutCount(emprunts, "EN_COURS");
final retards = _statutCount(emprunts, "EN_RETARD");
final anomaliesActives =
_statutCount(anomalies, "DETECTEE") +
_statutCount(anomalies, "EN_COURS_TRAITEMENT");
final notificationsNonLues = kpis["notificationsNonLues"] as int? ?? 0;
final ecartsEnAttente = data.ecarts.length;
final indisponibles = [
"NON_CONFORME",
"DETERIORE",
"MAINTENANCE",
"INDISPONIBLE",
].fold<int>(0, (total, statut) => total + _statutCount(materiels, statut));
return SingleChildScrollView(
padding: const EdgeInsets.all(28),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1440),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_DashboardHeader(onRefresh: onRefresh),
const SizedBox(height: 24),
LayoutBuilder(
builder: (context, constraints) {
final columns = constraints.maxWidth >= 1120
? 4
: constraints.maxWidth >= 560
? 2
: 1;
const spacing = 14.0;
final width =
(constraints.maxWidth - spacing * (columns - 1)) /
columns;
return Wrap(
spacing: spacing,
runSpacing: spacing,
children: [
_KpiCard(
width: width,
label: "Matériels disponibles",
value: "$disponibles",
detail: "sur $totalMateriels au total",
icon: Icons.inventory_2_outlined,
color: EnsupColors.cyan,
),
_KpiCard(
width: width,
label: "Emprunts en cours",
value: "$empruntsEnCours",
detail: retards > 0
? "$retards en retard à surveiller"
: "Aucun retard signalé",
icon: Icons.assignment_outlined,
color: retards > 0 ? _orange : EnsupColors.teal,
),
_KpiCard(
width: width,
label: "Anomalies actives",
value: "$anomaliesActives",
detail: anomaliesActives > 0
? "Traitement requis"
: "Aucune anomalie active",
icon: Icons.report_problem_outlined,
color: anomaliesActives > 0
? EnsupColors.red
: EnsupColors.green,
),
_KpiCard(
width: width,
label: "Notifications non lues",
value: "$notificationsNonLues",
detail: notificationsNonLues > 0
? "Nouvelles informations"
: "Vous êtes à jour",
icon: Icons.notifications_none_outlined,
color: EnsupColors.purple,
),
],
);
},
),
const SizedBox(height: 20),
LayoutBuilder(
builder: (context, constraints) {
final activity = _ActivityPanel(
items: activite,
onSeeAll: () => onNavigate(6),
);
final priorities = _PrioritiesPanel(
ecarts: ecartsEnAttente,
retards: retards,
anomalies: anomaliesActives,
notifications: notificationsNonLues,
indisponibles: indisponibles,
disponibles: disponibles,
totalMateriels: totalMateriels,
onNavigate: onNavigate,
);
if (constraints.maxWidth < 980) {
return Column(
children: [
activity,
const SizedBox(height: 16),
priorities,
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 2, child: activity),
const SizedBox(width: 16),
Expanded(child: priorities),
],
);
},
),
],
),
),
),
);
}
}
class _DashboardHeader extends StatelessWidget {
const _DashboardHeader({required this.onRefresh});
final VoidCallback onRefresh;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Vue densemble",
style: GoogleFonts.darkerGrotesque(
fontSize: 32,
height: 1,
fontWeight: FontWeight.w900,
color: EnsupColors.text,
),
),
const SizedBox(height: 7),
Text(
"La situation opérationnelle du campus en un coup d’œil · ${_dateLongue(DateTime.now())}",
style: GoogleFonts.titilliumWeb(
fontSize: 14,
color: EnsupColors.muted,
),
),
],
),
),
const SizedBox(width: 16),
OutlinedButton.icon(
onPressed: onRefresh,
icon: const Icon(Icons.refresh_rounded, size: 17),
label: const Text("Actualiser"),
),
],
);
}
}
class _KpiCard extends StatelessWidget {
const _KpiCard({
required this.width,
required this.label,
required this.value,
required this.detail,
required this.icon,
required this.color,
});
final double width;
final String label;
final String value;
final String detail;
final IconData icon;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
width: width,
height: 132,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: EnsupColors.line),
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: EnsupColors.text.withValues(alpha: 0.045),
blurRadius: 14,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.titilliumWeb(
fontSize: 13,
fontWeight: FontWeight.w600,
color: EnsupColors.muted,
),
),
Text(
value,
style: GoogleFonts.darkerGrotesque(
fontSize: 36,
height: 0.9,
fontWeight: FontWeight.w900,
color: EnsupColors.text,
),
),
Text(
detail,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.titilliumWeb(
fontSize: 11,
color: color,
fontWeight: FontWeight.w600,
),
),
],
),
),
const SizedBox(width: 10),
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: color, size: 22),
),
],
),
);
}
}
class _ActivityPanel extends StatelessWidget {
const _ActivityPanel({required this.items, required this.onSeeAll});
final List<Map<String, dynamic>> items;
final VoidCallback onSeeAll;
@override
Widget build(BuildContext context) {
return _DashboardPanel(
title: "Activité récente",
subtitle: "Dernières opérations enregistrées sur le campus",
action: TextButton(
onPressed: onSeeAll,
child: const Text("Voir lhistorique"),
),
child: items.isEmpty
? const _PanelEmptyState(
icon: Icons.history_toggle_off_outlined,
message: "Aucune activité récente",
)
: Column(
children: List.generate(items.take(6).length, (index) {
final item = items[index];
return Column(
children: [
_ActivityRow(item: item),
if (index < items.take(6).length - 1)
const Divider(height: 1, color: EnsupColors.line),
],
);
}),
),
);
}
}
class _ActivityRow extends StatelessWidget {
const _ActivityRow({required this.item});
final Map<String, dynamic> item;
@override
Widget build(BuildContext context) {
final utilisateur = _repartition(item["utilisateur"]);
final materiel = _repartition(item["materiel"]);
final action = "${item["action"] ?? ""}";
final isAlert = action == "CREATION_ANOMALIE";
final title = materiel.isNotEmpty
? "${materiel["nom"]}"
: _actionLabel(action);
final userName = utilisateur.isEmpty
? "Système"
: "${utilisateur["prenom"]} ${utilisateur["nom"]}";
return Padding(
padding: const EdgeInsets.symmetric(vertical: 13),
child: Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: (isAlert ? EnsupColors.red : EnsupColors.cyan).withValues(
alpha: 0.09,
),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
isAlert
? Icons.report_problem_outlined
: Icons.swap_horiz_rounded,
size: 19,
color: isAlert ? EnsupColors.red : EnsupColors.blue3,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.titilliumWeb(
fontSize: 13,
fontWeight: FontWeight.w700,
color: EnsupColors.text,
),
),
Text(
"${item["description"] ?? _actionLabel(action)} · $userName",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.titilliumWeb(
fontSize: 12,
color: EnsupColors.muted,
),
),
],
),
),
const SizedBox(width: 12),
Text(
_dateHeure(item["dateAction"]),
style: GoogleFonts.titilliumWeb(
fontSize: 11,
color: EnsupColors.muted,
),
),
],
),
);
}
}
class _PrioritiesPanel extends StatelessWidget {
const _PrioritiesPanel({
required this.ecarts,
required this.retards,
required this.anomalies,
required this.notifications,
required this.indisponibles,
required this.disponibles,
required this.totalMateriels,
required this.onNavigate,
});
final int ecarts;
final int retards;
final int anomalies;
final int notifications;
final int indisponibles;
final int disponibles;
final int totalMateriels;
final ValueChanged<int> onNavigate;
@override
Widget build(BuildContext context) {
final availability = totalMateriels == 0
? 0.0
: disponibles / totalMateriels;
return _DashboardPanel(
title: "Points dattention",
subtitle: "Éléments qui nécessitent votre suivi",
child: Column(
children: [
_PriorityRow(
label: "Écarts à confirmer",
value: ecarts,
icon: Icons.fact_check_outlined,
color: EnsupColors.red,
onTap: () => onNavigate(1),
),
const Divider(height: 1, color: EnsupColors.line),
_PriorityRow(
label: "Emprunts en retard",
value: retards,
icon: Icons.schedule_outlined,
color: _orange,
onTap: () => onNavigate(2),
),
const Divider(height: 1, color: EnsupColors.line),
_PriorityRow(
label: "Anomalies actives",
value: anomalies,
icon: Icons.report_problem_outlined,
color: EnsupColors.red,
onTap: () => onNavigate(4),
),
const Divider(height: 1, color: EnsupColors.line),
_PriorityRow(
label: "Notifications non lues",
value: notifications,
icon: Icons.notifications_none_outlined,
color: EnsupColors.purple,
onTap: () => onNavigate(5),
),
const Divider(height: 1, color: EnsupColors.line),
_PriorityRow(
label: "Matériels indisponibles",
value: indisponibles,
icon: Icons.build_outlined,
color: EnsupColors.teal,
onTap: () => onNavigate(3),
),
const SizedBox(height: 20),
Row(
children: [
Text(
"Disponibilité du parc",
style: GoogleFonts.titilliumWeb(
fontSize: 12,
fontWeight: FontWeight.w700,
color: EnsupColors.text2,
),
),
const Spacer(),
Text(
"${(availability * 100).round()} %",
style: GoogleFonts.darkerGrotesque(
fontSize: 17,
fontWeight: FontWeight.w900,
color: EnsupColors.blue3,
),
),
],
),
const SizedBox(height: 7),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: availability,
minHeight: 7,
backgroundColor: EnsupColors.line,
color: EnsupColors.cyan,
),
),
],
),
);
}
}
class _PriorityRow extends StatelessWidget {
const _PriorityRow({
required this.label,
required this.value,
required this.icon,
required this.color,
required this.onTap,
});
final String label;
final int value;
final IconData icon;
final Color color;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 11),
child: Row(
children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: 10),
Expanded(
child: Text(
label,
style: GoogleFonts.titilliumWeb(
fontSize: 12,
fontWeight: FontWeight.w600,
color: EnsupColors.text2,
),
),
),
Text(
"$value",
style: GoogleFonts.darkerGrotesque(
fontSize: 20,
fontWeight: FontWeight.w900,
color: value > 0 ? color : EnsupColors.muted,
),
),
const SizedBox(width: 5),
const Icon(
Icons.chevron_right_rounded,
size: 18,
color: EnsupColors.muted,
),
],
),
),
);
}
}
class _DashboardPanel extends StatelessWidget {
const _DashboardPanel({
required this.title,
required this.subtitle,
required this.child,
this.action,
});
final String title;
final String subtitle;
final Widget child;
final Widget? action;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: EnsupColors.line),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: GoogleFonts.darkerGrotesque(
fontSize: 21,
fontWeight: FontWeight.w900,
color: EnsupColors.text,
),
),
Text(
subtitle,
style: GoogleFonts.titilliumWeb(
fontSize: 12,
color: EnsupColors.muted,
),
),
],
),
),
?action,
],
),
const SizedBox(height: 12),
child,
],
),
);
}
}
class _PanelEmptyState extends StatelessWidget {
const _PanelEmptyState({required this.icon, required this.message});
final IconData icon;
final String message;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 180,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 32, color: EnsupColors.muted),
const SizedBox(height: 8),
Text(
message,
style: GoogleFonts.titilliumWeb(color: EnsupColors.muted),
),
],
),
),
);
}
}
class _VueListe extends StatefulWidget {
const _VueListe({
required this.titre,
required this.description,
required this.icon,
required this.items,
required this.builder,
required this.searchableText,
this.action,
});
final String titre;
final String description;
final IconData icon;
final List<Map<String, dynamic>> items;
final Widget Function(Map<String, dynamic>) builder;
final String Function(Map<String, dynamic>) searchableText;
final Widget? action;
@override
State<_VueListe> createState() => _VueListeState();
}
class _VueListeState extends State<_VueListe> {
String _recherche = "";
@override
Widget build(BuildContext context) {
final terme = _recherche.trim().toLowerCase();
final resultats = terme.isEmpty
? widget.items
: widget.items
.where(
(item) =>
widget.searchableText(item).toLowerCase().contains(terme),
)
.toList();
return ColoredBox(
color: _workspaceBackground,
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1440),
child: SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.all(28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 620;
final title = Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: EnsupColors.cyan.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
widget.icon,
color: EnsupColors.blue3,
size: 21,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.titre,
style: GoogleFonts.darkerGrotesque(
fontSize: 29,
height: 1,
fontWeight: FontWeight.w900,
color: EnsupColors.text,
),
),
Text(
widget.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.muted,
),
),
],
),
),
],
);
if (compact && widget.action != null) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
title,
const SizedBox(height: 14),
Align(
alignment: Alignment.centerLeft,
child: widget.action!,
),
],
);
}
return Row(
children: [
Expanded(child: title),
if (widget.action != null) ...[
const SizedBox(width: 16),
widget.action!,
],
],
);
},
),
const SizedBox(height: 22),
Container(
height: 46,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: EnsupColors.line),
boxShadow: [
BoxShadow(
color: EnsupColors.text.withValues(alpha: 0.035),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: TextField(
onChanged: (value) => setState(() => _recherche = value),
decoration: InputDecoration(
hintText:
"Rechercher dans ${widget.titre.toLowerCase()}...",
prefixIcon: const Icon(
Icons.search_rounded,
size: 20,
color: EnsupColors.muted,
),
suffixIcon: Padding(
padding: const EdgeInsets.only(right: 10),
child: Center(
widthFactor: 1,
child: Text(
"${resultats.length} résultat${resultats.length > 1 ? "s" : ""}",
style: GoogleFonts.titilliumWeb(
fontSize: 11,
color: EnsupColors.muted,
),
),
),
),
isDense: true,
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
vertical: 13,
),
),
),
),
const SizedBox(height: 14),
Expanded(
child: resultats.isEmpty
? const _EmptyState()
: ListView.separated(
itemCount: resultats.length,
separatorBuilder: (_, _) =>
const SizedBox(height: 8),
itemBuilder: (_, index) =>
widget.builder(resultats[index]),
),
),
],
),
),
),
),
),
);
}
}
class _InfoTile extends StatelessWidget {
const _InfoTile({
required this.icon,
required this.title,
required this.subtitle,
required this.trailing,
this.meta,
});
final IconData icon;
final String title;
final String subtitle;
final Widget trailing;
final String? meta;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: EnsupColors.line),
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: EnsupColors.text.withValues(alpha: 0.035),
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 620;
final information = Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: EnsupColors.teal.withValues(alpha: 0.09),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, size: 20, color: EnsupColors.teal),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: compact ? 2 : 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.darkerGrotesque(
fontSize: 19,
height: 1.05,
fontWeight: FontWeight.w800,
color: EnsupColors.text,
),
),
const SizedBox(height: 3),
Text(
subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.text2,
),
),
if (meta != null) ...[
const SizedBox(height: 4),
Text(
meta!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.titilliumWeb(
fontSize: 11,
color: EnsupColors.muted,
),
),
],
],
),
),
],
);
if (compact) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
information,
const SizedBox(height: 12),
Align(alignment: Alignment.centerRight, child: trailing),
],
);
}
return Row(
children: [
Expanded(child: information),
const SizedBox(width: 16),
trailing,
],
);
},
),
);
}
}
class _AnomalieAction extends StatelessWidget {
const _AnomalieAction({
required this.statut,
required this.prochainStatut,
required this.disabled,
required this.onPressed,
});
final String statut;
final String? prochainStatut;
final bool disabled;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final prochain = prochainStatut;
return Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
alignment: WrapAlignment.end,
children: [
_StatusBadge(statut),
if (prochain != null)
FilledButton.icon(
onPressed: disabled ? null : onPressed,
icon: const Icon(Icons.arrow_forward_rounded, size: 16),
label: Text(_statusLabel(prochain)),
),
],
);
}
}
class _StatusBadge extends StatelessWidget {
const _StatusBadge(this.statut);
final String statut;
@override
Widget build(BuildContext context) {
final color = _statusColor(statut);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.18)),
),
child: Text(
_statusLabel(statut),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: color,
),
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return Center(
child: Text(
"Aucun résultat",
style: GoogleFonts.titilliumWeb(color: EnsupColors.muted),
),
);
}
}
class _Erreur extends StatelessWidget {
const _Erreur({required this.message, required this.onRetry});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text("Réessayer"),
),
],
),
);
}
}