feat(loans): confirm declared material state changes

This commit is contained in:
SaidSoighiri94
2026-07-24 13:41:40 +02:00
parent 3739e9b0a0
commit a562b3248f
26 changed files with 1832 additions and 282 deletions
@@ -20,10 +20,12 @@ class _ResponsableScreenState extends State<ResponsableScreen> {
late Future<_ResponsableData> _future = _charger();
int _onglet = 0;
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();
final materiels = await ResponsableService.materiels();
final anomalies = await ResponsableService.anomalies();
@@ -32,6 +34,7 @@ class _ResponsableScreenState extends State<ResponsableScreen> {
return _ResponsableData(
dashboard: dashboard,
ecarts: ecarts,
emprunts: emprunts,
materiels: materiels,
anomalies: anomalies,
@@ -83,6 +86,48 @@ class _ResponsableScreenState extends State<ResponsableScreen> {
}
}
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;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
decision.decision == "CONFIRMER"
? "Le changement a été confirmé et l'anomalie a été créée."
: "Le changement a été refusé.",
),
),
);
_rafraichir();
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
} finally {
if (mounted) {
setState(() => _decisionEnCours = false);
}
}
}
Future<void> _marquerNotificationsLues() async {
try {
await ResponsableService.marquerNotificationsLues();
@@ -169,6 +214,7 @@ class _ResponsableScreenState extends State<ResponsableScreen> {
_statutCount(anomalies, "DETECTEE") +
_statutCount(anomalies, "EN_COURS_TRAITEMENT");
final notificationsNonLues = kpis["notificationsNonLues"] as int? ?? 0;
final ecartsEnAttente = data.ecarts.length;
final vues = [
_VueDashboard(
@@ -176,6 +222,19 @@ class _ResponsableScreenState extends State<ResponsableScreen> {
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"]}";
},
),
_VueListe(
titre: "Emprunts",
description:
@@ -257,6 +316,7 @@ class _ResponsableScreenState extends State<ResponsableScreen> {
if (!compact)
_ResponsableSidebar(
index: _onglet,
ecartsEnAttente: ecartsEnAttente,
anomaliesActives: anomaliesActives,
notificationsNonLues: notificationsNonLues,
onChanged: (index) => setState(() => _onglet = index),
@@ -293,6 +353,28 @@ class _ResponsableScreenState extends State<ResponsableScreen> {
);
}
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 _materielTile(Map<String, dynamic> item) {
final categorie = Map<String, dynamic>.from(item["categorie"] as Map);
return _InfoTile(
@@ -358,13 +440,16 @@ class _ResponsableScreenState extends State<ResponsableScreen> {
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é",
@@ -374,6 +459,8 @@ String _statusLabel(String statut) {
"CLOTUREE" => "Clôturée",
"LUE" => "Lue",
"NON_LUE" => "Non lue",
"PRESENT" => "Présent",
"ABSENT" => "Absent",
_ => statut.replaceAll("_", " ").toLowerCase(),
};
}
@@ -383,6 +470,15 @@ String _actionLabel(String 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(),
};
}
@@ -400,11 +496,369 @@ Color _statusColor(String statut) {
"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);
@@ -622,6 +1076,7 @@ class _ResponsableTopBar extends StatelessWidget {
class _ResponsableSidebar extends StatelessWidget {
const _ResponsableSidebar({
required this.index,
required this.ecartsEnAttente,
required this.anomaliesActives,
required this.notificationsNonLues,
required this.onChanged,
@@ -629,6 +1084,7 @@ class _ResponsableSidebar extends StatelessWidget {
});
final int index;
final int ecartsEnAttente;
final int anomaliesActives;
final int notificationsNonLues;
final ValueChanged<int> onChanged;
@@ -636,6 +1092,7 @@ class _ResponsableSidebar extends StatelessWidget {
static const _destinations = [
("Vue densemble", 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),
@@ -665,8 +1122,9 @@ class _ResponsableSidebar extends StatelessWidget {
...List.generate(_destinations.length, (itemIndex) {
final destination = _destinations[itemIndex];
final badge = switch (itemIndex) {
3 => anomaliesActives,
4 => notificationsNonLues,
1 => ecartsEnAttente,
4 => anomaliesActives,
5 => notificationsNonLues,
_ => 0,
};
return _SidebarItem(
@@ -674,7 +1132,7 @@ class _ResponsableSidebar extends StatelessWidget {
icon: destination.$2,
selected: itemIndex == index,
badge: badge,
alert: itemIndex == 3,
alert: itemIndex == 1 || itemIndex == 4,
onTap: () => onChanged(itemIndex),
);
}),
@@ -839,6 +1297,7 @@ class _NavigationCompacte extends StatelessWidget {
static const _destinations = [
("Vue densemble", Icons.space_dashboard_outlined),
("Écarts", Icons.fact_check_outlined),
("Emprunts", Icons.assignment_outlined),
("Stock", Icons.inventory_2_outlined),
("Anomalies", Icons.report_problem_outlined),
@@ -917,6 +1376,7 @@ class _ChargementWorkspace extends StatelessWidget {
class _ResponsableData {
const _ResponsableData({
required this.dashboard,
required this.ecarts,
required this.emprunts,
required this.materiels,
required this.anomalies,
@@ -925,6 +1385,7 @@ class _ResponsableData {
});
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;
@@ -962,6 +1423,7 @@ class _VueDashboard extends StatelessWidget {
_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",
@@ -1043,9 +1505,10 @@ class _VueDashboard extends StatelessWidget {
builder: (context, constraints) {
final activity = _ActivityPanel(
items: activite,
onSeeAll: () => onNavigate(5),
onSeeAll: () => onNavigate(6),
);
final priorities = _PrioritiesPanel(
ecarts: ecartsEnAttente,
retards: retards,
anomalies: anomaliesActives,
notifications: notificationsNonLues,
@@ -1334,6 +1797,7 @@ class _ActivityRow extends StatelessWidget {
class _PrioritiesPanel extends StatelessWidget {
const _PrioritiesPanel({
required this.ecarts,
required this.retards,
required this.anomalies,
required this.notifications,
@@ -1343,6 +1807,7 @@ class _PrioritiesPanel extends StatelessWidget {
required this.onNavigate,
});
final int ecarts;
final int retards;
final int anomalies;
final int notifications;
@@ -1361,12 +1826,20 @@ class _PrioritiesPanel extends StatelessWidget {
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(1),
onTap: () => onNavigate(2),
),
const Divider(height: 1, color: EnsupColors.line),
_PriorityRow(
@@ -1374,7 +1847,7 @@ class _PrioritiesPanel extends StatelessWidget {
value: anomalies,
icon: Icons.report_problem_outlined,
color: EnsupColors.red,
onTap: () => onNavigate(3),
onTap: () => onNavigate(4),
),
const Divider(height: 1, color: EnsupColors.line),
_PriorityRow(
@@ -1382,7 +1855,7 @@ class _PrioritiesPanel extends StatelessWidget {
value: notifications,
icon: Icons.notifications_none_outlined,
color: EnsupColors.purple,
onTap: () => onNavigate(4),
onTap: () => onNavigate(5),
),
const Divider(height: 1, color: EnsupColors.line),
_PriorityRow(
@@ -1390,7 +1863,7 @@ class _PrioritiesPanel extends StatelessWidget {
value: indisponibles,
icon: Icons.build_outlined,
color: EnsupColors.teal,
onTap: () => onNavigate(2),
onTap: () => onNavigate(3),
),
const SizedBox(height: 20),
Row(