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
@@ -9,8 +9,6 @@ import "../widgets/brand_corners.dart";
import "../widgets/etat_checklist.dart";
import "confirmation_screen.dart";
/// Checklist de départ (RG11) : l'étudiant indique l'état de chaque accessoire
/// attendu avant de confirmer l'emprunt.
class ChecklistDepartScreen extends StatefulWidget {
const ChecklistDepartScreen({super.key, required this.item});
@@ -31,6 +29,8 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
final TextEditingController _commentaireController = TextEditingController();
bool _envoiEnCours = false;
bool get _ecartDeclare => _etats.any((etat) => etat != EtatChecklist.present);
@override
void dispose() {
_commentaireController.dispose();
@@ -240,12 +240,45 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ChecklistInstructions(
titre: "Instructions",
titre: "État de référence",
texte:
"Vérifiez physiquement chaque élément du kit et indiquez son état : "
"Présent, Absent ou Détérioré. Signalez tout élément manquant ou "
"endommagé pour éviter tout litige au retour.",
"Tous les éléments sont préremplis comme présents. Vérifiez-les "
"physiquement et modifiez uniquement ceux qui sont absents ou détériorés.",
),
if (_ecartDeclare) ...[
const SizedBox(height: 14),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFFFFF7ED),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFFED7AA)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.warning_amber_rounded,
color: Color(0xFFD97706),
size: 20,
),
const SizedBox(width: 10),
Expanded(
child: Text(
"Cet écart sera transmis au responsable. L'emprunt restera "
"en attente jusqu'à sa confirmation.",
style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.text2,
height: 1.4,
),
),
),
],
),
),
],
const SizedBox(height: 14),
ChecklistCommentaire(controller: _commentaireController),
const SizedBox(height: 14),
@@ -266,7 +299,9 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
label: Text(
_envoiEnCours
? "Enregistrement..."
: "Valider la checklist et confirmer l'emprunt",
: _ecartDeclare
? "Signaler l'écart"
: "Confirmer l'emprunt",
textAlign: TextAlign.center,
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w700,
@@ -4,14 +4,13 @@ import "../demo_identity.dart";
import "../theme/ensup_colors.dart";
import "../models/emprunt_item.dart";
import "../models/anomalie_retour.dart";
import "../models/checklist_element_item.dart";
import "../services/emprunt_service.dart";
import "../widgets/ensup_top_bar.dart";
import "../widgets/brand_corners.dart";
import "../widgets/etat_checklist.dart";
import "resultat_retour_screen.dart";
/// Checklist de retour (RG16) : l'étudiant indique l'état de chaque accessoire
/// au retour, en vue de la comparaison avec la checklist de départ.
class ChecklistRetourScreen extends StatefulWidget {
const ChecklistRetourScreen({super.key, required this.emprunt});
@@ -22,16 +21,41 @@ class ChecklistRetourScreen extends StatefulWidget {
}
class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
late final List<String> _elements =
widget.emprunt.materiel.accessoires.isEmpty
? <String>[widget.emprunt.materiel.nom]
: widget.emprunt.materiel.accessoires;
late final List<EtatChecklist> _etats = List<EtatChecklist>.filled(
_elements.length,
EtatChecklist.present,
late final List<ChecklistElementItem> _checklistDepart =
widget.emprunt.checklistDepart.isNotEmpty
? widget.emprunt.checklistDepart
: (widget.emprunt.materiel.accessoires.isEmpty
? [
ChecklistElementItem(
accessoireId: null,
nom: widget.emprunt.materiel.nom,
etat: "PRESENT",
quantite: 1,
),
]
: widget.emprunt.materiel.accessoires
.map(
(nom) => ChecklistElementItem(
accessoireId: null,
nom: nom,
etat: "PRESENT",
quantite: 1,
),
)
.toList());
late final List<String> _elements = _checklistDepart
.map((element) => element.nom)
.toList();
late final List<EtatChecklist> _etatsInitiales = _checklistDepart
.map((element) => _etatDepuisApi(element.etat))
.toList();
late final List<EtatChecklist> _etats = List<EtatChecklist>.from(
_etatsInitiales,
);
final TextEditingController _commentaireController = TextEditingController();
bool _envoiEnCours = false;
bool _alerteEnCours = false;
bool _alerteEnvoyee = false;
@override
void dispose() {
@@ -44,15 +68,12 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
final anomalies = <AnomalieRetour>[];
for (var i = 0; i < _etats.length; i++) {
if (_etats[i] == EtatChecklist.absent) {
anomalies.add(
AnomalieRetour(element: _elements[i], etatRetour: "Absent au retour"),
);
} else if (_etats[i] == EtatChecklist.deteriore) {
if (_etats[i] != _etatsInitiales[i]) {
anomalies.add(
AnomalieRetour(
element: _elements[i],
etatRetour: "Détérioré au retour",
etatRetour:
"${_etatLabel(_etatsInitiales[i])}${_etatLabel(_etats[i])}",
),
);
}
@@ -63,12 +84,13 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
empruntId: widget.emprunt.id,
commentaireRetour: _commentaireController.text,
elements: _elements.asMap().entries.map((entry) {
final elementDepart = _checklistDepart[entry.key];
return {
if (elementDepart.accessoireId != null)
"accessoireId": elementDepart.accessoireId,
"nomElement": entry.value,
"etat": _etatApi(_etats[entry.key]),
"quantiteConstatee": _etats[entry.key] == EtatChecklist.absent
? 0
: 1,
"quantiteConstatee": _quantiteConstatee(entry.key),
};
}).toList(),
);
@@ -102,6 +124,60 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
}
}
Future<void> _changerEtat(int index, EtatChecklist etat) async {
setState(() => _etats[index] = etat);
if (_alerteEnvoyee || etat == _etatsInitiales[index]) {
return;
}
setState(() {
_alerteEnvoyee = true;
_alerteEnCours = true;
});
try {
await EmpruntService.signalerTentativeRetour(
empruntId: widget.emprunt.id,
nomElement: _elements[index],
etatInitial: _etatApi(_etatsInitiales[index]),
etatDemande: _etatApi(etat),
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"Le responsable a été alerté de cette tentative de modification.",
),
),
);
} catch (error) {
if (!mounted) return;
setState(() => _alerteEnvoyee = false);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
} finally {
if (mounted) {
setState(() => _alerteEnCours = false);
}
}
}
int _quantiteConstatee(int index) {
if (_etats[index] == EtatChecklist.absent) {
return 0;
}
final quantiteDepart = _checklistDepart[index].quantite;
return quantiteDepart > 0 ? quantiteDepart : 1;
}
static EtatChecklist _etatDepuisApi(String etat) {
return switch (etat) {
"ABSENT" => EtatChecklist.absent,
"DETERIORE" => EtatChecklist.deteriore,
_ => EtatChecklist.present,
};
}
String _etatApi(EtatChecklist etat) {
switch (etat) {
case EtatChecklist.present:
@@ -113,6 +189,14 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
}
}
String _etatLabel(EtatChecklist etat) {
return switch (etat) {
EtatChecklist.present => "Présent",
EtatChecklist.absent => "Absent",
EtatChecklist.deteriore => "Détérioré",
};
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -242,7 +326,7 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
ChecklistTableau(
accessoires: _elements,
etats: _etats,
onChanged: (index, etat) => setState(() => _etats[index] = etat),
onChanged: _changerEtat,
),
],
);
@@ -252,11 +336,12 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ChecklistInstructions(
titre: "Rappel checklist de départ",
ChecklistInstructions(
titre: "État validé au départ",
texte:
"Au départ, tous les accessoires du kit étaient présents. "
"Indiquez leur état au retour ; toute différence sera signalée.",
"La restitution reprend automatiquement l'état validé au départ : "
"${_checklistDepart.map((element) => "${element.nom} ${_etatLabel(_etatDepuisApi(element.etat)).toLowerCase()}").join(" · ")}. "
"Toute modification alerte immédiatement un responsable.",
),
const SizedBox(height: 14),
ChecklistCommentaire(
@@ -267,8 +352,8 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _envoiEnCours ? null : _valider,
icon: _envoiEnCours
onPressed: _envoiEnCours || _alerteEnCours ? null : _valider,
icon: _envoiEnCours || _alerteEnCours
? const SizedBox(
width: 18,
height: 18,
@@ -279,7 +364,11 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
)
: const Icon(Icons.check, size: 18),
label: Text(
_envoiEnCours ? "Enregistrement..." : "Valider le retour",
_alerteEnCours
? "Alerte en cours..."
: _envoiEnCours
? "Enregistrement..."
: "Valider le retour",
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w700,
fontSize: 16,
@@ -7,7 +7,6 @@ import "../models/materiel_item.dart";
import "../widgets/ensup_top_bar.dart";
import "../widgets/brand_corners.dart";
/// Écran de succès clôturant l'emprunt : confirmation + récapitulatif.
class ConfirmationScreen extends StatelessWidget {
const ConfirmationScreen({super.key, required this.emprunt, this.note});
@@ -15,6 +14,7 @@ class ConfirmationScreen extends StatelessWidget {
final String? note;
MaterielItem get item => emprunt.materiel;
bool get _enAttente => emprunt.statut == "EN_ATTENTE_VALIDATION_DEPART";
static String _deuxChiffres(int valeur) => valeur.toString().padLeft(2, "0");
@@ -127,15 +127,23 @@ class ConfirmationScreen extends StatelessWidget {
}
Widget _boiteSucces() {
final couleur = _enAttente
? const Color(0xFFD97706)
: const Color(0xFF15803D);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(36),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFFF0FDF4), Color(0xFFECFDF5)],
gradient: LinearGradient(
colors: _enAttente
? const [Color(0xFFFFF7ED), Color(0xFFFFFBEB)]
: const [Color(0xFFF0FDF4), Color(0xFFECFDF5)],
),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFBBF7D0), width: 2),
border: Border.all(
color: _enAttente ? const Color(0xFFFED7AA) : const Color(0xFFBBF7D0),
width: 2,
),
),
child: Column(
children: [
@@ -144,12 +152,14 @@ class ConfirmationScreen extends StatelessWidget {
height: 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(
colors: [Color(0xFF16A34A), Color(0xFF22C55E)],
gradient: LinearGradient(
colors: _enAttente
? const [Color(0xFFD97706), Color(0xFFF59E0B)]
: const [Color(0xFF16A34A), Color(0xFF22C55E)],
),
boxShadow: [
BoxShadow(
color: const Color(0xFF16A34A).withValues(alpha: 0.3),
color: couleur.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 6),
),
@@ -159,20 +169,24 @@ class ConfirmationScreen extends StatelessWidget {
),
const SizedBox(height: 18),
Text(
"Emprunt enregistré !",
_enAttente ? "Écart transmis" : "Emprunt enregistré !",
style: GoogleFonts.darkerGrotesque(
fontSize: 28,
fontWeight: FontWeight.w900,
color: const Color(0xFF15803D),
color: couleur,
),
),
const SizedBox(height: 8),
Text(
"Votre emprunt a bien été enregistré. Vous pouvez récupérer le matériel.",
_enAttente
? "Le matériel est réservé. Attendez la confirmation du responsable avant de le récupérer."
: "Votre emprunt est actif. Vous pouvez récupérer le matériel.",
textAlign: TextAlign.center,
style: GoogleFonts.titilliumWeb(
fontSize: 14,
color: const Color(0xFF166534),
color: _enAttente
? const Color(0xFF9A3412)
: const Color(0xFF166534),
),
),
],
@@ -211,6 +225,11 @@ class ConfirmationScreen extends StatelessWidget {
_rrow("Date d'emprunt", date),
_rrow("Heure", heure),
_rrow("Campus", item.campus),
_rrow(
"Statut",
_enAttente ? "En attente de confirmation" : "En cours",
valueColor: _enAttente ? const Color(0xFFD97706) : null,
),
if (note != null)
_rrow("Note", note, valueColor: const Color(0xFFD97706)),
],
@@ -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(
@@ -7,8 +7,6 @@ import "../models/anomalie_retour.dart";
import "../widgets/ensup_top_bar.dart";
import "../widgets/brand_corners.dart";
/// Résultat de la restitution (RG17-RG20) : conforme (succès) ou non conforme
/// (anomalie détectée + notification au responsable).
class ResultatRetourScreen extends StatelessWidget {
const ResultatRetourScreen({
super.key,
@@ -19,7 +17,8 @@ class ResultatRetourScreen extends StatelessWidget {
final EmpruntItem emprunt;
final List<AnomalieRetour> anomalies;
bool get _conforme => anomalies.isEmpty;
bool get _conforme => emprunt.statut == "CLOTURE";
bool get _enAttente => emprunt.statut == "EN_ATTENTE_VALIDATION_RETOUR";
static String _deux(int valeur) => valeur.toString().padLeft(2, "0");
@@ -27,6 +26,8 @@ class ResultatRetourScreen extends StatelessWidget {
Widget build(BuildContext context) {
final fond = _conforme
? const [Color(0xFFF0FDF4), Color(0xFFF8FAFC)]
: _enAttente
? const [Color(0xFFFFF7ED), Color(0xFFF8FAFC)]
: const [Color(0xFFFFF5F5), Color(0xFFF8FAFC)];
return Scaffold(
@@ -75,7 +76,12 @@ class ResultatRetourScreen extends StatelessWidget {
constraints: const BoxConstraints(maxWidth: 560),
child: Column(
children: [
if (_conforme) _succes() else _anomalie(),
if (_conforme)
_succes()
else if (_enAttente)
_attente()
else
_anomalie(),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
@@ -280,6 +286,81 @@ class ResultatRetourScreen extends StatelessWidget {
);
}
Widget _attente() {
return Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(22),
decoration: BoxDecoration(
color: const Color(0xFFFFF7ED),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFFED7AA), width: 1.5),
),
child: Column(
children: [
const Icon(
Icons.pending_actions_outlined,
color: Color(0xFFD97706),
size: 42,
),
const SizedBox(height: 12),
Text(
"Changement en attente",
style: GoogleFonts.darkerGrotesque(
fontSize: 26,
fontWeight: FontWeight.w900,
color: const Color(0xFF9A3412),
),
),
const SizedBox(height: 8),
Text(
"Le responsable a été alerté. Le changement reste provisoire "
"et aucune anomalie n'est créée avant sa confirmation.",
textAlign: TextAlign.center,
style: GoogleFonts.titilliumWeb(
fontSize: 14,
color: EnsupColors.text2,
height: 1.5,
),
),
],
),
),
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: EnsupColors.line),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"MODIFICATIONS DÉCLARÉES",
style: GoogleFonts.darkerGrotesque(
fontSize: 12,
fontWeight: FontWeight.w700,
color: EnsupColors.muted,
),
),
const SizedBox(height: 8),
for (final anomalie in anomalies)
_drow(
anomalie.element,
anomalie.etatRetour,
valeurCouleur: const Color(0xFFD97706),
),
],
),
),
],
);
}
Widget _drow(String cle, String valeur, {Color? valeurCouleur}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 7),