feat(frontend): polish responsable workspace

This commit is contained in:
SaidSoighiri94
2026-07-17 13:58:30 +02:00
parent 4b635b8654
commit 545d9f14ac
3 changed files with 238 additions and 29 deletions
+212 -22
View File
@@ -66,6 +66,18 @@ class _ResponsableScreenState extends State<ResponsableScreen> {
} }
} }
Future<void> _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) { String? _prochainStatut(String? statut) {
return switch (statut) { return switch (statut) {
"DETECTEE" => "EN_COURS_TRAITEMENT", "DETECTEE" => "EN_COURS_TRAITEMENT",
@@ -141,22 +153,63 @@ class _ResponsableScreenState extends State<ResponsableScreen> {
Widget _contenu(_ResponsableData data) { Widget _contenu(_ResponsableData data) {
final vues = [ final vues = [
_VueDashboard(data: data), _VueDashboard(data: data),
_VueListe(titre: "Emprunts", items: data.emprunts, builder: _empruntTile), _VueListe(
_VueListe(titre: "Stock", items: data.materiels, builder: _materielTile), titre: "Emprunts",
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",
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( _VueListe(
titre: "Anomalies", titre: "Anomalies",
items: data.anomalies, items: data.anomalies,
builder: _anomalieTile, builder: _anomalieTile,
searchableText: (item) {
final materiel = Map<String, dynamic>.from(item["materiel"] as Map);
return "${item["type"]} ${item["description"]} ${item["statut"]} ${materiel["nom"]}";
},
), ),
_VueListe( _VueListe(
titre: "Notifications", titre: "Notifications",
items: data.notifications, items: data.notifications,
builder: _notificationTile, 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( _VueListe(
titre: "Historique", titre: "Historique",
items: data.historique, items: data.historique,
builder: _historiqueTile, 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<ResponsableScreen> {
title: "${materiel["nom"]}", title: "${materiel["nom"]}",
subtitle: subtitle:
"${etudiant["prenom"]} ${etudiant["nom"]} · retour prévu ${_date(item["dateRetourPrevue"])}", "${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<ResponsableScreen> {
return _InfoTile( return _InfoTile(
icon: Icons.inventory_2_outlined, icon: Icons.inventory_2_outlined,
title: "${item["nom"]}", title: "${item["nom"]}",
subtitle: "${item["reference"]} · ${categorie["nom"]}", subtitle:
trailing: _Badge("${item["statut"]}"), "${item["reference"]} · ${categorie["nom"]} · ${item["etatGeneral"]}",
meta: "${item["marque"]} ${item["modele"]}",
trailing: _StatusBadge("${item["statut"]}"),
); );
} }
@@ -208,11 +264,12 @@ class _ResponsableScreenState extends State<ResponsableScreen> {
icon: Icons.report_problem_outlined, icon: Icons.report_problem_outlined,
title: "${item["type"]} · ${materiel["nom"]}", title: "${item["type"]} · ${materiel["nom"]}",
subtitle: "${item["description"]}", subtitle: "${item["description"]}",
meta: "Détectée le ${_date(item["dateDetection"])}",
trailing: prochain == null trailing: prochain == null
? _Badge("${item["statut"]}") ? _StatusBadge("${item["statut"]}")
: FilledButton( : FilledButton(
onPressed: _transitionEnCours ? null : () => _changerStatut(item), onPressed: _transitionEnCours ? null : () => _changerStatut(item),
child: Text(prochain), child: Text(_statusLabel(prochain)),
), ),
); );
} }
@@ -224,7 +281,8 @@ class _ResponsableScreenState extends State<ResponsableScreen> {
: Icons.notifications_active_outlined, : Icons.notifications_active_outlined,
title: "${item["titre"]}", title: "${item["titre"]}",
subtitle: "${item["message"]}", 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<ResponsableScreen> {
final utilisateur = Map<String, dynamic>.from(item["utilisateur"] as Map); final utilisateur = Map<String, dynamic>.from(item["utilisateur"] as Map);
return _InfoTile( return _InfoTile(
icon: Icons.history, icon: Icons.history,
title: "${item["action"]}", title: _actionLabel("${item["action"]}"),
subtitle: subtitle: "${item["description"]}",
meta:
"${_date(item["dateAction"])} · ${utilisateur["prenom"]} ${utilisateur["nom"]}", "${_date(item["dateAction"])} · ${utilisateur["prenom"]} ${utilisateur["nom"]}",
trailing: const Icon(Icons.chevron_right, color: EnsupColors.muted), trailing: const Icon(Icons.chevron_right, color: EnsupColors.muted),
); );
@@ -248,6 +307,56 @@ class _ResponsableScreenState extends State<ResponsableScreen> {
} }
} }
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 { class _ResponsableData {
const _ResponsableData({ const _ResponsableData({
required this.dashboard, required this.dashboard,
@@ -311,38 +420,83 @@ class _VueDashboard extends StatelessWidget {
} }
} }
class _VueListe extends StatelessWidget { class _VueListe extends StatefulWidget {
const _VueListe({ const _VueListe({
required this.titre, required this.titre,
required this.items, required this.items,
required this.builder, required this.builder,
required this.searchableText,
this.action,
}); });
final String titre; final String titre;
final List<Map<String, dynamic>> items; final List<Map<String, dynamic>> items;
final Widget Function(Map<String, dynamic>) builder; 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 @override
Widget build(BuildContext context) { 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( return Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
"$titre (${items.length})", children: [
Expanded(
child: Text(
"${widget.titre} (${resultats.length})",
style: GoogleFonts.darkerGrotesque( style: GoogleFonts.darkerGrotesque(
fontSize: 26, fontSize: 26,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
color: EnsupColors.text, 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), const SizedBox(height: 14),
Expanded( Expanded(
child: ListView.separated( child: resultats.isEmpty
itemCount: items.length, ? const _EmptyState()
: ListView.separated(
itemCount: resultats.length,
separatorBuilder: (_, _) => const SizedBox(height: 10), separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (_, index) => builder(items[index]), itemBuilder: (_, index) => widget.builder(resultats[index]),
), ),
), ),
], ],
@@ -446,12 +600,14 @@ class _InfoTile extends StatelessWidget {
required this.title, required this.title,
required this.subtitle, required this.subtitle,
required this.trailing, required this.trailing,
this.meta,
}); });
final IconData icon; final IconData icon;
final String title; final String title;
final String subtitle; final String subtitle;
final Widget trailing; final Widget trailing;
final String? meta;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -472,12 +628,26 @@ class _InfoTile extends StatelessWidget {
children: [ children: [
Text( Text(
title, title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.darkerGrotesque( style: GoogleFonts.darkerGrotesque(
fontSize: 19, fontSize: 19,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
), ),
), ),
Text(subtitle, maxLines: 2, overflow: TextOverflow.ellipsis), 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 { class _StatusBadge extends StatelessWidget {
const _Badge(this.label); const _StatusBadge(this.statut);
final String label; final String statut;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final color = _statusColor(statut);
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: EnsupColors.blue3.withValues(alpha: 0.08), color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.18)),
), ),
child: Text( child: Text(
label, _statusLabel(statut),
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700), 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),
), ),
); );
} }
@@ -29,6 +29,16 @@ class ResponsableService {
static Future<List<Map<String, dynamic>>> historique() => static Future<List<Map<String, dynamic>>> historique() =>
_liste("/responsable/historique"); _liste("/responsable/historique");
static Future<int> marquerNotificationsLues() async {
final reponse = await ApiClient.patch(
"/responsable/notifications/lu-toutes",
{},
utilisateurEmail: _email,
);
final data = Map<String, dynamic>.from(reponse["data"] as Map);
return data["count"] as int;
}
static Future<Map<String, dynamic>> changerStatutAnomalie( static Future<Map<String, dynamic>> changerStatutAnomalie(
int id, int id,
String statut, String statut,
+10 -1
View File
@@ -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. - 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é. - 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.*