From 545d9f14ac0ba14e732495348d95d212ccc929e5 Mon Sep 17 00:00:00 2001 From: SaidSoighiri94 Date: Fri, 17 Jul 2026 13:58:30 +0200 Subject: [PATCH] feat(frontend): polish responsable workspace --- .../lib/screens/responsable_screen.dart | 246 ++++++++++++++++-- .../lib/services/responsable_service.dart | 10 + review.md | 11 +- 3 files changed, 238 insertions(+), 29 deletions(-) diff --git a/eme-frontend/lib/screens/responsable_screen.dart b/eme-frontend/lib/screens/responsable_screen.dart index 42708a2..41ced1d 100644 --- a/eme-frontend/lib/screens/responsable_screen.dart +++ b/eme-frontend/lib/screens/responsable_screen.dart @@ -66,6 +66,18 @@ class _ResponsableScreenState extends State { } } + Future _marquerNotificationsLues() async { + try { + await ResponsableService.marquerNotificationsLues(); + _rafraichir(); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error.toString()))); + } + } + String? _prochainStatut(String? statut) { return switch (statut) { "DETECTEE" => "EN_COURS_TRAITEMENT", @@ -141,22 +153,63 @@ class _ResponsableScreenState extends State { Widget _contenu(_ResponsableData data) { final vues = [ _VueDashboard(data: data), - _VueListe(titre: "Emprunts", items: data.emprunts, builder: _empruntTile), - _VueListe(titre: "Stock", items: data.materiels, builder: _materielTile), + _VueListe( + titre: "Emprunts", + 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"]}"; + }, + ), + _VueListe( + titre: "Stock", + items: data.materiels, + builder: _materielTile, + searchableText: (item) { + final categorie = Map.from(item["categorie"] as Map); + return "${item["nom"]} ${item["reference"]} ${item["statut"]} ${categorie["nom"]}"; + }, + ), _VueListe( titre: "Anomalies", items: data.anomalies, builder: _anomalieTile, + searchableText: (item) { + final materiel = Map.from(item["materiel"] as Map); + return "${item["type"]} ${item["description"]} ${item["statut"]} ${materiel["nom"]}"; + }, ), _VueListe( titre: "Notifications", 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", items: data.historique, builder: _historiqueTile, + action: OutlinedButton.icon( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + "Export disponible via /api/responsable/historique/export.csv", + ), + ), + ); + }, + icon: const Icon(Icons.download_outlined, size: 16), + label: const Text("Export CSV"), + ), + searchableText: (item) => "${item["action"]} ${item["description"]}", ), ]; @@ -187,7 +240,8 @@ class _ResponsableScreenState extends State { title: "${materiel["nom"]}", subtitle: "${etudiant["prenom"]} ${etudiant["nom"]} · retour prévu ${_date(item["dateRetourPrevue"])}", - trailing: _Badge("${item["statut"]}"), + meta: "Emprunt #${item["id"]}", + trailing: _StatusBadge("${item["statut"]}"), ); } @@ -196,8 +250,10 @@ class _ResponsableScreenState extends State { return _InfoTile( icon: Icons.inventory_2_outlined, title: "${item["nom"]}", - subtitle: "${item["reference"]} · ${categorie["nom"]}", - trailing: _Badge("${item["statut"]}"), + subtitle: + "${item["reference"]} · ${categorie["nom"]} · ${item["etatGeneral"]}", + meta: "${item["marque"]} ${item["modele"]}", + trailing: _StatusBadge("${item["statut"]}"), ); } @@ -208,11 +264,12 @@ class _ResponsableScreenState extends State { icon: Icons.report_problem_outlined, title: "${item["type"]} · ${materiel["nom"]}", subtitle: "${item["description"]}", + meta: "Détectée le ${_date(item["dateDetection"])}", trailing: prochain == null - ? _Badge("${item["statut"]}") + ? _StatusBadge("${item["statut"]}") : FilledButton( onPressed: _transitionEnCours ? null : () => _changerStatut(item), - child: Text(prochain), + child: Text(_statusLabel(prochain)), ), ); } @@ -224,7 +281,8 @@ class _ResponsableScreenState extends State { : Icons.notifications_active_outlined, title: "${item["titre"]}", subtitle: "${item["message"]}", - trailing: _Badge(item["lu"] == true ? "LUE" : "NON LUE"), + meta: _date(item["dateCreation"]), + trailing: _StatusBadge(item["lu"] == true ? "LUE" : "NON_LUE"), ); } @@ -232,8 +290,9 @@ class _ResponsableScreenState extends State { final utilisateur = Map.from(item["utilisateur"] as Map); return _InfoTile( icon: Icons.history, - title: "${item["action"]}", - subtitle: + title: _actionLabel("${item["action"]}"), + subtitle: "${item["description"]}", + meta: "${_date(item["dateAction"])} · ${utilisateur["prenom"]} ${utilisateur["nom"]}", trailing: const Icon(Icons.chevron_right, color: EnsupColors.muted), ); @@ -248,6 +307,56 @@ class _ResponsableScreenState extends State { } } +String _statusLabel(String statut) { + return switch (statut) { + "DISPONIBLE" => "Disponible", + "EMPRUNTE" => "Emprunté", + "NON_CONFORME" => "Non conforme", + "DETERIORE" => "Détérioré", + "MAINTENANCE" => "Maintenance", + "INDISPONIBLE" => "Indisponible", + "EN_COURS" => "En cours", + "EN_RETARD" => "En retard", + "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", + _ => 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", + _ => 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, + "MAINTENANCE" || "INDISPONIBLE" || "DETERIORE" => EnsupColors.purple, + _ => EnsupColors.blue3, + }; +} + class _ResponsableData { const _ResponsableData({ required this.dashboard, @@ -311,39 +420,84 @@ class _VueDashboard extends StatelessWidget { } } -class _VueListe extends StatelessWidget { +class _VueListe extends StatefulWidget { const _VueListe({ required this.titre, required this.items, required this.builder, + required this.searchableText, + this.action, }); final String titre; final List> items; final Widget Function(Map) builder; + final String Function(Map) 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 Padding( padding: const EdgeInsets.all(24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - "$titre (${items.length})", - style: GoogleFonts.darkerGrotesque( - fontSize: 26, - fontWeight: FontWeight.w900, - color: EnsupColors.text, + Row( + children: [ + Expanded( + child: Text( + "${widget.titre} (${resultats.length})", + style: GoogleFonts.darkerGrotesque( + fontSize: 26, + fontWeight: FontWeight.w900, + color: EnsupColors.text, + ), + ), + ), + if (widget.action != null) widget.action!, + ], + ), + const SizedBox(height: 14), + TextField( + onChanged: (value) => setState(() => _recherche = value), + decoration: InputDecoration( + hintText: "Rechercher", + prefixIcon: const Icon(Icons.search), + isDense: true, + filled: true, + fillColor: EnsupColors.soft, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: EnsupColors.line), + ), ), ), const SizedBox(height: 14), Expanded( - child: ListView.separated( - itemCount: items.length, - separatorBuilder: (_, _) => const SizedBox(height: 10), - itemBuilder: (_, index) => builder(items[index]), - ), + child: resultats.isEmpty + ? const _EmptyState() + : ListView.separated( + itemCount: resultats.length, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (_, index) => widget.builder(resultats[index]), + ), ), ], ), @@ -446,12 +600,14 @@ class _InfoTile extends StatelessWidget { 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) { @@ -472,12 +628,26 @@ class _InfoTile extends StatelessWidget { children: [ Text( title, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: GoogleFonts.darkerGrotesque( fontSize: 19, fontWeight: FontWeight.w800, ), ), Text(subtitle, maxLines: 2, overflow: TextOverflow.ellipsis), + if (meta != null) ...[ + const SizedBox(height: 4), + Text( + meta!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.titilliumWeb( + fontSize: 12, + color: EnsupColors.muted, + ), + ), + ], ], ), ), @@ -489,22 +659,42 @@ class _InfoTile extends StatelessWidget { } } -class _Badge extends StatelessWidget { - const _Badge(this.label); +class _StatusBadge extends StatelessWidget { + const _StatusBadge(this.statut); - final String label; + 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: EnsupColors.blue3.withValues(alpha: 0.08), + color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withValues(alpha: 0.18)), ), child: Text( - label, - style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700), + _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), ), ); } diff --git a/eme-frontend/lib/services/responsable_service.dart b/eme-frontend/lib/services/responsable_service.dart index 02ea17e..5bec702 100644 --- a/eme-frontend/lib/services/responsable_service.dart +++ b/eme-frontend/lib/services/responsable_service.dart @@ -29,6 +29,16 @@ class ResponsableService { static Future>> historique() => _liste("/responsable/historique"); + static Future marquerNotificationsLues() async { + final reponse = await ApiClient.patch( + "/responsable/notifications/lu-toutes", + {}, + utilisateurEmail: _email, + ); + final data = Map.from(reponse["data"] as Map); + return data["count"] as int; + } + static Future> changerStatutAnomalie( int id, String statut, diff --git a/review.md b/review.md index 3600ade..f73064d 100644 --- a/review.md +++ b/review.md @@ -390,6 +390,15 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y - Les anomalies peuvent être avancées au prochain statut autorisé via l'API. - Vérification statique effectuée avec l'exécutable Dart direct : `dart analyze` OK. Le wrapper `flutter` reste instable dans cette session et bloque au lancement web automatisé. +### Étape 42 — Frontend responsable : polish opérationnel +- Branche dédiée `feat/responsable-frontend-polish` créée après merge du premier branchement responsable. +- Statuts responsables remplacés par des libellés métier lisibles et des badges colorés. +- Recherche ajoutée sur les onglets emprunts, stock, anomalies, notifications et historique. +- Les lignes de listes affichent désormais une méta-information utile : identifiant emprunt, marque/modèle, date de détection, date de notification ou auteur historique. +- Action "Tout marquer lu" ajoutée dans l'onglet notifications. +- Action export CSV rendue visible dans l'onglet historique avec indication de l'endpoint disponible. +- Vérification statique : `dart analyze` OK. + --- -*Dernière mise à jour : 2026-07-17 — Premier écran frontend responsable branché aux APIs validées.* +*Dernière mise à jour : 2026-07-17 — Frontend responsable branché et polishé pour une première utilisation métier.*