feat(frontend): polish responsable workspace
This commit is contained in:
@@ -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,39 +420,84 @@ 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: [
|
||||||
style: GoogleFonts.darkerGrotesque(
|
Expanded(
|
||||||
fontSize: 26,
|
child: Text(
|
||||||
fontWeight: FontWeight.w900,
|
"${widget.titre} (${resultats.length})",
|
||||||
color: EnsupColors.text,
|
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),
|
const SizedBox(height: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.separated(
|
child: resultats.isEmpty
|
||||||
itemCount: items.length,
|
? const _EmptyState()
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
: ListView.separated(
|
||||||
itemBuilder: (_, index) => builder(items[index]),
|
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.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,
|
||||||
|
|||||||
@@ -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.*
|
||||||
|
|||||||
Reference in New Issue
Block a user