Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d02d47fb87 | |||
| c03df5e810 | |||
| 368cd633c5 | |||
| 38efe6a873 | |||
| ec36349bad | |||
| 5121cac657 | |||
| 4ee593c8d4 | |||
| a562b3248f | |||
| 3739e9b0a0 | |||
| cb71b8fd8c | |||
| 81b23e0707 | |||
| 36224f30ee | |||
| dc208fa13a | |||
| ef31e2ab8c | |||
| 530bd52cb5 | |||
| 313bb1c6ef | |||
| f7ce98343e | |||
| 669ed85924 | |||
| 695b0d3965 | |||
| 1c8ed77ebf | |||
| 1f0a120ecb | |||
| cb3597874d | |||
| c85fab8ca3 | |||
| 95c9080777 | |||
| 6ac94becfb | |||
| ef74fd49bc | |||
| 1677015254 | |||
| 9a71eb3f34 | |||
| 2a533e3368 | |||
| 545d9f14ac | |||
| 4b635b8654 | |||
| 2a1281b0e5 | |||
| 736328c66b | |||
| 2772f4cfd4 | |||
| 9d8ae70063 | |||
| 92e25356bf | |||
| 832aa51e23 | |||
| d2d2245675 | |||
| a14d6827ee | |||
| 1ce023de71 | |||
| 26f036046d | |||
| c76f546f2a | |||
| 2c81bd7208 | |||
| f7f61a8ebd | |||
| b2a5f8cd94 | |||
| a3795b41a0 | |||
| e2165b9826 | |||
| 1d271e225c | |||
| 24e2793cf8 | |||
| 295dd03b8d | |||
| 3681ed5e44 | |||
| cbc67c6c2f | |||
| eb3094f3a6 | |||
| 245a6905a3 | |||
| 8c710b6e3c | |||
| 9c74de53c4 | |||
| 78a0c1305e | |||
| d83aa7dd75 | |||
| e46ec29543 | |||
| 98ba359bc5 | |||
| 7aba1a930d | |||
| 27171562de | |||
| 9564dd1ade | |||
| 90d5a65f29 | |||
| e9d67e78f5 | |||
| 648ea21155 | |||
| 0f72c021ba | |||
| 9941ae97dc | |||
| b124d7a775 | |||
| 84bd2cc629 | |||
| 97a1ad9e84 | |||
| 97faf3e9ee | |||
| e492220af4 | |||
| b2c999026f | |||
| d12302e8a6 | |||
| be7d095fc3 | |||
| 3215a3c11f | |||
| d8f044924e | |||
| ab1bf29b7f | |||
| 101e52236f | |||
| b5a7577e8d | |||
| 20490a05c5 | |||
| bee21b9fd8 | |||
| bc83308d05 | |||
| 63c56ce775 | |||
| 57266b5414 | |||
| d67cf8d65e |
+21
-1
@@ -1,8 +1,28 @@
|
|||||||
# Node.js (backend)
|
# Node.js (backend)
|
||||||
**/node_modules/
|
**/node_modules/
|
||||||
**/dist/
|
**/dist/
|
||||||
**/.env
|
|
||||||
**/*.log
|
**/*.log
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Variables d'environnement (secrets) — seul l'exemple est versionné
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
!**/.env.example
|
||||||
|
|
||||||
|
# Clés et certificats (Azure AD, TLS)
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
*.cert
|
||||||
|
*.pfx
|
||||||
|
*.p12
|
||||||
|
|
||||||
|
# Dumps / sauvegardes de base
|
||||||
|
*.bak
|
||||||
|
*.dump
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
**/coverage/
|
||||||
|
|
||||||
# Local project context
|
# Local project context
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# EME — Emprunt Matériel ENSUP
|
||||||
|
|
||||||
|
EME est une application web de gestion des emprunts de matériel pédagogique pour ENSUP / Ensitech.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
| Couche | Technologie |
|
||||||
|
|---|---|
|
||||||
|
| Frontend | Flutter Web |
|
||||||
|
| Backend | Express.js + TypeScript |
|
||||||
|
| Base de données | SQL Server 2022 |
|
||||||
|
| ORM | Prisma |
|
||||||
|
| Conteneurs | Docker Compose |
|
||||||
|
| Interface BDD | Adminer |
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
eme_app/
|
||||||
|
├── compose.yaml
|
||||||
|
├── CONTEXT.md
|
||||||
|
├── TODO.md
|
||||||
|
├── review.md
|
||||||
|
├── docs/
|
||||||
|
├── eme-backend/
|
||||||
|
└── eme-frontend/
|
||||||
|
```
|
||||||
|
|
||||||
|
Documents utiles :
|
||||||
|
|
||||||
|
- `CONTEXT.md` : contexte projet, stack, acteurs, modèle et charte.
|
||||||
|
- `TODO.md` : plan d'action et reste à faire.
|
||||||
|
- `review.md` : journal des décisions et état courant.
|
||||||
|
- `docs/regles-metier/regles-gestion.md` : règles métier validées.
|
||||||
|
|
||||||
|
## Prérequis
|
||||||
|
|
||||||
|
- Docker Desktop
|
||||||
|
- Node.js + npm
|
||||||
|
- Flutter SDK avec support Web
|
||||||
|
- Git
|
||||||
|
|
||||||
|
Vérifications rapides :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker --version
|
||||||
|
node --version
|
||||||
|
npm --version
|
||||||
|
flutter --version
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
Le fichier racine `.env` doit fournir le mot de passe SQL Server utilisé par `compose.yaml` :
|
||||||
|
|
||||||
|
```env
|
||||||
|
MSSQL_SA_PASSWORD=EmePass2026!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
Créer `eme-backend/.env` à partir de `eme-backend/.env.example`, puis vérifier que le mot de passe correspond à celui du `.env` racine.
|
||||||
|
|
||||||
|
Exemple de configuration locale :
|
||||||
|
|
||||||
|
```env
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=development
|
||||||
|
FRONTEND_URL=http://localhost:5000
|
||||||
|
|
||||||
|
DATABASE_URL="sqlserver://localhost:1433;database=eme_db;user=sa;password=EmePass2026!;encrypt=true;trustServerCertificate=true"
|
||||||
|
DB_SERVER=localhost
|
||||||
|
DB_PORT=1433
|
||||||
|
DB_NAME=eme_db
|
||||||
|
DB_USER=sa
|
||||||
|
DB_PASSWORD=EmePass2026!
|
||||||
|
|
||||||
|
AZURE_TENANT_ID=ton_tenant_id
|
||||||
|
AZURE_CLIENT_ID=ton_client_id
|
||||||
|
```
|
||||||
|
|
||||||
|
Azure AD n'est pas encore branché en V1 locale : l'authentification est simulée via `x-user-email`.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Depuis la racine :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd eme-backend
|
||||||
|
npm install
|
||||||
|
cd ../eme-frontend
|
||||||
|
flutter pub get
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lancement local
|
||||||
|
|
||||||
|
### 1. Démarrer SQL Server et Adminer
|
||||||
|
|
||||||
|
Depuis la racine :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Services :
|
||||||
|
|
||||||
|
- SQL Server : `localhost:1433`
|
||||||
|
- Adminer : `http://localhost:8081`
|
||||||
|
|
||||||
|
### 2. Préparer la base
|
||||||
|
|
||||||
|
Depuis `eme-backend` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run prisma:generate
|
||||||
|
npx prisma migrate deploy
|
||||||
|
npm run seed
|
||||||
|
npm run fixtures
|
||||||
|
```
|
||||||
|
|
||||||
|
Les fixtures ajoutent des utilisateurs, du matériel, des emprunts et des anomalies de démonstration.
|
||||||
|
|
||||||
|
### 3. Démarrer le backend
|
||||||
|
|
||||||
|
Depuis `eme-backend` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
API :
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Contrôle rapide :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Démarrer le frontend
|
||||||
|
|
||||||
|
Depuis `eme-frontend` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter run -d web-server --web-port 5000
|
||||||
|
```
|
||||||
|
|
||||||
|
Application :
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parcours cible V1
|
||||||
|
|
||||||
|
1. Ouvrir `http://localhost:5000`.
|
||||||
|
2. S'identifier avec l'option mail ENSUP ou carte étudiante.
|
||||||
|
3. Cliquer sur `Emprunter`.
|
||||||
|
4. Sélectionner un matériel disponible.
|
||||||
|
5. Vérifier la checklist de départ préremplie avec l'état de référence.
|
||||||
|
6. Sans modification, l'emprunt démarre automatiquement.
|
||||||
|
7. Si un élément est modifié, le responsable est alerté et confirme ou refuse cet écart avant le départ.
|
||||||
|
8. Au retour, vérifier la checklist automatiquement préremplie avec l'état de départ validé.
|
||||||
|
9. Sans modification, la restitution est clôturée automatiquement.
|
||||||
|
10. Dès qu'un état est modifié, le responsable est alerté et doit confirmer le changement avant l'anomalie et l'état final du matériel.
|
||||||
|
11. La catégorie redevient empruntable après la clôture automatique ou la décision du responsable sur un retour modifié.
|
||||||
|
|
||||||
|
Le code actuel gère déjà les parcours automatiques. Il doit encore être adapté au préremplissage de la restitution, à la détection des modifications et à leur confirmation par le responsable ; les tâches sont détaillées dans `TODO.md`.
|
||||||
|
|
||||||
|
## Endpoints principaux
|
||||||
|
|
||||||
|
| Méthode | Route | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/health` | Santé API |
|
||||||
|
| `GET` | `/api/auth/me` | Profil utilisateur courant |
|
||||||
|
| `GET` | `/api/auth/carte/:qr` | Identification par QR code |
|
||||||
|
| `GET` | `/api/materiels` | Catalogue disponible |
|
||||||
|
| `GET` | `/api/materiels/:id` | Détail matériel |
|
||||||
|
| `POST` | `/api/emprunts` | Création d'un emprunt |
|
||||||
|
| `GET` | `/api/mes-emprunts` | Emprunts en cours de l'utilisateur |
|
||||||
|
| `POST` | `/api/emprunts/:id/restitution` | Restitution |
|
||||||
|
|
||||||
|
En local, les routes protégées utilisent l'en-tête :
|
||||||
|
|
||||||
|
```http
|
||||||
|
x-user-email: lucas.martin@ensitech.eu
|
||||||
|
```
|
||||||
|
|
||||||
|
## Vérifications
|
||||||
|
|
||||||
|
Backend :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd eme-backend
|
||||||
|
npm run build
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd eme-frontend
|
||||||
|
flutter analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
Note : dans l'environnement Codex, `flutter analyze` et `dart format` ont déjà bloqué au timeout. Les relancer dans un terminal local Flutter si nécessaire.
|
||||||
|
|
||||||
|
## État V1
|
||||||
|
|
||||||
|
Déjà implémenté :
|
||||||
|
|
||||||
|
- catalogue et détail matériel ;
|
||||||
|
- checklists de départ et de retour ;
|
||||||
|
- espaces étudiant et responsable ;
|
||||||
|
- supervision, notifications, anomalies et historique ;
|
||||||
|
- SSO Microsoft Entra ID prêt à configurer, avec mode démo sécurisé ;
|
||||||
|
- fixtures de démonstration.
|
||||||
|
|
||||||
|
Reste à faire :
|
||||||
|
|
||||||
|
- préremplir la restitution avec l'état validé au départ ;
|
||||||
|
- bloquer les emprunts de même catégorie jusqu'à la clôture du retour ;
|
||||||
|
- mettre en attente uniquement les checklists modifiées et alerter le responsable ;
|
||||||
|
- ajouter les écrans responsable de confirmation des écarts ;
|
||||||
|
- valider le SSO avec les identifiants réels du tenant ENSUP ;
|
||||||
|
- OpenAPI / Swagger ;
|
||||||
|
- tests automatisés ;
|
||||||
|
- documentation utilisateur complète ;
|
||||||
|
- déploiement production.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Parcours utilisateur validé
|
||||||
|
|
||||||
|
Ce diagramme remplace le PNG historique `parcour_utilisateur.png`, qui décrit
|
||||||
|
partiellement l'ancien workflow automatique.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[Identification étudiant] --> B{Action}
|
||||||
|
|
||||||
|
B -->|Emprunter| C[Sélection du matériel]
|
||||||
|
C --> D{Même catégorie déjà bloquée ?}
|
||||||
|
D -->|Oui| E[Demande refusée]
|
||||||
|
D -->|Non| F[Checklist de départ<br/>préremplie avec l'état de référence]
|
||||||
|
F --> G{État modifié ?}
|
||||||
|
G -->|Non| H[Matériel EMPRUNTE<br/>Emprunt EN_COURS]
|
||||||
|
G -->|Oui| I[Matériel RESERVE<br/>Emprunt EN_ATTENTE_VALIDATION_DEPART]
|
||||||
|
I --> J[Alerte responsable du campus]
|
||||||
|
J --> K{Confirmation de l'écart}
|
||||||
|
K -->|Confirmé| L[Anomalie créée<br/>État de départ authentifié]
|
||||||
|
K -->|Refusé| M[Modification non appliquée]
|
||||||
|
L --> H
|
||||||
|
M --> H
|
||||||
|
|
||||||
|
B -->|Restituer| N[Sélection d'un emprunt personnel]
|
||||||
|
N --> O[Checklist de retour préremplie<br/>avec l'état de départ validé]
|
||||||
|
O --> P{État modifié ?}
|
||||||
|
P -->|Non| Q[Emprunt CLOTURE<br/>Matériel DISPONIBLE]
|
||||||
|
P -->|Oui| R[Emprunt EN_ATTENTE_VALIDATION_RETOUR<br/>Alerte responsable]
|
||||||
|
R --> S{Confirmation du changement}
|
||||||
|
S -->|Confirmé| T[Anomalie créée<br/>RETOUR_NON_CONFORME et état final]
|
||||||
|
S -->|Refusé| Q
|
||||||
|
|
||||||
|
Q --> U[Catégorie débloquée]
|
||||||
|
T --> U
|
||||||
|
```
|
||||||
|
|
||||||
|
## Règles structurantes
|
||||||
|
|
||||||
|
- Le départ et le retour sont automatiques lorsque la checklist préremplie
|
||||||
|
reste inchangée.
|
||||||
|
- Au retour, l'état validé au départ est automatiquement repris.
|
||||||
|
- Dès que l'étudiant modifie une valeur, le responsable est alerté et doit
|
||||||
|
confirmer ou refuser le changement.
|
||||||
|
- Une anomalie et un changement d'état officiel ne sont appliqués qu'après
|
||||||
|
confirmation du responsable.
|
||||||
|
- Une catégorie reste bloquée pour l'étudiant tant que le retour précédent
|
||||||
|
n'a pas été clôturé.
|
||||||
|
- Il n'existe pas de limite d'une fois par jour après clôture du retour.
|
||||||
|
- Chaque tentative de modification, décision, anomalie et changement de
|
||||||
|
statut matériel est tracé.
|
||||||
@@ -96,9 +96,13 @@ classDiagram
|
|||||||
+ModeIdentification modeIdentificationRetour
|
+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
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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é.
|
||||||
|
|
||||||
**RG11 — Checklist de départ obligatoire**
|
**RG10A — Unicité 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.
|
|
||||||
|
|||||||
Binary file not shown.
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
prisma/migrations
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
import prettier from 'eslint-config-prettier';
|
||||||
|
import globals from 'globals';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['dist/**', 'node_modules/**', 'prisma/migrations/**'],
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
prettier,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.node },
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'error',
|
||||||
|
'@typescript-eslint/no-non-null-assertion': 'error',
|
||||||
|
'@typescript-eslint/explicit-module-boundary-types': 'error',
|
||||||
|
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||||
|
'prefer-const': 'error',
|
||||||
|
'no-console': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
Generated
+1134
-4
File diff suppressed because it is too large
Load Diff
@@ -4,28 +4,38 @@
|
|||||||
"description": "EME — Emprunt Matériel ENSUP — API REST",
|
"description": "EME — Emprunt Matériel ENSUP — API REST",
|
||||||
"main": "dist/app.js",
|
"main": "dist/app.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc -p tsconfig.build.json",
|
||||||
"start": "node dist/app.js",
|
"start": "node dist/app.js",
|
||||||
"dev": "nodemon --exec ts-node src/app.ts",
|
"dev": "nodemon --exec ts-node --files src/app.ts",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:studio": "prisma studio",
|
"prisma:studio": "prisma studio",
|
||||||
"seed": "ts-node prisma/seed.ts",
|
"seed": "ts-node prisma/seed.ts",
|
||||||
"fixtures": "ts-node prisma/fixtures.ts"
|
"fixtures": "ts-node prisma/fixtures.ts",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"prisma/**/*.ts\"",
|
||||||
|
"format:check": "prettier --check \"src/**/*.ts\" \"prisma/**/*.ts\""
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^25.9.1",
|
"@types/node": "^25.9.1",
|
||||||
|
"eslint": "^10.5.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"globals": "^17.6.0",
|
||||||
"nodemon": "^3.1.14",
|
"nodemon": "^3.1.14",
|
||||||
|
"prettier": "^3.8.4",
|
||||||
"prisma": "^7.8.0",
|
"prisma": "^7.8.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3",
|
||||||
|
"typescript-eslint": "^8.61.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-mssql": "^7.8.0",
|
"@prisma/adapter-mssql": "^7.8.0",
|
||||||
@@ -33,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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+239
-18
@@ -51,34 +51,195 @@ function daysFromNow(n: number): Date {
|
|||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
interface DemoCategories {
|
||||||
// Idempotence : on ne recharge pas si déjà présent.
|
ordinateur: number;
|
||||||
const witness = await prisma.utilisateur.findUnique({ where: { email: WITNESS_EMAIL } });
|
video: number;
|
||||||
if (witness) {
|
tablette: number;
|
||||||
console.log('[fixtures] deja chargees — rien a faire');
|
camera: number;
|
||||||
return;
|
audio: number;
|
||||||
|
cable: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DemoMateriel {
|
||||||
|
nom: string;
|
||||||
|
marque: string;
|
||||||
|
modele: string;
|
||||||
|
reference: string;
|
||||||
|
numeroInventaire: string;
|
||||||
|
numeroSerie: string;
|
||||||
|
etatGeneral: string;
|
||||||
|
categorieId: number;
|
||||||
|
accessoires?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assurerAccessoire(nom: string, description: string): Promise<number> {
|
||||||
|
const existant = await prisma.accessoire.findFirst({ where: { nom } });
|
||||||
|
if (existant) {
|
||||||
|
return existant.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accessoire = await prisma.accessoire.create({ data: { nom, description } });
|
||||||
|
return accessoire.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assurerMaterielDemo(data: DemoMateriel, campusId: number): Promise<void> {
|
||||||
|
let materiel = await prisma.materiel.findFirst({ where: { reference: data.reference } });
|
||||||
|
|
||||||
|
if (!materiel) {
|
||||||
|
materiel = await prisma.materiel.create({
|
||||||
|
data: {
|
||||||
|
nom: data.nom,
|
||||||
|
marque: data.marque,
|
||||||
|
modele: data.modele,
|
||||||
|
reference: data.reference,
|
||||||
|
numeroInventaire: data.numeroInventaire,
|
||||||
|
numeroSerie: data.numeroSerie,
|
||||||
|
statut: 'DISPONIBLE',
|
||||||
|
etatGeneral: data.etatGeneral,
|
||||||
|
categorieId: data.categorieId,
|
||||||
|
campusId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const nomAccessoire of data.accessoires ?? []) {
|
||||||
|
const accessoireId = await assurerAccessoire(nomAccessoire, `Accessoire ${nomAccessoire}.`);
|
||||||
|
const liaison = await prisma.materielAccessoire.findFirst({
|
||||||
|
where: { materielId: materiel.id, accessoireId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!liaison) {
|
||||||
|
await prisma.materielAccessoire.create({
|
||||||
|
data: {
|
||||||
|
materielId: materiel.id,
|
||||||
|
accessoireId,
|
||||||
|
quantiteAttendue: 1,
|
||||||
|
obligatoire: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assurerCatalogueDemo(campusId: number, categories: DemoCategories): Promise<void> {
|
||||||
|
const materiels: DemoMateriel[] = [
|
||||||
|
{
|
||||||
|
nom: 'PC Lenovo ThinkPad E14',
|
||||||
|
marque: 'Lenovo',
|
||||||
|
modele: 'ThinkPad E14',
|
||||||
|
reference: 'REF-PC-003',
|
||||||
|
numeroInventaire: 'INV-PC-003',
|
||||||
|
numeroSerie: 'SN-LENOVO-003',
|
||||||
|
etatGeneral: 'Bon etat',
|
||||||
|
categorieId: categories.ordinateur,
|
||||||
|
accessoires: ['Chargeur', 'Souris', 'Housse'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nom: 'MacBook Air M2',
|
||||||
|
marque: 'Apple',
|
||||||
|
modele: 'MacBook Air 13',
|
||||||
|
reference: 'REF-PC-004',
|
||||||
|
numeroInventaire: 'INV-PC-004',
|
||||||
|
numeroSerie: 'SN-MBA-004',
|
||||||
|
etatGeneral: 'Tres bon etat',
|
||||||
|
categorieId: categories.ordinateur,
|
||||||
|
accessoires: ['Chargeur USB-C', 'Housse'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nom: 'Microsoft Surface Pro',
|
||||||
|
marque: 'Microsoft',
|
||||||
|
modele: 'Surface Pro 9',
|
||||||
|
reference: 'REF-TAB-002',
|
||||||
|
numeroInventaire: 'INV-TAB-002',
|
||||||
|
numeroSerie: 'SN-SURFACE-002',
|
||||||
|
etatGeneral: 'Bon etat',
|
||||||
|
categorieId: categories.tablette,
|
||||||
|
accessoires: ['Clavier Surface', 'Stylet'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nom: 'Vidéoprojecteur Epson EB-X49',
|
||||||
|
marque: 'Epson',
|
||||||
|
modele: 'EB-X49',
|
||||||
|
reference: 'REF-VP-002',
|
||||||
|
numeroInventaire: 'INV-VP-002',
|
||||||
|
numeroSerie: 'SN-EPSON-002',
|
||||||
|
etatGeneral: 'Bon etat',
|
||||||
|
categorieId: categories.video,
|
||||||
|
accessoires: ['Telecommande', 'Cable HDMI'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nom: 'Caméra Canon EOS M50',
|
||||||
|
marque: 'Canon',
|
||||||
|
modele: 'EOS M50',
|
||||||
|
reference: 'REF-CAM-002',
|
||||||
|
numeroInventaire: 'INV-CAM-002',
|
||||||
|
numeroSerie: 'SN-CANON-002',
|
||||||
|
etatGeneral: 'Bon etat',
|
||||||
|
categorieId: categories.camera,
|
||||||
|
accessoires: ['Batterie', 'Carte SD', 'Housse'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nom: 'Casque Jabra Evolve',
|
||||||
|
marque: 'Jabra',
|
||||||
|
modele: 'Evolve 40',
|
||||||
|
reference: 'REF-AUD-002',
|
||||||
|
numeroInventaire: 'INV-AUD-002',
|
||||||
|
numeroSerie: 'SN-JABRA-002',
|
||||||
|
etatGeneral: 'Bon etat',
|
||||||
|
categorieId: categories.audio,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nom: 'Kit câbles HDMI / USB-C',
|
||||||
|
marque: 'ENSUP',
|
||||||
|
modele: 'Kit connectique',
|
||||||
|
reference: 'REF-CAB-001',
|
||||||
|
numeroInventaire: 'INV-CAB-001',
|
||||||
|
numeroSerie: 'SN-CABLE-001',
|
||||||
|
etatGeneral: 'Bon etat',
|
||||||
|
categorieId: categories.cable,
|
||||||
|
accessoires: ['Cable HDMI', 'Adaptateur USB-C'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nom: 'Enceinte Bluetooth JBL',
|
||||||
|
marque: 'JBL',
|
||||||
|
modele: 'Flip 6',
|
||||||
|
reference: 'REF-AUD-003',
|
||||||
|
numeroInventaire: 'INV-AUD-003',
|
||||||
|
numeroSerie: 'SN-JBL-003',
|
||||||
|
etatGeneral: 'Bon etat',
|
||||||
|
categorieId: categories.audio,
|
||||||
|
accessoires: ['Cable USB-C'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const materiel of materiels) {
|
||||||
|
await assurerMaterielDemo(materiel, campusId);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[fixtures] ${materiels.length} materiels de catalogue assures`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
// Références (doivent exister via le seed).
|
// Références (doivent exister via le seed).
|
||||||
const roleEtudiant = required(
|
const roleEtudiant = required(
|
||||||
await prisma.role.findUnique({ where: { code: 'ETUDIANT' } }),
|
await prisma.role.findUnique({ where: { code: 'ETUDIANT' } }),
|
||||||
'Role ETUDIANT manquant — lance d\'abord `npm run seed`',
|
"Role ETUDIANT manquant — lance d'abord `npm run seed`",
|
||||||
);
|
);
|
||||||
const roleResponsable = required(
|
const roleResponsable = required(
|
||||||
await prisma.role.findUnique({ where: { code: 'RESPONSABLE' } }),
|
await prisma.role.findUnique({ where: { code: 'RESPONSABLE' } }),
|
||||||
'Role RESPONSABLE manquant — lance d\'abord `npm run seed`',
|
"Role RESPONSABLE manquant — lance d'abord `npm run seed`",
|
||||||
);
|
);
|
||||||
const campus = required(
|
const campus = required(
|
||||||
await prisma.campus.findFirst({ where: { nom: 'Campus Saint-Christophe' } }),
|
await prisma.campus.findFirst({ where: { nom: 'Campus Saint-Christophe' } }),
|
||||||
'Campus manquant — lance d\'abord `npm run seed`',
|
"Campus manquant — lance d'abord `npm run seed`",
|
||||||
);
|
);
|
||||||
const salle = required(
|
const salle = required(
|
||||||
await prisma.sallePret.findFirst({ where: { campusId: campus.id } }),
|
await prisma.sallePret.findFirst({ where: { campusId: campus.id } }),
|
||||||
'Salle de pret manquante — lance d\'abord `npm run seed`',
|
"Salle de pret manquante — lance d'abord `npm run seed`",
|
||||||
);
|
);
|
||||||
const poste = required(
|
const poste = required(
|
||||||
await prisma.posteEmprunt.findFirst({ where: { sallePretId: salle.id } }),
|
await prisma.posteEmprunt.findFirst({ where: { sallePretId: salle.id } }),
|
||||||
'Poste manquant — lance d\'abord `npm run seed`',
|
"Poste manquant — lance d'abord `npm run seed`",
|
||||||
);
|
);
|
||||||
const catOrdi = required(
|
const catOrdi = required(
|
||||||
await prisma.categorieMateriel.findFirst({ where: { nom: 'Ordinateur portable' } }),
|
await prisma.categorieMateriel.findFirst({ where: { nom: 'Ordinateur portable' } }),
|
||||||
@@ -100,6 +261,28 @@ async function main(): Promise<void> {
|
|||||||
await prisma.categorieMateriel.findFirst({ where: { nom: 'Périphérique audio' } }),
|
await prisma.categorieMateriel.findFirst({ where: { nom: 'Périphérique audio' } }),
|
||||||
'Categorie Périphérique audio manquante',
|
'Categorie Périphérique audio manquante',
|
||||||
);
|
);
|
||||||
|
const catCable = required(
|
||||||
|
await prisma.categorieMateriel.findFirst({ where: { nom: 'Câble / Adaptateur' } }),
|
||||||
|
'Categorie Câble / Adaptateur manquante',
|
||||||
|
);
|
||||||
|
|
||||||
|
const demoCategories: DemoCategories = {
|
||||||
|
ordinateur: catOrdi.id,
|
||||||
|
video: catVideo.id,
|
||||||
|
tablette: catTablette.id,
|
||||||
|
camera: catCamera.id,
|
||||||
|
audio: catAudio.id,
|
||||||
|
cable: catCable.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Idempotence : si les fixtures de base existent déjà, on assure seulement
|
||||||
|
// les matériels de catalogue ajoutés après coup.
|
||||||
|
const witness = await prisma.utilisateur.findUnique({ where: { email: WITNESS_EMAIL } });
|
||||||
|
if (witness) {
|
||||||
|
await assurerCatalogueDemo(campus.id, demoCategories);
|
||||||
|
console.log('[fixtures] deja chargees — catalogue demo assure');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Utilisateurs : 3 étudiants (2 info @ensitech.eu, 1 autre @ensup.eu) + 1 responsable.
|
// Utilisateurs : 3 étudiants (2 info @ensitech.eu, 1 autre @ensup.eu) + 1 responsable.
|
||||||
const marie = await prisma.utilisateur.create({
|
const marie = await prisma.utilisateur.create({
|
||||||
@@ -280,6 +463,7 @@ async function main(): Promise<void> {
|
|||||||
{ materielId: camera.id, accessoireId: chargeur.id, quantiteAttendue: 1, obligatoire: true },
|
{ materielId: camera.id, accessoireId: chargeur.id, quantiteAttendue: 1, obligatoire: true },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
await assurerCatalogueDemo(campus.id, demoCategories);
|
||||||
|
|
||||||
// Emprunt 1 — EN_COURS (Marie / PC Dell) + checklist de départ.
|
// Emprunt 1 — EN_COURS (Marie / PC Dell) + checklist de départ.
|
||||||
const empruntEnCours = await prisma.emprunt.create({
|
const empruntEnCours = await prisma.emprunt.create({
|
||||||
@@ -305,8 +489,20 @@ async function main(): Promise<void> {
|
|||||||
});
|
});
|
||||||
await prisma.checklistElement.createMany({
|
await prisma.checklistElement.createMany({
|
||||||
data: [
|
data: [
|
||||||
{ checklistId: clDepart1.id, accessoireId: chargeur.id, nomElement: 'Chargeur', etat: 'PRESENT', quantiteConstatee: 1 },
|
{
|
||||||
{ checklistId: clDepart1.id, accessoireId: souris.id, nomElement: 'Souris', etat: 'PRESENT', quantiteConstatee: 1 },
|
checklistId: clDepart1.id,
|
||||||
|
accessoireId: chargeur.id,
|
||||||
|
nomElement: 'Chargeur',
|
||||||
|
etat: 'PRESENT',
|
||||||
|
quantiteConstatee: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
checklistId: clDepart1.id,
|
||||||
|
accessoireId: souris.id,
|
||||||
|
nomElement: 'Souris',
|
||||||
|
etat: 'PRESENT',
|
||||||
|
quantiteConstatee: 1,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -393,8 +589,20 @@ async function main(): Promise<void> {
|
|||||||
});
|
});
|
||||||
await prisma.checklistElement.createMany({
|
await prisma.checklistElement.createMany({
|
||||||
data: [
|
data: [
|
||||||
{ checklistId: clDepart4.id, accessoireId: housse.id, nomElement: 'Housse', etat: 'PRESENT', quantiteConstatee: 1 },
|
{
|
||||||
{ checklistId: clDepart4.id, accessoireId: chargeur.id, nomElement: 'Chargeur', etat: 'PRESENT', quantiteConstatee: 1 },
|
checklistId: clDepart4.id,
|
||||||
|
accessoireId: housse.id,
|
||||||
|
nomElement: 'Housse',
|
||||||
|
etat: 'PRESENT',
|
||||||
|
quantiteConstatee: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
checklistId: clDepart4.id,
|
||||||
|
accessoireId: chargeur.id,
|
||||||
|
nomElement: 'Chargeur',
|
||||||
|
etat: 'PRESENT',
|
||||||
|
quantiteConstatee: 1,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const clRetour4 = await prisma.checklist.create({
|
const clRetour4 = await prisma.checklist.create({
|
||||||
@@ -407,8 +615,21 @@ async function main(): Promise<void> {
|
|||||||
});
|
});
|
||||||
await prisma.checklistElement.createMany({
|
await prisma.checklistElement.createMany({
|
||||||
data: [
|
data: [
|
||||||
{ checklistId: clRetour4.id, accessoireId: housse.id, nomElement: 'Housse', etat: 'ABSENT', quantiteConstatee: 0, commentaire: 'Manquante au retour' },
|
{
|
||||||
{ checklistId: clRetour4.id, accessoireId: chargeur.id, nomElement: 'Chargeur', etat: 'PRESENT', quantiteConstatee: 1 },
|
checklistId: clRetour4.id,
|
||||||
|
accessoireId: housse.id,
|
||||||
|
nomElement: 'Housse',
|
||||||
|
etat: 'ABSENT',
|
||||||
|
quantiteConstatee: 0,
|
||||||
|
commentaire: 'Manquante au retour',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
checklistId: clRetour4.id,
|
||||||
|
accessoireId: chargeur.id,
|
||||||
|
nomElement: 'Chargeur',
|
||||||
|
etat: 'PRESENT',
|
||||||
|
quantiteConstatee: 1,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
console.log('[fixtures] 4 emprunts + checklists crees');
|
console.log('[fixtures] 4 emprunts + checklists crees');
|
||||||
@@ -464,7 +685,7 @@ async function main(): Promise<void> {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
console.log('[fixtures] 2 entrees d\'historique creees');
|
console.log("[fixtures] 2 entrees d'historique creees");
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import { logger } from './utils/logger';
|
|||||||
import { requestLogger } from './middlewares/request-logger';
|
import { requestLogger } from './middlewares/request-logger';
|
||||||
import { notFoundHandler } from './middlewares/not-found';
|
import { notFoundHandler } from './middlewares/not-found';
|
||||||
import { errorHandler } from './middlewares/error-handler';
|
import { errorHandler } from './middlewares/error-handler';
|
||||||
|
import { apiRouter } from './routes';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(cors({ origin: env.frontendUrl }));
|
// En développement, on accepte toute origine locale (le front Flutter tourne sur
|
||||||
|
// un port variable) ; en production, seule l'origine du frontend est autorisée.
|
||||||
|
app.use(cors({ origin: env.nodeEnv === 'development' ? true : env.frontendUrl }));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(requestLogger);
|
app.use(requestLogger);
|
||||||
|
|
||||||
@@ -17,6 +20,8 @@ app.get('/health', (_req, res) => {
|
|||||||
res.json({ status: 'ok', app: 'EME API' });
|
res.json({ status: 'ok', app: 'EME API' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.use('/api', apiRouter);
|
||||||
|
|
||||||
/* notFoundHandler et errorHandler doivent rester après toutes les routes :
|
/* notFoundHandler et errorHandler doivent rester après toutes les routes :
|
||||||
le premier capture les URL non gérées, le second clôt la chaîne Express. */
|
le premier capture les URL non gérées, le second clôt la chaîne Express. */
|
||||||
app.use(notFoundHandler);
|
app.use(notFoundHandler);
|
||||||
|
|||||||
@@ -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 d’environnement 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',
|
||||||
|
]),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { AppError } from '../errors/app-error';
|
||||||
|
import { getProfil, identifierParCarte } from '../services/utilisateur.service';
|
||||||
|
import { toUtilisateurResponse } from '../dtos/utilisateur.dto';
|
||||||
|
|
||||||
|
export async function getMe(req: Request, res: Response): Promise<void> {
|
||||||
|
const user = req.user;
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(401, 'Authentification requise');
|
||||||
|
}
|
||||||
|
|
||||||
|
const profil = await getProfil(user.id);
|
||||||
|
res.json({ data: toUtilisateurResponse(profil) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function identifier(req: Request, res: Response): Promise<void> {
|
||||||
|
const qr = req.params.qr;
|
||||||
|
if (typeof qr !== 'string') {
|
||||||
|
throw new AppError(400, 'QR code invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
const profil = await identifierParCarte(qr);
|
||||||
|
res.json({ data: toUtilisateurResponse(profil) });
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { AppError } from '../errors/app-error';
|
||||||
|
import {
|
||||||
|
creerEmprunt,
|
||||||
|
listerMesEmpruntsEnCours,
|
||||||
|
restituerEmprunt,
|
||||||
|
signalerTentativeModificationRetour,
|
||||||
|
} from '../services/emprunt.service';
|
||||||
|
import {
|
||||||
|
parseCreerEmpruntRequest,
|
||||||
|
parseRestituerEmpruntRequest,
|
||||||
|
parseSignalerTentativeRetourRequest,
|
||||||
|
toEmpruntResponse,
|
||||||
|
} from '../dtos/emprunt.dto';
|
||||||
|
|
||||||
|
export async function getMesEmprunts(req: Request, res: Response): Promise<void> {
|
||||||
|
const user = req.user;
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(401, 'Authentification requise');
|
||||||
|
}
|
||||||
|
|
||||||
|
const emprunts = await listerMesEmpruntsEnCours(user.id);
|
||||||
|
res.json({ data: emprunts.map(toEmpruntResponse) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postEmprunt(req: Request, res: Response): Promise<void> {
|
||||||
|
const user = req.user;
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(401, 'Authentification requise');
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = parseCreerEmpruntRequest(req.body);
|
||||||
|
const emprunt = await creerEmprunt(user.id, user.campusId, request);
|
||||||
|
res.status(201).json({ data: toEmpruntResponse(emprunt) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postRestitution(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 = parseRestituerEmpruntRequest(req.body);
|
||||||
|
const emprunt = await restituerEmprunt(user.id, empruntId, request);
|
||||||
|
res.json({ data: toEmpruntResponse(emprunt) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postAlerteChangementRetour(req: Request, res: Response): Promise<void> {
|
||||||
|
const user = req.user;
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(401, 'Authentification requise');
|
||||||
|
}
|
||||||
|
|
||||||
|
const empruntId = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(empruntId) || empruntId <= 0) {
|
||||||
|
throw new AppError(400, 'Identifiant emprunt invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = parseSignalerTentativeRetourRequest(req.body);
|
||||||
|
const alerteCreee = await signalerTentativeModificationRetour(user.id, empruntId, request);
|
||||||
|
res.json({ data: { alerteCreee } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { AppError } from '../errors/app-error';
|
||||||
|
import { listerCatalogue, obtenirDetailMateriel } from '../services/materiel.service';
|
||||||
|
import { toMaterielDetailResponse, toMaterielResponse } from '../dtos/materiel.dto';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseId(value: unknown): number {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new AppError(400, 'id invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||||
|
throw new AppError(400, 'id invalide');
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCatalogue(req: Request, res: Response): Promise<void> {
|
||||||
|
const user = req.user;
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(401, 'Authentification requise');
|
||||||
|
}
|
||||||
|
|
||||||
|
const recherche = typeof req.query.q === 'string' ? req.query.q : undefined;
|
||||||
|
const categorieId = parseCategorieId(req.query.categorieId);
|
||||||
|
|
||||||
|
const materiels = await listerCatalogue(user.campusId, { categorieId, recherche });
|
||||||
|
res.json({ data: materiels.map(toMaterielResponse) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMaterielDetail(req: Request, res: Response): Promise<void> {
|
||||||
|
const user = req.user;
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(401, 'Authentification requise');
|
||||||
|
}
|
||||||
|
|
||||||
|
const materiel = await obtenirDetailMateriel(parseId(req.params.id), user.campusId);
|
||||||
|
res.json({ data: toMaterielDetailResponse(materiel) });
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { EmpruntAvecMateriel } from '../repositories/emprunt.repository';
|
||||||
|
import { MaterielResponse, toMaterielResponse } from './materiel.dto';
|
||||||
|
import { AppError } from '../errors/app-error';
|
||||||
|
import { ETAT_CHECKLIST_ELEMENT, MODE_IDENTIFICATION } from '../models/enums';
|
||||||
|
|
||||||
|
export interface EmpruntResponse {
|
||||||
|
id: number;
|
||||||
|
dateEmprunt: Date;
|
||||||
|
dateRetourPrevue: Date;
|
||||||
|
dateRetourReelle: Date | null;
|
||||||
|
statut: string;
|
||||||
|
materiel: MaterielResponse;
|
||||||
|
checklistDepart: ChecklistElementResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChecklistElementResponse {
|
||||||
|
accessoireId: number | null;
|
||||||
|
nomElement: string;
|
||||||
|
etat: string;
|
||||||
|
quantiteConstatee: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toEmpruntResponse(emprunt: EmpruntAvecMateriel): EmpruntResponse {
|
||||||
|
const checklistDepart = emprunt.checklists.find((checklist) => checklist.type === 'DEPART');
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: emprunt.id,
|
||||||
|
dateEmprunt: emprunt.dateEmprunt,
|
||||||
|
dateRetourPrevue: emprunt.dateRetourPrevue,
|
||||||
|
dateRetourReelle: emprunt.dateRetourReelle,
|
||||||
|
statut: emprunt.statut,
|
||||||
|
materiel: toMaterielResponse(emprunt.materiel),
|
||||||
|
checklistDepart:
|
||||||
|
checklistDepart?.elements.map((element) => ({
|
||||||
|
accessoireId: element.accessoireId,
|
||||||
|
nomElement: element.nomElement,
|
||||||
|
etat: element.etat,
|
||||||
|
quantiteConstatee: element.quantiteConstatee,
|
||||||
|
})) ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChecklistElementRequest {
|
||||||
|
accessoireId?: number;
|
||||||
|
nomElement: string;
|
||||||
|
etat: string;
|
||||||
|
quantiteConstatee: number;
|
||||||
|
commentaire?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreerEmpruntRequest {
|
||||||
|
materielId: number;
|
||||||
|
posteEmpruntId: number;
|
||||||
|
modeIdentification: string;
|
||||||
|
commentaireDepart?: string;
|
||||||
|
elements: ChecklistElementRequest[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function asObject(value: unknown, message: string): Record<string, unknown> {
|
||||||
|
if (typeof value !== 'object' || value === null) {
|
||||||
|
throw new AppError(400, message);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asPositiveInt(value: unknown, champ: string): number {
|
||||||
|
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
|
||||||
|
throw new AppError(400, `${champ} doit etre un entier positif`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asIntOrZero(value: unknown, champ: string): number {
|
||||||
|
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
||||||
|
throw new AppError(400, `${champ} doit etre un entier positif ou nul`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asEnum<T extends string>(value: unknown, autorisees: readonly T[], champ: string): T {
|
||||||
|
if (typeof value !== 'string' || !(autorisees as readonly string[]).includes(value)) {
|
||||||
|
throw new AppError(400, `${champ} invalide`);
|
||||||
|
}
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseElement(value: unknown, index: number): ChecklistElementRequest {
|
||||||
|
const obj = asObject(value, `elements[${index}] invalide`);
|
||||||
|
|
||||||
|
if (typeof obj.nomElement !== 'string' || obj.nomElement.trim() === '') {
|
||||||
|
throw new AppError(400, `elements[${index}].nomElement requis`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const element: ChecklistElementRequest = {
|
||||||
|
nomElement: obj.nomElement,
|
||||||
|
etat: asEnum(obj.etat, ETAT_CHECKLIST_ELEMENT, `elements[${index}].etat`),
|
||||||
|
quantiteConstatee: asIntOrZero(obj.quantiteConstatee, `elements[${index}].quantiteConstatee`),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (obj.accessoireId !== undefined) {
|
||||||
|
element.accessoireId = asPositiveInt(obj.accessoireId, `elements[${index}].accessoireId`);
|
||||||
|
}
|
||||||
|
if (obj.commentaire !== undefined) {
|
||||||
|
if (typeof obj.commentaire !== 'string') {
|
||||||
|
throw new AppError(400, `elements[${index}].commentaire invalide`);
|
||||||
|
}
|
||||||
|
element.commentaire = obj.commentaire;
|
||||||
|
}
|
||||||
|
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCreerEmpruntRequest(body: unknown): CreerEmpruntRequest {
|
||||||
|
const obj = asObject(body, 'Corps de requete invalide');
|
||||||
|
|
||||||
|
if (!Array.isArray(obj.elements) || obj.elements.length === 0) {
|
||||||
|
throw new AppError(400, 'La checklist de depart est obligatoire (RG11)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: CreerEmpruntRequest = {
|
||||||
|
materielId: asPositiveInt(obj.materielId, 'materielId'),
|
||||||
|
posteEmpruntId: asPositiveInt(obj.posteEmpruntId, 'posteEmpruntId'),
|
||||||
|
modeIdentification: asEnum(obj.modeIdentification, MODE_IDENTIFICATION, 'modeIdentification'),
|
||||||
|
elements: obj.elements.map((element, index) => parseElement(element, index)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (obj.commentaireDepart !== undefined) {
|
||||||
|
if (typeof obj.commentaireDepart !== 'string') {
|
||||||
|
throw new AppError(400, 'commentaireDepart invalide');
|
||||||
|
}
|
||||||
|
request.commentaireDepart = obj.commentaireDepart;
|
||||||
|
}
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestituerEmpruntRequest {
|
||||||
|
modeIdentification: string;
|
||||||
|
commentaireRetour?: string;
|
||||||
|
elements: ChecklistElementRequest[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignalerTentativeRetourRequest {
|
||||||
|
nomElement: string;
|
||||||
|
etatInitial: string;
|
||||||
|
etatDemande: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSignalerTentativeRetourRequest(body: unknown): SignalerTentativeRetourRequest {
|
||||||
|
const obj = asObject(body, 'Corps de requete invalide');
|
||||||
|
if (typeof obj.nomElement !== 'string' || obj.nomElement.trim() === '') {
|
||||||
|
throw new AppError(400, 'nomElement requis');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nomElement: obj.nomElement.trim(),
|
||||||
|
etatInitial: asEnum(obj.etatInitial, ETAT_CHECKLIST_ELEMENT, 'etatInitial'),
|
||||||
|
etatDemande: asEnum(obj.etatDemande, ETAT_CHECKLIST_ELEMENT, 'etatDemande'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRestituerEmpruntRequest(body: unknown): RestituerEmpruntRequest {
|
||||||
|
const obj = asObject(body, 'Corps de requete invalide');
|
||||||
|
|
||||||
|
if (!Array.isArray(obj.elements) || obj.elements.length === 0) {
|
||||||
|
throw new AppError(400, 'La checklist de retour est obligatoire (RG16)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: RestituerEmpruntRequest = {
|
||||||
|
modeIdentification: asEnum(obj.modeIdentification, MODE_IDENTIFICATION, 'modeIdentification'),
|
||||||
|
elements: obj.elements.map((element, index) => parseElement(element, index)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (obj.commentaireRetour !== undefined) {
|
||||||
|
if (typeof obj.commentaireRetour !== 'string') {
|
||||||
|
throw new AppError(400, 'commentaireRetour invalide');
|
||||||
|
}
|
||||||
|
request.commentaireRetour = obj.commentaireRetour;
|
||||||
|
}
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { MaterielAvecCategorie, MaterielDetail } from '../repositories/materiel.repository';
|
||||||
|
|
||||||
|
export interface CategorieResponse {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaterielResponse {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
marque: string;
|
||||||
|
modele: string;
|
||||||
|
reference: string;
|
||||||
|
statut: string;
|
||||||
|
etatGeneral: string;
|
||||||
|
categorie: CategorieResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaterielDetailResponse extends MaterielResponse {
|
||||||
|
campus: {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
ville: string;
|
||||||
|
};
|
||||||
|
accessoires: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toMaterielResponse(materiel: MaterielAvecCategorie): MaterielResponse {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toMaterielDetailResponse(materiel: MaterielDetail): MaterielDetailResponse {
|
||||||
|
return {
|
||||||
|
...toMaterielResponse(materiel),
|
||||||
|
campus: {
|
||||||
|
id: materiel.campus.id,
|
||||||
|
nom: materiel.campus.nom,
|
||||||
|
ville: materiel.campus.ville,
|
||||||
|
},
|
||||||
|
accessoires: materiel.accessoires.map((liaison) => liaison.accessoire.nom),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { UtilisateurProfil } from '../repositories/utilisateur.repository';
|
||||||
|
|
||||||
|
export interface RoleResponse {
|
||||||
|
code: string;
|
||||||
|
libelle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CampusResponse {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
ville: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UtilisateurResponse {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
prenom: string;
|
||||||
|
email: string;
|
||||||
|
classe: string | null;
|
||||||
|
role: RoleResponse;
|
||||||
|
campus: CampusResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toUtilisateurResponse(utilisateur: UtilisateurProfil): UtilisateurResponse {
|
||||||
|
return {
|
||||||
|
id: utilisateur.id,
|
||||||
|
nom: utilisateur.nom,
|
||||||
|
prenom: utilisateur.prenom,
|
||||||
|
email: utilisateur.email,
|
||||||
|
classe: utilisateur.classe,
|
||||||
|
role: {
|
||||||
|
code: utilisateur.role.code,
|
||||||
|
libelle: utilisateur.role.libelle,
|
||||||
|
},
|
||||||
|
campus: {
|
||||||
|
id: utilisateur.campus.id,
|
||||||
|
nom: utilisateur.campus.nom,
|
||||||
|
ville: utilisateur.campus.ville,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { authenticateUser } from '../services/authentication.service';
|
||||||
|
|
||||||
|
export async function currentUser(req: Request, _res: Response, next: NextFunction): Promise<void> {
|
||||||
|
req.user = await authenticateUser({
|
||||||
|
authorization: req.header('authorization'),
|
||||||
|
demoEmail: req.header('x-user-email'),
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
}
|
||||||
@@ -5,7 +5,12 @@ import { logger } from '../utils/logger';
|
|||||||
|
|
||||||
/* Le 4e argument est requis par Express pour identifier un gestionnaire d'erreurs,
|
/* Le 4e argument est requis par Express pour identifier un gestionnaire d'erreurs,
|
||||||
même s'il n'est pas utilisé. */
|
même s'il n'est pas utilisé. */
|
||||||
export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction): void {
|
export function errorHandler(
|
||||||
|
err: unknown,
|
||||||
|
_req: Request,
|
||||||
|
res: Response,
|
||||||
|
_next: NextFunction,
|
||||||
|
): void {
|
||||||
const isAppError = err instanceof AppError;
|
const isAppError = err instanceof AppError;
|
||||||
const statusCode = isAppError ? err.statusCode : 500;
|
const statusCode = isAppError ? err.statusCode : 500;
|
||||||
const message = isAppError ? err.message : 'Erreur interne du serveur';
|
const message = isAppError ? err.message : 'Erreur interne du serveur';
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -27,12 +30,7 @@ export const ETAT_CHECKLIST_ELEMENT = ['PRESENT', 'ABSENT', 'DETERIORE'] as cons
|
|||||||
|
|
||||||
export type EtatChecklistElement = (typeof ETAT_CHECKLIST_ELEMENT)[number];
|
export type EtatChecklistElement = (typeof ETAT_CHECKLIST_ELEMENT)[number];
|
||||||
|
|
||||||
export const STATUT_ANOMALIE = [
|
export const STATUT_ANOMALIE = ['DETECTEE', 'EN_COURS_TRAITEMENT', 'RESOLUE', 'CLOTUREE'] as const;
|
||||||
'DETECTEE',
|
|
||||||
'EN_COURS_TRAITEMENT',
|
|
||||||
'RESOLUE',
|
|
||||||
'CLOTUREE',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type StatutAnomalie = (typeof STATUT_ANOMALIE)[number];
|
export type StatutAnomalie = (typeof STATUT_ANOMALIE)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { prisma } from '../db/prisma';
|
||||||
|
|
||||||
|
export type CarteAvecUtilisateur = Prisma.CarteEtudianteGetPayload<{
|
||||||
|
include: { utilisateur: { include: { role: true; campus: true } } };
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export function findByQrCode(qrCode: string): Promise<CarteAvecUtilisateur | null> {
|
||||||
|
return prisma.carteEtudiante.findFirst({
|
||||||
|
where: { qrCode },
|
||||||
|
include: { utilisateur: { include: { role: true, campus: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { prisma } from '../db/prisma';
|
||||||
|
import { StatutEmprunt } from '../models/enums';
|
||||||
|
import { AppError } from '../errors/app-error';
|
||||||
|
|
||||||
|
export type EmpruntAvecMateriel = Prisma.EmpruntGetPayload<{
|
||||||
|
include: {
|
||||||
|
materiel: { include: { categorie: true } };
|
||||||
|
checklists: { include: { elements: true } };
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type EmpruntAvecChecklists = Prisma.EmpruntGetPayload<{
|
||||||
|
include: { checklists: { include: { elements: true } } };
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const STATUTS_EN_COURS: StatutEmprunt[] = ['EN_COURS', 'EN_RETARD'];
|
||||||
|
const STATUTS_BLOQUANTS: StatutEmprunt[] = [
|
||||||
|
'EN_ATTENTE_VALIDATION_DEPART',
|
||||||
|
'EN_COURS',
|
||||||
|
'EN_RETARD',
|
||||||
|
'EN_ATTENTE_VALIDATION_RETOUR',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function findEnCoursParUtilisateur(utilisateurId: number): Promise<EmpruntAvecMateriel[]> {
|
||||||
|
return prisma.emprunt.findMany({
|
||||||
|
where: {
|
||||||
|
utilisateurId,
|
||||||
|
statut: { in: STATUTS_EN_COURS },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
materiel: { include: { categorie: true } },
|
||||||
|
checklists: { include: { elements: true } },
|
||||||
|
},
|
||||||
|
orderBy: { dateRetourPrevue: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findByIdAvecChecklists(id: number): Promise<EmpruntAvecChecklists | null> {
|
||||||
|
return prisma.emprunt.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { checklists: { include: { elements: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignalerTentativeRetourData {
|
||||||
|
empruntId: number;
|
||||||
|
utilisateurId: number;
|
||||||
|
materielId: number;
|
||||||
|
campusId: number;
|
||||||
|
sallePretId: number;
|
||||||
|
posteEmpruntId: number;
|
||||||
|
responsablesIds: number[];
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signalerTentativeRetour(data: SignalerTentativeRetourData): Promise<boolean> {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const emprunt = await tx.emprunt.findFirst({
|
||||||
|
where: {
|
||||||
|
id: data.empruntId,
|
||||||
|
utilisateurId: data.utilisateurId,
|
||||||
|
statut: { in: STATUTS_EN_COURS },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!emprunt) {
|
||||||
|
throw new AppError(409, 'Emprunt non restituable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dejaSignalee = await tx.historique.findFirst({
|
||||||
|
where: {
|
||||||
|
empruntId: data.empruntId,
|
||||||
|
action: 'TENTATIVE_MODIFICATION_RETOUR',
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (dejaSignalee) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.responsablesIds.length > 0) {
|
||||||
|
await tx.notification.createMany({
|
||||||
|
data: data.responsablesIds.map((responsableId) => ({
|
||||||
|
utilisateurId: responsableId,
|
||||||
|
titre: 'Tentative de modification au retour',
|
||||||
|
message: data.description,
|
||||||
|
type: 'TENTATIVE_MODIFICATION_RETOUR',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.historique.create({
|
||||||
|
data: {
|
||||||
|
utilisateurId: data.utilisateurId,
|
||||||
|
empruntId: data.empruntId,
|
||||||
|
materielId: data.materielId,
|
||||||
|
campusId: data.campusId,
|
||||||
|
sallePretId: data.sallePretId,
|
||||||
|
posteEmpruntId: data.posteEmpruntId,
|
||||||
|
action: 'TENTATIVE_MODIFICATION_RETOUR',
|
||||||
|
description: data.description,
|
||||||
|
dateAction: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChecklistElementData {
|
||||||
|
accessoireId?: number;
|
||||||
|
nomElement: string;
|
||||||
|
etat: string;
|
||||||
|
quantiteConstatee: number;
|
||||||
|
commentaire?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreerEmpruntData {
|
||||||
|
utilisateurId: number;
|
||||||
|
materielId: number;
|
||||||
|
categorieId: number;
|
||||||
|
campusId: number;
|
||||||
|
sallePretId: number;
|
||||||
|
posteEmpruntId: number;
|
||||||
|
dateRetourPrevue: Date;
|
||||||
|
modeIdentification: string;
|
||||||
|
commentaireDepart?: string;
|
||||||
|
elements: ChecklistElementData[];
|
||||||
|
statutEmprunt: StatutEmprunt;
|
||||||
|
statutMateriel: string;
|
||||||
|
responsablesIds: number[];
|
||||||
|
descriptionEcart?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function creerEmpruntComplet(data: CreerEmpruntData): Promise<EmpruntAvecMateriel> {
|
||||||
|
return prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
const empruntBloquant = await tx.emprunt.findFirst({
|
||||||
|
where: {
|
||||||
|
utilisateurId: data.utilisateurId,
|
||||||
|
statut: { in: STATUTS_BLOQUANTS },
|
||||||
|
materiel: { categorieId: data.categorieId },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (empruntBloquant) {
|
||||||
|
throw new AppError(
|
||||||
|
409,
|
||||||
|
'Un materiel de cette categorie est deja emprunte ou en attente de confirmation',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reservation = await tx.materiel.updateMany({
|
||||||
|
where: { id: data.materielId, statut: 'DISPONIBLE' },
|
||||||
|
data: { statut: data.statutMateriel },
|
||||||
|
});
|
||||||
|
if (reservation.count === 0) {
|
||||||
|
throw new AppError(409, 'Materiel non disponible');
|
||||||
|
}
|
||||||
|
|
||||||
|
const maintenant = new Date();
|
||||||
|
const emprunt = await tx.emprunt.create({
|
||||||
|
data: {
|
||||||
|
utilisateurId: data.utilisateurId,
|
||||||
|
materielId: data.materielId,
|
||||||
|
campusId: data.campusId,
|
||||||
|
sallePretId: data.sallePretId,
|
||||||
|
posteEmpruntId: data.posteEmpruntId,
|
||||||
|
dateEmprunt: maintenant,
|
||||||
|
dateRetourPrevue: data.dateRetourPrevue,
|
||||||
|
statut: data.statutEmprunt,
|
||||||
|
modeIdentificationEmprunt: data.modeIdentification,
|
||||||
|
commentaireDepart: data.commentaireDepart ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.checklist.create({
|
||||||
|
data: {
|
||||||
|
empruntId: emprunt.id,
|
||||||
|
utilisateurId: data.utilisateurId,
|
||||||
|
type: 'DEPART',
|
||||||
|
dateVerification: maintenant,
|
||||||
|
elements: {
|
||||||
|
create: data.elements.map((element) => ({
|
||||||
|
accessoireId: element.accessoireId ?? null,
|
||||||
|
nomElement: element.nomElement,
|
||||||
|
etat: element.etat,
|
||||||
|
quantiteConstatee: element.quantiteConstatee,
|
||||||
|
commentaire: element.commentaire ?? null,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.descriptionEcart) {
|
||||||
|
if (data.responsablesIds.length > 0) {
|
||||||
|
await tx.notification.createMany({
|
||||||
|
data: data.responsablesIds.map((responsableId) => ({
|
||||||
|
utilisateurId: responsableId,
|
||||||
|
titre: 'Ecart signale au depart',
|
||||||
|
message: data.descriptionEcart as string,
|
||||||
|
type: 'ECART_DEPART',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tx.historique.create({
|
||||||
|
data: {
|
||||||
|
utilisateurId: data.utilisateurId,
|
||||||
|
empruntId: emprunt.id,
|
||||||
|
materielId: data.materielId,
|
||||||
|
campusId: data.campusId,
|
||||||
|
sallePretId: data.sallePretId,
|
||||||
|
posteEmpruntId: data.posteEmpruntId,
|
||||||
|
action: 'ECART_DEPART_SIGNALE',
|
||||||
|
description: data.descriptionEcart,
|
||||||
|
dateAction: maintenant,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await tx.historique.create({
|
||||||
|
data: {
|
||||||
|
utilisateurId: data.utilisateurId,
|
||||||
|
empruntId: emprunt.id,
|
||||||
|
materielId: data.materielId,
|
||||||
|
campusId: data.campusId,
|
||||||
|
sallePretId: data.sallePretId,
|
||||||
|
posteEmpruntId: data.posteEmpruntId,
|
||||||
|
action: 'EMPRUNT_AUTOMATIQUE',
|
||||||
|
description: `Emprunt #${emprunt.id} active avec une checklist de depart inchangee`,
|
||||||
|
dateAction: maintenant,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.emprunt.findUniqueOrThrow({
|
||||||
|
where: { id: emprunt.id },
|
||||||
|
include: {
|
||||||
|
materiel: { include: { categorie: true } },
|
||||||
|
checklists: { include: { elements: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestituerEmpruntData {
|
||||||
|
empruntId: number;
|
||||||
|
utilisateurId: number;
|
||||||
|
materielId: number;
|
||||||
|
statutEmprunt: string;
|
||||||
|
statutMateriel: string;
|
||||||
|
modeIdentification: string;
|
||||||
|
commentaireRetour?: string;
|
||||||
|
elements: ChecklistElementData[];
|
||||||
|
campusId: number;
|
||||||
|
sallePretId: number;
|
||||||
|
posteEmpruntId: number;
|
||||||
|
responsablesIds: number[];
|
||||||
|
descriptionEcart?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restituerEmpruntComplet(data: RestituerEmpruntData): Promise<EmpruntAvecMateriel> {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const maintenant = new Date();
|
||||||
|
|
||||||
|
const transition = await tx.emprunt.updateMany({
|
||||||
|
where: {
|
||||||
|
id: data.empruntId,
|
||||||
|
utilisateurId: data.utilisateurId,
|
||||||
|
statut: { in: STATUTS_EN_COURS },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
statut: data.statutEmprunt,
|
||||||
|
...(data.statutEmprunt === 'CLOTURE' ? { dateRetourReelle: maintenant } : {}),
|
||||||
|
modeIdentificationRetour: data.modeIdentification,
|
||||||
|
commentaireRetour: data.commentaireRetour ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (transition.count === 0) {
|
||||||
|
throw new AppError(409, 'Emprunt non restituable');
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.checklist.create({
|
||||||
|
data: {
|
||||||
|
empruntId: data.empruntId,
|
||||||
|
utilisateurId: data.utilisateurId,
|
||||||
|
type: 'RETOUR',
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.materiel.update({
|
||||||
|
where: { id: data.materielId },
|
||||||
|
data: { statut: data.statutMateriel },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.descriptionEcart) {
|
||||||
|
const tentativeDejaSignalee = await tx.historique.findFirst({
|
||||||
|
where: {
|
||||||
|
empruntId: data.empruntId,
|
||||||
|
action: 'TENTATIVE_MODIFICATION_RETOUR',
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!tentativeDejaSignalee && data.responsablesIds.length > 0) {
|
||||||
|
await tx.notification.createMany({
|
||||||
|
data: data.responsablesIds.map((responsableId) => ({
|
||||||
|
utilisateurId: responsableId,
|
||||||
|
titre: 'Changement signale au retour',
|
||||||
|
message: data.descriptionEcart as string,
|
||||||
|
type: 'ECART_RETOUR',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tx.historique.create({
|
||||||
|
data: {
|
||||||
|
utilisateurId: data.utilisateurId,
|
||||||
|
empruntId: data.empruntId,
|
||||||
|
materielId: data.materielId,
|
||||||
|
campusId: data.campusId,
|
||||||
|
sallePretId: data.sallePretId,
|
||||||
|
posteEmpruntId: data.posteEmpruntId,
|
||||||
|
action: 'ECART_RETOUR_SIGNALE',
|
||||||
|
description: data.descriptionEcart,
|
||||||
|
dateAction: maintenant,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await tx.historique.create({
|
||||||
|
data: {
|
||||||
|
utilisateurId: data.utilisateurId,
|
||||||
|
empruntId: data.empruntId,
|
||||||
|
materielId: data.materielId,
|
||||||
|
campusId: data.campusId,
|
||||||
|
sallePretId: data.sallePretId,
|
||||||
|
posteEmpruntId: data.posteEmpruntId,
|
||||||
|
action: 'RESTITUTION_AUTOMATIQUE',
|
||||||
|
description: `Emprunt #${data.empruntId} cloture avec une checklist de retour inchangee`,
|
||||||
|
dateAction: maintenant,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.emprunt.findUniqueOrThrow({
|
||||||
|
where: { id: data.empruntId },
|
||||||
|
include: {
|
||||||
|
materiel: { include: { categorie: true } },
|
||||||
|
checklists: { include: { elements: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Materiel, Prisma } from '@prisma/client';
|
||||||
|
import { prisma } from '../db/prisma';
|
||||||
|
|
||||||
|
export type MaterielAvecCategorie = Prisma.MaterielGetPayload<{ include: { categorie: true } }>;
|
||||||
|
export type MaterielDetail = Prisma.MaterielGetPayload<{
|
||||||
|
include: {
|
||||||
|
categorie: true;
|
||||||
|
campus: true;
|
||||||
|
accessoires: { include: { accessoire: true } };
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export interface CatalogueFiltres {
|
||||||
|
categorieId?: number;
|
||||||
|
recherche?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findDisponiblesParCampus(
|
||||||
|
campusId: number,
|
||||||
|
filtres: CatalogueFiltres,
|
||||||
|
): Promise<MaterielAvecCategorie[]> {
|
||||||
|
const { categorieId, recherche } = filtres;
|
||||||
|
|
||||||
|
return prisma.materiel.findMany({
|
||||||
|
where: {
|
||||||
|
campusId,
|
||||||
|
actif: true,
|
||||||
|
statut: 'DISPONIBLE',
|
||||||
|
...(categorieId !== undefined ? { categorieId } : {}),
|
||||||
|
...(recherche
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ nom: { contains: recherche } },
|
||||||
|
{ marque: { contains: recherche } },
|
||||||
|
{ modele: { contains: recherche } },
|
||||||
|
{ reference: { contains: recherche } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
include: { categorie: true },
|
||||||
|
orderBy: { nom: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findById(id: number): Promise<Materiel | null> {
|
||||||
|
return prisma.materiel.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findDetailParCampus(id: number, campusId: number): Promise<MaterielDetail | null> {
|
||||||
|
return prisma.materiel.findFirst({
|
||||||
|
where: { id, campusId, actif: true },
|
||||||
|
include: {
|
||||||
|
categorie: true,
|
||||||
|
campus: true,
|
||||||
|
accessoires: {
|
||||||
|
include: { accessoire: true },
|
||||||
|
orderBy: { accessoire: { nom: 'asc' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { prisma } from '../db/prisma';
|
||||||
|
|
||||||
|
export type PosteAvecSalle = Prisma.PosteEmpruntGetPayload<{ include: { sallePret: true } }>;
|
||||||
|
|
||||||
|
export function findById(id: number): Promise<PosteAvecSalle | null> {
|
||||||
|
return prisma.posteEmprunt.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { sallePret: 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { prisma } from '../db/prisma';
|
||||||
|
|
||||||
|
export type UtilisateurAvecRole = Prisma.UtilisateurGetPayload<{ include: { role: true } }>;
|
||||||
|
|
||||||
|
export type UtilisateurProfil = Prisma.UtilisateurGetPayload<{
|
||||||
|
include: { role: true; campus: true };
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export function findByEmail(email: string): Promise<UtilisateurAvecRole | null> {
|
||||||
|
return prisma.utilisateur.findUnique({
|
||||||
|
where: { email },
|
||||||
|
include: { role: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
return prisma.utilisateur.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { role: true, campus: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findResponsablesParCampus(campusId: number): Promise<{ id: number }[]> {
|
||||||
|
return prisma.utilisateur.findMany({
|
||||||
|
where: { campusId, actif: true, role: { code: 'RESPONSABLE' } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { currentUser } from '../middlewares/current-user';
|
||||||
|
import { getMe, identifier } from '../controllers/auth.controller';
|
||||||
|
|
||||||
|
export const authRoutes: Router = Router();
|
||||||
|
|
||||||
|
// Identification par QR code : point d'entrée du parcours, donc public.
|
||||||
|
authRoutes.get('/carte/:qr', identifier);
|
||||||
|
|
||||||
|
// Profil de l'utilisateur déjà identifié : protégé.
|
||||||
|
authRoutes.get('/me', currentUser, getMe);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import {
|
||||||
|
getMesEmprunts,
|
||||||
|
postAlerteChangementRetour,
|
||||||
|
postEmprunt,
|
||||||
|
postRestitution,
|
||||||
|
} from '../controllers/emprunt.controller';
|
||||||
|
|
||||||
|
export const empruntRoutes: Router = Router();
|
||||||
|
empruntRoutes.post('/', postEmprunt);
|
||||||
|
empruntRoutes.post('/:id/alerte-changement-retour', postAlerteChangementRetour);
|
||||||
|
empruntRoutes.post('/:id/restitution', postRestitution);
|
||||||
|
|
||||||
|
export const mesEmpruntsRoutes: Router = Router();
|
||||||
|
mesEmpruntsRoutes.get('/', getMesEmprunts);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { currentUser } from '../middlewares/current-user';
|
||||||
|
import { authRoutes } from './auth.routes';
|
||||||
|
import { materielRoutes } from './materiel.routes';
|
||||||
|
import { empruntRoutes, mesEmpruntsRoutes } from './emprunt.routes';
|
||||||
|
import { responsableRoutes } from './responsable.routes';
|
||||||
|
|
||||||
|
export const apiRouter: Router = Router();
|
||||||
|
|
||||||
|
// /auth gère lui-même ses routes publiques/protégées.
|
||||||
|
apiRouter.use('/auth', authRoutes);
|
||||||
|
|
||||||
|
// Le reste exige un utilisateur identifié.
|
||||||
|
apiRouter.use('/materiels', currentUser, materielRoutes);
|
||||||
|
apiRouter.use('/emprunts', currentUser, empruntRoutes);
|
||||||
|
apiRouter.use('/mes-emprunts', currentUser, mesEmpruntsRoutes);
|
||||||
|
apiRouter.use('/responsable', currentUser, responsableRoutes);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { getCatalogue, getMaterielDetail } from '../controllers/materiel.controller';
|
||||||
|
|
||||||
|
export const materielRoutes: Router = Router();
|
||||||
|
|
||||||
|
materielRoutes.get('/', getCatalogue);
|
||||||
|
materielRoutes.get('/:id', getMaterielDetail);
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import { AppError } from '../errors/app-error';
|
||||||
|
import {
|
||||||
|
CreerEmpruntRequest,
|
||||||
|
RestituerEmpruntRequest,
|
||||||
|
ChecklistElementRequest,
|
||||||
|
} from '../dtos/emprunt.dto';
|
||||||
|
import {
|
||||||
|
findEnCoursParUtilisateur,
|
||||||
|
creerEmpruntComplet,
|
||||||
|
findByIdAvecChecklists,
|
||||||
|
restituerEmpruntComplet,
|
||||||
|
EmpruntAvecMateriel,
|
||||||
|
RestituerEmpruntData,
|
||||||
|
signalerTentativeRetour,
|
||||||
|
} from '../repositories/emprunt.repository';
|
||||||
|
import { findDetailParCampus } from '../repositories/materiel.repository';
|
||||||
|
import { findById as findPosteById } from '../repositories/poste-emprunt.repository';
|
||||||
|
import { findResponsablesParCampus } from '../repositories/utilisateur.repository';
|
||||||
|
import { StatutEmprunt } from '../models/enums';
|
||||||
|
import { SignalerTentativeRetourRequest } from '../dtos/emprunt.dto';
|
||||||
|
|
||||||
|
const DUREE_EMPRUNT_JOURS = 14;
|
||||||
|
const STATUTS_RESTITUABLES: readonly string[] = ['EN_COURS', 'EN_RETARD'];
|
||||||
|
|
||||||
|
/* RG15 : un étudiant ne consulte que ses propres emprunts non restitués. */
|
||||||
|
export function listerMesEmpruntsEnCours(utilisateurId: number): Promise<EmpruntAvecMateriel[]> {
|
||||||
|
return findEnCoursParUtilisateur(utilisateurId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signalerTentativeModificationRetour(
|
||||||
|
utilisateurId: number,
|
||||||
|
empruntId: number,
|
||||||
|
request: SignalerTentativeRetourRequest,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const emprunt = await findByIdAvecChecklists(empruntId);
|
||||||
|
if (!emprunt) {
|
||||||
|
throw new AppError(404, 'Emprunt introuvable');
|
||||||
|
}
|
||||||
|
if (emprunt.utilisateurId !== utilisateurId) {
|
||||||
|
throw new AppError(403, 'Cet emprunt ne vous appartient pas');
|
||||||
|
}
|
||||||
|
if (!STATUTS_RESTITUABLES.includes(emprunt.statut)) {
|
||||||
|
throw new AppError(409, 'Emprunt non restituable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const checklistDepart = emprunt.checklists.find((checklist) => checklist.type === 'DEPART');
|
||||||
|
const elementDepart = checklistDepart?.elements.find(
|
||||||
|
(element) => element.nomElement.toLocaleLowerCase() === request.nomElement.toLocaleLowerCase(),
|
||||||
|
);
|
||||||
|
if (!elementDepart) {
|
||||||
|
throw new AppError(400, 'Element absent de la checklist de depart');
|
||||||
|
}
|
||||||
|
if (elementDepart.etat !== request.etatInitial) {
|
||||||
|
throw new AppError(400, "L'etat initial ne correspond pas au depart valide");
|
||||||
|
}
|
||||||
|
if (request.etatInitial === request.etatDemande) {
|
||||||
|
throw new AppError(400, 'Aucun changement a signaler');
|
||||||
|
}
|
||||||
|
|
||||||
|
const responsables = await findResponsablesParCampus(emprunt.campusId);
|
||||||
|
return signalerTentativeRetour({
|
||||||
|
empruntId,
|
||||||
|
utilisateurId,
|
||||||
|
materielId: emprunt.materielId,
|
||||||
|
campusId: emprunt.campusId,
|
||||||
|
sallePretId: emprunt.sallePretId,
|
||||||
|
posteEmpruntId: emprunt.posteEmpruntId,
|
||||||
|
responsablesIds: responsables.map((responsable) => responsable.id),
|
||||||
|
description:
|
||||||
|
`Tentative de modification au retour de l'emprunt #${emprunt.id} : ` +
|
||||||
|
`${elementDepart.nomElement} ${request.etatInitial} -> ${request.etatDemande}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RG07/RG10/RG11/RG12 : création d'un emprunt avec sa checklist de départ.
|
||||||
|
Les contrôles d'éligibilité (campus, disponibilité) sont faits ici ; l'écriture
|
||||||
|
atomique est déléguée au repository. */
|
||||||
|
export async function creerEmprunt(
|
||||||
|
utilisateurId: number,
|
||||||
|
campusId: number,
|
||||||
|
request: CreerEmpruntRequest,
|
||||||
|
): Promise<EmpruntAvecMateriel> {
|
||||||
|
const materiel = await findDetailParCampus(request.materielId, campusId);
|
||||||
|
if (!materiel) {
|
||||||
|
throw new AppError(404, 'Materiel introuvable');
|
||||||
|
}
|
||||||
|
if (materiel.statut !== 'DISPONIBLE') {
|
||||||
|
throw new AppError(409, 'Materiel non disponible');
|
||||||
|
}
|
||||||
|
|
||||||
|
const poste = await findPosteById(request.posteEmpruntId);
|
||||||
|
if (!poste) {
|
||||||
|
throw new AppError(400, 'Poste introuvable');
|
||||||
|
}
|
||||||
|
if (poste.sallePret.campusId !== campusId) {
|
||||||
|
throw new AppError(403, 'Poste rattache a un autre campus');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateRetourPrevue = new Date();
|
||||||
|
dateRetourPrevue.setDate(dateRetourPrevue.getDate() + DUREE_EMPRUNT_JOURS);
|
||||||
|
|
||||||
|
const references: ElementReference[] =
|
||||||
|
materiel.accessoires.length > 0
|
||||||
|
? materiel.accessoires.map((liaison) => ({
|
||||||
|
accessoireId: liaison.accessoireId,
|
||||||
|
nomElement: liaison.accessoire.nom,
|
||||||
|
etat: 'PRESENT',
|
||||||
|
quantiteConstatee: liaison.quantiteAttendue,
|
||||||
|
}))
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
accessoireId: null,
|
||||||
|
nomElement: materiel.nom,
|
||||||
|
etat: 'PRESENT',
|
||||||
|
quantiteConstatee: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const elements = normaliserChecklist(references, request.elements, 'depart');
|
||||||
|
const differences = comparerEtats(references, elements);
|
||||||
|
const responsables = differences.length > 0 ? await findResponsablesParCampus(campusId) : [];
|
||||||
|
const descriptionEcart =
|
||||||
|
differences.length > 0
|
||||||
|
? `Ecart au depart pour ${materiel.nom} : ${differences.join(', ')}.`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return creerEmpruntComplet({
|
||||||
|
utilisateurId,
|
||||||
|
materielId: request.materielId,
|
||||||
|
categorieId: materiel.categorieId,
|
||||||
|
campusId,
|
||||||
|
sallePretId: poste.sallePretId,
|
||||||
|
posteEmpruntId: request.posteEmpruntId,
|
||||||
|
dateRetourPrevue,
|
||||||
|
modeIdentification: request.modeIdentification,
|
||||||
|
commentaireDepart: request.commentaireDepart,
|
||||||
|
elements,
|
||||||
|
statutEmprunt: differences.length > 0 ? 'EN_ATTENTE_VALIDATION_DEPART' : 'EN_COURS',
|
||||||
|
statutMateriel: differences.length > 0 ? 'RESERVE' : 'EMPRUNTE',
|
||||||
|
responsablesIds: responsables.map((responsable) => responsable.id),
|
||||||
|
descriptionEcart,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElementReference {
|
||||||
|
accessoireId: number | null;
|
||||||
|
nomElement: string;
|
||||||
|
etat: string;
|
||||||
|
quantiteConstatee: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trouverElement(
|
||||||
|
reference: ElementReference,
|
||||||
|
elements: ChecklistElementRequest[],
|
||||||
|
): ChecklistElementRequest | undefined {
|
||||||
|
if (reference.accessoireId !== null) {
|
||||||
|
const parId = elements.find((element) => element.accessoireId === reference.accessoireId);
|
||||||
|
if (parId) {
|
||||||
|
return parId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return elements.find(
|
||||||
|
(element) =>
|
||||||
|
element.nomElement.trim().toLocaleLowerCase() === reference.nomElement.toLocaleLowerCase(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliserChecklist(
|
||||||
|
references: ElementReference[],
|
||||||
|
elements: ChecklistElementRequest[],
|
||||||
|
type: 'depart' | 'retour',
|
||||||
|
): ChecklistElementRequest[] {
|
||||||
|
if (elements.length !== references.length) {
|
||||||
|
throw new AppError(400, `La checklist de ${type} doit contenir tous les elements attendus`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const utilises = new Set<ChecklistElementRequest>();
|
||||||
|
return references.map((reference) => {
|
||||||
|
const element = trouverElement(reference, elements);
|
||||||
|
if (!element || utilises.has(element)) {
|
||||||
|
throw new AppError(400, `Element de checklist manquant : ${reference.nomElement}`);
|
||||||
|
}
|
||||||
|
utilises.add(element);
|
||||||
|
return {
|
||||||
|
accessoireId: reference.accessoireId ?? undefined,
|
||||||
|
nomElement: reference.nomElement,
|
||||||
|
etat: element.etat,
|
||||||
|
quantiteConstatee:
|
||||||
|
type === 'depart' && element.etat === 'PRESENT'
|
||||||
|
? reference.quantiteConstatee
|
||||||
|
: element.quantiteConstatee,
|
||||||
|
commentaire: element.commentaire,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function comparerEtats(
|
||||||
|
references: ElementReference[],
|
||||||
|
elements: ChecklistElementRequest[],
|
||||||
|
): string[] {
|
||||||
|
const details: string[] = [];
|
||||||
|
|
||||||
|
for (const reference of references) {
|
||||||
|
const element = trouverElement(reference, elements);
|
||||||
|
if (
|
||||||
|
!element ||
|
||||||
|
element.etat !== reference.etat ||
|
||||||
|
element.quantiteConstatee !== reference.quantiteConstatee
|
||||||
|
) {
|
||||||
|
details.push(
|
||||||
|
`${reference.nomElement} : ${reference.etat}/${reference.quantiteConstatee} -> ${
|
||||||
|
element?.etat ?? 'ABSENT'
|
||||||
|
}/${element?.quantiteConstatee ?? 0}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restituerEmprunt(
|
||||||
|
utilisateurId: number,
|
||||||
|
empruntId: number,
|
||||||
|
request: RestituerEmpruntRequest,
|
||||||
|
): Promise<EmpruntAvecMateriel> {
|
||||||
|
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');
|
||||||
|
if (!checklistDepart) {
|
||||||
|
throw new AppError(409, 'Checklist de depart introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const references = checklistDepart.elements.map((element) => ({
|
||||||
|
accessoireId: element.accessoireId,
|
||||||
|
nomElement: element.nomElement,
|
||||||
|
etat: element.etat,
|
||||||
|
quantiteConstatee: element.quantiteConstatee,
|
||||||
|
}));
|
||||||
|
const elements = normaliserChecklist(references, request.elements, 'retour');
|
||||||
|
const differences = comparerEtats(references, elements);
|
||||||
|
const statutEmprunt: StatutEmprunt =
|
||||||
|
differences.length === 0 ? 'CLOTURE' : 'EN_ATTENTE_VALIDATION_RETOUR';
|
||||||
|
const responsables =
|
||||||
|
differences.length > 0 ? await findResponsablesParCampus(emprunt.campusId) : [];
|
||||||
|
const descriptionEcart =
|
||||||
|
differences.length > 0
|
||||||
|
? `Changement au retour pour l'emprunt #${emprunt.id} : ${differences.join(', ')}.`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const donnees: RestituerEmpruntData = {
|
||||||
|
empruntId,
|
||||||
|
utilisateurId,
|
||||||
|
materielId: emprunt.materielId,
|
||||||
|
statutEmprunt,
|
||||||
|
statutMateriel: differences.length === 0 ? 'DISPONIBLE' : 'EMPRUNTE',
|
||||||
|
modeIdentification: request.modeIdentification,
|
||||||
|
commentaireRetour: request.commentaireRetour,
|
||||||
|
elements,
|
||||||
|
campusId: emprunt.campusId,
|
||||||
|
sallePretId: emprunt.sallePretId,
|
||||||
|
posteEmpruntId: emprunt.posteEmpruntId,
|
||||||
|
responsablesIds: responsables.map((responsable) => responsable.id),
|
||||||
|
descriptionEcart,
|
||||||
|
};
|
||||||
|
|
||||||
|
return restituerEmpruntComplet(donnees);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import {
|
||||||
|
findDisponiblesParCampus,
|
||||||
|
findDetailParCampus,
|
||||||
|
CatalogueFiltres,
|
||||||
|
MaterielDetail,
|
||||||
|
MaterielAvecCategorie,
|
||||||
|
} from '../repositories/materiel.repository';
|
||||||
|
import { AppError } from '../errors/app-error';
|
||||||
|
|
||||||
|
/* RG10 : après identification, seuls les matériels disponibles du campus de
|
||||||
|
l'étudiant sont affichés. */
|
||||||
|
export function listerCatalogue(
|
||||||
|
campusId: number,
|
||||||
|
filtres: CatalogueFiltres,
|
||||||
|
): Promise<MaterielAvecCategorie[]> {
|
||||||
|
return findDisponiblesParCampus(campusId, filtres);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function obtenirDetailMateriel(id: number, campusId: number): Promise<MaterielDetail> {
|
||||||
|
const materiel = await findDetailParCampus(id, campusId);
|
||||||
|
if (!materiel) {
|
||||||
|
throw new AppError(404, 'Materiel introuvable');
|
||||||
|
}
|
||||||
|
return materiel;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { AppError } from '../errors/app-error';
|
||||||
|
import { findById, UtilisateurProfil } from '../repositories/utilisateur.repository';
|
||||||
|
import { findByQrCode } from '../repositories/carte-etudiante.repository';
|
||||||
|
|
||||||
|
export async function getProfil(utilisateurId: number): Promise<UtilisateurProfil> {
|
||||||
|
const profil = await findById(utilisateurId);
|
||||||
|
if (!profil) {
|
||||||
|
throw new AppError(404, 'Utilisateur introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
return profil;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RG02 : identification de l'étudiant par le QR code de sa carte. La carte doit
|
||||||
|
exister, être active et non expirée, et le compte doit être actif. */
|
||||||
|
export async function identifierParCarte(qrCode: string): Promise<UtilisateurProfil> {
|
||||||
|
const carte = await findByQrCode(qrCode);
|
||||||
|
if (!carte || !carte.actif) {
|
||||||
|
throw new AppError(404, 'Carte non reconnue');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (carte.dateExpiration.getTime() < Date.now()) {
|
||||||
|
throw new AppError(401, 'Carte expiree');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!carte.utilisateur.actif) {
|
||||||
|
throw new AppError(401, 'Compte inactif');
|
||||||
|
}
|
||||||
|
|
||||||
|
return carte.utilisateur;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export interface AuthenticatedUser {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
roleCode: string;
|
||||||
|
campusId: number;
|
||||||
|
}
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
import { AuthenticatedUser } from './authenticated-user';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
user?: AuthenticatedUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
@@ -2,16 +2,15 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"rootDir": "./src",
|
|
||||||
"outDir": "./dist",
|
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"types": ["node"]
|
"types": ["node"],
|
||||||
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*", "prisma/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
+485
@@ -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
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Miscellaneous
|
||||||
|
*.class
|
||||||
|
*.log
|
||||||
|
*.pyc
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
.atom/
|
||||||
|
.build/
|
||||||
|
.buildlog/
|
||||||
|
.history
|
||||||
|
.svn/
|
||||||
|
.swiftpm/
|
||||||
|
migrate_working_dir/
|
||||||
|
|
||||||
|
# IntelliJ related
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# The .vscode folder contains launch configuration and tasks you configure in
|
||||||
|
# VS Code which you may wish to be included in version control, so this line
|
||||||
|
# is commented out by default.
|
||||||
|
#.vscode/
|
||||||
|
|
||||||
|
# Flutter/Dart/Pub related
|
||||||
|
**/doc/api/
|
||||||
|
**/ios/Flutter/.last_build_id
|
||||||
|
.dart_tool/
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.pub-cache/
|
||||||
|
.pub/
|
||||||
|
/build/
|
||||||
|
/coverage/
|
||||||
|
|
||||||
|
# Symbolication related
|
||||||
|
app.*.symbols
|
||||||
|
|
||||||
|
# Obfuscation related
|
||||||
|
app.*.map.json
|
||||||
|
|
||||||
|
# Android Studio will place build artifacts here
|
||||||
|
/android/app/debug
|
||||||
|
/android/app/profile
|
||||||
|
/android/app/release
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# This file tracks properties of this Flutter project.
|
||||||
|
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||||
|
#
|
||||||
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
|
version:
|
||||||
|
revision: "b45fa18946ecc2d9b4009952c636ba7e2ffbb787"
|
||||||
|
channel: "stable"
|
||||||
|
|
||||||
|
project_type: app
|
||||||
|
|
||||||
|
# Tracks metadata for the flutter migrate command
|
||||||
|
migration:
|
||||||
|
platforms:
|
||||||
|
- platform: root
|
||||||
|
create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
|
||||||
|
base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
|
||||||
|
- platform: web
|
||||||
|
create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
|
||||||
|
base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
|
||||||
|
|
||||||
|
# User provided section
|
||||||
|
|
||||||
|
# List of Local paths (relative to this file) that should be
|
||||||
|
# ignored by the migrate tool.
|
||||||
|
#
|
||||||
|
# Files that are not part of the templates will be ignored by default.
|
||||||
|
unmanaged_files:
|
||||||
|
- 'lib/main.dart'
|
||||||
|
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# eme_frontend
|
||||||
|
|
||||||
|
A new Flutter project.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
This project is a starting point for a Flutter application.
|
||||||
|
|
||||||
|
A few resources to get you started if this is your first Flutter project:
|
||||||
|
|
||||||
|
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||||
|
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
|
||||||
|
|
||||||
|
For help getting started with Flutter development, view the
|
||||||
|
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||||
|
samples, guidance on mobile development, and a full API reference.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
linter:
|
||||||
|
rules:
|
||||||
|
# Convention projet (CLAUDE.md) : guillemets doubles en Dart.
|
||||||
|
prefer_single_quotes: false
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import "services/auth_service.dart";
|
||||||
|
|
||||||
|
/// Identité de secours utilisée lorsque aucun profil de session n'est chargé.
|
||||||
|
class DemoIdentity {
|
||||||
|
DemoIdentity._();
|
||||||
|
|
||||||
|
static String get userName =>
|
||||||
|
AuthService.currentProfile?.fullName ?? "Lucas Martin";
|
||||||
|
static String get userInitials =>
|
||||||
|
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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "theme/ensup_theme.dart";
|
||||||
|
import "screens/login_screen.dart";
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
runApp(const EmeApp());
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmeApp extends StatelessWidget {
|
||||||
|
const EmeApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp(
|
||||||
|
title: "EME — Emprunt Matériel ENSUP",
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
theme: buildEnsupTheme(),
|
||||||
|
home: const LoginScreen(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/// Une différence constatée au retour par rapport au départ (RG20).
|
||||||
|
class AnomalieRetour {
|
||||||
|
const AnomalieRetour({required this.element, required this.etatRetour});
|
||||||
|
|
||||||
|
final String element;
|
||||||
|
final String etatRetour;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import "materiel_item.dart";
|
||||||
|
import "checklist_element_item.dart";
|
||||||
|
|
||||||
|
/// Un emprunt en cours de l'étudiant, affiché dans le parcours de restitution
|
||||||
|
/// et renvoyé par l'API.
|
||||||
|
class EmpruntItem {
|
||||||
|
const EmpruntItem({
|
||||||
|
required this.id,
|
||||||
|
required this.materiel,
|
||||||
|
required this.dateEmprunt,
|
||||||
|
required this.retourPrevu,
|
||||||
|
required this.statut,
|
||||||
|
required this.checklistDepart,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory EmpruntItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
final dateEmprunt = DateTime.parse(json["dateEmprunt"] as String);
|
||||||
|
final retourPrevu = DateTime.parse(json["dateRetourPrevue"] as String);
|
||||||
|
|
||||||
|
return EmpruntItem(
|
||||||
|
id: json["id"] as int,
|
||||||
|
materiel: MaterielItem.fromJson(json["materiel"] as Map<String, dynamic>),
|
||||||
|
dateEmprunt: _dateHeure(dateEmprunt),
|
||||||
|
retourPrevu: _date(retourPrevu),
|
||||||
|
statut: json["statut"] as String,
|
||||||
|
checklistDepart: (json["checklistDepart"] as List<dynamic>? ?? const [])
|
||||||
|
.map(
|
||||||
|
(element) => ChecklistElementItem.fromJson(
|
||||||
|
Map<String, dynamic>.from(element as Map),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final MaterielItem materiel;
|
||||||
|
final String dateEmprunt;
|
||||||
|
final String retourPrevu;
|
||||||
|
final String statut;
|
||||||
|
final List<ChecklistElementItem> checklistDepart;
|
||||||
|
|
||||||
|
EmpruntItem copyWith({MaterielItem? materiel, String? statut}) {
|
||||||
|
return EmpruntItem(
|
||||||
|
id: id,
|
||||||
|
materiel: materiel ?? this.materiel,
|
||||||
|
dateEmprunt: dateEmprunt,
|
||||||
|
retourPrevu: retourPrevu,
|
||||||
|
statut: statut ?? this.statut,
|
||||||
|
checklistDepart: checklistDepart,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _deux(int valeur) => valeur.toString().padLeft(2, "0");
|
||||||
|
|
||||||
|
static String _date(DateTime date) {
|
||||||
|
return "${_deux(date.day)}/${_deux(date.month)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _dateHeure(DateTime date) {
|
||||||
|
return "${_date(date)} à ${_deux(date.hour)}:${_deux(date.minute)}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import "materiel_item.dart";
|
||||||
|
|
||||||
|
/// Réponse renvoyée par l'API après création d'un emprunt.
|
||||||
|
class EmpruntResponse {
|
||||||
|
const EmpruntResponse({
|
||||||
|
required this.id,
|
||||||
|
required this.dateEmprunt,
|
||||||
|
required this.dateRetourPrevue,
|
||||||
|
required this.statut,
|
||||||
|
required this.materiel,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory EmpruntResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
return EmpruntResponse(
|
||||||
|
id: json["id"] as int,
|
||||||
|
dateEmprunt: DateTime.parse(json["dateEmprunt"] as String),
|
||||||
|
dateRetourPrevue: DateTime.parse(json["dateRetourPrevue"] as String),
|
||||||
|
statut: json["statut"] as String,
|
||||||
|
materiel: MaterielItem.fromJson(json["materiel"] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final DateTime dateEmprunt;
|
||||||
|
final DateTime dateRetourPrevue;
|
||||||
|
final String statut;
|
||||||
|
final MaterielItem materiel;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "../demo_identity.dart";
|
||||||
|
|
||||||
|
/// Représentation d'un matériel affiché dans le catalogue et son détail
|
||||||
|
/// à partir des réponses API.
|
||||||
|
class MaterielItem {
|
||||||
|
const MaterielItem({
|
||||||
|
required this.id,
|
||||||
|
required this.nom,
|
||||||
|
required this.categorie,
|
||||||
|
required this.marque,
|
||||||
|
required this.modele,
|
||||||
|
required this.reference,
|
||||||
|
required this.campus,
|
||||||
|
required this.etatGeneral,
|
||||||
|
required this.disponible,
|
||||||
|
required this.accessoires,
|
||||||
|
required this.icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Construit un MaterielItem depuis la réponse JSON du catalogue.
|
||||||
|
/// Les champs non fournis par cet endpoint (accessoires, icône) sont complétés.
|
||||||
|
factory MaterielItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
final categorie = (json["categorie"] as Map<String, dynamic>)["nom"] as String;
|
||||||
|
final campus = json["campus"] as Map<String, dynamic>?;
|
||||||
|
final accessoires = json["accessoires"] as List<dynamic>?;
|
||||||
|
return MaterielItem(
|
||||||
|
id: json["id"] as int,
|
||||||
|
nom: json["nom"] as String,
|
||||||
|
categorie: categorie,
|
||||||
|
marque: json["marque"] as String,
|
||||||
|
modele: json["modele"] as String,
|
||||||
|
reference: json["reference"] as String,
|
||||||
|
campus: campus == null
|
||||||
|
? DemoIdentity.campusTag
|
||||||
|
: "${campus["nom"]} · ${campus["ville"]}",
|
||||||
|
etatGeneral: json["etatGeneral"] as String,
|
||||||
|
disponible: json["statut"] == "DISPONIBLE",
|
||||||
|
accessoires: accessoires == null ? const <String>[] : accessoires.cast<String>(),
|
||||||
|
icon: iconePourCategorie(categorie),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String nom;
|
||||||
|
final String categorie;
|
||||||
|
final String marque;
|
||||||
|
final String modele;
|
||||||
|
final String reference;
|
||||||
|
final String campus;
|
||||||
|
final String etatGeneral;
|
||||||
|
final bool disponible;
|
||||||
|
final List<String> accessoires;
|
||||||
|
final IconData icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Déduit une icône à partir du nom de catégorie renvoyé par l'API.
|
||||||
|
IconData iconePourCategorie(String categorie) {
|
||||||
|
switch (categorie) {
|
||||||
|
case "Ordinateur portable":
|
||||||
|
return Icons.laptop_mac;
|
||||||
|
case "Vidéoprojecteur":
|
||||||
|
return Icons.videocam_outlined;
|
||||||
|
case "Tablette":
|
||||||
|
return Icons.tablet_mac;
|
||||||
|
case "Caméra":
|
||||||
|
return Icons.photo_camera_outlined;
|
||||||
|
case "Périphérique audio":
|
||||||
|
return Icons.headphones;
|
||||||
|
case "Câble / Adaptateur":
|
||||||
|
return Icons.cable;
|
||||||
|
default:
|
||||||
|
return Icons.inventory_2_outlined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../demo_identity.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
import "../models/materiel_item.dart";
|
||||||
|
import "../services/materiel_service.dart";
|
||||||
|
import "../widgets/ensup_top_bar.dart";
|
||||||
|
import "../widgets/brand_corners.dart";
|
||||||
|
import "../widgets/app_message.dart";
|
||||||
|
import "../widgets/materiel_card.dart";
|
||||||
|
import "detail_screen.dart";
|
||||||
|
|
||||||
|
/// Catalogue matériel : recherche, filtres par catégorie et liste chargée
|
||||||
|
/// depuis l'API.
|
||||||
|
class CatalogueScreen extends StatefulWidget {
|
||||||
|
const CatalogueScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CatalogueScreen> createState() => _CatalogueScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CatalogueScreenState extends State<CatalogueScreen> {
|
||||||
|
late Future<List<MaterielItem>> _future;
|
||||||
|
String _recherche = "";
|
||||||
|
String _categorie = "Tous";
|
||||||
|
int? _selectionEnCoursId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_future = MaterielService.getCatalogue();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _recharger() {
|
||||||
|
setState(() => _future = MaterielService.getCatalogue());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _categories(List<MaterielItem> materiels) {
|
||||||
|
final distinctes = materiels.map((m) => m.categorie).toSet().toList()
|
||||||
|
..sort();
|
||||||
|
return ["Tous", ...distinctes];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<MaterielItem> _filtrer(List<MaterielItem> materiels) {
|
||||||
|
final terme = _recherche.trim().toLowerCase();
|
||||||
|
return materiels.where((materiel) {
|
||||||
|
final matchTexte =
|
||||||
|
terme.isEmpty || materiel.nom.toLowerCase().contains(terme);
|
||||||
|
final matchCategorie =
|
||||||
|
_categorie == "Tous" || materiel.categorie == _categorie;
|
||||||
|
return matchTexte && matchCategorie;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _selectionner(MaterielItem item) async {
|
||||||
|
setState(() => _selectionEnCoursId = item.id);
|
||||||
|
try {
|
||||||
|
final detail = await MaterielService.getDetail(item.id);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.of(
|
||||||
|
context,
|
||||||
|
).push(MaterialPageRoute(builder: (_) => DetailScreen(item: detail)));
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AppMessage.erreur(context, error);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _selectionEnCoursId = null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: EnsupColors.soft,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: EnsupColors.text.withValues(alpha: 0.14),
|
||||||
|
blurRadius: 60,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
EnsupTopBar(
|
||||||
|
brandText: "Catalogue matériel",
|
||||||
|
campusTag: DemoIdentity.campusTag,
|
||||||
|
userName: DemoIdentity.userName,
|
||||||
|
userInitials: DemoIdentity.userInitials,
|
||||||
|
onBack: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
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 l’emprunt.",
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
_SearchField(
|
||||||
|
onChanged: (value) =>
|
||||||
|
setState(() => _recherche = value),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Expanded(
|
||||||
|
child: FutureBuilder<List<MaterielItem>>(
|
||||||
|
future: _future,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState ==
|
||||||
|
ConnectionState.waiting) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return _ErreurState(
|
||||||
|
message: snapshot.error.toString(),
|
||||||
|
onRetry: _recharger,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final materiels = snapshot.data ?? const [];
|
||||||
|
return _contenu(materiels);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Positioned.fill(child: BrandCorners()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _contenu(List<MaterielItem> materiels) {
|
||||||
|
final resultats = _filtrer(materiels);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: _categories(materiels).map((categorie) {
|
||||||
|
return _CategoryChip(
|
||||||
|
label: categorie,
|
||||||
|
selected: categorie == _categorie,
|
||||||
|
onTap: () => setState(() => _categorie = categorie),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
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(
|
||||||
|
child: resultats.isEmpty
|
||||||
|
? const _EmptyState()
|
||||||
|
: ListView.separated(
|
||||||
|
itemCount: resultats.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = resultats[index];
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
MaterielCard(
|
||||||
|
item: item,
|
||||||
|
onSelect: () => _selectionner(item),
|
||||||
|
),
|
||||||
|
if (_selectionEnCoursId == item.id)
|
||||||
|
const Positioned.fill(
|
||||||
|
child: ColoredBox(
|
||||||
|
color: Color(0x66FFFFFF),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SearchField extends StatelessWidget {
|
||||||
|
const _SearchField({required this.onChanged});
|
||||||
|
|
||||||
|
final ValueChanged<String> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
height: 44,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: EnsupColors.line, width: 1.5),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.search, size: 18, color: EnsupColors.muted),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
onChanged: onChanged,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 14,
|
||||||
|
color: EnsupColors.text,
|
||||||
|
),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isCollapsed: true,
|
||||||
|
border: InputBorder.none,
|
||||||
|
hintText: "Rechercher un matériel...",
|
||||||
|
hintStyle: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 14,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CategoryChip extends StatelessWidget {
|
||||||
|
const _CategoryChip({
|
||||||
|
required this.label,
|
||||||
|
required this.selected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final bool selected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected ? EnsupColors.cyan : Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(
|
||||||
|
color: selected ? EnsupColors.cyan : EnsupColors.line,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: selected ? Colors.white : EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmptyState extends StatelessWidget {
|
||||||
|
const _EmptyState();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.inventory_2_outlined,
|
||||||
|
size: 40,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
"Aucun matériel ne correspond",
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 14,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ErreurState extends StatelessWidget {
|
||||||
|
const _ErreurState({required this.message, required this.onRetry});
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
final VoidCallback onRetry;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.cloud_off_outlined,
|
||||||
|
size: 40,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 14,
|
||||||
|
color: EnsupColors.text2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: onRetry,
|
||||||
|
child: Text(
|
||||||
|
"Réessayer",
|
||||||
|
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../demo_identity.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
import "../models/materiel_item.dart";
|
||||||
|
import "../services/emprunt_service.dart";
|
||||||
|
import "../widgets/ensup_top_bar.dart";
|
||||||
|
import "../widgets/brand_corners.dart";
|
||||||
|
import "../widgets/etat_checklist.dart";
|
||||||
|
import "../widgets/app_message.dart";
|
||||||
|
import "confirmation_screen.dart";
|
||||||
|
|
||||||
|
class ChecklistDepartScreen extends StatefulWidget {
|
||||||
|
const ChecklistDepartScreen({super.key, required this.item});
|
||||||
|
|
||||||
|
final MaterielItem item;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChecklistDepartScreen> createState() => _ChecklistDepartScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
|
||||||
|
late final List<String> _elements = widget.item.accessoires.isEmpty
|
||||||
|
? <String>[widget.item.nom]
|
||||||
|
: widget.item.accessoires;
|
||||||
|
late final List<EtatChecklist> _etats = List<EtatChecklist>.filled(
|
||||||
|
_elements.length,
|
||||||
|
EtatChecklist.present,
|
||||||
|
);
|
||||||
|
final TextEditingController _commentaireController = TextEditingController();
|
||||||
|
bool _envoiEnCours = false;
|
||||||
|
|
||||||
|
bool get _ecartDeclare => _etats.any((etat) => etat != EtatChecklist.present);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_commentaireController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _valider() async {
|
||||||
|
setState(() => _envoiEnCours = true);
|
||||||
|
|
||||||
|
final anomalies = <String>[];
|
||||||
|
for (var i = 0; i < _etats.length; i++) {
|
||||||
|
if (_etats[i] == EtatChecklist.absent) {
|
||||||
|
anomalies.add("${_elements[i]} absent au départ");
|
||||||
|
} else if (_etats[i] == EtatChecklist.deteriore) {
|
||||||
|
anomalies.add("${_elements[i]} détérioré au départ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final note = anomalies.isEmpty ? null : anomalies.join(", ");
|
||||||
|
|
||||||
|
try {
|
||||||
|
final emprunt = await EmpruntService.creerEmprunt(
|
||||||
|
materielId: widget.item.id,
|
||||||
|
commentaireDepart: _commentaireController.text,
|
||||||
|
elements: _elements.asMap().entries.map((entry) {
|
||||||
|
return {
|
||||||
|
"nomElement": entry.value,
|
||||||
|
"etat": _etatApi(_etats[entry.key]),
|
||||||
|
"quantiteConstatee": _etats[entry.key] == EtatChecklist.absent
|
||||||
|
? 0
|
||||||
|
: 1,
|
||||||
|
};
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => ConfirmationScreen(emprunt: emprunt, note: note),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AppMessage.erreur(context, error);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _envoiEnCours = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _etatApi(EtatChecklist etat) {
|
||||||
|
switch (etat) {
|
||||||
|
case EtatChecklist.present:
|
||||||
|
return "PRESENT";
|
||||||
|
case EtatChecklist.absent:
|
||||||
|
return "ABSENT";
|
||||||
|
case EtatChecklist.deteriore:
|
||||||
|
return "DETERIORE";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: EnsupColors.soft,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: EnsupColors.text.withValues(alpha: 0.14),
|
||||||
|
blurRadius: 60,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
EnsupTopBar(
|
||||||
|
brandText: "Checklist de départ",
|
||||||
|
campusTag: DemoIdentity.campusTag,
|
||||||
|
userName: DemoIdentity.userName,
|
||||||
|
userInitials: DemoIdentity.userInitials,
|
||||||
|
onBack: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 24,
|
||||||
|
vertical: 26,
|
||||||
|
),
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
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()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Positioned.fill(child: BrandCorners()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _colonneGauche() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: EnsupColors.soft,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: EnsupColors.cyan.withValues(alpha: 0.15),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: EnsupColors.cyan.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(9),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
widget.item.icon,
|
||||||
|
color: EnsupColors.cyan,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.item.nom,
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
"Vérifiez chaque élément physiquement avant de valider",
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 12,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ChecklistTableau(
|
||||||
|
accessoires: _elements,
|
||||||
|
etats: _etats,
|
||||||
|
onChanged: (index, etat) => setState(() => _etats[index] = etat),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _colonneDroite() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ChecklistInstructions(
|
||||||
|
titre: "État de référence",
|
||||||
|
texte:
|
||||||
|
"Tous les éléments sont préremplis comme présents. Vérifiez-les "
|
||||||
|
"physiquement et modifiez uniquement ceux qui sont absents ou détériorés.",
|
||||||
|
),
|
||||||
|
if (_ecartDeclare) ...[
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFFF7ED),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: const Color(0xFFFED7AA)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.warning_amber_rounded,
|
||||||
|
color: Color(0xFFD97706),
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
"Cet écart sera transmis au responsable. L'emprunt restera "
|
||||||
|
"en attente jusqu'à sa confirmation.",
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
color: EnsupColors.text2,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
ChecklistCommentaire(controller: _commentaireController),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: _envoiEnCours ? null : _valider,
|
||||||
|
icon: _envoiEnCours
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.check, size: 18),
|
||||||
|
label: Text(
|
||||||
|
_envoiEnCours
|
||||||
|
? "Enregistrement..."
|
||||||
|
: _ecartDeclare
|
||||||
|
? "Signaler l'écart"
|
||||||
|
: "Confirmer l'emprunt",
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: EnsupColors.blue3,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../demo_identity.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
import "../models/emprunt_item.dart";
|
||||||
|
import "../models/anomalie_retour.dart";
|
||||||
|
import "../models/checklist_element_item.dart";
|
||||||
|
import "../services/emprunt_service.dart";
|
||||||
|
import "../widgets/ensup_top_bar.dart";
|
||||||
|
import "../widgets/brand_corners.dart";
|
||||||
|
import "../widgets/etat_checklist.dart";
|
||||||
|
import "../widgets/app_message.dart";
|
||||||
|
import "resultat_retour_screen.dart";
|
||||||
|
|
||||||
|
class ChecklistRetourScreen extends StatefulWidget {
|
||||||
|
const ChecklistRetourScreen({super.key, required this.emprunt});
|
||||||
|
|
||||||
|
final EmpruntItem emprunt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChecklistRetourScreen> createState() => _ChecklistRetourScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
|
||||||
|
late final List<ChecklistElementItem> _checklistDepart =
|
||||||
|
widget.emprunt.checklistDepart.isNotEmpty
|
||||||
|
? widget.emprunt.checklistDepart
|
||||||
|
: (widget.emprunt.materiel.accessoires.isEmpty
|
||||||
|
? [
|
||||||
|
ChecklistElementItem(
|
||||||
|
accessoireId: null,
|
||||||
|
nom: widget.emprunt.materiel.nom,
|
||||||
|
etat: "PRESENT",
|
||||||
|
quantite: 1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: widget.emprunt.materiel.accessoires
|
||||||
|
.map(
|
||||||
|
(nom) => ChecklistElementItem(
|
||||||
|
accessoireId: null,
|
||||||
|
nom: nom,
|
||||||
|
etat: "PRESENT",
|
||||||
|
quantite: 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList());
|
||||||
|
late final List<String> _elements = _checklistDepart
|
||||||
|
.map((element) => element.nom)
|
||||||
|
.toList();
|
||||||
|
late final List<EtatChecklist> _etatsInitiales = _checklistDepart
|
||||||
|
.map((element) => _etatDepuisApi(element.etat))
|
||||||
|
.toList();
|
||||||
|
late final List<EtatChecklist> _etats = List<EtatChecklist>.from(
|
||||||
|
_etatsInitiales,
|
||||||
|
);
|
||||||
|
final TextEditingController _commentaireController = TextEditingController();
|
||||||
|
bool _envoiEnCours = false;
|
||||||
|
bool _alerteEnCours = false;
|
||||||
|
bool _alerteEnvoyee = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_commentaireController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _valider() async {
|
||||||
|
setState(() => _envoiEnCours = true);
|
||||||
|
|
||||||
|
final anomalies = <AnomalieRetour>[];
|
||||||
|
for (var i = 0; i < _etats.length; i++) {
|
||||||
|
if (_etats[i] != _etatsInitiales[i]) {
|
||||||
|
anomalies.add(
|
||||||
|
AnomalieRetour(
|
||||||
|
element: _elements[i],
|
||||||
|
etatRetour:
|
||||||
|
"${_etatLabel(_etatsInitiales[i])} → ${_etatLabel(_etats[i])}",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final resultat = await EmpruntService.restituerEmprunt(
|
||||||
|
empruntId: widget.emprunt.id,
|
||||||
|
commentaireRetour: _commentaireController.text,
|
||||||
|
elements: _elements.asMap().entries.map((entry) {
|
||||||
|
final elementDepart = _checklistDepart[entry.key];
|
||||||
|
return {
|
||||||
|
if (elementDepart.accessoireId != null)
|
||||||
|
"accessoireId": elementDepart.accessoireId,
|
||||||
|
"nomElement": entry.value,
|
||||||
|
"etat": _etatApi(_etats[entry.key]),
|
||||||
|
"quantiteConstatee": _quantiteConstatee(entry.key),
|
||||||
|
};
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final anomaliesAffichees = resultat.statut == "CLOTURE"
|
||||||
|
? <AnomalieRetour>[]
|
||||||
|
: anomalies;
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => ResultatRetourScreen(
|
||||||
|
emprunt: widget.emprunt.copyWith(statut: resultat.statut),
|
||||||
|
anomalies: anomaliesAffichees,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AppMessage.erreur(context, error);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _envoiEnCours = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
switch (etat) {
|
||||||
|
case EtatChecklist.present:
|
||||||
|
return "PRESENT";
|
||||||
|
case EtatChecklist.absent:
|
||||||
|
return "ABSENT";
|
||||||
|
case EtatChecklist.deteriore:
|
||||||
|
return "DETERIORE";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _etatLabel(EtatChecklist etat) {
|
||||||
|
return switch (etat) {
|
||||||
|
EtatChecklist.present => "Présent",
|
||||||
|
EtatChecklist.absent => "Absent",
|
||||||
|
EtatChecklist.deteriore => "Détérioré",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: EnsupColors.soft,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: EnsupColors.text.withValues(alpha: 0.14),
|
||||||
|
blurRadius: 60,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
EnsupTopBar(
|
||||||
|
brandText: "Checklist de retour",
|
||||||
|
campusTag: DemoIdentity.campusTag,
|
||||||
|
userName: DemoIdentity.userName,
|
||||||
|
userInitials: DemoIdentity.userInitials,
|
||||||
|
onBack: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 24,
|
||||||
|
vertical: 26,
|
||||||
|
),
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
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()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Positioned.fill(child: BrandCorners()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _colonneGauche() {
|
||||||
|
final materiel = widget.emprunt.materiel;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: EnsupColors.soft,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: EnsupColors.teal.withValues(alpha: 0.15),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: EnsupColors.teal.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(9),
|
||||||
|
),
|
||||||
|
child: Icon(materiel.icon, color: EnsupColors.teal, size: 18),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
materiel.nom,
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
"Emprunté le ${widget.emprunt.dateEmprunt}",
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 12,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ChecklistTableau(
|
||||||
|
accessoires: _elements,
|
||||||
|
etats: _etats,
|
||||||
|
onChanged: _changerEtat,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _colonneDroite() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ChecklistInstructions(
|
||||||
|
titre: "État validé au départ",
|
||||||
|
texte:
|
||||||
|
"La restitution reprend automatiquement l'état validé au départ : "
|
||||||
|
"${_checklistDepart.map((element) => "${element.nom} ${_etatLabel(_etatDepuisApi(element.etat)).toLowerCase()}").join(" · ")}. "
|
||||||
|
"Toute modification alerte immédiatement un responsable.",
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
ChecklistCommentaire(
|
||||||
|
controller: _commentaireController,
|
||||||
|
hint: "Commentaire optionnel...",
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: _envoiEnCours || _alerteEnCours ? null : _valider,
|
||||||
|
icon: _envoiEnCours || _alerteEnCours
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.check, size: 18),
|
||||||
|
label: Text(
|
||||||
|
_alerteEnCours
|
||||||
|
? "Alerte en cours..."
|
||||||
|
: _envoiEnCours
|
||||||
|
? "Enregistrement..."
|
||||||
|
: "Valider le retour",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: EnsupColors.blue3,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../demo_identity.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
import "../models/emprunt_response.dart";
|
||||||
|
import "../models/materiel_item.dart";
|
||||||
|
import "../widgets/ensup_top_bar.dart";
|
||||||
|
import "../widgets/brand_corners.dart";
|
||||||
|
|
||||||
|
class ConfirmationScreen extends StatelessWidget {
|
||||||
|
const ConfirmationScreen({super.key, required this.emprunt, this.note});
|
||||||
|
|
||||||
|
final EmpruntResponse emprunt;
|
||||||
|
final String? note;
|
||||||
|
|
||||||
|
MaterielItem get item => emprunt.materiel;
|
||||||
|
bool get _enAttente => emprunt.statut == "EN_ATTENTE_VALIDATION_DEPART";
|
||||||
|
|
||||||
|
static String _deuxChiffres(int valeur) => valeur.toString().padLeft(2, "0");
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final dateEmprunt = emprunt.dateEmprunt;
|
||||||
|
final date =
|
||||||
|
"${_deuxChiffres(dateEmprunt.day)}/${_deuxChiffres(dateEmprunt.month)}/${dateEmprunt.year}";
|
||||||
|
final heure =
|
||||||
|
"${_deuxChiffres(dateEmprunt.hour)}:${_deuxChiffres(dateEmprunt.minute)}";
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: EnsupColors.soft,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: EnsupColors.text.withValues(alpha: 0.14),
|
||||||
|
blurRadius: 60,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
EnsupTopBar(
|
||||||
|
brandText: "EME · Confirmation",
|
||||||
|
campusTag: DemoIdentity.campusTag,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFFF0FDF4), Color(0xFFF8FAFC)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 28,
|
||||||
|
vertical: 32,
|
||||||
|
),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_boiteSucces(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_recapitulatif(date, heure),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.of(context).popUntil(
|
||||||
|
(route) =>
|
||||||
|
route.settings.name == "home",
|
||||||
|
),
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.home_outlined,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
"Retour à l'accueil",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: EnsupColors.cyan,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 16,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Positioned.fill(child: BrandCorners()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _boiteSucces() {
|
||||||
|
final couleur = _enAttente
|
||||||
|
? const Color(0xFFD97706)
|
||||||
|
: const Color(0xFF15803D);
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(36),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: _enAttente
|
||||||
|
? const [Color(0xFFFFF7ED), Color(0xFFFFFBEB)]
|
||||||
|
: const [Color(0xFFF0FDF4), Color(0xFFECFDF5)],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: _enAttente ? const Color(0xFFFED7AA) : const Color(0xFFBBF7D0),
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: _enAttente
|
||||||
|
? const [Color(0xFFD97706), Color(0xFFF59E0B)]
|
||||||
|
: const [Color(0xFF16A34A), Color(0xFF22C55E)],
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: couleur.withValues(alpha: 0.3),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.check, color: Colors.white, size: 36),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Text(
|
||||||
|
_enAttente ? "Écart transmis" : "Emprunt enregistré !",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: couleur,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_enAttente
|
||||||
|
? "Le matériel est réservé. Attendez la confirmation du responsable avant de le récupérer."
|
||||||
|
: "Votre emprunt est actif. Vous pouvez récupérer le matériel.",
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 14,
|
||||||
|
color: _enAttente
|
||||||
|
? const Color(0xFF9A3412)
|
||||||
|
: const Color(0xFF166534),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _recapitulatif(String date, String heure) {
|
||||||
|
final note = this.note;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: EnsupColors.line, width: 1.5),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"RÉCAPITULATIF DE L'EMPRUNT",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_rrow("Matériel", item.nom),
|
||||||
|
_rrow("Référence", item.reference),
|
||||||
|
_rrow("N° emprunt", "#${emprunt.id}"),
|
||||||
|
_rrow("Emprunteur", DemoIdentity.userName),
|
||||||
|
_rrow("Date d'emprunt", date),
|
||||||
|
_rrow("Heure", heure),
|
||||||
|
_rrow("Campus", item.campus),
|
||||||
|
_rrow(
|
||||||
|
"Statut",
|
||||||
|
_enAttente ? "En attente de confirmation" : "En cours",
|
||||||
|
valueColor: _enAttente ? const Color(0xFFD97706) : null,
|
||||||
|
),
|
||||||
|
if (note != null)
|
||||||
|
_rrow("Note", note, valueColor: const Color(0xFFD97706)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _rrow(String cle, String valeur, {Color? valueColor}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 7),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
cle,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
valeur,
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: valueColor ?? EnsupColors.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../demo_identity.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
import "../models/materiel_item.dart";
|
||||||
|
import "../widgets/ensup_top_bar.dart";
|
||||||
|
import "../widgets/brand_corners.dart";
|
||||||
|
import "../widgets/status_pill.dart";
|
||||||
|
import "checklist_depart_screen.dart";
|
||||||
|
|
||||||
|
/// Détail d'un matériel : caractéristiques, accessoires du kit et lancement de
|
||||||
|
/// l'emprunt.
|
||||||
|
class DetailScreen extends StatelessWidget {
|
||||||
|
const DetailScreen({super.key, required this.item});
|
||||||
|
|
||||||
|
final MaterielItem item;
|
||||||
|
|
||||||
|
void _demarrer(BuildContext context) {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (_) => ChecklistDepartScreen(item: item)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: EnsupColors.soft,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: EnsupColors.text.withValues(alpha: 0.14),
|
||||||
|
blurRadius: 60,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
EnsupTopBar(
|
||||||
|
brandText: "Détail matériel",
|
||||||
|
campusTag: DemoIdentity.campusTag,
|
||||||
|
userName: DemoIdentity.userName,
|
||||||
|
userInitials: DemoIdentity.userInitials,
|
||||||
|
onBack: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 24,
|
||||||
|
vertical: 26,
|
||||||
|
),
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Positioned.fill(child: BrandCorners()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _colonneGauche() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
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(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
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(
|
||||||
|
item.nom,
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 24,
|
||||||
|
height: 1,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: EnsupColors.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_row("Référence", item.reference),
|
||||||
|
_row("Catégorie", item.categorie),
|
||||||
|
_row("Marque", item.marque),
|
||||||
|
_row("Modèle", item.modele),
|
||||||
|
_row("État", item.etatGeneral, valueColor: EnsupColors.green),
|
||||||
|
_row("Campus", item.campus),
|
||||||
|
_rowStatut(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (item.accessoires.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Text(
|
||||||
|
"ACCESSOIRES INCLUS DANS LE KIT",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: item.accessoires.map(_tag).toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _row(String cle, String valeur, {Color? valueColor}) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
border: Border(bottom: BorderSide(color: EnsupColors.line)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
cle,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
valeur,
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: valueColor ?? EnsupColors.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _rowStatut() {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"Statut",
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
StatusPill(disponible: item.disponible),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _tag(String label) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: EnsupColors.teal.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: EnsupColors.teal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _colonneDroite(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 180,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: EnsupColors.cyan.withValues(alpha: 0.045),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: EnsupColors.cyan.withValues(alpha: 0.18),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
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),
|
||||||
|
Text(
|
||||||
|
"Aperçu du matériel",
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: item.disponible ? () => _demarrer(context) : null,
|
||||||
|
icon: const Icon(Icons.arrow_forward, size: 18),
|
||||||
|
label: Text(
|
||||||
|
"Démarrer l'emprunt",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: EnsupColors.cyan,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: EnsupColors.text2,
|
||||||
|
side: const BorderSide(color: EnsupColors.line, width: 1.5),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
"Retour au catalogue",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../demo_identity.dart";
|
||||||
|
import "../services/auth_service.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
import "../widgets/app_message.dart";
|
||||||
|
import "../widgets/ensup_top_bar.dart";
|
||||||
|
import "../widgets/profile_card.dart";
|
||||||
|
import "../widgets/action_card.dart";
|
||||||
|
import "../widgets/brand_corners.dart";
|
||||||
|
import "catalogue_screen.dart";
|
||||||
|
import "restitution_select_screen.dart";
|
||||||
|
|
||||||
|
/// Écran d'accueil étudiant : profil + choix Emprunter / Restituer.
|
||||||
|
class HomeScreen extends StatelessWidget {
|
||||||
|
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
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: EnsupColors.soft,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: EnsupColors.text.withValues(alpha: 0.14),
|
||||||
|
blurRadius: 60,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
EnsupTopBar(
|
||||||
|
campusTag: DemoIdentity.campusTag,
|
||||||
|
userName: DemoIdentity.userName,
|
||||||
|
userInitials: DemoIdentity.userInitials,
|
||||||
|
onBack: () async => _quitter(context),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 28,
|
||||||
|
vertical: 26,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1040),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ProfileCard(
|
||||||
|
name: DemoIdentity.userName,
|
||||||
|
subtitle: DemoIdentity.userSubtitle,
|
||||||
|
initials: DemoIdentity.userInitials,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
Text(
|
||||||
|
"Que souhaitez-vous faire ?",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 30,
|
||||||
|
height: 1,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: EnsupColors.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
"Choisissez l’opé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,
|
||||||
|
title: "EMPRUNTER",
|
||||||
|
description:
|
||||||
|
"Consulter les équipements disponibles et démarrer un nouvel emprunt.",
|
||||||
|
color: EnsupColors.cyan,
|
||||||
|
onTap: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) =>
|
||||||
|
const CatalogueScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final restituer = ActionCard(
|
||||||
|
icon: Icons.assignment_return_outlined,
|
||||||
|
title: "RESTITUER",
|
||||||
|
description:
|
||||||
|
"Sélectionner un emprunt en cours et contrôler le matériel rendu.",
|
||||||
|
color: EnsupColors.teal,
|
||||||
|
onTap: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Positioned.fill(child: BrandCorners()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../demo_identity.dart";
|
||||||
|
import "../services/api_client.dart";
|
||||||
|
import "../services/auth_service.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
import "../widgets/app_message.dart";
|
||||||
|
import "../widgets/ensup_top_bar.dart";
|
||||||
|
import "../widgets/identification_option.dart";
|
||||||
|
import "../widgets/brand_corners.dart";
|
||||||
|
import "home_screen.dart";
|
||||||
|
import "responsable_screen.dart";
|
||||||
|
|
||||||
|
/// Écran de démarrage : identification de l'étudiant (RG01/RG02).
|
||||||
|
class LoginScreen extends StatefulWidget {
|
||||||
|
const LoginScreen({super.key});
|
||||||
|
|
||||||
|
@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(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const HomeScreen(),
|
||||||
|
settings: const RouteSettings(name: "home"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: EnsupColors.soft,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: EnsupColors.text.withValues(alpha: 0.14),
|
||||||
|
blurRadius: 60,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const EnsupTopBar(
|
||||||
|
brandText: "EME — Emprunt Matériel ENSUP",
|
||||||
|
campusTag: DemoIdentity.loginCampusTag,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFFF0F6FF), Color(0xFFF8FAFC)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 28,
|
||||||
|
vertical: 40,
|
||||||
|
),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
colors: [
|
||||||
|
EnsupColors.blue3,
|
||||||
|
EnsupColors.cyan,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(22),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: EnsupColors.blue3.withValues(
|
||||||
|
alpha: 0.35,
|
||||||
|
),
|
||||||
|
blurRadius: 24,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.badge_outlined,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 34,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
"Bienvenue sur EME",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 34,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: EnsupColors.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
"Emprunt Matériel ENSUP · Accès réservé aux membres du groupe ENSUP",
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 14,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
IdentificationOption(
|
||||||
|
icon: Icons.qr_code_2,
|
||||||
|
iconColor: EnsupColors.cyan,
|
||||||
|
label: "Scanner ma carte étudiante",
|
||||||
|
description:
|
||||||
|
"Présentez votre carte étudiante dématérialisée à la douchette ou à la tablette",
|
||||||
|
onTap: _connexionEnCours
|
||||||
|
? null
|
||||||
|
: _identifierParQr,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
IdentificationOption(
|
||||||
|
icon: Icons.mail_outline,
|
||||||
|
iconColor: EnsupColors.teal,
|
||||||
|
label:
|
||||||
|
"Connexion compte professionnel ENSUP",
|
||||||
|
description:
|
||||||
|
"Utiliser mon adresse e-mail Microsoft 365",
|
||||||
|
onTap: _connexionEnCours
|
||||||
|
? null
|
||||||
|
: () => _connecterMicrosoft(
|
||||||
|
responsableDemo: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Expanded(
|
||||||
|
child: Divider(color: EnsupColors.line),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
"ou",
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 12,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Expanded(
|
||||||
|
child: Divider(color: EnsupColors.line),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _connexionEnCours
|
||||||
|
? null
|
||||||
|
: () => _connecterMicrosoft(
|
||||||
|
responsableDemo: true,
|
||||||
|
),
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.person_outline,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
"Accès responsable matériel",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: EnsupColors.text2,
|
||||||
|
side: const BorderSide(
|
||||||
|
color: EnsupColors.line,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 14,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Positioned.fill(child: BrandCorners()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,388 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../demo_identity.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
import "../models/emprunt_item.dart";
|
||||||
|
import "../services/emprunt_service.dart";
|
||||||
|
import "../services/materiel_service.dart";
|
||||||
|
import "../widgets/ensup_top_bar.dart";
|
||||||
|
import "../widgets/brand_corners.dart";
|
||||||
|
import "../widgets/app_message.dart";
|
||||||
|
import "checklist_retour_screen.dart";
|
||||||
|
|
||||||
|
/// Sélection de l'emprunt en cours à restituer (RG15 : ses propres emprunts).
|
||||||
|
class RestitutionSelectScreen extends StatefulWidget {
|
||||||
|
const RestitutionSelectScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<RestitutionSelectScreen> createState() =>
|
||||||
|
_RestitutionSelectScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RestitutionSelectScreenState extends State<RestitutionSelectScreen> {
|
||||||
|
late Future<List<EmpruntItem>> _future;
|
||||||
|
int? _chargementId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_future = EmpruntService.getMesEmprunts();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _recharger() {
|
||||||
|
setState(() => _future = EmpruntService.getMesEmprunts());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _restituer(EmpruntItem emprunt) async {
|
||||||
|
setState(() => _chargementId = emprunt.id);
|
||||||
|
try {
|
||||||
|
final detail = await MaterielService.getDetail(emprunt.materiel.id);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => ChecklistRetourScreen(
|
||||||
|
emprunt: emprunt.copyWith(materiel: detail),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AppMessage.erreur(context, error);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _chargementId = null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: EnsupColors.soft,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: EnsupColors.text.withValues(alpha: 0.14),
|
||||||
|
blurRadius: 60,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
EnsupTopBar(
|
||||||
|
brandText: "Restitution",
|
||||||
|
campusTag: DemoIdentity.campusTag,
|
||||||
|
userName: DemoIdentity.userName,
|
||||||
|
userInitials: DemoIdentity.userInitials,
|
||||||
|
onBack: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 32,
|
||||||
|
vertical: 28,
|
||||||
|
),
|
||||||
|
child: FutureBuilder<List<EmpruntItem>>(
|
||||||
|
future: _future,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState ==
|
||||||
|
ConnectionState.waiting) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return _ErreurState(
|
||||||
|
message: snapshot.error.toString(),
|
||||||
|
onRetry: _recharger,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _contenu(snapshot.data ?? const []);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Positioned.fill(child: BrandCorners()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _contenu(List<EmpruntItem> emprunts) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"Sélectionner le matériel à restituer",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 28,
|
||||||
|
height: 1,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: EnsupColors.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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(
|
||||||
|
child: emprunts.isEmpty
|
||||||
|
? const _EmptyState()
|
||||||
|
: ListView.separated(
|
||||||
|
itemCount: emprunts.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final emprunt = emprunts[index];
|
||||||
|
return _EmpruntCard(
|
||||||
|
emprunt: emprunt,
|
||||||
|
loading: _chargementId == emprunt.id,
|
||||||
|
onRestituer: () => _restituer(emprunt),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmpruntCard extends StatelessWidget {
|
||||||
|
const _EmpruntCard({
|
||||||
|
required this.emprunt,
|
||||||
|
required this.loading,
|
||||||
|
required this.onRestituer,
|
||||||
|
});
|
||||||
|
|
||||||
|
final EmpruntItem emprunt;
|
||||||
|
final bool loading;
|
||||||
|
final VoidCallback onRestituer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
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: 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,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmptyState extends StatelessWidget {
|
||||||
|
const _EmptyState();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
"Aucun emprunt en cours à restituer",
|
||||||
|
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.muted),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ErreurState extends StatelessWidget {
|
||||||
|
const _ErreurState({required this.message, required this.onRetry});
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
final VoidCallback onRetry;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.cloud_off_outlined,
|
||||||
|
size: 40,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 14,
|
||||||
|
color: EnsupColors.text2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: onRetry,
|
||||||
|
child: Text(
|
||||||
|
"Réessayer",
|
||||||
|
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../demo_identity.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
import "../models/emprunt_item.dart";
|
||||||
|
import "../models/anomalie_retour.dart";
|
||||||
|
import "../widgets/ensup_top_bar.dart";
|
||||||
|
import "../widgets/brand_corners.dart";
|
||||||
|
|
||||||
|
class ResultatRetourScreen extends StatelessWidget {
|
||||||
|
const ResultatRetourScreen({
|
||||||
|
super.key,
|
||||||
|
required this.emprunt,
|
||||||
|
required this.anomalies,
|
||||||
|
});
|
||||||
|
|
||||||
|
final EmpruntItem emprunt;
|
||||||
|
final List<AnomalieRetour> anomalies;
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fond = _conforme
|
||||||
|
? const [Color(0xFFF0FDF4), Color(0xFFF8FAFC)]
|
||||||
|
: _enAttente
|
||||||
|
? const [Color(0xFFFFF7ED), Color(0xFFF8FAFC)]
|
||||||
|
: const [Color(0xFFFFF5F5), Color(0xFFF8FAFC)];
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: EnsupColors.soft,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: EnsupColors.text.withValues(alpha: 0.14),
|
||||||
|
blurRadius: 60,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
EnsupTopBar(
|
||||||
|
brandText: "EME · Résultat du contrôle",
|
||||||
|
campusTag: DemoIdentity.campusTag,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: fond,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 28,
|
||||||
|
vertical: 32,
|
||||||
|
),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (_conforme)
|
||||||
|
_succes()
|
||||||
|
else if (_enAttente)
|
||||||
|
_attente()
|
||||||
|
else
|
||||||
|
_anomalie(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.of(context).popUntil(
|
||||||
|
(route) =>
|
||||||
|
route.settings.name == "home",
|
||||||
|
),
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.home_outlined,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
"Retour à l'accueil",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: EnsupColors.cyan,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 16,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Positioned.fill(child: BrandCorners()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _succes() {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(36),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
colors: [Color(0xFFF0FDF4), Color(0xFFECFDF5)],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: const Color(0xFFBBF7D0), width: 2),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [Color(0xFF16A34A), Color(0xFF22C55E)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.check, color: Colors.white, size: 36),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Text(
|
||||||
|
"Retour conforme",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: const Color(0xFF15803D),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
"L'emprunt a été clôturé et le matériel est de nouveau disponible.",
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 14,
|
||||||
|
color: const Color(0xFF166534),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _anomalie() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final date =
|
||||||
|
"${_deux(now.day)}/${_deux(now.month)}/${now.year} · ${_deux(now.hour)}:${_deux(now.minute)}";
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: EnsupColors.red.withValues(alpha: 0.06),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: EnsupColors.red.withValues(alpha: 0.2),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.warning_amber_rounded,
|
||||||
|
color: EnsupColors.red,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"Retour non conforme — Anomalie détectée",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: EnsupColors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
"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é.",
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
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(12),
|
||||||
|
border: Border.all(color: EnsupColors.line, width: 1.5),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"DÉTAIL DE L'ANOMALIE",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
for (final anomalie in anomalies)
|
||||||
|
_drow(
|
||||||
|
anomalie.element,
|
||||||
|
anomalie.etatRetour,
|
||||||
|
valeurCouleur: EnsupColors.red,
|
||||||
|
),
|
||||||
|
_drow(
|
||||||
|
"Matériel",
|
||||||
|
"${emprunt.materiel.nom} · ${emprunt.materiel.reference}",
|
||||||
|
),
|
||||||
|
_drow("Emprunteur", DemoIdentity.userName),
|
||||||
|
_drow("Date détection", date),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Text(
|
||||||
|
"Le responsable matériel traitera cette anomalie. Vous pouvez retourner à l'accueil.",
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _drow(String cle, String valeur, {Color? valeurCouleur}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 7),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
cle,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
valeur,
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: valeurCouleur ?? EnsupColors.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import "dart:convert";
|
||||||
|
import "dart:typed_data";
|
||||||
|
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).
|
||||||
|
class ApiException implements Exception {
|
||||||
|
const ApiException(this.message);
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiFile {
|
||||||
|
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 {
|
||||||
|
ApiClient._();
|
||||||
|
|
||||||
|
static const String _baseUrl = "http://localhost:3000/api";
|
||||||
|
|
||||||
|
static Future<dynamic> get(String chemin) 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) {
|
||||||
|
return jsonDecode(reponse.body);
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
final uri = Uri.parse("$_baseUrl$chemin");
|
||||||
|
|
||||||
|
late final http.Response reponse;
|
||||||
|
try {
|
||||||
|
final authHeaders = await AuthService.requestHeaders();
|
||||||
|
reponse = await http.post(
|
||||||
|
uri,
|
||||||
|
headers: {...authHeaders, "content-type": "application/json"},
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
} 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),
|
||||||
|
);
|
||||||
|
} 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 String _messageErreur(http.Response reponse) {
|
||||||
|
try {
|
||||||
|
final corps = jsonDecode(reponse.body);
|
||||||
|
if (corps is Map && corps["error"] is Map) {
|
||||||
|
final message = (corps["error"] as Map)["message"];
|
||||||
|
if (message is String) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Corps non-JSON : on retombe sur le code HTTP.
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import "../models/emprunt_response.dart";
|
||||||
|
import "../models/emprunt_item.dart";
|
||||||
|
import "api_client.dart";
|
||||||
|
|
||||||
|
/// Appels liés aux emprunts.
|
||||||
|
class EmpruntService {
|
||||||
|
EmpruntService._();
|
||||||
|
|
||||||
|
static const int posteEmpruntId = 1;
|
||||||
|
|
||||||
|
static Future<EmpruntResponse> creerEmprunt({
|
||||||
|
required int materielId,
|
||||||
|
required String commentaireDepart,
|
||||||
|
required List<Map<String, dynamic>> elements,
|
||||||
|
}) async {
|
||||||
|
final reponse = await ApiClient.post("/emprunts", {
|
||||||
|
"materielId": materielId,
|
||||||
|
"posteEmpruntId": posteEmpruntId,
|
||||||
|
"modeIdentification": "MAIL_ENSUP",
|
||||||
|
if (commentaireDepart.trim().isNotEmpty)
|
||||||
|
"commentaireDepart": commentaireDepart.trim(),
|
||||||
|
"elements": elements,
|
||||||
|
});
|
||||||
|
|
||||||
|
final data =
|
||||||
|
(reponse as Map<String, dynamic>)["data"] as Map<String, dynamic>;
|
||||||
|
return EmpruntResponse.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<List<EmpruntItem>> getMesEmprunts() async {
|
||||||
|
final reponse = await ApiClient.get("/mes-emprunts");
|
||||||
|
final data = (reponse as Map<String, dynamic>)["data"] as List<dynamic>;
|
||||||
|
return data
|
||||||
|
.map((json) => EmpruntItem.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<EmpruntResponse> restituerEmprunt({
|
||||||
|
required int empruntId,
|
||||||
|
required String commentaireRetour,
|
||||||
|
required List<Map<String, dynamic>> elements,
|
||||||
|
}) async {
|
||||||
|
final reponse = await ApiClient.post("/emprunts/$empruntId/restitution", {
|
||||||
|
"modeIdentification": "MAIL_ENSUP",
|
||||||
|
if (commentaireRetour.trim().isNotEmpty)
|
||||||
|
"commentaireRetour": commentaireRetour.trim(),
|
||||||
|
"elements": elements,
|
||||||
|
});
|
||||||
|
|
||||||
|
final data =
|
||||||
|
(reponse as Map<String, dynamic>)["data"] as Map<String, dynamic>;
|
||||||
|
return EmpruntResponse.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> signalerTentativeRetour({
|
||||||
|
required int empruntId,
|
||||||
|
required String nomElement,
|
||||||
|
required String etatInitial,
|
||||||
|
required String etatDemande,
|
||||||
|
}) async {
|
||||||
|
final reponse = await ApiClient.post(
|
||||||
|
"/emprunts/$empruntId/alerte-changement-retour",
|
||||||
|
{
|
||||||
|
"nomElement": nomElement,
|
||||||
|
"etatInitial": etatInitial,
|
||||||
|
"etatDemande": etatDemande,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final data = Map<String, dynamic>.from(reponse["data"] as Map);
|
||||||
|
return data["alerteCreee"] as bool;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import "../models/materiel_item.dart";
|
||||||
|
import "api_client.dart";
|
||||||
|
|
||||||
|
/// Appels liés au catalogue matériel.
|
||||||
|
class MaterielService {
|
||||||
|
MaterielService._();
|
||||||
|
|
||||||
|
/// Récupère le catalogue (matériels disponibles du campus de l'utilisateur).
|
||||||
|
static Future<List<MaterielItem>> getCatalogue() async {
|
||||||
|
final reponse = await ApiClient.get("/materiels");
|
||||||
|
final data = (reponse as Map<String, dynamic>)["data"] as List<dynamic>;
|
||||||
|
return data.map((json) => MaterielItem.fromJson(json as Map<String, dynamic>)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère le détail d'un matériel, notamment les accessoires du kit.
|
||||||
|
static Future<MaterielItem> getDetail(int id) async {
|
||||||
|
final reponse = await ApiClient.get("/materiels/$id");
|
||||||
|
final data = (reponse as Map<String, dynamic>)["data"] as Map<String, dynamic>;
|
||||||
|
return MaterielItem.fromJson(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
|
||||||
|
/// Couleurs de la charte graphique ENSUP.
|
||||||
|
class EnsupColors {
|
||||||
|
EnsupColors._();
|
||||||
|
|
||||||
|
static const Color cyan = Color(0xFF009CE0);
|
||||||
|
static const Color blue1 = Color(0xFF006DAC);
|
||||||
|
static const Color blue2 = Color(0xFF0090D7);
|
||||||
|
static const Color blue3 = Color(0xFF005CAA);
|
||||||
|
static const Color teal = Color(0xFF198AB4);
|
||||||
|
static const Color purple = Color(0xFF7E5AA2);
|
||||||
|
static const Color red = Color(0xFFDD1B3D);
|
||||||
|
static const Color green = Color(0xFF16A34A);
|
||||||
|
|
||||||
|
static const Color text = Color(0xFF0F172A);
|
||||||
|
static const Color text2 = Color(0xFF334155);
|
||||||
|
static const Color muted = Color(0xFF64748B);
|
||||||
|
static const Color line = Color(0xFFE2E8F0);
|
||||||
|
static const Color soft = Color(0xFFF8FAFC);
|
||||||
|
static const Color white = Color(0xFFFFFFFF);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "ensup_colors.dart";
|
||||||
|
|
||||||
|
/// Thème global : couleurs ENSUP et police de corps Titillium Web.
|
||||||
|
/// Les titres utilisent Darker Grotesque, appliqué directement dans les widgets.
|
||||||
|
ThemeData buildEnsupTheme() {
|
||||||
|
final base = ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: ColorScheme.fromSeed(
|
||||||
|
seedColor: EnsupColors.cyan,
|
||||||
|
primary: EnsupColors.cyan,
|
||||||
|
),
|
||||||
|
scaffoldBackgroundColor: EnsupColors.soft,
|
||||||
|
);
|
||||||
|
|
||||||
|
return base.copyWith(
|
||||||
|
textTheme: GoogleFonts.titilliumWebTextTheme(base.textTheme).apply(
|
||||||
|
bodyColor: EnsupColors.text,
|
||||||
|
displayColor: EnsupColors.text,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
|
||||||
|
/// Grande carte d'action de l'accueil (Emprunter / Restituer).
|
||||||
|
class ActionCard extends StatefulWidget {
|
||||||
|
const ActionCard({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.description,
|
||||||
|
required this.color,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final Color color;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ActionCard> createState() => _ActionCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ActionCardState extends State<ActionCard> {
|
||||||
|
bool _hovered = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final color = widget.color;
|
||||||
|
return MouseRegion(
|
||||||
|
onEnter: (_) => setState(() => _hovered = true),
|
||||||
|
onExit: (_) => setState(() => _hovered = false),
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
constraints: const BoxConstraints(minHeight: 230),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _hovered
|
||||||
|
? color.withValues(alpha: 0.1)
|
||||||
|
: color.withValues(alpha: 0.06),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
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: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: widget.onTap,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
|
||||||
|
/// Coins décoratifs de la charte ENSUP : un L coloré en haut-gauche et en
|
||||||
|
/// bas-droit, en surimpression et non cliquables (comme dans la maquette).
|
||||||
|
class BrandCorners extends StatelessWidget {
|
||||||
|
const BrandCorners({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return IgnorePointer(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
child: CustomPaint(size: const Size(120, 120), painter: _TopLeftCornerPainter()),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
child: CustomPaint(size: const Size(120, 120), painter: _BottomRightCornerPainter()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const double _thickness = 22;
|
||||||
|
|
||||||
|
class _TopLeftCornerPainter extends CustomPainter {
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final paint = Paint()..color = EnsupColors.red;
|
||||||
|
// Barre verticale gauche (rouge) + segment haut rouge.
|
||||||
|
canvas.drawRect(Rect.fromLTWH(0, 0, _thickness, size.height), paint);
|
||||||
|
canvas.drawRect(const Rect.fromLTWH(0, 0, 28, _thickness), paint);
|
||||||
|
// Segments colorés de la barre horizontale.
|
||||||
|
paint.color = EnsupColors.purple;
|
||||||
|
canvas.drawRect(const Rect.fromLTWH(28, 0, 22, _thickness), paint);
|
||||||
|
paint.color = EnsupColors.teal;
|
||||||
|
canvas.drawRect(const Rect.fromLTWH(50, 0, 22, _thickness), paint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BottomRightCornerPainter extends CustomPainter {
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final paint = Paint()..color = EnsupColors.cyan;
|
||||||
|
// Barre verticale droite (cyan) + segment bas cyan.
|
||||||
|
canvas.drawRect(Rect.fromLTWH(size.width - _thickness, 0, _thickness, size.height), paint);
|
||||||
|
canvas.drawRect(Rect.fromLTWH(size.width - 28, size.height - _thickness, 28, _thickness), paint);
|
||||||
|
// Segments colorés de la barre horizontale.
|
||||||
|
paint.color = EnsupColors.blue2;
|
||||||
|
canvas.drawRect(Rect.fromLTWH(size.width - 50, size.height - _thickness, 22, _thickness), paint);
|
||||||
|
paint.color = EnsupColors.blue3;
|
||||||
|
canvas.drawRect(Rect.fromLTWH(size.width - 72, size.height - _thickness, 22, _thickness), paint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
|
||||||
|
/// Barre supérieure ENSUP. L'utilisateur et le bouton retour sont optionnels
|
||||||
|
/// (absents sur l'écran d'identification).
|
||||||
|
class EnsupTopBar extends StatelessWidget {
|
||||||
|
const EnsupTopBar({
|
||||||
|
super.key,
|
||||||
|
this.brandText = "EME",
|
||||||
|
required this.campusTag,
|
||||||
|
this.userName,
|
||||||
|
this.userInitials,
|
||||||
|
this.onBack,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String brandText;
|
||||||
|
final String campusTag;
|
||||||
|
final String? userName;
|
||||||
|
final String? userInitials;
|
||||||
|
final VoidCallback? onBack;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final name = userName;
|
||||||
|
final initials = userInitials;
|
||||||
|
final onBack = this.onBack;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
height: 60,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18),
|
||||||
|
color: EnsupColors.blue3,
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final compact = constraints.maxWidth < 620;
|
||||||
|
final showCampus = constraints.maxWidth >= 520;
|
||||||
|
final showName = constraints.maxWidth >= 760;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
if (onBack != null) ...[
|
||||||
|
_BackButton(onTap: onBack, compact: compact),
|
||||||
|
SizedBox(width: compact ? 5 : 8),
|
||||||
|
],
|
||||||
|
const Icon(Icons.badge_outlined, color: Colors.white, size: 18),
|
||||||
|
const SizedBox(width: 9),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
brandText,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
fontSize: compact ? 18 : 21,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
if (showCampus)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 11,
|
||||||
|
vertical: 5,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.14),
|
||||||
|
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 {
|
||||||
|
const _BackButton({required this.onTap, required this.compact});
|
||||||
|
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final bool compact;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Tooltip(
|
||||||
|
message: "Retour",
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(7),
|
||||||
|
child: Container(
|
||||||
|
height: 34,
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(7),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Avatar extends StatelessWidget {
|
||||||
|
const _Avatar({required this.initials});
|
||||||
|
|
||||||
|
final String initials;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: EnsupColors.purple,
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white.withValues(alpha: 0.3),
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
initials,
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
|
||||||
|
/// État possible d'un élément de checklist (départ ou retour).
|
||||||
|
enum EtatChecklist { present, absent, deteriore }
|
||||||
|
|
||||||
|
/// Case cliquable colorée selon l'état sélectionné (vert / rouge / jaune).
|
||||||
|
class EtatBox extends StatelessWidget {
|
||||||
|
const EtatBox({
|
||||||
|
super.key,
|
||||||
|
required this.type,
|
||||||
|
required this.selected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final EtatChecklist type;
|
||||||
|
final bool selected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
var background = Colors.white;
|
||||||
|
var border = EnsupColors.line;
|
||||||
|
Widget? contenu;
|
||||||
|
|
||||||
|
if (selected) {
|
||||||
|
switch (type) {
|
||||||
|
case EtatChecklist.present:
|
||||||
|
background = const Color(0xFFF0FDF4);
|
||||||
|
border = const Color(0xFF86EFAC);
|
||||||
|
contenu = const Icon(Icons.check, size: 16, color: Color(0xFF16A34A));
|
||||||
|
case EtatChecklist.absent:
|
||||||
|
background = const Color(0xFFFEF2F2);
|
||||||
|
border = const Color(0xFFFCA5A5);
|
||||||
|
contenu = const Icon(Icons.close, size: 16, color: EnsupColors.red);
|
||||||
|
case EtatChecklist.deteriore:
|
||||||
|
background = const Color(0xFFFFFBEB);
|
||||||
|
border = const Color(0xFFFDE68A);
|
||||||
|
contenu = const Icon(
|
||||||
|
Icons.priority_high,
|
||||||
|
size: 16,
|
||||||
|
color: Color(0xFFD97706),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: background,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: border, width: 2),
|
||||||
|
),
|
||||||
|
child: contenu,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tableau de checklist : une ligne par accessoire avec les 3 états possibles.
|
||||||
|
class ChecklistTableau extends StatelessWidget {
|
||||||
|
const ChecklistTableau({
|
||||||
|
super.key,
|
||||||
|
required this.accessoires,
|
||||||
|
required this.etats,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<String> accessoires;
|
||||||
|
final List<EtatChecklist> etats;
|
||||||
|
final void Function(int index, EtatChecklist etat) onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final columnWidth = constraints.maxWidth < 520 ? 58.0 : 72.0;
|
||||||
|
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(double columnWidth) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
border: Border(bottom: BorderSide(color: EnsupColors.line, width: 2)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _texteEntete("ÉLÉMENT")),
|
||||||
|
_colonneEntete("PRÉSENT", columnWidth),
|
||||||
|
_colonneEntete("ABSENT", columnWidth),
|
||||||
|
_colonneEntete("DÉTÉRIORÉ", columnWidth),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _texteEntete(String label) {
|
||||||
|
return Text(
|
||||||
|
label,
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _colonneEntete(String label, double width) {
|
||||||
|
return SizedBox(
|
||||||
|
width: width,
|
||||||
|
child: Center(
|
||||||
|
child: FittedBox(fit: BoxFit.scaleDown, child: _texteEntete(label)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _ligne(int index, double columnWidth) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
border: Border(bottom: BorderSide(color: EnsupColors.line)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
accessoires[index],
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 14,
|
||||||
|
color: EnsupColors.text2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_case(index, EtatChecklist.present, columnWidth),
|
||||||
|
_case(index, EtatChecklist.absent, columnWidth),
|
||||||
|
_case(index, EtatChecklist.deteriore, columnWidth),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _case(int index, EtatChecklist type, double width) {
|
||||||
|
return SizedBox(
|
||||||
|
width: width,
|
||||||
|
child: Center(
|
||||||
|
child: EtatBox(
|
||||||
|
type: type,
|
||||||
|
selected: etats[index] == type,
|
||||||
|
onTap: () => onChanged(index, type),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encadré d'instructions / rappel à côté d'une checklist.
|
||||||
|
class ChecklistInstructions extends StatelessWidget {
|
||||||
|
const ChecklistInstructions({
|
||||||
|
super.key,
|
||||||
|
required this.titre,
|
||||||
|
required this.texte,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String titre;
|
||||||
|
final String texte;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: EnsupColors.blue2.withValues(alpha: 0.05),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(
|
||||||
|
color: EnsupColors.blue2.withValues(alpha: 0.15),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
titre.toUpperCase(),
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
texte,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
color: EnsupColors.text2,
|
||||||
|
height: 1.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zone de commentaire optionnel sous une checklist.
|
||||||
|
class ChecklistCommentaire extends StatelessWidget {
|
||||||
|
const ChecklistCommentaire({
|
||||||
|
super.key,
|
||||||
|
this.controller,
|
||||||
|
this.hint = "Commentaire ou observation optionnelle...",
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController? controller;
|
||||||
|
final String hint;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextField(
|
||||||
|
controller: controller,
|
||||||
|
maxLines: 4,
|
||||||
|
style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.text),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: hint,
|
||||||
|
hintStyle: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.white,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(9),
|
||||||
|
borderSide: const BorderSide(color: EnsupColors.line, width: 1.5),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(9),
|
||||||
|
borderSide: const BorderSide(color: EnsupColors.cyan, width: 1.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
|
||||||
|
/// Option d'identification de l'écran de connexion (QR ou mail ENSUP).
|
||||||
|
class IdentificationOption extends StatefulWidget {
|
||||||
|
const IdentificationOption({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.iconColor,
|
||||||
|
required this.label,
|
||||||
|
required this.description,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final Color iconColor;
|
||||||
|
final String label;
|
||||||
|
final String description;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<IdentificationOption> createState() => _IdentificationOptionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _IdentificationOptionState extends State<IdentificationOption> {
|
||||||
|
bool _hovered = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MouseRegion(
|
||||||
|
onEnter: (_) => setState(() => _hovered = true),
|
||||||
|
onExit: (_) => setState(() => _hovered = false),
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 160),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: _hovered ? widget.iconColor : EnsupColors.line,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: widget.iconColor.withValues(
|
||||||
|
alpha: _hovered ? 0.12 : 0.035,
|
||||||
|
),
|
||||||
|
blurRadius: _hovered ? 16 : 8,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
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: [
|
||||||
|
Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: widget.iconColor.withValues(alpha: 0.1),
|
||||||
|
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(width: 8),
|
||||||
|
Container(
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: widget.iconColor.withValues(alpha: 0.08),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.chevron_right_rounded,
|
||||||
|
color: widget.iconColor,
|
||||||
|
size: 19,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
import "../models/materiel_item.dart";
|
||||||
|
import "status_pill.dart";
|
||||||
|
|
||||||
|
/// Ligne du catalogue matériel (fidèle au `.mat-card` de la maquette).
|
||||||
|
class MaterielCard extends StatelessWidget {
|
||||||
|
const MaterielCard({super.key, required this.item, this.onSelect});
|
||||||
|
|
||||||
|
final MaterielItem item;
|
||||||
|
final VoidCallback? onSelect;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final disponible = item.disponible;
|
||||||
|
final accent = disponible ? EnsupColors.cyan : EnsupColors.muted;
|
||||||
|
|
||||||
|
return Opacity(
|
||||||
|
opacity: disponible ? 1 : 0.55,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
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: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
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(
|
||||||
|
"Indisponible",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 14,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
|
||||||
|
/// Carte de profil de l'étudiant identifié (accueil).
|
||||||
|
class ProfileCard extends StatelessWidget {
|
||||||
|
const ProfileCard({
|
||||||
|
super.key,
|
||||||
|
required this.name,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.initials,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final String subtitle;
|
||||||
|
final String initials;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: EnsupColors.cyan.withValues(alpha: 0.055),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: EnsupColors.cyan.withValues(alpha: 0.15),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: EnsupColors.blue3.withValues(alpha: 0.045),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
|
||||||
|
/// Pastille de statut d'un matériel (disponible / emprunté).
|
||||||
|
class StatusPill extends StatelessWidget {
|
||||||
|
const StatusPill({super.key, required this.disponible});
|
||||||
|
|
||||||
|
final bool disponible;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final label = disponible ? "Disponible" : "Emprunté";
|
||||||
|
final background = disponible ? const Color(0xFFDCFCE7) : const Color(0xFFFEF9C3);
|
||||||
|
final foreground = disponible ? const Color(0xFF15803D) : const Color(0xFFA16207);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(999)),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: foreground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+520
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
# Generated by pub
|
||||||
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
|
packages:
|
||||||
|
args:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: args
|
||||||
|
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
|
async:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: async
|
||||||
|
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.13.1"
|
||||||
|
boolean_selector:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: boolean_selector
|
||||||
|
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
|
characters:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: characters
|
||||||
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.1"
|
||||||
|
clock:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: clock
|
||||||
|
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.2"
|
||||||
|
collection:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: collection
|
||||||
|
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.19.1"
|
||||||
|
crypto:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: crypto
|
||||||
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.7"
|
||||||
|
cupertino_icons:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: cupertino_icons
|
||||||
|
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.9"
|
||||||
|
fake_async:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fake_async
|
||||||
|
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.3"
|
||||||
|
ffi:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: ffi
|
||||||
|
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
flutter:
|
||||||
|
dependency: "direct main"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
flutter_lints:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: flutter_lints
|
||||||
|
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.0.0"
|
||||||
|
flutter_test:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
google_fonts:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: google_fonts
|
||||||
|
sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.1.0"
|
||||||
|
http:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: http
|
||||||
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.6.0"
|
||||||
|
http_parser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_parser
|
||||||
|
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.2"
|
||||||
|
jni:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni
|
||||||
|
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
|
jni_flutter:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni_flutter
|
||||||
|
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.1"
|
||||||
|
leak_tracker:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker
|
||||||
|
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "11.0.2"
|
||||||
|
leak_tracker_flutter_testing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker_flutter_testing
|
||||||
|
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.10"
|
||||||
|
leak_tracker_testing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker_testing
|
||||||
|
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.2"
|
||||||
|
lints:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: lints
|
||||||
|
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.1.0"
|
||||||
|
matcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: matcher
|
||||||
|
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.12.19"
|
||||||
|
material_color_utilities:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: material_color_utilities
|
||||||
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.13.0"
|
||||||
|
meta:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: meta
|
||||||
|
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.18.0"
|
||||||
|
package_config:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_config
|
||||||
|
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
path:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path
|
||||||
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.9.1"
|
||||||
|
path_provider:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider
|
||||||
|
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.6"
|
||||||
|
path_provider_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_android
|
||||||
|
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.1"
|
||||||
|
path_provider_foundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_foundation
|
||||||
|
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.1"
|
||||||
|
path_provider_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_linux
|
||||||
|
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.2"
|
||||||
|
path_provider_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_platform_interface
|
||||||
|
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.3"
|
||||||
|
path_provider_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_windows
|
||||||
|
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.0"
|
||||||
|
platform:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: platform
|
||||||
|
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.6"
|
||||||
|
plugin_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: plugin_platform_interface
|
||||||
|
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.8"
|
||||||
|
sky_engine:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
source_span:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_span
|
||||||
|
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.10.2"
|
||||||
|
stack_trace:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stack_trace
|
||||||
|
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.12.1"
|
||||||
|
stream_channel:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stream_channel
|
||||||
|
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.4"
|
||||||
|
string_scanner:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: string_scanner
|
||||||
|
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.1"
|
||||||
|
term_glyph:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: term_glyph
|
||||||
|
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.2"
|
||||||
|
test_api:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: test_api
|
||||||
|
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.11"
|
||||||
|
typed_data:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: typed_data
|
||||||
|
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
|
vector_math:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vector_math
|
||||||
|
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
vm_service:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vm_service
|
||||||
|
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "15.2.0"
|
||||||
|
web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: web
|
||||||
|
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
|
xdg_directories:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xdg_directories
|
||||||
|
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
sdks:
|
||||||
|
dart: ">=3.10.0 <4.0.0"
|
||||||
|
flutter: ">=3.38.0"
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
name: eme_frontend
|
||||||
|
description: "A new Flutter project."
|
||||||
|
# The following line prevents the package from being accidentally published to
|
||||||
|
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||||
|
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
|
|
||||||
|
# The following defines the version and build number for your application.
|
||||||
|
# A version number is three numbers separated by dots, like 1.2.43
|
||||||
|
# followed by an optional build number separated by a +.
|
||||||
|
# Both the version and the builder number may be overridden in flutter
|
||||||
|
# build by specifying --build-name and --build-number, respectively.
|
||||||
|
# In Android, build-name is used as versionName while build-number used as versionCode.
|
||||||
|
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
|
||||||
|
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
|
||||||
|
# Read more about iOS versioning at
|
||||||
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
|
version: 1.0.0+1
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: ^3.10.0
|
||||||
|
|
||||||
|
# Dependencies specify other packages that your package needs in order to work.
|
||||||
|
# To automatically upgrade your package dependencies to the latest versions
|
||||||
|
# consider running `flutter pub upgrade --major-versions`. Alternatively,
|
||||||
|
# dependencies can be manually updated by changing the version numbers below to
|
||||||
|
# the latest version available on pub.dev. To see which dependencies have newer
|
||||||
|
# versions available, run `flutter pub outdated`.
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
# The following adds the Cupertino Icons font to your application.
|
||||||
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
|
cupertino_icons: ^1.0.8
|
||||||
|
google_fonts: ^8.1.0
|
||||||
|
http: ^1.6.0
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
# The "flutter_lints" package below contains a set of recommended lints to
|
||||||
|
# encourage good coding practices. The lint set provided by the package is
|
||||||
|
# activated in the `analysis_options.yaml` file located at the root of your
|
||||||
|
# package. See that file for information about deactivating specific lint
|
||||||
|
# rules and activating additional ones.
|
||||||
|
flutter_lints: ^6.0.0
|
||||||
|
|
||||||
|
# For information on the generic Dart part of this file, see the
|
||||||
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
|
|
||||||
|
# The following section is specific to Flutter packages.
|
||||||
|
flutter:
|
||||||
|
|
||||||
|
# The following line ensures that the Material Icons font is
|
||||||
|
# included with your application, so that you can use the icons in
|
||||||
|
# the material Icons class.
|
||||||
|
uses-material-design: true
|
||||||
|
|
||||||
|
# To add assets to your application, add an assets section, like this:
|
||||||
|
# assets:
|
||||||
|
# - images/a_dot_burr.jpeg
|
||||||
|
# - images/a_dot_ham.jpeg
|
||||||
|
|
||||||
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
|
# https://flutter.dev/to/resolution-aware-images
|
||||||
|
|
||||||
|
# For details regarding adding assets from package dependencies, see
|
||||||
|
# https://flutter.dev/to/asset-from-package
|
||||||
|
|
||||||
|
# To add custom fonts to your application, add a fonts section here,
|
||||||
|
# in this "flutter" section. Each entry in this list should have a
|
||||||
|
# "family" key with the font family name, and a "fonts" key with a
|
||||||
|
# list giving the asset and other descriptors for the font. For
|
||||||
|
# example:
|
||||||
|
# fonts:
|
||||||
|
# - family: Schyler
|
||||||
|
# fonts:
|
||||||
|
# - asset: fonts/Schyler-Regular.ttf
|
||||||
|
# - asset: fonts/Schyler-Italic.ttf
|
||||||
|
# style: italic
|
||||||
|
# - family: Trajan Pro
|
||||||
|
# fonts:
|
||||||
|
# - asset: fonts/TrajanPro.ttf
|
||||||
|
# - asset: fonts/TrajanPro_Bold.ttf
|
||||||
|
# weight: 700
|
||||||
|
#
|
||||||
|
# For details regarding fonts from package dependencies,
|
||||||
|
# see https://flutter.dev/to/font-from-package
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import "package:flutter_test/flutter_test.dart";
|
||||||
|
import "package:eme_frontend/main.dart";
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets("L'écran de démarrage affiche l'identification", (tester) async {
|
||||||
|
await tester.pumpWidget(const EmeApp());
|
||||||
|
|
||||||
|
expect(find.text("Bienvenue sur EME"), findsOneWidget);
|
||||||
|
expect(find.text("Scanner ma carte étudiante"), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 917 B |
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user