From 38efe6a87344d726241f26ee1e76fd53c2c8b310 Mon Sep 17 00:00:00 2001 From: SaidSoighiri94 Date: Fri, 24 Jul 2026 15:47:48 +0200 Subject: [PATCH] feat(responsable): add anomaly and history tables --- .../src/controllers/responsable.controller.ts | 11 + .../repositories/responsable.repository.ts | 68 +- .../src/services/responsable.service.ts | 54 + .../lib/screens/responsable_screen.dart | 1470 ++++++++++++++++- .../lib/services/responsable_service.dart | 83 +- review.md | 18 +- 6 files changed, 1623 insertions(+), 81 deletions(-) diff --git a/eme-backend/src/controllers/responsable.controller.ts b/eme-backend/src/controllers/responsable.controller.ts index bbf6190..43c4635 100644 --- a/eme-backend/src/controllers/responsable.controller.ts +++ b/eme-backend/src/controllers/responsable.controller.ts @@ -24,7 +24,9 @@ import { parseStatutAnomalie, parseStatutMateriel, parseStatutEmprunt, + parseTriAnomalie, parseTriEmprunt, + parseTriHistorique, parseTriMateriel, } from '../services/responsable.service'; import { @@ -53,6 +55,9 @@ function getHistoriqueFiltres(req: Request) { empruntId: parsePositiveIntQuery(req.query.empruntId, 'empruntId'), dateDebut: parseDateQuery(req.query.dateDebut, 'dateDebut'), dateFin: parseDateQuery(req.query.dateFin, 'dateFin'), + recherche: typeof req.query.q === 'string' ? req.query.q : undefined, + tri: parseTriHistorique(req.query.tri), + ordre: parseOrdreTri(req.query.ordre), }; } @@ -163,10 +168,16 @@ export async function getAnomalies(req: Request, res: Response): Promise { const statut = parseStatutAnomalie(req.query.statut); const type = typeof req.query.type === 'string' ? req.query.type : undefined; + const recherche = typeof req.query.q === 'string' ? req.query.q : undefined; + const tri = parseTriAnomalie(req.query.tri); + const ordre = parseOrdreTri(req.query.ordre); const anomalies = await listerAnomaliesResponsable(user.roleCode, user.campusId, { statut, type, + recherche, + tri, + ordre, }); res.json({ data: anomalies.map(toAnomalieResponsableResponse) }); } diff --git a/eme-backend/src/repositories/responsable.repository.ts b/eme-backend/src/repositories/responsable.repository.ts index e4086b9..3b7997f 100644 --- a/eme-backend/src/repositories/responsable.repository.ts +++ b/eme-backend/src/repositories/responsable.repository.ts @@ -96,6 +96,9 @@ export interface MaterielResponsableFiltres { export interface AnomalieResponsableFiltres { statut?: string; type?: string; + recherche?: string; + tri?: 'dateDetection' | 'type' | 'materiel' | 'etudiant' | 'statut'; + ordre?: 'asc' | 'desc'; } export interface NotificationResponsableFiltres { @@ -109,6 +112,9 @@ export interface HistoriqueResponsableFiltres { empruntId?: number; dateDebut?: Date; dateFin?: Date; + recherche?: string; + tri?: 'dateAction' | 'action' | 'utilisateur' | 'materiel' | 'emprunt'; + ordre?: 'asc' | 'desc'; } export interface ChangerStatutAnomalieData { @@ -421,11 +427,40 @@ export function findAnomaliesResponsable( campusId: number, filtres: AnomalieResponsableFiltres, ): Promise { + const ordre = filtres.ordre ?? 'desc'; + const orderBy: Prisma.AnomalieOrderByWithRelationInput[] = (() => { + switch (filtres.tri) { + case 'type': + return [{ type: ordre }, { dateDetection: 'desc' }, { id: 'desc' }]; + case 'materiel': + return [{ materiel: { nom: ordre } }, { dateDetection: 'desc' }, { id: 'desc' }]; + case 'etudiant': + return [{ etudiant: { nom: ordre } }, { dateDetection: 'desc' }, { id: 'desc' }]; + case 'statut': + return [{ statut: ordre }, { dateDetection: 'desc' }, { id: 'desc' }]; + default: + return [{ dateDetection: ordre }, { id: 'desc' }]; + } + })(); + return prisma.anomalie.findMany({ where: { emprunt: { campusId }, ...(filtres.statut ? { statut: filtres.statut } : {}), ...(filtres.type ? { type: filtres.type } : {}), + ...(filtres.recherche + ? { + OR: [ + { description: { contains: filtres.recherche } }, + { type: { contains: filtres.recherche } }, + { materiel: { nom: { contains: filtres.recherche } } }, + { materiel: { reference: { contains: filtres.recherche } } }, + { etudiant: { nom: { contains: filtres.recherche } } }, + { etudiant: { prenom: { contains: filtres.recherche } } }, + { etudiant: { email: { contains: filtres.recherche } } }, + ], + } + : {}), }, include: { emprunt: true, @@ -433,7 +468,7 @@ export function findAnomaliesResponsable( materiel: { include: { categorie: true } }, traitePar: true, }, - orderBy: { dateDetection: 'desc' }, + orderBy, }); } @@ -548,6 +583,22 @@ export function findHistoriqueResponsable( campusId: number, filtres: HistoriqueResponsableFiltres, ): Promise { + const ordre = filtres.ordre ?? 'desc'; + const orderBy: Prisma.HistoriqueOrderByWithRelationInput[] = (() => { + switch (filtres.tri) { + case 'action': + return [{ action: ordre }, { dateAction: 'desc' }, { id: 'desc' }]; + case 'utilisateur': + return [{ utilisateur: { nom: ordre } }, { dateAction: 'desc' }, { id: 'desc' }]; + case 'materiel': + return [{ materiel: { nom: ordre } }, { dateAction: 'desc' }, { id: 'desc' }]; + case 'emprunt': + return [{ empruntId: ordre }, { dateAction: 'desc' }, { id: 'desc' }]; + default: + return [{ dateAction: ordre }, { id: 'desc' }]; + } + })(); + return prisma.historique.findMany({ where: { campusId, @@ -563,6 +614,19 @@ export function findHistoriqueResponsable( }, } : {}), + ...(filtres.recherche + ? { + OR: [ + { action: { contains: filtres.recherche } }, + { description: { contains: filtres.recherche } }, + { utilisateur: { nom: { contains: filtres.recherche } } }, + { utilisateur: { prenom: { contains: filtres.recherche } } }, + { utilisateur: { email: { contains: filtres.recherche } } }, + { materiel: { nom: { contains: filtres.recherche } } }, + { materiel: { reference: { contains: filtres.recherche } } }, + ], + } + : {}), }, include: { utilisateur: true, @@ -571,6 +635,6 @@ export function findHistoriqueResponsable( sallePret: true, posteEmprunt: true, }, - orderBy: { dateAction: 'desc' }, + orderBy, }); } diff --git a/eme-backend/src/services/responsable.service.ts b/eme-backend/src/services/responsable.service.ts index 7c83377..4a8a104 100644 --- a/eme-backend/src/services/responsable.service.ts +++ b/eme-backend/src/services/responsable.service.ts @@ -80,8 +80,18 @@ export type TriMaterielResponsable = export interface ListerAnomaliesResponsableFiltres { statut?: StatutAnomalie; type?: string; + recherche?: string; + tri?: TriAnomalieResponsable; + ordre?: OrdreTri; } +export type TriAnomalieResponsable = + | 'dateDetection' + | 'type' + | 'materiel' + | 'etudiant' + | 'statut'; + export interface ListerNotificationsResponsableFiltres { lu?: boolean; } @@ -93,8 +103,18 @@ export interface ListerHistoriqueResponsableFiltres { empruntId?: number; dateDebut?: Date; dateFin?: Date; + recherche?: string; + tri?: TriHistoriqueResponsable; + ordre?: OrdreTri; } +export type TriHistoriqueResponsable = + | 'dateAction' + | 'action' + | 'utilisateur' + | 'materiel' + | 'emprunt'; + export interface ChangerStatutAnomalieResponsableRequest { statut: StatutAnomalie; observation?: string; @@ -244,6 +264,23 @@ export function parseStatutAnomalie(value: unknown): StatutAnomalie | undefined return value as StatutAnomalie; } +export function parseTriAnomalie(value: unknown): TriAnomalieResponsable | undefined { + if (value === undefined) { + return undefined; + } + const valeurs: readonly TriAnomalieResponsable[] = [ + 'dateDetection', + 'type', + 'materiel', + 'etudiant', + 'statut', + ]; + if (typeof value !== 'string' || !valeurs.includes(value as TriAnomalieResponsable)) { + throw new AppError(400, 'tri invalide'); + } + return value as TriAnomalieResponsable; +} + export function parseCategorieId(value: unknown): number | undefined { if (value === undefined) { return undefined; @@ -328,6 +365,23 @@ export function parseDateQuery(value: unknown, champ: string): Date | undefined return parsed; } +export function parseTriHistorique(value: unknown): TriHistoriqueResponsable | undefined { + if (value === undefined) { + return undefined; + } + const valeurs: readonly TriHistoriqueResponsable[] = [ + 'dateAction', + 'action', + 'utilisateur', + 'materiel', + 'emprunt', + ]; + if (typeof value !== 'string' || !valeurs.includes(value as TriHistoriqueResponsable)) { + throw new AppError(400, 'tri invalide'); + } + return value as TriHistoriqueResponsable; +} + export function parseLu(value: unknown): boolean | undefined { if (value === undefined) { return undefined; diff --git a/eme-frontend/lib/screens/responsable_screen.dart b/eme-frontend/lib/screens/responsable_screen.dart index 9e028c4..66ed0d5 100644 --- a/eme-frontend/lib/screens/responsable_screen.dart +++ b/eme-frontend/lib/screens/responsable_screen.dart @@ -22,7 +22,13 @@ class _ResponsableScreenState extends State { TextEditingController(); final TextEditingController _rechercheMaterielsController = TextEditingController(); + final TextEditingController _rechercheAnomaliesController = + TextEditingController(); + final TextEditingController _rechercheHistoriqueController = + TextEditingController(); final Map _categoriesMateriels = {}; + final Set _typesAnomalies = {}; + final Set _actionsHistorique = {}; late Future<_ResponsableData> _future = _charger(); int _onglet = 0; String? _statutEmprunts; @@ -33,6 +39,14 @@ class _ResponsableScreenState extends State { int? _categorieMateriels; String _triMateriels = "nom"; String _ordreMateriels = "asc"; + String? _statutAnomalies; + String? _typeAnomalies; + String _triAnomalies = "dateDetection"; + String _ordreAnomalies = "desc"; + String? _actionHistorique; + DateTimeRange? _periodeHistorique; + String _triHistorique = "dateAction"; + String _ordreHistorique = "desc"; bool _transitionEnCours = false; bool _decisionEnCours = false; bool _exportEnCours = false; @@ -58,9 +72,26 @@ class _ResponsableScreenState extends State { final categorie = Map.from(materiel["categorie"] as Map); _categoriesMateriels[categorie["id"] as int] = categorie["nom"] as String; } - final anomalies = await ResponsableService.anomalies(); + final anomalies = await ResponsableService.anomalies( + statut: _statutAnomalies, + type: _typeAnomalies, + recherche: _rechercheAnomaliesController.text, + tri: _triAnomalies, + ordre: _ordreAnomalies, + ); + _typesAnomalies.addAll(anomalies.map((item) => item["type"] as String)); final notifications = await ResponsableService.notifications(); - final historique = await ResponsableService.historique(); + final historique = await ResponsableService.historique( + action: _actionHistorique, + dateDebut: _periodeHistorique?.start, + dateFin: _periodeHistorique?.end, + recherche: _rechercheHistoriqueController.text, + tri: _triHistorique, + ordre: _ordreHistorique, + ); + _actionsHistorique.addAll( + historique.map((item) => item["action"] as String), + ); return _ResponsableData( dashboard: dashboard, @@ -139,10 +170,74 @@ class _ResponsableScreenState extends State { }); } + void _trierAnomalies(String tri) { + setState(() { + if (_triAnomalies == tri) { + _ordreAnomalies = _ordreAnomalies == "asc" ? "desc" : "asc"; + } else { + _triAnomalies = tri; + _ordreAnomalies = tri == "dateDetection" ? "desc" : "asc"; + } + _future = _charger(); + }); + } + + void _filtrerAnomalies({String? statut, String? type}) { + setState(() { + _statutAnomalies = statut; + _typeAnomalies = type; + _future = _charger(); + }); + } + + void _reinitialiserFiltresAnomalies() { + _rechercheAnomaliesController.clear(); + setState(() { + _statutAnomalies = null; + _typeAnomalies = null; + _triAnomalies = "dateDetection"; + _ordreAnomalies = "desc"; + _future = _charger(); + }); + } + + void _trierHistorique(String tri) { + setState(() { + if (_triHistorique == tri) { + _ordreHistorique = _ordreHistorique == "asc" ? "desc" : "asc"; + } else { + _triHistorique = tri; + _ordreHistorique = tri == "dateAction" ? "desc" : "asc"; + } + _future = _charger(); + }); + } + + void _filtrerHistorique({String? action, DateTimeRange? periode}) { + setState(() { + _actionHistorique = action; + _periodeHistorique = periode; + _future = _charger(); + }); + } + + void _reinitialiserFiltresHistorique() { + _rechercheHistoriqueController.clear(); + setState(() { + _actionHistorique = null; + _periodeHistorique = null; + _triHistorique = "dateAction"; + _ordreHistorique = "desc"; + _future = _charger(); + }); + } + @override void dispose() { _rechercheEmpruntsController.dispose(); _rechercheMaterielsController.dispose(); + _rechercheAnomaliesController.dispose(); + _rechercheHistoriqueController.dispose(); super.dispose(); } @@ -159,7 +254,7 @@ class _ResponsableScreenState extends State { } Future _changerStatut(Map anomalie) async { - final prochain = _prochainStatut(anomalie["statut"] as String?); + final prochain = _prochainStatutAnomalie(anomalie["statut"] as String?); if (prochain == null || _transitionEnCours) { return; } @@ -240,7 +335,14 @@ class _ResponsableScreenState extends State { setState(() => _exportEnCours = true); try { - final file = await ResponsableService.exporterHistorique(); + final file = await ResponsableService.exporterHistorique( + action: _actionHistorique, + dateDebut: _periodeHistorique?.start, + dateFin: _periodeHistorique?.end, + recherche: _rechercheHistoriqueController.text, + tri: _triHistorique, + ordre: _ordreHistorique, + ); downloadFile( bytes: file.bytes, fileName: file.fileName, @@ -262,15 +364,6 @@ class _ResponsableScreenState extends State { } } - 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( @@ -365,17 +458,25 @@ class _ResponsableScreenState extends State { onTri: _trierMateriels, onReset: _reinitialiserFiltresMateriels, ), - _VueListe( - titre: "Anomalies", - description: - "Priorisez et faites avancer les incidents détectés lors des retours.", - icon: Icons.report_problem_outlined, + _VueAnomaliesTableau( items: data.anomalies, - builder: _anomalieTile, - searchableText: (item) { - final materiel = Map.from(item["materiel"] as Map); - return "${item["type"]} ${item["description"]} ${item["statut"]} ${materiel["nom"]}"; + types: _typesAnomalies.toList(), + rechercheController: _rechercheAnomaliesController, + statut: _statutAnomalies, + type: _typeAnomalies, + tri: _triAnomalies, + ordre: _ordreAnomalies, + actionDesactivee: _transitionEnCours, + onRecherche: _rafraichir, + onStatutChanged: (statut) { + _filtrerAnomalies(statut: statut, type: _typeAnomalies); }, + onTypeChanged: (type) { + _filtrerAnomalies(statut: _statutAnomalies, type: type); + }, + onTri: _trierAnomalies, + onReset: _reinitialiserFiltresAnomalies, + onChangerStatut: _changerStatut, ), _VueListe( titre: "Notifications", @@ -391,25 +492,25 @@ class _ResponsableScreenState extends State { ), 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, + _VueHistoriqueTableau( 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"]}", + actions: _actionsHistorique.toList(), + rechercheController: _rechercheHistoriqueController, + action: _actionHistorique, + periode: _periodeHistorique, + tri: _triHistorique, + ordre: _ordreHistorique, + exportEnCours: _exportEnCours, + onRecherche: _rafraichir, + onActionChanged: (action) { + _filtrerHistorique(action: action, periode: _periodeHistorique); + }, + onPeriodeChanged: (periode) { + _filtrerHistorique(action: _actionHistorique, periode: periode); + }, + onTri: _trierHistorique, + onReset: _reinitialiserFiltresHistorique, + onExport: _exporterHistorique, ), ]; @@ -467,23 +568,6 @@ class _ResponsableScreenState extends State { ); } - Widget _anomalieTile(Map item) { - final materiel = Map.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 item) { return _InfoTile( icon: item["lu"] == true @@ -496,18 +580,6 @@ class _ResponsableScreenState extends State { ); } - Widget _historiqueTile(Map item) { - final utilisateur = Map.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()); @@ -545,6 +617,24 @@ String _statusLabel(String statut) { }; } +String _typeAnomalieLabel(String type) { + return switch (type) { + "ACCESSOIRE_MANQUANT" => "Accessoire manquant", + "ECART_DEPART" => "Écart au départ", + "RETOUR_NON_CONFORME" => "Retour non conforme", + _ => type.replaceAll("_", " ").toLowerCase(), + }; +} + +String? _prochainStatutAnomalie(String? statut) { + return switch (statut) { + "DETECTEE" => "EN_COURS_TRAITEMENT", + "EN_COURS_TRAITEMENT" => "RESOLUE", + "RESOLUE" => "CLOTUREE", + _ => null, + }; +} + String _actionLabel(String action) { return switch (action) { "CREATION_EMPRUNT" => "Création d'emprunt", @@ -997,6 +1087,16 @@ String _dateComplete(Object? value) { return "$jour/$mois/${date.year}"; } +String _dateHeureComplete(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/${date.year} · $heure:$minute"; +} + class _ResponsableTopBar extends StatelessWidget { const _ResponsableTopBar({required this.onExit, required this.onRefresh}); @@ -3180,6 +3280,1234 @@ class _VueMaterielsTableau extends StatelessWidget { } } +class _VueAnomaliesTableau extends StatelessWidget { + const _VueAnomaliesTableau({ + required this.items, + required this.types, + required this.rechercheController, + required this.statut, + required this.type, + required this.tri, + required this.ordre, + required this.actionDesactivee, + required this.onRecherche, + required this.onStatutChanged, + required this.onTypeChanged, + required this.onTri, + required this.onReset, + required this.onChangerStatut, + }); + + final List> items; + final List types; + final TextEditingController rechercheController; + final String? statut; + final String? type; + final String tri; + final String ordre; + final bool actionDesactivee; + final VoidCallback onRecherche; + final ValueChanged onStatutChanged; + final ValueChanged onTypeChanged; + final ValueChanged onTri; + final VoidCallback onReset; + final ValueChanged> onChangerStatut; + + static const _statuts = [ + "DETECTEE", + "EN_COURS_TRAITEMENT", + "RESOLUE", + "CLOTUREE", + ]; + + static const _tris = { + "dateDetection": "Date de détection", + "type": "Type", + "materiel": "Matériel", + "etudiant": "Étudiant", + "statut": "Statut", + }; + + int? get _sortColumnIndex { + return switch (tri) { + "dateDetection" => 0, + "type" => 1, + "materiel" => 2, + "etudiant" => 3, + "statut" => 4, + _ => null, + }; + } + + @override + Widget build(BuildContext context) { + 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: [ + _entete(), + const SizedBox(height: 22), + _filtres(), + const SizedBox(height: 14), + Row( + children: [ + Text( + "${items.length} anomalie${items.length > 1 ? "s" : ""}", + style: GoogleFonts.titilliumWeb( + fontSize: 12, + fontWeight: FontWeight.w700, + color: EnsupColors.text2, + ), + ), + const Spacer(), + Text( + "Tri : ${_tris[tri]} · ${ordre == "asc" ? "croissant" : "décroissant"}", + style: GoogleFonts.titilliumWeb( + fontSize: 11, + color: EnsupColors.muted, + ), + ), + ], + ), + const SizedBox(height: 8), + Expanded( + child: items.isEmpty + ? const _EmptyState() + : LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 980) { + return _listeCompacte(); + } + return _tableau(constraints.maxWidth); + }, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _entete() { + return Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: EnsupColors.red.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.report_problem_outlined, + color: EnsupColors.red, + size: 21, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Anomalies", + style: GoogleFonts.darkerGrotesque( + fontSize: 29, + height: 1, + fontWeight: FontWeight.w900, + color: EnsupColors.text, + ), + ), + Text( + "Priorisez et faites avancer les incidents détectés sur le campus.", + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.titilliumWeb( + fontSize: 13, + color: EnsupColors.muted, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _filtres() { + final typesTries = [...types] + ..sort( + (a, b) => _typeAnomalieLabel( + a, + ).toLowerCase().compareTo(_typeAnomalieLabel(b).toLowerCase()), + ); + + return Wrap( + spacing: 10, + runSpacing: 10, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + SizedBox( + width: 310, + height: 48, + child: TextField( + controller: rechercheController, + textInputAction: TextInputAction.search, + onSubmitted: (_) => onRecherche(), + decoration: InputDecoration( + hintText: "Description, matériel ou étudiant", + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: IconButton( + tooltip: "Lancer la recherche", + onPressed: onRecherche, + icon: const Icon(Icons.arrow_forward, size: 18), + ), + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: EnsupColors.line), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: EnsupColors.line), + ), + contentPadding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + SizedBox( + width: 210, + child: DropdownButtonFormField( + key: ValueKey("statut-anomalie-$statut"), + initialValue: statut ?? "", + isExpanded: true, + decoration: const InputDecoration( + labelText: "Statut", + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder(), + ), + items: [ + const DropdownMenuItem( + value: "", + child: Text( + "Tous les statuts", + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ..._statuts.map( + (value) => DropdownMenuItem( + value: value, + child: Text( + _statusLabel(value), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + onChanged: (value) { + onStatutChanged(value == null || value.isEmpty ? null : value); + }, + ), + ), + SizedBox( + width: 220, + child: DropdownButtonFormField( + key: ValueKey("type-anomalie-$type"), + initialValue: type ?? "", + isExpanded: true, + decoration: const InputDecoration( + labelText: "Type", + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder(), + ), + items: [ + const DropdownMenuItem( + value: "", + child: Text( + "Tous les types", + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ...typesTries.map( + (value) => DropdownMenuItem( + value: value, + child: Text( + _typeAnomalieLabel(value), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + onChanged: (value) { + onTypeChanged(value == null || value.isEmpty ? null : value); + }, + ), + ), + SizedBox( + width: 190, + child: DropdownButtonFormField( + key: ValueKey("tri-anomalie-$tri"), + initialValue: tri, + isExpanded: true, + decoration: const InputDecoration( + labelText: "Trier par", + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder(), + ), + items: _tris.entries + .map( + (entry) => DropdownMenuItem( + value: entry.key, + child: Text( + entry.value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ) + .toList(), + onChanged: (value) { + if (value != null && value != tri) { + onTri(value); + } + }, + ), + ), + Tooltip( + message: ordre == "asc" + ? "Passer en ordre décroissant" + : "Passer en ordre croissant", + child: IconButton.outlined( + onPressed: () => onTri(tri), + icon: Icon( + ordre == "asc" + ? Icons.arrow_upward_rounded + : Icons.arrow_downward_rounded, + ), + ), + ), + Tooltip( + message: "Réinitialiser les filtres", + child: IconButton.outlined( + onPressed: onReset, + icon: const Icon(Icons.filter_alt_off_outlined), + ), + ), + ], + ); + } + + Widget _tableau(double largeurDisponible) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: EnsupColors.line), + ), + child: SingleChildScrollView( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: largeurDisponible), + child: DataTable( + sortColumnIndex: _sortColumnIndex, + sortAscending: ordre == "asc", + headingRowColor: WidgetStatePropertyAll( + EnsupColors.soft.withValues(alpha: 0.8), + ), + headingTextStyle: GoogleFonts.titilliumWeb( + fontSize: 12, + fontWeight: FontWeight.w700, + color: EnsupColors.text2, + ), + dataTextStyle: GoogleFonts.titilliumWeb( + fontSize: 12, + color: EnsupColors.text, + ), + columns: [ + DataColumn( + label: const Text("Détection"), + onSort: (_, _) => onTri("dateDetection"), + ), + DataColumn( + label: const Text("Anomalie"), + onSort: (_, _) => onTri("type"), + ), + DataColumn( + label: const Text("Matériel"), + onSort: (_, _) => onTri("materiel"), + ), + DataColumn( + label: const Text("Étudiant"), + onSort: (_, _) => onTri("etudiant"), + ), + DataColumn( + label: const Text("Statut"), + onSort: (_, _) => onTri("statut"), + ), + const DataColumn(label: Text("Action")), + ], + rows: items.map(_ligneTableau).toList(), + ), + ), + ), + ), + ), + ); + } + + DataRow _ligneTableau(Map item) { + final materiel = Map.from(item["materiel"] as Map); + final etudiant = Map.from(item["etudiant"] as Map); + final prochain = _prochainStatutAnomalie(item["statut"] as String?); + return DataRow( + cells: [ + DataCell(Text(_dateComplete(item["dateDetection"]))), + DataCell( + Tooltip( + message: "${item["description"]}", + child: SizedBox( + width: 220, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _typeAnomalieLabel("${item["type"]}"), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + Text( + "${item["description"]}", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: EnsupColors.muted), + ), + ], + ), + ), + ), + ), + DataCell( + SizedBox( + width: 170, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "${materiel["nom"]}", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + Text( + "${materiel["reference"]}", + style: const TextStyle(color: EnsupColors.muted), + ), + ], + ), + ), + ), + DataCell( + SizedBox( + width: 160, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "${etudiant["prenom"]} ${etudiant["nom"]}", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + Text( + "${etudiant["classe"] ?? "-"}", + style: const TextStyle(color: EnsupColors.muted), + ), + ], + ), + ), + ), + DataCell(_StatusBadge("${item["statut"]}")), + DataCell( + _AnomalieAction( + statut: "${item["statut"]}", + prochainStatut: prochain, + disabled: actionDesactivee, + afficherStatut: false, + onPressed: () => onChangerStatut(item), + ), + ), + ], + ); + } + + Widget _listeCompacte() { + return ListView.separated( + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final item = items[index]; + final materiel = Map.from(item["materiel"] as Map); + final etudiant = Map.from(item["etudiant"] as Map); + final prochain = _prochainStatutAnomalie(item["statut"] as String?); + return Container( + padding: const EdgeInsets.all(14), + 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: Text( + _typeAnomalieLabel("${item["type"]}"), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.darkerGrotesque( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(width: 8), + _StatusBadge("${item["statut"]}"), + ], + ), + const SizedBox(height: 6), + Text( + "${item["description"]}", + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.titilliumWeb( + fontSize: 12, + color: EnsupColors.text2, + ), + ), + const Divider(height: 20, color: EnsupColors.line), + _detailCompact( + Icons.inventory_2_outlined, + "${materiel["nom"]} · ${materiel["reference"]}", + ), + _detailCompact( + Icons.person_outline, + "${etudiant["prenom"]} ${etudiant["nom"]}", + ), + _detailCompact( + Icons.calendar_today_outlined, + "Détectée le ${_dateComplete(item["dateDetection"])}", + ), + if (prochain != null) ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: _AnomalieAction( + statut: "${item["statut"]}", + prochainStatut: prochain, + disabled: actionDesactivee, + afficherStatut: false, + onPressed: () => onChangerStatut(item), + ), + ), + ], + ], + ), + ); + }, + ); + } + + Widget _detailCompact(IconData icon, String texte) { + return Padding( + padding: const EdgeInsets.only(bottom: 5), + child: Row( + children: [ + Icon(icon, size: 15, color: EnsupColors.muted), + const SizedBox(width: 7), + Expanded( + child: Text( + texte, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.titilliumWeb( + fontSize: 12, + color: EnsupColors.text2, + ), + ), + ), + ], + ), + ); + } +} + +class _VueHistoriqueTableau extends StatelessWidget { + const _VueHistoriqueTableau({ + required this.items, + required this.actions, + required this.rechercheController, + required this.action, + required this.periode, + required this.tri, + required this.ordre, + required this.exportEnCours, + required this.onRecherche, + required this.onActionChanged, + required this.onPeriodeChanged, + required this.onTri, + required this.onReset, + required this.onExport, + }); + + final List> items; + final List actions; + final TextEditingController rechercheController; + final String? action; + final DateTimeRange? periode; + final String tri; + final String ordre; + final bool exportEnCours; + final VoidCallback onRecherche; + final ValueChanged onActionChanged; + final ValueChanged onPeriodeChanged; + final ValueChanged onTri; + final VoidCallback onReset; + final VoidCallback onExport; + + static const _tris = { + "dateAction": "Date", + "action": "Action", + "utilisateur": "Utilisateur", + "materiel": "Matériel", + "emprunt": "Emprunt", + }; + + int? get _sortColumnIndex { + return switch (tri) { + "dateAction" => 0, + "action" => 1, + "utilisateur" => 2, + "materiel" => 3, + "emprunt" => 4, + _ => null, + }; + } + + @override + Widget build(BuildContext context) { + 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: [ + _entete(), + const SizedBox(height: 22), + _filtres(context), + const SizedBox(height: 14), + Row( + children: [ + Text( + "${items.length} événement${items.length > 1 ? "s" : ""}", + style: GoogleFonts.titilliumWeb( + fontSize: 12, + fontWeight: FontWeight.w700, + color: EnsupColors.text2, + ), + ), + const Spacer(), + Text( + "Tri : ${_tris[tri]} · ${ordre == "asc" ? "croissant" : "décroissant"}", + style: GoogleFonts.titilliumWeb( + fontSize: 11, + color: EnsupColors.muted, + ), + ), + ], + ), + const SizedBox(height: 8), + Expanded( + child: items.isEmpty + ? const _EmptyState() + : LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 980) { + return _listeCompacte(); + } + return _tableau(constraints.maxWidth); + }, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _entete() { + final export = OutlinedButton.icon( + onPressed: exportEnCours ? null : onExport, + 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"), + ); + + return LayoutBuilder( + builder: (context, constraints) { + final titre = Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: EnsupColors.cyan.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.history, + color: EnsupColors.blue3, + size: 21, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Historique", + style: GoogleFonts.darkerGrotesque( + fontSize: 29, + height: 1, + fontWeight: FontWeight.w900, + color: EnsupColors.text, + ), + ), + Text( + "Retrouvez la trace complète des actions réalisées sur le campus.", + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.titilliumWeb( + fontSize: 13, + color: EnsupColors.muted, + ), + ), + ], + ), + ), + ], + ); + + if (constraints.maxWidth < 620) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + titre, + const SizedBox(height: 14), + Align(alignment: Alignment.centerLeft, child: export), + ], + ); + } + return Row( + children: [ + Expanded(child: titre), + const SizedBox(width: 16), + export, + ], + ); + }, + ); + } + + Widget _filtres(BuildContext context) { + final actionsTriees = [...actions] + ..sort( + (a, b) => _actionLabel( + a, + ).toLowerCase().compareTo(_actionLabel(b).toLowerCase()), + ); + + return Wrap( + spacing: 10, + runSpacing: 10, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + SizedBox( + width: 310, + height: 48, + child: TextField( + controller: rechercheController, + textInputAction: TextInputAction.search, + onSubmitted: (_) => onRecherche(), + decoration: InputDecoration( + hintText: "Action, description, utilisateur ou matériel", + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: IconButton( + tooltip: "Lancer la recherche", + onPressed: onRecherche, + icon: const Icon(Icons.arrow_forward, size: 18), + ), + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: EnsupColors.line), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: EnsupColors.line), + ), + contentPadding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + SizedBox( + width: 230, + child: DropdownButtonFormField( + key: ValueKey("action-historique-$action"), + initialValue: action ?? "", + isExpanded: true, + decoration: const InputDecoration( + labelText: "Action", + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder(), + ), + items: [ + const DropdownMenuItem( + value: "", + child: Text( + "Toutes les actions", + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ...actionsTriees.map( + (value) => DropdownMenuItem( + value: value, + child: Text( + _actionLabel(value), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + onChanged: (value) { + onActionChanged(value == null || value.isEmpty ? null : value); + }, + ), + ), + SizedBox( + height: 48, + child: OutlinedButton.icon( + onPressed: () => _choisirPeriode(context), + icon: const Icon(Icons.date_range_outlined, size: 18), + label: SizedBox( + width: 150, + child: Text( + _periodeLabel(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + if (periode != null) + Tooltip( + message: "Effacer la période", + child: IconButton.outlined( + onPressed: () => onPeriodeChanged(null), + icon: const Icon(Icons.event_busy_outlined), + ), + ), + SizedBox( + width: 180, + child: DropdownButtonFormField( + key: ValueKey("tri-historique-$tri"), + initialValue: tri, + isExpanded: true, + decoration: const InputDecoration( + labelText: "Trier par", + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder(), + ), + items: _tris.entries + .map( + (entry) => DropdownMenuItem( + value: entry.key, + child: Text( + entry.value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ) + .toList(), + onChanged: (value) { + if (value != null && value != tri) { + onTri(value); + } + }, + ), + ), + Tooltip( + message: ordre == "asc" + ? "Passer en ordre décroissant" + : "Passer en ordre croissant", + child: IconButton.outlined( + onPressed: () => onTri(tri), + icon: Icon( + ordre == "asc" + ? Icons.arrow_upward_rounded + : Icons.arrow_downward_rounded, + ), + ), + ), + Tooltip( + message: "Réinitialiser les filtres", + child: IconButton.outlined( + onPressed: onReset, + icon: const Icon(Icons.filter_alt_off_outlined), + ), + ), + ], + ); + } + + Future _choisirPeriode(BuildContext context) async { + final maintenant = DateTime.now(); + final selection = await showDateRangePicker( + context: context, + firstDate: DateTime(2020), + lastDate: DateTime(maintenant.year + 5, 12, 31), + initialDateRange: periode, + helpText: "Filtrer l’historique", + cancelText: "Annuler", + confirmText: "Appliquer", + saveText: "Appliquer", + ); + if (selection != null) { + onPeriodeChanged(selection); + } + } + + String _periodeLabel() { + final selection = periode; + if (selection == null) return "Toutes les périodes"; + return "${_dateComplete(selection.start)} – ${_dateComplete(selection.end)}"; + } + + Widget _tableau(double largeurDisponible) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: EnsupColors.line), + ), + child: SingleChildScrollView( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: largeurDisponible), + child: DataTable( + sortColumnIndex: _sortColumnIndex, + sortAscending: ordre == "asc", + headingRowColor: WidgetStatePropertyAll( + EnsupColors.soft.withValues(alpha: 0.8), + ), + headingTextStyle: GoogleFonts.titilliumWeb( + fontSize: 12, + fontWeight: FontWeight.w700, + color: EnsupColors.text2, + ), + dataTextStyle: GoogleFonts.titilliumWeb( + fontSize: 12, + color: EnsupColors.text, + ), + columns: [ + DataColumn( + label: const Text("Date"), + onSort: (_, _) => onTri("dateAction"), + ), + DataColumn( + label: const Text("Action"), + onSort: (_, _) => onTri("action"), + ), + DataColumn( + label: const Text("Utilisateur"), + onSort: (_, _) => onTri("utilisateur"), + ), + DataColumn( + label: const Text("Matériel"), + onSort: (_, _) => onTri("materiel"), + ), + DataColumn( + label: const Text("Emprunt"), + onSort: (_, _) => onTri("emprunt"), + ), + const DataColumn(label: Text("Point d’opération")), + ], + rows: items.map(_ligneTableau).toList(), + ), + ), + ), + ), + ), + ); + } + + DataRow _ligneTableau(Map item) { + final utilisateur = Map.from(item["utilisateur"] as Map); + final materiel = item["materiel"] is Map + ? Map.from(item["materiel"] as Map) + : null; + return DataRow( + cells: [ + DataCell(Text(_dateHeureComplete(item["dateAction"]))), + DataCell( + Tooltip( + message: "${item["description"]}", + child: SizedBox( + width: 235, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _actionLabel("${item["action"]}"), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + Text( + "${item["description"]}", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: EnsupColors.muted), + ), + ], + ), + ), + ), + ), + DataCell( + SizedBox( + width: 170, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "${utilisateur["prenom"]} ${utilisateur["nom"]}", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + Text( + "${utilisateur["email"]}", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: EnsupColors.muted), + ), + ], + ), + ), + ), + DataCell( + SizedBox( + width: 165, + child: materiel == null + ? const Text("-") + : Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "${materiel["nom"]}", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + Text( + "${materiel["reference"]}", + style: const TextStyle(color: EnsupColors.muted), + ), + ], + ), + ), + ), + DataCell( + Text(item["empruntId"] == null ? "-" : "#${item["empruntId"]}"), + ), + DataCell( + SizedBox( + width: 150, + child: Text( + _pointOperation(item), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ); + } + + Widget _listeCompacte() { + return ListView.separated( + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final item = items[index]; + final utilisateur = Map.from( + item["utilisateur"] as Map, + ); + final materiel = item["materiel"] is Map + ? Map.from(item["materiel"] as Map) + : null; + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: EnsupColors.line), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _actionLabel("${item["action"]}"), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.darkerGrotesque( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + Text( + _dateHeureComplete(item["dateAction"]), + style: GoogleFonts.titilliumWeb( + fontSize: 11, + color: EnsupColors.muted, + ), + ), + const SizedBox(height: 6), + Text( + "${item["description"]}", + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.titilliumWeb( + fontSize: 12, + color: EnsupColors.text2, + ), + ), + const Divider(height: 20, color: EnsupColors.line), + _detailCompact( + Icons.person_outline, + "${utilisateur["prenom"]} ${utilisateur["nom"]}", + ), + _detailCompact( + Icons.inventory_2_outlined, + materiel == null + ? "Aucun matériel associé" + : "${materiel["nom"]} · ${materiel["reference"]}", + ), + _detailCompact( + Icons.assignment_outlined, + item["empruntId"] == null + ? "Aucun emprunt associé" + : "Emprunt #${item["empruntId"]}", + ), + _detailCompact(Icons.location_on_outlined, _pointOperation(item)), + ], + ), + ); + }, + ); + } + + String _pointOperation(Map item) { + final salle = item["sallePret"] is Map + ? Map.from(item["sallePret"] as Map) + : null; + final poste = item["posteEmprunt"] is Map + ? Map.from(item["posteEmprunt"] as Map) + : null; + if (salle == null && poste == null) return "-"; + return [ + if (salle != null) "${salle["nom"]}", + if (poste != null) "${poste["nom"]}", + ].join(" · "); + } + + Widget _detailCompact(IconData icon, String texte) { + return Padding( + padding: const EdgeInsets.only(bottom: 5), + child: Row( + children: [ + Icon(icon, size: 15, color: EnsupColors.muted), + const SizedBox(width: 7), + Expanded( + child: Text( + texte, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.titilliumWeb( + fontSize: 12, + color: EnsupColors.text2, + ), + ), + ), + ], + ), + ); + } +} + class _VueListe extends StatefulWidget { const _VueListe({ required this.titre, @@ -3488,12 +4816,14 @@ class _AnomalieAction extends StatelessWidget { required this.prochainStatut, required this.disabled, required this.onPressed, + this.afficherStatut = true, }); final String statut; final String? prochainStatut; final bool disabled; final VoidCallback onPressed; + final bool afficherStatut; @override Widget build(BuildContext context) { @@ -3504,7 +4834,7 @@ class _AnomalieAction extends StatelessWidget { crossAxisAlignment: WrapCrossAlignment.center, alignment: WrapAlignment.end, children: [ - _StatusBadge(statut), + if (afficherStatut) _StatusBadge(statut), if (prochain != null) FilledButton.icon( onPressed: disabled ? null : onPressed, diff --git a/eme-frontend/lib/services/responsable_service.dart b/eme-frontend/lib/services/responsable_service.dart index b049010..d3a97c8 100644 --- a/eme-frontend/lib/services/responsable_service.dart +++ b/eme-frontend/lib/services/responsable_service.dart @@ -50,19 +50,86 @@ class ResponsableService { return _liste("/responsable/materiels?$query"); } - static Future>> anomalies() => - _liste("/responsable/anomalies"); + static Future>> anomalies({ + String? statut, + String? type, + String? recherche, + String tri = "dateDetection", + String ordre = "desc", + }) { + final parametres = { + "tri": tri, + "ordre": ordre, + "statut": ?statut, + "type": ?type, + if (recherche != null && recherche.trim().isNotEmpty) + "q": recherche.trim(), + }; + final query = Uri(queryParameters: parametres).query; + return _liste("/responsable/anomalies?$query"); + } static Future>> notifications() => _liste("/responsable/notifications"); - static Future>> historique() => - _liste("/responsable/historique"); + static Future>> historique({ + String? action, + DateTime? dateDebut, + DateTime? dateFin, + String? recherche, + String tri = "dateAction", + String ordre = "desc", + }) { + return _liste( + "/responsable/historique?${_historiqueQuery(action: action, dateDebut: dateDebut, dateFin: dateFin, recherche: recherche, tri: tri, ordre: ordre)}", + ); + } - static Future exporterHistorique() => ApiClient.getFile( - "/responsable/historique/export.csv", - fallbackFileName: "historique-responsable.csv", - ); + static Future exporterHistorique({ + String? action, + DateTime? dateDebut, + DateTime? dateFin, + String? recherche, + String tri = "dateAction", + String ordre = "desc", + }) { + final query = _historiqueQuery( + action: action, + dateDebut: dateDebut, + dateFin: dateFin, + recherche: recherche, + tri: tri, + ordre: ordre, + ); + return ApiClient.getFile( + "/responsable/historique/export.csv?$query", + fallbackFileName: "historique-responsable.csv", + ); + } + + static String _historiqueQuery({ + String? action, + DateTime? dateDebut, + DateTime? dateFin, + String? recherche, + required String tri, + required String ordre, + }) { + final finIncluse = dateFin == null + ? null + : DateTime(dateFin.year, dateFin.month, dateFin.day, 23, 59, 59, 999); + return Uri( + queryParameters: { + "tri": tri, + "ordre": ordre, + "action": ?action, + if (dateDebut != null) "dateDebut": dateDebut.toUtc().toIso8601String(), + if (finIncluse != null) "dateFin": finIncluse.toUtc().toIso8601String(), + if (recherche != null && recherche.trim().isNotEmpty) + "q": recherche.trim(), + }, + ).query; + } static Future marquerNotificationsLues() async { final reponse = await ApiClient.patch( diff --git a/review.md b/review.md index 6984a80..c255c4d 100644 --- a/review.md +++ b/review.md @@ -501,6 +501,22 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y - Les menus de filtres restent sur une ligne et tronquent les libellés trop longs pour prévenir tout débordement. - Vérifications réussies : build et lint backend, analyse Dart, tris et filtres combinés testés sur SQL Server. +### Étape 52 — Tableau des anomalies responsable +- La liste des anomalies devient un tableau opérationnel sur ordinateur et une liste compacte sur petit écran. +- Les colonnes présentent la date de détection, le type et la description, le matériel, l'étudiant, le statut et l'action suivante. +- La recherche serveur couvre la description, le type, le matériel, la référence et l'identité de l'étudiant. +- Les anomalies peuvent être filtrées par statut et type, puis triées par date, type, matériel, étudiant ou statut. +- Le changement de statut existant reste disponible dans chaque ligne et conserve les transitions métier autorisées. +- Vérifications réussies : build et lint backend, analyse Dart, tris et filtres combinés testés sur SQL Server. + +### Étape 53 — Tableau et export filtré de l'historique responsable +- L'historique devient un tableau d'audit sur ordinateur et une liste compacte sur petit écran. +- Les colonnes présentent la date et l'heure, l'action et sa description, l'utilisateur, le matériel, l'emprunt et le point d'opération. +- La recherche serveur couvre l'action, la description, l'utilisateur et le matériel. +- Les événements peuvent être filtrés par action et période, puis triés par date, action, utilisateur, matériel ou emprunt. +- L'export CSV reprend désormais les filtres, la période et l'ordre actifs dans l'interface. +- Vérifications réussies : build et lint backend, analyse Dart, filtre combiné sur 22 événements et export CSV filtré testés sur SQL Server. + --- -*Dernière mise à jour : 2026-07-24 — Tableau de supervision du stock matériel enrichi.* +*Dernière mise à jour : 2026-07-24 — Supervision des anomalies et historique enrichie.*