feat(loans): confirm declared material state changes
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
EME est une application web de gestion des emprunts de matériel pédagogique pour ENSUP / Ensitech.
|
||||
|
||||
La V1 couvre le parcours étudiant : consulter le catalogue, emprunter un matériel, restituer un emprunt et détecter automatiquement les retours non conformes.
|
||||
La V1 cible un parcours contrôlé : les checklists sont préremplies et les opérations sans écart restent automatiques. Toute modification déclarée par l'étudiant alerte un responsable, qui doit la confirmer avant la création d'une anomalie ou la mise à jour de l'état officiel du matériel.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -158,19 +158,21 @@ Application :
|
||||
http://localhost:5000
|
||||
```
|
||||
|
||||
## Parcours de démo V1
|
||||
## Parcours cible V1
|
||||
|
||||
1. Ouvrir `http://localhost:5000`.
|
||||
2. S'identifier avec l'option mail ENSUP ou carte étudiante.
|
||||
3. Cliquer sur `Emprunter`.
|
||||
4. Sélectionner un matériel disponible.
|
||||
5. Valider la checklist de départ.
|
||||
6. Vérifier la confirmation de l'emprunt.
|
||||
7. Revenir à l'accueil.
|
||||
8. Cliquer sur `Restituer`.
|
||||
9. Sélectionner un emprunt en cours.
|
||||
10. Valider un retour conforme ou indiquer un élément absent/détérioré.
|
||||
11. Vérifier le résultat conforme ou non conforme.
|
||||
5. Vérifier la checklist de départ préremplie avec l'état de référence.
|
||||
6. Sans modification, l'emprunt démarre automatiquement.
|
||||
7. Si un élément est modifié, le responsable est alerté et confirme ou refuse cet écart avant le départ.
|
||||
8. Au retour, vérifier la checklist automatiquement préremplie avec l'état de départ validé.
|
||||
9. Sans modification, la restitution est clôturée automatiquement.
|
||||
10. Dès qu'un état est modifié, le responsable est alerté et doit confirmer le changement avant l'anomalie et l'état final du matériel.
|
||||
11. La catégorie redevient empruntable après la clôture automatique ou la décision du responsable sur un retour modifié.
|
||||
|
||||
Le code actuel gère déjà les parcours automatiques. Il doit encore être adapté au préremplissage de la restitution, à la détection des modifications et à leur confirmation par le responsable ; les tâches sont détaillées dans `TODO.md`.
|
||||
|
||||
## Endpoints principaux
|
||||
|
||||
@@ -212,20 +214,22 @@ Note : dans l'environnement Codex, `flutter analyze` et `dart format` ont déjà
|
||||
|
||||
## État V1
|
||||
|
||||
Terminé pour la V1 étudiant :
|
||||
Déjà implémenté :
|
||||
|
||||
- catalogue matériel ;
|
||||
- détail matériel ;
|
||||
- emprunt avec checklist de départ ;
|
||||
- restitution avec checklist de retour ;
|
||||
- détection automatique de retour non conforme ;
|
||||
- fixtures de démonstration ;
|
||||
- tests runtime manuels du parcours étudiant.
|
||||
- catalogue et détail matériel ;
|
||||
- checklists de départ et de retour ;
|
||||
- espaces étudiant et responsable ;
|
||||
- supervision, notifications, anomalies et historique ;
|
||||
- SSO Microsoft Entra ID prêt à configurer, avec mode démo sécurisé ;
|
||||
- fixtures de démonstration.
|
||||
|
||||
Reste à faire :
|
||||
|
||||
- authentification Azure AD réelle ;
|
||||
- parcours responsable matériel ;
|
||||
- préremplir la restitution avec l'état validé au départ ;
|
||||
- bloquer les emprunts de même catégorie jusqu'à la clôture du retour ;
|
||||
- mettre en attente uniquement les checklists modifiées et alerter le responsable ;
|
||||
- ajouter les écrans responsable de confirmation des écarts ;
|
||||
- valider le SSO avec les identifiants réels du tenant ENSUP ;
|
||||
- OpenAPI / Swagger ;
|
||||
- tests automatisés ;
|
||||
- documentation utilisateur complète ;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Parcours utilisateur validé
|
||||
|
||||
Ce diagramme remplace le PNG historique `parcour_utilisateur.png`, qui décrit
|
||||
partiellement l'ancien workflow automatique.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Identification étudiant] --> B{Action}
|
||||
|
||||
B -->|Emprunter| C[Sélection du matériel]
|
||||
C --> D{Même catégorie déjà bloquée ?}
|
||||
D -->|Oui| E[Demande refusée]
|
||||
D -->|Non| F[Checklist de départ<br/>préremplie avec l'état de référence]
|
||||
F --> G{État modifié ?}
|
||||
G -->|Non| H[Matériel EMPRUNTE<br/>Emprunt EN_COURS]
|
||||
G -->|Oui| I[Matériel RESERVE<br/>Emprunt EN_ATTENTE_VALIDATION_DEPART]
|
||||
I --> J[Alerte responsable du campus]
|
||||
J --> K{Confirmation de l'écart}
|
||||
K -->|Confirmé| L[Anomalie créée<br/>État de départ authentifié]
|
||||
K -->|Refusé| M[Modification non appliquée]
|
||||
L --> H
|
||||
M --> H
|
||||
|
||||
B -->|Restituer| N[Sélection d'un emprunt personnel]
|
||||
N --> O[Checklist de retour préremplie<br/>avec l'état de départ validé]
|
||||
O --> P{État modifié ?}
|
||||
P -->|Non| Q[Emprunt CLOTURE<br/>Matériel DISPONIBLE]
|
||||
P -->|Oui| R[Emprunt EN_ATTENTE_VALIDATION_RETOUR<br/>Alerte responsable]
|
||||
R --> S{Confirmation du changement}
|
||||
S -->|Confirmé| T[Anomalie créée<br/>RETOUR_NON_CONFORME et état final]
|
||||
S -->|Refusé| Q
|
||||
|
||||
Q --> U[Catégorie débloquée]
|
||||
T --> U
|
||||
```
|
||||
|
||||
## Règles structurantes
|
||||
|
||||
- Le départ et le retour sont automatiques lorsque la checklist préremplie
|
||||
reste inchangée.
|
||||
- Au retour, l'état validé au départ est automatiquement repris.
|
||||
- Dès que l'étudiant modifie une valeur, le responsable est alerté et doit
|
||||
confirmer ou refuser le changement.
|
||||
- Une anomalie et un changement d'état officiel ne sont appliqués qu'après
|
||||
confirmation du responsable.
|
||||
- Une catégorie reste bloquée pour l'étudiant tant que le retour précédent
|
||||
n'a pas été clôturé.
|
||||
- Il n'existe pas de limite d'une fois par jour après clôture du retour.
|
||||
- Chaque tentative de modification, décision, anomalie et changement de
|
||||
statut matériel est tracé.
|
||||
@@ -96,9 +96,13 @@ classDiagram
|
||||
+ModeIdentification modeIdentificationRetour
|
||||
+String commentaireDepart
|
||||
+String commentaireRetour
|
||||
+creer()
|
||||
+enregistrerRetour()
|
||||
+cloturer()
|
||||
+confirmerDepartInchange()
|
||||
+soumettreEcartDepart()
|
||||
+confirmerEcartDepart()
|
||||
+initialiserRetourDepuisDepart()
|
||||
+confirmerRetourInchange()
|
||||
+soumettreEcartRetour()
|
||||
+confirmerEcartRetour()
|
||||
+marquerEnRetard()
|
||||
}
|
||||
|
||||
@@ -152,6 +156,7 @@ classDiagram
|
||||
class StatutMateriel {
|
||||
<<enumeration>>
|
||||
DISPONIBLE
|
||||
RESERVE
|
||||
EMPRUNTE
|
||||
NON_CONFORME
|
||||
DETERIORE
|
||||
@@ -161,8 +166,10 @@ classDiagram
|
||||
|
||||
class StatutEmprunt {
|
||||
<<enumeration>>
|
||||
EN_ATTENTE_VALIDATION_DEPART
|
||||
EN_COURS
|
||||
EN_RETARD
|
||||
EN_ATTENTE_VALIDATION_RETOUR
|
||||
CLOTURE
|
||||
RETOUR_NON_CONFORME
|
||||
ANNULE
|
||||
@@ -237,4 +244,4 @@ classDiagram
|
||||
|
||||
Anomalie "0..1" --> "0..*" Notification : genere
|
||||
|
||||
```
|
||||
```
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Règles métier — EME
|
||||
## Nouveau workflow d'emprunt et de restitution autonome
|
||||
## Workflow automatique avec confirmation responsable des écarts
|
||||
|
||||
Les règles métier définissent les contraintes fonctionnelles que l'application EME — Emprunt Matériel ENSUP doit respecter dans le cadre du nouveau workflow validé.
|
||||
|
||||
@@ -36,7 +36,7 @@ Chaque emprunt et restitution doit être associé au poste dédié, à la salle
|
||||
Chaque matériel doit être identifié de manière unique par une référence, un numéro d'inventaire ou un numéro de série, et rattaché à une catégorie, un campus et un statut.
|
||||
|
||||
**RG07 — Disponibilité du matériel**
|
||||
Seuls les matériels ayant le statut `DISPONIBLE` peuvent être empruntés. Un matériel emprunté, non conforme, détérioré, en maintenance ou indisponible ne peut pas faire l'objet d'un nouvel emprunt.
|
||||
Seuls les matériels ayant le statut `DISPONIBLE` peuvent faire l'objet d'un emprunt. Sans écart déclaré au départ, le matériel passe directement à `EMPRUNTE`. Si l'étudiant modifie l'état prérempli d'un élément, le matériel passe à `RESERVE` jusqu'à la décision du responsable. Un matériel réservé, emprunté, non conforme, détérioré, en maintenance ou indisponible ne peut pas faire l'objet d'un autre emprunt.
|
||||
|
||||
**RG08 — Accessoires attendus**
|
||||
Chaque matériel peut être associé à une liste d'accessoires attendus (chargeur, souris, câble, housse, etc.).
|
||||
@@ -51,11 +51,17 @@ L'étudiant doit choisir l'action `Emprunter` depuis l'écran d'accueil, puis s'
|
||||
**RG10 — Affichage des matériels disponibles**
|
||||
Après identification, le système affiche uniquement les matériels disponibles du campus concerné.
|
||||
|
||||
**RG11 — Checklist de départ obligatoire**
|
||||
Une checklist de départ doit être obligatoirement renseignée avant la création de l'emprunt. Elle reprend les accessoires attendus du matériel sélectionné. L'étudiant indique pour chaque élément son état : `PRESENT`, `ABSENT` ou `DETERIORE`.
|
||||
**RG10A — Unicité d'emprunt actif par catégorie**
|
||||
Un étudiant ne peut pas demander un matériel d'une catégorie s'il possède déjà un emprunt bloquant de cette même catégorie. Sont bloquants les emprunts `EN_ATTENTE_VALIDATION_DEPART`, `EN_COURS`, `EN_RETARD` et `EN_ATTENTE_VALIDATION_RETOUR`. La catégorie redevient empruntable après la clôture automatique d'un retour sans écart ou après la décision du responsable sur un retour modifié. Il n'existe pas de limite calendaire : l'étudiant peut emprunter à nouveau la catégorie le même jour après cette clôture.
|
||||
|
||||
**RG12 — Création automatique de l'emprunt**
|
||||
Après validation de la checklist de départ, le système crée automatiquement l'emprunt, enregistre la date et l'heure, et fait passer le matériel au statut `EMPRUNTE`.
|
||||
**RG11 — Checklist de départ obligatoire**
|
||||
Une checklist de départ doit être obligatoirement vérifiée avant la création de l'emprunt. Elle reprend les accessoires attendus du matériel sélectionné et initialise automatiquement chaque élément dans son état de référence, normalement `PRESENT`. L'étudiant contrôle physiquement le matériel et ne modifie une valeur en `ABSENT` ou `DETERIORE` que s'il constate réellement un écart.
|
||||
|
||||
**RG12 — Départ automatique sans écart**
|
||||
Si l'étudiant confirme la checklist de départ préremplie sans la modifier, l'emprunt passe directement à `EN_COURS` et le matériel à `EMPRUNTE`. Aucune validation du responsable n'est requise pour ce départ normal.
|
||||
|
||||
**RG12A — Confirmation d'un écart au départ**
|
||||
Dès que l'étudiant modifie l'état prérempli d'un élément, le système place le départ en `EN_ATTENTE_VALIDATION_DEPART`, réserve le matériel et alerte les responsables du campus. La modification reste provisoire et ne change pas l'état officiel du matériel. Le responsable contrôle l'écart et le confirme ou le refuse. Une confirmation authentifie le nouvel état de départ, crée l'anomalie correspondante, puis permet le passage à `EN_COURS` et `EMPRUNTE`. Un refus n'applique pas l'état déclaré par l'étudiant. La décision, son auteur, sa date et son observation éventuelle sont historisés.
|
||||
|
||||
**RG13 — Détection automatique des emprunts en retard**
|
||||
Lorsqu'un emprunt dépasse sa date de retour prévue sans avoir été restitué, son statut passe automatiquement à `EN_RETARD` et il apparaît dans le tableau de bord du responsable matériel.
|
||||
@@ -71,23 +77,26 @@ L'étudiant doit choisir l'action `Restituer` depuis l'écran d'accueil, puis s'
|
||||
Après identification, le système affiche uniquement les emprunts en cours de l'étudiant identifié. Un étudiant ne peut restituer que ses propres emprunts.
|
||||
|
||||
**RG16 — Checklist de retour obligatoire**
|
||||
Une checklist de retour doit obligatoirement être renseignée. Elle reprend les mêmes éléments que la checklist de départ pour permettre la comparaison.
|
||||
Une checklist de retour doit être obligatoirement vérifiée. Elle est automatiquement préremplie avec l'état de départ validé de chaque élément. L'étudiant ne modifie une valeur que s'il constate une différence au moment de la restitution.
|
||||
|
||||
**RG17 — Comparaison automatique des checklists**
|
||||
Le système compare automatiquement la checklist de départ avec la checklist de retour pour déterminer la conformité de la restitution.
|
||||
Le système compare automatiquement la checklist de retour avec l'état de départ validé. Une checklist inchangée constitue un retour conforme. Toute modification constitue une déclaration d'écart provisoire soumise à la confirmation du responsable.
|
||||
|
||||
**RG18 — Retour conforme**
|
||||
Si la checklist de retour correspond à celle de départ, l'emprunt est clôturé et le matériel repasse au statut `DISPONIBLE`.
|
||||
**RG18 — Retour automatique sans écart**
|
||||
Si l'étudiant confirme la checklist de retour préremplie sans la modifier, le retour est clôturé automatiquement : l'emprunt passe à `CLOTURE` et le matériel à `DISPONIBLE`. Aucune validation du responsable n'est requise.
|
||||
|
||||
**RG19 — Retour non conforme**
|
||||
Si une différence est détectée, l'emprunt passe au statut `RETOUR_NON_CONFORME` et le matériel peut passer à `NON_CONFORME`, `DETERIORE`, `MAINTENANCE` ou `INDISPONIBLE` selon le problème détecté.
|
||||
**RG19 — Confirmation d'un changement au retour**
|
||||
Dès que l'étudiant tente de modifier une valeur préremplie, l'emprunt passe à `EN_ATTENTE_VALIDATION_RETOUR`, le matériel reste indisponible et une alerte est envoyée aux responsables du campus. Le changement reste provisoire : il ne modifie ni l'état officiel du matériel ni l'état de départ conservé. Après contrôle, le responsable confirme ou refuse la différence. Une confirmation crée l'anomalie et applique l'état final approprié avec le statut `RETOUR_NON_CONFORME`. Un refus conserve l'état validé au départ et permet une clôture conforme. La décision est historisée.
|
||||
|
||||
**RG19A — Déblocage de la catégorie**
|
||||
La catégorie est débloquée immédiatement après un retour automatique sans écart. Lorsqu'une différence est déclarée, elle reste bloquée jusqu'à la décision du responsable, puis est débloquée que la différence soit confirmée ou refusée.
|
||||
|
||||
---
|
||||
|
||||
## 6. Anomalies
|
||||
|
||||
**RG20 — Création automatique d'une anomalie**
|
||||
Une anomalie est créée automatiquement si un élément présent au départ est absent au retour, ou si un élément est déclaré détérioré au retour.
|
||||
**RG20 — Création d'une anomalie confirmée**
|
||||
Une anomalie est créée uniquement si le responsable confirme une différence ou une dégradation déclarée au départ ou au retour. La tentative de modification déclenche l'alerte et la demande de confirmation, mais elle ne crée pas encore une anomalie authentifiée et ne change pas l'état officiel du matériel.
|
||||
|
||||
**RG21 — Association de l'anomalie**
|
||||
Chaque anomalie doit être associée à l'étudiant concerné, au matériel, à l'emprunt et aux éléments de checklist concernés.
|
||||
@@ -103,7 +112,7 @@ Le responsable matériel peut consulter une anomalie, ajouter une observation et
|
||||
## 7. Notifications
|
||||
|
||||
**RG24 — Création automatique d'une notification**
|
||||
Lors de la création d'une anomalie, une notification est créée automatiquement et adressée au responsable matériel concerné.
|
||||
Une notification est créée pour les responsables du campus dès la première modification d'une checklist préremplie au départ ou au retour. Les confirmations d'anomalie et les changements d'état officiel du matériel sont également signalés et historisés. Une seule demande active doit regrouper les modifications d'une même checklist afin d'éviter les alertes en double.
|
||||
|
||||
**RG25 — Persistance et lecture des notifications**
|
||||
Les notifications sont enregistrées en base, consultables a posteriori, et peuvent être marquées comme lues par leur destinataire.
|
||||
@@ -113,19 +122,19 @@ Les notifications sont enregistrées en base, consultables a posteriori, et peuv
|
||||
## 8. Supervision par le responsable matériel
|
||||
|
||||
**RG26 — Périmètre de supervision**
|
||||
Le responsable matériel agit a posteriori et n'intervient pas en temps réel dans le parcours étudiant. Son rôle est la supervision, la gestion du stock et le traitement des anomalies.
|
||||
Le responsable matériel intervient lorsqu'un étudiant déclare un écart par rapport à une checklist préremplie. Il confirme ou refuse ce changement avant toute modification de l'état officiel du matériel. Il assure également la supervision, la gestion du stock et le traitement des anomalies.
|
||||
|
||||
**RG27 — Filtrage par campus**
|
||||
Un responsable matériel ne peut consulter que les données (matériels, emprunts, anomalies, historique) du campus auquel il est rattaché.
|
||||
|
||||
**RG28 — Consultation des emprunts**
|
||||
Le responsable matériel peut consulter les emprunts en cours, en retard, clôturés et les retours non conformes.
|
||||
Le responsable matériel peut consulter les écarts en attente de confirmation au départ ou au retour, les emprunts en cours, en retard, clôturés, annulés et les retours non conformes.
|
||||
|
||||
**RG29 — Consultation du stock**
|
||||
Le responsable matériel peut consulter l'état du stock et les statuts des matériels.
|
||||
|
||||
**RG30 — Gestion du matériel et des accessoires**
|
||||
Le responsable matériel peut gérer les matériels, les accessoires associés, les checklists liées et faire évoluer les statuts.
|
||||
Le responsable matériel peut gérer les matériels, les accessoires associés, confirmer les écarts de checklist et faire évoluer les statuts. Une différence déclarée par un étudiant ne peut modifier l'état officiel du matériel que dans le cadre des confirmations prévues par RG12A et RG19.
|
||||
|
||||
**RG31 — Consultation de l'historique**
|
||||
Le responsable matériel peut consulter l'historique des emprunts, restitutions, anomalies et changements de statut.
|
||||
@@ -135,7 +144,7 @@ Le responsable matériel peut consulter l'historique des emprunts, restitutions,
|
||||
## 9. Historique et traçabilité
|
||||
|
||||
**RG32 — Historisation des actions importantes**
|
||||
Toute opération importante doit être enregistrée dans l'historique : identification d'un étudiant, création d'un emprunt, validation des checklists, restitution, comparaison, création d'une anomalie, changement de statut d'un matériel et traitement d'une anomalie.
|
||||
Toute opération importante doit être enregistrée dans l'historique : identification d'un étudiant, confirmation automatique d'une checklist inchangée, tentative de modification, validation ou refus du responsable, restitution, création d'une anomalie, changement de statut d'un matériel et traitement d'une anomalie.
|
||||
|
||||
**RG33 — Informations conservées dans l'historique**
|
||||
L'historique doit conserver la date, l'heure, l'utilisateur concerné, le matériel concerné, le poste utilisé, la salle de prêt, le campus et l'action réalisée.
|
||||
@@ -144,10 +153,8 @@ L'historique doit conserver la date, l'heure, l'utilisateur concerné, le matér
|
||||
|
||||
## Résumé
|
||||
|
||||
Les règles métier du nouveau workflow EME reposent sur un principe d'**autonomie de l'étudiant**.
|
||||
Les règles métier du workflow EME reposent sur un **parcours automatique tant que l'étudiant ne déclare aucun écart**.
|
||||
|
||||
L'étudiant emprunte ou restitue son matériel depuis un poste dédié, s'identifie par QR code ou mail ENSUP, complète une checklist au départ et au retour, puis le système compare automatiquement les informations saisies.
|
||||
Au départ, la checklist est préremplie avec l'état de référence du matériel. Une confirmation sans modification active automatiquement l'emprunt. Toute différence déclarée réserve le matériel, alerte le responsable et reste provisoire jusqu'à sa décision.
|
||||
|
||||
En cas de différence, une anomalie est créée automatiquement et une notification est envoyée au responsable matériel. L'ensemble des opérations est historisé afin de garantir la traçabilité.
|
||||
|
||||
Le responsable matériel agit **a posteriori** : il consulte, traite les anomalies, gère le stock et l'historique de son campus, sans intervenir en temps réel dans le parcours étudiant.
|
||||
Au retour, la checklist reprend automatiquement l'état validé au départ. Une restitution inchangée est clôturée automatiquement. Dès que l'étudiant tente de modifier cet état, le responsable est alerté et doit confirmer le changement avant la création de l'anomalie et la mise à jour de l'état officiel. La catégorie reste bloquée tant que l'emprunt précédent n'est pas clôturé.
|
||||
|
||||
@@ -554,8 +554,8 @@ html,body{height:100%;font-family:var(--body);background:#f8fafc;color:var(--tex
|
||||
</div>
|
||||
<div class="cl-row">
|
||||
<span>Câble HDMI</span>
|
||||
<div class="cb-wrap"><div class="cb" id="cd-4-p" onclick="toggleCb('cd-4','p')"></div></div>
|
||||
<div class="cb-wrap"><div class="cb no" id="cd-4-a" onclick="toggleCb('cd-4','a')"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--red)" stroke-width="3"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></div></div>
|
||||
<div class="cb-wrap"><div class="cb ok" id="cd-4-p" onclick="toggleCb('cd-4','p')"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--green)" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg></div></div>
|
||||
<div class="cb-wrap"><div class="cb" id="cd-4-a" onclick="toggleCb('cd-4','a')"></div></div>
|
||||
<div class="cb-wrap"><div class="cb" id="cd-4-d" onclick="toggleCb('cd-4','d')"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -563,12 +563,12 @@ html,body{height:100%;font-family:var(--body);background:#f8fafc;color:var(--tex
|
||||
<div style="display:flex;flex-direction:column;gap:14px;">
|
||||
<div class="instr-box">
|
||||
<div class="lbl" style="margin-bottom:8px;">Instructions</div>
|
||||
<p>Vérifiez physiquement chaque élément présent dans le kit. Cliquez sur <strong>Présent</strong>, <strong>Absent</strong> ou <strong>Détérioré</strong> pour chaque élément. Signalez tout élément manquant ou endommagé pour éviter tout litige au retour.</p>
|
||||
<p>La checklist est préremplie avec l'état de référence. Vérifiez physiquement chaque élément et ne modifiez une valeur que si vous constatez un élément manquant ou endommagé. Toute modification alertera un responsable.</p>
|
||||
</div>
|
||||
<textarea class="textarea" placeholder="Commentaire ou observation optionnelle..." rows="4"></textarea>
|
||||
<button class="btn btn-dark btn-lg full" onclick="goTo('s-confirmation')">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 11l3 3L22 4"/></svg>
|
||||
Valider la checklist et confirmer l'emprunt
|
||||
Confirmer l'état de départ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -590,7 +590,7 @@ html,body{height:100%;font-family:var(--body);background:#f8fafc;color:var(--tex
|
||||
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
</div>
|
||||
<div class="success-title">Emprunt enregistré !</div>
|
||||
<div class="success-sub">Votre emprunt a bien été enregistré. Vous pouvez récupérer le matériel.</div>
|
||||
<div class="success-sub">La checklist n'a pas été modifiée. L'emprunt est actif et vous pouvez récupérer le matériel.</div>
|
||||
</div>
|
||||
<div class="recap-card">
|
||||
<div class="recap-label">Récapitulatif de l'emprunt</div>
|
||||
@@ -600,7 +600,8 @@ html,body{height:100%;font-family:var(--body);background:#f8fafc;color:var(--tex
|
||||
<div class="rrow"><span class="rk">Date d'emprunt</span><span class="rv">28/05/2026</span></div>
|
||||
<div class="rrow"><span class="rk">Heure</span><span class="rv">09:15</span></div>
|
||||
<div class="rrow"><span class="rk">Campus</span><span class="rv">Paris · Ensitech</span></div>
|
||||
<div class="rrow"><span class="rk">Note</span><span class="rv" style="color:var(--orange);">Câble HDMI absent au départ</span></div>
|
||||
<div class="rrow"><span class="rk">Statut</span><span class="rv">En cours</span></div>
|
||||
<div class="rrow"><span class="rk">État de départ</span><span class="rv" style="color:var(--green);">Conforme à l'état de référence</span></div>
|
||||
</div>
|
||||
<button class="btn btn-primary full btn-lg" onclick="goTo('s-home-student')">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||
@@ -692,17 +693,23 @@ html,body{height:100%;font-family:var(--body);background:#f8fafc;color:var(--tex
|
||||
<div class="cb-wrap"><div class="cb" id="cr-3-a" onclick="toggleCb('cr-3','a')"></div></div>
|
||||
<div class="cb-wrap"><div class="cb" id="cr-3-d" onclick="toggleCb('cr-3','d')"></div></div>
|
||||
</div>
|
||||
<div class="cl-row">
|
||||
<span>Câble HDMI</span>
|
||||
<div class="cb-wrap"><div class="cb ok" id="cr-4-p" onclick="toggleCb('cr-4','p')"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--green)" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg></div></div>
|
||||
<div class="cb-wrap"><div class="cb" id="cr-4-a" onclick="toggleCb('cr-4','a')"></div></div>
|
||||
<div class="cb-wrap"><div class="cb" id="cr-4-d" onclick="toggleCb('cr-4','d')"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:14px;">
|
||||
<div class="instr-box">
|
||||
<div class="lbl" style="margin-bottom:8px;">Rappel checklist de départ</div>
|
||||
<p>Au départ : Chargeur ✗ absent · Souris ✓ présente · Housse ✓ présente · Câble HDMI ✗ absent</p>
|
||||
<div class="lbl" style="margin-bottom:8px;">État validé au départ</div>
|
||||
<p>La checklist de retour est préremplie automatiquement : Chargeur présent · Souris présente · Housse présente · Câble HDMI présent. Modifiez uniquement l'élément dont l'état a changé.</p>
|
||||
</div>
|
||||
<textarea class="textarea" placeholder="Commentaire optionnel..." rows="4"></textarea>
|
||||
<button class="btn btn-dark btn-lg full" onclick="goTo('s-anomalie')">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 11l3 3L22 4"/></svg>
|
||||
Valider le retour
|
||||
Signaler le changement et restituer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -725,20 +732,20 @@ html,body{height:100%;font-family:var(--body);background:#f8fafc;color:var(--tex
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--red)" stroke-width="2.5"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||
</div>
|
||||
<div class="alert-body">
|
||||
<strong>Retour non conforme — Anomalie détectée</strong>
|
||||
<p>Une différence a été constatée avec la checklist de départ. Une anomalie a été créée automatiquement et le responsable matériel a été notifié.</p>
|
||||
<strong>Retour en attente de contrôle</strong>
|
||||
<p>Le chargeur a été modifié de « Présent » à « Absent ». Le responsable matériel a été alerté et doit confirmer ou refuser ce changement avant toute mise à jour de l'état officiel.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-box">
|
||||
<div class="lbl" style="margin-bottom:12px;">Détail de l'anomalie</div>
|
||||
<div class="lbl" style="margin-bottom:12px;">Changement en attente de confirmation</div>
|
||||
<div class="drow"><span class="dk">Élément concerné</span><span class="dv" style="color:var(--red);">Chargeur 65W</span></div>
|
||||
<div class="drow"><span class="dk">Statut au départ</span><span class="dv"><span class="pill p-closed">Non renseigné (absent au départ)</span></span></div>
|
||||
<div class="drow"><span class="dk">Statut au départ</span><span class="dv"><span class="pill p-closed">Présent</span></span></div>
|
||||
<div class="drow"><span class="dk">Statut au retour</span><span class="dv"><span class="pill p-no">Absent</span></span></div>
|
||||
<div class="drow"><span class="dk">Matériel</span><span class="dv">PC Dell Latitude 5420 · ENS-PC-001</span></div>
|
||||
<div class="drow"><span class="dk">Emprunteur</span><span class="dv">Lucas Martin</span></div>
|
||||
<div class="drow"><span class="dk">Date détection</span><span class="dv">28/05/2026 · 10:30</span></div>
|
||||
</div>
|
||||
<div style="font-size:13px;color:var(--muted);text-align:center;line-height:1.6;padding:0 16px;">Le responsable matériel traitera cette anomalie. Vous pouvez retourner à l'accueil.</div>
|
||||
<div style="font-size:13px;color:var(--muted);text-align:center;line-height:1.6;padding:0 16px;">L'anomalie et le nouvel état du matériel ne seront enregistrés qu'après confirmation du responsable. La catégorie reste bloquée jusque-là.</div>
|
||||
<button class="btn btn-primary full btn-lg" onclick="goTo('s-home-student')">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||
Retour à l'accueil
|
||||
@@ -1057,4 +1064,4 @@ function toggleCb(group, type) {
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -4,10 +4,12 @@ import {
|
||||
creerEmprunt,
|
||||
listerMesEmpruntsEnCours,
|
||||
restituerEmprunt,
|
||||
signalerTentativeModificationRetour,
|
||||
} from '../services/emprunt.service';
|
||||
import {
|
||||
parseCreerEmpruntRequest,
|
||||
parseRestituerEmpruntRequest,
|
||||
parseSignalerTentativeRetourRequest,
|
||||
toEmpruntResponse,
|
||||
} from '../dtos/emprunt.dto';
|
||||
|
||||
@@ -47,3 +49,19 @@ export async function postRestitution(req: Request, res: Response): Promise<void
|
||||
const emprunt = await restituerEmprunt(user.id, empruntId, request);
|
||||
res.json({ data: toEmpruntResponse(emprunt) });
|
||||
}
|
||||
|
||||
export async function postAlerteChangementRetour(req: Request, res: Response): Promise<void> {
|
||||
const user = req.user;
|
||||
if (!user) {
|
||||
throw new AppError(401, 'Authentification requise');
|
||||
}
|
||||
|
||||
const empruntId = Number(req.params.id);
|
||||
if (!Number.isInteger(empruntId) || empruntId <= 0) {
|
||||
throw new AppError(400, 'Identifiant emprunt invalide');
|
||||
}
|
||||
|
||||
const request = parseSignalerTentativeRetourRequest(req.body);
|
||||
const alerteCreee = await signalerTentativeModificationRetour(user.id, empruntId, request);
|
||||
res.json({ data: { alerteCreee } });
|
||||
}
|
||||
|
||||
@@ -2,15 +2,18 @@ import { Request, Response } from 'express';
|
||||
import { AppError } from '../errors/app-error';
|
||||
import {
|
||||
changerStatutAnomalieResponsableService,
|
||||
deciderEcartResponsableService,
|
||||
getDashboardResponsable,
|
||||
listerAnomaliesResponsable,
|
||||
listerHistoriqueResponsable,
|
||||
listerNotificationsResponsable,
|
||||
listerMaterielsResponsable,
|
||||
listerEmpruntsResponsable,
|
||||
listerEcartsResponsable,
|
||||
marquerNotificationResponsableLue,
|
||||
marquerToutesNotificationsResponsableLues,
|
||||
parseAnomalieId,
|
||||
parseEmpruntId,
|
||||
parseCategorieId,
|
||||
parseLu,
|
||||
parseNotificationId,
|
||||
@@ -24,6 +27,7 @@ import {
|
||||
toAnomalieResponsableResponse,
|
||||
toDashboardResponsableResponse,
|
||||
toEmpruntResponsableResponse,
|
||||
toEcartResponsableResponse,
|
||||
toHistoriqueResponsableResponse,
|
||||
toMaterielResponsableResponse,
|
||||
toNotificationResponsableResponse,
|
||||
@@ -77,6 +81,44 @@ export async function getEmprunts(req: Request, res: Response): Promise<void> {
|
||||
res.json({ data: emprunts.map(toEmpruntResponsableResponse) });
|
||||
}
|
||||
|
||||
export async function getEcarts(req: Request, res: Response): Promise<void> {
|
||||
const user = req.user;
|
||||
if (!user) {
|
||||
throw new AppError(401, 'Authentification requise');
|
||||
}
|
||||
|
||||
const ecarts = await listerEcartsResponsable(user.roleCode, user.campusId);
|
||||
res.json({ data: ecarts.map(toEcartResponsableResponse) });
|
||||
}
|
||||
|
||||
export async function patchEcartDecision(req: Request, res: Response): Promise<void> {
|
||||
const user = req.user;
|
||||
if (!user) {
|
||||
throw new AppError(401, 'Authentification requise');
|
||||
}
|
||||
|
||||
const empruntId = parseEmpruntId(req.params.id);
|
||||
const body = asBodyObject(req.body);
|
||||
if (body.decision !== 'CONFIRMER' && body.decision !== 'REFUSER') {
|
||||
throw new AppError(400, 'decision invalide');
|
||||
}
|
||||
const observation = typeof body.observation === 'string' ? body.observation : undefined;
|
||||
const statutMateriel = parseStatutMateriel(body.statutMateriel);
|
||||
|
||||
const ecart = await deciderEcartResponsableService(
|
||||
user.id,
|
||||
user.roleCode,
|
||||
user.campusId,
|
||||
empruntId,
|
||||
{
|
||||
decision: body.decision,
|
||||
observation,
|
||||
statutMateriel,
|
||||
},
|
||||
);
|
||||
res.json({ data: toEcartResponsableResponse(ecart) });
|
||||
}
|
||||
|
||||
export async function getMateriels(req: Request, res: Response): Promise<void> {
|
||||
const user = req.user;
|
||||
if (!user) {
|
||||
|
||||
@@ -10,9 +10,19 @@ export interface EmpruntResponse {
|
||||
dateRetourReelle: Date | null;
|
||||
statut: string;
|
||||
materiel: MaterielResponse;
|
||||
checklistDepart: ChecklistElementResponse[];
|
||||
}
|
||||
|
||||
export interface ChecklistElementResponse {
|
||||
accessoireId: number | null;
|
||||
nomElement: string;
|
||||
etat: string;
|
||||
quantiteConstatee: number;
|
||||
}
|
||||
|
||||
export function toEmpruntResponse(emprunt: EmpruntAvecMateriel): EmpruntResponse {
|
||||
const checklistDepart = emprunt.checklists.find((checklist) => checklist.type === 'DEPART');
|
||||
|
||||
return {
|
||||
id: emprunt.id,
|
||||
dateEmprunt: emprunt.dateEmprunt,
|
||||
@@ -20,6 +30,13 @@ export function toEmpruntResponse(emprunt: EmpruntAvecMateriel): EmpruntResponse
|
||||
dateRetourReelle: emprunt.dateRetourReelle,
|
||||
statut: emprunt.statut,
|
||||
materiel: toMaterielResponse(emprunt.materiel),
|
||||
checklistDepart:
|
||||
checklistDepart?.elements.map((element) => ({
|
||||
accessoireId: element.accessoireId,
|
||||
nomElement: element.nomElement,
|
||||
etat: element.etat,
|
||||
quantiteConstatee: element.quantiteConstatee,
|
||||
})) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,6 +140,25 @@ export interface RestituerEmpruntRequest {
|
||||
elements: ChecklistElementRequest[];
|
||||
}
|
||||
|
||||
export interface SignalerTentativeRetourRequest {
|
||||
nomElement: string;
|
||||
etatInitial: string;
|
||||
etatDemande: string;
|
||||
}
|
||||
|
||||
export function parseSignalerTentativeRetourRequest(body: unknown): SignalerTentativeRetourRequest {
|
||||
const obj = asObject(body, 'Corps de requete invalide');
|
||||
if (typeof obj.nomElement !== 'string' || obj.nomElement.trim() === '') {
|
||||
throw new AppError(400, 'nomElement requis');
|
||||
}
|
||||
|
||||
return {
|
||||
nomElement: obj.nomElement.trim(),
|
||||
etatInitial: asEnum(obj.etatInitial, ETAT_CHECKLIST_ELEMENT, 'etatInitial'),
|
||||
etatDemande: asEnum(obj.etatDemande, ETAT_CHECKLIST_ELEMENT, 'etatDemande'),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRestituerEmpruntRequest(body: unknown): RestituerEmpruntRequest {
|
||||
const obj = asObject(body, 'Corps de requete invalide');
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ActiviteResponsable,
|
||||
AnomalieResponsable,
|
||||
EmpruntResponsable,
|
||||
EcartResponsable,
|
||||
HistoriqueResponsable,
|
||||
MaterielResponsable,
|
||||
NotificationResponsable,
|
||||
@@ -67,6 +68,21 @@ export interface EmpruntResponsableResponse {
|
||||
};
|
||||
}
|
||||
|
||||
export interface EcartResponsableResponse extends EmpruntResponsableResponse {
|
||||
typeEcart: 'DEPART' | 'RETOUR';
|
||||
checklists: Array<{
|
||||
type: string;
|
||||
elements: Array<{
|
||||
id: number;
|
||||
accessoireId: number | null;
|
||||
nomElement: string;
|
||||
etat: string;
|
||||
quantiteConstatee: number;
|
||||
commentaire: string | null;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface MaterielResponsableResponse {
|
||||
id: number;
|
||||
nom: string;
|
||||
@@ -251,6 +267,26 @@ export function toEmpruntResponsableResponse(
|
||||
};
|
||||
}
|
||||
|
||||
export function toEcartResponsableResponse(emprunt: EcartResponsable): EcartResponsableResponse {
|
||||
return {
|
||||
...toEmpruntResponsableResponse(emprunt),
|
||||
typeEcart: emprunt.checklists.some((checklist) => checklist.type === 'RETOUR')
|
||||
? 'RETOUR'
|
||||
: 'DEPART',
|
||||
checklists: emprunt.checklists.map((checklist) => ({
|
||||
type: checklist.type,
|
||||
elements: checklist.elements.map((element) => ({
|
||||
id: element.id,
|
||||
accessoireId: element.accessoireId,
|
||||
nomElement: element.nomElement,
|
||||
etat: element.etat,
|
||||
quantiteConstatee: element.quantiteConstatee,
|
||||
commentaire: element.commentaire,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function toMaterielResponsableResponse(
|
||||
materiel: MaterielResponsable,
|
||||
): MaterielResponsableResponse {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const STATUT_MATERIEL = [
|
||||
'DISPONIBLE',
|
||||
'RESERVE',
|
||||
'EMPRUNTE',
|
||||
'NON_CONFORME',
|
||||
'DETERIORE',
|
||||
@@ -10,8 +11,10 @@ export const STATUT_MATERIEL = [
|
||||
export type StatutMateriel = (typeof STATUT_MATERIEL)[number];
|
||||
|
||||
export const STATUT_EMPRUNT = [
|
||||
'EN_ATTENTE_VALIDATION_DEPART',
|
||||
'EN_COURS',
|
||||
'EN_RETARD',
|
||||
'EN_ATTENTE_VALIDATION_RETOUR',
|
||||
'CLOTURE',
|
||||
'RETOUR_NON_CONFORME',
|
||||
'ANNULE',
|
||||
|
||||
@@ -4,7 +4,10 @@ import { StatutEmprunt } from '../models/enums';
|
||||
import { AppError } from '../errors/app-error';
|
||||
|
||||
export type EmpruntAvecMateriel = Prisma.EmpruntGetPayload<{
|
||||
include: { materiel: { include: { categorie: true } } };
|
||||
include: {
|
||||
materiel: { include: { categorie: true } };
|
||||
checklists: { include: { elements: true } };
|
||||
};
|
||||
}>;
|
||||
|
||||
export type EmpruntAvecChecklists = Prisma.EmpruntGetPayload<{
|
||||
@@ -12,6 +15,12 @@ export type EmpruntAvecChecklists = Prisma.EmpruntGetPayload<{
|
||||
}>;
|
||||
|
||||
const STATUTS_EN_COURS: StatutEmprunt[] = ['EN_COURS', 'EN_RETARD'];
|
||||
const STATUTS_BLOQUANTS: StatutEmprunt[] = [
|
||||
'EN_ATTENTE_VALIDATION_DEPART',
|
||||
'EN_COURS',
|
||||
'EN_RETARD',
|
||||
'EN_ATTENTE_VALIDATION_RETOUR',
|
||||
];
|
||||
|
||||
export function findEnCoursParUtilisateur(utilisateurId: number): Promise<EmpruntAvecMateriel[]> {
|
||||
return prisma.emprunt.findMany({
|
||||
@@ -19,7 +28,10 @@ export function findEnCoursParUtilisateur(utilisateurId: number): Promise<Emprun
|
||||
utilisateurId,
|
||||
statut: { in: STATUTS_EN_COURS },
|
||||
},
|
||||
include: { materiel: { include: { categorie: true } } },
|
||||
include: {
|
||||
materiel: { include: { categorie: true } },
|
||||
checklists: { include: { elements: true } },
|
||||
},
|
||||
orderBy: { dateRetourPrevue: 'asc' },
|
||||
});
|
||||
}
|
||||
@@ -31,6 +43,71 @@ export function findByIdAvecChecklists(id: number): Promise<EmpruntAvecChecklist
|
||||
});
|
||||
}
|
||||
|
||||
export interface SignalerTentativeRetourData {
|
||||
empruntId: number;
|
||||
utilisateurId: number;
|
||||
materielId: number;
|
||||
campusId: number;
|
||||
sallePretId: number;
|
||||
posteEmpruntId: number;
|
||||
responsablesIds: number[];
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function signalerTentativeRetour(data: SignalerTentativeRetourData): Promise<boolean> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const emprunt = await tx.emprunt.findFirst({
|
||||
where: {
|
||||
id: data.empruntId,
|
||||
utilisateurId: data.utilisateurId,
|
||||
statut: { in: STATUTS_EN_COURS },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!emprunt) {
|
||||
throw new AppError(409, 'Emprunt non restituable');
|
||||
}
|
||||
|
||||
const dejaSignalee = await tx.historique.findFirst({
|
||||
where: {
|
||||
empruntId: data.empruntId,
|
||||
action: 'TENTATIVE_MODIFICATION_RETOUR',
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (dejaSignalee) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data.responsablesIds.length > 0) {
|
||||
await tx.notification.createMany({
|
||||
data: data.responsablesIds.map((responsableId) => ({
|
||||
utilisateurId: responsableId,
|
||||
titre: 'Tentative de modification au retour',
|
||||
message: data.description,
|
||||
type: 'TENTATIVE_MODIFICATION_RETOUR',
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
await tx.historique.create({
|
||||
data: {
|
||||
utilisateurId: data.utilisateurId,
|
||||
empruntId: data.empruntId,
|
||||
materielId: data.materielId,
|
||||
campusId: data.campusId,
|
||||
sallePretId: data.sallePretId,
|
||||
posteEmpruntId: data.posteEmpruntId,
|
||||
action: 'TENTATIVE_MODIFICATION_RETOUR',
|
||||
description: data.description,
|
||||
dateAction: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
interface ChecklistElementData {
|
||||
accessoireId?: number;
|
||||
nomElement: string;
|
||||
@@ -42,6 +119,7 @@ interface ChecklistElementData {
|
||||
export interface CreerEmpruntData {
|
||||
utilisateurId: number;
|
||||
materielId: number;
|
||||
categorieId: number;
|
||||
campusId: number;
|
||||
sallePretId: number;
|
||||
posteEmpruntId: number;
|
||||
@@ -49,59 +127,122 @@ export interface CreerEmpruntData {
|
||||
modeIdentification: string;
|
||||
commentaireDepart?: string;
|
||||
elements: ChecklistElementData[];
|
||||
statutEmprunt: StatutEmprunt;
|
||||
statutMateriel: string;
|
||||
responsablesIds: number[];
|
||||
descriptionEcart?: string;
|
||||
}
|
||||
|
||||
/* Création atomique : réservation du matériel (DISPONIBLE -> EMPRUNTE), emprunt,
|
||||
checklist de départ et ses éléments. Si une étape échoue, tout est annulé. */
|
||||
export function creerEmpruntComplet(data: CreerEmpruntData): Promise<EmpruntAvecMateriel> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
// Réservation atomique : empêche un double emprunt du même matériel (RG07).
|
||||
const reservation = await tx.materiel.updateMany({
|
||||
where: { id: data.materielId, statut: 'DISPONIBLE' },
|
||||
data: { statut: 'EMPRUNTE' },
|
||||
});
|
||||
if (reservation.count === 0) {
|
||||
throw new AppError(409, 'Materiel non disponible');
|
||||
}
|
||||
|
||||
const emprunt = await tx.emprunt.create({
|
||||
data: {
|
||||
utilisateurId: data.utilisateurId,
|
||||
materielId: data.materielId,
|
||||
campusId: data.campusId,
|
||||
sallePretId: data.sallePretId,
|
||||
posteEmpruntId: data.posteEmpruntId,
|
||||
dateEmprunt: new Date(),
|
||||
dateRetourPrevue: data.dateRetourPrevue,
|
||||
statut: 'EN_COURS',
|
||||
modeIdentificationEmprunt: data.modeIdentification,
|
||||
commentaireDepart: data.commentaireDepart ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.checklist.create({
|
||||
data: {
|
||||
empruntId: emprunt.id,
|
||||
utilisateurId: data.utilisateurId,
|
||||
type: 'DEPART',
|
||||
dateVerification: new Date(),
|
||||
elements: {
|
||||
create: data.elements.map((element) => ({
|
||||
accessoireId: element.accessoireId ?? null,
|
||||
nomElement: element.nomElement,
|
||||
etat: element.etat,
|
||||
quantiteConstatee: element.quantiteConstatee,
|
||||
commentaire: element.commentaire ?? null,
|
||||
})),
|
||||
return prisma.$transaction(
|
||||
async (tx) => {
|
||||
const empruntBloquant = await tx.emprunt.findFirst({
|
||||
where: {
|
||||
utilisateurId: data.utilisateurId,
|
||||
statut: { in: STATUTS_BLOQUANTS },
|
||||
materiel: { categorieId: data.categorieId },
|
||||
},
|
||||
},
|
||||
});
|
||||
select: { id: true },
|
||||
});
|
||||
if (empruntBloquant) {
|
||||
throw new AppError(
|
||||
409,
|
||||
'Un materiel de cette categorie est deja emprunte ou en attente de confirmation',
|
||||
);
|
||||
}
|
||||
|
||||
return tx.emprunt.findUniqueOrThrow({
|
||||
where: { id: emprunt.id },
|
||||
include: { materiel: { include: { categorie: true } } },
|
||||
});
|
||||
});
|
||||
const reservation = await tx.materiel.updateMany({
|
||||
where: { id: data.materielId, statut: 'DISPONIBLE' },
|
||||
data: { statut: data.statutMateriel },
|
||||
});
|
||||
if (reservation.count === 0) {
|
||||
throw new AppError(409, 'Materiel non disponible');
|
||||
}
|
||||
|
||||
const maintenant = new Date();
|
||||
const emprunt = await tx.emprunt.create({
|
||||
data: {
|
||||
utilisateurId: data.utilisateurId,
|
||||
materielId: data.materielId,
|
||||
campusId: data.campusId,
|
||||
sallePretId: data.sallePretId,
|
||||
posteEmpruntId: data.posteEmpruntId,
|
||||
dateEmprunt: maintenant,
|
||||
dateRetourPrevue: data.dateRetourPrevue,
|
||||
statut: data.statutEmprunt,
|
||||
modeIdentificationEmprunt: data.modeIdentification,
|
||||
commentaireDepart: data.commentaireDepart ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.checklist.create({
|
||||
data: {
|
||||
empruntId: emprunt.id,
|
||||
utilisateurId: data.utilisateurId,
|
||||
type: 'DEPART',
|
||||
dateVerification: maintenant,
|
||||
elements: {
|
||||
create: data.elements.map((element) => ({
|
||||
accessoireId: element.accessoireId ?? null,
|
||||
nomElement: element.nomElement,
|
||||
etat: element.etat,
|
||||
quantiteConstatee: element.quantiteConstatee,
|
||||
commentaire: element.commentaire ?? null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (data.descriptionEcart) {
|
||||
if (data.responsablesIds.length > 0) {
|
||||
await tx.notification.createMany({
|
||||
data: data.responsablesIds.map((responsableId) => ({
|
||||
utilisateurId: responsableId,
|
||||
titre: 'Ecart signale au depart',
|
||||
message: data.descriptionEcart as string,
|
||||
type: 'ECART_DEPART',
|
||||
})),
|
||||
});
|
||||
}
|
||||
await tx.historique.create({
|
||||
data: {
|
||||
utilisateurId: data.utilisateurId,
|
||||
empruntId: emprunt.id,
|
||||
materielId: data.materielId,
|
||||
campusId: data.campusId,
|
||||
sallePretId: data.sallePretId,
|
||||
posteEmpruntId: data.posteEmpruntId,
|
||||
action: 'ECART_DEPART_SIGNALE',
|
||||
description: data.descriptionEcart,
|
||||
dateAction: maintenant,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await tx.historique.create({
|
||||
data: {
|
||||
utilisateurId: data.utilisateurId,
|
||||
empruntId: emprunt.id,
|
||||
materielId: data.materielId,
|
||||
campusId: data.campusId,
|
||||
sallePretId: data.sallePretId,
|
||||
posteEmpruntId: data.posteEmpruntId,
|
||||
action: 'EMPRUNT_AUTOMATIQUE',
|
||||
description: `Emprunt #${emprunt.id} active avec une checklist de depart inchangee`,
|
||||
dateAction: maintenant,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return tx.emprunt.findUniqueOrThrow({
|
||||
where: { id: emprunt.id },
|
||||
include: {
|
||||
materiel: { include: { categorie: true } },
|
||||
checklists: { include: { elements: true } },
|
||||
},
|
||||
});
|
||||
},
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||
);
|
||||
}
|
||||
|
||||
export interface RestituerEmpruntData {
|
||||
@@ -113,19 +254,34 @@ export interface RestituerEmpruntData {
|
||||
modeIdentification: string;
|
||||
commentaireRetour?: string;
|
||||
elements: ChecklistElementData[];
|
||||
anomalie?: {
|
||||
type: string;
|
||||
description: string;
|
||||
responsablesIds: number[];
|
||||
};
|
||||
campusId: number;
|
||||
sallePretId: number;
|
||||
posteEmpruntId: number;
|
||||
responsablesIds: number[];
|
||||
descriptionEcart?: string;
|
||||
}
|
||||
|
||||
/* Restitution atomique : checklist de retour, mise à jour de l'emprunt et du
|
||||
matériel, et — si non conforme — création de l'anomalie et des notifications. */
|
||||
export function restituerEmpruntComplet(data: RestituerEmpruntData): Promise<EmpruntAvecMateriel> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const maintenant = new Date();
|
||||
|
||||
const transition = await tx.emprunt.updateMany({
|
||||
where: {
|
||||
id: data.empruntId,
|
||||
utilisateurId: data.utilisateurId,
|
||||
statut: { in: STATUTS_EN_COURS },
|
||||
},
|
||||
data: {
|
||||
statut: data.statutEmprunt,
|
||||
...(data.statutEmprunt === 'CLOTURE' ? { dateRetourReelle: maintenant } : {}),
|
||||
modeIdentificationRetour: data.modeIdentification,
|
||||
commentaireRetour: data.commentaireRetour ?? null,
|
||||
},
|
||||
});
|
||||
if (transition.count === 0) {
|
||||
throw new AppError(409, 'Emprunt non restituable');
|
||||
}
|
||||
|
||||
await tx.checklist.create({
|
||||
data: {
|
||||
empruntId: data.empruntId,
|
||||
@@ -144,52 +300,64 @@ export function restituerEmpruntComplet(data: RestituerEmpruntData): Promise<Emp
|
||||
},
|
||||
});
|
||||
|
||||
await tx.emprunt.update({
|
||||
where: { id: data.empruntId },
|
||||
data: {
|
||||
statut: data.statutEmprunt,
|
||||
dateRetourReelle: maintenant,
|
||||
modeIdentificationRetour: data.modeIdentification,
|
||||
commentaireRetour: data.commentaireRetour ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.materiel.update({
|
||||
where: { id: data.materielId },
|
||||
data: { statut: data.statutMateriel },
|
||||
});
|
||||
|
||||
if (data.anomalie) {
|
||||
const infoAnomalie = data.anomalie;
|
||||
const anomalie = await tx.anomalie.create({
|
||||
data: {
|
||||
if (data.descriptionEcart) {
|
||||
const tentativeDejaSignalee = await tx.historique.findFirst({
|
||||
where: {
|
||||
empruntId: data.empruntId,
|
||||
materielId: data.materielId,
|
||||
utilisateurId: data.utilisateurId,
|
||||
type: infoAnomalie.type,
|
||||
description: infoAnomalie.description,
|
||||
statut: 'DETECTEE',
|
||||
detecteeAutomatiquement: true,
|
||||
dateDetection: maintenant,
|
||||
action: 'TENTATIVE_MODIFICATION_RETOUR',
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (infoAnomalie.responsablesIds.length > 0) {
|
||||
if (!tentativeDejaSignalee && data.responsablesIds.length > 0) {
|
||||
await tx.notification.createMany({
|
||||
data: infoAnomalie.responsablesIds.map((responsableId) => ({
|
||||
data: data.responsablesIds.map((responsableId) => ({
|
||||
utilisateurId: responsableId,
|
||||
anomalieId: anomalie.id,
|
||||
titre: 'Nouvelle anomalie detectee',
|
||||
message: infoAnomalie.description,
|
||||
type: 'ANOMALIE',
|
||||
titre: 'Changement signale au retour',
|
||||
message: data.descriptionEcart as string,
|
||||
type: 'ECART_RETOUR',
|
||||
})),
|
||||
});
|
||||
}
|
||||
await tx.historique.create({
|
||||
data: {
|
||||
utilisateurId: data.utilisateurId,
|
||||
empruntId: data.empruntId,
|
||||
materielId: data.materielId,
|
||||
campusId: data.campusId,
|
||||
sallePretId: data.sallePretId,
|
||||
posteEmpruntId: data.posteEmpruntId,
|
||||
action: 'ECART_RETOUR_SIGNALE',
|
||||
description: data.descriptionEcart,
|
||||
dateAction: maintenant,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await tx.historique.create({
|
||||
data: {
|
||||
utilisateurId: data.utilisateurId,
|
||||
empruntId: data.empruntId,
|
||||
materielId: data.materielId,
|
||||
campusId: data.campusId,
|
||||
sallePretId: data.sallePretId,
|
||||
posteEmpruntId: data.posteEmpruntId,
|
||||
action: 'RESTITUTION_AUTOMATIQUE',
|
||||
description: `Emprunt #${data.empruntId} cloture avec une checklist de retour inchangee`,
|
||||
dateAction: maintenant,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return tx.emprunt.findUniqueOrThrow({
|
||||
where: { id: data.empruntId },
|
||||
include: { materiel: { include: { categorie: true } } },
|
||||
include: {
|
||||
materiel: { include: { categorie: true } },
|
||||
checklists: { include: { elements: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,6 +21,19 @@ export type EmpruntResponsable = Prisma.EmpruntGetPayload<{
|
||||
};
|
||||
}>;
|
||||
|
||||
export type EcartResponsable = Prisma.EmpruntGetPayload<{
|
||||
include: {
|
||||
utilisateur: true;
|
||||
materiel: {
|
||||
include: {
|
||||
categorie: true;
|
||||
accessoires: { include: { accessoire: true } };
|
||||
};
|
||||
};
|
||||
checklists: { include: { elements: true } };
|
||||
};
|
||||
}>;
|
||||
|
||||
export type MaterielResponsable = Prisma.MaterielGetPayload<{
|
||||
include: {
|
||||
categorie: true;
|
||||
@@ -94,6 +107,23 @@ export interface ChangerStatutAnomalieData {
|
||||
observation?: string;
|
||||
}
|
||||
|
||||
export interface DeciderEcartResponsableData {
|
||||
empruntId: number;
|
||||
campusId: number;
|
||||
responsableId: number;
|
||||
statutAttendu: string;
|
||||
statutEmpruntFinal: string;
|
||||
statutMaterielFinal: string;
|
||||
typeEcart: 'DEPART' | 'RETOUR';
|
||||
decision: 'CONFIRMER' | 'REFUSER';
|
||||
description: string;
|
||||
observation?: string;
|
||||
elementsDepartCorriges?: Array<{
|
||||
id: number;
|
||||
quantiteConstatee: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
function mapStatutCounts(rows: Array<{ statut: string; _count: { _all: number } }>): StatutCount[] {
|
||||
return rows.map((row) => ({ statut: row.statut, count: row._count._all }));
|
||||
}
|
||||
@@ -167,6 +197,128 @@ export function findEmpruntsResponsable(
|
||||
});
|
||||
}
|
||||
|
||||
const INCLUDE_ECART = {
|
||||
utilisateur: true,
|
||||
materiel: {
|
||||
include: {
|
||||
categorie: true,
|
||||
accessoires: { include: { accessoire: true } },
|
||||
},
|
||||
},
|
||||
checklists: { include: { elements: true } },
|
||||
} satisfies Prisma.EmpruntInclude;
|
||||
|
||||
export function findEcartsResponsable(campusId: number): Promise<EcartResponsable[]> {
|
||||
return prisma.emprunt.findMany({
|
||||
where: {
|
||||
campusId,
|
||||
statut: {
|
||||
in: ['EN_ATTENTE_VALIDATION_DEPART', 'EN_ATTENTE_VALIDATION_RETOUR'],
|
||||
},
|
||||
},
|
||||
include: INCLUDE_ECART,
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
export function findEcartResponsableById(
|
||||
campusId: number,
|
||||
empruntId: number,
|
||||
): Promise<EcartResponsable | null> {
|
||||
return prisma.emprunt.findFirst({
|
||||
where: {
|
||||
id: empruntId,
|
||||
campusId,
|
||||
statut: {
|
||||
in: ['EN_ATTENTE_VALIDATION_DEPART', 'EN_ATTENTE_VALIDATION_RETOUR'],
|
||||
},
|
||||
},
|
||||
include: INCLUDE_ECART,
|
||||
});
|
||||
}
|
||||
|
||||
export function deciderEcartResponsable(
|
||||
data: DeciderEcartResponsableData,
|
||||
): Promise<EcartResponsable> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const maintenant = new Date();
|
||||
const transition = await tx.emprunt.updateMany({
|
||||
where: {
|
||||
id: data.empruntId,
|
||||
campusId: data.campusId,
|
||||
statut: data.statutAttendu,
|
||||
},
|
||||
data: {
|
||||
statut: data.statutEmpruntFinal,
|
||||
...(data.typeEcart === 'RETOUR' ? { dateRetourReelle: maintenant } : {}),
|
||||
},
|
||||
});
|
||||
if (transition.count === 0) {
|
||||
throw new Error('ECART_DEJA_TRAITE');
|
||||
}
|
||||
|
||||
await tx.materiel.update({
|
||||
where: {
|
||||
id: (await tx.emprunt.findUniqueOrThrow({ where: { id: data.empruntId } })).materielId,
|
||||
},
|
||||
data: { statut: data.statutMaterielFinal },
|
||||
});
|
||||
|
||||
if (data.elementsDepartCorriges) {
|
||||
await Promise.all(
|
||||
data.elementsDepartCorriges.map((element) =>
|
||||
tx.checklistElement.update({
|
||||
where: { id: element.id },
|
||||
data: {
|
||||
etat: 'PRESENT',
|
||||
quantiteConstatee: element.quantiteConstatee,
|
||||
commentaire: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (data.decision === 'CONFIRMER') {
|
||||
const emprunt = await tx.emprunt.findUniqueOrThrow({ where: { id: data.empruntId } });
|
||||
await tx.anomalie.create({
|
||||
data: {
|
||||
empruntId: data.empruntId,
|
||||
materielId: emprunt.materielId,
|
||||
utilisateurId: emprunt.utilisateurId,
|
||||
traiteeParId: data.responsableId,
|
||||
type: data.typeEcart === 'DEPART' ? 'ECART_DEPART' : 'RETOUR_NON_CONFORME',
|
||||
description: data.description,
|
||||
statut: 'DETECTEE',
|
||||
detecteeAutomatiquement: false,
|
||||
observation: data.observation ?? null,
|
||||
dateDetection: maintenant,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const emprunt = await tx.emprunt.findUniqueOrThrow({ where: { id: data.empruntId } });
|
||||
await tx.historique.create({
|
||||
data: {
|
||||
utilisateurId: data.responsableId,
|
||||
empruntId: data.empruntId,
|
||||
materielId: emprunt.materielId,
|
||||
campusId: data.campusId,
|
||||
sallePretId: emprunt.sallePretId,
|
||||
posteEmpruntId: emprunt.posteEmpruntId,
|
||||
action: `ECART_${data.typeEcart}_${data.decision}`,
|
||||
description: data.description,
|
||||
dateAction: maintenant,
|
||||
},
|
||||
});
|
||||
|
||||
return tx.emprunt.findUniqueOrThrow({
|
||||
where: { id: data.empruntId },
|
||||
include: INCLUDE_ECART,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function findMaterielsResponsable(
|
||||
campusId: number,
|
||||
filtres: MaterielResponsableFiltres,
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import { getMesEmprunts, postEmprunt, postRestitution } from '../controllers/emprunt.controller';
|
||||
import {
|
||||
getMesEmprunts,
|
||||
postAlerteChangementRetour,
|
||||
postEmprunt,
|
||||
postRestitution,
|
||||
} from '../controllers/emprunt.controller';
|
||||
|
||||
export const empruntRoutes: Router = Router();
|
||||
empruntRoutes.post('/', postEmprunt);
|
||||
empruntRoutes.post('/:id/alerte-changement-retour', postAlerteChangementRetour);
|
||||
empruntRoutes.post('/:id/restitution', postRestitution);
|
||||
|
||||
export const mesEmpruntsRoutes: Router = Router();
|
||||
|
||||
@@ -3,11 +3,13 @@ import {
|
||||
getAnomalies,
|
||||
getDashboard,
|
||||
getEmprunts,
|
||||
getEcarts,
|
||||
getHistorique,
|
||||
getHistoriqueCsv,
|
||||
getMateriels,
|
||||
getNotifications,
|
||||
patchAnomalieStatut,
|
||||
patchEcartDecision,
|
||||
patchNotificationLue,
|
||||
patchNotificationsLues,
|
||||
} from '../controllers/responsable.controller';
|
||||
@@ -16,6 +18,8 @@ export const responsableRoutes: Router = Router();
|
||||
|
||||
responsableRoutes.get('/dashboard', getDashboard);
|
||||
responsableRoutes.get('/emprunts', getEmprunts);
|
||||
responsableRoutes.get('/ecarts', getEcarts);
|
||||
responsableRoutes.patch('/ecarts/:id/decision', patchEcartDecision);
|
||||
responsableRoutes.get('/materiels', getMateriels);
|
||||
responsableRoutes.get('/anomalies', getAnomalies);
|
||||
responsableRoutes.patch('/anomalies/:id/statut', patchAnomalieStatut);
|
||||
|
||||
@@ -11,11 +11,13 @@ import {
|
||||
restituerEmpruntComplet,
|
||||
EmpruntAvecMateriel,
|
||||
RestituerEmpruntData,
|
||||
signalerTentativeRetour,
|
||||
} from '../repositories/emprunt.repository';
|
||||
import { findById as findMaterielById } from '../repositories/materiel.repository';
|
||||
import { findDetailParCampus } from '../repositories/materiel.repository';
|
||||
import { findById as findPosteById } from '../repositories/poste-emprunt.repository';
|
||||
import { findResponsablesParCampus } from '../repositories/utilisateur.repository';
|
||||
import { StatutEmprunt } from '../models/enums';
|
||||
import { SignalerTentativeRetourRequest } from '../dtos/emprunt.dto';
|
||||
|
||||
const DUREE_EMPRUNT_JOURS = 14;
|
||||
const STATUTS_RESTITUABLES: readonly string[] = ['EN_COURS', 'EN_RETARD'];
|
||||
@@ -25,6 +27,51 @@ export function listerMesEmpruntsEnCours(utilisateurId: number): Promise<Emprunt
|
||||
return findEnCoursParUtilisateur(utilisateurId);
|
||||
}
|
||||
|
||||
export async function signalerTentativeModificationRetour(
|
||||
utilisateurId: number,
|
||||
empruntId: number,
|
||||
request: SignalerTentativeRetourRequest,
|
||||
): Promise<boolean> {
|
||||
const emprunt = await findByIdAvecChecklists(empruntId);
|
||||
if (!emprunt) {
|
||||
throw new AppError(404, 'Emprunt introuvable');
|
||||
}
|
||||
if (emprunt.utilisateurId !== utilisateurId) {
|
||||
throw new AppError(403, 'Cet emprunt ne vous appartient pas');
|
||||
}
|
||||
if (!STATUTS_RESTITUABLES.includes(emprunt.statut)) {
|
||||
throw new AppError(409, 'Emprunt non restituable');
|
||||
}
|
||||
|
||||
const checklistDepart = emprunt.checklists.find((checklist) => checklist.type === 'DEPART');
|
||||
const elementDepart = checklistDepart?.elements.find(
|
||||
(element) => element.nomElement.toLocaleLowerCase() === request.nomElement.toLocaleLowerCase(),
|
||||
);
|
||||
if (!elementDepart) {
|
||||
throw new AppError(400, 'Element absent de la checklist de depart');
|
||||
}
|
||||
if (elementDepart.etat !== request.etatInitial) {
|
||||
throw new AppError(400, "L'etat initial ne correspond pas au depart valide");
|
||||
}
|
||||
if (request.etatInitial === request.etatDemande) {
|
||||
throw new AppError(400, 'Aucun changement a signaler');
|
||||
}
|
||||
|
||||
const responsables = await findResponsablesParCampus(emprunt.campusId);
|
||||
return signalerTentativeRetour({
|
||||
empruntId,
|
||||
utilisateurId,
|
||||
materielId: emprunt.materielId,
|
||||
campusId: emprunt.campusId,
|
||||
sallePretId: emprunt.sallePretId,
|
||||
posteEmpruntId: emprunt.posteEmpruntId,
|
||||
responsablesIds: responsables.map((responsable) => responsable.id),
|
||||
description:
|
||||
`Tentative de modification au retour de l'emprunt #${emprunt.id} : ` +
|
||||
`${elementDepart.nomElement} ${request.etatInitial} -> ${request.etatDemande}.`,
|
||||
});
|
||||
}
|
||||
|
||||
/* RG07/RG10/RG11/RG12 : création d'un emprunt avec sa checklist de départ.
|
||||
Les contrôles d'éligibilité (campus, disponibilité) sont faits ici ; l'écriture
|
||||
atomique est déléguée au repository. */
|
||||
@@ -33,13 +80,10 @@ export async function creerEmprunt(
|
||||
campusId: number,
|
||||
request: CreerEmpruntRequest,
|
||||
): Promise<EmpruntAvecMateriel> {
|
||||
const materiel = await findMaterielById(request.materielId);
|
||||
const materiel = await findDetailParCampus(request.materielId, campusId);
|
||||
if (!materiel) {
|
||||
throw new AppError(404, 'Materiel introuvable');
|
||||
}
|
||||
if (materiel.campusId !== campusId) {
|
||||
throw new AppError(403, 'Materiel rattache a un autre campus');
|
||||
}
|
||||
if (materiel.statut !== 'DISPONIBLE') {
|
||||
throw new AppError(409, 'Materiel non disponible');
|
||||
}
|
||||
@@ -55,72 +99,124 @@ export async function creerEmprunt(
|
||||
const dateRetourPrevue = new Date();
|
||||
dateRetourPrevue.setDate(dateRetourPrevue.getDate() + DUREE_EMPRUNT_JOURS);
|
||||
|
||||
const references: ElementReference[] =
|
||||
materiel.accessoires.length > 0
|
||||
? materiel.accessoires.map((liaison) => ({
|
||||
accessoireId: liaison.accessoireId,
|
||||
nomElement: liaison.accessoire.nom,
|
||||
etat: 'PRESENT',
|
||||
quantiteConstatee: liaison.quantiteAttendue,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
accessoireId: null,
|
||||
nomElement: materiel.nom,
|
||||
etat: 'PRESENT',
|
||||
quantiteConstatee: 1,
|
||||
},
|
||||
];
|
||||
const elements = normaliserChecklist(references, request.elements, 'depart');
|
||||
const differences = comparerEtats(references, elements);
|
||||
const responsables = differences.length > 0 ? await findResponsablesParCampus(campusId) : [];
|
||||
const descriptionEcart =
|
||||
differences.length > 0
|
||||
? `Ecart au depart pour ${materiel.nom} : ${differences.join(', ')}.`
|
||||
: undefined;
|
||||
|
||||
return creerEmpruntComplet({
|
||||
utilisateurId,
|
||||
materielId: request.materielId,
|
||||
categorieId: materiel.categorieId,
|
||||
campusId,
|
||||
sallePretId: poste.sallePretId,
|
||||
posteEmpruntId: request.posteEmpruntId,
|
||||
dateRetourPrevue,
|
||||
modeIdentification: request.modeIdentification,
|
||||
commentaireDepart: request.commentaireDepart,
|
||||
elements: request.elements,
|
||||
elements,
|
||||
statutEmprunt: differences.length > 0 ? 'EN_ATTENTE_VALIDATION_DEPART' : 'EN_COURS',
|
||||
statutMateriel: differences.length > 0 ? 'RESERVE' : 'EMPRUNTE',
|
||||
responsablesIds: responsables.map((responsable) => responsable.id),
|
||||
descriptionEcart,
|
||||
});
|
||||
}
|
||||
|
||||
interface ElementDepart {
|
||||
interface ElementReference {
|
||||
accessoireId: number | null;
|
||||
nomElement: string;
|
||||
etat: string;
|
||||
quantiteConstatee: number;
|
||||
}
|
||||
|
||||
interface ResultatComparaison {
|
||||
conforme: boolean;
|
||||
auMoinsUnDeteriore: boolean;
|
||||
details: string[];
|
||||
}
|
||||
|
||||
function trouverRetour(
|
||||
depart: ElementDepart,
|
||||
retour: ChecklistElementRequest[],
|
||||
function trouverElement(
|
||||
reference: ElementReference,
|
||||
elements: ChecklistElementRequest[],
|
||||
): ChecklistElementRequest | undefined {
|
||||
if (depart.accessoireId !== null) {
|
||||
const parId = retour.find((element) => element.accessoireId === depart.accessoireId);
|
||||
if (reference.accessoireId !== null) {
|
||||
const parId = elements.find((element) => element.accessoireId === reference.accessoireId);
|
||||
if (parId) {
|
||||
return parId;
|
||||
}
|
||||
}
|
||||
return retour.find((element) => element.nomElement === depart.nomElement);
|
||||
return elements.find(
|
||||
(element) =>
|
||||
element.nomElement.trim().toLocaleLowerCase() === reference.nomElement.toLocaleLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
/* RG17 : compare la checklist de départ à celle de retour. Un élément présent au
|
||||
départ mais absent ou détérioré au retour rend la restitution non conforme (RG20). */
|
||||
function comparerChecklists(
|
||||
depart: ElementDepart[],
|
||||
retour: ChecklistElementRequest[],
|
||||
): ResultatComparaison {
|
||||
const details: string[] = [];
|
||||
let auMoinsUnDeteriore = false;
|
||||
function normaliserChecklist(
|
||||
references: ElementReference[],
|
||||
elements: ChecklistElementRequest[],
|
||||
type: 'depart' | 'retour',
|
||||
): ChecklistElementRequest[] {
|
||||
if (elements.length !== references.length) {
|
||||
throw new AppError(400, `La checklist de ${type} doit contenir tous les elements attendus`);
|
||||
}
|
||||
|
||||
for (const elementDepart of depart) {
|
||||
if (elementDepart.etat !== 'PRESENT') {
|
||||
continue;
|
||||
const utilises = new Set<ChecklistElementRequest>();
|
||||
return references.map((reference) => {
|
||||
const element = trouverElement(reference, elements);
|
||||
if (!element || utilises.has(element)) {
|
||||
throw new AppError(400, `Element de checklist manquant : ${reference.nomElement}`);
|
||||
}
|
||||
utilises.add(element);
|
||||
return {
|
||||
accessoireId: reference.accessoireId ?? undefined,
|
||||
nomElement: reference.nomElement,
|
||||
etat: element.etat,
|
||||
quantiteConstatee:
|
||||
type === 'depart' && element.etat === 'PRESENT'
|
||||
? reference.quantiteConstatee
|
||||
: element.quantiteConstatee,
|
||||
commentaire: element.commentaire,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const elementRetour = trouverRetour(elementDepart, retour);
|
||||
if (!elementRetour || elementRetour.etat === 'ABSENT') {
|
||||
details.push(`${elementDepart.nomElement} manquant au retour`);
|
||||
} else if (elementRetour.etat === 'DETERIORE') {
|
||||
details.push(`${elementDepart.nomElement} deteriore au retour`);
|
||||
auMoinsUnDeteriore = true;
|
||||
function comparerEtats(
|
||||
references: ElementReference[],
|
||||
elements: ChecklistElementRequest[],
|
||||
): string[] {
|
||||
const details: string[] = [];
|
||||
|
||||
for (const reference of references) {
|
||||
const element = trouverElement(reference, elements);
|
||||
if (
|
||||
!element ||
|
||||
element.etat !== reference.etat ||
|
||||
element.quantiteConstatee !== reference.quantiteConstatee
|
||||
) {
|
||||
details.push(
|
||||
`${reference.nomElement} : ${reference.etat}/${reference.quantiteConstatee} -> ${
|
||||
element?.etat ?? 'ABSENT'
|
||||
}/${element?.quantiteConstatee ?? 0}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { conforme: details.length === 0, auMoinsUnDeteriore, details };
|
||||
return details;
|
||||
}
|
||||
|
||||
/* RG14-RG20 : restitution d'un emprunt avec comparaison des checklists, clôture
|
||||
ou passage en non conforme, et création automatique d'anomalie + notifications. */
|
||||
export async function restituerEmprunt(
|
||||
utilisateurId: number,
|
||||
empruntId: number,
|
||||
@@ -142,41 +238,38 @@ export async function restituerEmprunt(
|
||||
throw new AppError(409, 'Checklist de depart introuvable');
|
||||
}
|
||||
|
||||
const comparaison = comparerChecklists(
|
||||
checklistDepart.elements.map((element) => ({
|
||||
accessoireId: element.accessoireId,
|
||||
nomElement: element.nomElement,
|
||||
etat: element.etat,
|
||||
})),
|
||||
request.elements,
|
||||
);
|
||||
|
||||
const statutEmprunt: StatutEmprunt = comparaison.conforme ? 'CLOTURE' : 'RETOUR_NON_CONFORME';
|
||||
|
||||
let statutMateriel = 'DISPONIBLE';
|
||||
if (!comparaison.conforme) {
|
||||
statutMateriel = comparaison.auMoinsUnDeteriore ? 'DETERIORE' : 'NON_CONFORME';
|
||||
}
|
||||
const references = checklistDepart.elements.map((element) => ({
|
||||
accessoireId: element.accessoireId,
|
||||
nomElement: element.nomElement,
|
||||
etat: element.etat,
|
||||
quantiteConstatee: element.quantiteConstatee,
|
||||
}));
|
||||
const elements = normaliserChecklist(references, request.elements, 'retour');
|
||||
const differences = comparerEtats(references, elements);
|
||||
const statutEmprunt: StatutEmprunt =
|
||||
differences.length === 0 ? 'CLOTURE' : 'EN_ATTENTE_VALIDATION_RETOUR';
|
||||
const responsables =
|
||||
differences.length > 0 ? await findResponsablesParCampus(emprunt.campusId) : [];
|
||||
const descriptionEcart =
|
||||
differences.length > 0
|
||||
? `Changement au retour pour l'emprunt #${emprunt.id} : ${differences.join(', ')}.`
|
||||
: undefined;
|
||||
|
||||
const donnees: RestituerEmpruntData = {
|
||||
empruntId,
|
||||
utilisateurId,
|
||||
materielId: emprunt.materielId,
|
||||
statutEmprunt,
|
||||
statutMateriel,
|
||||
statutMateriel: differences.length === 0 ? 'DISPONIBLE' : 'EMPRUNTE',
|
||||
modeIdentification: request.modeIdentification,
|
||||
commentaireRetour: request.commentaireRetour,
|
||||
elements: request.elements,
|
||||
elements,
|
||||
campusId: emprunt.campusId,
|
||||
sallePretId: emprunt.sallePretId,
|
||||
posteEmpruntId: emprunt.posteEmpruntId,
|
||||
responsablesIds: responsables.map((responsable) => responsable.id),
|
||||
descriptionEcart,
|
||||
};
|
||||
|
||||
if (!comparaison.conforme) {
|
||||
const responsables = await findResponsablesParCampus(emprunt.campusId);
|
||||
donnees.anomalie = {
|
||||
type: 'RETOUR_NON_CONFORME',
|
||||
description: `Retour non conforme : ${comparaison.details.join(', ')}.`,
|
||||
responsablesIds: responsables.map((responsable) => responsable.id),
|
||||
};
|
||||
}
|
||||
|
||||
return restituerEmpruntComplet(donnees);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
countEmpruntsParStatut,
|
||||
countMaterielsParStatut,
|
||||
countNotificationsNonLues,
|
||||
deciderEcartResponsable,
|
||||
findActiviteRecente,
|
||||
findEcartResponsableById,
|
||||
findEcartsResponsable,
|
||||
findAnomalieResponsableById,
|
||||
findAnomaliesResponsable,
|
||||
findEmpruntsResponsable,
|
||||
@@ -21,6 +24,7 @@ import {
|
||||
HistoriqueResponsable,
|
||||
MaterielResponsable,
|
||||
NotificationResponsable,
|
||||
EcartResponsable,
|
||||
} from '../repositories/responsable.repository';
|
||||
import {
|
||||
STATUT_ANOMALIE,
|
||||
@@ -72,6 +76,20 @@ export interface ChangerStatutAnomalieResponsableRequest {
|
||||
observation?: string;
|
||||
}
|
||||
|
||||
export interface DeciderEcartResponsableRequest {
|
||||
decision: 'CONFIRMER' | 'REFUSER';
|
||||
observation?: string;
|
||||
statutMateriel?: StatutMateriel;
|
||||
}
|
||||
|
||||
const STATUTS_MATERIEL_RETOUR_CONFIRMES: readonly StatutMateriel[] = [
|
||||
'DISPONIBLE',
|
||||
'NON_CONFORME',
|
||||
'DETERIORE',
|
||||
'MAINTENANCE',
|
||||
'INDISPONIBLE',
|
||||
];
|
||||
|
||||
const TRANSITIONS_ANOMALIE: Record<StatutAnomalie, StatutAnomalie[]> = {
|
||||
DETECTEE: ['EN_COURS_TRAITEMENT'],
|
||||
EN_COURS_TRAITEMENT: ['RESOLUE'],
|
||||
@@ -180,6 +198,19 @@ export function parseAnomalieId(value: unknown): number {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function parseEmpruntId(value: unknown): number {
|
||||
if (typeof value !== 'string') {
|
||||
throw new AppError(400, 'empruntId invalide');
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new AppError(400, 'empruntId invalide');
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function parsePositiveIntQuery(value: unknown, champ: string): number | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
@@ -234,6 +265,101 @@ export function listerEmpruntsResponsable(
|
||||
return findEmpruntsResponsable(campusId, filtres);
|
||||
}
|
||||
|
||||
export function listerEcartsResponsable(
|
||||
roleCode: string,
|
||||
campusId: number,
|
||||
): Promise<EcartResponsable[]> {
|
||||
verifierResponsable(roleCode);
|
||||
return findEcartsResponsable(campusId);
|
||||
}
|
||||
|
||||
function decrireChecklist(ecart: EcartResponsable, type: 'DEPART' | 'RETOUR'): string {
|
||||
const checklist = ecart.checklists.find((item) => item.type === type);
|
||||
if (!checklist) {
|
||||
throw new AppError(409, `Checklist de ${type.toLocaleLowerCase()} introuvable`);
|
||||
}
|
||||
|
||||
return checklist.elements
|
||||
.map((element) => `${element.nomElement}=${element.etat}/${element.quantiteConstatee}`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
export async function deciderEcartResponsableService(
|
||||
responsableId: number,
|
||||
roleCode: string,
|
||||
campusId: number,
|
||||
empruntId: number,
|
||||
request: DeciderEcartResponsableRequest,
|
||||
): Promise<EcartResponsable> {
|
||||
verifierResponsable(roleCode);
|
||||
|
||||
const ecart = await findEcartResponsableById(campusId, empruntId);
|
||||
if (!ecart) {
|
||||
throw new AppError(404, 'Ecart en attente introuvable');
|
||||
}
|
||||
|
||||
const typeEcart = ecart.statut === 'EN_ATTENTE_VALIDATION_DEPART' ? 'DEPART' : 'RETOUR';
|
||||
if (
|
||||
typeEcart === 'RETOUR' &&
|
||||
request.decision === 'CONFIRMER' &&
|
||||
(!request.statutMateriel || !STATUTS_MATERIEL_RETOUR_CONFIRMES.includes(request.statutMateriel))
|
||||
) {
|
||||
throw new AppError(400, 'statutMateriel final requis pour confirmer le retour');
|
||||
}
|
||||
|
||||
const description =
|
||||
`Ecart ${typeEcart.toLocaleLowerCase()} ${request.decision.toLocaleLowerCase()} ` +
|
||||
`pour l'emprunt #${ecart.id} : ${decrireChecklist(ecart, typeEcart)}.`;
|
||||
|
||||
let elementsDepartCorriges: Array<{ id: number; quantiteConstatee: number }> | undefined;
|
||||
if (typeEcart === 'DEPART' && request.decision === 'REFUSER') {
|
||||
const checklistDepart = ecart.checklists.find((checklist) => checklist.type === 'DEPART');
|
||||
if (!checklistDepart) {
|
||||
throw new AppError(409, 'Checklist de depart introuvable');
|
||||
}
|
||||
elementsDepartCorriges = checklistDepart.elements.map((element) => {
|
||||
const accessoire = ecart.materiel.accessoires.find(
|
||||
(liaison) => liaison.accessoireId === element.accessoireId,
|
||||
);
|
||||
return {
|
||||
id: element.id,
|
||||
quantiteConstatee: accessoire?.quantiteAttendue ?? 1,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
return await deciderEcartResponsable({
|
||||
empruntId,
|
||||
campusId,
|
||||
responsableId,
|
||||
statutAttendu: ecart.statut,
|
||||
statutEmpruntFinal:
|
||||
typeEcart === 'DEPART'
|
||||
? 'EN_COURS'
|
||||
: request.decision === 'CONFIRMER'
|
||||
? 'RETOUR_NON_CONFORME'
|
||||
: 'CLOTURE',
|
||||
statutMaterielFinal:
|
||||
typeEcart === 'DEPART'
|
||||
? 'EMPRUNTE'
|
||||
: request.decision === 'CONFIRMER'
|
||||
? (request.statutMateriel as StatutMateriel)
|
||||
: 'DISPONIBLE',
|
||||
typeEcart,
|
||||
decision: request.decision,
|
||||
description,
|
||||
observation: request.observation,
|
||||
elementsDepartCorriges,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'ECART_DEJA_TRAITE') {
|
||||
throw new AppError(409, 'Cet ecart a deja ete traite');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function listerMaterielsResponsable(
|
||||
roleCode: string,
|
||||
campusId: number,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
class ChecklistElementItem {
|
||||
const ChecklistElementItem({
|
||||
required this.accessoireId,
|
||||
required this.nom,
|
||||
required this.etat,
|
||||
required this.quantite,
|
||||
});
|
||||
|
||||
factory ChecklistElementItem.fromJson(Map<String, dynamic> json) {
|
||||
return ChecklistElementItem(
|
||||
accessoireId: json["accessoireId"] as int?,
|
||||
nom: json["nomElement"] as String,
|
||||
etat: json["etat"] as String,
|
||||
quantite: json["quantiteConstatee"] as int,
|
||||
);
|
||||
}
|
||||
|
||||
final int? accessoireId;
|
||||
final String nom;
|
||||
final String etat;
|
||||
final int quantite;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import "materiel_item.dart";
|
||||
import "checklist_element_item.dart";
|
||||
|
||||
/// Un emprunt en cours de l'étudiant, affiché dans le parcours de restitution
|
||||
/// et renvoyé par l'API.
|
||||
@@ -9,6 +10,7 @@ class EmpruntItem {
|
||||
required this.dateEmprunt,
|
||||
required this.retourPrevu,
|
||||
required this.statut,
|
||||
required this.checklistDepart,
|
||||
});
|
||||
|
||||
factory EmpruntItem.fromJson(Map<String, dynamic> json) {
|
||||
@@ -21,6 +23,13 @@ class EmpruntItem {
|
||||
dateEmprunt: _dateHeure(dateEmprunt),
|
||||
retourPrevu: _date(retourPrevu),
|
||||
statut: json["statut"] as String,
|
||||
checklistDepart: (json["checklistDepart"] as List<dynamic>? ?? const [])
|
||||
.map(
|
||||
(element) => ChecklistElementItem.fromJson(
|
||||
Map<String, dynamic>.from(element as Map),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +38,7 @@ class EmpruntItem {
|
||||
final String dateEmprunt;
|
||||
final String retourPrevu;
|
||||
final String statut;
|
||||
final List<ChecklistElementItem> checklistDepart;
|
||||
|
||||
EmpruntItem copyWith({MaterielItem? materiel, String? statut}) {
|
||||
return EmpruntItem(
|
||||
@@ -37,6 +47,7 @@ class EmpruntItem {
|
||||
dateEmprunt: dateEmprunt,
|
||||
retourPrevu: retourPrevu,
|
||||
statut: statut ?? this.statut,
|
||||
checklistDepart: checklistDepart,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 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),
|
||||
@@ -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 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),
|
||||
@@ -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),
|
||||
|
||||
@@ -17,18 +17,22 @@ class EmpruntService {
|
||||
"materielId": materielId,
|
||||
"posteEmpruntId": posteEmpruntId,
|
||||
"modeIdentification": "MAIL_ENSUP",
|
||||
if (commentaireDepart.trim().isNotEmpty) "commentaireDepart": commentaireDepart.trim(),
|
||||
if (commentaireDepart.trim().isNotEmpty)
|
||||
"commentaireDepart": commentaireDepart.trim(),
|
||||
"elements": elements,
|
||||
});
|
||||
|
||||
final data = (reponse as Map<String, dynamic>)["data"] as Map<String, dynamic>;
|
||||
final data =
|
||||
(reponse as Map<String, dynamic>)["data"] as Map<String, dynamic>;
|
||||
return EmpruntResponse.fromJson(data);
|
||||
}
|
||||
|
||||
static Future<List<EmpruntItem>> getMesEmprunts() async {
|
||||
final reponse = await ApiClient.get("/mes-emprunts");
|
||||
final data = (reponse as Map<String, dynamic>)["data"] as List<dynamic>;
|
||||
return data.map((json) => EmpruntItem.fromJson(json as Map<String, dynamic>)).toList();
|
||||
return data
|
||||
.map((json) => EmpruntItem.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
static Future<EmpruntResponse> restituerEmprunt({
|
||||
@@ -38,11 +42,31 @@ class EmpruntService {
|
||||
}) async {
|
||||
final reponse = await ApiClient.post("/emprunts/$empruntId/restitution", {
|
||||
"modeIdentification": "MAIL_ENSUP",
|
||||
if (commentaireRetour.trim().isNotEmpty) "commentaireRetour": commentaireRetour.trim(),
|
||||
if (commentaireRetour.trim().isNotEmpty)
|
||||
"commentaireRetour": commentaireRetour.trim(),
|
||||
"elements": elements,
|
||||
});
|
||||
|
||||
final data = (reponse as Map<String, dynamic>)["data"] as Map<String, dynamic>;
|
||||
final data =
|
||||
(reponse as Map<String, dynamic>)["data"] as Map<String, dynamic>;
|
||||
return EmpruntResponse.fromJson(data);
|
||||
}
|
||||
|
||||
static Future<bool> signalerTentativeRetour({
|
||||
required int empruntId,
|
||||
required String nomElement,
|
||||
required String etatInitial,
|
||||
required String etatDemande,
|
||||
}) async {
|
||||
final reponse = await ApiClient.post(
|
||||
"/emprunts/$empruntId/alerte-changement-retour",
|
||||
{
|
||||
"nomElement": nomElement,
|
||||
"etatInitial": etatInitial,
|
||||
"etatDemande": etatDemande,
|
||||
},
|
||||
);
|
||||
final data = Map<String, dynamic>.from(reponse["data"] as Map);
|
||||
return data["alerteCreee"] as bool;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ class ResponsableService {
|
||||
static Future<List<Map<String, dynamic>>> emprunts() =>
|
||||
_liste("/responsable/emprunts");
|
||||
|
||||
static Future<List<Map<String, dynamic>>> ecarts() =>
|
||||
_liste("/responsable/ecarts");
|
||||
|
||||
static Future<List<Map<String, dynamic>>> materiels() =>
|
||||
_liste("/responsable/materiels");
|
||||
|
||||
@@ -49,6 +52,22 @@ class ResponsableService {
|
||||
return Map<String, dynamic>.from(reponse["data"] as Map);
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> deciderEcart({
|
||||
required int empruntId,
|
||||
required String decision,
|
||||
String? observation,
|
||||
String? statutMateriel,
|
||||
}) async {
|
||||
final reponse =
|
||||
await ApiClient.patch("/responsable/ecarts/$empruntId/decision", {
|
||||
"decision": decision,
|
||||
if (observation != null && observation.trim().isNotEmpty)
|
||||
"observation": observation.trim(),
|
||||
"statutMateriel": ?statutMateriel,
|
||||
});
|
||||
return Map<String, dynamic>.from(reponse["data"] as Map);
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> _liste(String chemin) async {
|
||||
final reponse = await ApiClient.get(chemin);
|
||||
final data = reponse["data"] as List;
|
||||
|
||||
@@ -23,6 +23,8 @@ Ces décisions s'appliquent à tout le projet, sauf mention contraire.
|
||||
| Workflow Git : `develop` comme branche d'intégration + branches dédiées `feat/...`, `fix/...`, `docs/...` | `master` reste figé ; chaque changement logique est isolé sur une branche puis mergé dans `develop`, conformément à `CLAUDE.md`. |
|
||||
| Process avant chaque migration : `prisma format` -> `prisma validate` -> `npm run build` -> migration | Détecter toute erreur de schéma/typage avant de toucher la base. |
|
||||
| DTO nommés `XxxResponse` (sortie) et `XxxRequest` (entrée) | Le sens est explicite dans le nom. Sortie = mapping (`toXxxResponse`), entrée = validation. |
|
||||
| Confirmation responsable uniquement en cas d'écart | Les checklists sont préremplies. Sans modification, le départ ou le retour est automatique ; une différence déclarée reste provisoire jusqu'à la décision du responsable. |
|
||||
| Un seul emprunt bloquant par catégorie et par étudiant | La catégorie reste bloquée jusqu'à la clôture du retour, sans limite calendaire après clôture. |
|
||||
|
||||
---
|
||||
|
||||
@@ -35,11 +37,11 @@ l'historique complet, y compris des étapes devenues obsolètes après brancheme
|
||||
|---|---|---|
|
||||
| Infrastructure | Terminé | Docker Compose SQL Server + Adminer, monorepo, backend Node/TS, frontend Flutter Web. |
|
||||
| Base de données | Terminé pour la V1 | Schéma Prisma 16 entités, migrations, seeds et fixtures de démonstration. |
|
||||
| API étudiant | Terminé pour la V1 | Catalogue, détail matériel, création d'emprunt, mes emprunts, restitution, anomalie automatique. |
|
||||
| Frontend étudiant | Fonctionnel pour la V1 | Parcours API complet, ordre identification puis choix validé, interface premium et responsive fidèle à la maquette. |
|
||||
| API étudiant | À adapter au nouveau workflow | Les parcours automatiques restent valides ; le retour doit être prérempli depuis le départ et les seules différences doivent être mises en attente. |
|
||||
| Frontend étudiant | À adapter au nouveau workflow | Interface premium conservée ; préremplissage et écran d'attente à ajouter lorsqu'une valeur est modifiée. |
|
||||
| Authentification | Prête pour Azure, démo active | Double mode sécurisé : `x-user-email` seulement en développement, MSAL/OIDC + Bearer JWT en mode Azure. Validation réelle en attente des identifiants Entra ID. |
|
||||
| API responsable | Terminé pour la V1 | Dashboard, emprunts, stock, anomalies, notifications, historique et export CSV, avec contrôle du rôle et du campus. |
|
||||
| Frontend responsable | Fonctionnel pour la V1 | Espace de supervision responsive avec navigation dédiée, dashboard métier, vues API et export CSV opérationnel. |
|
||||
| API responsable | Partielle pour le nouveau workflow | Supervision existante ; confirmation des écarts de départ et de retour à ajouter avec alertes et transitions atomiques. |
|
||||
| Frontend responsable | Partiel pour le nouveau workflow | Dashboard existant ; file des écarts, contrôle des checklists et décisions à ajouter. |
|
||||
| Tests automatisés | Non démarré | Tests backend/frontend/E2E à ajouter ; tests runtime manuels effectués. |
|
||||
| Documentation | Partielle | README principal et documents de conception présents ; OpenAPI, guides utilisateur et captures restent à produire. |
|
||||
|
||||
@@ -68,7 +70,9 @@ Notes :
|
||||
- le wrapper `flutter` peut rester bloqué dans l'environnement Codex ; l'analyse directe avec l'exécutable Dart fonctionne et ne signale aucune erreur ;
|
||||
- la compilation Web complète a été validée avec l'exécutable Dart du SDK Flutter et produit `build/web`.
|
||||
|
||||
## Scénario de démo validé
|
||||
## Scénario de démo historique
|
||||
|
||||
> Ce scénario valide toujours les parcours automatiques sans écart. Les branches avec modification de checklist et confirmation responsable restent à implémenter et à tester.
|
||||
|
||||
Scénario étudiant validé avec SQL Server Docker et backend compilé :
|
||||
|
||||
@@ -272,7 +276,8 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
|
||||
| Tailles de colonnes `@db.NVarChar(n)` | À cadrer | Faible V1, important pour qualité BDD. |
|
||||
| Swagger/OpenAPI | À faire | Moyen : utile pour tester/documenter l'API. |
|
||||
| Validation réelle Azure AD | En attente des identifiants Entra ID | Fort : le code OIDC/MSAL/JWT est prêt, mais une connexion Microsoft réelle doit encore être testée sur le tenant ENSUP. |
|
||||
| Limite d'emprunts actifs par étudiant | À valider métier | Non bloquant V1 ; décision métier non présente dans les règles actuelles. |
|
||||
| Blocage d'une catégorie jusqu'à la clôture du retour | Décidé, à implémenter | Fort : contrôle à appliquer atomiquement lors de toute demande d'emprunt. |
|
||||
| Confirmation responsable des écarts déclarés | Décidée, à implémenter | Fort : nécessite des statuts conditionnels, alertes, endpoints et écrans de décision. |
|
||||
|
||||
---
|
||||
|
||||
@@ -458,6 +463,19 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
|
||||
- Limite actuelle : faute d'identifiants Entra ID, le flux Microsoft réel n'a pas encore pu être exécuté. Le QR sécurisé reste un sous-bloc séparé à développer après le SSO.
|
||||
- Commits : `313bb1c`, `530bd52`, `ef31e2a`, `dc208fa`, `36224f3`.
|
||||
|
||||
### Étape 48 — Changement métier : confirmation responsable des écarts
|
||||
- Décision corrigée après précision métier : le responsable ne valide pas systématiquement chaque départ et chaque retour.
|
||||
- Au départ, la checklist est préremplie avec l'état de référence, normalement tous les éléments présents. Sans modification, l'emprunt démarre automatiquement.
|
||||
- Si l'étudiant modifie une valeur au départ, le matériel est réservé, le responsable est alerté et l'état déclaré reste provisoire jusqu'à sa confirmation.
|
||||
- Au retour, la checklist est automatiquement préremplie avec l'état de départ réellement validé.
|
||||
- Sans modification au retour, la restitution est clôturée automatiquement.
|
||||
- Dès que l'étudiant tente de modifier une valeur au retour, le responsable est alerté. Aucun changement de l'état officiel du matériel n'est appliqué avant sa décision.
|
||||
- La confirmation du responsable authentifie le changement et crée l'anomalie ; un refus conserve l'état précédemment validé.
|
||||
- Un étudiant ne peut avoir qu'un emprunt bloquant d'une même catégorie. La catégorie est débloquée après la clôture automatique ou la décision du responsable sur un retour modifié.
|
||||
- Chaque tentative de modification, décision, anomalie et changement d'état officiel est historisé.
|
||||
- `CLAUDE.md`, `CONTEXT.md`, les règles métier, `TODO.md`, le README et la maquette textuelle sont synchronisés avant toute modification du code.
|
||||
- Un diagramme Mermaid devient la source de vérité du parcours ; le PNG contradictoire est conservé uniquement comme historique.
|
||||
|
||||
---
|
||||
|
||||
*Dernière mise à jour : 2026-07-23 — SSO Microsoft Entra ID prêt à configurer, mode démo sécurisé actif.*
|
||||
*Dernière mise à jour : 2026-07-24 — Confirmation responsable uniquement en cas d'écart déclaré.*
|
||||
|
||||
Reference in New Issue
Block a user