46 Commits

Author SHA1 Message Date
SaidSoighiri94 d02d47fb87 fix(responsable): show horizontal table scrollbars 2026-07-24 16:40:08 +02:00
SaidSoighiri94 c03df5e810 merge: responsable supervision tables 2026-07-24 16:13:27 +02:00
SaidSoighiri94 368cd633c5 fix(responsable): refresh after discrepancy decision 2026-07-24 16:02:20 +02:00
SaidSoighiri94 38efe6a873 feat(responsable): add anomaly and history tables 2026-07-24 15:47:48 +02:00
SaidSoighiri94 ec36349bad feat(responsable): add material stock table 2026-07-24 15:22:58 +02:00
SaidSoighiri94 5121cac657 feat(responsable): improve loan supervision table 2026-07-24 15:00:16 +02:00
SaidSoighiri94 4ee593c8d4 feat(frontend): improve application feedback messages 2026-07-24 14:08:55 +02:00
SaidSoighiri94 a562b3248f feat(loans): confirm declared material state changes 2026-07-24 13:41:40 +02:00
SaidSoighiri94 3739e9b0a0 merge: Azure SSO integration 2026-07-23 16:09:20 +02:00
SaidSoighiri94 cb71b8fd8c chore(db): add SQL Server demo snapshot 2026-07-23 15:55:13 +02:00
SaidSoighiri94 81b23e0707 docs: record SSO integration progress 2026-07-23 15:54:47 +02:00
SaidSoighiri94 36224f30ee feat(auth): restore authenticated user profile 2026-07-23 14:39:35 +02:00
SaidSoighiri94 dc208fa13a feat(auth): connect Flutter login to MSAL 2026-07-23 14:13:58 +02:00
SaidSoighiri94 ef31e2ab8c feat(auth): prepare MSAL browser integration 2026-07-23 13:38:20 +02:00
SaidSoighiri94 530bd52cb5 feat(frontend): simulate authenticated user sessions 2026-07-23 12:39:12 +02:00
SaidSoighiri94 313bb1c6ef feat(auth): secure backend authentication modes 2026-07-23 12:35:21 +02:00
SaidSoighiri94 f7ce98343e merge: student premium workspace 2026-07-23 10:30:03 +02:00
SaidSoighiri94 669ed85924 docs: record student workspace polish 2026-07-23 10:29:47 +02:00
SaidSoighiri94 695b0d3965 style(frontend): polish student workspace 2026-07-23 10:27:27 +02:00
SaidSoighiri94 1c8ed77ebf fix(frontend): correct responsable brand label 2026-07-23 09:56:19 +02:00
SaidSoighiri94 1f0a120ecb merge: responsable history csv export 2026-07-23 09:42:27 +02:00
SaidSoighiri94 cb3597874d docs: record responsable csv export 2026-07-23 09:42:14 +02:00
SaidSoighiri94 c85fab8ca3 feat(frontend): download responsable history csv 2026-07-23 09:42:01 +02:00
SaidSoighiri94 95c9080777 merge: responsable premium dashboard 2026-07-23 09:25:47 +02:00
SaidSoighiri94 6ac94becfb docs: record responsable workspace redesign 2026-07-23 09:25:30 +02:00
SaidSoighiri94 ef74fd49bc style(frontend): redesign responsable workspace 2026-07-22 17:26:00 +02:00
SaidSoighiri94 1677015254 merge: synchronize project review status 2026-07-22 16:50:54 +02:00
SaidSoighiri94 9a71eb3f34 docs: synchronize project review status 2026-07-22 16:50:37 +02:00
SaidSoighiri94 2a533e3368 merge: responsable frontend polish 2026-07-18 23:32:37 +02:00
SaidSoighiri94 545d9f14ac feat(frontend): polish responsable workspace 2026-07-17 13:58:30 +02:00
SaidSoighiri94 4b635b8654 merge: responsable frontend api 2026-07-17 11:58:16 +02:00
SaidSoighiri94 2a1281b0e5 feat(frontend): add responsable dashboard screen 2026-07-17 11:55:44 +02:00
SaidSoighiri94 736328c66b merge: responsable anomalies status cycle 2026-07-17 10:07:10 +02:00
SaidSoighiri94 2772f4cfd4 feat(backend): add responsable anomalies status cycle 2026-07-17 10:06:39 +02:00
SaidSoighiri94 9d8ae70063 merge: responsable historique api 2026-07-16 16:48:02 +02:00
SaidSoighiri94 92e25356bf feat(backend): add responsable historique endpoint 2026-07-16 16:45:41 +02:00
SaidSoighiri94 832aa51e23 merge: responsable notifications api 2026-07-15 19:47:55 +02:00
SaidSoighiri94 d2d2245675 feat(backend): add responsable notifications endpoints 2026-07-15 15:50:08 +02:00
SaidSoighiri94 a14d6827ee merge: responsable anomalies api 2026-07-15 15:41:58 +02:00
SaidSoighiri94 1ce023de71 feat(backend): add responsable anomalies endpoint 2026-07-15 15:30:24 +02:00
SaidSoighiri94 26f036046d merge: responsable stock api 2026-07-15 12:57:19 +02:00
SaidSoighiri94 c76f546f2a feat(backend): add responsable stock endpoint 2026-07-15 12:35:16 +02:00
SaidSoighiri94 2c81bd7208 merge: responsable emprunts consultation api 2026-07-15 12:25:47 +02:00
SaidSoighiri94 f7f61a8ebd feat(backend): add responsable emprunts endpoint 2026-07-15 11:47:36 +02:00
SaidSoighiri94 b2a5f8cd94 merge: responsable dashboard api 2026-07-15 11:36:57 +02:00
SaidSoighiri94 a3795b41a0 feat(backend): add responsable dashboard endpoint 2026-07-15 11:04:09 +02:00
59 changed files with 11832 additions and 840 deletions
+23 -19
View File
@@ -2,7 +2,7 @@
EME est une application web de gestion des emprunts de matériel pédagogique pour ENSUP / Ensitech. 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 ## Stack
@@ -158,19 +158,21 @@ Application :
http://localhost:5000 http://localhost:5000
``` ```
## Parcours de démo V1 ## Parcours cible V1
1. Ouvrir `http://localhost:5000`. 1. Ouvrir `http://localhost:5000`.
2. S'identifier avec l'option mail ENSUP ou carte étudiante. 2. S'identifier avec l'option mail ENSUP ou carte étudiante.
3. Cliquer sur `Emprunter`. 3. Cliquer sur `Emprunter`.
4. Sélectionner un matériel disponible. 4. Sélectionner un matériel disponible.
5. Valider la checklist de départ. 5. Vérifier la checklist de départ préremplie avec l'état de référence.
6. Vérifier la confirmation de l'emprunt. 6. Sans modification, l'emprunt démarre automatiquement.
7. Revenir à l'accueil. 7. Si un élément est modifié, le responsable est alerté et confirme ou refuse cet écart avant le départ.
8. Cliquer sur `Restituer`. 8. Au retour, vérifier la checklist automatiquement préremplie avec l'état de départ validé.
9. Sélectionner un emprunt en cours. 9. Sans modification, la restitution est clôturée automatiquement.
10. Valider un retour conforme ou indiquer un élément absent/détérioré. 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. Vérifier le résultat conforme ou non conforme. 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 ## Endpoints principaux
@@ -212,20 +214,22 @@ Note : dans l'environnement Codex, `flutter analyze` et `dart format` ont déjà
## État V1 ## État V1
Terminé pour la V1 étudiant : Déjà implémenté :
- catalogue matériel ; - catalogue et détail matériel ;
- détail matériel ; - checklists de départ et de retour ;
- emprunt avec checklist de départ ; - espaces étudiant et responsable ;
- restitution avec checklist de retour ; - supervision, notifications, anomalies et historique ;
- détection automatique de retour non conforme ; - SSO Microsoft Entra ID prêt à configurer, avec mode démo sécurisé ;
- fixtures de démonstration ; - fixtures de démonstration.
- tests runtime manuels du parcours étudiant.
Reste à faire : Reste à faire :
- authentification Azure AD réelle ; - préremplir la restitution avec l'état validé au départ ;
- parcours responsable matériel ; - 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 ; - OpenAPI / Swagger ;
- tests automatisés ; - tests automatisés ;
- documentation utilisateur complète ; - documentation utilisateur complète ;
+50
View File
@@ -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é.
+11 -4
View File
@@ -96,9 +96,13 @@ classDiagram
+ModeIdentification modeIdentificationRetour +ModeIdentification modeIdentificationRetour
+String commentaireDepart +String commentaireDepart
+String commentaireRetour +String commentaireRetour
+creer() +confirmerDepartInchange()
+enregistrerRetour() +soumettreEcartDepart()
+cloturer() +confirmerEcartDepart()
+initialiserRetourDepuisDepart()
+confirmerRetourInchange()
+soumettreEcartRetour()
+confirmerEcartRetour()
+marquerEnRetard() +marquerEnRetard()
} }
@@ -152,6 +156,7 @@ classDiagram
class StatutMateriel { class StatutMateriel {
<<enumeration>> <<enumeration>>
DISPONIBLE DISPONIBLE
RESERVE
EMPRUNTE EMPRUNTE
NON_CONFORME NON_CONFORME
DETERIORE DETERIORE
@@ -161,8 +166,10 @@ classDiagram
class StatutEmprunt { class StatutEmprunt {
<<enumeration>> <<enumeration>>
EN_ATTENTE_VALIDATION_DEPART
EN_COURS EN_COURS
EN_RETARD EN_RETARD
EN_ATTENTE_VALIDATION_RETOUR
CLOTURE CLOTURE
RETOUR_NON_CONFORME RETOUR_NON_CONFORME
ANNULE ANNULE
@@ -237,4 +244,4 @@ classDiagram
Anomalie "0..1" --> "0..*" Notification : genere Anomalie "0..1" --> "0..*" Notification : genere
``` ```
+31 -24
View File
@@ -1,5 +1,5 @@
# Règles métier — EME # 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é. 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. 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** **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** **RG08 — Accessoires attendus**
Chaque matériel peut être associé à une liste d'accessoires attendus (chargeur, souris, câble, housse, etc.). 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** **RG10 — Affichage des matériels disponibles**
Après identification, le système affiche uniquement les matériels disponibles du campus concerné. Après identification, le système affiche uniquement les matériels disponibles du campus concerné.
**RG11Checklist de départ obligatoire** **RG10AUnicité d'emprunt actif par catégorie**
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`. 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** **RG11 — Checklist de départ obligatoire**
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`. 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** **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. 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. 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** **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** **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** **RG18 — Retour automatique sans écart**
Si la checklist de retour correspond à celle de départ, l'emprunt est clôturé et le matériel repasse au statut `DISPONIBLE`. 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** **RG19 — Confirmation d'un changement au retour**
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é. 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 ## 6. Anomalies
**RG20 — Création automatique d'une anomalie** **RG20 — Création d'une anomalie confirmée**
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. 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** **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. 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 ## 7. Notifications
**RG24 — Création automatique d'une notification** **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** **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. 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 ## 8. Supervision par le responsable matériel
**RG26 — Périmètre de supervision** **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** **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é. 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** **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** **RG29 — Consultation du stock**
Le responsable matériel peut consulter l'état du stock et les statuts des matériels. Le responsable matériel peut consulter l'état du stock et les statuts des matériels.
**RG30 — Gestion du matériel et des accessoires** **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** **RG31 — Consultation de l'historique**
Le responsable matériel peut consulter l'historique des emprunts, restitutions, anomalies et changements de statut. 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é ## 9. Historique et traçabilité
**RG32 — Historisation des actions importantes** **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** **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. 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é ## 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é. 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é.
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.
+22 -15
View File
@@ -554,8 +554,8 @@ html,body{height:100%;font-family:var(--body);background:#f8fafc;color:var(--tex
</div> </div>
<div class="cl-row"> <div class="cl-row">
<span>Câble HDMI</span> <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 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 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" 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 class="cb-wrap"><div class="cb" id="cd-4-d" onclick="toggleCb('cd-4','d')"></div></div>
</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 style="display:flex;flex-direction:column;gap:14px;">
<div class="instr-box"> <div class="instr-box">
<div class="lbl" style="margin-bottom:8px;">Instructions</div> <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> </div>
<textarea class="textarea" placeholder="Commentaire ou observation optionnelle..." rows="4"></textarea> <textarea class="textarea" placeholder="Commentaire ou observation optionnelle..." rows="4"></textarea>
<button class="btn btn-dark btn-lg full" onclick="goTo('s-confirmation')"> <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> <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> </button>
</div> </div>
</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> <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>
<div class="success-title">Emprunt enregistré !</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>
<div class="recap-card"> <div class="recap-card">
<div class="recap-label">Récapitulatif de l'emprunt</div> <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">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">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">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> </div>
<button class="btn btn-primary full btn-lg" onclick="goTo('s-home-student')"> <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> <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-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 class="cb-wrap"><div class="cb" id="cr-3-d" onclick="toggleCb('cr-3','d')"></div></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> </div>
<div style="display:flex;flex-direction:column;gap:14px;"> <div style="display:flex;flex-direction:column;gap:14px;">
<div class="instr-box"> <div class="instr-box">
<div class="lbl" style="margin-bottom:8px;">Rappel checklist de départ</div> <div class="lbl" style="margin-bottom:8px;">État validé au départ</div>
<p>Au départ : Chargeur ✗ absent · Souris présente · Housse présente · Câble HDMI ✗ absent</p> <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> </div>
<textarea class="textarea" placeholder="Commentaire optionnel..." rows="4"></textarea> <textarea class="textarea" placeholder="Commentaire optionnel..." rows="4"></textarea>
<button class="btn btn-dark btn-lg full" onclick="goTo('s-anomalie')"> <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> <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> </button>
</div> </div>
</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> <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>
<div class="alert-body"> <div class="alert-body">
<strong>Retour non conforme — Anomalie détectée</strong> <strong>Retour en attente de contrôle</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> <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> </div>
<div class="detail-box"> <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">É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">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">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">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 class="drow"><span class="dk">Date détection</span><span class="dv">28/05/2026 · 10:30</span></div>
</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')"> <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> <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 Retour à l'accueil
@@ -1057,4 +1064,4 @@ function toggleCb(group, type) {
} }
</script> </script>
</body> </body>
</html> </html>
+5 -1
View File
@@ -9,5 +9,9 @@ DB_NAME=eme_db
DB_USER=sa DB_USER=sa
DB_PASSWORD=YourStrongPassword123! DB_PASSWORD=YourStrongPassword123!
AUTH_MODE=demo
AZURE_TENANT_ID= AZURE_TENANT_ID=
AZURE_CLIENT_ID= AZURE_API_CLIENT_ID=
AZURE_API_AUDIENCE=
AZURE_API_SCOPE=access_as_user
AZURE_ALLOWED_EMAIL_DOMAINS=ensup.eu,ensitech.eu
+58 -3
View File
@@ -14,7 +14,8 @@
"cors": "^2.8.6", "cors": "^2.8.6",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
"jsonwebtoken": "^9.0.3" "jsonwebtoken": "^9.0.3",
"jwks-rsa": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
@@ -1068,7 +1069,6 @@
"version": "9.0.10", "version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/ms": "*", "@types/ms": "*",
@@ -1079,7 +1079,6 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
@@ -3074,6 +3073,15 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/jose": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz",
"integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-md4": { "node_modules/js-md4": {
"version": "0.3.2", "version": "0.3.2",
"resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz",
@@ -3134,6 +3142,23 @@
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
"node_modules/jwks-rsa": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-4.1.0.tgz",
"integrity": "sha512-sbkByqyATKYJP5F4RXj03N5TUNC0QLTjCAZvwTzC4BwJZ8e0/cWxN8YROnyUth2g1/ONWi4eSFHeu6oYalrc3Q==",
"license": "MIT",
"dependencies": {
"@types/jsonwebtoken": "^9.0.4",
"debug": "^4.3.4",
"jose": "^6.1.3",
"limiter": "^1.1.5",
"lru-cache": "^11.0.0",
"lru-memoizer": "^3.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >= 23.0.0"
}
},
"node_modules/jws": { "node_modules/jws": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
@@ -3168,6 +3193,11 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/limiter": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
},
"node_modules/locate-path": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -3184,6 +3214,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"license": "MIT"
},
"node_modules/lodash.includes": { "node_modules/lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -3233,6 +3269,25 @@
"devOptional": true, "devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/lru-memoizer": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-3.0.0.tgz",
"integrity": "sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==",
"license": "MIT",
"dependencies": {
"lodash.clonedeep": "^4.5.0",
"lru-cache": "^11.0.1"
}
},
"node_modules/lru.min": { "node_modules/lru.min": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
+2 -1
View File
@@ -43,6 +43,7 @@
"cors": "^2.8.6", "cors": "^2.8.6",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
"jsonwebtoken": "^9.0.3" "jsonwebtoken": "^9.0.3",
"jwks-rsa": "^4.1.0"
} }
} }
+56 -4
View File
@@ -2,6 +2,8 @@ import dotenv from 'dotenv';
dotenv.config(); dotenv.config();
type AuthMode = 'demo' | 'azure';
function required(name: string): string { function required(name: string): string {
const value = process.env[name]; const value = process.env[name];
@@ -28,9 +30,50 @@ function numberFromEnv(name: string, defaultValue: number): number {
return parsed; return parsed;
} }
function authModeFromEnv(nodeEnv: string): AuthMode {
const value = process.env.AUTH_MODE || (nodeEnv === 'production' ? 'azure' : 'demo');
if (value !== 'demo' && value !== 'azure') {
throw new Error('Variable denvironnement invalide: AUTH_MODE');
}
if (nodeEnv === 'production' && value === 'demo') {
throw new Error('AUTH_MODE=demo est interdit en production');
}
return value;
}
function azureValue(name: string, authMode: AuthMode): string {
const value = process.env[name];
if (authMode === 'azure' && !value) {
throw new Error(`Variable d'environnement manquante en mode Azure: ${name}`);
}
return value || '';
}
function listFromEnv(name: string, defaultValue: string[]): string[] {
const value = process.env[name];
if (!value) {
return defaultValue;
}
return value
.split(',')
.map((item) => item.trim().toLowerCase())
.filter((item) => item.length > 0);
}
const nodeEnv = process.env.NODE_ENV || 'development';
const authMode = authModeFromEnv(nodeEnv);
const azureApiClientId = azureValue('AZURE_API_CLIENT_ID', authMode);
export const env = { export const env = {
port: numberFromEnv('PORT', 3000), port: numberFromEnv('PORT', 3000),
nodeEnv: process.env.NODE_ENV || 'development', nodeEnv,
frontendUrl: required('FRONTEND_URL'), frontendUrl: required('FRONTEND_URL'),
databaseUrl: required('DATABASE_URL'), databaseUrl: required('DATABASE_URL'),
db: { db: {
@@ -40,8 +83,17 @@ export const env = {
user: required('DB_USER'), user: required('DB_USER'),
password: required('DB_PASSWORD'), password: required('DB_PASSWORD'),
}, },
azure: { auth: {
tenantId: required('AZURE_TENANT_ID'), mode: authMode,
clientId: required('AZURE_CLIENT_ID'), azure: {
tenantId: azureValue('AZURE_TENANT_ID', authMode),
apiClientId: azureApiClientId,
audience: process.env.AZURE_API_AUDIENCE || azureApiClientId,
scope: process.env.AZURE_API_SCOPE || 'access_as_user',
allowedEmailDomains: listFromEnv('AZURE_ALLOWED_EMAIL_DOMAINS', [
'ensup.eu',
'ensitech.eu',
]),
},
}, },
}; };
@@ -4,10 +4,12 @@ import {
creerEmprunt, creerEmprunt,
listerMesEmpruntsEnCours, listerMesEmpruntsEnCours,
restituerEmprunt, restituerEmprunt,
signalerTentativeModificationRetour,
} from '../services/emprunt.service'; } from '../services/emprunt.service';
import { import {
parseCreerEmpruntRequest, parseCreerEmpruntRequest,
parseRestituerEmpruntRequest, parseRestituerEmpruntRequest,
parseSignalerTentativeRetourRequest,
toEmpruntResponse, toEmpruntResponse,
} from '../dtos/emprunt.dto'; } 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); const emprunt = await restituerEmprunt(user.id, empruntId, request);
res.json({ data: toEmpruntResponse(emprunt) }); 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 } });
}
@@ -0,0 +1,293 @@
import { Request, Response } from 'express';
import { AppError } from '../errors/app-error';
import {
changerStatutAnomalieResponsableService,
deciderEcartResponsableService,
getDashboardResponsable,
listerAnomaliesResponsable,
listerHistoriqueResponsable,
listerNotificationsResponsable,
listerMaterielsResponsable,
listerEmpruntsResponsable,
listerEcartsResponsable,
marquerNotificationResponsableLue,
marquerToutesNotificationsResponsableLues,
parseAnomalieId,
parseAnneeEmprunt,
parseEmpruntId,
parseCategorieId,
parseLu,
parseNotificationId,
parseOrdreTri,
parseDateQuery,
parsePositiveIntQuery,
parseStatutAnomalie,
parseStatutMateriel,
parseStatutEmprunt,
parseTriAnomalie,
parseTriEmprunt,
parseTriHistorique,
parseTriMateriel,
} from '../services/responsable.service';
import {
toAnomalieResponsableResponse,
toDashboardResponsableResponse,
toEmpruntResponsableResponse,
toEcartResponsableResponse,
toHistoriqueResponsableResponse,
toMaterielResponsableResponse,
toNotificationResponsableResponse,
} from '../dtos/responsable.dto';
function asBodyObject(value: unknown): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new AppError(400, 'Corps de requete invalide');
}
return value as Record<string, unknown>;
}
function getHistoriqueFiltres(req: Request) {
const action = typeof req.query.action === 'string' ? req.query.action : undefined;
return {
action,
utilisateurId: parsePositiveIntQuery(req.query.utilisateurId, 'utilisateurId'),
materielId: parsePositiveIntQuery(req.query.materielId, 'materielId'),
empruntId: parsePositiveIntQuery(req.query.empruntId, 'empruntId'),
dateDebut: parseDateQuery(req.query.dateDebut, 'dateDebut'),
dateFin: parseDateQuery(req.query.dateFin, 'dateFin'),
recherche: typeof req.query.q === 'string' ? req.query.q : undefined,
tri: parseTriHistorique(req.query.tri),
ordre: parseOrdreTri(req.query.ordre),
};
}
function escapeCsv(value: string | number | Date | null): string {
if (value === null) {
return '';
}
const raw = value instanceof Date ? value.toISOString() : String(value);
return `"${raw.replace(/"/g, '""')}"`;
}
export async function getDashboard(req: Request, res: Response): Promise<void> {
const user = req.user;
if (!user) {
throw new AppError(401, 'Authentification requise');
}
const dashboard = await getDashboardResponsable(user.id, user.roleCode, user.campusId);
res.json({ data: toDashboardResponsableResponse(dashboard) });
}
export async function getEmprunts(req: Request, res: Response): Promise<void> {
const user = req.user;
if (!user) {
throw new AppError(401, 'Authentification requise');
}
const statut = parseStatutEmprunt(req.query.statut);
const annee = parseAnneeEmprunt(req.query.annee);
const recherche = typeof req.query.q === 'string' ? req.query.q.trim() : undefined;
const tri = parseTriEmprunt(req.query.tri);
const ordre = parseOrdreTri(req.query.ordre);
const emprunts = await listerEmpruntsResponsable(user.roleCode, user.campusId, {
statut,
annee,
recherche: recherche || undefined,
tri,
ordre,
});
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) {
throw new AppError(401, 'Authentification requise');
}
const statut = parseStatutMateriel(req.query.statut);
const categorieId = parseCategorieId(req.query.categorieId);
const recherche = typeof req.query.q === 'string' ? req.query.q : undefined;
const tri = parseTriMateriel(req.query.tri);
const ordre = parseOrdreTri(req.query.ordre);
const materiels = await listerMaterielsResponsable(user.roleCode, user.campusId, {
statut,
categorieId,
recherche,
tri,
ordre,
});
res.json({ data: materiels.map(toMaterielResponsableResponse) });
}
export async function getAnomalies(req: Request, res: Response): Promise<void> {
const user = req.user;
if (!user) {
throw new AppError(401, 'Authentification requise');
}
const statut = parseStatutAnomalie(req.query.statut);
const type = typeof req.query.type === 'string' ? req.query.type : undefined;
const recherche = typeof req.query.q === 'string' ? req.query.q : undefined;
const tri = parseTriAnomalie(req.query.tri);
const ordre = parseOrdreTri(req.query.ordre);
const anomalies = await listerAnomaliesResponsable(user.roleCode, user.campusId, {
statut,
type,
recherche,
tri,
ordre,
});
res.json({ data: anomalies.map(toAnomalieResponsableResponse) });
}
export async function patchAnomalieStatut(req: Request, res: Response): Promise<void> {
const user = req.user;
if (!user) {
throw new AppError(401, 'Authentification requise');
}
const anomalieId = parseAnomalieId(req.params.id);
const body = asBodyObject(req.body);
const statut = parseStatutAnomalie(body.statut);
if (!statut) {
throw new AppError(400, 'statut requis');
}
const observation = typeof body.observation === 'string' ? body.observation : undefined;
const anomalie = await changerStatutAnomalieResponsableService(
user.id,
user.roleCode,
user.campusId,
anomalieId,
{ statut, observation },
);
res.json({ data: toAnomalieResponsableResponse(anomalie) });
}
export async function getNotifications(req: Request, res: Response): Promise<void> {
const user = req.user;
if (!user) {
throw new AppError(401, 'Authentification requise');
}
const lu = parseLu(req.query.lu);
const notifications = await listerNotificationsResponsable(user.id, user.roleCode, { lu });
res.json({ data: notifications.map(toNotificationResponsableResponse) });
}
export async function patchNotificationLue(req: Request, res: Response): Promise<void> {
const user = req.user;
if (!user) {
throw new AppError(401, 'Authentification requise');
}
const notificationId = parseNotificationId(req.params.id);
const notification = await marquerNotificationResponsableLue(
user.id,
user.roleCode,
notificationId,
);
res.json({ data: toNotificationResponsableResponse(notification) });
}
export async function patchNotificationsLues(req: Request, res: Response): Promise<void> {
const user = req.user;
if (!user) {
throw new AppError(401, 'Authentification requise');
}
const count = await marquerToutesNotificationsResponsableLues(user.id, user.roleCode);
res.json({ data: { count } });
}
export async function getHistorique(req: Request, res: Response): Promise<void> {
const user = req.user;
if (!user) {
throw new AppError(401, 'Authentification requise');
}
const historique = await listerHistoriqueResponsable(
user.roleCode,
user.campusId,
getHistoriqueFiltres(req),
);
res.json({ data: historique.map(toHistoriqueResponsableResponse) });
}
export async function getHistoriqueCsv(req: Request, res: Response): Promise<void> {
const user = req.user;
if (!user) {
throw new AppError(401, 'Authentification requise');
}
const historique = await listerHistoriqueResponsable(
user.roleCode,
user.campusId,
getHistoriqueFiltres(req),
);
const lignes = [
['id', 'dateAction', 'action', 'description', 'utilisateur', 'email', 'materiel', 'empruntId']
.map(escapeCsv)
.join(','),
...historique.map((item) =>
[
item.id,
item.dateAction,
item.action,
item.description,
`${item.utilisateur.prenom} ${item.utilisateur.nom}`,
item.utilisateur.email,
item.materiel ? `${item.materiel.nom} (${item.materiel.reference})` : null,
item.empruntId,
]
.map(escapeCsv)
.join(','),
),
];
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="historique-responsable.csv"');
res.send(lignes.join('\n'));
}
+36
View File
@@ -10,9 +10,19 @@ export interface EmpruntResponse {
dateRetourReelle: Date | null; dateRetourReelle: Date | null;
statut: string; statut: string;
materiel: MaterielResponse; materiel: MaterielResponse;
checklistDepart: ChecklistElementResponse[];
}
export interface ChecklistElementResponse {
accessoireId: number | null;
nomElement: string;
etat: string;
quantiteConstatee: number;
} }
export function toEmpruntResponse(emprunt: EmpruntAvecMateriel): EmpruntResponse { export function toEmpruntResponse(emprunt: EmpruntAvecMateriel): EmpruntResponse {
const checklistDepart = emprunt.checklists.find((checklist) => checklist.type === 'DEPART');
return { return {
id: emprunt.id, id: emprunt.id,
dateEmprunt: emprunt.dateEmprunt, dateEmprunt: emprunt.dateEmprunt,
@@ -20,6 +30,13 @@ export function toEmpruntResponse(emprunt: EmpruntAvecMateriel): EmpruntResponse
dateRetourReelle: emprunt.dateRetourReelle, dateRetourReelle: emprunt.dateRetourReelle,
statut: emprunt.statut, statut: emprunt.statut,
materiel: toMaterielResponse(emprunt.materiel), 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[]; 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 { export function parseRestituerEmpruntRequest(body: unknown): RestituerEmpruntRequest {
const obj = asObject(body, 'Corps de requete invalide'); const obj = asObject(body, 'Corps de requete invalide');
+421
View File
@@ -0,0 +1,421 @@
import {
ActiviteResponsable,
AnomalieResponsable,
EmpruntResponsable,
EcartResponsable,
HistoriqueResponsable,
MaterielResponsable,
NotificationResponsable,
StatutCount,
} from '../repositories/responsable.repository';
import { DashboardResponsableData } from '../services/responsable.service';
export interface RepartitionStatutResponse {
total: number;
parStatut: Record<string, number>;
}
export interface ActiviteResponsableResponse {
id: number;
action: string;
description: string;
dateAction: Date;
utilisateur: {
id: number;
nom: string;
prenom: string;
};
materiel: {
id: number;
nom: string;
reference: string;
} | null;
empruntId: number | null;
}
export interface DashboardResponsableResponse {
kpis: {
materiels: RepartitionStatutResponse;
emprunts: RepartitionStatutResponse;
anomalies: RepartitionStatutResponse;
notificationsNonLues: number;
};
activiteRecente: ActiviteResponsableResponse[];
}
export interface EmpruntResponsableResponse {
id: number;
statut: string;
dateEmprunt: Date;
dateRetourPrevue: Date;
dateRetourReelle: Date | null;
etudiant: {
id: number;
nom: string;
prenom: string;
email: string;
classe: string | null;
};
materiel: {
id: number;
nom: string;
reference: string;
statut: string;
categorie: {
id: number;
nom: string;
};
};
}
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;
marque: string;
modele: string;
reference: string;
statut: string;
etatGeneral: string;
categorie: {
id: number;
nom: string;
};
accessoires: Array<{
id: number;
nom: string;
quantiteAttendue: number;
obligatoire: boolean;
}>;
}
export interface AnomalieResponsableResponse {
id: number;
type: string;
description: string;
statut: string;
detecteeAutomatiquement: boolean;
observation: string | null;
dateDetection: Date;
dateResolution: Date | null;
emprunt: {
id: number;
statut: string;
dateEmprunt: Date;
dateRetourPrevue: Date;
dateRetourReelle: Date | null;
};
etudiant: {
id: number;
nom: string;
prenom: string;
email: string;
classe: string | null;
};
materiel: {
id: number;
nom: string;
reference: string;
statut: string;
categorie: {
id: number;
nom: string;
};
};
traitePar: {
id: number;
nom: string;
prenom: string;
} | null;
}
export interface NotificationResponsableResponse {
id: number;
titre: string;
message: string;
type: string;
lu: boolean;
dateCreation: Date;
anomalie: {
id: number;
statut: string;
type: string;
empruntId: number;
materiel: {
id: number;
nom: string;
reference: string;
};
} | null;
}
export interface HistoriqueResponsableResponse {
id: number;
action: string;
description: string;
dateAction: Date;
utilisateur: {
id: number;
nom: string;
prenom: string;
email: string;
};
materiel: {
id: number;
nom: string;
reference: string;
} | null;
empruntId: number | null;
sallePret: {
id: number;
nom: string;
} | null;
posteEmprunt: {
id: number;
nom: string;
} | null;
}
function toRepartition(rows: StatutCount[]): RepartitionStatutResponse {
const parStatut: Record<string, number> = {};
let total = 0;
for (const row of rows) {
parStatut[row.statut] = row.count;
total += row.count;
}
return { total, parStatut };
}
function toActiviteResponse(activite: ActiviteResponsable): ActiviteResponsableResponse {
return {
id: activite.id,
action: activite.action,
description: activite.description,
dateAction: activite.dateAction,
utilisateur: {
id: activite.utilisateur.id,
nom: activite.utilisateur.nom,
prenom: activite.utilisateur.prenom,
},
materiel: activite.materiel
? {
id: activite.materiel.id,
nom: activite.materiel.nom,
reference: activite.materiel.reference,
}
: null,
empruntId: activite.empruntId,
};
}
export function toDashboardResponsableResponse(
data: DashboardResponsableData,
): DashboardResponsableResponse {
return {
kpis: {
materiels: toRepartition(data.materiels),
emprunts: toRepartition(data.emprunts),
anomalies: toRepartition(data.anomalies),
notificationsNonLues: data.notificationsNonLues,
},
activiteRecente: data.activiteRecente.map(toActiviteResponse),
};
}
export function toEmpruntResponsableResponse(
emprunt: EmpruntResponsable,
): EmpruntResponsableResponse {
return {
id: emprunt.id,
statut: emprunt.statut,
dateEmprunt: emprunt.dateEmprunt,
dateRetourPrevue: emprunt.dateRetourPrevue,
dateRetourReelle: emprunt.dateRetourReelle,
etudiant: {
id: emprunt.utilisateur.id,
nom: emprunt.utilisateur.nom,
prenom: emprunt.utilisateur.prenom,
email: emprunt.utilisateur.email,
classe: emprunt.utilisateur.classe,
},
materiel: {
id: emprunt.materiel.id,
nom: emprunt.materiel.nom,
reference: emprunt.materiel.reference,
statut: emprunt.materiel.statut,
categorie: {
id: emprunt.materiel.categorie.id,
nom: emprunt.materiel.categorie.nom,
},
},
};
}
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 {
return {
id: materiel.id,
nom: materiel.nom,
marque: materiel.marque,
modele: materiel.modele,
reference: materiel.reference,
statut: materiel.statut,
etatGeneral: materiel.etatGeneral,
categorie: {
id: materiel.categorie.id,
nom: materiel.categorie.nom,
},
accessoires: materiel.accessoires.map((liaison) => ({
id: liaison.accessoire.id,
nom: liaison.accessoire.nom,
quantiteAttendue: liaison.quantiteAttendue,
obligatoire: liaison.obligatoire,
})),
};
}
export function toAnomalieResponsableResponse(
anomalie: AnomalieResponsable,
): AnomalieResponsableResponse {
return {
id: anomalie.id,
type: anomalie.type,
description: anomalie.description,
statut: anomalie.statut,
detecteeAutomatiquement: anomalie.detecteeAutomatiquement,
observation: anomalie.observation,
dateDetection: anomalie.dateDetection,
dateResolution: anomalie.dateResolution,
emprunt: {
id: anomalie.emprunt.id,
statut: anomalie.emprunt.statut,
dateEmprunt: anomalie.emprunt.dateEmprunt,
dateRetourPrevue: anomalie.emprunt.dateRetourPrevue,
dateRetourReelle: anomalie.emprunt.dateRetourReelle,
},
etudiant: {
id: anomalie.etudiant.id,
nom: anomalie.etudiant.nom,
prenom: anomalie.etudiant.prenom,
email: anomalie.etudiant.email,
classe: anomalie.etudiant.classe,
},
materiel: {
id: anomalie.materiel.id,
nom: anomalie.materiel.nom,
reference: anomalie.materiel.reference,
statut: anomalie.materiel.statut,
categorie: {
id: anomalie.materiel.categorie.id,
nom: anomalie.materiel.categorie.nom,
},
},
traitePar: anomalie.traitePar
? {
id: anomalie.traitePar.id,
nom: anomalie.traitePar.nom,
prenom: anomalie.traitePar.prenom,
}
: null,
};
}
export function toNotificationResponsableResponse(
notification: NotificationResponsable,
): NotificationResponsableResponse {
return {
id: notification.id,
titre: notification.titre,
message: notification.message,
type: notification.type,
lu: notification.lu,
dateCreation: notification.dateCreation,
anomalie: notification.anomalie
? {
id: notification.anomalie.id,
statut: notification.anomalie.statut,
type: notification.anomalie.type,
empruntId: notification.anomalie.emprunt.id,
materiel: {
id: notification.anomalie.materiel.id,
nom: notification.anomalie.materiel.nom,
reference: notification.anomalie.materiel.reference,
},
}
: null,
};
}
export function toHistoriqueResponsableResponse(
historique: HistoriqueResponsable,
): HistoriqueResponsableResponse {
return {
id: historique.id,
action: historique.action,
description: historique.description,
dateAction: historique.dateAction,
utilisateur: {
id: historique.utilisateur.id,
nom: historique.utilisateur.nom,
prenom: historique.utilisateur.prenom,
email: historique.utilisateur.email,
},
materiel: historique.materiel
? {
id: historique.materiel.id,
nom: historique.materiel.nom,
reference: historique.materiel.reference,
}
: null,
empruntId: historique.empruntId,
sallePret: historique.sallePret
? {
id: historique.sallePret.id,
nom: historique.sallePret.nom,
}
: null,
posteEmprunt: historique.posteEmprunt
? {
id: historique.posteEmprunt.id,
nom: historique.posteEmprunt.nom,
}
: null,
};
}
+5 -23
View File
@@ -1,28 +1,10 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import { AppError } from '../errors/app-error'; import { authenticateUser } from '../services/authentication.service';
import { findByEmail } from '../repositories/utilisateur.repository';
import { AuthenticatedUser } from '../types/authenticated-user';
/* Auth simulée (temporaire) : en attendant Azure AD, l'utilisateur courant est
résolu depuis l'en-tête `x-user-email`. À remplacer par la validation du JWT. */
export async function currentUser(req: Request, _res: Response, next: NextFunction): Promise<void> { export async function currentUser(req: Request, _res: Response, next: NextFunction): Promise<void> {
const email = req.header('x-user-email'); req.user = await authenticateUser({
if (!email) { authorization: req.header('authorization'),
throw new AppError(401, 'En-tete x-user-email requis (auth simulee)'); demoEmail: req.header('x-user-email'),
} });
const utilisateur = await findByEmail(email);
if (!utilisateur || !utilisateur.actif) {
throw new AppError(401, 'Utilisateur non reconnu');
}
const authenticated: AuthenticatedUser = {
id: utilisateur.id,
email: utilisateur.email,
roleCode: utilisateur.role.code,
campusId: utilisateur.campusId,
};
req.user = authenticated;
next(); next();
} }
+3
View File
@@ -1,5 +1,6 @@
export const STATUT_MATERIEL = [ export const STATUT_MATERIEL = [
'DISPONIBLE', 'DISPONIBLE',
'RESERVE',
'EMPRUNTE', 'EMPRUNTE',
'NON_CONFORME', 'NON_CONFORME',
'DETERIORE', 'DETERIORE',
@@ -10,8 +11,10 @@ export const STATUT_MATERIEL = [
export type StatutMateriel = (typeof STATUT_MATERIEL)[number]; export type StatutMateriel = (typeof STATUT_MATERIEL)[number];
export const STATUT_EMPRUNT = [ export const STATUT_EMPRUNT = [
'EN_ATTENTE_VALIDATION_DEPART',
'EN_COURS', 'EN_COURS',
'EN_RETARD', 'EN_RETARD',
'EN_ATTENTE_VALIDATION_RETOUR',
'CLOTURE', 'CLOTURE',
'RETOUR_NON_CONFORME', 'RETOUR_NON_CONFORME',
'ANNULE', 'ANNULE',
@@ -4,7 +4,10 @@ import { StatutEmprunt } from '../models/enums';
import { AppError } from '../errors/app-error'; import { AppError } from '../errors/app-error';
export type EmpruntAvecMateriel = Prisma.EmpruntGetPayload<{ export type EmpruntAvecMateriel = Prisma.EmpruntGetPayload<{
include: { materiel: { include: { categorie: true } } }; include: {
materiel: { include: { categorie: true } };
checklists: { include: { elements: true } };
};
}>; }>;
export type EmpruntAvecChecklists = Prisma.EmpruntGetPayload<{ 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_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[]> { export function findEnCoursParUtilisateur(utilisateurId: number): Promise<EmpruntAvecMateriel[]> {
return prisma.emprunt.findMany({ return prisma.emprunt.findMany({
@@ -19,7 +28,10 @@ export function findEnCoursParUtilisateur(utilisateurId: number): Promise<Emprun
utilisateurId, utilisateurId,
statut: { in: STATUTS_EN_COURS }, statut: { in: STATUTS_EN_COURS },
}, },
include: { materiel: { include: { categorie: true } } }, include: {
materiel: { include: { categorie: true } },
checklists: { include: { elements: true } },
},
orderBy: { dateRetourPrevue: 'asc' }, 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 { interface ChecklistElementData {
accessoireId?: number; accessoireId?: number;
nomElement: string; nomElement: string;
@@ -42,6 +119,7 @@ interface ChecklistElementData {
export interface CreerEmpruntData { export interface CreerEmpruntData {
utilisateurId: number; utilisateurId: number;
materielId: number; materielId: number;
categorieId: number;
campusId: number; campusId: number;
sallePretId: number; sallePretId: number;
posteEmpruntId: number; posteEmpruntId: number;
@@ -49,59 +127,122 @@ export interface CreerEmpruntData {
modeIdentification: string; modeIdentification: string;
commentaireDepart?: string; commentaireDepart?: string;
elements: ChecklistElementData[]; 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> { export function creerEmpruntComplet(data: CreerEmpruntData): Promise<EmpruntAvecMateriel> {
return prisma.$transaction(async (tx) => { return prisma.$transaction(
// Réservation atomique : empêche un double emprunt du même matériel (RG07). async (tx) => {
const reservation = await tx.materiel.updateMany({ const empruntBloquant = await tx.emprunt.findFirst({
where: { id: data.materielId, statut: 'DISPONIBLE' }, where: {
data: { statut: 'EMPRUNTE' }, utilisateurId: data.utilisateurId,
}); statut: { in: STATUTS_BLOQUANTS },
if (reservation.count === 0) { materiel: { categorieId: data.categorieId },
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,
})),
}, },
}, 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({ const reservation = await tx.materiel.updateMany({
where: { id: emprunt.id }, where: { id: data.materielId, statut: 'DISPONIBLE' },
include: { materiel: { include: { categorie: true } } }, 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 { export interface RestituerEmpruntData {
@@ -113,19 +254,34 @@ export interface RestituerEmpruntData {
modeIdentification: string; modeIdentification: string;
commentaireRetour?: string; commentaireRetour?: string;
elements: ChecklistElementData[]; elements: ChecklistElementData[];
anomalie?: { campusId: number;
type: string; sallePretId: number;
description: string; posteEmpruntId: number;
responsablesIds: 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> { export function restituerEmpruntComplet(data: RestituerEmpruntData): Promise<EmpruntAvecMateriel> {
return prisma.$transaction(async (tx) => { return prisma.$transaction(async (tx) => {
const maintenant = new Date(); 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({ await tx.checklist.create({
data: { data: {
empruntId: data.empruntId, 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({ await tx.materiel.update({
where: { id: data.materielId }, where: { id: data.materielId },
data: { statut: data.statutMateriel }, data: { statut: data.statutMateriel },
}); });
if (data.anomalie) { if (data.descriptionEcart) {
const infoAnomalie = data.anomalie; const tentativeDejaSignalee = await tx.historique.findFirst({
const anomalie = await tx.anomalie.create({ where: {
data: {
empruntId: data.empruntId, empruntId: data.empruntId,
materielId: data.materielId, action: 'TENTATIVE_MODIFICATION_RETOUR',
utilisateurId: data.utilisateurId,
type: infoAnomalie.type,
description: infoAnomalie.description,
statut: 'DETECTEE',
detecteeAutomatiquement: true,
dateDetection: maintenant,
}, },
select: { id: true },
}); });
if (!tentativeDejaSignalee && data.responsablesIds.length > 0) {
if (infoAnomalie.responsablesIds.length > 0) {
await tx.notification.createMany({ await tx.notification.createMany({
data: infoAnomalie.responsablesIds.map((responsableId) => ({ data: data.responsablesIds.map((responsableId) => ({
utilisateurId: responsableId, utilisateurId: responsableId,
anomalieId: anomalie.id, titre: 'Changement signale au retour',
titre: 'Nouvelle anomalie detectee', message: data.descriptionEcart as string,
message: infoAnomalie.description, type: 'ECART_RETOUR',
type: 'ANOMALIE',
})), })),
}); });
} }
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({ return tx.emprunt.findUniqueOrThrow({
where: { id: data.empruntId }, where: { id: data.empruntId },
include: { materiel: { include: { categorie: true } } }, include: {
materiel: { include: { categorie: true } },
checklists: { include: { elements: true } },
},
}); });
}); });
} }
@@ -0,0 +1,640 @@
import { Prisma } from '@prisma/client';
import { prisma } from '../db/prisma';
export interface StatutCount {
statut: string;
count: number;
}
export type ActiviteResponsable = Prisma.HistoriqueGetPayload<{
include: {
utilisateur: true;
materiel: true;
emprunt: true;
};
}>;
export type EmpruntResponsable = Prisma.EmpruntGetPayload<{
include: {
utilisateur: true;
materiel: { include: { categorie: true } };
};
}>;
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;
accessoires: { include: { accessoire: true } };
};
}>;
export type AnomalieResponsable = Prisma.AnomalieGetPayload<{
include: {
emprunt: true;
etudiant: true;
materiel: { include: { categorie: true } };
traitePar: true;
};
}>;
export type NotificationResponsable = Prisma.NotificationGetPayload<{
include: {
anomalie: {
include: {
emprunt: true;
materiel: true;
};
};
};
}>;
export type HistoriqueResponsable = Prisma.HistoriqueGetPayload<{
include: {
utilisateur: true;
materiel: true;
emprunt: true;
sallePret: true;
posteEmprunt: true;
};
}>;
export interface EmpruntResponsableFiltres {
statut?: string;
annee?: number;
recherche?: string;
tri?:
| 'dateEmprunt'
| 'dateRetourPrevue'
| 'dateRetourReelle'
| 'materiel'
| 'etudiant'
| 'statut';
ordre?: 'asc' | 'desc';
}
export interface MaterielResponsableFiltres {
statut?: string;
categorieId?: number;
recherche?: string;
tri?: 'nom' | 'reference' | 'categorie' | 'marque' | 'etatGeneral' | 'statut';
ordre?: 'asc' | 'desc';
}
export interface AnomalieResponsableFiltres {
statut?: string;
type?: string;
recherche?: string;
tri?: 'dateDetection' | 'type' | 'materiel' | 'etudiant' | 'statut';
ordre?: 'asc' | 'desc';
}
export interface NotificationResponsableFiltres {
lu?: boolean;
}
export interface HistoriqueResponsableFiltres {
action?: string;
utilisateurId?: number;
materielId?: number;
empruntId?: number;
dateDebut?: Date;
dateFin?: Date;
recherche?: string;
tri?: 'dateAction' | 'action' | 'utilisateur' | 'materiel' | 'emprunt';
ordre?: 'asc' | 'desc';
}
export interface ChangerStatutAnomalieData {
anomalieId: number;
campusId: number;
responsableId: number;
statut: string;
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 }));
}
export async function countMaterielsParStatut(campusId: number): Promise<StatutCount[]> {
const rows = await prisma.materiel.groupBy({
by: ['statut'],
where: { campusId, actif: true },
_count: { _all: true },
});
return mapStatutCounts(rows);
}
export async function countEmpruntsParStatut(campusId: number): Promise<StatutCount[]> {
const rows = await prisma.emprunt.groupBy({
by: ['statut'],
where: { campusId },
_count: { _all: true },
});
return mapStatutCounts(rows);
}
export async function countAnomaliesParStatut(campusId: number): Promise<StatutCount[]> {
const rows = await prisma.anomalie.groupBy({
by: ['statut'],
where: { emprunt: { campusId } },
_count: { _all: true },
});
return mapStatutCounts(rows);
}
export function countNotificationsNonLues(utilisateurId: number): Promise<number> {
return prisma.notification.count({
where: { utilisateurId, lu: false },
});
}
export function findActiviteRecente(
campusId: number,
limite: number,
): Promise<ActiviteResponsable[]> {
return prisma.historique.findMany({
where: { campusId },
include: {
utilisateur: true,
materiel: true,
emprunt: true,
},
orderBy: { dateAction: 'desc' },
take: limite,
});
}
export function findEmpruntsResponsable(
campusId: number,
filtres: EmpruntResponsableFiltres,
): Promise<EmpruntResponsable[]> {
const ordre = filtres.ordre ?? 'desc';
const periode = filtres.annee
? {
gte: new Date(Date.UTC(filtres.annee, 0, 1)),
lt: new Date(Date.UTC(filtres.annee + 1, 0, 1)),
}
: undefined;
const orderBy: Prisma.EmpruntOrderByWithRelationInput[] = (() => {
switch (filtres.tri) {
case 'dateRetourPrevue':
return [{ dateRetourPrevue: ordre }, { id: 'desc' }];
case 'dateRetourReelle':
return [{ dateRetourReelle: ordre }, { id: 'desc' }];
case 'materiel':
return [{ materiel: { nom: ordre } }, { id: 'desc' }];
case 'etudiant':
return [{ utilisateur: { nom: ordre } }, { id: 'desc' }];
case 'statut':
return [{ statut: ordre }, { id: 'desc' }];
default:
return [{ dateEmprunt: ordre }, { id: 'desc' }];
}
})();
return prisma.emprunt.findMany({
where: {
campusId,
...(filtres.statut ? { statut: filtres.statut } : {}),
...(periode ? { dateEmprunt: periode } : {}),
...(filtres.recherche
? {
OR: [
{ materiel: { nom: { contains: filtres.recherche } } },
{ materiel: { reference: { contains: filtres.recherche } } },
{ materiel: { marque: { contains: filtres.recherche } } },
{ materiel: { modele: { contains: filtres.recherche } } },
{ utilisateur: { nom: { contains: filtres.recherche } } },
{ utilisateur: { prenom: { contains: filtres.recherche } } },
{ utilisateur: { email: { contains: filtres.recherche } } },
{ utilisateur: { classe: { contains: filtres.recherche } } },
],
}
: {}),
},
include: {
utilisateur: true,
materiel: { include: { categorie: true } },
},
orderBy,
});
}
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,
): Promise<MaterielResponsable[]> {
const ordre = filtres.ordre ?? 'asc';
const orderBy: Prisma.MaterielOrderByWithRelationInput[] = (() => {
switch (filtres.tri) {
case 'reference':
return [{ reference: ordre }, { id: 'asc' }];
case 'categorie':
return [{ categorie: { nom: ordre } }, { nom: 'asc' }, { id: 'asc' }];
case 'marque':
return [{ marque: ordre }, { modele: ordre }, { nom: 'asc' }, { id: 'asc' }];
case 'etatGeneral':
return [{ etatGeneral: ordre }, { nom: 'asc' }, { id: 'asc' }];
case 'statut':
return [{ statut: ordre }, { nom: 'asc' }, { id: 'asc' }];
default:
return [{ nom: ordre }, { id: 'asc' }];
}
})();
return prisma.materiel.findMany({
where: {
campusId,
actif: true,
...(filtres.statut ? { statut: filtres.statut } : {}),
...(filtres.categorieId !== undefined ? { categorieId: filtres.categorieId } : {}),
...(filtres.recherche
? {
OR: [
{ nom: { contains: filtres.recherche } },
{ marque: { contains: filtres.recherche } },
{ modele: { contains: filtres.recherche } },
{ reference: { contains: filtres.recherche } },
],
}
: {}),
},
include: {
categorie: true,
accessoires: { include: { accessoire: true } },
},
orderBy,
});
}
export function findAnomaliesResponsable(
campusId: number,
filtres: AnomalieResponsableFiltres,
): Promise<AnomalieResponsable[]> {
const ordre = filtres.ordre ?? 'desc';
const orderBy: Prisma.AnomalieOrderByWithRelationInput[] = (() => {
switch (filtres.tri) {
case 'type':
return [{ type: ordre }, { dateDetection: 'desc' }, { id: 'desc' }];
case 'materiel':
return [{ materiel: { nom: ordre } }, { dateDetection: 'desc' }, { id: 'desc' }];
case 'etudiant':
return [{ etudiant: { nom: ordre } }, { dateDetection: 'desc' }, { id: 'desc' }];
case 'statut':
return [{ statut: ordre }, { dateDetection: 'desc' }, { id: 'desc' }];
default:
return [{ dateDetection: ordre }, { id: 'desc' }];
}
})();
return prisma.anomalie.findMany({
where: {
emprunt: { campusId },
...(filtres.statut ? { statut: filtres.statut } : {}),
...(filtres.type ? { type: filtres.type } : {}),
...(filtres.recherche
? {
OR: [
{ description: { contains: filtres.recherche } },
{ type: { contains: filtres.recherche } },
{ materiel: { nom: { contains: filtres.recherche } } },
{ materiel: { reference: { contains: filtres.recherche } } },
{ etudiant: { nom: { contains: filtres.recherche } } },
{ etudiant: { prenom: { contains: filtres.recherche } } },
{ etudiant: { email: { contains: filtres.recherche } } },
],
}
: {}),
},
include: {
emprunt: true,
etudiant: true,
materiel: { include: { categorie: true } },
traitePar: true,
},
orderBy,
});
}
export function findAnomalieResponsableById(
campusId: number,
anomalieId: number,
): Promise<AnomalieResponsable | null> {
return prisma.anomalie.findFirst({
where: {
id: anomalieId,
emprunt: { campusId },
},
include: {
emprunt: true,
etudiant: true,
materiel: { include: { categorie: true } },
traitePar: true,
},
});
}
export function changerStatutAnomalieResponsable(
data: ChangerStatutAnomalieData,
): Promise<AnomalieResponsable> {
return prisma.$transaction(async (tx) => {
const anomalie = await tx.anomalie.update({
where: { id: data.anomalieId },
data: {
statut: data.statut,
traiteeParId: data.responsableId,
...(data.observation !== undefined ? { observation: data.observation } : {}),
...(data.statut === 'RESOLUE' || data.statut === 'CLOTUREE'
? { dateResolution: new Date() }
: {}),
},
include: {
emprunt: true,
etudiant: true,
materiel: { include: { categorie: true } },
traitePar: true,
},
});
await tx.historique.create({
data: {
utilisateurId: data.responsableId,
empruntId: anomalie.empruntId,
materielId: anomalie.materielId,
campusId: data.campusId,
action: 'TRAITEMENT_ANOMALIE',
description: `Anomalie #${anomalie.id} passee au statut ${data.statut}`,
dateAction: new Date(),
},
});
return anomalie;
});
}
export function findNotificationsResponsable(
utilisateurId: number,
filtres: NotificationResponsableFiltres,
): Promise<NotificationResponsable[]> {
return prisma.notification.findMany({
where: {
utilisateurId,
...(filtres.lu !== undefined ? { lu: filtres.lu } : {}),
},
include: {
anomalie: {
include: {
emprunt: true,
materiel: true,
},
},
},
orderBy: { dateCreation: 'desc' },
});
}
export function marquerNotificationLue(
utilisateurId: number,
notificationId: number,
): Promise<NotificationResponsable> {
return prisma.notification.update({
where: {
id: notificationId,
utilisateurId,
},
data: { lu: true },
include: {
anomalie: {
include: {
emprunt: true,
materiel: true,
},
},
},
});
}
export async function marquerNotificationsResponsableLues(utilisateurId: number): Promise<number> {
const result = await prisma.notification.updateMany({
where: { utilisateurId, lu: false },
data: { lu: true },
});
return result.count;
}
export function findHistoriqueResponsable(
campusId: number,
filtres: HistoriqueResponsableFiltres,
): Promise<HistoriqueResponsable[]> {
const ordre = filtres.ordre ?? 'desc';
const orderBy: Prisma.HistoriqueOrderByWithRelationInput[] = (() => {
switch (filtres.tri) {
case 'action':
return [{ action: ordre }, { dateAction: 'desc' }, { id: 'desc' }];
case 'utilisateur':
return [{ utilisateur: { nom: ordre } }, { dateAction: 'desc' }, { id: 'desc' }];
case 'materiel':
return [{ materiel: { nom: ordre } }, { dateAction: 'desc' }, { id: 'desc' }];
case 'emprunt':
return [{ empruntId: ordre }, { dateAction: 'desc' }, { id: 'desc' }];
default:
return [{ dateAction: ordre }, { id: 'desc' }];
}
})();
return prisma.historique.findMany({
where: {
campusId,
...(filtres.action ? { action: filtres.action } : {}),
...(filtres.utilisateurId !== undefined ? { utilisateurId: filtres.utilisateurId } : {}),
...(filtres.materielId !== undefined ? { materielId: filtres.materielId } : {}),
...(filtres.empruntId !== undefined ? { empruntId: filtres.empruntId } : {}),
...(filtres.dateDebut || filtres.dateFin
? {
dateAction: {
...(filtres.dateDebut ? { gte: filtres.dateDebut } : {}),
...(filtres.dateFin ? { lte: filtres.dateFin } : {}),
},
}
: {}),
...(filtres.recherche
? {
OR: [
{ action: { contains: filtres.recherche } },
{ description: { contains: filtres.recherche } },
{ utilisateur: { nom: { contains: filtres.recherche } } },
{ utilisateur: { prenom: { contains: filtres.recherche } } },
{ utilisateur: { email: { contains: filtres.recherche } } },
{ materiel: { nom: { contains: filtres.recherche } } },
{ materiel: { reference: { contains: filtres.recherche } } },
],
}
: {}),
},
include: {
utilisateur: true,
materiel: true,
emprunt: true,
sallePret: true,
posteEmprunt: true,
},
orderBy,
});
}
@@ -14,6 +14,13 @@ export function findByEmail(email: string): Promise<UtilisateurAvecRole | null>
}); });
} }
export function findByMicrosoftId(microsoftId: string): Promise<UtilisateurAvecRole | null> {
return prisma.utilisateur.findUnique({
where: { microsoftId },
include: { role: true },
});
}
export function findById(id: number): Promise<UtilisateurProfil | null> { export function findById(id: number): Promise<UtilisateurProfil | null> {
return prisma.utilisateur.findUnique({ return prisma.utilisateur.findUnique({
where: { id }, where: { id },
+7 -1
View File
@@ -1,8 +1,14 @@
import { Router } from 'express'; 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(); export const empruntRoutes: Router = Router();
empruntRoutes.post('/', postEmprunt); empruntRoutes.post('/', postEmprunt);
empruntRoutes.post('/:id/alerte-changement-retour', postAlerteChangementRetour);
empruntRoutes.post('/:id/restitution', postRestitution); empruntRoutes.post('/:id/restitution', postRestitution);
export const mesEmpruntsRoutes: Router = Router(); export const mesEmpruntsRoutes: Router = Router();
+2
View File
@@ -3,6 +3,7 @@ import { currentUser } from '../middlewares/current-user';
import { authRoutes } from './auth.routes'; import { authRoutes } from './auth.routes';
import { materielRoutes } from './materiel.routes'; import { materielRoutes } from './materiel.routes';
import { empruntRoutes, mesEmpruntsRoutes } from './emprunt.routes'; import { empruntRoutes, mesEmpruntsRoutes } from './emprunt.routes';
import { responsableRoutes } from './responsable.routes';
export const apiRouter: Router = Router(); export const apiRouter: Router = Router();
@@ -13,3 +14,4 @@ apiRouter.use('/auth', authRoutes);
apiRouter.use('/materiels', currentUser, materielRoutes); apiRouter.use('/materiels', currentUser, materielRoutes);
apiRouter.use('/emprunts', currentUser, empruntRoutes); apiRouter.use('/emprunts', currentUser, empruntRoutes);
apiRouter.use('/mes-emprunts', currentUser, mesEmpruntsRoutes); apiRouter.use('/mes-emprunts', currentUser, mesEmpruntsRoutes);
apiRouter.use('/responsable', currentUser, responsableRoutes);
@@ -0,0 +1,30 @@
import { Router } from 'express';
import {
getAnomalies,
getDashboard,
getEmprunts,
getEcarts,
getHistorique,
getHistoriqueCsv,
getMateriels,
getNotifications,
patchAnomalieStatut,
patchEcartDecision,
patchNotificationLue,
patchNotificationsLues,
} from '../controllers/responsable.controller';
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);
responsableRoutes.get('/notifications', getNotifications);
responsableRoutes.patch('/notifications/lu-toutes', patchNotificationsLues);
responsableRoutes.patch('/notifications/:id/lu', patchNotificationLue);
responsableRoutes.get('/historique', getHistorique);
responsableRoutes.get('/historique/export.csv', getHistoriqueCsv);
@@ -0,0 +1,68 @@
import { env } from '../config/env';
import { AppError } from '../errors/app-error';
import {
findByEmail,
findByMicrosoftId,
UtilisateurAvecRole,
} from '../repositories/utilisateur.repository';
import { AuthenticatedUser } from '../types/authenticated-user';
import { verifyAzureAccessToken } from './azure-token.service';
export interface AuthenticationHeaders {
authorization?: string;
demoEmail?: string;
}
function toAuthenticatedUser(utilisateur: UtilisateurAvecRole): AuthenticatedUser {
return {
id: utilisateur.id,
email: utilisateur.email,
roleCode: utilisateur.role.code,
campusId: utilisateur.campusId,
};
}
function bearerToken(authorization: string | undefined): string {
const match = authorization?.match(/^Bearer\s+(\S+)$/i);
if (!match) {
throw new AppError(401, 'Jeton Bearer requis');
}
return match[1];
}
async function authenticateDemo(email: string | undefined): Promise<UtilisateurAvecRole | null> {
if (!email) {
throw new AppError(401, 'En-tete x-user-email requis en mode demo');
}
return findByEmail(email.trim().toLowerCase());
}
async function authenticateAzure(
authorization: string | undefined,
): Promise<UtilisateurAvecRole | null> {
const identity = await verifyAzureAccessToken(bearerToken(authorization));
const byMicrosoftId = await findByMicrosoftId(identity.microsoftId);
if (byMicrosoftId) {
return byMicrosoftId;
}
return findByEmail(identity.email);
}
export async function authenticateUser(
headers: AuthenticationHeaders,
): Promise<AuthenticatedUser> {
const utilisateur =
env.auth.mode === 'azure'
? await authenticateAzure(headers.authorization)
: await authenticateDemo(headers.demoEmail);
if (!utilisateur || !utilisateur.actif) {
throw new AppError(401, 'Utilisateur non reconnu');
}
return toAuthenticatedUser(utilisateur);
}
@@ -0,0 +1,122 @@
import jwt, {
GetPublicKeyOrSecret,
JwtHeader,
JwtPayload,
SigningKeyCallback,
} from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
import { env } from '../config/env';
import { AppError } from '../errors/app-error';
export interface AzureIdentity {
microsoftId: string;
email: string;
}
interface AzureClaims extends JwtPayload {
oid?: string;
tid?: string;
scp?: string;
preferred_username?: string;
upn?: string;
email?: string;
}
const issuer = `https://login.microsoftonline.com/${env.auth.azure.tenantId}/v2.0`;
const client = jwksClient({
jwksUri: `${issuer}/discovery/v2.0/keys`,
cache: true,
cacheMaxEntries: 5,
cacheMaxAge: 10 * 60 * 1000,
rateLimit: true,
jwksRequestsPerMinute: 10,
timeout: 5000,
});
const getSigningKey: GetPublicKeyOrSecret = (
header: JwtHeader,
callback: SigningKeyCallback,
): void => {
if (!header.kid) {
callback(new Error('Identifiant de cle de signature absent'));
return;
}
client.getSigningKey(header.kid, (error, key) => {
if (error || !key) {
callback(error || new Error('Cle de signature Microsoft introuvable'));
return;
}
callback(null, key.getPublicKey());
});
};
function verifyJwt(token: string): Promise<AzureClaims> {
return new Promise((resolve, reject) => {
jwt.verify(
token,
getSigningKey,
{
algorithms: ['RS256'],
audience: env.auth.azure.audience,
issuer,
},
(error, decoded) => {
if (error || !decoded || typeof decoded === 'string') {
reject(error || new Error('Contenu du jeton invalide'));
return;
}
resolve(decoded);
},
);
});
}
function emailFromClaims(claims: AzureClaims): string | null {
const email = claims.preferred_username || claims.upn || claims.email;
return email?.trim().toLowerCase() || null;
}
function hasAllowedEmailDomain(email: string): boolean {
const separatorIndex = email.lastIndexOf('@');
if (separatorIndex < 1) {
return false;
}
const domain = email.slice(separatorIndex + 1);
return env.auth.azure.allowedEmailDomains.includes(domain);
}
export async function verifyAzureAccessToken(token: string): Promise<AzureIdentity> {
if (env.auth.mode !== 'azure') {
throw new AppError(500, 'Validation Azure indisponible hors du mode Azure');
}
let claims: AzureClaims;
try {
claims = await verifyJwt(token);
} catch {
throw new AppError(401, 'Jeton Microsoft invalide ou expire');
}
if (claims.tid !== env.auth.azure.tenantId) {
throw new AppError(401, 'Tenant Microsoft non autorise');
}
const scopes = claims.scp?.split(' ') || [];
if (!scopes.includes(env.auth.azure.scope)) {
throw new AppError(403, 'Permission Microsoft insuffisante');
}
const email = emailFromClaims(claims);
if (!claims.oid || !email || !hasAllowedEmailDomain(email)) {
throw new AppError(401, 'Identite Microsoft non autorisee');
}
return {
microsoftId: claims.oid,
email,
};
}
+158 -65
View File
@@ -11,11 +11,13 @@ import {
restituerEmpruntComplet, restituerEmpruntComplet,
EmpruntAvecMateriel, EmpruntAvecMateriel,
RestituerEmpruntData, RestituerEmpruntData,
signalerTentativeRetour,
} from '../repositories/emprunt.repository'; } 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 { findById as findPosteById } from '../repositories/poste-emprunt.repository';
import { findResponsablesParCampus } from '../repositories/utilisateur.repository'; import { findResponsablesParCampus } from '../repositories/utilisateur.repository';
import { StatutEmprunt } from '../models/enums'; import { StatutEmprunt } from '../models/enums';
import { SignalerTentativeRetourRequest } from '../dtos/emprunt.dto';
const DUREE_EMPRUNT_JOURS = 14; const DUREE_EMPRUNT_JOURS = 14;
const STATUTS_RESTITUABLES: readonly string[] = ['EN_COURS', 'EN_RETARD']; const STATUTS_RESTITUABLES: readonly string[] = ['EN_COURS', 'EN_RETARD'];
@@ -25,6 +27,51 @@ export function listerMesEmpruntsEnCours(utilisateurId: number): Promise<Emprunt
return findEnCoursParUtilisateur(utilisateurId); 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. /* 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 Les contrôles d'éligibilité (campus, disponibilité) sont faits ici ; l'écriture
atomique est déléguée au repository. */ atomique est déléguée au repository. */
@@ -33,13 +80,10 @@ export async function creerEmprunt(
campusId: number, campusId: number,
request: CreerEmpruntRequest, request: CreerEmpruntRequest,
): Promise<EmpruntAvecMateriel> { ): Promise<EmpruntAvecMateriel> {
const materiel = await findMaterielById(request.materielId); const materiel = await findDetailParCampus(request.materielId, campusId);
if (!materiel) { if (!materiel) {
throw new AppError(404, 'Materiel introuvable'); throw new AppError(404, 'Materiel introuvable');
} }
if (materiel.campusId !== campusId) {
throw new AppError(403, 'Materiel rattache a un autre campus');
}
if (materiel.statut !== 'DISPONIBLE') { if (materiel.statut !== 'DISPONIBLE') {
throw new AppError(409, 'Materiel non disponible'); throw new AppError(409, 'Materiel non disponible');
} }
@@ -55,72 +99,124 @@ export async function creerEmprunt(
const dateRetourPrevue = new Date(); const dateRetourPrevue = new Date();
dateRetourPrevue.setDate(dateRetourPrevue.getDate() + DUREE_EMPRUNT_JOURS); 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({ return creerEmpruntComplet({
utilisateurId, utilisateurId,
materielId: request.materielId, materielId: request.materielId,
categorieId: materiel.categorieId,
campusId, campusId,
sallePretId: poste.sallePretId, sallePretId: poste.sallePretId,
posteEmpruntId: request.posteEmpruntId, posteEmpruntId: request.posteEmpruntId,
dateRetourPrevue, dateRetourPrevue,
modeIdentification: request.modeIdentification, modeIdentification: request.modeIdentification,
commentaireDepart: request.commentaireDepart, 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; accessoireId: number | null;
nomElement: string; nomElement: string;
etat: string; etat: string;
quantiteConstatee: number;
} }
interface ResultatComparaison { function trouverElement(
conforme: boolean; reference: ElementReference,
auMoinsUnDeteriore: boolean; elements: ChecklistElementRequest[],
details: string[];
}
function trouverRetour(
depart: ElementDepart,
retour: ChecklistElementRequest[],
): ChecklistElementRequest | undefined { ): ChecklistElementRequest | undefined {
if (depart.accessoireId !== null) { if (reference.accessoireId !== null) {
const parId = retour.find((element) => element.accessoireId === depart.accessoireId); const parId = elements.find((element) => element.accessoireId === reference.accessoireId);
if (parId) { if (parId) {
return 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 function normaliserChecklist(
départ mais absent ou détérioré au retour rend la restitution non conforme (RG20). */ references: ElementReference[],
function comparerChecklists( elements: ChecklistElementRequest[],
depart: ElementDepart[], type: 'depart' | 'retour',
retour: ChecklistElementRequest[], ): ChecklistElementRequest[] {
): ResultatComparaison { if (elements.length !== references.length) {
const details: string[] = []; throw new AppError(400, `La checklist de ${type} doit contenir tous les elements attendus`);
let auMoinsUnDeteriore = false; }
for (const elementDepart of depart) { const utilises = new Set<ChecklistElementRequest>();
if (elementDepart.etat !== 'PRESENT') { return references.map((reference) => {
continue; 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); function comparerEtats(
if (!elementRetour || elementRetour.etat === 'ABSENT') { references: ElementReference[],
details.push(`${elementDepart.nomElement} manquant au retour`); elements: ChecklistElementRequest[],
} else if (elementRetour.etat === 'DETERIORE') { ): string[] {
details.push(`${elementDepart.nomElement} deteriore au retour`); const details: string[] = [];
auMoinsUnDeteriore = true;
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( export async function restituerEmprunt(
utilisateurId: number, utilisateurId: number,
empruntId: number, empruntId: number,
@@ -142,41 +238,38 @@ export async function restituerEmprunt(
throw new AppError(409, 'Checklist de depart introuvable'); throw new AppError(409, 'Checklist de depart introuvable');
} }
const comparaison = comparerChecklists( const references = checklistDepart.elements.map((element) => ({
checklistDepart.elements.map((element) => ({ accessoireId: element.accessoireId,
accessoireId: element.accessoireId, nomElement: element.nomElement,
nomElement: element.nomElement, etat: element.etat,
etat: element.etat, quantiteConstatee: element.quantiteConstatee,
})), }));
request.elements, const elements = normaliserChecklist(references, request.elements, 'retour');
); const differences = comparerEtats(references, elements);
const statutEmprunt: StatutEmprunt =
const statutEmprunt: StatutEmprunt = comparaison.conforme ? 'CLOTURE' : 'RETOUR_NON_CONFORME'; differences.length === 0 ? 'CLOTURE' : 'EN_ATTENTE_VALIDATION_RETOUR';
const responsables =
let statutMateriel = 'DISPONIBLE'; differences.length > 0 ? await findResponsablesParCampus(emprunt.campusId) : [];
if (!comparaison.conforme) { const descriptionEcart =
statutMateriel = comparaison.auMoinsUnDeteriore ? 'DETERIORE' : 'NON_CONFORME'; differences.length > 0
} ? `Changement au retour pour l'emprunt #${emprunt.id} : ${differences.join(', ')}.`
: undefined;
const donnees: RestituerEmpruntData = { const donnees: RestituerEmpruntData = {
empruntId, empruntId,
utilisateurId, utilisateurId,
materielId: emprunt.materielId, materielId: emprunt.materielId,
statutEmprunt, statutEmprunt,
statutMateriel, statutMateriel: differences.length === 0 ? 'DISPONIBLE' : 'EMPRUNTE',
modeIdentification: request.modeIdentification, modeIdentification: request.modeIdentification,
commentaireRetour: request.commentaireRetour, 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); return restituerEmpruntComplet(donnees);
} }
@@ -0,0 +1,586 @@
import { AppError } from '../errors/app-error';
import {
changerStatutAnomalieResponsable,
countAnomaliesParStatut,
countEmpruntsParStatut,
countMaterielsParStatut,
countNotificationsNonLues,
deciderEcartResponsable,
findActiviteRecente,
findEcartResponsableById,
findEcartsResponsable,
findAnomalieResponsableById,
findAnomaliesResponsable,
findEmpruntsResponsable,
findHistoriqueResponsable,
findMaterielsResponsable,
findNotificationsResponsable,
marquerNotificationLue,
marquerNotificationsResponsableLues,
StatutCount,
ActiviteResponsable,
AnomalieResponsable,
EmpruntResponsable,
HistoriqueResponsable,
MaterielResponsable,
NotificationResponsable,
EcartResponsable,
} from '../repositories/responsable.repository';
import {
STATUT_ANOMALIE,
STATUT_EMPRUNT,
STATUT_MATERIEL,
StatutAnomalie,
StatutEmprunt,
StatutMateriel,
} from '../models/enums';
export interface DashboardResponsableData {
materiels: StatutCount[];
emprunts: StatutCount[];
anomalies: StatutCount[];
notificationsNonLues: number;
activiteRecente: ActiviteResponsable[];
}
export interface ListerEmpruntsResponsableFiltres {
statut?: StatutEmprunt;
annee?: number;
recherche?: string;
tri?: TriEmpruntResponsable;
ordre?: OrdreTri;
}
export type TriEmpruntResponsable =
| 'dateEmprunt'
| 'dateRetourPrevue'
| 'dateRetourReelle'
| 'materiel'
| 'etudiant'
| 'statut';
export type OrdreTri = 'asc' | 'desc';
export interface ListerMaterielsResponsableFiltres {
statut?: StatutMateriel;
categorieId?: number;
recherche?: string;
tri?: TriMaterielResponsable;
ordre?: OrdreTri;
}
export type TriMaterielResponsable =
| 'nom'
| 'reference'
| 'categorie'
| 'marque'
| 'etatGeneral'
| 'statut';
export interface ListerAnomaliesResponsableFiltres {
statut?: StatutAnomalie;
type?: string;
recherche?: string;
tri?: TriAnomalieResponsable;
ordre?: OrdreTri;
}
export type TriAnomalieResponsable =
| 'dateDetection'
| 'type'
| 'materiel'
| 'etudiant'
| 'statut';
export interface ListerNotificationsResponsableFiltres {
lu?: boolean;
}
export interface ListerHistoriqueResponsableFiltres {
action?: string;
utilisateurId?: number;
materielId?: number;
empruntId?: number;
dateDebut?: Date;
dateFin?: Date;
recherche?: string;
tri?: TriHistoriqueResponsable;
ordre?: OrdreTri;
}
export type TriHistoriqueResponsable =
| 'dateAction'
| 'action'
| 'utilisateur'
| 'materiel'
| 'emprunt';
export interface ChangerStatutAnomalieResponsableRequest {
statut: StatutAnomalie;
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'],
RESOLUE: ['CLOTUREE'],
CLOTUREE: [],
};
function verifierResponsable(roleCode: string): void {
if (roleCode !== 'RESPONSABLE') {
throw new AppError(403, 'Acces reserve au responsable materiel');
}
}
export async function getDashboardResponsable(
utilisateurId: number,
roleCode: string,
campusId: number,
): Promise<DashboardResponsableData> {
verifierResponsable(roleCode);
const [materiels, emprunts, anomalies, notificationsNonLues, activiteRecente] = await Promise.all(
[
countMaterielsParStatut(campusId),
countEmpruntsParStatut(campusId),
countAnomaliesParStatut(campusId),
countNotificationsNonLues(utilisateurId),
findActiviteRecente(campusId, 10),
],
);
return {
materiels,
emprunts,
anomalies,
notificationsNonLues,
activiteRecente,
};
}
export function parseStatutEmprunt(value: unknown): StatutEmprunt | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== 'string' || !(STATUT_EMPRUNT as readonly string[]).includes(value)) {
throw new AppError(400, 'statut invalide');
}
return value as StatutEmprunt;
}
export function parseAnneeEmprunt(value: unknown): number | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== 'string') {
throw new AppError(400, 'annee invalide');
}
const annee = Number(value);
if (!Number.isInteger(annee) || annee < 2000 || annee > 2100) {
throw new AppError(400, 'annee invalide');
}
return annee;
}
export function parseTriEmprunt(value: unknown): TriEmpruntResponsable | undefined {
if (value === undefined) {
return undefined;
}
const valeurs: readonly TriEmpruntResponsable[] = [
'dateEmprunt',
'dateRetourPrevue',
'dateRetourReelle',
'materiel',
'etudiant',
'statut',
];
if (typeof value !== 'string' || !valeurs.includes(value as TriEmpruntResponsable)) {
throw new AppError(400, 'tri invalide');
}
return value as TriEmpruntResponsable;
}
export function parseOrdreTri(value: unknown): OrdreTri | undefined {
if (value === undefined) {
return undefined;
}
if (value !== 'asc' && value !== 'desc') {
throw new AppError(400, 'ordre invalide');
}
return value;
}
export function parseStatutMateriel(value: unknown): StatutMateriel | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== 'string' || !(STATUT_MATERIEL as readonly string[]).includes(value)) {
throw new AppError(400, 'statut invalide');
}
return value as StatutMateriel;
}
export function parseTriMateriel(value: unknown): TriMaterielResponsable | undefined {
if (value === undefined) {
return undefined;
}
const valeurs: readonly TriMaterielResponsable[] = [
'nom',
'reference',
'categorie',
'marque',
'etatGeneral',
'statut',
];
if (typeof value !== 'string' || !valeurs.includes(value as TriMaterielResponsable)) {
throw new AppError(400, 'tri invalide');
}
return value as TriMaterielResponsable;
}
export function parseStatutAnomalie(value: unknown): StatutAnomalie | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== 'string' || !(STATUT_ANOMALIE as readonly string[]).includes(value)) {
throw new AppError(400, 'statut invalide');
}
return value as StatutAnomalie;
}
export function parseTriAnomalie(value: unknown): TriAnomalieResponsable | undefined {
if (value === undefined) {
return undefined;
}
const valeurs: readonly TriAnomalieResponsable[] = [
'dateDetection',
'type',
'materiel',
'etudiant',
'statut',
];
if (typeof value !== 'string' || !valeurs.includes(value as TriAnomalieResponsable)) {
throw new AppError(400, 'tri invalide');
}
return value as TriAnomalieResponsable;
}
export function parseCategorieId(value: unknown): number | undefined {
if (value === undefined) {
return undefined;
}
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new AppError(400, 'categorieId invalide');
}
return parsed;
}
export function parseNotificationId(value: unknown): number {
if (typeof value !== 'string') {
throw new AppError(400, 'notificationId invalide');
}
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new AppError(400, 'notificationId invalide');
}
return parsed;
}
export function parseAnomalieId(value: unknown): number {
if (typeof value !== 'string') {
throw new AppError(400, 'anomalieId invalide');
}
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new AppError(400, 'anomalieId invalide');
}
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;
}
if (typeof value !== 'string') {
throw new AppError(400, `${champ} invalide`);
}
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new AppError(400, `${champ} invalide`);
}
return parsed;
}
export function parseDateQuery(value: unknown, champ: string): Date | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value !== 'string') {
throw new AppError(400, `${champ} invalide`);
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw new AppError(400, `${champ} invalide`);
}
return parsed;
}
export function parseTriHistorique(value: unknown): TriHistoriqueResponsable | undefined {
if (value === undefined) {
return undefined;
}
const valeurs: readonly TriHistoriqueResponsable[] = [
'dateAction',
'action',
'utilisateur',
'materiel',
'emprunt',
];
if (typeof value !== 'string' || !valeurs.includes(value as TriHistoriqueResponsable)) {
throw new AppError(400, 'tri invalide');
}
return value as TriHistoriqueResponsable;
}
export function parseLu(value: unknown): boolean | undefined {
if (value === undefined) {
return undefined;
}
if (value === 'true') {
return true;
}
if (value === 'false') {
return false;
}
throw new AppError(400, 'lu invalide');
}
export function listerEmpruntsResponsable(
roleCode: string,
campusId: number,
filtres: ListerEmpruntsResponsableFiltres,
): Promise<EmpruntResponsable[]> {
verifierResponsable(roleCode);
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,
filtres: ListerMaterielsResponsableFiltres,
): Promise<MaterielResponsable[]> {
verifierResponsable(roleCode);
return findMaterielsResponsable(campusId, filtres);
}
export function listerAnomaliesResponsable(
roleCode: string,
campusId: number,
filtres: ListerAnomaliesResponsableFiltres,
): Promise<AnomalieResponsable[]> {
verifierResponsable(roleCode);
return findAnomaliesResponsable(campusId, filtres);
}
export function listerNotificationsResponsable(
utilisateurId: number,
roleCode: string,
filtres: ListerNotificationsResponsableFiltres,
): Promise<NotificationResponsable[]> {
verifierResponsable(roleCode);
return findNotificationsResponsable(utilisateurId, filtres);
}
export async function marquerNotificationResponsableLue(
utilisateurId: number,
roleCode: string,
notificationId: number,
): Promise<NotificationResponsable> {
verifierResponsable(roleCode);
try {
return await marquerNotificationLue(utilisateurId, notificationId);
} catch {
throw new AppError(404, 'Notification introuvable');
}
}
export function marquerToutesNotificationsResponsableLues(
utilisateurId: number,
roleCode: string,
): Promise<number> {
verifierResponsable(roleCode);
return marquerNotificationsResponsableLues(utilisateurId);
}
export function listerHistoriqueResponsable(
roleCode: string,
campusId: number,
filtres: ListerHistoriqueResponsableFiltres,
): Promise<HistoriqueResponsable[]> {
verifierResponsable(roleCode);
return findHistoriqueResponsable(campusId, filtres);
}
export async function changerStatutAnomalieResponsableService(
responsableId: number,
roleCode: string,
campusId: number,
anomalieId: number,
data: ChangerStatutAnomalieResponsableRequest,
): Promise<AnomalieResponsable> {
verifierResponsable(roleCode);
const anomalie = await findAnomalieResponsableById(campusId, anomalieId);
if (!anomalie) {
throw new AppError(404, 'Anomalie introuvable');
}
const statutActuel = anomalie.statut as StatutAnomalie;
if (!TRANSITIONS_ANOMALIE[statutActuel]?.includes(data.statut)) {
throw new AppError(400, `Transition invalide depuis ${anomalie.statut} vers ${data.statut}`);
}
return changerStatutAnomalieResponsable({
anomalieId,
campusId,
responsableId,
statut: data.statut,
observation: data.observation,
});
}
+485
View File
@@ -0,0 +1,485 @@
-- Adminer 5.4.2 MS SQL dump
DROP TABLE IF EXISTS [dbo].[_prisma_migrations];
CREATE TABLE [dbo].[_prisma_migrations] (
[id] varchar(36) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[checksum] varchar(64) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[finished_at] datetimeoffset NULL,
[migration_name] nvarchar(250) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[logs] nvarchar-05 COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[rolled_back_at] datetimeoffset NULL,
[started_at] datetimeoffset NOT NULL DEFAULT '(getdate())',
[applied_steps_count] int NOT NULL DEFAULT '((0))',
CONSTRAINT [PK___prisma___3213E83F83C32F3A] PRIMARY KEY ([id])
);
INSERT INTO [dbo].[_prisma_migrations] ([id], [checksum], [finished_at], [migration_name], [logs], [rolled_back_at], [started_at], [applied_steps_count]) VALUES
('0f9bb926-166c-4082-8732-8a12e67b677d', 'c2faa7ef8d703d77e508f037850a7c2b07566b0eed00da21d85e1acfc5738a1e', 'Jun 4 2026 12:07:21:0885370PM', '20260604120720_add_materiel_models', NULL, NULL, 'Jun 4 2026 12:07:21:0042228PM', 1),
('247934d2-3c7e-4798-a551-d3bdc81eb86d', 'fb4d09b02739fd6bf7cea5bfd0aad1bb0fbfcce21a04f1bd91ce300a4c8bbfec', 'Jun 5 2026 08:03:28:7441715AM', '20260605080328_add_transaction_models', NULL, NULL, 'Jun 5 2026 08:03:28:6232902AM', 1),
('3266f9eb-7370-4b00-b749-f0ed39263d07', '2e7b96c74202f25fd3ab0a9438fcc8699059fd5501d66092148daa41252f146b', 'Jun 4 2026 11:52:51:4456229AM', '20260604115251_add_infrastructure_models', NULL, NULL, 'Jun 4 2026 11:52:51:3998600AM', 1),
('3cced054-eb44-41f8-89ac-51a72201310a', '5e82403dcc57c7ab48dea2fe70e688714145f7f46b770a5c79ea868e6ba60f7a', 'Jun 4 2026 10:30:16:9084629AM', '20260604103016_add_defaults_and_timestamps', NULL, NULL, 'Jun 4 2026 10:30:16:8610742AM', 1),
('3e9709da-458c-402d-8b32-bac51a84d241', 'c95d2b3e6c2454daa3fb9ba2ef7a99f9419e991174869612c81ab907e5a6d802', 'Jun 4 2026 12:02:27:3861796PM', '20260604120227_add_carte_etudiante', NULL, NULL, 'Jun 4 2026 12:02:27:3195110PM', 1),
('5b298865-5e96-4b54-ad96-1ec3acc7080a', '5e3e0d880e67c5ba91ccdb04f0dee6c056da6a325510c9dd9e4f9a80c5625fc4', 'Jun 4 2026 10:54:28:1304306AM', '20260604125356_add_unique_constraints_and_nullable_classe', NULL, NULL, 'Jun 4 2026 10:54:28:0949933AM', 1),
('66581e38-0e42-435a-9937-06479116e595', '4e632e5273ea555527c4726688865c7b21bc03d92df67e2f4679c6428d8a33e2', 'Jun 2 2026 02:19:19:8915005PM', '20260602141919_init', NULL, NULL, 'Jun 2 2026 02:19:19:8389748PM', 1),
('fbf572ea-075e-4077-9d58-39fdf613d737', '29faca22003505a071ea6b845d527566c51086c6b17980f386b70abff64d9148', 'Jun 15 2026 07:57:00:9671170AM', '20260615075700_add_suivi_models', NULL, NULL, 'Jun 15 2026 07:57:00:8308160AM', 1);
DROP TABLE IF EXISTS [dbo].[accessoire];
CREATE TABLE [dbo].[accessoire] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[description] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[actif] bit NOT NULL DEFAULT '((1))',
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[accessoire] ON;
INSERT INTO [dbo].[accessoire] ([id], [nom], [description], [actif], [created_at], [updated_at]) VALUES
(1, 'Chargeur', 'Chargeur secteur.', '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(2, 'Souris', 'Souris filaire.', '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(3, 'Housse', 'Housse de protection.', '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(1002, 'Chargeur USB-C', 'Accessoire Chargeur USB-C.', '1', '2026-07-07 11:39:38', '2026-07-07 11:39:38'),
(1003, 'Clavier Surface', 'Accessoire Clavier Surface.', '1', '2026-07-07 11:39:38', '2026-07-07 11:39:38'),
(1004, 'Stylet', 'Accessoire Stylet.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
(1005, 'Telecommande', 'Accessoire Telecommande.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
(1006, 'Cable HDMI', 'Accessoire Cable HDMI.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
(1007, 'Batterie', 'Accessoire Batterie.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
(1008, 'Carte SD', 'Accessoire Carte SD.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
(1009, 'Adaptateur USB-C', 'Accessoire Adaptateur USB-C.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
(1010, 'Cable USB-C', 'Accessoire Cable USB-C.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39');
SET IDENTITY_INSERT [dbo].[accessoire] OFF;
DROP TABLE IF EXISTS [dbo].[anomalie];
CREATE TABLE [dbo].[anomalie] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[emprunt_id] int NOT NULL,
[materiel_id] int NOT NULL,
[utilisateur_id] int NOT NULL,
[traitee_par_id] int NULL,
[type] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[description] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[statut] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[detectee_automatiquement] bit NOT NULL DEFAULT '((1))',
[observation] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[date_detection] datetime2 NOT NULL,
[date_resolution] datetime2 NULL,
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[anomalie] ON;
INSERT INTO [dbo].[anomalie] ([id], [emprunt_id], [materiel_id], [utilisateur_id], [traitee_par_id], [type], [description], [statut], [detectee_automatiquement], [observation], [date_detection], [date_resolution], [created_at], [updated_at]) VALUES
(1, 4, 4, 3, NULL, 'ACCESSOIRE_MANQUANT', 'Housse declaree presente au depart, absente au retour.', 'DETECTEE', '1', NULL, '2026-06-14 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(2, 1002, 3, 1, NULL, 'RETOUR_NON_CONFORME', 'Retour non conforme : Etat general manquant au retour.', 'DETECTEE', '1', NULL, '2026-07-06 08:03:20', NULL, '2026-07-06 08:03:20', '2026-07-06 08:03:20'),
(1002, 4003, 1009, 2, NULL, 'RETOUR_NON_CONFORME', 'Retour non conforme : Cable USB-C manquant au retour.', 'DETECTEE', '1', NULL, '2026-07-08 09:19:47', NULL, '2026-07-08 09:19:47', '2026-07-08 09:19:47');
SET IDENTITY_INSERT [dbo].[anomalie] OFF;
DROP TABLE IF EXISTS [dbo].[campus];
CREATE TABLE [dbo].[campus] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[adresse] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[ville] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[code_postal] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[actif] bit NOT NULL DEFAULT '((1))',
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[campus] ON;
INSERT INTO [dbo].[campus] ([id], [nom], [adresse], [ville], [code_postal], [actif], [created_at], [updated_at]) VALUES
(1, 'Campus Saint-Christophe', 'À compléter — Cergy Saint-Christophe', 'Cergy', '95800', '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37');
SET IDENTITY_INSERT [dbo].[campus] OFF;
DROP TABLE IF EXISTS [dbo].[carte_etudiante];
CREATE TABLE [dbo].[carte_etudiante] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[utilisateur_id] int NOT NULL,
[qr_code] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[date_activation] datetime2 NOT NULL,
[date_expiration] datetime2 NOT NULL,
[actif] bit NOT NULL DEFAULT '((1))',
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[carte_etudiante] ON;
INSERT INTO [dbo].[carte_etudiante] ([id], [utilisateur_id], [qr_code], [date_activation], [date_expiration], [actif], [created_at], [updated_at]) VALUES
(1, 1, 'QR-ETU-0001', '2025-08-19 14:48:51', '2027-07-20 14:48:51', '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(2, 2, 'QR-ETU-0002', '2025-08-19 14:48:51', '2027-07-20 14:48:51', '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(3, 3, 'QR-ETU-0003', '2025-08-19 14:48:51', '2027-07-20 14:48:51', '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52');
SET IDENTITY_INSERT [dbo].[carte_etudiante] OFF;
DROP TABLE IF EXISTS [dbo].[categorie_materiel];
CREATE TABLE [dbo].[categorie_materiel] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[description] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[actif] bit NOT NULL DEFAULT '((1))',
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[categorie_materiel] ON;
INSERT INTO [dbo].[categorie_materiel] ([id], [nom], [description], [actif], [created_at], [updated_at]) VALUES
(1, 'Ordinateur portable', 'Ordinateurs portables pédagogiques.', '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37'),
(2, 'Vidéoprojecteur', 'Vidéoprojecteurs et accessoires de projection.', '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37'),
(3, 'Tablette', 'Tablettes tactiles.', '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37'),
(4, 'Caméra', 'Caméras et matériel de captation vidéo.', '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37'),
(5, 'Périphérique audio', 'Micros, casques et enceintes.', '1', '2026-06-15 09:03:38', '2026-06-15 09:03:38'),
(6, 'Câble / Adaptateur', 'Câbles, adaptateurs et connectique.', '1', '2026-06-15 09:03:38', '2026-06-15 09:03:38');
SET IDENTITY_INSERT [dbo].[categorie_materiel] OFF;
DROP TABLE IF EXISTS [dbo].[checklist];
CREATE TABLE [dbo].[checklist] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[emprunt_id] int NOT NULL,
[utilisateur_id] int NOT NULL,
[type] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[date_verification] datetime2 NOT NULL,
[commentaire] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[checklist] ON;
INSERT INTO [dbo].[checklist] ([id], [emprunt_id], [utilisateur_id], [type], [date_verification], [commentaire], [created_at], [updated_at]) VALUES
(1, 1, 1, 'DEPART', '2026-06-13 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(2, 2, 2, 'DEPART', '2026-05-26 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(3, 3, 1, 'DEPART', '2026-06-05 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(4, 3, 1, 'RETOUR', '2026-06-12 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(5, 4, 3, 'DEPART', '2026-06-10 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(6, 4, 3, 'RETOUR', '2026-06-14 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(1002, 1002, 1, 'DEPART', '2026-07-06 07:38:13', NULL, '2026-07-06 07:38:13', '2026-07-06 07:38:13'),
(1003, 1, 1, 'RETOUR', '2026-07-06 08:03:20', NULL, '2026-07-06 08:03:20', '2026-07-06 08:03:20'),
(1004, 1002, 1, 'RETOUR', '2026-07-06 08:03:20', NULL, '2026-07-06 08:03:20', '2026-07-06 08:03:20'),
(2002, 2002, 2, 'DEPART', '2026-07-07 11:51:13', NULL, '2026-07-07 11:51:13', '2026-07-07 11:51:13'),
(2003, 2003, 2, 'DEPART', '2026-07-07 11:58:06', NULL, '2026-07-07 11:58:06', '2026-07-07 11:58:06'),
(3002, 2, 2, 'RETOUR', '2026-07-07 12:33:18', NULL, '2026-07-07 12:33:18', '2026-07-07 12:33:18'),
(3003, 2002, 2, 'RETOUR', '2026-07-07 12:33:29', NULL, '2026-07-07 12:33:29', '2026-07-07 12:33:29'),
(3004, 2003, 2, 'RETOUR', '2026-07-07 12:33:43', NULL, '2026-07-07 12:33:43', '2026-07-07 12:33:43'),
(3005, 3002, 2, 'DEPART', '2026-07-07 12:34:25', NULL, '2026-07-07 12:34:25', '2026-07-07 12:34:25'),
(4002, 4002, 2, 'DEPART', '2026-07-08 09:19:17', NULL, '2026-07-08 09:19:17', '2026-07-08 09:19:17'),
(4003, 4002, 2, 'RETOUR', '2026-07-08 09:19:17', NULL, '2026-07-08 09:19:17', '2026-07-08 09:19:17'),
(4004, 4003, 2, 'DEPART', '2026-07-08 09:19:47', NULL, '2026-07-08 09:19:47', '2026-07-08 09:19:47'),
(4005, 4003, 2, 'RETOUR', '2026-07-08 09:19:47', NULL, '2026-07-08 09:19:47', '2026-07-08 09:19:47'),
(4006, 4004, 2, 'DEPART', '2026-07-08 13:19:04', NULL, '2026-07-08 13:19:04', '2026-07-08 13:19:04');
SET IDENTITY_INSERT [dbo].[checklist] OFF;
DROP TABLE IF EXISTS [dbo].[checklist_element];
CREATE TABLE [dbo].[checklist_element] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[checklist_id] int NOT NULL,
[accessoire_id] int NULL,
[nom_element] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[etat] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[quantite_constatee] int NOT NULL,
[commentaire] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[checklist_element] ON;
INSERT INTO [dbo].[checklist_element] ([id], [checklist_id], [accessoire_id], [nom_element], [etat], [quantite_constatee], [commentaire], [created_at], [updated_at]) VALUES
(1, 1, 1, 'Chargeur', 'PRESENT', 1, NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(2, 1, 2, 'Souris', 'PRESENT', 1, NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(3, 5, 3, 'Housse', 'PRESENT', 1, NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(4, 5, 1, 'Chargeur', 'PRESENT', 1, NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(5, 6, 3, 'Housse', 'ABSENT', 0, 'Manquante au retour', '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(6, 6, 1, 'Chargeur', 'PRESENT', 1, NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(1002, 1002, NULL, 'Etat general', 'PRESENT', 1, NULL, '2026-07-06 07:38:13', '2026-07-06 07:38:13'),
(1003, 1003, NULL, 'Chargeur', 'PRESENT', 1, NULL, '2026-07-06 08:03:20', '2026-07-06 08:03:20'),
(1004, 1003, NULL, 'Souris', 'PRESENT', 1, NULL, '2026-07-06 08:03:20', '2026-07-06 08:03:20'),
(1005, 1004, NULL, 'Etat general', 'ABSENT', 0, NULL, '2026-07-06 08:03:20', '2026-07-06 08:03:20'),
(2002, 2002, NULL, 'Batterie', 'ABSENT', 0, NULL, '2026-07-07 11:51:13', '2026-07-07 11:51:13'),
(2003, 2002, NULL, 'Carte SD', 'PRESENT', 1, NULL, '2026-07-07 11:51:13', '2026-07-07 11:51:13'),
(2004, 2002, NULL, 'Housse', 'PRESENT', 1, NULL, '2026-07-07 11:51:13', '2026-07-07 11:51:13'),
(2005, 2003, NULL, 'Chargeur USB-C', 'ABSENT', 0, NULL, '2026-07-07 11:58:06', '2026-07-07 11:58:06'),
(2006, 2003, NULL, 'Housse', 'PRESENT', 1, NULL, '2026-07-07 11:58:06', '2026-07-07 11:58:06'),
(3002, 3002, NULL, 'Vidéoprojecteur Epson', 'PRESENT', 1, NULL, '2026-07-07 12:33:18', '2026-07-07 12:33:18'),
(3003, 3003, NULL, 'Batterie', 'PRESENT', 1, NULL, '2026-07-07 12:33:29', '2026-07-07 12:33:29'),
(3004, 3003, NULL, 'Carte SD', 'PRESENT', 1, NULL, '2026-07-07 12:33:29', '2026-07-07 12:33:29'),
(3005, 3003, NULL, 'Housse', 'PRESENT', 1, NULL, '2026-07-07 12:33:29', '2026-07-07 12:33:29'),
(3006, 3004, NULL, 'Chargeur USB-C', 'ABSENT', 0, NULL, '2026-07-07 12:33:43', '2026-07-07 12:33:43'),
(3007, 3004, NULL, 'Housse', 'PRESENT', 1, NULL, '2026-07-07 12:33:43', '2026-07-07 12:33:43'),
(3008, 3005, NULL, 'Chargeur', 'PRESENT', 1, NULL, '2026-07-07 12:34:25', '2026-07-07 12:34:25'),
(3009, 3005, NULL, 'Housse', 'ABSENT', 0, NULL, '2026-07-07 12:34:25', '2026-07-07 12:34:25'),
(3010, 3005, NULL, 'Souris', 'PRESENT', 1, NULL, '2026-07-07 12:34:25', '2026-07-07 12:34:25'),
(4002, 4002, NULL, 'Casque Jabra Evolve', 'PRESENT', 1, NULL, '2026-07-08 09:19:17', '2026-07-08 09:19:17'),
(4003, 4003, NULL, 'Casque Jabra Evolve', 'PRESENT', 1, NULL, '2026-07-08 09:19:17', '2026-07-08 09:19:17'),
(4004, 4004, NULL, 'Cable USB-C', 'PRESENT', 1, NULL, '2026-07-08 09:19:47', '2026-07-08 09:19:47'),
(4005, 4005, NULL, 'Cable USB-C', 'ABSENT', 0, NULL, '2026-07-08 09:19:47', '2026-07-08 09:19:47'),
(4006, 4006, NULL, 'Chargeur USB-C', 'ABSENT', 0, NULL, '2026-07-08 13:19:04', '2026-07-08 13:19:04'),
(4007, 4006, NULL, 'Housse', 'PRESENT', 1, NULL, '2026-07-08 13:19:04', '2026-07-08 13:19:04');
SET IDENTITY_INSERT [dbo].[checklist_element] OFF;
DROP TABLE IF EXISTS [dbo].[emprunt];
CREATE TABLE [dbo].[emprunt] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[utilisateur_id] int NOT NULL,
[materiel_id] int NOT NULL,
[campus_id] int NOT NULL,
[salle_pret_id] int NOT NULL,
[poste_emprunt_id] int NOT NULL,
[date_emprunt] datetime2 NOT NULL,
[date_retour_prevue] datetime2 NOT NULL,
[date_retour_reelle] datetime2 NULL,
[statut] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[mode_identification_emprunt] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[mode_identification_retour] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[commentaire_depart] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[commentaire_retour] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[emprunt] ON;
INSERT INTO [dbo].[emprunt] ([id], [utilisateur_id], [materiel_id], [campus_id], [salle_pret_id], [poste_emprunt_id], [date_emprunt], [date_retour_prevue], [date_retour_reelle], [statut], [mode_identification_emprunt], [mode_identification_retour], [commentaire_depart], [commentaire_retour], [created_at], [updated_at]) VALUES
(1, 1, 1, 1, 1, 1, '2026-06-13 14:48:51', '2026-06-20 14:48:51', '2026-07-06 08:03:20', 'CLOTURE', 'QR_CODE', 'QR_CODE', NULL, NULL, '2026-06-15 14:48:53', '2026-07-06 08:03:20'),
(2, 2, 2, 1, 1, 1, '2026-05-26 14:48:51', '2026-06-09 14:48:51', '2026-07-07 12:33:18', 'CLOTURE', 'MAIL_ENSUP', 'MAIL_ENSUP', NULL, NULL, '2026-06-15 14:48:53', '2026-07-07 12:33:18'),
(3, 1, 3, 1, 1, 1, '2026-06-05 14:48:51', '2026-06-12 14:48:51', '2026-06-12 14:48:51', 'CLOTURE', 'QR_CODE', 'QR_CODE', NULL, NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(4, 3, 4, 1, 1, 1, '2026-06-10 14:48:51', '2026-06-14 14:48:51', '2026-06-14 14:48:51', 'RETOUR_NON_CONFORME', 'MAIL_ENSUP', 'MAIL_ENSUP', NULL, 'Housse non rapportee.', '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(1002, 1, 3, 1, 1, 1, '2026-07-06 07:38:13', '2026-07-20 07:38:13', '2026-07-06 08:03:20', 'RETOUR_NON_CONFORME', 'QR_CODE', 'QR_CODE', 'RAS', 'element manquant', '2026-07-06 07:38:13', '2026-07-06 08:03:20'),
(2002, 2, 1006, 1, 1, 1, '2026-07-07 11:51:13', '2026-07-21 11:51:13', '2026-07-07 12:33:29', 'CLOTURE', 'MAIL_ENSUP', 'MAIL_ENSUP', NULL, NULL, '2026-07-07 11:51:13', '2026-07-07 12:33:29'),
(2003, 2, 1003, 1, 1, 1, '2026-07-07 11:58:06', '2026-07-21 11:58:06', '2026-07-07 12:33:43', 'CLOTURE', 'MAIL_ENSUP', 'MAIL_ENSUP', NULL, NULL, '2026-07-07 11:58:06', '2026-07-07 12:33:44'),
(3002, 2, 1002, 1, 1, 1, '2026-07-07 12:34:25', '2026-07-21 12:34:25', NULL, 'EN_COURS', 'MAIL_ENSUP', NULL, NULL, NULL, '2026-07-07 12:34:25', '2026-07-07 12:34:25'),
(4002, 2, 1007, 1, 1, 1, '2026-07-08 09:19:17', '2026-07-22 09:19:17', '2026-07-08 09:19:17', 'CLOTURE', 'MAIL_ENSUP', 'MAIL_ENSUP', 'Test stabilisation API', 'Retour test conforme', '2026-07-08 09:19:17', '2026-07-08 09:19:17'),
(4003, 2, 1009, 1, 1, 1, '2026-07-08 09:19:47', '2026-07-22 09:19:46', '2026-07-08 09:19:47', 'RETOUR_NON_CONFORME', 'MAIL_ENSUP', 'MAIL_ENSUP', 'Test non conforme API', 'Retour test non conforme', '2026-07-08 09:19:47', '2026-07-08 09:19:47'),
(4004, 2, 1003, 1, 1, 1, '2026-07-08 13:19:04', '2026-07-22 13:19:04', NULL, 'EN_COURS', 'MAIL_ENSUP', NULL, NULL, NULL, '2026-07-08 13:19:04', '2026-07-08 13:19:04');
SET IDENTITY_INSERT [dbo].[emprunt] OFF;
DROP TABLE IF EXISTS [dbo].[historique];
CREATE TABLE [dbo].[historique] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[utilisateur_id] int NOT NULL,
[emprunt_id] int NULL,
[materiel_id] int NULL,
[campus_id] int NULL,
[salle_pret_id] int NULL,
[poste_emprunt_id] int NULL,
[action] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[description] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[date_action] datetime2 NOT NULL,
[created_at] datetime2 NOT NULL DEFAULT '(getdate())'
);
SET IDENTITY_INSERT [dbo].[historique] ON;
INSERT INTO [dbo].[historique] ([id], [utilisateur_id], [emprunt_id], [materiel_id], [campus_id], [salle_pret_id], [poste_emprunt_id], [action], [description], [date_action], [created_at]) VALUES
(1, 1, 1, 1, 1, 1, 1, 'CREATION_EMPRUNT', 'Emprunt du PC portable Dell.', '2026-06-13 14:48:51', '2026-06-15 14:48:53'),
(2, 3, 4, 4, 1, 1, 1, 'CREATION_ANOMALIE', 'Anomalie detectee au retour de la Caméra Sony.', '2026-06-14 14:48:51', '2026-06-15 14:48:53');
SET IDENTITY_INSERT [dbo].[historique] OFF;
DROP TABLE IF EXISTS [dbo].[materiel];
CREATE TABLE [dbo].[materiel] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[marque] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[modele] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[reference] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[numero_inventaire] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[numero_serie] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[statut] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[etat_general] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[categorie_id] int NOT NULL,
[campus_id] int NOT NULL,
[actif] bit NOT NULL DEFAULT '((1))',
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[materiel] ON;
INSERT INTO [dbo].[materiel] ([id], [nom], [marque], [modele], [reference], [numero_inventaire], [numero_serie], [statut], [etat_general], [categorie_id], [campus_id], [actif], [created_at], [updated_at]) VALUES
(1, 'PC portable Dell Latitude', 'Dell', 'Latitude 5440', 'REF-PC-001', 'INV-PC-001', 'SN-DELL-001', 'DISPONIBLE', 'Bon etat', 1, 1, '1', '2026-06-15 14:48:52', '2026-07-06 08:03:20'),
(2, 'Vidéoprojecteur Epson', 'Epson', 'EB-W06', 'REF-VP-001', 'INV-VP-001', 'SN-EPSON-001', 'DISPONIBLE', 'Bon etat', 2, 1, '1', '2026-06-15 14:48:52', '2026-07-07 12:33:18'),
(3, 'Tablette iPad', 'Apple', 'iPad 10', 'REF-TAB-001', 'INV-TAB-001', 'SN-IPAD-001', 'NON_CONFORME', 'Bon etat', 3, 1, '1', '2026-06-15 14:48:52', '2026-07-06 08:03:20'),
(4, 'Caméra Sony', 'Sony', 'Alpha 6400', 'REF-CAM-001', 'INV-CAM-001', 'SN-SONY-001', 'DETERIORE', 'Housse manquante au retour', 4, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(5, 'Micro Rode', 'Rode', 'NT-USB', 'REF-MIC-001', 'INV-MIC-001', 'SN-RODE-001', 'DISPONIBLE', 'Bon etat', 5, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(6, 'PC portable HP', 'HP', 'ProBook 450', 'REF-PC-002', 'INV-PC-002', 'SN-HP-002', 'MAINTENANCE', 'En revision', 1, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(1002, 'PC Lenovo ThinkPad E14', 'Lenovo', 'ThinkPad E14', 'REF-PC-003', 'INV-PC-003', 'SN-LENOVO-003', 'EMPRUNTE', 'Bon etat', 1, 1, '1', '2026-07-07 11:39:38', '2026-07-07 12:34:25'),
(1003, 'MacBook Air M2', 'Apple', 'MacBook Air 13', 'REF-PC-004', 'INV-PC-004', 'SN-MBA-004', 'EMPRUNTE', 'Tres bon etat', 1, 1, '1', '2026-07-07 11:39:38', '2026-07-08 13:19:04'),
(1004, 'Microsoft Surface Pro', 'Microsoft', 'Surface Pro 9', 'REF-TAB-002', 'INV-TAB-002', 'SN-SURFACE-002', 'DISPONIBLE', 'Bon etat', 3, 1, '1', '2026-07-07 11:39:38', '2026-07-07 11:39:38'),
(1005, 'Vidéoprojecteur Epson EB-X49', 'Epson', 'EB-X49', 'REF-VP-002', 'INV-VP-002', 'SN-EPSON-002', 'DISPONIBLE', 'Bon etat', 2, 1, '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
(1006, 'Caméra Canon EOS M50', 'Canon', 'EOS M50', 'REF-CAM-002', 'INV-CAM-002', 'SN-CANON-002', 'DISPONIBLE', 'Bon etat', 4, 1, '1', '2026-07-07 11:39:39', '2026-07-07 12:33:29'),
(1007, 'Casque Jabra Evolve', 'Jabra', 'Evolve 40', 'REF-AUD-002', 'INV-AUD-002', 'SN-JABRA-002', 'DISPONIBLE', 'Bon etat', 5, 1, '1', '2026-07-07 11:39:39', '2026-07-08 09:19:17'),
(1008, 'Kit câbles HDMI / USB-C', 'ENSUP', 'Kit connectique', 'REF-CAB-001', 'INV-CAB-001', 'SN-CABLE-001', 'DISPONIBLE', 'Bon etat', 6, 1, '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
(1009, 'Enceinte Bluetooth JBL', 'JBL', 'Flip 6', 'REF-AUD-003', 'INV-AUD-003', 'SN-JBL-003', 'NON_CONFORME', 'Bon etat', 5, 1, '1', '2026-07-07 11:39:39', '2026-07-08 09:19:47');
SET IDENTITY_INSERT [dbo].[materiel] OFF;
DROP TABLE IF EXISTS [dbo].[materiel_accessoire];
CREATE TABLE [dbo].[materiel_accessoire] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[materiel_id] int NOT NULL,
[accessoire_id] int NOT NULL,
[quantite_attendue] int NOT NULL,
[obligatoire] bit NOT NULL
);
SET IDENTITY_INSERT [dbo].[materiel_accessoire] ON;
INSERT INTO [dbo].[materiel_accessoire] ([id], [materiel_id], [accessoire_id], [quantite_attendue], [obligatoire]) VALUES
(1, 1, 1, 1, '1'),
(2, 1, 2, 1, '0'),
(3, 4, 3, 1, '1'),
(4, 4, 1, 1, '1'),
(1002, 1002, 1, 1, '1'),
(1003, 1002, 2, 1, '1'),
(1004, 1002, 3, 1, '1'),
(1005, 1003, 1002, 1, '1'),
(1006, 1003, 3, 1, '1'),
(1007, 1004, 1003, 1, '1'),
(1008, 1004, 1004, 1, '1'),
(1009, 1005, 1005, 1, '1'),
(1010, 1005, 1006, 1, '1'),
(1011, 1006, 1007, 1, '1'),
(1012, 1006, 1008, 1, '1'),
(1013, 1006, 3, 1, '1'),
(1014, 1008, 1006, 1, '1'),
(1015, 1008, 1009, 1, '1'),
(1016, 1009, 1010, 1, '1');
SET IDENTITY_INSERT [dbo].[materiel_accessoire] OFF;
DROP TABLE IF EXISTS [dbo].[notification];
CREATE TABLE [dbo].[notification] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[utilisateur_id] int NOT NULL,
[anomalie_id] int NULL,
[titre] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[message] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[type] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[lu] bit NOT NULL DEFAULT '((0))',
[date_creation] datetime2 NOT NULL DEFAULT '(getdate())'
);
SET IDENTITY_INSERT [dbo].[notification] ON;
INSERT INTO [dbo].[notification] ([id], [utilisateur_id], [anomalie_id], [titre], [message], [type], [lu], [date_creation]) VALUES
(1, 4, 1, 'Nouvelle anomalie detectee', 'Retour non conforme sur la Caméra Sony (housse manquante).', 'ANOMALIE', '0', '2026-06-15 14:48:53'),
(2, 4, 2, 'Nouvelle anomalie detectee', 'Retour non conforme : Etat general manquant au retour.', 'ANOMALIE', '0', '2026-07-06 08:03:20'),
(1002, 4, 1002, 'Nouvelle anomalie detectee', 'Retour non conforme : Cable USB-C manquant au retour.', 'ANOMALIE', '0', '2026-07-08 09:19:47');
SET IDENTITY_INSERT [dbo].[notification] OFF;
DROP TABLE IF EXISTS [dbo].[poste_emprunt];
CREATE TABLE [dbo].[poste_emprunt] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[identifiant_poste] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[salle_pret_id] int NOT NULL,
[actif] bit NOT NULL DEFAULT '((1))',
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[poste_emprunt] ON;
INSERT INTO [dbo].[poste_emprunt] ([id], [nom], [identifiant_poste], [salle_pret_id], [actif], [created_at], [updated_at]) VALUES
(1, 'Poste 1', 'POSTE-01', 1, '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37');
SET IDENTITY_INSERT [dbo].[poste_emprunt] OFF;
DROP TABLE IF EXISTS [dbo].[role];
CREATE TABLE [dbo].[role] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[code] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[libelle] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[description] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
CONSTRAINT [role_code_key] UNIQUE ([code])
);
SET IDENTITY_INSERT [dbo].[role] ON;
INSERT INTO [dbo].[role] ([id], [code], [libelle], [description]) VALUES
(1, 'ETUDIANT', 'Étudiant', 'Emprunte et restitue du matériel via le poste dédié.'),
(2, 'RESPONSABLE', 'Responsable matériel', 'Supervise, gère le stock et traite les anomalies.');
SET IDENTITY_INSERT [dbo].[role] OFF;
DROP TABLE IF EXISTS [dbo].[salle_pret];
CREATE TABLE [dbo].[salle_pret] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[localisation] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[campus_id] int NOT NULL,
[actif] bit NOT NULL DEFAULT '((1))',
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[salle_pret] ON;
INSERT INTO [dbo].[salle_pret] ([id], [nom], [localisation], [campus_id], [actif], [created_at], [updated_at]) VALUES
(1, 'Salle de prêt principale', 'Campus Saint-Christophe', 1, '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37');
SET IDENTITY_INSERT [dbo].[salle_pret] OFF;
DROP TABLE IF EXISTS [dbo].[utilisateur];
CREATE TABLE [dbo].[utilisateur] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[microsoft_id] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[prenom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[email] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[classe] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[role_id] int NOT NULL,
[campus_id] int NOT NULL,
[actif] bit NOT NULL DEFAULT '((1))',
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL,
CONSTRAINT [utilisateur_microsoft_id_key] UNIQUE ([microsoft_id]),
CONSTRAINT [utilisateur_email_key] UNIQUE ([email])
);
SET IDENTITY_INSERT [dbo].[utilisateur] ON;
INSERT INTO [dbo].[utilisateur] ([id], [microsoft_id], [nom], [prenom], [email], [classe], [role_id], [campus_id], [actif], [created_at], [updated_at]) VALUES
(1, 'ms-marie-dupont', 'Dupont', 'Marie', 'marie.dupont@ensitech.eu', 'B3 Informatique', 1, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(2, 'ms-lucas-martin', 'Martin', 'Lucas', 'lucas.martin@ensitech.eu', 'B2 Informatique', 1, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(3, 'ms-sofia-bernard', 'Bernard', 'Sofia', 'sofia.bernard@ensup.eu', 'B1 Marketing', 1, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(4, 'ms-karim-benali', 'Benali', 'Karim', 'karim.benali@ensup.eu', NULL, 2, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52');
SET IDENTITY_INSERT [dbo].[utilisateur] OFF;
ALTER TABLE [dbo].[anomalie] ADD
FOREIGN KEY ([emprunt_id]) REFERENCES [emprunt] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([materiel_id]) REFERENCES [materiel] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([utilisateur_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([traitee_par_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION;
ALTER TABLE [dbo].[carte_etudiante] ADD
FOREIGN KEY ([utilisateur_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE;
ALTER TABLE [dbo].[checklist] ADD
FOREIGN KEY ([emprunt_id]) REFERENCES [emprunt] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([utilisateur_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION;
ALTER TABLE [dbo].[checklist_element] ADD
FOREIGN KEY ([accessoire_id]) REFERENCES [accessoire] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([checklist_id]) REFERENCES [checklist] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION;
ALTER TABLE [dbo].[emprunt] ADD
FOREIGN KEY ([campus_id]) REFERENCES [campus] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([materiel_id]) REFERENCES [materiel] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([poste_emprunt_id]) REFERENCES [poste_emprunt] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([salle_pret_id]) REFERENCES [salle_pret] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([utilisateur_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION;
ALTER TABLE [dbo].[historique] ADD
FOREIGN KEY ([campus_id]) REFERENCES [campus] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([emprunt_id]) REFERENCES [emprunt] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([materiel_id]) REFERENCES [materiel] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([poste_emprunt_id]) REFERENCES [poste_emprunt] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([salle_pret_id]) REFERENCES [salle_pret] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([utilisateur_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION;
ALTER TABLE [dbo].[materiel] ADD
FOREIGN KEY ([campus_id]) REFERENCES [campus] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE,
FOREIGN KEY ([categorie_id]) REFERENCES [categorie_materiel] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE;
ALTER TABLE [dbo].[materiel_accessoire] ADD
FOREIGN KEY ([accessoire_id]) REFERENCES [accessoire] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE,
FOREIGN KEY ([materiel_id]) REFERENCES [materiel] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE;
ALTER TABLE [dbo].[notification] ADD
FOREIGN KEY ([anomalie_id]) REFERENCES [anomalie] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([utilisateur_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION;
ALTER TABLE [dbo].[poste_emprunt] ADD
FOREIGN KEY ([salle_pret_id]) REFERENCES [salle_pret] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE;
ALTER TABLE [dbo].[salle_pret] ADD
FOREIGN KEY ([campus_id]) REFERENCES [campus] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE;
ALTER TABLE [dbo].[utilisateur] ADD
FOREIGN KEY ([campus_id]) REFERENCES [campus] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE,
FOREIGN KEY ([role_id]) REFERENCES [role] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE;
-- 2026-07-10 09:04:02 UTC
+13 -5
View File
@@ -1,10 +1,18 @@
/// Identité simulée utilisée tant que l'authentification Azure AD n'est pas branchée. import "services/auth_service.dart";
/// Identité de secours utilisée lorsque aucun profil de session n'est chargé.
class DemoIdentity { class DemoIdentity {
DemoIdentity._(); DemoIdentity._();
static const userName = "Lucas Martin"; static String get userName =>
static const userInitials = "LM"; AuthService.currentProfile?.fullName ?? "Lucas Martin";
static const userSubtitle = "Étudiant · B2 Informatique · Ensitech Cergy"; static String get userInitials =>
static const campusTag = "Saint-Christophe · Cergy"; AuthService.currentProfile?.initials ?? "LM";
static String get userSubtitle =>
AuthService.currentProfile?.subtitle ??
"Étudiant · B2 Informatique · Ensitech Cergy";
static String get campusTag =>
AuthService.currentProfile?.campusTag ?? "Saint-Christophe · Cergy";
static const loginCampusTag = "Ensitech · Cergy"; static const loginCampusTag = "Ensitech · Cergy";
} }
@@ -0,0 +1,90 @@
class AuthenticatedProfile {
const AuthenticatedProfile({
required this.id,
required this.nom,
required this.prenom,
required this.email,
required this.classe,
required this.roleCode,
required this.roleLabel,
required this.campusId,
required this.campusNom,
required this.campusVille,
});
final int id;
final String nom;
final String prenom;
final String email;
final String? classe;
final String roleCode;
final String roleLabel;
final int campusId;
final String campusNom;
final String campusVille;
factory AuthenticatedProfile.fromApi(dynamic reponse) {
if (reponse is! Map || reponse["data"] is! Map) {
throw const FormatException("Profil utilisateur invalide.");
}
final data = reponse["data"] as Map;
final role = data["role"];
final campus = data["campus"];
if (role is! Map || campus is! Map) {
throw const FormatException("Role ou campus utilisateur invalide.");
}
final id = data["id"];
final campusId = campus["id"];
if (id is! int || campusId is! int) {
throw const FormatException("Identifiant utilisateur invalide.");
}
final classe = data["classe"];
if (classe != null && classe is! String) {
throw const FormatException("Classe utilisateur invalide.");
}
return AuthenticatedProfile(
id: id,
nom: _requiredString(data, "nom"),
prenom: _requiredString(data, "prenom"),
email: _requiredString(data, "email"),
classe: classe as String?,
roleCode: _requiredString(role, "code"),
roleLabel: _requiredString(role, "libelle"),
campusId: campusId,
campusNom: _requiredString(campus, "nom"),
campusVille: _requiredString(campus, "ville"),
);
}
String get fullName => "$prenom $nom";
String get initials {
final first = prenom.trim().isEmpty ? "" : prenom.trim()[0];
final last = nom.trim().isEmpty ? "" : nom.trim()[0];
return "$first$last".toUpperCase();
}
String get campusTag => "$campusNom · $campusVille";
String get subtitle {
final details = <String>[roleLabel];
final studentClass = classe?.trim();
if (studentClass != null && studentClass.isNotEmpty) {
details.add(studentClass);
}
details.add(campusTag);
return details.join(" · ");
}
static String _requiredString(Map source, String key) {
final value = source[key];
if (value is! String || value.trim().isEmpty) {
throw FormatException("Champ utilisateur invalide : $key.");
}
return value.trim();
}
}
@@ -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;
}
+11
View File
@@ -1,4 +1,5 @@
import "materiel_item.dart"; import "materiel_item.dart";
import "checklist_element_item.dart";
/// Un emprunt en cours de l'étudiant, affiché dans le parcours de restitution /// Un emprunt en cours de l'étudiant, affiché dans le parcours de restitution
/// et renvoyé par l'API. /// et renvoyé par l'API.
@@ -9,6 +10,7 @@ class EmpruntItem {
required this.dateEmprunt, required this.dateEmprunt,
required this.retourPrevu, required this.retourPrevu,
required this.statut, required this.statut,
required this.checklistDepart,
}); });
factory EmpruntItem.fromJson(Map<String, dynamic> json) { factory EmpruntItem.fromJson(Map<String, dynamic> json) {
@@ -21,6 +23,13 @@ class EmpruntItem {
dateEmprunt: _dateHeure(dateEmprunt), dateEmprunt: _dateHeure(dateEmprunt),
retourPrevu: _date(retourPrevu), retourPrevu: _date(retourPrevu),
statut: json["statut"] as String, 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 dateEmprunt;
final String retourPrevu; final String retourPrevu;
final String statut; final String statut;
final List<ChecklistElementItem> checklistDepart;
EmpruntItem copyWith({MaterielItem? materiel, String? statut}) { EmpruntItem copyWith({MaterielItem? materiel, String? statut}) {
return EmpruntItem( return EmpruntItem(
@@ -37,6 +47,7 @@ class EmpruntItem {
dateEmprunt: dateEmprunt, dateEmprunt: dateEmprunt,
retourPrevu: retourPrevu, retourPrevu: retourPrevu,
statut: statut ?? this.statut, statut: statut ?? this.statut,
checklistDepart: checklistDepart,
); );
} }
+95 -24
View File
@@ -6,6 +6,7 @@ import "../models/materiel_item.dart";
import "../services/materiel_service.dart"; import "../services/materiel_service.dart";
import "../widgets/ensup_top_bar.dart"; import "../widgets/ensup_top_bar.dart";
import "../widgets/brand_corners.dart"; import "../widgets/brand_corners.dart";
import "../widgets/app_message.dart";
import "../widgets/materiel_card.dart"; import "../widgets/materiel_card.dart";
import "detail_screen.dart"; import "detail_screen.dart";
@@ -35,15 +36,18 @@ class _CatalogueScreenState extends State<CatalogueScreen> {
} }
List<String> _categories(List<MaterielItem> materiels) { List<String> _categories(List<MaterielItem> materiels) {
final distinctes = materiels.map((m) => m.categorie).toSet().toList()..sort(); final distinctes = materiels.map((m) => m.categorie).toSet().toList()
..sort();
return ["Tous", ...distinctes]; return ["Tous", ...distinctes];
} }
List<MaterielItem> _filtrer(List<MaterielItem> materiels) { List<MaterielItem> _filtrer(List<MaterielItem> materiels) {
final terme = _recherche.trim().toLowerCase(); final terme = _recherche.trim().toLowerCase();
return materiels.where((materiel) { return materiels.where((materiel) {
final matchTexte = terme.isEmpty || materiel.nom.toLowerCase().contains(terme); final matchTexte =
final matchCategorie = _categorie == "Tous" || materiel.categorie == _categorie; terme.isEmpty || materiel.nom.toLowerCase().contains(terme);
final matchCategorie =
_categorie == "Tous" || materiel.categorie == _categorie;
return matchTexte && matchCategorie; return matchTexte && matchCategorie;
}).toList(); }).toList();
} }
@@ -55,16 +59,14 @@ class _CatalogueScreenState extends State<CatalogueScreen> {
if (!mounted) { if (!mounted) {
return; return;
} }
Navigator.of(context).push( Navigator.of(
MaterialPageRoute(builder: (_) => DetailScreen(item: detail)), context,
); ).push(MaterialPageRoute(builder: (_) => DetailScreen(item: detail)));
} catch (error) { } catch (error) {
if (!mounted) { if (!mounted) {
return; return;
} }
ScaffoldMessenger.of(context).showSnackBar( AppMessage.erreur(context, error);
SnackBar(content: Text(error.toString())),
);
} finally { } finally {
if (mounted) { if (mounted) {
setState(() => _selectionEnCoursId = null); setState(() => _selectionEnCoursId = null);
@@ -106,20 +108,42 @@ class _CatalogueScreenState extends State<CatalogueScreen> {
), ),
Expanded( Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(32, 24, 32, 8), padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(
"Matériel disponible",
style: GoogleFonts.darkerGrotesque(
fontSize: 28,
height: 1,
fontWeight: FontWeight.w900,
color: EnsupColors.text,
),
),
const SizedBox(height: 5),
Text(
"Recherchez un équipement puis vérifiez son contenu avant lemprunt.",
style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.muted,
),
),
const SizedBox(height: 18),
_SearchField( _SearchField(
onChanged: (value) => setState(() => _recherche = value), onChanged: (value) =>
setState(() => _recherche = value),
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
Expanded( Expanded(
child: FutureBuilder<List<MaterielItem>>( child: FutureBuilder<List<MaterielItem>>(
future: _future, future: _future,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState ==
return const Center(child: CircularProgressIndicator()); ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(),
);
} }
if (snapshot.hasError) { if (snapshot.hasError) {
return _ErreurState( return _ErreurState(
@@ -165,8 +189,25 @@ class _CatalogueScreenState extends State<CatalogueScreen> {
}).toList(), }).toList(),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Divider(color: EnsupColors.line, height: 1), Row(
const SizedBox(height: 16), children: [
Text(
"${resultats.length} équipement${resultats.length > 1 ? "s" : ""}",
style: GoogleFonts.darkerGrotesque(
fontSize: 16,
fontWeight: FontWeight.w800,
color: EnsupColors.text2,
),
),
const Expanded(
child: Padding(
padding: EdgeInsets.only(left: 14),
child: Divider(color: EnsupColors.line, height: 1),
),
),
],
),
const SizedBox(height: 12),
Expanded( Expanded(
child: resultats.isEmpty child: resultats.isEmpty
? const _EmptyState() ? const _EmptyState()
@@ -177,7 +218,10 @@ class _CatalogueScreenState extends State<CatalogueScreen> {
final item = resultats[index]; final item = resultats[index];
return Stack( return Stack(
children: [ children: [
MaterielCard(item: item, onSelect: () => _selectionner(item)), MaterielCard(
item: item,
onSelect: () => _selectionner(item),
),
if (_selectionEnCoursId == item.id) if (_selectionEnCoursId == item.id)
const Positioned.fill( const Positioned.fill(
child: ColoredBox( child: ColoredBox(
@@ -217,12 +261,18 @@ class _SearchField extends StatelessWidget {
Expanded( Expanded(
child: TextField( child: TextField(
onChanged: onChanged, onChanged: onChanged,
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.text), style: GoogleFonts.titilliumWeb(
fontSize: 14,
color: EnsupColors.text,
),
decoration: InputDecoration( decoration: InputDecoration(
isCollapsed: true, isCollapsed: true,
border: InputBorder.none, border: InputBorder.none,
hintText: "Rechercher un matériel...", hintText: "Rechercher un matériel...",
hintStyle: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.muted), hintStyle: GoogleFonts.titilliumWeb(
fontSize: 14,
color: EnsupColors.muted,
),
), ),
), ),
), ),
@@ -233,7 +283,11 @@ class _SearchField extends StatelessWidget {
} }
class _CategoryChip extends StatelessWidget { class _CategoryChip extends StatelessWidget {
const _CategoryChip({required this.label, required this.selected, required this.onTap}); const _CategoryChip({
required this.label,
required this.selected,
required this.onTap,
});
final String label; final String label;
final bool selected; final bool selected;
@@ -249,7 +303,10 @@ class _CategoryChip extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: selected ? EnsupColors.cyan : Colors.white, color: selected ? EnsupColors.cyan : Colors.white,
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
border: Border.all(color: selected ? EnsupColors.cyan : EnsupColors.line, width: 1.5), border: Border.all(
color: selected ? EnsupColors.cyan : EnsupColors.line,
width: 1.5,
),
), ),
child: Text( child: Text(
label, label,
@@ -273,11 +330,18 @@ class _EmptyState extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.inventory_2_outlined, size: 40, color: EnsupColors.muted), const Icon(
Icons.inventory_2_outlined,
size: 40,
color: EnsupColors.muted,
),
const SizedBox(height: 10), const SizedBox(height: 10),
Text( Text(
"Aucun matériel ne correspond", "Aucun matériel ne correspond",
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.muted), style: GoogleFonts.titilliumWeb(
fontSize: 14,
color: EnsupColors.muted,
),
), ),
], ],
), ),
@@ -297,12 +361,19 @@ class _ErreurState extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.cloud_off_outlined, size: 40, color: EnsupColors.muted), const Icon(
Icons.cloud_off_outlined,
size: 40,
color: EnsupColors.muted,
),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
message, message,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.text2), style: GoogleFonts.titilliumWeb(
fontSize: 14,
color: EnsupColors.text2,
),
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
OutlinedButton( OutlinedButton(
@@ -7,10 +7,9 @@ import "../services/emprunt_service.dart";
import "../widgets/ensup_top_bar.dart"; import "../widgets/ensup_top_bar.dart";
import "../widgets/brand_corners.dart"; import "../widgets/brand_corners.dart";
import "../widgets/etat_checklist.dart"; import "../widgets/etat_checklist.dart";
import "../widgets/app_message.dart";
import "confirmation_screen.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 { class ChecklistDepartScreen extends StatefulWidget {
const ChecklistDepartScreen({super.key, required this.item}); const ChecklistDepartScreen({super.key, required this.item});
@@ -31,6 +30,8 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
final TextEditingController _commentaireController = TextEditingController(); final TextEditingController _commentaireController = TextEditingController();
bool _envoiEnCours = false; bool _envoiEnCours = false;
bool get _ecartDeclare => _etats.any((etat) => etat != EtatChecklist.present);
@override @override
void dispose() { void dispose() {
_commentaireController.dispose(); _commentaireController.dispose();
@@ -58,7 +59,9 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
return { return {
"nomElement": entry.value, "nomElement": entry.value,
"etat": _etatApi(_etats[entry.key]), "etat": _etatApi(_etats[entry.key]),
"quantiteConstatee": _etats[entry.key] == EtatChecklist.absent ? 0 : 1, "quantiteConstatee": _etats[entry.key] == EtatChecklist.absent
? 0
: 1,
}; };
}).toList(), }).toList(),
); );
@@ -75,9 +78,7 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
if (!mounted) { if (!mounted) {
return; return;
} }
ScaffoldMessenger.of(context).showSnackBar( AppMessage.erreur(context, error);
SnackBar(content: Text(error.toString())),
);
} finally { } finally {
if (mounted) { if (mounted) {
setState(() => _envoiEnCours = false); setState(() => _envoiEnCours = false);
@@ -130,14 +131,31 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
), ),
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28), padding: const EdgeInsets.symmetric(
child: Row( horizontal: 24,
crossAxisAlignment: CrossAxisAlignment.start, vertical: 26,
children: [ ),
Expanded(flex: 3, child: _colonneGauche()), child: LayoutBuilder(
const SizedBox(width: 20), builder: (context, constraints) {
Expanded(flex: 2, child: _colonneDroite()), if (constraints.maxWidth < 780) {
], return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_colonneGauche(),
const SizedBox(height: 22),
_colonneDroite(),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 3, child: _colonneGauche()),
const SizedBox(width: 20),
Expanded(flex: 2, child: _colonneDroite()),
],
);
},
), ),
), ),
), ),
@@ -161,7 +179,10 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: EnsupColors.soft, color: EnsupColors.soft,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.cyan.withValues(alpha: 0.15), width: 1.5), border: Border.all(
color: EnsupColors.cyan.withValues(alpha: 0.15),
width: 1.5,
),
), ),
child: Row( child: Row(
children: [ children: [
@@ -172,7 +193,11 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
color: EnsupColors.cyan.withValues(alpha: 0.1), color: EnsupColors.cyan.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(9), borderRadius: BorderRadius.circular(9),
), ),
child: Icon(widget.item.icon, color: EnsupColors.cyan, size: 18), child: Icon(
widget.item.icon,
color: EnsupColors.cyan,
size: 18,
),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
@@ -181,11 +206,17 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
children: [ children: [
Text( Text(
widget.item.nom, widget.item.nom,
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w800, fontSize: 16), style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w800,
fontSize: 16,
),
), ),
Text( Text(
"Vérifiez chaque élément physiquement avant de valider", "Vérifiez chaque élément physiquement avant de valider",
style: GoogleFonts.titilliumWeb(fontSize: 12, color: EnsupColors.muted), style: GoogleFonts.titilliumWeb(
fontSize: 12,
color: EnsupColors.muted,
),
), ),
], ],
), ),
@@ -208,12 +239,45 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ChecklistInstructions( ChecklistInstructions(
titre: "Instructions", titre: "État de référence",
texte: texte:
"Vérifiez physiquement chaque élément du kit et indiquez son état : " "Tous les éléments sont préremplis comme présents. Vérifiez-les "
"Présent, Absent ou Détérioré. Signalez tout élément manquant ou " "physiquement et modifiez uniquement ceux qui sont absents ou détériorés.",
"endommagé pour éviter tout litige au retour.",
), ),
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), const SizedBox(height: 14),
ChecklistCommentaire(controller: _commentaireController), ChecklistCommentaire(controller: _commentaireController),
const SizedBox(height: 14), const SizedBox(height: 14),
@@ -225,20 +289,30 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
? const SizedBox( ? const SizedBox(
width: 18, width: 18,
height: 18, height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
) )
: const Icon(Icons.check, size: 18), : const Icon(Icons.check, size: 18),
label: Text( label: Text(
_envoiEnCours _envoiEnCours
? "Enregistrement..." ? "Enregistrement..."
: "Valider la checklist et confirmer l'emprunt", : _ecartDeclare
? "Signaler l'écart"
: "Confirmer l'emprunt",
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 15), style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w700,
fontSize: 15,
),
), ),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: EnsupColors.blue3, backgroundColor: EnsupColors.blue3,
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
), ),
), ),
), ),
@@ -4,14 +4,14 @@ import "../demo_identity.dart";
import "../theme/ensup_colors.dart"; import "../theme/ensup_colors.dart";
import "../models/emprunt_item.dart"; import "../models/emprunt_item.dart";
import "../models/anomalie_retour.dart"; import "../models/anomalie_retour.dart";
import "../models/checklist_element_item.dart";
import "../services/emprunt_service.dart"; import "../services/emprunt_service.dart";
import "../widgets/ensup_top_bar.dart"; import "../widgets/ensup_top_bar.dart";
import "../widgets/brand_corners.dart"; import "../widgets/brand_corners.dart";
import "../widgets/etat_checklist.dart"; import "../widgets/etat_checklist.dart";
import "../widgets/app_message.dart";
import "resultat_retour_screen.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 { class ChecklistRetourScreen extends StatefulWidget {
const ChecklistRetourScreen({super.key, required this.emprunt}); const ChecklistRetourScreen({super.key, required this.emprunt});
@@ -22,15 +22,41 @@ class ChecklistRetourScreen extends StatefulWidget {
} }
class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> { class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
late final List<String> _elements = widget.emprunt.materiel.accessoires.isEmpty late final List<ChecklistElementItem> _checklistDepart =
? <String>[widget.emprunt.materiel.nom] widget.emprunt.checklistDepart.isNotEmpty
: widget.emprunt.materiel.accessoires; ? widget.emprunt.checklistDepart
late final List<EtatChecklist> _etats = List<EtatChecklist>.filled( : (widget.emprunt.materiel.accessoires.isEmpty
_elements.length, ? [
EtatChecklist.present, 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(); final TextEditingController _commentaireController = TextEditingController();
bool _envoiEnCours = false; bool _envoiEnCours = false;
bool _alerteEnCours = false;
bool _alerteEnvoyee = false;
@override @override
void dispose() { void dispose() {
@@ -43,10 +69,14 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
final anomalies = <AnomalieRetour>[]; final anomalies = <AnomalieRetour>[];
for (var i = 0; i < _etats.length; i++) { for (var i = 0; i < _etats.length; i++) {
if (_etats[i] == EtatChecklist.absent) { if (_etats[i] != _etatsInitiales[i]) {
anomalies.add(AnomalieRetour(element: _elements[i], etatRetour: "Absent au retour")); anomalies.add(
} else if (_etats[i] == EtatChecklist.deteriore) { AnomalieRetour(
anomalies.add(AnomalieRetour(element: _elements[i], etatRetour: "Détérioré au retour")); element: _elements[i],
etatRetour:
"${_etatLabel(_etatsInitiales[i])}${_etatLabel(_etats[i])}",
),
);
} }
} }
@@ -55,10 +85,13 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
empruntId: widget.emprunt.id, empruntId: widget.emprunt.id,
commentaireRetour: _commentaireController.text, commentaireRetour: _commentaireController.text,
elements: _elements.asMap().entries.map((entry) { elements: _elements.asMap().entries.map((entry) {
final elementDepart = _checklistDepart[entry.key];
return { return {
if (elementDepart.accessoireId != null)
"accessoireId": elementDepart.accessoireId,
"nomElement": entry.value, "nomElement": entry.value,
"etat": _etatApi(_etats[entry.key]), "etat": _etatApi(_etats[entry.key]),
"quantiteConstatee": _etats[entry.key] == EtatChecklist.absent ? 0 : 1, "quantiteConstatee": _quantiteConstatee(entry.key),
}; };
}).toList(), }).toList(),
); );
@@ -67,7 +100,9 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
return; return;
} }
final anomaliesAffichees = resultat.statut == "CLOTURE" ? <AnomalieRetour>[] : anomalies; final anomaliesAffichees = resultat.statut == "CLOTURE"
? <AnomalieRetour>[]
: anomalies;
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => ResultatRetourScreen( builder: (_) => ResultatRetourScreen(
@@ -80,9 +115,7 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
if (!mounted) { if (!mounted) {
return; return;
} }
ScaffoldMessenger.of(context).showSnackBar( AppMessage.erreur(context, error);
SnackBar(content: Text(error.toString())),
);
} finally { } finally {
if (mounted) { if (mounted) {
setState(() => _envoiEnCours = false); setState(() => _envoiEnCours = false);
@@ -90,6 +123,56 @@ 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;
AppMessage.alerte(
context,
"Le responsable a été alerté de cette tentative de modification.",
titre: "Changement signalé",
);
} catch (error) {
if (!mounted) return;
setState(() => _alerteEnvoyee = false);
AppMessage.erreur(context, error);
} 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) { String _etatApi(EtatChecklist etat) {
switch (etat) { switch (etat) {
case EtatChecklist.present: case EtatChecklist.present:
@@ -101,6 +184,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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -135,14 +226,31 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
), ),
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28), padding: const EdgeInsets.symmetric(
child: Row( horizontal: 24,
crossAxisAlignment: CrossAxisAlignment.start, vertical: 26,
children: [ ),
Expanded(flex: 3, child: _colonneGauche()), child: LayoutBuilder(
const SizedBox(width: 20), builder: (context, constraints) {
Expanded(flex: 2, child: _colonneDroite()), if (constraints.maxWidth < 780) {
], return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_colonneGauche(),
const SizedBox(height: 22),
_colonneDroite(),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 3, child: _colonneGauche()),
const SizedBox(width: 20),
Expanded(flex: 2, child: _colonneDroite()),
],
);
},
), ),
), ),
), ),
@@ -168,7 +276,10 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: EnsupColors.soft, color: EnsupColors.soft,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.teal.withValues(alpha: 0.15), width: 1.5), border: Border.all(
color: EnsupColors.teal.withValues(alpha: 0.15),
width: 1.5,
),
), ),
child: Row( child: Row(
children: [ children: [
@@ -188,11 +299,17 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
children: [ children: [
Text( Text(
materiel.nom, materiel.nom,
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w800, fontSize: 16), style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w800,
fontSize: 16,
),
), ),
Text( Text(
"Emprunté le ${widget.emprunt.dateEmprunt}", "Emprunté le ${widget.emprunt.dateEmprunt}",
style: GoogleFonts.titilliumWeb(fontSize: 12, color: EnsupColors.muted), style: GoogleFonts.titilliumWeb(
fontSize: 12,
color: EnsupColors.muted,
),
), ),
], ],
), ),
@@ -204,7 +321,7 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
ChecklistTableau( ChecklistTableau(
accessoires: _elements, accessoires: _elements,
etats: _etats, etats: _etats,
onChanged: (index, etat) => setState(() => _etats[index] = etat), onChanged: _changerEtat,
), ),
], ],
); );
@@ -214,11 +331,12 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const ChecklistInstructions( ChecklistInstructions(
titre: "Rappel checklist de départ", titre: "État validé au départ",
texte: texte:
"Au départ, tous les accessoires du kit étaient présents. " "La restitution reprend automatiquement l'état validé au départ : "
"Indiquez leur état au retour ; toute différence sera signalée.", "${_checklistDepart.map((element) => "${element.nom} ${_etatLabel(_etatDepuisApi(element.etat)).toLowerCase()}").join(" · ")}. "
"Toute modification alerte immédiatement un responsable.",
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
ChecklistCommentaire( ChecklistCommentaire(
@@ -229,22 +347,34 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton.icon( child: FilledButton.icon(
onPressed: _envoiEnCours ? null : _valider, onPressed: _envoiEnCours || _alerteEnCours ? null : _valider,
icon: _envoiEnCours icon: _envoiEnCours || _alerteEnCours
? const SizedBox( ? const SizedBox(
width: 18, width: 18,
height: 18, height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
) )
: const Icon(Icons.check, size: 18), : const Icon(Icons.check, size: 18),
label: Text( label: Text(
_envoiEnCours ? "Enregistrement..." : "Valider le retour", _alerteEnCours
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 16), ? "Alerte en cours..."
: _envoiEnCours
? "Enregistrement..."
: "Valider le retour",
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w700,
fontSize: 16,
),
), ),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: EnsupColors.blue3, backgroundColor: EnsupColors.blue3,
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
), ),
), ),
), ),
@@ -7,7 +7,6 @@ import "../models/materiel_item.dart";
import "../widgets/ensup_top_bar.dart"; import "../widgets/ensup_top_bar.dart";
import "../widgets/brand_corners.dart"; import "../widgets/brand_corners.dart";
/// Écran de succès clôturant l'emprunt : confirmation + récapitulatif.
class ConfirmationScreen extends StatelessWidget { class ConfirmationScreen extends StatelessWidget {
const ConfirmationScreen({super.key, required this.emprunt, this.note}); const ConfirmationScreen({super.key, required this.emprunt, this.note});
@@ -15,6 +14,7 @@ class ConfirmationScreen extends StatelessWidget {
final String? note; final String? note;
MaterielItem get item => emprunt.materiel; MaterielItem get item => emprunt.materiel;
bool get _enAttente => emprunt.statut == "EN_ATTENTE_VALIDATION_DEPART";
static String _deuxChiffres(int valeur) => valeur.toString().padLeft(2, "0"); static String _deuxChiffres(int valeur) => valeur.toString().padLeft(2, "0");
@@ -23,7 +23,8 @@ class ConfirmationScreen extends StatelessWidget {
final dateEmprunt = emprunt.dateEmprunt; final dateEmprunt = emprunt.dateEmprunt;
final date = final date =
"${_deuxChiffres(dateEmprunt.day)}/${_deuxChiffres(dateEmprunt.month)}/${dateEmprunt.year}"; "${_deuxChiffres(dateEmprunt.day)}/${_deuxChiffres(dateEmprunt.month)}/${dateEmprunt.year}";
final heure = "${_deuxChiffres(dateEmprunt.hour)}:${_deuxChiffres(dateEmprunt.minute)}"; final heure =
"${_deuxChiffres(dateEmprunt.hour)}:${_deuxChiffres(dateEmprunt.minute)}";
return Scaffold( return Scaffold(
backgroundColor: EnsupColors.soft, backgroundColor: EnsupColors.soft,
@@ -48,7 +49,7 @@ class ConfirmationScreen extends StatelessWidget {
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: Column( child: Column(
children: [ children: [
const EnsupTopBar( EnsupTopBar(
brandText: "EME · Confirmation", brandText: "EME · Confirmation",
campusTag: DemoIdentity.campusTag, campusTag: DemoIdentity.campusTag,
), ),
@@ -63,7 +64,10 @@ class ConfirmationScreen extends StatelessWidget {
), ),
child: Center( child: Center(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32), padding: const EdgeInsets.symmetric(
horizontal: 28,
vertical: 32,
),
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560), constraints: const BoxConstraints(maxWidth: 560),
child: Column( child: Column(
@@ -75,10 +79,15 @@ class ConfirmationScreen extends StatelessWidget {
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton.icon( child: FilledButton.icon(
onPressed: () => Navigator.of(context).popUntil( onPressed: () =>
(route) => route.settings.name == "home", Navigator.of(context).popUntil(
(route) =>
route.settings.name == "home",
),
icon: const Icon(
Icons.home_outlined,
size: 18,
), ),
icon: const Icon(Icons.home_outlined, size: 18),
label: Text( label: Text(
"Retour à l'accueil", "Retour à l'accueil",
style: GoogleFonts.darkerGrotesque( style: GoogleFonts.darkerGrotesque(
@@ -88,9 +97,13 @@ class ConfirmationScreen extends StatelessWidget {
), ),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: EnsupColors.cyan, backgroundColor: EnsupColors.cyan,
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(
vertical: 16,
),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(
10,
),
), ),
), ),
), ),
@@ -114,13 +127,23 @@ class ConfirmationScreen extends StatelessWidget {
} }
Widget _boiteSucces() { Widget _boiteSucces() {
final couleur = _enAttente
? const Color(0xFFD97706)
: const Color(0xFF15803D);
return Container( return Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(36), padding: const EdgeInsets.all(36),
decoration: BoxDecoration( 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), 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( child: Column(
children: [ children: [
@@ -129,10 +152,14 @@ class ConfirmationScreen extends StatelessWidget {
height: 72, height: 72,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, 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: [
BoxShadow( BoxShadow(
color: const Color(0xFF16A34A).withValues(alpha: 0.3), color: couleur.withValues(alpha: 0.3),
blurRadius: 20, blurRadius: 20,
offset: const Offset(0, 6), offset: const Offset(0, 6),
), ),
@@ -142,18 +169,25 @@ class ConfirmationScreen extends StatelessWidget {
), ),
const SizedBox(height: 18), const SizedBox(height: 18),
Text( Text(
"Emprunt enregistré !", _enAttente ? "Écart transmis" : "Emprunt enregistré !",
style: GoogleFonts.darkerGrotesque( style: GoogleFonts.darkerGrotesque(
fontSize: 28, fontSize: 28,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
color: const Color(0xFF15803D), color: couleur,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( 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, textAlign: TextAlign.center,
style: GoogleFonts.titilliumWeb(fontSize: 14, color: const Color(0xFF166534)), style: GoogleFonts.titilliumWeb(
fontSize: 14,
color: _enAttente
? const Color(0xFF9A3412)
: const Color(0xFF166534),
),
), ),
], ],
), ),
@@ -191,7 +225,13 @@ class ConfirmationScreen extends StatelessWidget {
_rrow("Date d'emprunt", date), _rrow("Date d'emprunt", date),
_rrow("Heure", heure), _rrow("Heure", heure),
_rrow("Campus", item.campus), _rrow("Campus", item.campus),
if (note != null) _rrow("Note", note, valueColor: const Color(0xFFD97706)), _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)),
], ],
), ),
); );
@@ -203,7 +243,13 @@ class ConfirmationScreen extends StatelessWidget {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(cle, style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted)), Text(
cle,
style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.muted,
),
),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: Text( child: Text(
+109 -24
View File
@@ -55,14 +55,34 @@ class DetailScreen extends StatelessWidget {
), ),
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28), padding: const EdgeInsets.symmetric(
child: Row( horizontal: 24,
crossAxisAlignment: CrossAxisAlignment.start, vertical: 26,
children: [ ),
Expanded(flex: 3, child: _colonneGauche()), child: LayoutBuilder(
const SizedBox(width: 20), builder: (context, constraints) {
Expanded(flex: 2, child: _colonneDroite(context)), if (constraints.maxWidth < 760) {
], return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_colonneGauche(),
const SizedBox(height: 20),
_colonneDroite(context),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 3, child: _colonneGauche()),
const SizedBox(width: 20),
Expanded(
flex: 2,
child: _colonneDroite(context),
),
],
);
},
), ),
), ),
), ),
@@ -84,17 +104,35 @@ class DetailScreen extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(18), padding: const EdgeInsets.all(18),
decoration: BoxDecoration( decoration: BoxDecoration(
color: EnsupColors.soft, color: Colors.white,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.line, width: 1.5), border: Border.all(color: EnsupColors.line, width: 1.5),
boxShadow: [
BoxShadow(
color: EnsupColors.text.withValues(alpha: 0.04),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: EnsupColors.cyan.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(item.icon, color: EnsupColors.blue3, size: 21),
),
const SizedBox(height: 12),
Text( Text(
item.nom, item.nom,
style: GoogleFonts.darkerGrotesque( style: GoogleFonts.darkerGrotesque(
fontSize: 20, fontSize: 24,
height: 1,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
color: EnsupColors.text, color: EnsupColors.text,
), ),
@@ -139,15 +177,25 @@ class DetailScreen extends StatelessWidget {
border: Border(bottom: BorderSide(color: EnsupColors.line)), border: Border(bottom: BorderSide(color: EnsupColors.line)),
), ),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(cle, style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted)),
Text( Text(
valeur, cle,
style: GoogleFonts.titilliumWeb( style: GoogleFonts.titilliumWeb(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, color: EnsupColors.muted,
color: valueColor ?? EnsupColors.text, ),
),
const SizedBox(width: 16),
Expanded(
child: Text(
valeur,
textAlign: TextAlign.right,
style: GoogleFonts.titilliumWeb(
fontSize: 13,
fontWeight: FontWeight.w600,
color: valueColor ?? EnsupColors.text,
),
), ),
), ),
], ],
@@ -161,7 +209,13 @@ class DetailScreen extends StatelessWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text("Statut", style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted)), Text(
"Statut",
style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.muted,
),
),
StatusPill(disponible: item.disponible), StatusPill(disponible: item.disponible),
], ],
), ),
@@ -193,18 +247,39 @@ class DetailScreen extends StatelessWidget {
height: 180, height: 180,
width: double.infinity, width: double.infinity,
decoration: BoxDecoration( decoration: BoxDecoration(
color: EnsupColors.soft, color: EnsupColors.cyan.withValues(alpha: 0.045),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.line, width: 1.5), border: Border.all(
color: EnsupColors.cyan.withValues(alpha: 0.18),
width: 1.5,
),
), ),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(item.icon, size: 48, color: EnsupColors.muted), Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: EnsupColors.blue3.withValues(alpha: 0.1),
blurRadius: 14,
offset: const Offset(0, 4),
),
],
),
child: Icon(item.icon, size: 36, color: EnsupColors.blue3),
),
const SizedBox(height: 10), const SizedBox(height: 10),
Text( Text(
"Aperçu du matériel", "Aperçu du matériel",
style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted), style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.muted,
),
), ),
], ],
), ),
@@ -217,12 +292,17 @@ class DetailScreen extends StatelessWidget {
icon: const Icon(Icons.arrow_forward, size: 18), icon: const Icon(Icons.arrow_forward, size: 18),
label: Text( label: Text(
"Démarrer l'emprunt", "Démarrer l'emprunt",
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 16), style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w700,
fontSize: 16,
),
), ),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: EnsupColors.cyan, backgroundColor: EnsupColors.cyan,
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
), ),
), ),
), ),
@@ -235,11 +315,16 @@ class DetailScreen extends StatelessWidget {
foregroundColor: EnsupColors.text2, foregroundColor: EnsupColors.text2,
side: const BorderSide(color: EnsupColors.line, width: 1.5), side: const BorderSide(color: EnsupColors.line, width: 1.5),
padding: const EdgeInsets.symmetric(vertical: 14), padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
), ),
child: Text( child: Text(
"Retour au catalogue", "Retour au catalogue",
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 15), style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w700,
fontSize: 15,
),
), ),
), ),
), ),
+91 -40
View File
@@ -1,7 +1,9 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.dart"; import "package:google_fonts/google_fonts.dart";
import "../demo_identity.dart"; import "../demo_identity.dart";
import "../services/auth_service.dart";
import "../theme/ensup_colors.dart"; import "../theme/ensup_colors.dart";
import "../widgets/app_message.dart";
import "../widgets/ensup_top_bar.dart"; import "../widgets/ensup_top_bar.dart";
import "../widgets/profile_card.dart"; import "../widgets/profile_card.dart";
import "../widgets/action_card.dart"; import "../widgets/action_card.dart";
@@ -13,6 +15,18 @@ import "restitution_select_screen.dart";
class HomeScreen extends StatelessWidget { class HomeScreen extends StatelessWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
Future<void> _quitter(BuildContext context) async {
try {
await AuthService.signOut();
if (context.mounted) {
Navigator.of(context).pop();
}
} catch (error) {
if (!context.mounted) return;
AppMessage.erreur(context, error);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -42,64 +56,101 @@ class HomeScreen extends StatelessWidget {
campusTag: DemoIdentity.campusTag, campusTag: DemoIdentity.campusTag,
userName: DemoIdentity.userName, userName: DemoIdentity.userName,
userInitials: DemoIdentity.userInitials, userInitials: DemoIdentity.userInitials,
onBack: () => Navigator.of(context).pop(), onBack: () async => _quitter(context),
), ),
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28), padding: const EdgeInsets.symmetric(
child: Column( horizontal: 28,
crossAxisAlignment: CrossAxisAlignment.start, vertical: 26,
children: [ ),
const ProfileCard( child: Center(
name: DemoIdentity.userName, child: ConstrainedBox(
subtitle: DemoIdentity.userSubtitle, constraints: const BoxConstraints(maxWidth: 1040),
initials: DemoIdentity.userInitials, child: Column(
), crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(height: 24), children: [
Text( ProfileCard(
"Que souhaitez-vous faire ?", name: DemoIdentity.userName,
style: GoogleFonts.darkerGrotesque( subtitle: DemoIdentity.userSubtitle,
fontSize: 28, initials: DemoIdentity.userInitials,
fontWeight: FontWeight.w900, ),
color: EnsupColors.text, const SizedBox(height: 28),
), Text(
), "Que souhaitez-vous faire ?",
const SizedBox(height: 18), style: GoogleFonts.darkerGrotesque(
IntrinsicHeight( fontSize: 30,
child: Row( height: 1,
crossAxisAlignment: CrossAxisAlignment.stretch, fontWeight: FontWeight.w900,
children: [ color: EnsupColors.text,
Expanded( ),
child: ActionCard( ),
const SizedBox(height: 6),
Text(
"Choisissez lopération à réaliser sur ce poste.",
style: GoogleFonts.titilliumWeb(
fontSize: 14,
color: EnsupColors.muted,
),
),
const SizedBox(height: 20),
LayoutBuilder(
builder: (context, constraints) {
final emprunter = ActionCard(
icon: Icons.inventory_2_outlined, icon: Icons.inventory_2_outlined,
title: "EMPRUNTER", title: "EMPRUNTER",
description: description:
"Accéder au catalogue et emprunter du matériel éducatif", "Consulter les équipements disponibles et démarrer un nouvel emprunt.",
color: EnsupColors.cyan, color: EnsupColors.cyan,
onTap: () => Navigator.of(context).push( onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const CatalogueScreen()), MaterialPageRoute(
builder: (_) =>
const CatalogueScreen(),
),
), ),
), );
), final restituer = ActionCard(
const SizedBox(width: 16),
Expanded(
child: ActionCard(
icon: Icons.assignment_return_outlined, icon: Icons.assignment_return_outlined,
title: "RESTITUER", title: "RESTITUER",
description: description:
"Restituer un matériel que vous avez emprunté", "Sélectionner un emprunt en cours et contrôler le matériel rendu.",
color: EnsupColors.teal, color: EnsupColors.teal,
onTap: () => Navigator.of(context).push( onTap: () => Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => const RestitutionSelectScreen(), builder: (_) =>
const RestitutionSelectScreen(),
), ),
), ),
), );
),
], if (constraints.maxWidth < 720) {
), return Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
emprunter,
const SizedBox(height: 14),
restituer,
],
);
}
return IntrinsicHeight(
child: Row(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
Expanded(child: emprunter),
const SizedBox(width: 16),
Expanded(child: restituer),
],
),
);
},
),
],
), ),
], ),
), ),
), ),
), ),
+166 -18
View File
@@ -1,17 +1,36 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.dart"; import "package:google_fonts/google_fonts.dart";
import "../demo_identity.dart"; import "../demo_identity.dart";
import "../services/api_client.dart";
import "../services/auth_service.dart";
import "../theme/ensup_colors.dart"; import "../theme/ensup_colors.dart";
import "../widgets/app_message.dart";
import "../widgets/ensup_top_bar.dart"; import "../widgets/ensup_top_bar.dart";
import "../widgets/identification_option.dart"; import "../widgets/identification_option.dart";
import "../widgets/brand_corners.dart"; import "../widgets/brand_corners.dart";
import "home_screen.dart"; import "home_screen.dart";
import "responsable_screen.dart";
/// Écran de démarrage : identification de l'étudiant (RG01/RG02). /// Écran de démarrage : identification de l'étudiant (RG01/RG02).
class LoginScreen extends StatelessWidget { class LoginScreen extends StatefulWidget {
const LoginScreen({super.key}); const LoginScreen({super.key});
void _identifier(BuildContext context) { @override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
bool _connexionEnCours = false;
@override
void initState() {
super.initState();
if (AuthService.mode == AuthenticationMode.azure) {
WidgetsBinding.instance.addPostFrameCallback((_) => _restaurerSession());
}
}
void _ouvrirEtudiant() {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => const HomeScreen(), builder: (_) => const HomeScreen(),
@@ -20,6 +39,95 @@ class LoginScreen extends StatelessWidget {
); );
} }
void _ouvrirResponsable() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const ResponsableScreen(),
settings: const RouteSettings(name: "responsable"),
),
);
}
void _ouvrirPourRole(String roleCode) {
switch (roleCode) {
case "ETUDIANT":
_ouvrirEtudiant();
case "RESPONSABLE":
_ouvrirResponsable();
default:
throw StateError("Role utilisateur non autorise.");
}
}
Future<void> _restaurerSession() async {
setState(() => _connexionEnCours = true);
try {
if (!await AuthService.hasMicrosoftAccount()) {
return;
}
final reponse = await ApiClient.get("/auth/me");
final profile = AuthService.setProfileFromApi(reponse);
if (mounted) {
_ouvrirPourRole(profile.roleCode);
}
} catch (_) {
if (!mounted) return;
AppMessage.alerte(
context,
"La session Microsoft doit être renouvelée.",
titre: "Session expirée",
);
} finally {
if (mounted) {
setState(() => _connexionEnCours = false);
}
}
}
Future<void> _identifierParQr() async {
if (_connexionEnCours) return;
if (AuthService.mode == AuthenticationMode.azure) {
AppMessage.information(
context,
"L'identification QR sécurisée n'est pas encore configurée.",
titre: "QR code indisponible",
);
return;
}
await _connecterMicrosoft(responsableDemo: false);
}
Future<void> _connecterMicrosoft({required bool responsableDemo}) async {
if (_connexionEnCours) return;
setState(() => _connexionEnCours = true);
try {
if (AuthService.mode == AuthenticationMode.demo) {
if (responsableDemo) {
AuthService.selectDemoResponsable();
} else {
AuthService.selectDemoStudent();
}
} else {
await AuthService.signInWithMicrosoft();
}
final reponse = await ApiClient.get("/auth/me");
final profile = AuthService.setProfileFromApi(reponse);
if (!mounted) return;
_ouvrirPourRole(profile.roleCode);
} catch (error) {
if (!mounted) return;
AppMessage.erreur(context, error, titre: "Connexion impossible");
} finally {
if (mounted) {
setState(() => _connexionEnCours = false);
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -60,7 +168,10 @@ class LoginScreen extends StatelessWidget {
), ),
child: Center( child: Center(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 40), padding: const EdgeInsets.symmetric(
horizontal: 28,
vertical: 40,
),
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560), constraints: const BoxConstraints(maxWidth: 560),
child: Column( child: Column(
@@ -71,18 +182,27 @@ class LoginScreen extends StatelessWidget {
height: 72, height: 72,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient( gradient: const LinearGradient(
colors: [EnsupColors.blue3, EnsupColors.cyan], colors: [
EnsupColors.blue3,
EnsupColors.cyan,
],
), ),
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(22),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: EnsupColors.blue3.withValues(alpha: 0.35), color: EnsupColors.blue3.withValues(
alpha: 0.35,
),
blurRadius: 24, blurRadius: 24,
offset: const Offset(0, 8), offset: const Offset(0, 8),
), ),
], ],
), ),
child: const Icon(Icons.badge_outlined, color: Colors.white, size: 34), child: const Icon(
Icons.badge_outlined,
color: Colors.white,
size: 34,
),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
Text( Text(
@@ -109,22 +229,34 @@ class LoginScreen extends StatelessWidget {
label: "Scanner ma carte étudiante", label: "Scanner ma carte étudiante",
description: description:
"Présentez votre carte étudiante dématérialisée à la douchette ou à la tablette", "Présentez votre carte étudiante dématérialisée à la douchette ou à la tablette",
onTap: () => _identifier(context), onTap: _connexionEnCours
? null
: _identifierParQr,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
IdentificationOption( IdentificationOption(
icon: Icons.mail_outline, icon: Icons.mail_outline,
iconColor: EnsupColors.teal, iconColor: EnsupColors.teal,
label: "Connexion compte professionnel ENSUP", label:
description: "Utiliser mon adresse e-mail Microsoft 365", "Connexion compte professionnel ENSUP",
onTap: () => _identifier(context), description:
"Utiliser mon adresse e-mail Microsoft 365",
onTap: _connexionEnCours
? null
: () => _connecterMicrosoft(
responsableDemo: false,
),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
Row( Row(
children: [ children: [
const Expanded(child: Divider(color: EnsupColors.line)), const Expanded(
child: Divider(color: EnsupColors.line),
),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(
horizontal: 12,
),
child: Text( child: Text(
"ou", "ou",
style: GoogleFonts.titilliumWeb( style: GoogleFonts.titilliumWeb(
@@ -133,15 +265,24 @@ class LoginScreen extends StatelessWidget {
), ),
), ),
), ),
const Expanded(child: Divider(color: EnsupColors.line)), const Expanded(
child: Divider(color: EnsupColors.line),
),
], ],
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: () {}, onPressed: _connexionEnCours
icon: const Icon(Icons.person_outline, size: 16), ? null
: () => _connecterMicrosoft(
responsableDemo: true,
),
icon: const Icon(
Icons.person_outline,
size: 16,
),
label: Text( label: Text(
"Accès responsable matériel", "Accès responsable matériel",
style: GoogleFonts.darkerGrotesque( style: GoogleFonts.darkerGrotesque(
@@ -151,10 +292,17 @@ class LoginScreen extends StatelessWidget {
), ),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: EnsupColors.text2, foregroundColor: EnsupColors.text2,
side: const BorderSide(color: EnsupColors.line, width: 1.5), side: const BorderSide(
padding: const EdgeInsets.symmetric(vertical: 14), color: EnsupColors.line,
width: 1.5,
),
padding: const EdgeInsets.symmetric(
vertical: 14,
),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(
10,
),
), ),
), ),
), ),
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,7 @@ import "../services/emprunt_service.dart";
import "../services/materiel_service.dart"; import "../services/materiel_service.dart";
import "../widgets/ensup_top_bar.dart"; import "../widgets/ensup_top_bar.dart";
import "../widgets/brand_corners.dart"; import "../widgets/brand_corners.dart";
import "../widgets/app_message.dart";
import "checklist_retour_screen.dart"; import "checklist_retour_screen.dart";
/// Sélection de l'emprunt en cours à restituer (RG15 : ses propres emprunts). /// Sélection de l'emprunt en cours à restituer (RG15 : ses propres emprunts).
@@ -14,7 +15,8 @@ class RestitutionSelectScreen extends StatefulWidget {
const RestitutionSelectScreen({super.key}); const RestitutionSelectScreen({super.key});
@override @override
State<RestitutionSelectScreen> createState() => _RestitutionSelectScreenState(); State<RestitutionSelectScreen> createState() =>
_RestitutionSelectScreenState();
} }
class _RestitutionSelectScreenState extends State<RestitutionSelectScreen> { class _RestitutionSelectScreenState extends State<RestitutionSelectScreen> {
@@ -40,16 +42,16 @@ class _RestitutionSelectScreenState extends State<RestitutionSelectScreen> {
} }
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => ChecklistRetourScreen(emprunt: emprunt.copyWith(materiel: detail)), builder: (_) => ChecklistRetourScreen(
emprunt: emprunt.copyWith(materiel: detail),
),
), ),
); );
} catch (error) { } catch (error) {
if (!mounted) { if (!mounted) {
return; return;
} }
ScaffoldMessenger.of(context).showSnackBar( AppMessage.erreur(context, error);
SnackBar(content: Text(error.toString())),
);
} finally { } finally {
if (mounted) { if (mounted) {
setState(() => _chargementId = null); setState(() => _chargementId = null);
@@ -91,12 +93,18 @@ class _RestitutionSelectScreenState extends State<RestitutionSelectScreen> {
), ),
Expanded( Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28), padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 28,
),
child: FutureBuilder<List<EmpruntItem>>( child: FutureBuilder<List<EmpruntItem>>(
future: _future, future: _future,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState ==
return const Center(child: CircularProgressIndicator()); ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(),
);
} }
if (snapshot.hasError) { if (snapshot.hasError) {
return _ErreurState( return _ErreurState(
@@ -128,12 +136,21 @@ class _RestitutionSelectScreenState extends State<RestitutionSelectScreen> {
Text( Text(
"Sélectionner le matériel à restituer", "Sélectionner le matériel à restituer",
style: GoogleFonts.darkerGrotesque( style: GoogleFonts.darkerGrotesque(
fontSize: 24, fontSize: 28,
height: 1,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
color: EnsupColors.text, color: EnsupColors.text,
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 6),
Text(
"${emprunts.length} emprunt${emprunts.length > 1 ? "s" : ""} en cours associé${emprunts.length > 1 ? "s" : ""} à votre compte.",
style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.muted,
),
),
const SizedBox(height: 18),
Expanded( Expanded(
child: emprunts.isEmpty child: emprunts.isEmpty
? const _EmptyState() ? const _EmptyState()
@@ -174,70 +191,144 @@ class _EmpruntCard extends StatelessWidget {
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.line, width: 1.5), border: Border.all(color: EnsupColors.line, width: 1.5),
), boxShadow: [
child: Row( BoxShadow(
children: [ color: EnsupColors.text.withValues(alpha: 0.035),
Container( blurRadius: 10,
width: 44, offset: const Offset(0, 3),
height: 44,
decoration: BoxDecoration(
color: EnsupColors.teal.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(10),
),
child: Icon(emprunt.materiel.icon, color: EnsupColors.teal, size: 20),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
emprunt.materiel.nom,
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w700,
fontSize: 16,
color: EnsupColors.text,
),
),
const SizedBox(height: 2),
Text(
"Emprunté le ${emprunt.dateEmprunt} · Retour prévu ${emprunt.retourPrevu}",
style: GoogleFonts.titilliumWeb(fontSize: 12, color: EnsupColors.muted),
),
],
),
),
const SizedBox(width: 10),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFFFEF9C3),
borderRadius: BorderRadius.circular(999),
),
child: Text(
emprunt.statut == "EN_RETARD" ? "En retard" : "En cours",
style: GoogleFonts.darkerGrotesque(
fontSize: 13,
fontWeight: FontWeight.w700,
color: const Color(0xFFA16207),
),
),
),
const SizedBox(width: 12),
FilledButton(
onPressed: loading ? null : onRestituer,
style: FilledButton.styleFrom(
backgroundColor: EnsupColors.cyan,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
child: Text(
loading ? "Chargement..." : "Restituer",
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 14),
),
), ),
], ],
), ),
child: LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 650;
final information = Row(
children: [
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
color: EnsupColors.teal.withValues(alpha: 0.09),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
emprunt.materiel.icon,
color: EnsupColors.teal,
size: 21,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
emprunt.materiel.nom,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w800,
fontSize: 17,
height: 1.05,
color: EnsupColors.text,
),
),
const SizedBox(height: 4),
Text(
"Emprunté le ${emprunt.dateEmprunt} · Retour prévu ${emprunt.retourPrevu}",
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.titilliumWeb(
fontSize: 12,
color: EnsupColors.muted,
),
),
],
),
),
],
);
final actions = Wrap(
spacing: 10,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
alignment: WrapAlignment.end,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 11,
vertical: 5,
),
decoration: BoxDecoration(
color: emprunt.statut == "EN_RETARD"
? EnsupColors.red.withValues(alpha: 0.09)
: const Color(0xFFFEF9C3),
borderRadius: BorderRadius.circular(999),
),
child: Text(
emprunt.statut == "EN_RETARD" ? "En retard" : "En cours",
style: GoogleFonts.darkerGrotesque(
fontSize: 13,
fontWeight: FontWeight.w700,
color: emprunt.statut == "EN_RETARD"
? EnsupColors.red
: const Color(0xFFA16207),
),
),
),
FilledButton.icon(
onPressed: loading ? null : onRestituer,
style: FilledButton.styleFrom(
backgroundColor: EnsupColors.cyan,
padding: const EdgeInsets.symmetric(
horizontal: 15,
vertical: 11,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(9),
),
),
icon: loading
? const SizedBox(
width: 15,
height: 15,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.assignment_return_outlined, size: 16),
label: Text(
loading ? "Chargement" : "Restituer",
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w700,
fontSize: 14,
),
),
),
],
);
if (compact) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
information,
const SizedBox(height: 14),
Align(alignment: Alignment.centerRight, child: actions),
],
);
}
return Row(
children: [
Expanded(child: information),
const SizedBox(width: 16),
actions,
],
);
},
),
); );
} }
} }
@@ -268,12 +359,19 @@ class _ErreurState extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.cloud_off_outlined, size: 40, color: EnsupColors.muted), const Icon(
Icons.cloud_off_outlined,
size: 40,
color: EnsupColors.muted,
),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
message, message,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.text2), style: GoogleFonts.titilliumWeb(
fontSize: 14,
color: EnsupColors.text2,
),
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
OutlinedButton( OutlinedButton(
@@ -7,15 +7,18 @@ import "../models/anomalie_retour.dart";
import "../widgets/ensup_top_bar.dart"; import "../widgets/ensup_top_bar.dart";
import "../widgets/brand_corners.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 { class ResultatRetourScreen extends StatelessWidget {
const ResultatRetourScreen({super.key, required this.emprunt, required this.anomalies}); const ResultatRetourScreen({
super.key,
required this.emprunt,
required this.anomalies,
});
final EmpruntItem emprunt; final EmpruntItem emprunt;
final List<AnomalieRetour> anomalies; 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"); static String _deux(int valeur) => valeur.toString().padLeft(2, "0");
@@ -23,6 +26,8 @@ class ResultatRetourScreen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final fond = _conforme final fond = _conforme
? const [Color(0xFFF0FDF4), Color(0xFFF8FAFC)] ? const [Color(0xFFF0FDF4), Color(0xFFF8FAFC)]
: _enAttente
? const [Color(0xFFFFF7ED), Color(0xFFF8FAFC)]
: const [Color(0xFFFFF5F5), Color(0xFFF8FAFC)]; : const [Color(0xFFFFF5F5), Color(0xFFF8FAFC)];
return Scaffold( return Scaffold(
@@ -48,7 +53,7 @@ class ResultatRetourScreen extends StatelessWidget {
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: Column( child: Column(
children: [ children: [
const EnsupTopBar( EnsupTopBar(
brandText: "EME · Résultat du contrôle", brandText: "EME · Résultat du contrôle",
campusTag: DemoIdentity.campusTag, campusTag: DemoIdentity.campusTag,
), ),
@@ -63,20 +68,33 @@ class ResultatRetourScreen extends StatelessWidget {
), ),
child: Center( child: Center(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32), padding: const EdgeInsets.symmetric(
horizontal: 28,
vertical: 32,
),
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560), constraints: const BoxConstraints(maxWidth: 560),
child: Column( child: Column(
children: [ children: [
if (_conforme) _succes() else _anomalie(), if (_conforme)
_succes()
else if (_enAttente)
_attente()
else
_anomalie(),
const SizedBox(height: 16), const SizedBox(height: 16),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton.icon( child: FilledButton.icon(
onPressed: () => Navigator.of(context).popUntil( onPressed: () =>
(route) => route.settings.name == "home", Navigator.of(context).popUntil(
(route) =>
route.settings.name == "home",
),
icon: const Icon(
Icons.home_outlined,
size: 18,
), ),
icon: const Icon(Icons.home_outlined, size: 18),
label: Text( label: Text(
"Retour à l'accueil", "Retour à l'accueil",
style: GoogleFonts.darkerGrotesque( style: GoogleFonts.darkerGrotesque(
@@ -86,9 +104,13 @@ class ResultatRetourScreen extends StatelessWidget {
), ),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: EnsupColors.cyan, backgroundColor: EnsupColors.cyan,
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(
vertical: 16,
),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(
10,
),
), ),
), ),
), ),
@@ -116,7 +138,9 @@ class ResultatRetourScreen extends StatelessWidget {
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(36), padding: const EdgeInsets.all(36),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient(colors: [Color(0xFFF0FDF4), Color(0xFFECFDF5)]), gradient: const LinearGradient(
colors: [Color(0xFFF0FDF4), Color(0xFFECFDF5)],
),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFBBF7D0), width: 2), border: Border.all(color: const Color(0xFFBBF7D0), width: 2),
), ),
@@ -127,7 +151,9 @@ class ResultatRetourScreen extends StatelessWidget {
height: 72, height: 72,
decoration: const BoxDecoration( decoration: const BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
gradient: LinearGradient(colors: [Color(0xFF16A34A), Color(0xFF22C55E)]), gradient: LinearGradient(
colors: [Color(0xFF16A34A), Color(0xFF22C55E)],
),
), ),
child: const Icon(Icons.check, color: Colors.white, size: 36), child: const Icon(Icons.check, color: Colors.white, size: 36),
), ),
@@ -144,7 +170,10 @@ class ResultatRetourScreen extends StatelessWidget {
Text( Text(
"L'emprunt a été clôturé et le matériel est de nouveau disponible.", "L'emprunt a été clôturé et le matériel est de nouveau disponible.",
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: GoogleFonts.titilliumWeb(fontSize: 14, color: const Color(0xFF166534)), style: GoogleFonts.titilliumWeb(
fontSize: 14,
color: const Color(0xFF166534),
),
), ),
], ],
), ),
@@ -164,12 +193,19 @@ class ResultatRetourScreen extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: EnsupColors.red.withValues(alpha: 0.06), color: EnsupColors.red.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.red.withValues(alpha: 0.2), width: 1.5), border: Border.all(
color: EnsupColors.red.withValues(alpha: 0.2),
width: 1.5,
),
), ),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Icon(Icons.warning_amber_rounded, color: EnsupColors.red, size: 24), const Icon(
Icons.warning_amber_rounded,
color: EnsupColors.red,
size: 24,
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
@@ -227,7 +263,10 @@ class ResultatRetourScreen extends StatelessWidget {
anomalie.etatRetour, anomalie.etatRetour,
valeurCouleur: EnsupColors.red, valeurCouleur: EnsupColors.red,
), ),
_drow("Matériel", "${emprunt.materiel.nom} · ${emprunt.materiel.reference}"), _drow(
"Matériel",
"${emprunt.materiel.nom} · ${emprunt.materiel.reference}",
),
_drow("Emprunteur", DemoIdentity.userName), _drow("Emprunteur", DemoIdentity.userName),
_drow("Date détection", date), _drow("Date détection", date),
], ],
@@ -237,7 +276,86 @@ class ResultatRetourScreen extends StatelessWidget {
Text( Text(
"Le responsable matériel traitera cette anomalie. Vous pouvez retourner à l'accueil.", "Le responsable matériel traitera cette anomalie. Vous pouvez retourner à l'accueil.",
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted, height: 1.6), style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.muted,
height: 1.6,
),
),
],
);
}
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),
),
],
),
), ),
], ],
); );
@@ -249,7 +367,13 @@ class ResultatRetourScreen extends StatelessWidget {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(cle, style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted)), Text(
cle,
style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.muted,
),
),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: Text( child: Text(
+82 -8
View File
@@ -1,5 +1,7 @@
import "dart:convert"; import "dart:convert";
import "dart:typed_data";
import "package:http/http.dart" as http; import "package:http/http.dart" as http;
import "auth_service.dart";
/// Erreur levée par le client API (serveur injoignable ou réponse en échec). /// Erreur levée par le client API (serveur injoignable ou réponse en échec).
class ApiException implements Exception { class ApiException implements Exception {
@@ -11,20 +13,32 @@ class ApiException implements Exception {
String toString() => message; String toString() => message;
} }
/// Point d'accès unique au backend : centralise l'URL de base, l'en-tête class ApiFile {
/// d'authentification (simulée) et la gestion des erreurs. const ApiFile({
required this.bytes,
required this.fileName,
required this.contentType,
});
final Uint8List bytes;
final String fileName;
final String contentType;
}
/// Point d'accès unique au backend : centralise l'URL, l'authentification et
/// la gestion des erreurs.
class ApiClient { class ApiClient {
ApiClient._(); ApiClient._();
static const String _baseUrl = "http://localhost:3000/api"; static const String _baseUrl = "http://localhost:3000/api";
static const String _utilisateurEmail = "lucas.martin@ensitech.eu";
static Future<dynamic> get(String chemin) async { static Future<dynamic> get(String chemin) async {
final uri = Uri.parse("$_baseUrl$chemin"); final uri = Uri.parse("$_baseUrl$chemin");
late final http.Response reponse; late final http.Response reponse;
try { try {
reponse = await http.get(uri, headers: const {"x-user-email": _utilisateurEmail}); final authHeaders = await AuthService.requestHeaders();
reponse = await http.get(uri, headers: authHeaders);
} catch (_) { } catch (_) {
throw const ApiException( throw const ApiException(
"Impossible de joindre le serveur. Vérifiez que le backend est démarré.", "Impossible de joindre le serveur. Vérifiez que le backend est démarré.",
@@ -37,17 +51,66 @@ class ApiClient {
throw ApiException(_messageErreur(reponse)); throw ApiException(_messageErreur(reponse));
} }
static Future<ApiFile> getFile(
String chemin, {
required String fallbackFileName,
}) async {
final uri = Uri.parse("$_baseUrl$chemin");
late final http.Response reponse;
try {
final authHeaders = await AuthService.requestHeaders();
reponse = await http.get(uri, headers: authHeaders);
} catch (_) {
throw const ApiException(
"Impossible de joindre le serveur. Vérifiez que le backend est démarré.",
);
}
if (reponse.statusCode < 200 || reponse.statusCode >= 300) {
throw ApiException(_messageErreur(reponse));
}
return ApiFile(
bytes: reponse.bodyBytes,
fileName: _fileName(reponse, fallbackFileName),
contentType:
reponse.headers["content-type"] ?? "application/octet-stream",
);
}
static Future<dynamic> post(String chemin, Map<String, dynamic> body) async { static Future<dynamic> post(String chemin, Map<String, dynamic> body) async {
final uri = Uri.parse("$_baseUrl$chemin"); final uri = Uri.parse("$_baseUrl$chemin");
late final http.Response reponse; late final http.Response reponse;
try { try {
final authHeaders = await AuthService.requestHeaders();
reponse = await http.post( reponse = await http.post(
uri, uri,
headers: const { headers: {...authHeaders, "content-type": "application/json"},
"content-type": "application/json", body: jsonEncode(body),
"x-user-email": _utilisateurEmail, );
}, } catch (_) {
throw const ApiException(
"Impossible de joindre le serveur. Vérifiez que le backend est démarré.",
);
}
if (reponse.statusCode >= 200 && reponse.statusCode < 300) {
return jsonDecode(reponse.body);
}
throw ApiException(_messageErreur(reponse));
}
static Future<dynamic> patch(String chemin, Map<String, dynamic> body) async {
final uri = Uri.parse("$_baseUrl$chemin");
late final http.Response reponse;
try {
final authHeaders = await AuthService.requestHeaders();
reponse = await http.patch(
uri,
headers: {...authHeaders, "content-type": "application/json"},
body: jsonEncode(body), body: jsonEncode(body),
); );
} catch (_) { } catch (_) {
@@ -76,4 +139,15 @@ class ApiClient {
} }
return "Erreur ${reponse.statusCode}"; return "Erreur ${reponse.statusCode}";
} }
static String _fileName(http.Response reponse, String fallback) {
final disposition = reponse.headers["content-disposition"];
if (disposition == null) return fallback;
final match = RegExp(
"filename=\"?([^\";]+)\"?",
caseSensitive: false,
).firstMatch(disposition);
return match?.group(1) ?? fallback;
}
} }
+122
View File
@@ -0,0 +1,122 @@
import "../models/authenticated_profile.dart";
import "msal_bridge.dart";
enum AuthenticationMode { demo, azure }
class AuthService {
AuthService._();
static const String _configuredMode = String.fromEnvironment(
"AUTH_MODE",
defaultValue: "demo",
);
static const String azureTenantId = String.fromEnvironment("AZURE_TENANT_ID");
static const String azureSpaClientId = String.fromEnvironment(
"AZURE_SPA_CLIENT_ID",
);
static const String azureApiScope = String.fromEnvironment("AZURE_API_SCOPE");
static const String demoStudentEmail = "lucas.martin@ensitech.eu";
static const String demoResponsableEmail = "karim.benali@ensup.eu";
static String? _demoEmail;
static AuthenticatedProfile? _currentProfile;
static bool _azureInitialized = false;
static AuthenticatedProfile? get currentProfile => _currentProfile;
static AuthenticationMode get mode {
return switch (_configuredMode) {
"demo" => AuthenticationMode.demo,
"azure" => AuthenticationMode.azure,
_ => throw StateError("AUTH_MODE doit valoir demo ou azure."),
};
}
static void selectDemoStudent() {
_ensureDemoMode();
_demoEmail = demoStudentEmail;
}
static void selectDemoResponsable() {
_ensureDemoMode();
_demoEmail = demoResponsableEmail;
}
static void clearSession() {
_demoEmail = null;
_currentProfile = null;
}
static AuthenticatedProfile setProfileFromApi(dynamic reponse) {
final profile = AuthenticatedProfile.fromApi(reponse);
_currentProfile = profile;
return profile;
}
static Future<void> signInWithMicrosoft() async {
await _initializeAzure();
await MsalBridge.signIn();
}
static Future<bool> hasMicrosoftAccount() async {
await _initializeAzure();
return MsalBridge.hasAccount();
}
static Future<void> signOut() async {
if (mode == AuthenticationMode.azure) {
await _initializeAzure();
await MsalBridge.signOut();
_currentProfile = null;
return;
}
clearSession();
}
static Future<Map<String, String>> requestHeaders() async {
if (mode == AuthenticationMode.azure) {
await _initializeAzure();
final token = await MsalBridge.getAccessToken();
return {"authorization": "Bearer $token"};
}
final email = _demoEmail;
if (email == null) {
throw StateError("Aucun utilisateur de demonstration selectionne.");
}
return {"x-user-email": email};
}
static Future<void> _initializeAzure() async {
if (mode != AuthenticationMode.azure) {
throw StateError("MSAL est disponible uniquement en mode Azure.");
}
if (_azureInitialized) {
return;
}
if (azureTenantId.isEmpty ||
azureSpaClientId.isEmpty ||
azureApiScope.isEmpty) {
throw StateError("La configuration Azure du frontend est incomplete.");
}
await MsalBridge.initialize(
tenantId: azureTenantId,
clientId: azureSpaClientId,
scope: azureApiScope,
redirectUri: Uri.base.origin,
);
_azureInitialized = true;
}
static void _ensureDemoMode() {
if (mode != AuthenticationMode.demo) {
throw StateError(
"Une identite de demonstration est interdite en mode Azure.",
);
}
}
}
+29 -5
View File
@@ -17,18 +17,22 @@ class EmpruntService {
"materielId": materielId, "materielId": materielId,
"posteEmpruntId": posteEmpruntId, "posteEmpruntId": posteEmpruntId,
"modeIdentification": "MAIL_ENSUP", "modeIdentification": "MAIL_ENSUP",
if (commentaireDepart.trim().isNotEmpty) "commentaireDepart": commentaireDepart.trim(), if (commentaireDepart.trim().isNotEmpty)
"commentaireDepart": commentaireDepart.trim(),
"elements": elements, "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); return EmpruntResponse.fromJson(data);
} }
static Future<List<EmpruntItem>> getMesEmprunts() async { static Future<List<EmpruntItem>> getMesEmprunts() async {
final reponse = await ApiClient.get("/mes-emprunts"); final reponse = await ApiClient.get("/mes-emprunts");
final data = (reponse as Map<String, dynamic>)["data"] as List<dynamic>; 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({ static Future<EmpruntResponse> restituerEmprunt({
@@ -38,11 +42,31 @@ class EmpruntService {
}) async { }) async {
final reponse = await ApiClient.post("/emprunts/$empruntId/restitution", { final reponse = await ApiClient.post("/emprunts/$empruntId/restitution", {
"modeIdentification": "MAIL_ENSUP", "modeIdentification": "MAIL_ENSUP",
if (commentaireRetour.trim().isNotEmpty) "commentaireRetour": commentaireRetour.trim(), if (commentaireRetour.trim().isNotEmpty)
"commentaireRetour": commentaireRetour.trim(),
"elements": elements, "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); 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;
}
} }
@@ -0,0 +1,56 @@
import "dart:js_interop";
@JS("emeMsal.initialize")
external JSPromise<JSAny?> _initialize(
JSString tenantId,
JSString clientId,
JSString scope,
JSString redirectUri,
);
@JS("emeMsal.signIn")
external JSPromise<JSAny?> _signIn();
@JS("emeMsal.getAccessToken")
external JSPromise<JSString> _getAccessToken();
@JS("emeMsal.hasAccount")
external JSBoolean _hasAccount();
@JS("emeMsal.signOut")
external JSPromise<JSAny?> _signOut();
class MsalBridge {
MsalBridge._();
static Future<void> initialize({
required String tenantId,
required String clientId,
required String scope,
required String redirectUri,
}) async {
await _initialize(
tenantId.toJS,
clientId.toJS,
scope.toJS,
redirectUri.toJS,
).toDart;
}
static Future<void> signIn() async {
await _signIn().toDart;
}
static Future<String> getAccessToken() async {
final token = await _getAccessToken().toDart;
return token.toDart;
}
static bool hasAccount() {
return _hasAccount().toDart;
}
static Future<void> signOut() async {
await _signOut().toDart;
}
}
@@ -0,0 +1,175 @@
import "api_client.dart";
/// Appels API du tableau responsable matériel.
class ResponsableService {
ResponsableService._();
static Future<Map<String, dynamic>> dashboard() async {
final reponse = await ApiClient.get("/responsable/dashboard");
return Map<String, dynamic>.from(reponse["data"] as Map);
}
static Future<List<Map<String, dynamic>>> emprunts({
String? statut,
int? annee,
String? recherche,
String tri = "dateEmprunt",
String ordre = "desc",
}) {
final parametres = <String, String>{
"tri": tri,
"ordre": ordre,
"statut": ?statut,
if (annee != null) "annee": "$annee",
if (recherche != null && recherche.trim().isNotEmpty)
"q": recherche.trim(),
};
final query = Uri(queryParameters: parametres).query;
return _liste("/responsable/emprunts?$query");
}
static Future<List<Map<String, dynamic>>> ecarts() =>
_liste("/responsable/ecarts");
static Future<List<Map<String, dynamic>>> materiels({
String? statut,
int? categorieId,
String? recherche,
String tri = "nom",
String ordre = "asc",
}) {
final parametres = <String, String>{
"tri": tri,
"ordre": ordre,
"statut": ?statut,
if (categorieId != null) "categorieId": "$categorieId",
if (recherche != null && recherche.trim().isNotEmpty)
"q": recherche.trim(),
};
final query = Uri(queryParameters: parametres).query;
return _liste("/responsable/materiels?$query");
}
static Future<List<Map<String, dynamic>>> anomalies({
String? statut,
String? type,
String? recherche,
String tri = "dateDetection",
String ordre = "desc",
}) {
final parametres = <String, String>{
"tri": tri,
"ordre": ordre,
"statut": ?statut,
"type": ?type,
if (recherche != null && recherche.trim().isNotEmpty)
"q": recherche.trim(),
};
final query = Uri(queryParameters: parametres).query;
return _liste("/responsable/anomalies?$query");
}
static Future<List<Map<String, dynamic>>> notifications() =>
_liste("/responsable/notifications");
static Future<List<Map<String, dynamic>>> historique({
String? action,
DateTime? dateDebut,
DateTime? dateFin,
String? recherche,
String tri = "dateAction",
String ordre = "desc",
}) {
return _liste(
"/responsable/historique?${_historiqueQuery(action: action, dateDebut: dateDebut, dateFin: dateFin, recherche: recherche, tri: tri, ordre: ordre)}",
);
}
static Future<ApiFile> exporterHistorique({
String? action,
DateTime? dateDebut,
DateTime? dateFin,
String? recherche,
String tri = "dateAction",
String ordre = "desc",
}) {
final query = _historiqueQuery(
action: action,
dateDebut: dateDebut,
dateFin: dateFin,
recherche: recherche,
tri: tri,
ordre: ordre,
);
return ApiClient.getFile(
"/responsable/historique/export.csv?$query",
fallbackFileName: "historique-responsable.csv",
);
}
static String _historiqueQuery({
String? action,
DateTime? dateDebut,
DateTime? dateFin,
String? recherche,
required String tri,
required String ordre,
}) {
final finIncluse = dateFin == null
? null
: DateTime(dateFin.year, dateFin.month, dateFin.day, 23, 59, 59, 999);
return Uri(
queryParameters: {
"tri": tri,
"ordre": ordre,
"action": ?action,
if (dateDebut != null) "dateDebut": dateDebut.toUtc().toIso8601String(),
if (finIncluse != null) "dateFin": finIncluse.toUtc().toIso8601String(),
if (recherche != null && recherche.trim().isNotEmpty)
"q": recherche.trim(),
},
).query;
}
static Future<int> marquerNotificationsLues() async {
final reponse = await ApiClient.patch(
"/responsable/notifications/lu-toutes",
{},
);
final data = Map<String, dynamic>.from(reponse["data"] as Map);
return data["count"] as int;
}
static Future<Map<String, dynamic>> changerStatutAnomalie(
int id,
String statut,
) async {
final reponse = await ApiClient.patch("/responsable/anomalies/$id/statut", {
"statut": statut,
"observation": "Traitement depuis l'interface responsable",
});
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;
return data.map((item) => Map<String, dynamic>.from(item as Map)).toList();
}
}
+21
View File
@@ -0,0 +1,21 @@
// ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use
import "dart:html" as html;
import "dart:typed_data";
void downloadFile({
required Uint8List bytes,
required String fileName,
required String contentType,
}) {
final blob = html.Blob([bytes], contentType);
final url = html.Url.createObjectUrlFromBlob(blob);
final anchor = html.AnchorElement(href: url)
..download = fileName
..style.display = "none";
html.document.body?.children.add(anchor);
anchor.click();
anchor.remove();
html.Url.revokeObjectUrl(url);
}
+93 -42
View File
@@ -3,7 +3,7 @@ import "package:google_fonts/google_fonts.dart";
import "../theme/ensup_colors.dart"; import "../theme/ensup_colors.dart";
/// Grande carte d'action de l'accueil (Emprunter / Restituer). /// Grande carte d'action de l'accueil (Emprunter / Restituer).
class ActionCard extends StatelessWidget { class ActionCard extends StatefulWidget {
const ActionCard({ const ActionCard({
super.key, super.key,
required this.icon, required this.icon,
@@ -19,56 +19,107 @@ class ActionCard extends StatelessWidget {
final Color color; final Color color;
final VoidCallback? onTap; final VoidCallback? onTap;
@override
State<ActionCard> createState() => _ActionCardState();
}
class _ActionCardState extends State<ActionCard> {
bool _hovered = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return InkWell( final color = widget.color;
onTap: onTap, return MouseRegion(
borderRadius: BorderRadius.circular(16), onEnter: (_) => setState(() => _hovered = true),
child: Container( onExit: (_) => setState(() => _hovered = false),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), cursor: SystemMouseCursors.click,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
constraints: const BoxConstraints(minHeight: 230),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withValues(alpha: 0.07), color: _hovered
? color.withValues(alpha: 0.1)
: color.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: color.withValues(alpha: 0.2), width: 2), border: Border.all(
color: color.withValues(alpha: _hovered ? 0.55 : 0.22),
width: _hovered ? 2 : 1.5,
),
boxShadow: [
BoxShadow(
color: color.withValues(alpha: _hovered ? 0.16 : 0.06),
blurRadius: _hovered ? 22 : 10,
offset: Offset(0, _hovered ? 8 : 3),
),
],
), ),
child: Column( child: Material(
children: [ color: Colors.transparent,
Container( child: InkWell(
width: 60, onTap: widget.onTap,
height: 60, borderRadius: BorderRadius.circular(16),
decoration: BoxDecoration( child: Padding(
gradient: LinearGradient(colors: [color, color.withValues(alpha: 0.75)]), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
borderRadius: BorderRadius.circular(18), child: Column(
boxShadow: [ mainAxisAlignment: MainAxisAlignment.center,
BoxShadow( children: [
color: color.withValues(alpha: 0.35), AnimatedContainer(
blurRadius: 16, duration: const Duration(milliseconds: 180),
offset: const Offset(0, 4), width: 60,
height: 60,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(18),
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.3),
blurRadius: _hovered ? 18 : 12,
offset: const Offset(0, 5),
),
],
),
child: Icon(widget.icon, color: Colors.white, size: 28),
),
const SizedBox(height: 16),
Text(
widget.title,
textAlign: TextAlign.center,
style: GoogleFonts.darkerGrotesque(
fontSize: 25,
height: 1,
fontWeight: FontWeight.w900,
color: EnsupColors.text,
),
),
const SizedBox(height: 8),
Text(
widget.description,
textAlign: TextAlign.center,
style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.muted,
height: 1.45,
),
),
const SizedBox(height: 16),
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
shape: BoxShape.circle,
),
child: Icon(
Icons.arrow_forward_rounded,
size: 17,
color: color,
),
), ),
], ],
), ),
child: Icon(icon, color: Colors.white, size: 28),
), ),
const SizedBox(height: 14), ),
Text(
title,
style: GoogleFonts.darkerGrotesque(
fontSize: 26,
fontWeight: FontWeight.w900,
color: EnsupColors.text,
),
),
const SizedBox(height: 6),
Text(
description,
textAlign: TextAlign.center,
style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.muted,
height: 1.5,
),
),
],
), ),
), ),
); );
+189
View File
@@ -0,0 +1,189 @@
import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.dart";
import "../theme/ensup_colors.dart";
enum AppMessageType { information, succes, alerte, erreur }
class AppMessage {
AppMessage._();
static int _messageActif = 0;
static void information(
BuildContext context,
String message, {
String titre = "Information",
}) {
_afficher(
context,
type: AppMessageType.information,
titre: titre,
message: message,
);
}
static void succes(
BuildContext context,
String message, {
String titre = "Action réussie",
}) {
_afficher(
context,
type: AppMessageType.succes,
titre: titre,
message: message,
);
}
static void alerte(
BuildContext context,
String message, {
String titre = "Attention",
}) {
_afficher(
context,
type: AppMessageType.alerte,
titre: titre,
message: message,
);
}
static void erreur(
BuildContext context,
Object erreur, {
String titre = "Une erreur est survenue",
}) {
_afficher(
context,
type: AppMessageType.erreur,
titre: titre,
message: _nettoyerErreur(erreur),
duree: const Duration(seconds: 8),
);
}
static void _afficher(
BuildContext context, {
required AppMessageType type,
required String titre,
required String message,
Duration duree = const Duration(seconds: 6),
}) {
final messenger = ScaffoldMessenger.of(context);
final identifiant = ++_messageActif;
final style = _style(type);
messenger
..clearSnackBars()
..hideCurrentMaterialBanner()
..showMaterialBanner(
MaterialBanner(
backgroundColor: style.fond,
surfaceTintColor: Colors.transparent,
elevation: 6,
shadowColor: EnsupColors.text.withValues(alpha: 0.18),
padding: const EdgeInsets.fromLTRB(20, 14, 8, 14),
leadingPadding: const EdgeInsets.only(right: 14),
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: style.couleur,
borderRadius: BorderRadius.circular(8),
),
child: Icon(style.icone, color: Colors.white, size: 22),
),
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
titre,
style: GoogleFonts.darkerGrotesque(
fontSize: 16,
fontWeight: FontWeight.w800,
color: style.texte,
),
),
const SizedBox(height: 2),
Text(
message,
style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.text2,
height: 1.35,
),
),
],
),
actions: [
IconButton(
tooltip: "Fermer le message",
onPressed: () {
_messageActif++;
messenger.hideCurrentMaterialBanner();
},
icon: Icon(Icons.close, color: style.texte),
),
],
),
);
Future<void>.delayed(duree, () {
if (_messageActif == identifiant && messenger.mounted) {
messenger.hideCurrentMaterialBanner();
}
});
}
static String _nettoyerErreur(Object erreur) {
return erreur
.toString()
.replaceFirst(RegExp(r"^(Exception|Error):\s*"), "")
.trim();
}
static _AppMessageStyle _style(AppMessageType type) {
return switch (type) {
AppMessageType.information => const _AppMessageStyle(
fond: Color(0xFFEFF8FC),
couleur: EnsupColors.cyan,
texte: EnsupColors.blue3,
icone: Icons.info_outline,
),
AppMessageType.succes => const _AppMessageStyle(
fond: Color(0xFFF0FDF4),
couleur: EnsupColors.green,
texte: Color(0xFF166534),
icone: Icons.check_circle_outline,
),
AppMessageType.alerte => const _AppMessageStyle(
fond: Color(0xFFFFF7ED),
couleur: Color(0xFFD97706),
texte: Color(0xFF9A3412),
icone: Icons.warning_amber_rounded,
),
AppMessageType.erreur => const _AppMessageStyle(
fond: Color(0xFFFFF1F2),
couleur: EnsupColors.red,
texte: Color(0xFF9F1239),
icone: Icons.error_outline,
),
};
}
}
class _AppMessageStyle {
const _AppMessageStyle({
required this.fond,
required this.couleur,
required this.texte,
required this.icone,
});
final Color fond;
final Color couleur;
final Color texte;
final IconData icone;
}
+95 -66
View File
@@ -27,96 +27,122 @@ class EnsupTopBar extends StatelessWidget {
final onBack = this.onBack; final onBack = this.onBack;
return Container( return Container(
height: 56, height: 60,
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 18),
color: EnsupColors.blue3, color: EnsupColors.blue3,
child: Row( child: LayoutBuilder(
mainAxisAlignment: MainAxisAlignment.spaceBetween, builder: (context, constraints) {
children: [ final compact = constraints.maxWidth < 620;
Row( final showCampus = constraints.maxWidth >= 520;
final showName = constraints.maxWidth >= 760;
return Row(
children: [ children: [
if (onBack != null) ...[ if (onBack != null) ...[
_BackButton(onTap: onBack), _BackButton(onTap: onBack, compact: compact),
const SizedBox(width: 8), SizedBox(width: compact ? 5 : 8),
], ],
const Icon(Icons.badge_outlined, color: Colors.white, size: 18), const Icon(Icons.badge_outlined, color: Colors.white, size: 18),
const SizedBox(width: 10), const SizedBox(width: 9),
Text( Expanded(
brandText,
style: GoogleFonts.darkerGrotesque(
color: Colors.white,
fontWeight: FontWeight.w900,
fontSize: 22,
letterSpacing: 0.3,
),
),
],
),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: Colors.white.withValues(alpha: 0.2)),
),
child: Text( child: Text(
campusTag, brandText,
style: GoogleFonts.titilliumWeb( maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.darkerGrotesque(
color: Colors.white, color: Colors.white,
fontSize: 12, fontWeight: FontWeight.w900,
fontWeight: FontWeight.w600, fontSize: compact ? 18 : 21,
), ),
), ),
), ),
if (name != null && initials != null) ...[ const SizedBox(width: 10),
const SizedBox(width: 12), if (showCampus)
_Avatar(initials: initials), Container(
const SizedBox(width: 8), padding: const EdgeInsets.symmetric(
Text( horizontal: 11,
name, vertical: 5,
style: GoogleFonts.titilliumWeb( ),
color: Colors.white, decoration: BoxDecoration(
fontSize: 13, color: Colors.white.withValues(alpha: 0.14),
fontWeight: FontWeight.w600, borderRadius: BorderRadius.circular(999),
border: Border.all(
color: Colors.white.withValues(alpha: 0.2),
),
),
child: Text(
campusTag,
style: GoogleFonts.titilliumWeb(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
),
), ),
), ),
if (name != null && initials != null) ...[
const SizedBox(width: 10),
_Avatar(initials: initials),
if (showName) ...[
const SizedBox(width: 8),
Text(
name,
style: GoogleFonts.titilliumWeb(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
], ],
], ],
), );
], },
), ),
); );
} }
} }
class _BackButton extends StatelessWidget { class _BackButton extends StatelessWidget {
const _BackButton({required this.onTap}); const _BackButton({required this.onTap, required this.compact});
final VoidCallback onTap; final VoidCallback onTap;
final bool compact;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return InkWell( return Tooltip(
onTap: onTap, message: "Retour",
borderRadius: BorderRadius.circular(8), child: InkWell(
child: Padding( onTap: onTap,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), borderRadius: BorderRadius.circular(7),
child: Row( child: Container(
mainAxisSize: MainAxisSize.min, height: 34,
children: [ padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 10),
Icon(Icons.arrow_back, size: 16, color: Colors.white.withValues(alpha: 0.85)), decoration: BoxDecoration(
const SizedBox(width: 6), color: Colors.white.withValues(alpha: 0.08),
Text( borderRadius: BorderRadius.circular(7),
"Retour", ),
style: GoogleFonts.titilliumWeb( child: Row(
color: Colors.white.withValues(alpha: 0.85), mainAxisSize: MainAxisSize.min,
fontSize: 13, children: [
fontWeight: FontWeight.w600, Icon(
Icons.arrow_back_rounded,
size: 17,
color: Colors.white.withValues(alpha: 0.9),
), ),
), if (!compact) ...[
], const SizedBox(width: 6),
Text(
"Retour",
style: GoogleFonts.titilliumWeb(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
],
),
), ),
), ),
); );
@@ -135,8 +161,11 @@ class _Avatar extends StatelessWidget {
height: 30, height: 30,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
gradient: const LinearGradient(colors: [EnsupColors.teal, EnsupColors.cyan]), color: EnsupColors.purple,
border: Border.all(color: Colors.white.withValues(alpha: 0.3), width: 2), border: Border.all(
color: Colors.white.withValues(alpha: 0.3),
width: 2,
),
), ),
alignment: Alignment.center, alignment: Alignment.center,
child: Text( child: Text(
+74 -24
View File
@@ -37,7 +37,11 @@ class EtatBox extends StatelessWidget {
case EtatChecklist.deteriore: case EtatChecklist.deteriore:
background = const Color(0xFFFFFBEB); background = const Color(0xFFFFFBEB);
border = const Color(0xFFFDE68A); border = const Color(0xFFFDE68A);
contenu = const Icon(Icons.priority_high, size: 16, color: Color(0xFFD97706)); contenu = const Icon(
Icons.priority_high,
size: 16,
color: Color(0xFFD97706),
);
} }
} }
@@ -73,15 +77,36 @@ class ChecklistTableau extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return LayoutBuilder(
children: [ builder: (context, constraints) {
_entete(), final columnWidth = constraints.maxWidth < 520 ? 58.0 : 72.0;
for (var i = 0; i < accessoires.length; i++) _ligne(i), return Container(
], padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: EnsupColors.line, width: 1.5),
boxShadow: [
BoxShadow(
color: EnsupColors.text.withValues(alpha: 0.035),
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: Column(
children: [
_entete(columnWidth),
for (var i = 0; i < accessoires.length; i++)
_ligne(i, columnWidth),
],
),
);
},
); );
} }
Widget _entete() { Widget _entete(double columnWidth) {
return Container( return Container(
padding: const EdgeInsets.symmetric(vertical: 10), padding: const EdgeInsets.symmetric(vertical: 10),
decoration: const BoxDecoration( decoration: const BoxDecoration(
@@ -90,9 +115,9 @@ class ChecklistTableau extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
Expanded(child: _texteEntete("ÉLÉMENT")), Expanded(child: _texteEntete("ÉLÉMENT")),
_colonneEntete("PRÉSENT"), _colonneEntete("PRÉSENT", columnWidth),
_colonneEntete("ABSENT"), _colonneEntete("ABSENT", columnWidth),
_colonneEntete("DÉTÉRIORÉ"), _colonneEntete("DÉTÉRIORÉ", columnWidth),
], ],
), ),
); );
@@ -110,11 +135,16 @@ class ChecklistTableau extends StatelessWidget {
); );
} }
Widget _colonneEntete(String label) { Widget _colonneEntete(String label, double width) {
return SizedBox(width: 72, child: Center(child: _texteEntete(label))); return SizedBox(
width: width,
child: Center(
child: FittedBox(fit: BoxFit.scaleDown, child: _texteEntete(label)),
),
);
} }
Widget _ligne(int index) { Widget _ligne(int index, double columnWidth) {
return Container( return Container(
padding: const EdgeInsets.symmetric(vertical: 10), padding: const EdgeInsets.symmetric(vertical: 10),
decoration: const BoxDecoration( decoration: const BoxDecoration(
@@ -125,20 +155,23 @@ class ChecklistTableau extends StatelessWidget {
Expanded( Expanded(
child: Text( child: Text(
accessoires[index], accessoires[index],
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.text2), style: GoogleFonts.titilliumWeb(
fontSize: 14,
color: EnsupColors.text2,
),
), ),
), ),
_case(index, EtatChecklist.present), _case(index, EtatChecklist.present, columnWidth),
_case(index, EtatChecklist.absent), _case(index, EtatChecklist.absent, columnWidth),
_case(index, EtatChecklist.deteriore), _case(index, EtatChecklist.deteriore, columnWidth),
], ],
), ),
); );
} }
Widget _case(int index, EtatChecklist type) { Widget _case(int index, EtatChecklist type, double width) {
return SizedBox( return SizedBox(
width: 72, width: width,
child: Center( child: Center(
child: EtatBox( child: EtatBox(
type: type, type: type,
@@ -152,7 +185,11 @@ class ChecklistTableau extends StatelessWidget {
/// Encadré d'instructions / rappel à côté d'une checklist. /// Encadré d'instructions / rappel à côté d'une checklist.
class ChecklistInstructions extends StatelessWidget { class ChecklistInstructions extends StatelessWidget {
const ChecklistInstructions({super.key, required this.titre, required this.texte}); const ChecklistInstructions({
super.key,
required this.titre,
required this.texte,
});
final String titre; final String titre;
final String texte; final String texte;
@@ -164,7 +201,10 @@ class ChecklistInstructions extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: EnsupColors.blue2.withValues(alpha: 0.05), color: EnsupColors.blue2.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
border: Border.all(color: EnsupColors.blue2.withValues(alpha: 0.15), width: 1.5), border: Border.all(
color: EnsupColors.blue2.withValues(alpha: 0.15),
width: 1.5,
),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -181,7 +221,11 @@ class ChecklistInstructions extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
texte, texte,
style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.text2, height: 1.6), style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.text2,
height: 1.6,
),
), ),
], ],
), ),
@@ -208,10 +252,16 @@ class ChecklistCommentaire extends StatelessWidget {
style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.text), style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.text),
decoration: InputDecoration( decoration: InputDecoration(
hintText: hint, hintText: hint,
hintStyle: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted), hintStyle: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.muted,
),
filled: true, filled: true,
fillColor: Colors.white, fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(9), borderRadius: BorderRadius.circular(9),
borderSide: const BorderSide(color: EnsupColors.line, width: 1.5), borderSide: const BorderSide(color: EnsupColors.line, width: 1.5),
@@ -3,7 +3,7 @@ import "package:google_fonts/google_fonts.dart";
import "../theme/ensup_colors.dart"; import "../theme/ensup_colors.dart";
/// Option d'identification de l'écran de connexion (QR ou mail ENSUP). /// Option d'identification de l'écran de connexion (QR ou mail ENSUP).
class IdentificationOption extends StatelessWidget { class IdentificationOption extends StatefulWidget {
const IdentificationOption({ const IdentificationOption({
super.key, super.key,
required this.icon, required this.icon,
@@ -19,56 +19,102 @@ class IdentificationOption extends StatelessWidget {
final String description; final String description;
final VoidCallback? onTap; final VoidCallback? onTap;
@override
State<IdentificationOption> createState() => _IdentificationOptionState();
}
class _IdentificationOptionState extends State<IdentificationOption> {
bool _hovered = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return InkWell( return MouseRegion(
onTap: onTap, onEnter: (_) => setState(() => _hovered = true),
borderRadius: BorderRadius.circular(12), onExit: (_) => setState(() => _hovered = false),
child: Container( cursor: SystemMouseCursors.click,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.line, width: 2), border: Border.all(
), color: _hovered ? widget.iconColor : EnsupColors.line,
child: Row( width: 1.5,
children: [ ),
Container( boxShadow: [
width: 48, BoxShadow(
height: 48, color: widget.iconColor.withValues(
decoration: BoxDecoration( alpha: _hovered ? 0.12 : 0.035,
color: iconColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
), ),
child: Icon(icon, color: iconColor, size: 24), blurRadius: _hovered ? 16 : 8,
offset: const Offset(0, 4),
), ),
const SizedBox(width: 16), ],
Expanded( ),
child: Column( child: Material(
crossAxisAlignment: CrossAxisAlignment.start, color: Colors.transparent,
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 17),
child: Row(
children: [ children: [
Text( Container(
label, width: 48,
style: GoogleFonts.darkerGrotesque( height: 48,
fontWeight: FontWeight.w700, decoration: BoxDecoration(
fontSize: 18, color: widget.iconColor.withValues(alpha: 0.1),
color: EnsupColors.text, borderRadius: BorderRadius.circular(12),
),
child: Icon(widget.icon, color: widget.iconColor, size: 24),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.label,
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w800,
fontSize: 18,
height: 1.05,
color: EnsupColors.text,
),
),
const SizedBox(height: 4),
Text(
widget.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.titilliumWeb(
fontSize: 12,
color: EnsupColors.muted,
height: 1.35,
),
),
],
), ),
), ),
const SizedBox(height: 3), const SizedBox(width: 8),
Text( Container(
description, width: 28,
style: GoogleFonts.titilliumWeb( height: 28,
fontSize: 12, decoration: BoxDecoration(
color: EnsupColors.muted, color: widget.iconColor.withValues(alpha: 0.08),
height: 1.4, shape: BoxShape.circle,
),
child: Icon(
Icons.chevron_right_rounded,
color: widget.iconColor,
size: 19,
), ),
), ),
], ],
), ),
), ),
const Icon(Icons.chevron_right, color: EnsupColors.muted, size: 20), ),
],
), ),
), ),
); );
+129 -52
View File
@@ -24,65 +24,142 @@ class MaterielCard extends StatelessWidget {
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.line, width: 1.5), border: Border.all(color: EnsupColors.line, width: 1.5),
), boxShadow: [
child: Row( BoxShadow(
children: [ color: EnsupColors.text.withValues(alpha: 0.035),
Container( blurRadius: 10,
width: 44, offset: const Offset(0, 3),
height: 44,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(10),
),
child: Icon(item.icon, color: accent, size: 20),
), ),
const SizedBox(width: 14), ],
Expanded( ),
child: Column( child: LayoutBuilder(
crossAxisAlignment: CrossAxisAlignment.start, builder: (context, constraints) {
children: [ final compact = constraints.maxWidth < 620;
final information = Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.09),
borderRadius: BorderRadius.circular(10),
),
child: Icon(item.icon, color: accent, size: 21),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.nom,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w800,
fontSize: 17,
height: 1.05,
color: EnsupColors.text,
),
),
const SizedBox(height: 4),
Text(
"${item.categorie} · Réf. ${item.reference}",
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.titilliumWeb(
fontSize: 12,
color: EnsupColors.muted,
),
),
const SizedBox(height: 3),
Row(
children: [
const Icon(
Icons.location_on_outlined,
size: 13,
color: EnsupColors.muted,
),
const SizedBox(width: 4),
Expanded(
child: Text(
item.campus,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.titilliumWeb(
fontSize: 11,
color: EnsupColors.muted,
),
),
),
],
),
],
),
),
],
);
final actions = Wrap(
spacing: 10,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
alignment: WrapAlignment.end,
children: [
StatusPill(disponible: disponible),
if (disponible)
FilledButton.icon(
onPressed: onSelect,
style: FilledButton.styleFrom(
backgroundColor: EnsupColors.cyan,
padding: const EdgeInsets.symmetric(
horizontal: 15,
vertical: 11,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(9),
),
),
icon: const Icon(Icons.arrow_forward_rounded, size: 16),
label: Text(
"Sélectionner",
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w700,
fontSize: 14,
),
),
)
else
Text( Text(
item.nom, "Indisponible",
style: GoogleFonts.darkerGrotesque( style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
fontSize: 16, fontSize: 14,
color: EnsupColors.text, color: EnsupColors.muted,
), ),
), ),
const SizedBox(height: 2), ],
Text( );
"${item.categorie} · Réf. ${item.reference} · ${item.campus}",
style: GoogleFonts.titilliumWeb(fontSize: 12, color: EnsupColors.muted), if (compact) {
), return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
information,
const SizedBox(height: 14),
Align(alignment: Alignment.centerRight, child: actions),
], ],
), );
), }
const SizedBox(width: 10),
StatusPill(disponible: disponible), return Row(
const SizedBox(width: 12), children: [
if (disponible) Expanded(child: information),
FilledButton( const SizedBox(width: 16),
onPressed: onSelect, actions,
style: FilledButton.styleFrom( ],
backgroundColor: EnsupColors.cyan, );
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), },
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
child: Text(
"Sélectionner",
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 14),
),
)
else
Text(
"Indisponible",
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w700,
fontSize: 14,
color: EnsupColors.muted,
),
),
],
), ),
), ),
); );
+109 -63
View File
@@ -18,74 +18,120 @@ class ProfileCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( color: EnsupColors.cyan.withValues(alpha: 0.055),
colors: [
EnsupColors.cyan.withValues(alpha: 0.06),
EnsupColors.teal.withValues(alpha: 0.04),
],
),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: EnsupColors.cyan.withValues(alpha: 0.15), width: 1.5), border: Border.all(
), color: EnsupColors.cyan.withValues(alpha: 0.15),
child: Row( width: 1.5,
children: [ ),
Container( boxShadow: [
width: 48, BoxShadow(
height: 48, color: EnsupColors.blue3.withValues(alpha: 0.045),
decoration: const BoxDecoration( blurRadius: 12,
shape: BoxShape.circle, offset: const Offset(0, 4),
gradient: LinearGradient(colors: [EnsupColors.teal, EnsupColors.cyan]),
),
alignment: Alignment.center,
child: Text(
initials,
style: GoogleFonts.darkerGrotesque(
color: Colors.white,
fontWeight: FontWeight.w900,
fontSize: 18,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w800,
fontSize: 20,
color: EnsupColors.text,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: GoogleFonts.titilliumWeb(fontSize: 12, color: EnsupColors.muted),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
decoration: BoxDecoration(
color: EnsupColors.green.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
),
child: Text(
"Connecté",
style: GoogleFonts.titilliumWeb(
fontSize: 12,
fontWeight: FontWeight.w600,
color: EnsupColors.green,
),
),
), ),
], ],
), ),
child: LayoutBuilder(
builder: (context, constraints) {
final showStatus = constraints.maxWidth >= 430;
return Row(
children: [
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: EnsupColors.teal,
border: Border.all(color: Colors.white, width: 3),
boxShadow: [
BoxShadow(
color: EnsupColors.teal.withValues(alpha: 0.22),
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
alignment: Alignment.center,
child: Text(
initials,
style: GoogleFonts.darkerGrotesque(
color: Colors.white,
fontWeight: FontWeight.w900,
fontSize: 18,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.darkerGrotesque(
fontWeight: FontWeight.w800,
fontSize: 20,
height: 1,
color: EnsupColors.text,
),
),
const SizedBox(height: 4),
Text(
subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.titilliumWeb(
fontSize: 12,
color: EnsupColors.muted,
),
),
],
),
),
if (showStatus) ...[
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 11,
vertical: 6,
),
decoration: BoxDecoration(
color: EnsupColors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: EnsupColors.green.withValues(alpha: 0.18),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.check_circle,
size: 13,
color: EnsupColors.green,
),
const SizedBox(width: 5),
Text(
"Identifié",
style: GoogleFonts.titilliumWeb(
fontSize: 11,
fontWeight: FontWeight.w700,
color: EnsupColors.green,
),
),
],
),
),
],
],
);
},
),
); );
} }
} }
+520
View File
@@ -0,0 +1,520 @@
{
"name": "eme-frontend",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@azure/msal-browser": "^5.17.1"
},
"devDependencies": {
"esbuild": "^0.28.1"
}
},
"node_modules/@azure/msal-browser": {
"version": "5.17.1",
"resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.17.1.tgz",
"integrity": "sha512-zBhRGzABKSI7hfWh5EaZmril5ybZ7imBN1qEZl5sDTaelr+l8SnPjZO50Q4dnKnm347YPIlBMSnXKZyh3Yu5DQ==",
"license": "MIT",
"dependencies": {
"@azure/msal-common": "16.11.2"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@azure/msal-common": {
"version": "16.11.2",
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.11.2.tgz",
"integrity": "sha512-yDhtBOGDCdK9ipQ9g3+wmlMEPnZx2pXaDicDd9jYyR1L+7lEbvEohTDmF5qejZDutZY3m9pWPxeYxzNC701A2w==",
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "eme-frontend-auth",
"private": true,
"scripts": {
"build:auth": "esbuild ./web/auth/msal_bridge.js --bundle --minify --format=iife --outfile=./web/msal_bridge.bundle.js"
},
"dependencies": {
"@azure/msal-browser": "^5.17.1"
},
"devDependencies": {
"esbuild": "^0.28.1"
}
}
+105
View File
@@ -0,0 +1,105 @@
import {
InteractionRequiredAuthError,
PublicClientApplication,
} from "@azure/msal-browser";
let msalInstance = null;
let apiScope = "";
function requireInstance() {
if (!msalInstance) {
throw new Error("MSAL n'est pas initialise.");
}
return msalInstance;
}
function activeAccount() {
const instance = requireInstance();
return instance.getActiveAccount() ?? instance.getAllAccounts()[0] ?? null;
}
async function initialize(tenantId, clientId, scope, redirectUri) {
if (msalInstance) {
return;
}
if (!tenantId || !clientId || !scope || !redirectUri) {
throw new Error("La configuration Azure du frontend est incomplete.");
}
apiScope = scope;
msalInstance = await PublicClientApplication.createPublicClientApplication({
auth: {
clientId,
authority: `https://login.microsoftonline.com/${tenantId}`,
redirectUri,
postLogoutRedirectUri: redirectUri,
},
cache: {
cacheLocation: "sessionStorage",
},
});
const account = activeAccount();
if (account) {
msalInstance.setActiveAccount(account);
}
}
async function signIn() {
const instance = requireInstance();
const result = await instance.loginPopup({
scopes: ["openid", "profile", "email", apiScope],
});
instance.setActiveAccount(result.account);
}
async function getAccessToken() {
const instance = requireInstance();
const account = activeAccount();
if (!account) {
throw new Error("Aucun compte Microsoft connecte.");
}
try {
const result = await instance.acquireTokenSilent({
account,
scopes: [apiScope],
});
return result.accessToken;
} catch (error) {
if (!(error instanceof InteractionRequiredAuthError)) {
throw error;
}
const result = await instance.acquireTokenPopup({
account,
scopes: [apiScope],
});
return result.accessToken;
}
}
function hasAccount() {
return activeAccount() !== null;
}
async function signOut() {
const instance = requireInstance();
const account = activeAccount();
if (!account) {
return;
}
await instance.logoutPopup({ account });
}
globalThis.emeMsal = {
getAccessToken,
hasAccount,
initialize,
signIn,
signOut,
};
+1
View File
@@ -33,6 +33,7 @@
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
</head> </head>
<body> <body>
<script src="msal_bridge.bundle.js"></script>
<script src="flutter_bootstrap.js" async></script> <script src="flutter_bootstrap.js" async></script>
</body> </body>
</html> </html>
File diff suppressed because one or more lines are too long
+219 -10
View File
@@ -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`. | | 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. | | 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. | | 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,12 +37,13 @@ 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. | | 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. | | 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. | | 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 | Terminé pour la V1 | Parcours emprunt et restitution branchés sur l'API et testés en réel. | | Frontend étudiant | À adapter au nouveau workflow | Interface premium conservée ; préremplissage et écran d'attente à ajouter lorsqu'une valeur est modifiée. |
| Authentification | Simulée | `x-user-email` côté backend et identité démo côté frontend ; Azure AD reste à faire. | | 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. |
| Responsable matériel | Non démarré | Dashboard, stock, anomalies, historique à développer. | | 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. | | Tests automatisés | Non démarré | Tests backend/frontend/E2E à ajouter ; tests runtime manuels effectués. |
| Documentation | Partielle | `CONTEXT.md`, `TODO.md`, `review.md` existent ; README principal et OpenAPI restent à créer. | | Documentation | Partielle | README principal et documents de conception présents ; OpenAPI, guides utilisateur et captures restent à produire. |
## Commandes validées ## Commandes validées
@@ -55,16 +58,21 @@ npm run lint
npm run seed npm run seed
npm run fixtures npm run fixtures
cd ../eme-frontend cd ../eme-frontend
npm run build:auth
flutter run -d web-server --web-port 5000 flutter run -d web-server --web-port 5000
C:\flutter\flutter\bin\cache\dart-sdk\bin\dart.exe analyze
``` ```
Notes : Notes :
- le backend écoute sur `http://localhost:3000` ; - le backend écoute sur `http://localhost:3000` ;
- le frontend web de démo écoute sur `http://localhost:5000` ; - le frontend web de démo écoute sur `http://localhost:5000` ;
- Adminer est disponible sur `http://localhost:8081` ; - Adminer est disponible sur `http://localhost:8081` ;
- dans l'environnement Codex, `flutter analyze`, `flutter analyze --no-pub` et `dart format` ont déjà bloqué au timeout ; à relancer dans un terminal local Flutter. - 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é : Scénario étudiant validé avec SQL Server Docker et backend compilé :
@@ -75,6 +83,14 @@ Scénario étudiant validé avec SQL Server Docker et backend compilé :
5. Restituer conforme : l'emprunt passe `CLOTURE` et le matériel redevient disponible. 5. Restituer conforme : l'emprunt passe `CLOTURE` et le matériel redevient disponible.
6. Restituer non conforme : l'emprunt passe `RETOUR_NON_CONFORME` et le matériel sort du catalogue disponible. 6. Restituer non conforme : l'emprunt passe `RETOUR_NON_CONFORME` et le matériel sort du catalogue disponible.
Scénario responsable validé avec SQL Server Docker et backend compilé :
1. Karim (`RESPONSABLE`) accède au dashboard, aux emprunts, au stock, aux anomalies, aux notifications et à l'historique de son campus.
2. Lucas (`ETUDIANT`) reçoit une réponse `403` sur les routes responsable.
3. Une anomalie peut avancer dans le cycle `DETECTEE -> EN_COURS_TRAITEMENT -> RESOLUE -> CLOTUREE`.
4. Chaque transition enregistre le responsable et crée une entrée d'historique.
5. L'API génère l'export CSV de l'historique et l'interface Flutter déclenche son téléchargement.
### Note outillage — migrations en environnement non-interactif ### Note outillage — migrations en environnement non-interactif
`prisma migrate dev` se bloque quand il doit demander une confirmation (ex. ajout de contrainte `@unique` sur table existante) car le terminal est non-interactif. `prisma migrate dev` se bloque quand il doit demander une confirmation (ex. ajout de contrainte `@unique` sur table existante) car le terminal est non-interactif.
@@ -259,8 +275,9 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
| `Materiel.statut` -> `@default("DISPONIBLE")` | À faire avec migration + ERD | Faible : le code fournit déjà le statut explicitement. | | `Materiel.statut` -> `@default("DISPONIBLE")` | À faire avec migration + ERD | Faible : le code fournit déjà le statut explicitement. |
| Tailles de colonnes `@db.NVarChar(n)` | À cadrer | Faible V1, important pour qualité BDD. | | Tailles de colonnes `@db.NVarChar(n)` | À cadrer | Faible V1, important pour qualité BDD. |
| Swagger/OpenAPI | À faire | Moyen : utile pour tester/documenter l'API. | | Swagger/OpenAPI | À faire | Moyen : utile pour tester/documenter l'API. |
| Auth réelle Azure AD | À faire | Fort : requis pour sortir de la démo avec auth simulée. | | 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. |
--- ---
@@ -324,6 +341,198 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
- Dette technique reformatée en tableau avec statut et impact V1. - Dette technique reformatée en tableau avec statut et impact V1.
- Clarification des étapes frontend statiques historiques : elles sont conservées pour mémoire mais remplacées par les branchements API ultérieurs. - Clarification des étapes frontend statiques historiques : elles sont conservées pour mémoire mais remplacées par les branchements API ultérieurs.
### Étape 34 — API responsable : dashboard
- Branche dédiée `feat/responsable-dashboard-api` créée depuis `develop`.
- Endpoint `GET /api/responsable/dashboard` ajouté, protégé par rôle `RESPONSABLE`.
- Périmètre campus appliqué via `user.campusId` (RG27).
- KPIs exposés : matériels par statut, emprunts par statut, anomalies par statut, notifications non lues.
- Activité récente exposée depuis l'historique, avec utilisateur, matériel et emprunt associé.
- Vérifié au runtime : Karim (`RESPONSABLE`) obtient 200 avec KPIs ; Lucas (`ETUDIANT`) obtient 403.
### Étape 35 — API responsable : consultation des emprunts
- Branche dédiée `feat/responsable-emprunts-api` créée après merge du dashboard responsable dans `develop`.
- Endpoint `GET /api/responsable/emprunts` ajouté, protégé par rôle `RESPONSABLE`.
- Filtrage par campus du responsable appliqué systématiquement (RG27).
- Filtre optionnel `statut` ajouté pour RG28 : `EN_COURS`, `EN_RETARD`, `CLOTURE`, `RETOUR_NON_CONFORME`, `ANNULE`.
- Réponse enrichie avec l'étudiant, le matériel, la catégorie, les dates et le statut.
- Vérifié au runtime : Karim obtient les emprunts du campus, `statut=EN_COURS` filtre correctement, statut invalide renvoie 400, Lucas obtient 403.
### Étape 36 — API responsable : consultation du stock
- Branche dédiée `feat/responsable-stock-api` créée après merge de la consultation des emprunts responsable dans `develop`.
- Endpoint `GET /api/responsable/materiels` ajouté, protégé par rôle `RESPONSABLE`.
- Filtrage par campus du responsable appliqué systématiquement (RG27/RG29).
- Filtres optionnels ajoutés : `statut`, `categorieId`, `q` (nom, marque, modèle, référence).
- Contrairement au catalogue étudiant, cet endpoint retourne tout le stock actif du campus, quel que soit le statut.
- Réponse enrichie avec catégorie et accessoires attendus.
- Vérifié au runtime : Karim obtient tout le stock du campus, `statut=DISPONIBLE` et `q=MacBook` filtrent correctement, statut invalide renvoie 400, Lucas obtient 403.
### Étape 37 — API responsable : consultation des anomalies
- Branche dédiée `feat/responsable-anomalies-api` créée après merge de la consultation du stock responsable dans `develop`.
- Endpoint `GET /api/responsable/anomalies` ajouté, protégé par rôle `RESPONSABLE`.
- Filtrage par campus du responsable appliqué systématiquement via l'emprunt lié à l'anomalie (RG27).
- Filtres optionnels ajoutés : `statut` (`DETECTEE`, `EN_COURS_TRAITEMENT`, `RESOLUE`, `CLOTUREE`) et `type`.
- Réponse enrichie avec anomalie, emprunt, étudiant concerné, matériel, catégorie et responsable de traitement si renseigné.
- Limite volontaire : consultation uniquement ; le cycle de traitement/résolution reste à développer.
### Étape 38 — API responsable : notifications
- Branche dédiée `feat/responsable-notifications-api` créée après merge de la consultation des anomalies responsable dans `develop`.
- Endpoint `GET /api/responsable/notifications` ajouté, protégé par rôle `RESPONSABLE`.
- Filtre optionnel `lu=true|false` ajouté pour séparer les notifications lues et non lues.
- Endpoint `PATCH /api/responsable/notifications/:id/lu` ajouté pour marquer une notification du responsable connecté comme lue.
- Endpoint `PATCH /api/responsable/notifications/lu-toutes` ajouté pour marquer toutes les notifications non lues du responsable connecté comme lues.
- Réponse enrichie avec l'anomalie liée et le matériel concerné lorsque la notification pointe vers une anomalie.
### Étape 39 — API responsable : historique et export
- Branche dédiée `feat/responsable-historique-api` créée après merge des notifications responsable dans `develop`.
- Endpoint `GET /api/responsable/historique` ajouté, protégé par rôle `RESPONSABLE`.
- Filtrage par campus du responsable appliqué systématiquement (RG27).
- Filtres optionnels ajoutés : `action`, `utilisateurId`, `materielId`, `empruntId`, `dateDebut`, `dateFin`.
- Endpoint `GET /api/responsable/historique/export.csv` ajouté avec les mêmes filtres.
- Export CSV généré côté API avec les colonnes principales : date, action, description, utilisateur, email, matériel, emprunt.
### Étape 40 — API responsable : cycle de statut des anomalies
- Branche dédiée `feat/responsable-anomalies-cycle-api` créée après merge de l'historique responsable dans `develop`.
- Endpoint `PATCH /api/responsable/anomalies/:id/statut` ajouté, protégé par rôle `RESPONSABLE`.
- Filtrage par campus appliqué avant toute modification pour empêcher le traitement d'une anomalie hors campus (RG27).
- Transitions autorisées : `DETECTEE -> EN_COURS_TRAITEMENT -> RESOLUE -> CLOTUREE`.
- Le responsable connecté est enregistré dans `traiteeParId`; une observation optionnelle peut être conservée.
- Une entrée d'historique `TRAITEMENT_ANOMALIE` est créée à chaque transition.
### Étape 41 — Frontend responsable : premier branchement API
- Branche dédiée `feat/responsable-frontend-api` créée pour isoler le travail frontend responsable.
- Le bouton "Accès responsable matériel" de l'écran d'identification ouvre maintenant un écran responsable.
- Client API étendu pour accepter l'e-mail simulé du responsable (`karim.benali@ensup.eu`) et les requêtes `PATCH`.
- Service frontend responsable ajouté pour consommer dashboard, emprunts, stock, anomalies, notifications et historique.
- Écran responsable ajouté avec onglets : dashboard, emprunts, stock, anomalies, notifications, historique.
- Les anomalies peuvent être avancées au prochain statut autorisé via l'API.
- Vérification statique effectuée avec l'exécutable Dart direct : `dart analyze` OK. Le wrapper `flutter` reste instable dans cette session et bloque au lancement web automatisé.
### Étape 42 — Frontend responsable : polish opérationnel
- Branche dédiée `feat/responsable-frontend-polish` créée après merge du premier branchement responsable.
- Statuts responsables remplacés par des libellés métier lisibles et des badges colorés.
- Recherche ajoutée sur les onglets emprunts, stock, anomalies, notifications et historique.
- Les lignes de listes affichent désormais une méta-information utile : identifiant emprunt, marque/modèle, date de détection, date de notification ou auteur historique.
- Action "Tout marquer lu" ajoutée dans l'onglet notifications.
- Action export CSV rendue visible dans l'onglet historique avec indication de l'endpoint disponible.
- Vérification statique : `dart analyze` OK.
### Étape 43 — Synchronisation de l'état courant
- Synthèse mise en cohérence avec les étapes 34 à 42 : API et frontend responsable désormais fonctionnels pour la V1.
- Documentation corrigée pour refléter l'existence du README principal.
- Limites restantes explicitées : authentification Azure AD, téléchargement CSV frontend, tests automatisés, OpenAPI et guides utilisateur.
- Commandes de vérification actualisées : build et lint backend verts, analyse Dart directe sans erreur.
### Étape 44 — Refonte premium de l'espace responsable
- Branche dédiée `style/responsable-premium-dashboard` créée depuis `develop`.
- L'espace responsable adopte une structure de supervision distincte du parcours étudiant : barre supérieure métier, navigation latérale sur ordinateur et navigation compacte sur petit écran.
- Dashboard enrichi avec disponibilités, emprunts en cours, retards, anomalies actives, notifications non lues, activité récente et taux de disponibilité du parc.
- Accès directs ajoutés depuis les points d'attention vers les vues emprunts, stock, anomalies et notifications.
- Les listes métier partagent désormais une hiérarchie visuelle, une recherche et des lignes responsives cohérentes avec la charte ENSUP.
- Aucun changement backend ni métier ; rendu validé manuellement dans le navigateur et `dart analyze` sans erreur.
### Étape 45 — Téléchargement CSV de l'historique responsable
- Branche dédiée `feat/responsable-historique-export` créée depuis `develop`.
- Client HTTP étendu pour récupérer un fichier binaire avec son type de contenu et son nom.
- Le bouton `Export CSV` appelle désormais l'endpoint responsable avec l'identité simulée de Karim et déclenche le téléchargement navigateur.
- Un état de chargement bloque les doubles clics et les erreurs API sont affichées dans l'interface.
- Endpoint vérifié au runtime : réponse `200`, type `text/csv`, nom `historique-responsable.csv` et contenu non vide.
- Vérification statique : `dart analyze` sans erreur.
### Étape 46 — Refonte premium de l'espace étudiant
- L'ordre actuel du parcours est confirmé : identification, puis choix `Emprunter` ou `Restituer`.
- Branche dédiée `style/student-premium-workspace` créée depuis `develop`.
- Charte et maquette conservées : couleurs ENSUP, typographies, barre supérieure, coins de marque et structure des écrans.
- Barre supérieure rendue responsive, avec priorité donnée au titre et à l'identité sur les petites largeurs.
- Accueil rééquilibré avec profil identifié, hiérarchie renforcée et cartes d'action adaptatives avec états de survol.
- Catalogue, détail matériel et sélection de restitution enrichis sans modifier leurs données ni leurs actions.
- Détail et checklists passent automatiquement de deux colonnes à une colonne sur écran étroit ; tableau de contrôle et lignes de matériel évitent les débordements.
- Écrans de confirmation et de résultat conservés fonctionnellement et harmonisés via les composants partagés.
- Vérification statique : `dart analyze` sans erreur.
### Étape 47 — Sécurisation SSO Microsoft Entra ID
- Branche dédiée `feat/block-3-azure-sso` créée depuis `develop`.
- Backend configuré avec deux modes : `demo` en développement et `azure` pour une authentification réelle ; le mode démo est explicitement interdit en production.
- Middleware d'authentification refactoré vers un service dédié. En mode Azure, `x-user-email` est ignoré et un Bearer Token Microsoft est obligatoire.
- Validation JWT ajoutée : signature RSA via les clés JWKS Microsoft, algorithme `RS256`, issuer, audience, tenant, scope `access_as_user`, identifiant `oid` et domaines `ensup.eu` / `ensitech.eu`.
- L'utilisateur, son rôle et son campus restent résolus depuis `eme_db`. Un étudiant obtient `403` sur la supervision, tandis que le responsable de démonstration obtient `200`.
- Frontend doté d'un double mode de session. La simulation sélectionne Lucas ou Karim sans pouvoir être utilisée en mode Azure.
- Bibliothèque officielle `@azure/msal-browser` intégrée via un bundle local construit avec esbuild et un pont typé `dart:js_interop`.
- Flux OpenID Connect préparé avec connexion Microsoft, acquisition silencieuse de l'Access Token, appel de `/api/auth/me`, navigation selon le rôle local, restauration après actualisation et déconnexion MSAL.
- Profil authentifié typé et affiché dans les espaces étudiant et responsable : nom, initiales, classe, rôle et campus ne sont plus figés sur les identités de démonstration.
- Vérifications réussies : build et lint backend, analyse Dart, compilation Flutter Web complète et contrôles runtime des rôles en mode démo.
- 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.
### Étape 49 — Messages applicatifs visibles
- Les `SnackBar` Flutter noir et blanc sont remplacés par un composant global affiché en haut de l'écran.
- Quatre niveaux visuels sont distingués : information, succès, alerte et erreur.
- Chaque message dispose d'une icône, d'un titre, d'un texte explicite, d'une fermeture manuelle et d'une disparition automatique.
- Le composant est utilisé dans les parcours étudiant, la connexion et l'espace responsable.
- Vérification réussie avec `dart analyze`.
- Commit : `4ee593c`.
### Étape 50 — Tableau des emprunts responsable
- La liste des emprunts responsable devient un tableau opérationnel sur ordinateur avec une colonne par information : numéro, matériel, étudiant, dates et statut.
- Les en-têtes permettent de trier par matériel, étudiant, date d'emprunt, retour prévu, retour réel et statut.
- Des filtres serveur sont ajoutés pour l'année, le statut et la recherche par matériel, référence ou étudiant.
- Le tableau devient une liste compacte sur petit écran afin d'éviter tout débordement horizontal.
- Les menus déroulants contraignent les libellés longs afin d'éviter les débordements Flutter.
- Les requêtes restent filtrées par campus côté API.
- Vérifications réussies : build et lint backend, analyse Dart, tris et recherches testés sur SQL Server.
### Étape 51 — Tableau du stock matériel responsable
- La liste du stock devient un tableau opérationnel sur ordinateur et conserve une vue compacte sur petit écran.
- Les colonnes présentent la référence, le matériel, la catégorie, la marque et le modèle, l'état général et le statut.
- La recherche et les filtres serveur permettent de cibler un statut ou une catégorie du campus.
- Le tri serveur accepte le matériel, la référence, la catégorie, la marque, l'état général et le statut, dans les deux ordres.
- Les menus de filtres restent sur une ligne et tronquent les libellés trop longs pour prévenir tout débordement.
- Vérifications réussies : build et lint backend, analyse Dart, tris et filtres combinés testés sur SQL Server.
### Étape 52 — Tableau des anomalies responsable
- La liste des anomalies devient un tableau opérationnel sur ordinateur et une liste compacte sur petit écran.
- Les colonnes présentent la date de détection, le type et la description, le matériel, l'étudiant, le statut et l'action suivante.
- La recherche serveur couvre la description, le type, le matériel, la référence et l'identité de l'étudiant.
- Les anomalies peuvent être filtrées par statut et type, puis triées par date, type, matériel, étudiant ou statut.
- Le changement de statut existant reste disponible dans chaque ligne et conserve les transitions métier autorisées.
- Vérifications réussies : build et lint backend, analyse Dart, tris et filtres combinés testés sur SQL Server.
### Étape 53 — Tableau et export filtré de l'historique responsable
- L'historique devient un tableau d'audit sur ordinateur et une liste compacte sur petit écran.
- Les colonnes présentent la date et l'heure, l'action et sa description, l'utilisateur, le matériel, l'emprunt et le point d'opération.
- La recherche serveur couvre l'action, la description, l'utilisateur et le matériel.
- Les événements peuvent être filtrés par action et période, puis triés par date, action, utilisateur, matériel ou emprunt.
- L'export CSV reprend désormais les filtres, la période et l'ordre actifs dans l'interface.
- Vérifications réussies : build et lint backend, analyse Dart, filtre combiné sur 22 événements et export CSV filtré testés sur SQL Server.
### Étape 54 — Correction du rafraîchissement après décision sur un écart
- La décision du responsable était correctement enregistrée en base, mais l'interface affichait ensuite l'erreur Flutter `setState() callback argument returned a Future`.
- La méthode `_rafraichir()` utilisait une fonction fléchée dont l'affectation de `_charger()` retournait accidentellement un `Future` depuis `setState()`.
- Le message de succès était déclenché, puis l'exception du rafraîchissement était interceptée par le même `catch`, qui affichait un faux message d'erreur à sa place.
- Le callback de `setState()` utilise maintenant un bloc synchrone qui affecte le nouveau chargement sans retourner le `Future`.
- La confirmation reste enregistrée, le message de succès reste visible et les données de supervision sont rechargées normalement.
- Vérifications réussies : `dart analyze` sans erreur et serveur Flutter opérationnel sur le port 5000.
### Étape 55 — Défilement horizontal visible des tableaux responsable
- Les tableaux Emprunts, Stock matériel, Anomalies et Historique restent accessibles sur les écrans de largeur intermédiaire.
- Un composant partagé gère le défilement horizontal avec son propre contrôleur Flutter.
- La barre horizontale est visible, interactive et positionnée sous le contenu de chaque tableau.
- Les dernières colonnes, notamment les statuts et les actions, peuvent être atteintes sans modifier la disposition sur grand écran.
- Les listes compactes mobiles existantes sont conservées sous leurs seuils responsifs.
- Vérifications réussies : les quatre tableaux utilisent le composant partagé, `dart analyze` est sans erreur et le serveur Flutter répond sur le port 5000.
--- ---
*Dernière mise à jour : 2026-07-09État courant du projet et journal de décisions clarifiés.* *Dernière mise à jour : 2026-07-24Défilement horizontal des tableaux responsable ajouté.*