3584 lines
113 KiB
Dart
3584 lines
113 KiB
Dart
import "package:flutter/material.dart";
|
||
import "package:google_fonts/google_fonts.dart";
|
||
|
||
import "../services/auth_service.dart";
|
||
import "../services/responsable_service.dart";
|
||
import "../theme/ensup_colors.dart";
|
||
import "../utils/file_download.dart";
|
||
import "../widgets/app_message.dart";
|
||
|
||
const _workspaceBackground = Color(0xFFF3F6F9);
|
||
const _orange = Color(0xFFD97706);
|
||
|
||
class ResponsableScreen extends StatefulWidget {
|
||
const ResponsableScreen({super.key});
|
||
|
||
@override
|
||
State<ResponsableScreen> createState() => _ResponsableScreenState();
|
||
}
|
||
|
||
class _ResponsableScreenState extends State<ResponsableScreen> {
|
||
final TextEditingController _rechercheEmpruntsController =
|
||
TextEditingController();
|
||
final TextEditingController _rechercheMaterielsController =
|
||
TextEditingController();
|
||
final Map<int, String> _categoriesMateriels = {};
|
||
late Future<_ResponsableData> _future = _charger();
|
||
int _onglet = 0;
|
||
String? _statutEmprunts;
|
||
int? _anneeEmprunts;
|
||
String _triEmprunts = "dateEmprunt";
|
||
String _ordreEmprunts = "desc";
|
||
String? _statutMateriels;
|
||
int? _categorieMateriels;
|
||
String _triMateriels = "nom";
|
||
String _ordreMateriels = "asc";
|
||
bool _transitionEnCours = false;
|
||
bool _decisionEnCours = false;
|
||
bool _exportEnCours = false;
|
||
|
||
Future<_ResponsableData> _charger() async {
|
||
final dashboard = await ResponsableService.dashboard();
|
||
final ecarts = await ResponsableService.ecarts();
|
||
final emprunts = await ResponsableService.emprunts(
|
||
statut: _statutEmprunts,
|
||
annee: _anneeEmprunts,
|
||
recherche: _rechercheEmpruntsController.text,
|
||
tri: _triEmprunts,
|
||
ordre: _ordreEmprunts,
|
||
);
|
||
final materiels = await ResponsableService.materiels(
|
||
statut: _statutMateriels,
|
||
categorieId: _categorieMateriels,
|
||
recherche: _rechercheMaterielsController.text,
|
||
tri: _triMateriels,
|
||
ordre: _ordreMateriels,
|
||
);
|
||
for (final materiel in materiels) {
|
||
final categorie = Map<String, dynamic>.from(materiel["categorie"] as Map);
|
||
_categoriesMateriels[categorie["id"] as int] = categorie["nom"] as String;
|
||
}
|
||
final anomalies = await ResponsableService.anomalies();
|
||
final notifications = await ResponsableService.notifications();
|
||
final historique = await ResponsableService.historique();
|
||
|
||
return _ResponsableData(
|
||
dashboard: dashboard,
|
||
ecarts: ecarts,
|
||
emprunts: emprunts,
|
||
materiels: materiels,
|
||
anomalies: anomalies,
|
||
notifications: notifications,
|
||
historique: historique,
|
||
);
|
||
}
|
||
|
||
void _rafraichir() {
|
||
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();
|
||
});
|
||
}
|
||
|
||
void _trierMateriels(String tri) {
|
||
setState(() {
|
||
if (_triMateriels == tri) {
|
||
_ordreMateriels = _ordreMateriels == "asc" ? "desc" : "asc";
|
||
} else {
|
||
_triMateriels = tri;
|
||
_ordreMateriels = "asc";
|
||
}
|
||
_future = _charger();
|
||
});
|
||
}
|
||
|
||
void _filtrerMateriels({String? statut, int? categorieId}) {
|
||
setState(() {
|
||
_statutMateriels = statut;
|
||
_categorieMateriels = categorieId;
|
||
_future = _charger();
|
||
});
|
||
}
|
||
|
||
void _reinitialiserFiltresMateriels() {
|
||
_rechercheMaterielsController.clear();
|
||
setState(() {
|
||
_statutMateriels = null;
|
||
_categorieMateriels = null;
|
||
_triMateriels = "nom";
|
||
_ordreMateriels = "asc";
|
||
_future = _charger();
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_rechercheEmpruntsController.dispose();
|
||
_rechercheMaterielsController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _quitter() async {
|
||
try {
|
||
await AuthService.signOut();
|
||
if (mounted) {
|
||
Navigator.of(context).pop();
|
||
}
|
||
} catch (error) {
|
||
if (!mounted) return;
|
||
AppMessage.erreur(context, error);
|
||
}
|
||
}
|
||
|
||
Future<void> _changerStatut(Map<String, dynamic> anomalie) async {
|
||
final prochain = _prochainStatut(anomalie["statut"] as String?);
|
||
if (prochain == null || _transitionEnCours) {
|
||
return;
|
||
}
|
||
|
||
setState(() => _transitionEnCours = true);
|
||
try {
|
||
await ResponsableService.changerStatutAnomalie(
|
||
anomalie["id"] as int,
|
||
prochain,
|
||
);
|
||
if (!mounted) return;
|
||
AppMessage.succes(
|
||
context,
|
||
"L'anomalie est maintenant au statut ${_statusLabel(prochain)}.",
|
||
titre: "Statut mis à jour",
|
||
);
|
||
_rafraichir();
|
||
} catch (error) {
|
||
if (!mounted) return;
|
||
AppMessage.erreur(context, error);
|
||
} finally {
|
||
if (mounted) {
|
||
setState(() => _transitionEnCours = false);
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _examinerEcart(Map<String, dynamic> ecart) async {
|
||
if (_decisionEnCours) return;
|
||
|
||
final decision = await showDialog<_DecisionEcart>(
|
||
context: context,
|
||
builder: (context) => _EcartDecisionDialog(ecart: ecart),
|
||
);
|
||
if (decision == null || !mounted) {
|
||
return;
|
||
}
|
||
|
||
setState(() => _decisionEnCours = true);
|
||
try {
|
||
await ResponsableService.deciderEcart(
|
||
empruntId: ecart["id"] as int,
|
||
decision: decision.decision,
|
||
observation: decision.observation,
|
||
statutMateriel: decision.statutMateriel,
|
||
);
|
||
if (!mounted) return;
|
||
AppMessage.succes(
|
||
context,
|
||
decision.decision == "CONFIRMER"
|
||
? "Le changement a été confirmé et l'anomalie a été créée."
|
||
: "Le changement a été refusé.",
|
||
titre: "Décision enregistrée",
|
||
);
|
||
_rafraichir();
|
||
} catch (error) {
|
||
if (!mounted) return;
|
||
AppMessage.erreur(context, error);
|
||
} finally {
|
||
if (mounted) {
|
||
setState(() => _decisionEnCours = false);
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _marquerNotificationsLues() async {
|
||
try {
|
||
await ResponsableService.marquerNotificationsLues();
|
||
_rafraichir();
|
||
} catch (error) {
|
||
if (!mounted) return;
|
||
AppMessage.erreur(context, error);
|
||
}
|
||
}
|
||
|
||
Future<void> _exporterHistorique() async {
|
||
if (_exportEnCours) return;
|
||
|
||
setState(() => _exportEnCours = true);
|
||
try {
|
||
final file = await ResponsableService.exporterHistorique();
|
||
downloadFile(
|
||
bytes: file.bytes,
|
||
fileName: file.fileName,
|
||
contentType: file.contentType,
|
||
);
|
||
if (!mounted) return;
|
||
AppMessage.succes(
|
||
context,
|
||
"Le fichier d'historique est disponible dans vos téléchargements.",
|
||
titre: "Export CSV téléchargé",
|
||
);
|
||
} catch (error) {
|
||
if (!mounted) return;
|
||
AppMessage.erreur(context, error, titre: "Export impossible");
|
||
} finally {
|
||
if (mounted) {
|
||
setState(() => _exportEnCours = false);
|
||
}
|
||
}
|
||
}
|
||
|
||
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(
|
||
backgroundColor: _workspaceBackground,
|
||
body: SafeArea(
|
||
child: Column(
|
||
children: [
|
||
_ResponsableTopBar(onExit: _quitter, onRefresh: _rafraichir),
|
||
Expanded(
|
||
child: FutureBuilder<_ResponsableData>(
|
||
future: _future,
|
||
builder: (context, snapshot) {
|
||
if (snapshot.connectionState != ConnectionState.done) {
|
||
return const _ChargementWorkspace();
|
||
}
|
||
if (snapshot.hasError) {
|
||
return _Erreur(
|
||
message: snapshot.error.toString(),
|
||
onRetry: _rafraichir,
|
||
);
|
||
}
|
||
return _contenu(snapshot.data!);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _contenu(_ResponsableData data) {
|
||
final kpis = Map<String, dynamic>.from(data.dashboard["kpis"] as Map);
|
||
final anomalies = _repartition(kpis["anomalies"]);
|
||
final anomaliesActives =
|
||
_statutCount(anomalies, "DETECTEE") +
|
||
_statutCount(anomalies, "EN_COURS_TRAITEMENT");
|
||
final notificationsNonLues = kpis["notificationsNonLues"] as int? ?? 0;
|
||
final ecartsEnAttente = data.ecarts.length;
|
||
|
||
final vues = [
|
||
_VueDashboard(
|
||
data: data,
|
||
onNavigate: (index) => setState(() => _onglet = index),
|
||
onRefresh: _rafraichir,
|
||
),
|
||
_VueListe(
|
||
titre: "Écarts à confirmer",
|
||
description:
|
||
"Contrôlez les changements déclarés avant toute modification de l’état officiel.",
|
||
icon: Icons.fact_check_outlined,
|
||
items: data.ecarts,
|
||
builder: _ecartTile,
|
||
searchableText: (item) {
|
||
final etudiant = Map<String, dynamic>.from(item["etudiant"] as Map);
|
||
final materiel = Map<String, dynamic>.from(item["materiel"] as Map);
|
||
return "${item["typeEcart"]} ${materiel["nom"]} ${materiel["reference"]} ${etudiant["prenom"]} ${etudiant["nom"]}";
|
||
},
|
||
),
|
||
_VueEmpruntsTableau(
|
||
items: data.emprunts,
|
||
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,
|
||
),
|
||
_VueMaterielsTableau(
|
||
items: data.materiels,
|
||
categories: _categoriesMateriels.entries.toList(),
|
||
rechercheController: _rechercheMaterielsController,
|
||
statut: _statutMateriels,
|
||
categorieId: _categorieMateriels,
|
||
tri: _triMateriels,
|
||
ordre: _ordreMateriels,
|
||
onRecherche: _rafraichir,
|
||
onStatutChanged: (statut) {
|
||
_filtrerMateriels(statut: statut, categorieId: _categorieMateriels);
|
||
},
|
||
onCategorieChanged: (categorieId) {
|
||
_filtrerMateriels(statut: _statutMateriels, categorieId: categorieId);
|
||
},
|
||
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,
|
||
items: data.anomalies,
|
||
builder: _anomalieTile,
|
||
searchableText: (item) {
|
||
final materiel = Map<String, dynamic>.from(item["materiel"] as Map);
|
||
return "${item["type"]} ${item["description"]} ${item["statut"]} ${materiel["nom"]}";
|
||
},
|
||
),
|
||
_VueListe(
|
||
titre: "Notifications",
|
||
description:
|
||
"Centralisez les alertes liées aux opérations et aux anomalies.",
|
||
icon: Icons.notifications_none_outlined,
|
||
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",
|
||
description:
|
||
"Retrouvez la trace complète des actions réalisées sur le campus.",
|
||
icon: Icons.history,
|
||
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"]}",
|
||
),
|
||
];
|
||
|
||
return LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final compact = constraints.maxWidth < 900;
|
||
return Row(
|
||
children: [
|
||
if (!compact)
|
||
_ResponsableSidebar(
|
||
index: _onglet,
|
||
ecartsEnAttente: ecartsEnAttente,
|
||
anomaliesActives: anomaliesActives,
|
||
notificationsNonLues: notificationsNonLues,
|
||
onChanged: (index) => setState(() => _onglet = index),
|
||
onExit: _quitter,
|
||
),
|
||
Expanded(
|
||
child: Column(
|
||
children: [
|
||
if (compact)
|
||
_NavigationCompacte(
|
||
index: _onglet,
|
||
onChanged: (index) => setState(() => _onglet = index),
|
||
),
|
||
Expanded(child: vues[_onglet]),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _ecartTile(Map<String, dynamic> item) {
|
||
final etudiant = Map<String, dynamic>.from(item["etudiant"] as Map);
|
||
final materiel = Map<String, dynamic>.from(item["materiel"] as Map);
|
||
final type = item["typeEcart"] == "DEPART" ? "Départ" : "Retour";
|
||
return _InfoTile(
|
||
icon: Icons.fact_check_outlined,
|
||
title: "$type · ${materiel["nom"]}",
|
||
subtitle:
|
||
"${etudiant["prenom"]} ${etudiant["nom"]} · ${materiel["reference"]}",
|
||
meta: "Emprunt #${item["id"]}",
|
||
trailing: FilledButton.icon(
|
||
onPressed: _decisionEnCours ? null : () => _examinerEcart(item),
|
||
icon: const Icon(Icons.visibility_outlined, size: 16),
|
||
label: const Text("Examiner"),
|
||
style: FilledButton.styleFrom(
|
||
backgroundColor: EnsupColors.blue3,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(7)),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _anomalieTile(Map<String, dynamic> item) {
|
||
final materiel = Map<String, dynamic>.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<String, dynamic> item) {
|
||
return _InfoTile(
|
||
icon: item["lu"] == true
|
||
? Icons.notifications_none
|
||
: Icons.notifications_active_outlined,
|
||
title: "${item["titre"]}",
|
||
subtitle: "${item["message"]}",
|
||
meta: _date(item["dateCreation"]),
|
||
trailing: _StatusBadge(item["lu"] == true ? "LUE" : "NON_LUE"),
|
||
);
|
||
}
|
||
|
||
Widget _historiqueTile(Map<String, dynamic> item) {
|
||
final utilisateur = Map<String, dynamic>.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());
|
||
if (parsed == null) return value.toString();
|
||
return "${parsed.day.toString().padLeft(2, "0")}/"
|
||
"${parsed.month.toString().padLeft(2, "0")}/${parsed.year}";
|
||
}
|
||
}
|
||
|
||
String _statusLabel(String statut) {
|
||
return switch (statut) {
|
||
"DISPONIBLE" => "Disponible",
|
||
"RESERVE" => "Réservé",
|
||
"EMPRUNTE" => "Emprunté",
|
||
"NON_CONFORME" => "Non conforme",
|
||
"DETERIORE" => "Détérioré",
|
||
"MAINTENANCE" => "Maintenance",
|
||
"INDISPONIBLE" => "Indisponible",
|
||
"EN_COURS" => "En cours",
|
||
"EN_ATTENTE_VALIDATION_DEPART" => "Écart départ en attente",
|
||
"EN_RETARD" => "En retard",
|
||
"EN_ATTENTE_VALIDATION_RETOUR" => "Écart retour en attente",
|
||
"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",
|
||
"PRESENT" => "Présent",
|
||
"ABSENT" => "Absent",
|
||
_ => 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",
|
||
"TENTATIVE_MODIFICATION_RETOUR" => "Tentative de modification au retour",
|
||
"ECART_DEPART_SIGNALE" => "Écart signalé au départ",
|
||
"ECART_RETOUR_SIGNALE" => "Écart signalé au retour",
|
||
"ECART_DEPART_CONFIRMER" => "Écart de départ confirmé",
|
||
"ECART_DEPART_REFUSER" => "Écart de départ refusé",
|
||
"ECART_RETOUR_CONFIRMER" => "Écart de retour confirmé",
|
||
"ECART_RETOUR_REFUSER" => "Écart de retour refusé",
|
||
"EMPRUNT_AUTOMATIQUE" => "Emprunt automatique",
|
||
"RESTITUTION_AUTOMATIQUE" => "Restitution automatique",
|
||
_ => 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,
|
||
"RESERVE" ||
|
||
"EN_ATTENTE_VALIDATION_DEPART" ||
|
||
"EN_ATTENTE_VALIDATION_RETOUR" => _orange,
|
||
"MAINTENANCE" || "INDISPONIBLE" || "DETERIORE" => EnsupColors.purple,
|
||
_ => EnsupColors.blue3,
|
||
};
|
||
}
|
||
|
||
class _DecisionEcart {
|
||
const _DecisionEcart({
|
||
required this.decision,
|
||
required this.observation,
|
||
this.statutMateriel,
|
||
});
|
||
|
||
final String decision;
|
||
final String observation;
|
||
final String? statutMateriel;
|
||
}
|
||
|
||
class _EcartDecisionDialog extends StatefulWidget {
|
||
const _EcartDecisionDialog({required this.ecart});
|
||
|
||
final Map<String, dynamic> ecart;
|
||
|
||
@override
|
||
State<_EcartDecisionDialog> createState() => _EcartDecisionDialogState();
|
||
}
|
||
|
||
class _EcartDecisionDialogState extends State<_EcartDecisionDialog> {
|
||
final TextEditingController _observationController = TextEditingController();
|
||
String _statutMateriel = "NON_CONFORME";
|
||
|
||
bool get _estRetour => widget.ecart["typeEcart"] == "RETOUR";
|
||
|
||
@override
|
||
void dispose() {
|
||
_observationController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
List<Map<String, dynamic>> _elements(String type) {
|
||
final checklists = widget.ecart["checklists"] as List<dynamic>? ?? const [];
|
||
for (final checklistValue in checklists) {
|
||
final checklist = Map<String, dynamic>.from(checklistValue as Map);
|
||
if (checklist["type"] == type) {
|
||
return (checklist["elements"] as List<dynamic>? ?? const [])
|
||
.map((element) => Map<String, dynamic>.from(element as Map))
|
||
.toList();
|
||
}
|
||
}
|
||
return const [];
|
||
}
|
||
|
||
String _etat(Map<String, dynamic> element) {
|
||
final quantite = element["quantiteConstatee"] as int? ?? 0;
|
||
return "${_statusLabel("${element["etat"]}")} · quantité $quantite";
|
||
}
|
||
|
||
List<Widget> _comparaisons() {
|
||
final depart = _elements("DEPART");
|
||
if (!_estRetour) {
|
||
return depart
|
||
.map(
|
||
(element) => _EcartComparisonRow(
|
||
nom: "${element["nomElement"]}",
|
||
reference: "Présent · état de référence",
|
||
declaration: _etat(element),
|
||
),
|
||
)
|
||
.toList();
|
||
}
|
||
|
||
final retour = _elements("RETOUR");
|
||
final lignes = <Widget>[];
|
||
for (final elementDepart in depart) {
|
||
Map<String, dynamic>? elementRetour;
|
||
for (final candidat in retour) {
|
||
if (candidat["nomElement"] == elementDepart["nomElement"]) {
|
||
elementRetour = candidat;
|
||
break;
|
||
}
|
||
}
|
||
if (elementRetour == null ||
|
||
elementRetour["etat"] != elementDepart["etat"] ||
|
||
elementRetour["quantiteConstatee"] !=
|
||
elementDepart["quantiteConstatee"]) {
|
||
lignes.add(
|
||
_EcartComparisonRow(
|
||
nom: "${elementDepart["nomElement"]}",
|
||
reference: _etat(elementDepart),
|
||
declaration: elementRetour == null
|
||
? "Élément non renseigné"
|
||
: _etat(elementRetour),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
return lignes;
|
||
}
|
||
|
||
void _terminer(String decision) {
|
||
Navigator.of(context).pop(
|
||
_DecisionEcart(
|
||
decision: decision,
|
||
observation: _observationController.text,
|
||
statutMateriel: _estRetour && decision == "CONFIRMER"
|
||
? _statutMateriel
|
||
: null,
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final etudiant = Map<String, dynamic>.from(widget.ecart["etudiant"] as Map);
|
||
final materiel = Map<String, dynamic>.from(widget.ecart["materiel"] as Map);
|
||
final type = _estRetour ? "retour" : "départ";
|
||
|
||
return Dialog(
|
||
insetPadding: const EdgeInsets.all(20),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 720, maxHeight: 760),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(24, 22, 16, 18),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 42,
|
||
height: 42,
|
||
decoration: BoxDecoration(
|
||
color: _orange.withValues(alpha: 0.1),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: const Icon(
|
||
Icons.fact_check_outlined,
|
||
color: _orange,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
"Contrôle de $type",
|
||
style: GoogleFonts.darkerGrotesque(
|
||
fontSize: 22,
|
||
fontWeight: FontWeight.w900,
|
||
color: EnsupColors.text,
|
||
),
|
||
),
|
||
Text(
|
||
"${materiel["nom"]} · ${etudiant["prenom"]} ${etudiant["nom"]}",
|
||
style: GoogleFonts.titilliumWeb(
|
||
fontSize: 13,
|
||
color: EnsupColors.muted,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
IconButton(
|
||
tooltip: "Fermer",
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
icon: const Icon(Icons.close),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const Divider(height: 1, color: EnsupColors.line),
|
||
Expanded(
|
||
child: SingleChildScrollView(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Text(
|
||
_estRetour
|
||
? "Différences avec l’état validé au départ"
|
||
: "Différences avec l’état de référence",
|
||
style: GoogleFonts.darkerGrotesque(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w800,
|
||
),
|
||
),
|
||
const SizedBox(height: 10),
|
||
..._comparaisons(),
|
||
const SizedBox(height: 20),
|
||
if (_estRetour) ...[
|
||
DropdownButtonFormField<String>(
|
||
initialValue: _statutMateriel,
|
||
decoration: const InputDecoration(
|
||
labelText: "État final si le changement est confirmé",
|
||
border: OutlineInputBorder(),
|
||
),
|
||
items: const [
|
||
DropdownMenuItem(
|
||
value: "DISPONIBLE",
|
||
child: Text("Disponible"),
|
||
),
|
||
DropdownMenuItem(
|
||
value: "NON_CONFORME",
|
||
child: Text("Non conforme"),
|
||
),
|
||
DropdownMenuItem(
|
||
value: "DETERIORE",
|
||
child: Text("Détérioré"),
|
||
),
|
||
DropdownMenuItem(
|
||
value: "MAINTENANCE",
|
||
child: Text("Maintenance"),
|
||
),
|
||
DropdownMenuItem(
|
||
value: "INDISPONIBLE",
|
||
child: Text("Indisponible"),
|
||
),
|
||
],
|
||
onChanged: (value) {
|
||
if (value != null) {
|
||
setState(() => _statutMateriel = value);
|
||
}
|
||
},
|
||
),
|
||
const SizedBox(height: 14),
|
||
],
|
||
TextField(
|
||
controller: _observationController,
|
||
minLines: 2,
|
||
maxLines: 4,
|
||
decoration: const InputDecoration(
|
||
labelText: "Observation",
|
||
hintText: "Constat effectué lors du contrôle physique",
|
||
border: OutlineInputBorder(),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const Divider(height: 1, color: EnsupColors.line),
|
||
Padding(
|
||
padding: const EdgeInsets.all(18),
|
||
child: Wrap(
|
||
alignment: WrapAlignment.end,
|
||
spacing: 10,
|
||
runSpacing: 10,
|
||
children: [
|
||
OutlinedButton.icon(
|
||
onPressed: () => _terminer("REFUSER"),
|
||
icon: const Icon(Icons.close, size: 18),
|
||
label: const Text("Refuser le changement"),
|
||
),
|
||
FilledButton.icon(
|
||
onPressed: () => _terminer("CONFIRMER"),
|
||
icon: const Icon(Icons.check, size: 18),
|
||
label: const Text("Confirmer le changement"),
|
||
style: FilledButton.styleFrom(
|
||
backgroundColor: EnsupColors.blue3,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _EcartComparisonRow extends StatelessWidget {
|
||
const _EcartComparisonRow({
|
||
required this.nom,
|
||
required this.reference,
|
||
required this.declaration,
|
||
});
|
||
|
||
final String nom;
|
||
final String reference;
|
||
final String declaration;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||
decoration: const BoxDecoration(
|
||
border: Border(bottom: BorderSide(color: EnsupColors.line)),
|
||
),
|
||
child: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final compact = constraints.maxWidth < 520;
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
nom,
|
||
style: GoogleFonts.darkerGrotesque(
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w800,
|
||
),
|
||
),
|
||
const SizedBox(height: 5),
|
||
if (compact)
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Text(
|
||
reference,
|
||
style: GoogleFonts.titilliumWeb(
|
||
fontSize: 13,
|
||
color: EnsupColors.text2,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
declaration,
|
||
style: GoogleFonts.titilliumWeb(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w700,
|
||
color: _orange,
|
||
),
|
||
),
|
||
],
|
||
)
|
||
else
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
reference,
|
||
style: GoogleFonts.titilliumWeb(
|
||
fontSize: 13,
|
||
color: EnsupColors.text2,
|
||
),
|
||
),
|
||
),
|
||
const Icon(Icons.arrow_forward, size: 16),
|
||
Expanded(
|
||
child: Text(
|
||
declaration,
|
||
textAlign: TextAlign.right,
|
||
style: GoogleFonts.titilliumWeb(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w700,
|
||
color: _orange,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
Map<String, dynamic> _repartition(Object? value) {
|
||
if (value is! Map) return <String, dynamic>{};
|
||
return Map<String, dynamic>.from(value);
|
||
}
|
||
|
||
int _statutCount(Map<String, dynamic> repartition, String statut) {
|
||
final parStatut = repartition["parStatut"];
|
||
if (parStatut is! Map) return 0;
|
||
final value = parStatut[statut];
|
||
return value is int ? value : 0;
|
||
}
|
||
|
||
String _dateLongue(DateTime date) {
|
||
const jours = [
|
||
"lundi",
|
||
"mardi",
|
||
"mercredi",
|
||
"jeudi",
|
||
"vendredi",
|
||
"samedi",
|
||
"dimanche",
|
||
];
|
||
const mois = [
|
||
"janvier",
|
||
"février",
|
||
"mars",
|
||
"avril",
|
||
"mai",
|
||
"juin",
|
||
"juillet",
|
||
"août",
|
||
"septembre",
|
||
"octobre",
|
||
"novembre",
|
||
"décembre",
|
||
];
|
||
return "${jours[date.weekday - 1]} ${date.day} ${mois[date.month - 1]} ${date.year}";
|
||
}
|
||
|
||
String _dateHeure(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 · $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});
|
||
|
||
final VoidCallback onExit;
|
||
final VoidCallback onRefresh;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final profile = AuthService.currentProfile;
|
||
final campusTag = profile?.campusTag ?? "Ensitech · Cergy";
|
||
final initials = profile?.initials ?? "KB";
|
||
final userName = profile?.fullName ?? "Karim Benali";
|
||
final roleLabel = profile?.roleLabel ?? "Responsable matériel";
|
||
|
||
return Container(
|
||
height: 68,
|
||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||
decoration: const BoxDecoration(
|
||
color: Colors.white,
|
||
border: Border(bottom: BorderSide(color: EnsupColors.line)),
|
||
),
|
||
child: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final showCampus = constraints.maxWidth >= 650;
|
||
final showName = constraints.maxWidth >= 820;
|
||
return Row(
|
||
children: [
|
||
Container(
|
||
width: 38,
|
||
height: 38,
|
||
decoration: BoxDecoration(
|
||
color: EnsupColors.blue3,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: const Icon(
|
||
Icons.grid_view_rounded,
|
||
color: Colors.white,
|
||
size: 19,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
"EME",
|
||
style: GoogleFonts.darkerGrotesque(
|
||
fontSize: 21,
|
||
height: 0.95,
|
||
fontWeight: FontWeight.w900,
|
||
color: EnsupColors.text,
|
||
),
|
||
),
|
||
Text(
|
||
"Emprunt matériel",
|
||
style: GoogleFonts.titilliumWeb(
|
||
fontSize: 11,
|
||
color: EnsupColors.muted,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const Spacer(),
|
||
if (showCampus) ...[
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 12,
|
||
vertical: 7,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: _workspaceBackground,
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(color: EnsupColors.line),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
const Icon(
|
||
Icons.location_on_outlined,
|
||
size: 15,
|
||
color: EnsupColors.blue3,
|
||
),
|
||
const SizedBox(width: 6),
|
||
Text(
|
||
campusTag,
|
||
style: GoogleFonts.titilliumWeb(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w600,
|
||
color: EnsupColors.text2,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
],
|
||
Tooltip(
|
||
message: "Actualiser les données",
|
||
child: IconButton(
|
||
onPressed: onRefresh,
|
||
icon: const Icon(Icons.refresh_rounded),
|
||
color: EnsupColors.text2,
|
||
),
|
||
),
|
||
const SizedBox(width: 4),
|
||
Container(width: 1, height: 28, color: EnsupColors.line),
|
||
const SizedBox(width: 12),
|
||
Container(
|
||
width: 34,
|
||
height: 34,
|
||
alignment: Alignment.center,
|
||
decoration: const BoxDecoration(
|
||
color: EnsupColors.purple,
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: Text(
|
||
initials,
|
||
style: GoogleFonts.darkerGrotesque(
|
||
color: Colors.white,
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w900,
|
||
),
|
||
),
|
||
),
|
||
if (showName) ...[
|
||
const SizedBox(width: 9),
|
||
Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
userName,
|
||
style: GoogleFonts.titilliumWeb(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w700,
|
||
color: EnsupColors.text,
|
||
),
|
||
),
|
||
Text(
|
||
roleLabel,
|
||
style: GoogleFonts.titilliumWeb(
|
||
fontSize: 11,
|
||
color: EnsupColors.muted,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
const SizedBox(width: 4),
|
||
Tooltip(
|
||
message: "Quitter l’espace responsable",
|
||
child: IconButton(
|
||
onPressed: onExit,
|
||
icon: const Icon(Icons.logout_rounded),
|
||
color: EnsupColors.muted,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ResponsableSidebar extends StatelessWidget {
|
||
const _ResponsableSidebar({
|
||
required this.index,
|
||
required this.ecartsEnAttente,
|
||
required this.anomaliesActives,
|
||
required this.notificationsNonLues,
|
||
required this.onChanged,
|
||
required this.onExit,
|
||
});
|
||
|
||
final int index;
|
||
final int ecartsEnAttente;
|
||
final int anomaliesActives;
|
||
final int notificationsNonLues;
|
||
final ValueChanged<int> onChanged;
|
||
final VoidCallback onExit;
|
||
|
||
static const _destinations = [
|
||
("Vue d’ensemble", Icons.space_dashboard_outlined),
|
||
("Écarts à confirmer", Icons.fact_check_outlined),
|
||
("Emprunts", Icons.assignment_outlined),
|
||
("Stock matériel", Icons.inventory_2_outlined),
|
||
("Anomalies", Icons.report_problem_outlined),
|
||
("Notifications", Icons.notifications_none_outlined),
|
||
("Historique", Icons.history),
|
||
];
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
width: 244,
|
||
color: EnsupColors.blue3,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 24, 20, 10),
|
||
child: Text(
|
||
"SUPERVISION",
|
||
style: GoogleFonts.titilliumWeb(
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w700,
|
||
color: Colors.white.withValues(alpha: 0.58),
|
||
),
|
||
),
|
||
),
|
||
...List.generate(_destinations.length, (itemIndex) {
|
||
final destination = _destinations[itemIndex];
|
||
final badge = switch (itemIndex) {
|
||
1 => ecartsEnAttente,
|
||
4 => anomaliesActives,
|
||
5 => notificationsNonLues,
|
||
_ => 0,
|
||
};
|
||
return _SidebarItem(
|
||
label: destination.$1,
|
||
icon: destination.$2,
|
||
selected: itemIndex == index,
|
||
badge: badge,
|
||
alert: itemIndex == 1 || itemIndex == 4,
|
||
onTap: () => onChanged(itemIndex),
|
||
);
|
||
}),
|
||
const Spacer(),
|
||
Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Container(
|
||
padding: const EdgeInsets.all(14),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white.withValues(alpha: 0.08),
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: Colors.white.withValues(alpha: 0.12)),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
const Icon(
|
||
Icons.verified_user_outlined,
|
||
size: 17,
|
||
color: Colors.white,
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
"Session responsable",
|
||
style: GoogleFonts.titilliumWeb(
|
||
color: Colors.white,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
"Accès limité au campus actif",
|
||
style: GoogleFonts.titilliumWeb(
|
||
color: Colors.white.withValues(alpha: 0.62),
|
||
fontSize: 11,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 18),
|
||
child: TextButton.icon(
|
||
onPressed: onExit,
|
||
style: TextButton.styleFrom(
|
||
foregroundColor: Colors.white.withValues(alpha: 0.78),
|
||
alignment: Alignment.centerLeft,
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 12,
|
||
vertical: 12,
|
||
),
|
||
),
|
||
icon: const Icon(Icons.logout_rounded, size: 18),
|
||
label: const Text("Quitter l’espace"),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SidebarItem extends StatelessWidget {
|
||
const _SidebarItem({
|
||
required this.label,
|
||
required this.icon,
|
||
required this.selected,
|
||
required this.badge,
|
||
required this.alert,
|
||
required this.onTap,
|
||
});
|
||
|
||
final String label;
|
||
final IconData icon;
|
||
final bool selected;
|
||
final int badge;
|
||
final bool alert;
|
||
final VoidCallback onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final foreground = selected
|
||
? Colors.white
|
||
: Colors.white.withValues(alpha: 0.72);
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
|
||
child: Material(
|
||
color: selected
|
||
? Colors.white.withValues(alpha: 0.14)
|
||
: Colors.transparent,
|
||
borderRadius: BorderRadius.circular(7),
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(7),
|
||
child: Container(
|
||
height: 46,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||
decoration: BoxDecoration(
|
||
border: selected
|
||
? const Border(
|
||
left: BorderSide(color: EnsupColors.cyan, width: 3),
|
||
)
|
||
: null,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(icon, size: 19, color: foreground),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Text(
|
||
label,
|
||
style: GoogleFonts.titilliumWeb(
|
||
fontSize: 13,
|
||
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
|
||
color: foreground,
|
||
),
|
||
),
|
||
),
|
||
if (badge > 0)
|
||
Container(
|
||
constraints: const BoxConstraints(minWidth: 22),
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 6,
|
||
vertical: 2,
|
||
),
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
color: alert ? EnsupColors.red : EnsupColors.cyan,
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: Text(
|
||
"$badge",
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _NavigationCompacte extends StatelessWidget {
|
||
const _NavigationCompacte({required this.index, required this.onChanged});
|
||
|
||
final int index;
|
||
final ValueChanged<int> onChanged;
|
||
|
||
static const _destinations = [
|
||
("Vue d’ensemble", Icons.space_dashboard_outlined),
|
||
("Écarts", Icons.fact_check_outlined),
|
||
("Emprunts", Icons.assignment_outlined),
|
||
("Stock", Icons.inventory_2_outlined),
|
||
("Anomalies", Icons.report_problem_outlined),
|
||
("Notifications", Icons.notifications_none_outlined),
|
||
("Historique", Icons.history),
|
||
];
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
height: 66,
|
||
decoration: const BoxDecoration(
|
||
color: Colors.white,
|
||
border: Border(bottom: BorderSide(color: EnsupColors.line)),
|
||
),
|
||
child: ListView.separated(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||
scrollDirection: Axis.horizontal,
|
||
itemCount: _destinations.length,
|
||
separatorBuilder: (_, _) => const SizedBox(width: 6),
|
||
itemBuilder: (context, itemIndex) {
|
||
final selected = itemIndex == index;
|
||
final destination = _destinations[itemIndex];
|
||
return Material(
|
||
color: selected
|
||
? EnsupColors.cyan.withValues(alpha: 0.1)
|
||
: Colors.transparent,
|
||
borderRadius: BorderRadius.circular(7),
|
||
child: InkWell(
|
||
onTap: () => onChanged(itemIndex),
|
||
borderRadius: BorderRadius.circular(7),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||
child: Row(
|
||
children: [
|
||
Icon(
|
||
destination.$2,
|
||
size: 18,
|
||
color: selected ? EnsupColors.blue3 : EnsupColors.muted,
|
||
),
|
||
const SizedBox(width: 7),
|
||
Text(
|
||
destination.$1,
|
||
style: GoogleFonts.titilliumWeb(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w700,
|
||
color: selected ? EnsupColors.blue3 : EnsupColors.muted,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ChargementWorkspace extends StatelessWidget {
|
||
const _ChargementWorkspace();
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return const Center(
|
||
child: SizedBox(
|
||
width: 34,
|
||
height: 34,
|
||
child: CircularProgressIndicator(strokeWidth: 3),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ResponsableData {
|
||
const _ResponsableData({
|
||
required this.dashboard,
|
||
required this.ecarts,
|
||
required this.emprunts,
|
||
required this.materiels,
|
||
required this.anomalies,
|
||
required this.notifications,
|
||
required this.historique,
|
||
});
|
||
|
||
final Map<String, dynamic> dashboard;
|
||
final List<Map<String, dynamic>> ecarts;
|
||
final List<Map<String, dynamic>> emprunts;
|
||
final List<Map<String, dynamic>> materiels;
|
||
final List<Map<String, dynamic>> anomalies;
|
||
final List<Map<String, dynamic>> notifications;
|
||
final List<Map<String, dynamic>> historique;
|
||
}
|
||
|
||
class _VueDashboard extends StatelessWidget {
|
||
const _VueDashboard({
|
||
required this.data,
|
||
required this.onNavigate,
|
||
required this.onRefresh,
|
||
});
|
||
|
||
final _ResponsableData data;
|
||
final ValueChanged<int> onNavigate;
|
||
final VoidCallback onRefresh;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final kpis = Map<String, dynamic>.from(data.dashboard["kpis"] as Map);
|
||
final materiels = _repartition(kpis["materiels"]);
|
||
final emprunts = _repartition(kpis["emprunts"]);
|
||
final anomalies = _repartition(kpis["anomalies"]);
|
||
final activite = (data.dashboard["activiteRecente"] as List? ?? const [])
|
||
.whereType<Map>()
|
||
.map((item) => Map<String, dynamic>.from(item))
|
||
.toList();
|
||
|
||
final totalMateriels = materiels["total"] as int? ?? 0;
|
||
final disponibles = _statutCount(materiels, "DISPONIBLE");
|
||
final empruntsEnCours = _statutCount(emprunts, "EN_COURS");
|
||
final retards = _statutCount(emprunts, "EN_RETARD");
|
||
final anomaliesActives =
|
||
_statutCount(anomalies, "DETECTEE") +
|
||
_statutCount(anomalies, "EN_COURS_TRAITEMENT");
|
||
final notificationsNonLues = kpis["notificationsNonLues"] as int? ?? 0;
|
||
final ecartsEnAttente = data.ecarts.length;
|
||
final indisponibles = [
|
||
"NON_CONFORME",
|
||
"DETERIORE",
|
||
"MAINTENANCE",
|
||
"INDISPONIBLE",
|
||
].fold<int>(0, (total, statut) => total + _statutCount(materiels, statut));
|
||
|
||
return SingleChildScrollView(
|
||
padding: const EdgeInsets.all(28),
|
||
child: Center(
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 1440),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_DashboardHeader(onRefresh: onRefresh),
|
||
const SizedBox(height: 24),
|
||
LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final columns = constraints.maxWidth >= 1120
|
||
? 4
|
||
: constraints.maxWidth >= 560
|
||
? 2
|
||
: 1;
|
||
const spacing = 14.0;
|
||
final width =
|
||
(constraints.maxWidth - spacing * (columns - 1)) /
|
||
columns;
|
||
return Wrap(
|
||
spacing: spacing,
|
||
runSpacing: spacing,
|
||
children: [
|
||
_KpiCard(
|
||
width: width,
|
||
label: "Matériels disponibles",
|
||
value: "$disponibles",
|
||
detail: "sur $totalMateriels au total",
|
||
icon: Icons.inventory_2_outlined,
|
||
color: EnsupColors.cyan,
|
||
),
|
||
_KpiCard(
|
||
width: width,
|
||
label: "Emprunts en cours",
|
||
value: "$empruntsEnCours",
|
||
detail: retards > 0
|
||
? "$retards en retard à surveiller"
|
||
: "Aucun retard signalé",
|
||
icon: Icons.assignment_outlined,
|
||
color: retards > 0 ? _orange : EnsupColors.teal,
|
||
),
|
||
_KpiCard(
|
||
width: width,
|
||
label: "Anomalies actives",
|
||
value: "$anomaliesActives",
|
||
detail: anomaliesActives > 0
|
||
? "Traitement requis"
|
||
: "Aucune anomalie active",
|
||
icon: Icons.report_problem_outlined,
|
||
color: anomaliesActives > 0
|
||
? EnsupColors.red
|
||
: EnsupColors.green,
|
||
),
|
||
_KpiCard(
|
||
width: width,
|
||
label: "Notifications non lues",
|
||
value: "$notificationsNonLues",
|
||
detail: notificationsNonLues > 0
|
||
? "Nouvelles informations"
|
||
: "Vous êtes à jour",
|
||
icon: Icons.notifications_none_outlined,
|
||
color: EnsupColors.purple,
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
const SizedBox(height: 20),
|
||
LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final activity = _ActivityPanel(
|
||
items: activite,
|
||
onSeeAll: () => onNavigate(6),
|
||
);
|
||
final priorities = _PrioritiesPanel(
|
||
ecarts: ecartsEnAttente,
|
||
retards: retards,
|
||
anomalies: anomaliesActives,
|
||
notifications: notificationsNonLues,
|
||
indisponibles: indisponibles,
|
||
disponibles: disponibles,
|
||
totalMateriels: totalMateriels,
|
||
onNavigate: onNavigate,
|
||
);
|
||
if (constraints.maxWidth < 980) {
|
||
return Column(
|
||
children: [
|
||
activity,
|
||
const SizedBox(height: 16),
|
||
priorities,
|
||
],
|
||
);
|
||
}
|
||
return Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Expanded(flex: 2, child: activity),
|
||
const SizedBox(width: 16),
|
||
Expanded(child: priorities),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _DashboardHeader extends StatelessWidget {
|
||
const _DashboardHeader({required this.onRefresh});
|
||
|
||
final VoidCallback onRefresh;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
"Vue d’ensemble",
|
||
style: GoogleFonts.darkerGrotesque(
|
||
fontSize: 32,
|
||
height: 1,
|
||
fontWeight: FontWeight.w900,
|
||
color: EnsupColors.text,
|
||
),
|
||
),
|
||
const SizedBox(height: 7),
|
||
Text(
|
||
"La situation opérationnelle du campus en un coup d’œil · ${_dateLongue(DateTime.now())}",
|
||
style: GoogleFonts.titilliumWeb(
|
||
fontSize: 14,
|
||
color: EnsupColors.muted,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 16),
|
||
OutlinedButton.icon(
|
||
onPressed: onRefresh,
|
||
icon: const Icon(Icons.refresh_rounded, size: 17),
|
||
label: const Text("Actualiser"),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _KpiCard extends StatelessWidget {
|
||
const _KpiCard({
|
||
required this.width,
|
||
required this.label,
|
||
required this.value,
|
||
required this.detail,
|
||
required this.icon,
|
||
required this.color,
|
||
});
|
||
|
||
final double width;
|
||
final String label;
|
||
final String value;
|
||
final String detail;
|
||
final IconData icon;
|
||
final Color color;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
width: width,
|
||
height: 132,
|
||
padding: const EdgeInsets.all(18),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
border: Border.all(color: EnsupColors.line),
|
||
borderRadius: BorderRadius.circular(8),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: EnsupColors.text.withValues(alpha: 0.045),
|
||
blurRadius: 14,
|
||
offset: const Offset(0, 4),
|
||
),
|
||
],
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(
|
||
label,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: GoogleFonts.titilliumWeb(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
color: EnsupColors.muted,
|
||
),
|
||
),
|
||
Text(
|
||
value,
|
||
style: GoogleFonts.darkerGrotesque(
|
||
fontSize: 36,
|
||
height: 0.9,
|
||
fontWeight: FontWeight.w900,
|
||
color: EnsupColors.text,
|
||
),
|
||
),
|
||
Text(
|
||
detail,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: GoogleFonts.titilliumWeb(
|
||
fontSize: 11,
|
||
color: color,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Container(
|
||
width: 44,
|
||
height: 44,
|
||
decoration: BoxDecoration(
|
||
color: color.withValues(alpha: 0.1),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Icon(icon, color: color, size: 22),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ActivityPanel extends StatelessWidget {
|
||
const _ActivityPanel({required this.items, required this.onSeeAll});
|
||
|
||
final List<Map<String, dynamic>> items;
|
||
final VoidCallback onSeeAll;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return _DashboardPanel(
|
||
title: "Activité récente",
|
||
subtitle: "Dernières opérations enregistrées sur le campus",
|
||
action: TextButton(
|
||
onPressed: onSeeAll,
|
||
child: const Text("Voir l’historique"),
|
||
),
|
||
child: items.isEmpty
|
||
? const _PanelEmptyState(
|
||
icon: Icons.history_toggle_off_outlined,
|
||
message: "Aucune activité récente",
|
||
)
|
||
: Column(
|
||
children: List.generate(items.take(6).length, (index) {
|
||
final item = items[index];
|
||
return Column(
|
||
children: [
|
||
_ActivityRow(item: item),
|
||
if (index < items.take(6).length - 1)
|
||
const Divider(height: 1, color: EnsupColors.line),
|
||
],
|
||
);
|
||
}),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ActivityRow extends StatelessWidget {
|
||
const _ActivityRow({required this.item});
|
||
|
||
final Map<String, dynamic> item;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final utilisateur = _repartition(item["utilisateur"]);
|
||
final materiel = _repartition(item["materiel"]);
|
||
final action = "${item["action"] ?? ""}";
|
||
final isAlert = action == "CREATION_ANOMALIE";
|
||
final title = materiel.isNotEmpty
|
||
? "${materiel["nom"]}"
|
||
: _actionLabel(action);
|
||
final userName = utilisateur.isEmpty
|
||
? "Système"
|
||
: "${utilisateur["prenom"]} ${utilisateur["nom"]}";
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 13),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 38,
|
||
height: 38,
|
||
decoration: BoxDecoration(
|
||
color: (isAlert ? EnsupColors.red : EnsupColors.cyan).withValues(
|
||
alpha: 0.09,
|
||
),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Icon(
|
||
isAlert
|
||
? Icons.report_problem_outlined
|
||
: Icons.swap_horiz_rounded,
|
||
size: 19,
|
||
color: isAlert ? EnsupColors.red : EnsupColors.blue3,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
title,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: GoogleFonts.titilliumWeb(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w700,
|
||
color: EnsupColors.text,
|
||
),
|
||
),
|
||
Text(
|
||
"${item["description"] ?? _actionLabel(action)} · $userName",
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: GoogleFonts.titilliumWeb(
|
||
fontSize: 12,
|
||
color: EnsupColors.muted,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Text(
|
||
_dateHeure(item["dateAction"]),
|
||
style: GoogleFonts.titilliumWeb(
|
||
fontSize: 11,
|
||
color: EnsupColors.muted,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _PrioritiesPanel extends StatelessWidget {
|
||
const _PrioritiesPanel({
|
||
required this.ecarts,
|
||
required this.retards,
|
||
required this.anomalies,
|
||
required this.notifications,
|
||
required this.indisponibles,
|
||
required this.disponibles,
|
||
required this.totalMateriels,
|
||
required this.onNavigate,
|
||
});
|
||
|
||
final int ecarts;
|
||
final int retards;
|
||
final int anomalies;
|
||
final int notifications;
|
||
final int indisponibles;
|
||
final int disponibles;
|
||
final int totalMateriels;
|
||
final ValueChanged<int> onNavigate;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final availability = totalMateriels == 0
|
||
? 0.0
|
||
: disponibles / totalMateriels;
|
||
return _DashboardPanel(
|
||
title: "Points d’attention",
|
||
subtitle: "Éléments qui nécessitent votre suivi",
|
||
child: Column(
|
||
children: [
|
||
_PriorityRow(
|
||
label: "Écarts à confirmer",
|
||
value: ecarts,
|
||
icon: Icons.fact_check_outlined,
|
||
color: EnsupColors.red,
|
||
onTap: () => onNavigate(1),
|
||
),
|
||
const Divider(height: 1, color: EnsupColors.line),
|
||
_PriorityRow(
|
||
label: "Emprunts en retard",
|
||
value: retards,
|
||
icon: Icons.schedule_outlined,
|
||
color: _orange,
|
||
onTap: () => onNavigate(2),
|
||
),
|
||
const Divider(height: 1, color: EnsupColors.line),
|
||
_PriorityRow(
|
||
label: "Anomalies actives",
|
||
value: anomalies,
|
||
icon: Icons.report_problem_outlined,
|
||
color: EnsupColors.red,
|
||
onTap: () => onNavigate(4),
|
||
),
|
||
const Divider(height: 1, color: EnsupColors.line),
|
||
_PriorityRow(
|
||
label: "Notifications non lues",
|
||
value: notifications,
|
||
icon: Icons.notifications_none_outlined,
|
||
color: EnsupColors.purple,
|
||
onTap: () => onNavigate(5),
|
||
),
|
||
const Divider(height: 1, color: EnsupColors.line),
|
||
_PriorityRow(
|
||
label: "Matériels indisponibles",
|
||
value: indisponibles,
|
||
icon: Icons.build_outlined,
|
||
color: EnsupColors.teal,
|
||
onTap: () => onNavigate(3),
|
||
),
|
||
const SizedBox(height: 20),
|
||
Row(
|
||
children: [
|
||
Text(
|
||
"Disponibilité du parc",
|
||
style: GoogleFonts.titilliumWeb(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w700,
|
||
color: EnsupColors.text2,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
Text(
|
||
"${(availability * 100).round()} %",
|
||
style: GoogleFonts.darkerGrotesque(
|
||
fontSize: 17,
|
||
fontWeight: FontWeight.w900,
|
||
color: EnsupColors.blue3,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 7),
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(4),
|
||
child: LinearProgressIndicator(
|
||
value: availability,
|
||
minHeight: 7,
|
||
backgroundColor: EnsupColors.line,
|
||
color: EnsupColors.cyan,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _PriorityRow extends StatelessWidget {
|
||
const _PriorityRow({
|
||
required this.label,
|
||
required this.value,
|
||
required this.icon,
|
||
required this.color,
|
||
required this.onTap,
|
||
});
|
||
|
||
final String label;
|
||
final int value;
|
||
final IconData icon;
|
||
final Color color;
|
||
final VoidCallback onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(6),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 11),
|
||
child: Row(
|
||
children: [
|
||
Icon(icon, size: 18, color: color),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Text(
|
||
label,
|
||
style: GoogleFonts.titilliumWeb(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w600,
|
||
color: EnsupColors.text2,
|
||
),
|
||
),
|
||
),
|
||
Text(
|
||
"$value",
|
||
style: GoogleFonts.darkerGrotesque(
|
||
fontSize: 20,
|
||
fontWeight: FontWeight.w900,
|
||
color: value > 0 ? color : EnsupColors.muted,
|
||
),
|
||
),
|
||
const SizedBox(width: 5),
|
||
const Icon(
|
||
Icons.chevron_right_rounded,
|
||
size: 18,
|
||
color: EnsupColors.muted,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _DashboardPanel extends StatelessWidget {
|
||
const _DashboardPanel({
|
||
required this.title,
|
||
required this.subtitle,
|
||
required this.child,
|
||
this.action,
|
||
});
|
||
|
||
final String title;
|
||
final String subtitle;
|
||
final Widget child;
|
||
final Widget? action;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
padding: const EdgeInsets.all(20),
|
||
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: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
title,
|
||
style: GoogleFonts.darkerGrotesque(
|
||
fontSize: 21,
|
||
fontWeight: FontWeight.w900,
|
||
color: EnsupColors.text,
|
||
),
|
||
),
|
||
Text(
|
||
subtitle,
|
||
style: GoogleFonts.titilliumWeb(
|
||
fontSize: 12,
|
||
color: EnsupColors.muted,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
?action,
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
child,
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _PanelEmptyState extends StatelessWidget {
|
||
const _PanelEmptyState({required this.icon, required this.message});
|
||
|
||
final IconData icon;
|
||
final String message;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return SizedBox(
|
||
height: 180,
|
||
child: Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(icon, size: 32, color: EnsupColors.muted),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
message,
|
||
style: GoogleFonts.titilliumWeb(color: EnsupColors.muted),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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<Map<String, dynamic>> items;
|
||
final TextEditingController rechercheController;
|
||
final String? statut;
|
||
final int? annee;
|
||
final String tri;
|
||
final String ordre;
|
||
final VoidCallback onRecherche;
|
||
final ValueChanged<String?> onStatutChanged;
|
||
final ValueChanged<int?> onAnneeChanged;
|
||
final ValueChanged<String> 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<int>.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<String>(
|
||
key: ValueKey("statut-$statut"),
|
||
initialValue: statut ?? "",
|
||
isExpanded: true,
|
||
decoration: const InputDecoration(
|
||
labelText: "Statut",
|
||
filled: true,
|
||
fillColor: Colors.white,
|
||
border: OutlineInputBorder(),
|
||
),
|
||
items: [
|
||
const DropdownMenuItem<String>(
|
||
value: "",
|
||
child: Text(
|
||
"Tous les statuts",
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
..._statuts.map(
|
||
(value) => DropdownMenuItem<String>(
|
||
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<int>(
|
||
key: ValueKey("annee-$annee"),
|
||
initialValue: annee ?? 0,
|
||
decoration: const InputDecoration(
|
||
labelText: "Année",
|
||
filled: true,
|
||
fillColor: Colors.white,
|
||
border: OutlineInputBorder(),
|
||
),
|
||
items: [
|
||
const DropdownMenuItem<int>(value: 0, child: Text("Toutes")),
|
||
...annees.map(
|
||
(value) =>
|
||
DropdownMenuItem<int>(value: value, child: Text("$value")),
|
||
),
|
||
],
|
||
onChanged: (value) {
|
||
onAnneeChanged(value == null || value == 0 ? null : value);
|
||
},
|
||
),
|
||
),
|
||
SizedBox(
|
||
width: 180,
|
||
child: DropdownButtonFormField<String>(
|
||
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<String, dynamic> item) {
|
||
final materiel = Map<String, dynamic>.from(item["materiel"] as Map);
|
||
final etudiant = Map<String, dynamic>.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<String, dynamic>.from(item["materiel"] as Map);
|
||
final etudiant = Map<String, dynamic>.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 _VueMaterielsTableau extends StatelessWidget {
|
||
const _VueMaterielsTableau({
|
||
required this.items,
|
||
required this.categories,
|
||
required this.rechercheController,
|
||
required this.statut,
|
||
required this.categorieId,
|
||
required this.tri,
|
||
required this.ordre,
|
||
required this.onRecherche,
|
||
required this.onStatutChanged,
|
||
required this.onCategorieChanged,
|
||
required this.onTri,
|
||
required this.onReset,
|
||
});
|
||
|
||
final List<Map<String, dynamic>> items;
|
||
final List<MapEntry<int, String>> categories;
|
||
final TextEditingController rechercheController;
|
||
final String? statut;
|
||
final int? categorieId;
|
||
final String tri;
|
||
final String ordre;
|
||
final VoidCallback onRecherche;
|
||
final ValueChanged<String?> onStatutChanged;
|
||
final ValueChanged<int?> onCategorieChanged;
|
||
final ValueChanged<String> onTri;
|
||
final VoidCallback onReset;
|
||
|
||
static const _statuts = [
|
||
"DISPONIBLE",
|
||
"RESERVE",
|
||
"EMPRUNTE",
|
||
"NON_CONFORME",
|
||
"DETERIORE",
|
||
"MAINTENANCE",
|
||
"INDISPONIBLE",
|
||
];
|
||
|
||
static const _tris = {
|
||
"nom": "Matériel",
|
||
"reference": "Référence",
|
||
"categorie": "Catégorie",
|
||
"marque": "Marque",
|
||
"etatGeneral": "État",
|
||
"statut": "Statut",
|
||
};
|
||
|
||
int? get _sortColumnIndex {
|
||
return switch (tri) {
|
||
"reference" => 0,
|
||
"nom" => 1,
|
||
"categorie" => 2,
|
||
"marque" => 3,
|
||
"etatGeneral" => 4,
|
||
"statut" => 5,
|
||
_ => 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} matériel${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.inventory_2_outlined,
|
||
color: EnsupColors.blue3,
|
||
size: 21,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
"Stock matériel",
|
||
style: GoogleFonts.darkerGrotesque(
|
||
fontSize: 29,
|
||
height: 1,
|
||
fontWeight: FontWeight.w900,
|
||
color: EnsupColors.text,
|
||
),
|
||
),
|
||
Text(
|
||
"Comparez la disponibilité et l’état du parc de votre campus.",
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: GoogleFonts.titilliumWeb(
|
||
fontSize: 13,
|
||
color: EnsupColors.muted,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _filtres() {
|
||
final categoriesTriees = [...categories]
|
||
..sort((a, b) => a.value.toLowerCase().compareTo(b.value.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: "Matériel, référence, marque ou modèle",
|
||
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: 200,
|
||
child: DropdownButtonFormField<String>(
|
||
key: ValueKey("statut-materiel-$statut"),
|
||
initialValue: statut ?? "",
|
||
isExpanded: true,
|
||
decoration: const InputDecoration(
|
||
labelText: "Statut",
|
||
filled: true,
|
||
fillColor: Colors.white,
|
||
border: OutlineInputBorder(),
|
||
),
|
||
items: [
|
||
const DropdownMenuItem<String>(
|
||
value: "",
|
||
child: Text(
|
||
"Tous les statuts",
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
..._statuts.map(
|
||
(value) => DropdownMenuItem<String>(
|
||
value: value,
|
||
child: Text(
|
||
_statusLabel(value),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
onChanged: (value) {
|
||
onStatutChanged(value == null || value.isEmpty ? null : value);
|
||
},
|
||
),
|
||
),
|
||
SizedBox(
|
||
width: 210,
|
||
child: DropdownButtonFormField<int>(
|
||
key: ValueKey("categorie-materiel-$categorieId"),
|
||
initialValue: categorieId ?? 0,
|
||
isExpanded: true,
|
||
decoration: const InputDecoration(
|
||
labelText: "Catégorie",
|
||
filled: true,
|
||
fillColor: Colors.white,
|
||
border: OutlineInputBorder(),
|
||
),
|
||
items: [
|
||
const DropdownMenuItem<int>(
|
||
value: 0,
|
||
child: Text(
|
||
"Toutes les catégories",
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
...categoriesTriees.map(
|
||
(categorie) => DropdownMenuItem<int>(
|
||
value: categorie.key,
|
||
child: Text(
|
||
categorie.value,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
onChanged: (value) {
|
||
onCategorieChanged(value == null || value == 0 ? null : value);
|
||
},
|
||
),
|
||
),
|
||
SizedBox(
|
||
width: 180,
|
||
child: DropdownButtonFormField<String>(
|
||
key: ValueKey("tri-materiel-$tri"),
|
||
initialValue: tri,
|
||
isExpanded: true,
|
||
decoration: const InputDecoration(
|
||
labelText: "Trier par",
|
||
filled: true,
|
||
fillColor: Colors.white,
|
||
border: OutlineInputBorder(),
|
||
),
|
||
items: _tris.entries
|
||
.map(
|
||
(entry) => DropdownMenuItem<String>(
|
||
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("Référence"),
|
||
onSort: (_, _) => onTri("reference"),
|
||
),
|
||
DataColumn(
|
||
label: const Text("Matériel"),
|
||
onSort: (_, _) => onTri("nom"),
|
||
),
|
||
DataColumn(
|
||
label: const Text("Catégorie"),
|
||
onSort: (_, _) => onTri("categorie"),
|
||
),
|
||
DataColumn(
|
||
label: const Text("Marque / modèle"),
|
||
onSort: (_, _) => onTri("marque"),
|
||
),
|
||
DataColumn(
|
||
label: const Text("État"),
|
||
onSort: (_, _) => onTri("etatGeneral"),
|
||
),
|
||
DataColumn(
|
||
label: const Text("Statut"),
|
||
onSort: (_, _) => onTri("statut"),
|
||
),
|
||
],
|
||
rows: items.map(_ligneTableau).toList(),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
DataRow _ligneTableau(Map<String, dynamic> item) {
|
||
final categorie = Map<String, dynamic>.from(item["categorie"] as Map);
|
||
return DataRow(
|
||
cells: [
|
||
DataCell(
|
||
Text(
|
||
"${item["reference"]}",
|
||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||
),
|
||
),
|
||
DataCell(
|
||
SizedBox(
|
||
width: 190,
|
||
child: Text(
|
||
"${item["nom"]}",
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||
),
|
||
),
|
||
),
|
||
DataCell(
|
||
SizedBox(
|
||
width: 150,
|
||
child: Text(
|
||
"${categorie["nom"]}",
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
),
|
||
DataCell(
|
||
SizedBox(
|
||
width: 170,
|
||
child: Text(
|
||
"${item["marque"] ?? "-"} ${item["modele"] ?? ""}".trim(),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
),
|
||
DataCell(Text(_statusLabel("${item["etatGeneral"]}"))),
|
||
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 categorie = Map<String, dynamic>.from(item["categorie"] 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(
|
||
"${item["nom"]}",
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: GoogleFonts.darkerGrotesque(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w800,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
_StatusBadge("${item["statut"]}"),
|
||
],
|
||
),
|
||
Text(
|
||
"${item["reference"]}",
|
||
style: GoogleFonts.titilliumWeb(
|
||
fontSize: 12,
|
||
color: EnsupColors.muted,
|
||
),
|
||
),
|
||
const Divider(height: 20, color: EnsupColors.line),
|
||
_detailCompact(Icons.category_outlined, "${categorie["nom"]}"),
|
||
_detailCompact(
|
||
Icons.precision_manufacturing_outlined,
|
||
"${item["marque"] ?? "-"} ${item["modele"] ?? ""}".trim(),
|
||
),
|
||
_detailCompact(
|
||
Icons.fact_check_outlined,
|
||
"État : ${_statusLabel("${item["etatGeneral"]}")}",
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
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,
|
||
required this.description,
|
||
required this.icon,
|
||
required this.items,
|
||
required this.builder,
|
||
required this.searchableText,
|
||
this.action,
|
||
});
|
||
|
||
final String titre;
|
||
final String description;
|
||
final IconData icon;
|
||
final List<Map<String, dynamic>> items;
|
||
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
|
||
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 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: [
|
||
LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final compact = constraints.maxWidth < 620;
|
||
final title = Row(
|
||
children: [
|
||
Container(
|
||
width: 42,
|
||
height: 42,
|
||
decoration: BoxDecoration(
|
||
color: EnsupColors.cyan.withValues(alpha: 0.1),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Icon(
|
||
widget.icon,
|
||
color: EnsupColors.blue3,
|
||
size: 21,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
widget.titre,
|
||
style: GoogleFonts.darkerGrotesque(
|
||
fontSize: 29,
|
||
height: 1,
|
||
fontWeight: FontWeight.w900,
|
||
color: EnsupColors.text,
|
||
),
|
||
),
|
||
Text(
|
||
widget.description,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: GoogleFonts.titilliumWeb(
|
||
fontSize: 13,
|
||
color: EnsupColors.muted,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
if (compact && widget.action != null) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
title,
|
||
const SizedBox(height: 14),
|
||
Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: widget.action!,
|
||
),
|
||
],
|
||
);
|
||
}
|
||
return Row(
|
||
children: [
|
||
Expanded(child: title),
|
||
if (widget.action != null) ...[
|
||
const SizedBox(width: 16),
|
||
widget.action!,
|
||
],
|
||
],
|
||
);
|
||
},
|
||
),
|
||
const SizedBox(height: 22),
|
||
Container(
|
||
height: 46,
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: EnsupColors.line),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: EnsupColors.text.withValues(alpha: 0.035),
|
||
blurRadius: 8,
|
||
offset: const Offset(0, 2),
|
||
),
|
||
],
|
||
),
|
||
child: TextField(
|
||
onChanged: (value) => setState(() => _recherche = value),
|
||
decoration: InputDecoration(
|
||
hintText:
|
||
"Rechercher dans ${widget.titre.toLowerCase()}...",
|
||
prefixIcon: const Icon(
|
||
Icons.search_rounded,
|
||
size: 20,
|
||
color: EnsupColors.muted,
|
||
),
|
||
suffixIcon: Padding(
|
||
padding: const EdgeInsets.only(right: 10),
|
||
child: Center(
|
||
widthFactor: 1,
|
||
child: Text(
|
||
"${resultats.length} résultat${resultats.length > 1 ? "s" : ""}",
|
||
style: GoogleFonts.titilliumWeb(
|
||
fontSize: 11,
|
||
color: EnsupColors.muted,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
isDense: true,
|
||
border: InputBorder.none,
|
||
contentPadding: const EdgeInsets.symmetric(
|
||
vertical: 13,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 14),
|
||
Expanded(
|
||
child: resultats.isEmpty
|
||
? const _EmptyState()
|
||
: ListView.separated(
|
||
itemCount: resultats.length,
|
||
separatorBuilder: (_, _) =>
|
||
const SizedBox(height: 8),
|
||
itemBuilder: (_, index) =>
|
||
widget.builder(resultats[index]),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _InfoTile extends StatelessWidget {
|
||
const _InfoTile({
|
||
required this.icon,
|
||
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) {
|
||
return Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
border: Border.all(color: EnsupColors.line),
|
||
borderRadius: BorderRadius.circular(8),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: EnsupColors.text.withValues(alpha: 0.035),
|
||
blurRadius: 10,
|
||
offset: const Offset(0, 3),
|
||
),
|
||
],
|
||
),
|
||
child: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final compact = constraints.maxWidth < 620;
|
||
final information = Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Container(
|
||
width: 40,
|
||
height: 40,
|
||
decoration: BoxDecoration(
|
||
color: EnsupColors.teal.withValues(alpha: 0.09),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Icon(icon, size: 20, color: EnsupColors.teal),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
title,
|
||
maxLines: compact ? 2 : 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: GoogleFonts.darkerGrotesque(
|
||
fontSize: 19,
|
||
height: 1.05,
|
||
fontWeight: FontWeight.w800,
|
||
color: EnsupColors.text,
|
||
),
|
||
),
|
||
const SizedBox(height: 3),
|
||
Text(
|
||
subtitle,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: GoogleFonts.titilliumWeb(
|
||
fontSize: 13,
|
||
color: EnsupColors.text2,
|
||
),
|
||
),
|
||
if (meta != null) ...[
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
meta!,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: GoogleFonts.titilliumWeb(
|
||
fontSize: 11,
|
||
color: EnsupColors.muted,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
|
||
if (compact) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
information,
|
||
const SizedBox(height: 12),
|
||
Align(alignment: Alignment.centerRight, child: trailing),
|
||
],
|
||
);
|
||
}
|
||
|
||
return Row(
|
||
children: [
|
||
Expanded(child: information),
|
||
const SizedBox(width: 16),
|
||
trailing,
|
||
],
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _AnomalieAction extends StatelessWidget {
|
||
const _AnomalieAction({
|
||
required this.statut,
|
||
required this.prochainStatut,
|
||
required this.disabled,
|
||
required this.onPressed,
|
||
});
|
||
|
||
final String statut;
|
||
final String? prochainStatut;
|
||
final bool disabled;
|
||
final VoidCallback onPressed;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final prochain = prochainStatut;
|
||
return Wrap(
|
||
spacing: 8,
|
||
runSpacing: 8,
|
||
crossAxisAlignment: WrapCrossAlignment.center,
|
||
alignment: WrapAlignment.end,
|
||
children: [
|
||
_StatusBadge(statut),
|
||
if (prochain != null)
|
||
FilledButton.icon(
|
||
onPressed: disabled ? null : onPressed,
|
||
icon: const Icon(Icons.arrow_forward_rounded, size: 16),
|
||
label: Text(_statusLabel(prochain)),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _StatusBadge extends StatelessWidget {
|
||
const _StatusBadge(this.statut);
|
||
|
||
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: color.withValues(alpha: 0.1),
|
||
borderRadius: BorderRadius.circular(999),
|
||
border: Border.all(color: color.withValues(alpha: 0.18)),
|
||
),
|
||
child: Text(
|
||
_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),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _Erreur extends StatelessWidget {
|
||
const _Erreur({required this.message, required this.onRetry});
|
||
|
||
final String message;
|
||
final VoidCallback onRetry;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(message, textAlign: TextAlign.center),
|
||
const SizedBox(height: 12),
|
||
OutlinedButton.icon(
|
||
onPressed: onRetry,
|
||
icon: const Icon(Icons.refresh),
|
||
label: const Text("Réessayer"),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|