From 5121cac65774bf53bc41ca85737a0dbe83b56644 Mon Sep 17 00:00:00 2001 From: SaidSoighiri94 Date: Fri, 24 Jul 2026 15:00:16 +0200 Subject: [PATCH] feat(responsable): improve loan supervision table --- .../src/controllers/responsable.controller.ts | 15 +- .../repositories/responsable.repository.ts | 51 +- .../src/services/responsable.service.ts | 57 ++ .../lib/screens/responsable_screen.dart | 616 +++++++++++++++++- .../lib/services/responsable_service.dart | 20 +- review.md | 18 +- 6 files changed, 748 insertions(+), 29 deletions(-) diff --git a/eme-backend/src/controllers/responsable.controller.ts b/eme-backend/src/controllers/responsable.controller.ts index b155e25..7593ca4 100644 --- a/eme-backend/src/controllers/responsable.controller.ts +++ b/eme-backend/src/controllers/responsable.controller.ts @@ -13,15 +13,18 @@ import { marquerNotificationResponsableLue, marquerToutesNotificationsResponsableLues, parseAnomalieId, + parseAnneeEmprunt, parseEmpruntId, parseCategorieId, parseLu, parseNotificationId, + parseOrdreTri, parseDateQuery, parsePositiveIntQuery, parseStatutAnomalie, parseStatutMateriel, parseStatutEmprunt, + parseTriEmprunt, } from '../services/responsable.service'; import { toAnomalieResponsableResponse, @@ -77,7 +80,17 @@ export async function getEmprunts(req: Request, res: Response): Promise { } const statut = parseStatutEmprunt(req.query.statut); - const emprunts = await listerEmpruntsResponsable(user.roleCode, user.campusId, { statut }); + const annee = parseAnneeEmprunt(req.query.annee); + const recherche = typeof req.query.q === 'string' ? req.query.q.trim() : undefined; + const tri = parseTriEmprunt(req.query.tri); + const ordre = parseOrdreTri(req.query.ordre); + const emprunts = await listerEmpruntsResponsable(user.roleCode, user.campusId, { + statut, + annee, + recherche: recherche || undefined, + tri, + ordre, + }); res.json({ data: emprunts.map(toEmpruntResponsableResponse) }); } diff --git a/eme-backend/src/repositories/responsable.repository.ts b/eme-backend/src/repositories/responsable.repository.ts index e8dd3f6..765a021 100644 --- a/eme-backend/src/repositories/responsable.repository.ts +++ b/eme-backend/src/repositories/responsable.repository.ts @@ -73,6 +73,16 @@ export type HistoriqueResponsable = Prisma.HistoriqueGetPayload<{ export interface EmpruntResponsableFiltres { statut?: string; + annee?: number; + recherche?: string; + tri?: + | 'dateEmprunt' + | 'dateRetourPrevue' + | 'dateRetourReelle' + | 'materiel' + | 'etudiant' + | 'statut'; + ordre?: 'asc' | 'desc'; } export interface MaterielResponsableFiltres { @@ -184,16 +194,55 @@ export function findEmpruntsResponsable( campusId: number, filtres: EmpruntResponsableFiltres, ): Promise { + const ordre = filtres.ordre ?? 'desc'; + const periode = filtres.annee + ? { + gte: new Date(Date.UTC(filtres.annee, 0, 1)), + lt: new Date(Date.UTC(filtres.annee + 1, 0, 1)), + } + : undefined; + const orderBy: Prisma.EmpruntOrderByWithRelationInput[] = (() => { + switch (filtres.tri) { + case 'dateRetourPrevue': + return [{ dateRetourPrevue: ordre }, { id: 'desc' }]; + case 'dateRetourReelle': + return [{ dateRetourReelle: ordre }, { id: 'desc' }]; + case 'materiel': + return [{ materiel: { nom: ordre } }, { id: 'desc' }]; + case 'etudiant': + return [{ utilisateur: { nom: ordre } }, { id: 'desc' }]; + case 'statut': + return [{ statut: ordre }, { id: 'desc' }]; + default: + return [{ dateEmprunt: ordre }, { id: 'desc' }]; + } + })(); + return prisma.emprunt.findMany({ where: { campusId, ...(filtres.statut ? { statut: filtres.statut } : {}), + ...(periode ? { dateEmprunt: periode } : {}), + ...(filtres.recherche + ? { + OR: [ + { materiel: { nom: { contains: filtres.recherche } } }, + { materiel: { reference: { contains: filtres.recherche } } }, + { materiel: { marque: { contains: filtres.recherche } } }, + { materiel: { modele: { contains: filtres.recherche } } }, + { utilisateur: { nom: { contains: filtres.recherche } } }, + { utilisateur: { prenom: { contains: filtres.recherche } } }, + { utilisateur: { email: { contains: filtres.recherche } } }, + { utilisateur: { classe: { contains: filtres.recherche } } }, + ], + } + : {}), }, include: { utilisateur: true, materiel: { include: { categorie: true } }, }, - orderBy: { dateEmprunt: 'desc' }, + orderBy, }); } diff --git a/eme-backend/src/services/responsable.service.ts b/eme-backend/src/services/responsable.service.ts index 84fe680..3a06d24 100644 --- a/eme-backend/src/services/responsable.service.ts +++ b/eme-backend/src/services/responsable.service.ts @@ -45,8 +45,22 @@ export interface DashboardResponsableData { export interface ListerEmpruntsResponsableFiltres { statut?: StatutEmprunt; + annee?: number; + recherche?: string; + tri?: TriEmpruntResponsable; + ordre?: OrdreTri; } +export type TriEmpruntResponsable = + | 'dateEmprunt' + | 'dateRetourPrevue' + | 'dateRetourReelle' + | 'materiel' + | 'etudiant' + | 'statut'; + +export type OrdreTri = 'asc' | 'desc'; + export interface ListerMaterielsResponsableFiltres { statut?: StatutMateriel; categorieId?: number; @@ -139,6 +153,49 @@ export function parseStatutEmprunt(value: unknown): StatutEmprunt | undefined { return value as StatutEmprunt; } +export function parseAnneeEmprunt(value: unknown): number | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== 'string') { + throw new AppError(400, 'annee invalide'); + } + + const annee = Number(value); + if (!Number.isInteger(annee) || annee < 2000 || annee > 2100) { + throw new AppError(400, 'annee invalide'); + } + return annee; +} + +export function parseTriEmprunt(value: unknown): TriEmpruntResponsable | undefined { + if (value === undefined) { + return undefined; + } + const valeurs: readonly TriEmpruntResponsable[] = [ + 'dateEmprunt', + 'dateRetourPrevue', + 'dateRetourReelle', + 'materiel', + 'etudiant', + 'statut', + ]; + if (typeof value !== 'string' || !valeurs.includes(value as TriEmpruntResponsable)) { + throw new AppError(400, 'tri invalide'); + } + return value as TriEmpruntResponsable; +} + +export function parseOrdreTri(value: unknown): OrdreTri | undefined { + if (value === undefined) { + return undefined; + } + if (value !== 'asc' && value !== 'desc') { + throw new AppError(400, 'ordre invalide'); + } + return value; +} + export function parseStatutMateriel(value: unknown): StatutMateriel | 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 546db40..93057fb 100644 --- a/eme-frontend/lib/screens/responsable_screen.dart +++ b/eme-frontend/lib/screens/responsable_screen.dart @@ -18,8 +18,14 @@ class ResponsableScreen extends StatefulWidget { } class _ResponsableScreenState extends State { + final TextEditingController _rechercheEmpruntsController = + TextEditingController(); late Future<_ResponsableData> _future = _charger(); int _onglet = 0; + String? _statutEmprunts; + int? _anneeEmprunts; + String _triEmprunts = "dateEmprunt"; + String _ordreEmprunts = "desc"; bool _transitionEnCours = false; bool _decisionEnCours = false; bool _exportEnCours = false; @@ -27,7 +33,13 @@ class _ResponsableScreenState extends State { Future<_ResponsableData> _charger() async { final dashboard = await ResponsableService.dashboard(); final ecarts = await ResponsableService.ecarts(); - final emprunts = await ResponsableService.emprunts(); + final emprunts = await ResponsableService.emprunts( + statut: _statutEmprunts, + annee: _anneeEmprunts, + recherche: _rechercheEmpruntsController.text, + tri: _triEmprunts, + ordre: _ordreEmprunts, + ); final materiels = await ResponsableService.materiels(); final anomalies = await ResponsableService.anomalies(); final notifications = await ResponsableService.notifications(); @@ -48,6 +60,43 @@ class _ResponsableScreenState extends State { setState(() => _future = _charger()); } + void _trierEmprunts(String tri) { + setState(() { + if (_triEmprunts == tri) { + _ordreEmprunts = _ordreEmprunts == "asc" ? "desc" : "asc"; + } else { + _triEmprunts = tri; + _ordreEmprunts = tri.startsWith("date") ? "desc" : "asc"; + } + _future = _charger(); + }); + } + + void _filtrerEmprunts({String? statut, int? annee}) { + setState(() { + _statutEmprunts = statut; + _anneeEmprunts = annee; + _future = _charger(); + }); + } + + void _reinitialiserFiltresEmprunts() { + _rechercheEmpruntsController.clear(); + setState(() { + _statutEmprunts = null; + _anneeEmprunts = null; + _triEmprunts = "dateEmprunt"; + _ordreEmprunts = "desc"; + _future = _charger(); + }); + } + + @override + void dispose() { + _rechercheEmpruntsController.dispose(); + super.dispose(); + } + Future _quitter() async { try { await AuthService.signOut(); @@ -232,18 +281,22 @@ class _ResponsableScreenState extends State { 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, + _VueEmpruntsTableau( items: data.emprunts, - builder: _empruntTile, - searchableText: (item) { - final etudiant = Map.from(item["etudiant"] as Map); - final materiel = Map.from(item["materiel"] as Map); - return "${materiel["nom"]} ${materiel["reference"]} ${etudiant["prenom"]} ${etudiant["nom"]} ${item["statut"]}"; + rechercheController: _rechercheEmpruntsController, + statut: _statutEmprunts, + annee: _anneeEmprunts, + tri: _triEmprunts, + ordre: _ordreEmprunts, + onRecherche: _rafraichir, + onStatutChanged: (statut) { + _filtrerEmprunts(statut: statut, annee: _anneeEmprunts); }, + onAnneeChanged: (annee) { + _filtrerEmprunts(statut: _statutEmprunts, annee: annee); + }, + onTri: _trierEmprunts, + onReset: _reinitialiserFiltresEmprunts, ), _VueListe( titre: "Stock matériel", @@ -337,19 +390,6 @@ class _ResponsableScreenState extends State { ); } - Widget _empruntTile(Map item) { - final etudiant = Map.from(item["etudiant"] as Map); - final materiel = Map.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 item) { final etudiant = Map.from(item["etudiant"] as Map); final materiel = Map.from(item["materiel"] as Map); @@ -905,6 +945,15 @@ String _dateHeure(Object? value) { return "$jour/$mois · $heure:$minute"; } +String _dateComplete(Object? value) { + if (value == null) return "-"; + 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"); + return "$jour/$mois/${date.year}"; +} + class _ResponsableTopBar extends StatelessWidget { const _ResponsableTopBar({required this.onExit, required this.onRefresh}); @@ -2044,6 +2093,525 @@ class _PanelEmptyState extends StatelessWidget { } } +class _VueEmpruntsTableau extends StatelessWidget { + const _VueEmpruntsTableau({ + required this.items, + required this.rechercheController, + required this.statut, + required this.annee, + required this.tri, + required this.ordre, + required this.onRecherche, + required this.onStatutChanged, + required this.onAnneeChanged, + required this.onTri, + required this.onReset, + }); + + final List> items; + final TextEditingController rechercheController; + final String? statut; + final int? annee; + final String tri; + final String ordre; + final VoidCallback onRecherche; + final ValueChanged onStatutChanged; + final ValueChanged onAnneeChanged; + final ValueChanged onTri; + final VoidCallback onReset; + + static const _statuts = [ + "EN_ATTENTE_VALIDATION_DEPART", + "EN_COURS", + "EN_RETARD", + "EN_ATTENTE_VALIDATION_RETOUR", + "CLOTURE", + "RETOUR_NON_CONFORME", + "ANNULE", + ]; + + static const _tris = { + "dateEmprunt": "Date d’emprunt", + "dateRetourPrevue": "Retour prévu", + "dateRetourReelle": "Retour réel", + "materiel": "Matériel", + "etudiant": "Étudiant", + "statut": "Statut", + }; + + int? get _sortColumnIndex { + return switch (tri) { + "materiel" => 1, + "etudiant" => 2, + "dateEmprunt" => 3, + "dateRetourPrevue" => 4, + "dateRetourReelle" => 5, + "statut" => 6, + _ => 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} emprunt${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 < 820) { + return _listeCompacte(); + } + return _tableau(constraints.maxWidth); + }, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _entete() { + return Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: EnsupColors.cyan.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.assignment_outlined, + color: EnsupColors.blue3, + size: 21, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Emprunts", + style: GoogleFonts.darkerGrotesque( + fontSize: 29, + height: 1, + fontWeight: FontWeight.w900, + color: EnsupColors.text, + ), + ), + Text( + "Comparez les prêts, échéances et statuts du campus.", + style: GoogleFonts.titilliumWeb( + fontSize: 13, + color: EnsupColors.muted, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _filtres() { + final anneeCourante = DateTime.now().year; + final annees = List.generate(8, (index) => anneeCourante - index); + + 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: "Matériel, référence 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: 220, + child: DropdownButtonFormField( + key: ValueKey("statut-$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: 150, + child: DropdownButtonFormField( + key: ValueKey("annee-$annee"), + initialValue: annee ?? 0, + decoration: const InputDecoration( + labelText: "Année", + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder(), + ), + items: [ + const DropdownMenuItem(value: 0, child: Text("Toutes")), + ...annees.map( + (value) => + DropdownMenuItem(value: value, child: Text("$value")), + ), + ], + onChanged: (value) { + onAnneeChanged(value == null || value == 0 ? null : value); + }, + ), + ), + SizedBox( + width: 180, + child: DropdownButtonFormField( + key: ValueKey("tri-$tri"), + initialValue: tri, + 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), + ), + ) + .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: [ + const DataColumn(label: Text("N°")), + DataColumn( + label: const Text("Matériel"), + onSort: (_, _) => onTri("materiel"), + ), + DataColumn( + label: const Text("Étudiant"), + onSort: (_, _) => onTri("etudiant"), + ), + DataColumn( + label: const Text("Date d’emprunt"), + onSort: (_, _) => onTri("dateEmprunt"), + ), + DataColumn( + label: const Text("Retour prévu"), + onSort: (_, _) => onTri("dateRetourPrevue"), + ), + DataColumn( + label: const Text("Retour réel"), + onSort: (_, _) => onTri("dateRetourReelle"), + ), + DataColumn( + label: const Text("Statut"), + onSort: (_, _) => onTri("statut"), + ), + ], + 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); + return DataRow( + cells: [ + DataCell( + Text( + "#${item["id"]}", + style: const TextStyle(fontWeight: FontWeight.w700), + ), + ), + DataCell( + SizedBox( + width: 190, + 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: 170, + 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(Text(_dateComplete(item["dateEmprunt"]))), + DataCell(Text(_dateComplete(item["dateRetourPrevue"]))), + DataCell(Text(_dateComplete(item["dateRetourReelle"]))), + DataCell(_StatusBadge("${item["statut"]}")), + ], + ); + } + + 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); + 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( + "${materiel["nom"]}", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.darkerGrotesque( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(width: 8), + _StatusBadge("${item["statut"]}"), + ], + ), + Text( + "#${item["id"]} · ${materiel["reference"]}", + style: GoogleFonts.titilliumWeb( + fontSize: 12, + color: EnsupColors.muted, + ), + ), + const Divider(height: 20, color: EnsupColors.line), + _detailCompact( + Icons.person_outline, + "${etudiant["prenom"]} ${etudiant["nom"]}", + ), + _detailCompact( + Icons.calendar_today_outlined, + "Emprunt ${_dateComplete(item["dateEmprunt"])} · prévu ${_dateComplete(item["dateRetourPrevue"])}", + ), + _detailCompact( + Icons.assignment_turned_in_outlined, + "Retour réel ${_dateComplete(item["dateRetourReelle"])}", + ), + ], + ), + ); + }, + ); + } + + 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, + style: GoogleFonts.titilliumWeb( + fontSize: 12, + color: EnsupColors.text2, + ), + ), + ), + ], + ), + ); + } +} + class _VueListe extends StatefulWidget { const _VueListe({ required this.titre, diff --git a/eme-frontend/lib/services/responsable_service.dart b/eme-frontend/lib/services/responsable_service.dart index cb8f603..3440c33 100644 --- a/eme-frontend/lib/services/responsable_service.dart +++ b/eme-frontend/lib/services/responsable_service.dart @@ -9,8 +9,24 @@ class ResponsableService { return Map.from(reponse["data"] as Map); } - static Future>> emprunts() => - _liste("/responsable/emprunts"); + static Future>> emprunts({ + String? statut, + int? annee, + String? recherche, + String tri = "dateEmprunt", + String ordre = "desc", + }) { + final parametres = { + "tri": tri, + "ordre": ordre, + "statut": ?statut, + if (annee != null) "annee": "$annee", + if (recherche != null && recherche.trim().isNotEmpty) + "q": recherche.trim(), + }; + final query = Uri(queryParameters: parametres).query; + return _liste("/responsable/emprunts?$query"); + } static Future>> ecarts() => _liste("/responsable/ecarts"); diff --git a/review.md b/review.md index 0732d2a..95ab479 100644 --- a/review.md +++ b/review.md @@ -476,6 +476,22 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y - `CLAUDE.md`, `CONTEXT.md`, les règles métier, `TODO.md`, le README et la maquette textuelle sont synchronisés avant toute modification du code. - Un diagramme Mermaid devient la source de vérité du parcours ; le PNG contradictoire est conservé uniquement comme historique. +### Étape 49 — Messages applicatifs visibles +- Les `SnackBar` Flutter noir et blanc sont remplacés par un composant global affiché en haut de l'écran. +- Quatre niveaux visuels sont distingués : information, succès, alerte et erreur. +- Chaque message dispose d'une icône, d'un titre, d'un texte explicite, d'une fermeture manuelle et d'une disparition automatique. +- Le composant est utilisé dans les parcours étudiant, la connexion et l'espace responsable. +- Vérification réussie avec `dart analyze`. +- Commit : `4ee593c`. + +### Étape 50 — Tableau des emprunts responsable +- La liste des emprunts responsable devient un tableau opérationnel sur ordinateur avec une colonne par information : numéro, matériel, étudiant, dates et statut. +- Les en-têtes permettent de trier par matériel, étudiant, date d'emprunt, retour prévu, retour réel et statut. +- Des filtres serveur sont ajoutés pour l'année, le statut et la recherche par matériel, référence ou étudiant. +- Le tableau devient une liste compacte sur petit écran afin d'éviter tout débordement horizontal. +- Les requêtes restent filtrées par campus côté API. +- Vérifications réussies : build et lint backend, analyse Dart, tris et recherches testés sur SQL Server. + --- -*Dernière mise à jour : 2026-07-24 — Confirmation responsable uniquement en cas d'écart déclaré.* +*Dernière mise à jour : 2026-07-24 — Tableau de supervision des emprunts enrichi.*