68 Commits

Author SHA1 Message Date
SaidSoighiri94 cb71b8fd8c chore(db): add SQL Server demo snapshot 2026-07-23 15:55:13 +02:00
SaidSoighiri94 81b23e0707 docs: record SSO integration progress 2026-07-23 15:54:47 +02:00
SaidSoighiri94 36224f30ee feat(auth): restore authenticated user profile 2026-07-23 14:39:35 +02:00
SaidSoighiri94 dc208fa13a feat(auth): connect Flutter login to MSAL 2026-07-23 14:13:58 +02:00
SaidSoighiri94 ef31e2ab8c feat(auth): prepare MSAL browser integration 2026-07-23 13:38:20 +02:00
SaidSoighiri94 530bd52cb5 feat(frontend): simulate authenticated user sessions 2026-07-23 12:39:12 +02:00
SaidSoighiri94 313bb1c6ef feat(auth): secure backend authentication modes 2026-07-23 12:35:21 +02:00
SaidSoighiri94 f7ce98343e merge: student premium workspace 2026-07-23 10:30:03 +02:00
SaidSoighiri94 669ed85924 docs: record student workspace polish 2026-07-23 10:29:47 +02:00
SaidSoighiri94 695b0d3965 style(frontend): polish student workspace 2026-07-23 10:27:27 +02:00
SaidSoighiri94 1c8ed77ebf fix(frontend): correct responsable brand label 2026-07-23 09:56:19 +02:00
SaidSoighiri94 1f0a120ecb merge: responsable history csv export 2026-07-23 09:42:27 +02:00
SaidSoighiri94 cb3597874d docs: record responsable csv export 2026-07-23 09:42:14 +02:00
SaidSoighiri94 c85fab8ca3 feat(frontend): download responsable history csv 2026-07-23 09:42:01 +02:00
SaidSoighiri94 95c9080777 merge: responsable premium dashboard 2026-07-23 09:25:47 +02:00
SaidSoighiri94 6ac94becfb docs: record responsable workspace redesign 2026-07-23 09:25:30 +02:00
SaidSoighiri94 ef74fd49bc style(frontend): redesign responsable workspace 2026-07-22 17:26:00 +02:00
SaidSoighiri94 1677015254 merge: synchronize project review status 2026-07-22 16:50:54 +02:00
SaidSoighiri94 9a71eb3f34 docs: synchronize project review status 2026-07-22 16:50:37 +02:00
SaidSoighiri94 2a533e3368 merge: responsable frontend polish 2026-07-18 23:32:37 +02:00
SaidSoighiri94 545d9f14ac feat(frontend): polish responsable workspace 2026-07-17 13:58:30 +02:00
SaidSoighiri94 4b635b8654 merge: responsable frontend api 2026-07-17 11:58:16 +02:00
SaidSoighiri94 2a1281b0e5 feat(frontend): add responsable dashboard screen 2026-07-17 11:55:44 +02:00
SaidSoighiri94 736328c66b merge: responsable anomalies status cycle 2026-07-17 10:07:10 +02:00
SaidSoighiri94 2772f4cfd4 feat(backend): add responsable anomalies status cycle 2026-07-17 10:06:39 +02:00
SaidSoighiri94 9d8ae70063 merge: responsable historique api 2026-07-16 16:48:02 +02:00
SaidSoighiri94 92e25356bf feat(backend): add responsable historique endpoint 2026-07-16 16:45:41 +02:00
SaidSoighiri94 832aa51e23 merge: responsable notifications api 2026-07-15 19:47:55 +02:00
SaidSoighiri94 d2d2245675 feat(backend): add responsable notifications endpoints 2026-07-15 15:50:08 +02:00
SaidSoighiri94 a14d6827ee merge: responsable anomalies api 2026-07-15 15:41:58 +02:00
SaidSoighiri94 1ce023de71 feat(backend): add responsable anomalies endpoint 2026-07-15 15:30:24 +02:00
SaidSoighiri94 26f036046d merge: responsable stock api 2026-07-15 12:57:19 +02:00
SaidSoighiri94 c76f546f2a feat(backend): add responsable stock endpoint 2026-07-15 12:35:16 +02:00
SaidSoighiri94 2c81bd7208 merge: responsable emprunts consultation api 2026-07-15 12:25:47 +02:00
SaidSoighiri94 f7f61a8ebd feat(backend): add responsable emprunts endpoint 2026-07-15 11:47:36 +02:00
SaidSoighiri94 b2a5f8cd94 merge: responsable dashboard api 2026-07-15 11:36:57 +02:00
SaidSoighiri94 a3795b41a0 feat(backend): add responsable dashboard endpoint 2026-07-15 11:04:09 +02:00
SaidSoighiri94 e2165b9826 merge: readme v1 docs 2026-07-15 10:19:17 +02:00
SaidSoighiri94 1d271e225c docs: add V1 setup README 2026-07-09 10:52:12 +02:00
SaidSoighiri94 24e2793cf8 merge: review current state docs 2026-07-09 10:43:33 +02:00
SaidSoighiri94 295dd03b8d docs: clarify review current state 2026-07-09 10:01:30 +02:00
SaidSoighiri94 3681ed5e44 merge: v1 student polish 2026-07-09 09:33:15 +02:00
SaidSoighiri94 cbc67c6c2f chore(frontend): update Flutter lockfile 2026-07-09 09:32:27 +02:00
SaidSoighiri94 eb3094f3a6 style(frontend): harmonize student demo labels 2026-07-08 14:44:09 +02:00
SaidSoighiri94 245a6905a3 merge: student flow stabilization 2026-07-08 11:52:51 +02:00
SaidSoighiri94 8c710b6e3c docs: record student flow stabilization 2026-07-08 11:21:44 +02:00
SaidSoighiri94 9c74de53c4 merge: frontend restitution api 2026-07-08 10:52:28 +02:00
SaidSoighiri94 78a0c1305e feat(frontend): connect return flow to api 2026-07-07 14:10:12 +02:00
SaidSoighiri94 d83aa7dd75 merge: demo catalogue fixtures 2026-07-07 14:05:37 +02:00
SaidSoighiri94 e46ec29543 feat(db): enrich demo catalogue fixtures 2026-07-07 13:47:55 +02:00
SaidSoighiri94 98ba359bc5 merge: frontend borrow api 2026-07-07 13:38:17 +02:00
SaidSoighiri94 7aba1a930d feat(frontend): connect borrow flow to api 2026-07-07 13:22:53 +02:00
SaidSoighiri94 27171562de merge: catalogue detail api 2026-07-07 13:14:20 +02:00
SaidSoighiri94 9564dd1ade feat(materiel): connect catalogue and detail to api 2026-07-07 13:07:47 +02:00
SaidSoighiri94 90d5a65f29 feat(frontend): add restitution result screen (completes student flow) 2026-07-07 10:08:33 +02:00
SaidSoighiri94 e9d67e78f5 feat(frontend): add return checklist screen with shared checklist widgets 2026-07-07 09:45:15 +02:00
SaidSoighiri94 648ea21155 feat(frontend): add restitution selection screen 2026-07-06 15:45:09 +02:00
SaidSoighiri94 0f72c021ba feat(frontend): add emprunt confirmation screen (completes borrow flow) 2026-07-06 15:18:48 +02:00
SaidSoighiri94 9941ae97dc feat(frontend): add departure checklist screen 2026-07-06 15:02:31 +02:00
SaidSoighiri94 b124d7a775 feat(frontend): add material detail screen 2026-07-06 12:44:43 +02:00
SaidSoighiri94 84bd2cc629 feat(frontend): add material catalogue screen with search and filters 2026-07-06 11:44:43 +02:00
SaidSoighiri94 97a1ad9e84 feat(frontend): add back button in top bar (in-app navigation) 2026-07-06 11:37:25 +02:00
SaidSoighiri94 97faf3e9ee feat(frontend): add student identification screen as entry point 2026-07-06 11:25:40 +02:00
SaidSoighiri94 e492220af4 feat(frontend): add ENSUP brand corners to home screen 2026-07-06 11:05:53 +02:00
SaidSoighiri94 b2c999026f chore(docs): remove ENSUP charter PDF, reference validated mockup instead 2026-07-06 10:51:52 +02:00
SaidSoighiri94 d12302e8a6 feat(frontend): scaffold Flutter Web app with ENSUP home screen 2026-07-06 10:37:21 +02:00
SaidSoighiri94 be7d095fc3 feat(backend): add emprunt restitution with automatic anomaly detection 2026-07-06 10:10:09 +02:00
SaidSoighiri94 3215a3c11f feat(backend): expose POST /api/emprunts with departure checklist 2026-07-06 09:46:03 +02:00
83 changed files with 11549 additions and 62 deletions
+232
View File
@@ -0,0 +1,232 @@
# EME — Emprunt Matériel ENSUP
EME est une application web de gestion des emprunts de matériel pédagogique pour ENSUP / Ensitech.
La V1 couvre le parcours étudiant : consulter le catalogue, emprunter un matériel, restituer un emprunt et détecter automatiquement les retours non conformes.
## 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 de démo V1
1. Ouvrir `http://localhost:5000`.
2. S'identifier avec l'option mail ENSUP ou carte étudiante.
3. Cliquer sur `Emprunter`.
4. Sélectionner un matériel disponible.
5. Valider la checklist de départ.
6. Vérifier la confirmation de l'emprunt.
7. Revenir à l'accueil.
8. Cliquer sur `Restituer`.
9. Sélectionner un emprunt en cours.
10. Valider un retour conforme ou indiquer un élément absent/détérioré.
11. Vérifier le résultat conforme ou non conforme.
## 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
Terminé pour la V1 étudiant :
- catalogue matériel ;
- détail matériel ;
- emprunt avec checklist de départ ;
- restitution avec checklist de retour ;
- détection automatique de retour non conforme ;
- fixtures de démonstration ;
- tests runtime manuels du parcours étudiant.
Reste à faire :
- authentification Azure AD réelle ;
- parcours responsable matériel ;
- OpenAPI / Swagger ;
- tests automatisés ;
- documentation utilisateur complète ;
- déploiement production.
+5 -1
View File
@@ -9,5 +9,9 @@ DB_NAME=eme_db
DB_USER=sa
DB_PASSWORD=YourStrongPassword123!
AUTH_MODE=demo
AZURE_TENANT_ID=
AZURE_CLIENT_ID=
AZURE_API_CLIENT_ID=
AZURE_API_AUDIENCE=
AZURE_API_SCOPE=access_as_user
AZURE_ALLOWED_EMAIL_DOMAINS=ensup.eu,ensitech.eu
+58 -3
View File
@@ -14,7 +14,8 @@
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3"
"jsonwebtoken": "^9.0.3",
"jwks-rsa": "^4.1.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -1068,7 +1069,6 @@
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
@@ -1079,7 +1079,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
@@ -3074,6 +3073,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz",
"integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-md4": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz",
@@ -3134,6 +3142,23 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/jwks-rsa": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-4.1.0.tgz",
"integrity": "sha512-sbkByqyATKYJP5F4RXj03N5TUNC0QLTjCAZvwTzC4BwJZ8e0/cWxN8YROnyUth2g1/ONWi4eSFHeu6oYalrc3Q==",
"license": "MIT",
"dependencies": {
"@types/jsonwebtoken": "^9.0.4",
"debug": "^4.3.4",
"jose": "^6.1.3",
"limiter": "^1.1.5",
"lru-cache": "^11.0.0",
"lru-memoizer": "^3.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >= 23.0.0"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
@@ -3168,6 +3193,11 @@
"node": ">= 0.8.0"
}
},
"node_modules/limiter": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -3184,6 +3214,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -3233,6 +3269,25 @@
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/lru-memoizer": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-3.0.0.tgz",
"integrity": "sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==",
"license": "MIT",
"dependencies": {
"lodash.clonedeep": "^4.5.0",
"lru-cache": "^11.0.1"
}
},
"node_modules/lru.min": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
+3 -2
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "tsc -p tsconfig.build.json",
"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:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio",
@@ -43,6 +43,7 @@
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3"
"jsonwebtoken": "^9.0.3",
"jwks-rsa": "^4.1.0"
}
}
+190 -6
View File
@@ -51,14 +51,175 @@ function daysFromNow(n: number): Date {
return d;
}
async function main(): Promise<void> {
// Idempotence : on ne recharge pas si déjà présent.
const witness = await prisma.utilisateur.findUnique({ where: { email: WITNESS_EMAIL } });
if (witness) {
console.log('[fixtures] deja chargees — rien a faire');
return;
interface DemoCategories {
ordinateur: number;
video: number;
tablette: number;
camera: number;
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).
const roleEtudiant = required(
await prisma.role.findUnique({ where: { code: 'ETUDIANT' } }),
@@ -100,6 +261,28 @@ async function main(): Promise<void> {
await prisma.categorieMateriel.findFirst({ where: { nom: 'Périphérique audio' } }),
'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.
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 },
],
});
await assurerCatalogueDemo(campus.id, demoCategories);
// Emprunt 1 — EN_COURS (Marie / PC Dell) + checklist de départ.
const empruntEnCours = await prisma.emprunt.create({
+3 -1
View File
@@ -10,7 +10,9 @@ import { apiRouter } from './routes';
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(requestLogger);
+56 -4
View File
@@ -2,6 +2,8 @@ import dotenv from 'dotenv';
dotenv.config();
type AuthMode = 'demo' | 'azure';
function required(name: string): string {
const value = process.env[name];
@@ -28,9 +30,50 @@ function numberFromEnv(name: string, defaultValue: number): number {
return parsed;
}
function authModeFromEnv(nodeEnv: string): AuthMode {
const value = process.env.AUTH_MODE || (nodeEnv === 'production' ? 'azure' : 'demo');
if (value !== 'demo' && value !== 'azure') {
throw new Error('Variable denvironnement invalide: AUTH_MODE');
}
if (nodeEnv === 'production' && value === 'demo') {
throw new Error('AUTH_MODE=demo est interdit en production');
}
return value;
}
function azureValue(name: string, authMode: AuthMode): string {
const value = process.env[name];
if (authMode === 'azure' && !value) {
throw new Error(`Variable d'environnement manquante en mode Azure: ${name}`);
}
return value || '';
}
function listFromEnv(name: string, defaultValue: string[]): string[] {
const value = process.env[name];
if (!value) {
return defaultValue;
}
return value
.split(',')
.map((item) => item.trim().toLowerCase())
.filter((item) => item.length > 0);
}
const nodeEnv = process.env.NODE_ENV || 'development';
const authMode = authModeFromEnv(nodeEnv);
const azureApiClientId = azureValue('AZURE_API_CLIENT_ID', authMode);
export const env = {
port: numberFromEnv('PORT', 3000),
nodeEnv: process.env.NODE_ENV || 'development',
nodeEnv,
frontendUrl: required('FRONTEND_URL'),
databaseUrl: required('DATABASE_URL'),
db: {
@@ -40,8 +83,17 @@ export const env = {
user: required('DB_USER'),
password: required('DB_PASSWORD'),
},
azure: {
tenantId: required('AZURE_TENANT_ID'),
clientId: required('AZURE_CLIENT_ID'),
auth: {
mode: authMode,
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',
]),
},
},
};
@@ -1,7 +1,15 @@
import { Request, Response } from 'express';
import { AppError } from '../errors/app-error';
import { listerMesEmpruntsEnCours } from '../services/emprunt.service';
import { toEmpruntResponse } from '../dtos/emprunt.dto';
import {
creerEmprunt,
listerMesEmpruntsEnCours,
restituerEmprunt,
} from '../services/emprunt.service';
import {
parseCreerEmpruntRequest,
parseRestituerEmpruntRequest,
toEmpruntResponse,
} from '../dtos/emprunt.dto';
export async function getMesEmprunts(req: Request, res: Response): Promise<void> {
const user = req.user;
@@ -12,3 +20,30 @@ export async function getMesEmprunts(req: Request, res: Response): Promise<void>
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) });
}
@@ -1,7 +1,7 @@
import { Request, Response } from 'express';
import { AppError } from '../errors/app-error';
import { listerCatalogue } from '../services/materiel.service';
import { toMaterielResponse } from '../dtos/materiel.dto';
import { listerCatalogue, obtenirDetailMateriel } from '../services/materiel.service';
import { toMaterielDetailResponse, toMaterielResponse } from '../dtos/materiel.dto';
function parseCategorieId(value: unknown): number | undefined {
if (value === undefined) {
@@ -16,6 +16,18 @@ function parseCategorieId(value: unknown): number | undefined {
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) {
@@ -28,3 +40,13 @@ export async function getCatalogue(req: Request, res: Response): Promise<void> {
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,222 @@
import { Request, Response } from 'express';
import { AppError } from '../errors/app-error';
import {
changerStatutAnomalieResponsableService,
getDashboardResponsable,
listerAnomaliesResponsable,
listerHistoriqueResponsable,
listerNotificationsResponsable,
listerMaterielsResponsable,
listerEmpruntsResponsable,
marquerNotificationResponsableLue,
marquerToutesNotificationsResponsableLues,
parseAnomalieId,
parseCategorieId,
parseLu,
parseNotificationId,
parseDateQuery,
parsePositiveIntQuery,
parseStatutAnomalie,
parseStatutMateriel,
parseStatutEmprunt,
} from '../services/responsable.service';
import {
toAnomalieResponsableResponse,
toDashboardResponsableResponse,
toEmpruntResponsableResponse,
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'),
};
}
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 emprunts = await listerEmpruntsResponsable(user.roleCode, user.campusId, { statut });
res.json({ data: emprunts.map(toEmpruntResponsableResponse) });
}
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 materiels = await listerMaterielsResponsable(user.roleCode, user.campusId, {
statut,
categorieId,
recherche,
});
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 anomalies = await listerAnomaliesResponsable(user.roleCode, user.campusId, {
statut,
type,
});
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'));
}
+124
View File
@@ -1,5 +1,7 @@
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;
@@ -20,3 +22,125 @@ export function toEmpruntResponse(emprunt: EmpruntAvecMateriel): EmpruntResponse
materiel: toMaterielResponse(emprunt.materiel),
};
}
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 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;
}
+22 -1
View File
@@ -1,4 +1,4 @@
import { MaterielAvecCategorie } from '../repositories/materiel.repository';
import { MaterielAvecCategorie, MaterielDetail } from '../repositories/materiel.repository';
export interface CategorieResponse {
id: number;
@@ -16,6 +16,15 @@ export interface MaterielResponse {
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,
@@ -31,3 +40,15 @@ export function toMaterielResponse(materiel: MaterielAvecCategorie): MaterielRes
},
};
}
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),
};
}
+385
View File
@@ -0,0 +1,385 @@
import {
ActiviteResponsable,
AnomalieResponsable,
EmpruntResponsable,
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 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 toMaterielResponsableResponse(
materiel: MaterielResponsable,
): MaterielResponsableResponse {
return {
id: materiel.id,
nom: materiel.nom,
marque: materiel.marque,
modele: materiel.modele,
reference: materiel.reference,
statut: materiel.statut,
etatGeneral: materiel.etatGeneral,
categorie: {
id: materiel.categorie.id,
nom: materiel.categorie.nom,
},
accessoires: materiel.accessoires.map((liaison) => ({
id: liaison.accessoire.id,
nom: liaison.accessoire.nom,
quantiteAttendue: liaison.quantiteAttendue,
obligatoire: liaison.obligatoire,
})),
};
}
export function toAnomalieResponsableResponse(
anomalie: AnomalieResponsable,
): AnomalieResponsableResponse {
return {
id: anomalie.id,
type: anomalie.type,
description: anomalie.description,
statut: anomalie.statut,
detecteeAutomatiquement: anomalie.detecteeAutomatiquement,
observation: anomalie.observation,
dateDetection: anomalie.dateDetection,
dateResolution: anomalie.dateResolution,
emprunt: {
id: anomalie.emprunt.id,
statut: anomalie.emprunt.statut,
dateEmprunt: anomalie.emprunt.dateEmprunt,
dateRetourPrevue: anomalie.emprunt.dateRetourPrevue,
dateRetourReelle: anomalie.emprunt.dateRetourReelle,
},
etudiant: {
id: anomalie.etudiant.id,
nom: anomalie.etudiant.nom,
prenom: anomalie.etudiant.prenom,
email: anomalie.etudiant.email,
classe: anomalie.etudiant.classe,
},
materiel: {
id: anomalie.materiel.id,
nom: anomalie.materiel.nom,
reference: anomalie.materiel.reference,
statut: anomalie.materiel.statut,
categorie: {
id: anomalie.materiel.categorie.id,
nom: anomalie.materiel.categorie.nom,
},
},
traitePar: anomalie.traitePar
? {
id: anomalie.traitePar.id,
nom: anomalie.traitePar.nom,
prenom: anomalie.traitePar.prenom,
}
: null,
};
}
export function toNotificationResponsableResponse(
notification: NotificationResponsable,
): NotificationResponsableResponse {
return {
id: notification.id,
titre: notification.titre,
message: notification.message,
type: notification.type,
lu: notification.lu,
dateCreation: notification.dateCreation,
anomalie: notification.anomalie
? {
id: notification.anomalie.id,
statut: notification.anomalie.statut,
type: notification.anomalie.type,
empruntId: notification.anomalie.emprunt.id,
materiel: {
id: notification.anomalie.materiel.id,
nom: notification.anomalie.materiel.nom,
reference: notification.anomalie.materiel.reference,
},
}
: null,
};
}
export function toHistoriqueResponsableResponse(
historique: HistoriqueResponsable,
): HistoriqueResponsableResponse {
return {
id: historique.id,
action: historique.action,
description: historique.description,
dateAction: historique.dateAction,
utilisateur: {
id: historique.utilisateur.id,
nom: historique.utilisateur.nom,
prenom: historique.utilisateur.prenom,
email: historique.utilisateur.email,
},
materiel: historique.materiel
? {
id: historique.materiel.id,
nom: historique.materiel.nom,
reference: historique.materiel.reference,
}
: null,
empruntId: historique.empruntId,
sallePret: historique.sallePret
? {
id: historique.sallePret.id,
nom: historique.sallePret.nom,
}
: null,
posteEmprunt: historique.posteEmprunt
? {
id: historique.posteEmprunt.id,
nom: historique.posteEmprunt.nom,
}
: null,
};
}
+5 -23
View File
@@ -1,28 +1,10 @@
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../errors/app-error';
import { findByEmail } from '../repositories/utilisateur.repository';
import { AuthenticatedUser } from '../types/authenticated-user';
import { authenticateUser } from '../services/authentication.service';
/* Auth simulée (temporaire) : en attendant Azure AD, l'utilisateur courant est
résolu depuis l'en-tête `x-user-email`. À remplacer par la validation du JWT. */
export async function currentUser(req: Request, _res: Response, next: NextFunction): Promise<void> {
const email = req.header('x-user-email');
if (!email) {
throw new AppError(401, 'En-tete x-user-email requis (auth simulee)');
}
const utilisateur = await findByEmail(email);
if (!utilisateur || !utilisateur.actif) {
throw new AppError(401, 'Utilisateur non reconnu');
}
const authenticated: AuthenticatedUser = {
id: utilisateur.id,
email: utilisateur.email,
roleCode: utilisateur.role.code,
campusId: utilisateur.campusId,
};
req.user = authenticated;
req.user = await authenticateUser({
authorization: req.header('authorization'),
demoEmail: req.header('x-user-email'),
});
next();
}
@@ -1,11 +1,16 @@
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 } } };
}>;
export type EmpruntAvecChecklists = Prisma.EmpruntGetPayload<{
include: { checklists: { include: { elements: true } } };
}>;
const STATUTS_EN_COURS: StatutEmprunt[] = ['EN_COURS', 'EN_RETARD'];
export function findEnCoursParUtilisateur(utilisateurId: number): Promise<EmpruntAvecMateriel[]> {
@@ -18,3 +23,173 @@ export function findEnCoursParUtilisateur(utilisateurId: number): Promise<Emprun
orderBy: { dateRetourPrevue: 'asc' },
});
}
export function findByIdAvecChecklists(id: number): Promise<EmpruntAvecChecklists | null> {
return prisma.emprunt.findUnique({
where: { id },
include: { checklists: { include: { elements: true } } },
});
}
interface ChecklistElementData {
accessoireId?: number;
nomElement: string;
etat: string;
quantiteConstatee: number;
commentaire?: string;
}
export interface CreerEmpruntData {
utilisateurId: number;
materielId: number;
campusId: number;
sallePretId: number;
posteEmpruntId: number;
dateRetourPrevue: Date;
modeIdentification: string;
commentaireDepart?: string;
elements: ChecklistElementData[];
}
/* Création atomique : réservation du matériel (DISPONIBLE -> EMPRUNTE), emprunt,
checklist de départ et ses éléments. Si une étape échoue, tout est annulé. */
export function creerEmpruntComplet(data: CreerEmpruntData): Promise<EmpruntAvecMateriel> {
return prisma.$transaction(async (tx) => {
// Réservation atomique : empêche un double emprunt du même matériel (RG07).
const reservation = await tx.materiel.updateMany({
where: { id: data.materielId, statut: 'DISPONIBLE' },
data: { statut: 'EMPRUNTE' },
});
if (reservation.count === 0) {
throw new AppError(409, 'Materiel non disponible');
}
const emprunt = await tx.emprunt.create({
data: {
utilisateurId: data.utilisateurId,
materielId: data.materielId,
campusId: data.campusId,
sallePretId: data.sallePretId,
posteEmpruntId: data.posteEmpruntId,
dateEmprunt: new Date(),
dateRetourPrevue: data.dateRetourPrevue,
statut: 'EN_COURS',
modeIdentificationEmprunt: data.modeIdentification,
commentaireDepart: data.commentaireDepart ?? null,
},
});
await tx.checklist.create({
data: {
empruntId: emprunt.id,
utilisateurId: data.utilisateurId,
type: 'DEPART',
dateVerification: new Date(),
elements: {
create: data.elements.map((element) => ({
accessoireId: element.accessoireId ?? null,
nomElement: element.nomElement,
etat: element.etat,
quantiteConstatee: element.quantiteConstatee,
commentaire: element.commentaire ?? null,
})),
},
},
});
return tx.emprunt.findUniqueOrThrow({
where: { id: emprunt.id },
include: { materiel: { include: { categorie: true } } },
});
});
}
export interface RestituerEmpruntData {
empruntId: number;
utilisateurId: number;
materielId: number;
statutEmprunt: string;
statutMateriel: string;
modeIdentification: string;
commentaireRetour?: string;
elements: ChecklistElementData[];
anomalie?: {
type: string;
description: string;
responsablesIds: number[];
};
}
/* Restitution atomique : checklist de retour, mise à jour de l'emprunt et du
matériel, et — si non conforme — création de l'anomalie et des notifications. */
export function restituerEmpruntComplet(data: RestituerEmpruntData): Promise<EmpruntAvecMateriel> {
return prisma.$transaction(async (tx) => {
const maintenant = new Date();
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.emprunt.update({
where: { id: data.empruntId },
data: {
statut: data.statutEmprunt,
dateRetourReelle: maintenant,
modeIdentificationRetour: data.modeIdentification,
commentaireRetour: data.commentaireRetour ?? null,
},
});
await tx.materiel.update({
where: { id: data.materielId },
data: { statut: data.statutMateriel },
});
if (data.anomalie) {
const infoAnomalie = data.anomalie;
const anomalie = await tx.anomalie.create({
data: {
empruntId: data.empruntId,
materielId: data.materielId,
utilisateurId: data.utilisateurId,
type: infoAnomalie.type,
description: infoAnomalie.description,
statut: 'DETECTEE',
detecteeAutomatiquement: true,
dateDetection: maintenant,
},
});
if (infoAnomalie.responsablesIds.length > 0) {
await tx.notification.createMany({
data: infoAnomalie.responsablesIds.map((responsableId) => ({
utilisateurId: responsableId,
anomalieId: anomalie.id,
titre: 'Nouvelle anomalie detectee',
message: infoAnomalie.description,
type: 'ANOMALIE',
})),
});
}
}
return tx.emprunt.findUniqueOrThrow({
where: { id: data.empruntId },
include: { materiel: { include: { categorie: true } } },
});
});
}
@@ -1,7 +1,14 @@
import { Prisma } from '@prisma/client';
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;
@@ -35,3 +42,21 @@ export function findDisponiblesParCampus(
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,355 @@
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 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;
}
export interface MaterielResponsableFiltres {
statut?: string;
categorieId?: number;
recherche?: string;
}
export interface AnomalieResponsableFiltres {
statut?: string;
type?: string;
}
export interface NotificationResponsableFiltres {
lu?: boolean;
}
export interface HistoriqueResponsableFiltres {
action?: string;
utilisateurId?: number;
materielId?: number;
empruntId?: number;
dateDebut?: Date;
dateFin?: Date;
}
export interface ChangerStatutAnomalieData {
anomalieId: number;
campusId: number;
responsableId: number;
statut: string;
observation?: string;
}
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[]> {
return prisma.emprunt.findMany({
where: {
campusId,
...(filtres.statut ? { statut: filtres.statut } : {}),
},
include: {
utilisateur: true,
materiel: { include: { categorie: true } },
},
orderBy: { dateEmprunt: 'desc' },
});
}
export function findMaterielsResponsable(
campusId: number,
filtres: MaterielResponsableFiltres,
): Promise<MaterielResponsable[]> {
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: { nom: 'asc' },
});
}
export function findAnomaliesResponsable(
campusId: number,
filtres: AnomalieResponsableFiltres,
): Promise<AnomalieResponsable[]> {
return prisma.anomalie.findMany({
where: {
emprunt: { campusId },
...(filtres.statut ? { statut: filtres.statut } : {}),
...(filtres.type ? { type: filtres.type } : {}),
},
include: {
emprunt: true,
etudiant: true,
materiel: { include: { categorie: true } },
traitePar: true,
},
orderBy: { dateDetection: 'desc' },
});
}
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[]> {
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 } : {}),
},
}
: {}),
},
include: {
utilisateur: true,
materiel: true,
emprunt: true,
sallePret: true,
posteEmprunt: true,
},
orderBy: { dateAction: 'desc' },
});
}
@@ -14,9 +14,23 @@ export function findByEmail(email: string): Promise<UtilisateurAvecRole | null>
});
}
export function findByMicrosoftId(microsoftId: string): Promise<UtilisateurAvecRole | null> {
return prisma.utilisateur.findUnique({
where: { microsoftId },
include: { role: true },
});
}
export function findById(id: number): Promise<UtilisateurProfil | null> {
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 },
});
}
+5 -2
View File
@@ -1,6 +1,9 @@
import { Router } from 'express';
import { getMesEmprunts } from '../controllers/emprunt.controller';
import { getMesEmprunts, postEmprunt, postRestitution } from '../controllers/emprunt.controller';
export const empruntRoutes: Router = Router();
empruntRoutes.post('/', postEmprunt);
empruntRoutes.post('/:id/restitution', postRestitution);
empruntRoutes.get('/', getMesEmprunts);
export const mesEmpruntsRoutes: Router = Router();
mesEmpruntsRoutes.get('/', getMesEmprunts);
+5 -2
View File
@@ -2,7 +2,8 @@ import { Router } from 'express';
import { currentUser } from '../middlewares/current-user';
import { authRoutes } from './auth.routes';
import { materielRoutes } from './materiel.routes';
import { empruntRoutes } from './emprunt.routes';
import { empruntRoutes, mesEmpruntsRoutes } from './emprunt.routes';
import { responsableRoutes } from './responsable.routes';
export const apiRouter: Router = Router();
@@ -11,4 +12,6 @@ apiRouter.use('/auth', authRoutes);
// Le reste exige un utilisateur identifié.
apiRouter.use('/materiels', currentUser, materielRoutes);
apiRouter.use('/mes-emprunts', currentUser, empruntRoutes);
apiRouter.use('/emprunts', currentUser, empruntRoutes);
apiRouter.use('/mes-emprunts', currentUser, mesEmpruntsRoutes);
apiRouter.use('/responsable', currentUser, responsableRoutes);
+2 -1
View File
@@ -1,6 +1,7 @@
import { Router } from 'express';
import { getCatalogue } from '../controllers/materiel.controller';
import { getCatalogue, getMaterielDetail } from '../controllers/materiel.controller';
export const materielRoutes: Router = Router();
materielRoutes.get('/', getCatalogue);
materielRoutes.get('/:id', getMaterielDetail);
@@ -0,0 +1,26 @@
import { Router } from 'express';
import {
getAnomalies,
getDashboard,
getEmprunts,
getHistorique,
getHistoriqueCsv,
getMateriels,
getNotifications,
patchAnomalieStatut,
patchNotificationLue,
patchNotificationsLues,
} from '../controllers/responsable.controller';
export const responsableRoutes: Router = Router();
responsableRoutes.get('/dashboard', getDashboard);
responsableRoutes.get('/emprunts', getEmprunts);
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,
};
}
+178 -3
View File
@@ -1,7 +1,182 @@
import { findEnCoursParUtilisateur, EmpruntAvecMateriel } from '../repositories/emprunt.repository';
import { AppError } from '../errors/app-error';
import {
CreerEmpruntRequest,
RestituerEmpruntRequest,
ChecklistElementRequest,
} from '../dtos/emprunt.dto';
import {
findEnCoursParUtilisateur,
creerEmpruntComplet,
findByIdAvecChecklists,
restituerEmpruntComplet,
EmpruntAvecMateriel,
RestituerEmpruntData,
} from '../repositories/emprunt.repository';
import { findById as findMaterielById } from '../repositories/materiel.repository';
import { findById as findPosteById } from '../repositories/poste-emprunt.repository';
import { findResponsablesParCampus } from '../repositories/utilisateur.repository';
import { StatutEmprunt } from '../models/enums';
/* RG15 : un étudiant ne consulte que ses propres emprunts, et uniquement ceux
non encore restitués (en cours ou en retard). */
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);
}
/* 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 findMaterielById(request.materielId);
if (!materiel) {
throw new AppError(404, 'Materiel introuvable');
}
if (materiel.campusId !== campusId) {
throw new AppError(403, 'Materiel rattache a un autre campus');
}
if (materiel.statut !== 'DISPONIBLE') {
throw new AppError(409, 'Materiel non disponible');
}
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);
return creerEmpruntComplet({
utilisateurId,
materielId: request.materielId,
campusId,
sallePretId: poste.sallePretId,
posteEmpruntId: request.posteEmpruntId,
dateRetourPrevue,
modeIdentification: request.modeIdentification,
commentaireDepart: request.commentaireDepart,
elements: request.elements,
});
}
interface ElementDepart {
accessoireId: number | null;
nomElement: string;
etat: string;
}
interface ResultatComparaison {
conforme: boolean;
auMoinsUnDeteriore: boolean;
details: string[];
}
function trouverRetour(
depart: ElementDepart,
retour: ChecklistElementRequest[],
): ChecklistElementRequest | undefined {
if (depart.accessoireId !== null) {
const parId = retour.find((element) => element.accessoireId === depart.accessoireId);
if (parId) {
return parId;
}
}
return retour.find((element) => element.nomElement === depart.nomElement);
}
/* RG17 : compare la checklist de départ à celle de retour. Un élément présent au
départ mais absent ou détérioré au retour rend la restitution non conforme (RG20). */
function comparerChecklists(
depart: ElementDepart[],
retour: ChecklistElementRequest[],
): ResultatComparaison {
const details: string[] = [];
let auMoinsUnDeteriore = false;
for (const elementDepart of depart) {
if (elementDepart.etat !== 'PRESENT') {
continue;
}
const elementRetour = trouverRetour(elementDepart, retour);
if (!elementRetour || elementRetour.etat === 'ABSENT') {
details.push(`${elementDepart.nomElement} manquant au retour`);
} else if (elementRetour.etat === 'DETERIORE') {
details.push(`${elementDepart.nomElement} deteriore au retour`);
auMoinsUnDeteriore = true;
}
}
return { conforme: details.length === 0, auMoinsUnDeteriore, details };
}
/* RG14-RG20 : restitution d'un emprunt avec comparaison des checklists, clôture
ou passage en non conforme, et création automatique d'anomalie + notifications. */
export async function restituerEmprunt(
utilisateurId: number,
empruntId: number,
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 comparaison = comparerChecklists(
checklistDepart.elements.map((element) => ({
accessoireId: element.accessoireId,
nomElement: element.nomElement,
etat: element.etat,
})),
request.elements,
);
const statutEmprunt: StatutEmprunt = comparaison.conforme ? 'CLOTURE' : 'RETOUR_NON_CONFORME';
let statutMateriel = 'DISPONIBLE';
if (!comparaison.conforme) {
statutMateriel = comparaison.auMoinsUnDeteriore ? 'DETERIORE' : 'NON_CONFORME';
}
const donnees: RestituerEmpruntData = {
empruntId,
utilisateurId,
materielId: emprunt.materielId,
statutEmprunt,
statutMateriel,
modeIdentification: request.modeIdentification,
commentaireRetour: request.commentaireRetour,
elements: request.elements,
};
if (!comparaison.conforme) {
const responsables = await findResponsablesParCampus(emprunt.campusId);
donnees.anomalie = {
type: 'RETOUR_NON_CONFORME',
description: `Retour non conforme : ${comparaison.details.join(', ')}.`,
responsablesIds: responsables.map((responsable) => responsable.id),
};
}
return restituerEmpruntComplet(donnees);
}
@@ -1,8 +1,11 @@
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. */
@@ -12,3 +15,11 @@ export function listerCatalogue(
): 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,321 @@
import { AppError } from '../errors/app-error';
import {
changerStatutAnomalieResponsable,
countAnomaliesParStatut,
countEmpruntsParStatut,
countMaterielsParStatut,
countNotificationsNonLues,
findActiviteRecente,
findAnomalieResponsableById,
findAnomaliesResponsable,
findEmpruntsResponsable,
findHistoriqueResponsable,
findMaterielsResponsable,
findNotificationsResponsable,
marquerNotificationLue,
marquerNotificationsResponsableLues,
StatutCount,
ActiviteResponsable,
AnomalieResponsable,
EmpruntResponsable,
HistoriqueResponsable,
MaterielResponsable,
NotificationResponsable,
} 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;
}
export interface ListerMaterielsResponsableFiltres {
statut?: StatutMateriel;
categorieId?: number;
recherche?: string;
}
export interface ListerAnomaliesResponsableFiltres {
statut?: StatutAnomalie;
type?: string;
}
export interface ListerNotificationsResponsableFiltres {
lu?: boolean;
}
export interface ListerHistoriqueResponsableFiltres {
action?: string;
utilisateurId?: number;
materielId?: number;
empruntId?: number;
dateDebut?: Date;
dateFin?: Date;
}
export interface ChangerStatutAnomalieResponsableRequest {
statut: StatutAnomalie;
observation?: string;
}
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 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 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 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 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 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 listerMaterielsResponsable(
roleCode: string,
campusId: number,
filtres: ListerMaterielsResponsableFiltres,
): Promise<MaterielResponsable[]> {
verifierResponsable(roleCode);
return findMaterielsResponsable(campusId, filtres);
}
export function listerAnomaliesResponsable(
roleCode: string,
campusId: number,
filtres: ListerAnomaliesResponsableFiltres,
): Promise<AnomalieResponsable[]> {
verifierResponsable(roleCode);
return findAnomaliesResponsable(campusId, filtres);
}
export function listerNotificationsResponsable(
utilisateurId: number,
roleCode: string,
filtres: ListerNotificationsResponsableFiltres,
): Promise<NotificationResponsable[]> {
verifierResponsable(roleCode);
return findNotificationsResponsable(utilisateurId, filtres);
}
export async function marquerNotificationResponsableLue(
utilisateurId: number,
roleCode: string,
notificationId: number,
): Promise<NotificationResponsable> {
verifierResponsable(roleCode);
try {
return await marquerNotificationLue(utilisateurId, notificationId);
} catch {
throw new AppError(404, 'Notification introuvable');
}
}
export function marquerToutesNotificationsResponsableLues(
utilisateurId: number,
roleCode: string,
): Promise<number> {
verifierResponsable(roleCode);
return marquerNotificationsResponsableLues(utilisateurId);
}
export function listerHistoriqueResponsable(
roleCode: string,
campusId: number,
filtres: ListerHistoriqueResponsableFiltres,
): Promise<HistoriqueResponsable[]> {
verifierResponsable(roleCode);
return findHistoriqueResponsable(campusId, filtres);
}
export async function changerStatutAnomalieResponsableService(
responsableId: number,
roleCode: string,
campusId: number,
anomalieId: number,
data: ChangerStatutAnomalieResponsableRequest,
): Promise<AnomalieResponsable> {
verifierResponsable(roleCode);
const anomalie = await findAnomalieResponsableById(campusId, anomalieId);
if (!anomalie) {
throw new AppError(404, 'Anomalie introuvable');
}
const statutActuel = anomalie.statut as StatutAnomalie;
if (!TRANSITIONS_ANOMALIE[statutActuel]?.includes(data.statut)) {
throw new AppError(400, `Transition invalide depuis ${anomalie.statut} vers ${data.statut}`);
}
return changerStatutAnomalieResponsable({
anomalieId,
campusId,
responsableId,
statut: data.statut,
observation: data.observation,
});
}
+485
View File
@@ -0,0 +1,485 @@
-- Adminer 5.4.2 MS SQL dump
DROP TABLE IF EXISTS [dbo].[_prisma_migrations];
CREATE TABLE [dbo].[_prisma_migrations] (
[id] varchar(36) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[checksum] varchar(64) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[finished_at] datetimeoffset NULL,
[migration_name] nvarchar(250) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[logs] nvarchar-05 COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[rolled_back_at] datetimeoffset NULL,
[started_at] datetimeoffset NOT NULL DEFAULT '(getdate())',
[applied_steps_count] int NOT NULL DEFAULT '((0))',
CONSTRAINT [PK___prisma___3213E83F83C32F3A] PRIMARY KEY ([id])
);
INSERT INTO [dbo].[_prisma_migrations] ([id], [checksum], [finished_at], [migration_name], [logs], [rolled_back_at], [started_at], [applied_steps_count]) VALUES
('0f9bb926-166c-4082-8732-8a12e67b677d', 'c2faa7ef8d703d77e508f037850a7c2b07566b0eed00da21d85e1acfc5738a1e', 'Jun 4 2026 12:07:21:0885370PM', '20260604120720_add_materiel_models', NULL, NULL, 'Jun 4 2026 12:07:21:0042228PM', 1),
('247934d2-3c7e-4798-a551-d3bdc81eb86d', 'fb4d09b02739fd6bf7cea5bfd0aad1bb0fbfcce21a04f1bd91ce300a4c8bbfec', 'Jun 5 2026 08:03:28:7441715AM', '20260605080328_add_transaction_models', NULL, NULL, 'Jun 5 2026 08:03:28:6232902AM', 1),
('3266f9eb-7370-4b00-b749-f0ed39263d07', '2e7b96c74202f25fd3ab0a9438fcc8699059fd5501d66092148daa41252f146b', 'Jun 4 2026 11:52:51:4456229AM', '20260604115251_add_infrastructure_models', NULL, NULL, 'Jun 4 2026 11:52:51:3998600AM', 1),
('3cced054-eb44-41f8-89ac-51a72201310a', '5e82403dcc57c7ab48dea2fe70e688714145f7f46b770a5c79ea868e6ba60f7a', 'Jun 4 2026 10:30:16:9084629AM', '20260604103016_add_defaults_and_timestamps', NULL, NULL, 'Jun 4 2026 10:30:16:8610742AM', 1),
('3e9709da-458c-402d-8b32-bac51a84d241', 'c95d2b3e6c2454daa3fb9ba2ef7a99f9419e991174869612c81ab907e5a6d802', 'Jun 4 2026 12:02:27:3861796PM', '20260604120227_add_carte_etudiante', NULL, NULL, 'Jun 4 2026 12:02:27:3195110PM', 1),
('5b298865-5e96-4b54-ad96-1ec3acc7080a', '5e3e0d880e67c5ba91ccdb04f0dee6c056da6a325510c9dd9e4f9a80c5625fc4', 'Jun 4 2026 10:54:28:1304306AM', '20260604125356_add_unique_constraints_and_nullable_classe', NULL, NULL, 'Jun 4 2026 10:54:28:0949933AM', 1),
('66581e38-0e42-435a-9937-06479116e595', '4e632e5273ea555527c4726688865c7b21bc03d92df67e2f4679c6428d8a33e2', 'Jun 2 2026 02:19:19:8915005PM', '20260602141919_init', NULL, NULL, 'Jun 2 2026 02:19:19:8389748PM', 1),
('fbf572ea-075e-4077-9d58-39fdf613d737', '29faca22003505a071ea6b845d527566c51086c6b17980f386b70abff64d9148', 'Jun 15 2026 07:57:00:9671170AM', '20260615075700_add_suivi_models', NULL, NULL, 'Jun 15 2026 07:57:00:8308160AM', 1);
DROP TABLE IF EXISTS [dbo].[accessoire];
CREATE TABLE [dbo].[accessoire] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[description] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[actif] bit NOT NULL DEFAULT '((1))',
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[accessoire] ON;
INSERT INTO [dbo].[accessoire] ([id], [nom], [description], [actif], [created_at], [updated_at]) VALUES
(1, 'Chargeur', 'Chargeur secteur.', '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(2, 'Souris', 'Souris filaire.', '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(3, 'Housse', 'Housse de protection.', '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(1002, 'Chargeur USB-C', 'Accessoire Chargeur USB-C.', '1', '2026-07-07 11:39:38', '2026-07-07 11:39:38'),
(1003, 'Clavier Surface', 'Accessoire Clavier Surface.', '1', '2026-07-07 11:39:38', '2026-07-07 11:39:38'),
(1004, 'Stylet', 'Accessoire Stylet.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
(1005, 'Telecommande', 'Accessoire Telecommande.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
(1006, 'Cable HDMI', 'Accessoire Cable HDMI.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
(1007, 'Batterie', 'Accessoire Batterie.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
(1008, 'Carte SD', 'Accessoire Carte SD.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
(1009, 'Adaptateur USB-C', 'Accessoire Adaptateur USB-C.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
(1010, 'Cable USB-C', 'Accessoire Cable USB-C.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39');
SET IDENTITY_INSERT [dbo].[accessoire] OFF;
DROP TABLE IF EXISTS [dbo].[anomalie];
CREATE TABLE [dbo].[anomalie] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[emprunt_id] int NOT NULL,
[materiel_id] int NOT NULL,
[utilisateur_id] int NOT NULL,
[traitee_par_id] int NULL,
[type] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[description] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[statut] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[detectee_automatiquement] bit NOT NULL DEFAULT '((1))',
[observation] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[date_detection] datetime2 NOT NULL,
[date_resolution] datetime2 NULL,
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[anomalie] ON;
INSERT INTO [dbo].[anomalie] ([id], [emprunt_id], [materiel_id], [utilisateur_id], [traitee_par_id], [type], [description], [statut], [detectee_automatiquement], [observation], [date_detection], [date_resolution], [created_at], [updated_at]) VALUES
(1, 4, 4, 3, NULL, 'ACCESSOIRE_MANQUANT', 'Housse declaree presente au depart, absente au retour.', 'DETECTEE', '1', NULL, '2026-06-14 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(2, 1002, 3, 1, NULL, 'RETOUR_NON_CONFORME', 'Retour non conforme : Etat general manquant au retour.', 'DETECTEE', '1', NULL, '2026-07-06 08:03:20', NULL, '2026-07-06 08:03:20', '2026-07-06 08:03:20'),
(1002, 4003, 1009, 2, NULL, 'RETOUR_NON_CONFORME', 'Retour non conforme : Cable USB-C manquant au retour.', 'DETECTEE', '1', NULL, '2026-07-08 09:19:47', NULL, '2026-07-08 09:19:47', '2026-07-08 09:19:47');
SET IDENTITY_INSERT [dbo].[anomalie] OFF;
DROP TABLE IF EXISTS [dbo].[campus];
CREATE TABLE [dbo].[campus] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[adresse] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[ville] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[code_postal] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[actif] bit NOT NULL DEFAULT '((1))',
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[campus] ON;
INSERT INTO [dbo].[campus] ([id], [nom], [adresse], [ville], [code_postal], [actif], [created_at], [updated_at]) VALUES
(1, 'Campus Saint-Christophe', 'À compléter — Cergy Saint-Christophe', 'Cergy', '95800', '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37');
SET IDENTITY_INSERT [dbo].[campus] OFF;
DROP TABLE IF EXISTS [dbo].[carte_etudiante];
CREATE TABLE [dbo].[carte_etudiante] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[utilisateur_id] int NOT NULL,
[qr_code] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[date_activation] datetime2 NOT NULL,
[date_expiration] datetime2 NOT NULL,
[actif] bit NOT NULL DEFAULT '((1))',
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[carte_etudiante] ON;
INSERT INTO [dbo].[carte_etudiante] ([id], [utilisateur_id], [qr_code], [date_activation], [date_expiration], [actif], [created_at], [updated_at]) VALUES
(1, 1, 'QR-ETU-0001', '2025-08-19 14:48:51', '2027-07-20 14:48:51', '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(2, 2, 'QR-ETU-0002', '2025-08-19 14:48:51', '2027-07-20 14:48:51', '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(3, 3, 'QR-ETU-0003', '2025-08-19 14:48:51', '2027-07-20 14:48:51', '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52');
SET IDENTITY_INSERT [dbo].[carte_etudiante] OFF;
DROP TABLE IF EXISTS [dbo].[categorie_materiel];
CREATE TABLE [dbo].[categorie_materiel] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[description] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[actif] bit NOT NULL DEFAULT '((1))',
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[categorie_materiel] ON;
INSERT INTO [dbo].[categorie_materiel] ([id], [nom], [description], [actif], [created_at], [updated_at]) VALUES
(1, 'Ordinateur portable', 'Ordinateurs portables pédagogiques.', '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37'),
(2, 'Vidéoprojecteur', 'Vidéoprojecteurs et accessoires de projection.', '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37'),
(3, 'Tablette', 'Tablettes tactiles.', '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37'),
(4, 'Caméra', 'Caméras et matériel de captation vidéo.', '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37'),
(5, 'Périphérique audio', 'Micros, casques et enceintes.', '1', '2026-06-15 09:03:38', '2026-06-15 09:03:38'),
(6, 'Câble / Adaptateur', 'Câbles, adaptateurs et connectique.', '1', '2026-06-15 09:03:38', '2026-06-15 09:03:38');
SET IDENTITY_INSERT [dbo].[categorie_materiel] OFF;
DROP TABLE IF EXISTS [dbo].[checklist];
CREATE TABLE [dbo].[checklist] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[emprunt_id] int NOT NULL,
[utilisateur_id] int NOT NULL,
[type] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[date_verification] datetime2 NOT NULL,
[commentaire] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[checklist] ON;
INSERT INTO [dbo].[checklist] ([id], [emprunt_id], [utilisateur_id], [type], [date_verification], [commentaire], [created_at], [updated_at]) VALUES
(1, 1, 1, 'DEPART', '2026-06-13 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(2, 2, 2, 'DEPART', '2026-05-26 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(3, 3, 1, 'DEPART', '2026-06-05 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(4, 3, 1, 'RETOUR', '2026-06-12 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(5, 4, 3, 'DEPART', '2026-06-10 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(6, 4, 3, 'RETOUR', '2026-06-14 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(1002, 1002, 1, 'DEPART', '2026-07-06 07:38:13', NULL, '2026-07-06 07:38:13', '2026-07-06 07:38:13'),
(1003, 1, 1, 'RETOUR', '2026-07-06 08:03:20', NULL, '2026-07-06 08:03:20', '2026-07-06 08:03:20'),
(1004, 1002, 1, 'RETOUR', '2026-07-06 08:03:20', NULL, '2026-07-06 08:03:20', '2026-07-06 08:03:20'),
(2002, 2002, 2, 'DEPART', '2026-07-07 11:51:13', NULL, '2026-07-07 11:51:13', '2026-07-07 11:51:13'),
(2003, 2003, 2, 'DEPART', '2026-07-07 11:58:06', NULL, '2026-07-07 11:58:06', '2026-07-07 11:58:06'),
(3002, 2, 2, 'RETOUR', '2026-07-07 12:33:18', NULL, '2026-07-07 12:33:18', '2026-07-07 12:33:18'),
(3003, 2002, 2, 'RETOUR', '2026-07-07 12:33:29', NULL, '2026-07-07 12:33:29', '2026-07-07 12:33:29'),
(3004, 2003, 2, 'RETOUR', '2026-07-07 12:33:43', NULL, '2026-07-07 12:33:43', '2026-07-07 12:33:43'),
(3005, 3002, 2, 'DEPART', '2026-07-07 12:34:25', NULL, '2026-07-07 12:34:25', '2026-07-07 12:34:25'),
(4002, 4002, 2, 'DEPART', '2026-07-08 09:19:17', NULL, '2026-07-08 09:19:17', '2026-07-08 09:19:17'),
(4003, 4002, 2, 'RETOUR', '2026-07-08 09:19:17', NULL, '2026-07-08 09:19:17', '2026-07-08 09:19:17'),
(4004, 4003, 2, 'DEPART', '2026-07-08 09:19:47', NULL, '2026-07-08 09:19:47', '2026-07-08 09:19:47'),
(4005, 4003, 2, 'RETOUR', '2026-07-08 09:19:47', NULL, '2026-07-08 09:19:47', '2026-07-08 09:19:47'),
(4006, 4004, 2, 'DEPART', '2026-07-08 13:19:04', NULL, '2026-07-08 13:19:04', '2026-07-08 13:19:04');
SET IDENTITY_INSERT [dbo].[checklist] OFF;
DROP TABLE IF EXISTS [dbo].[checklist_element];
CREATE TABLE [dbo].[checklist_element] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[checklist_id] int NOT NULL,
[accessoire_id] int NULL,
[nom_element] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[etat] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[quantite_constatee] int NOT NULL,
[commentaire] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[checklist_element] ON;
INSERT INTO [dbo].[checklist_element] ([id], [checklist_id], [accessoire_id], [nom_element], [etat], [quantite_constatee], [commentaire], [created_at], [updated_at]) VALUES
(1, 1, 1, 'Chargeur', 'PRESENT', 1, NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(2, 1, 2, 'Souris', 'PRESENT', 1, NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(3, 5, 3, 'Housse', 'PRESENT', 1, NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(4, 5, 1, 'Chargeur', 'PRESENT', 1, NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(5, 6, 3, 'Housse', 'ABSENT', 0, 'Manquante au retour', '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(6, 6, 1, 'Chargeur', 'PRESENT', 1, NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(1002, 1002, NULL, 'Etat general', 'PRESENT', 1, NULL, '2026-07-06 07:38:13', '2026-07-06 07:38:13'),
(1003, 1003, NULL, 'Chargeur', 'PRESENT', 1, NULL, '2026-07-06 08:03:20', '2026-07-06 08:03:20'),
(1004, 1003, NULL, 'Souris', 'PRESENT', 1, NULL, '2026-07-06 08:03:20', '2026-07-06 08:03:20'),
(1005, 1004, NULL, 'Etat general', 'ABSENT', 0, NULL, '2026-07-06 08:03:20', '2026-07-06 08:03:20'),
(2002, 2002, NULL, 'Batterie', 'ABSENT', 0, NULL, '2026-07-07 11:51:13', '2026-07-07 11:51:13'),
(2003, 2002, NULL, 'Carte SD', 'PRESENT', 1, NULL, '2026-07-07 11:51:13', '2026-07-07 11:51:13'),
(2004, 2002, NULL, 'Housse', 'PRESENT', 1, NULL, '2026-07-07 11:51:13', '2026-07-07 11:51:13'),
(2005, 2003, NULL, 'Chargeur USB-C', 'ABSENT', 0, NULL, '2026-07-07 11:58:06', '2026-07-07 11:58:06'),
(2006, 2003, NULL, 'Housse', 'PRESENT', 1, NULL, '2026-07-07 11:58:06', '2026-07-07 11:58:06'),
(3002, 3002, NULL, 'Vidéoprojecteur Epson', 'PRESENT', 1, NULL, '2026-07-07 12:33:18', '2026-07-07 12:33:18'),
(3003, 3003, NULL, 'Batterie', 'PRESENT', 1, NULL, '2026-07-07 12:33:29', '2026-07-07 12:33:29'),
(3004, 3003, NULL, 'Carte SD', 'PRESENT', 1, NULL, '2026-07-07 12:33:29', '2026-07-07 12:33:29'),
(3005, 3003, NULL, 'Housse', 'PRESENT', 1, NULL, '2026-07-07 12:33:29', '2026-07-07 12:33:29'),
(3006, 3004, NULL, 'Chargeur USB-C', 'ABSENT', 0, NULL, '2026-07-07 12:33:43', '2026-07-07 12:33:43'),
(3007, 3004, NULL, 'Housse', 'PRESENT', 1, NULL, '2026-07-07 12:33:43', '2026-07-07 12:33:43'),
(3008, 3005, NULL, 'Chargeur', 'PRESENT', 1, NULL, '2026-07-07 12:34:25', '2026-07-07 12:34:25'),
(3009, 3005, NULL, 'Housse', 'ABSENT', 0, NULL, '2026-07-07 12:34:25', '2026-07-07 12:34:25'),
(3010, 3005, NULL, 'Souris', 'PRESENT', 1, NULL, '2026-07-07 12:34:25', '2026-07-07 12:34:25'),
(4002, 4002, NULL, 'Casque Jabra Evolve', 'PRESENT', 1, NULL, '2026-07-08 09:19:17', '2026-07-08 09:19:17'),
(4003, 4003, NULL, 'Casque Jabra Evolve', 'PRESENT', 1, NULL, '2026-07-08 09:19:17', '2026-07-08 09:19:17'),
(4004, 4004, NULL, 'Cable USB-C', 'PRESENT', 1, NULL, '2026-07-08 09:19:47', '2026-07-08 09:19:47'),
(4005, 4005, NULL, 'Cable USB-C', 'ABSENT', 0, NULL, '2026-07-08 09:19:47', '2026-07-08 09:19:47'),
(4006, 4006, NULL, 'Chargeur USB-C', 'ABSENT', 0, NULL, '2026-07-08 13:19:04', '2026-07-08 13:19:04'),
(4007, 4006, NULL, 'Housse', 'PRESENT', 1, NULL, '2026-07-08 13:19:04', '2026-07-08 13:19:04');
SET IDENTITY_INSERT [dbo].[checklist_element] OFF;
DROP TABLE IF EXISTS [dbo].[emprunt];
CREATE TABLE [dbo].[emprunt] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[utilisateur_id] int NOT NULL,
[materiel_id] int NOT NULL,
[campus_id] int NOT NULL,
[salle_pret_id] int NOT NULL,
[poste_emprunt_id] int NOT NULL,
[date_emprunt] datetime2 NOT NULL,
[date_retour_prevue] datetime2 NOT NULL,
[date_retour_reelle] datetime2 NULL,
[statut] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[mode_identification_emprunt] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[mode_identification_retour] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[commentaire_depart] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[commentaire_retour] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[emprunt] ON;
INSERT INTO [dbo].[emprunt] ([id], [utilisateur_id], [materiel_id], [campus_id], [salle_pret_id], [poste_emprunt_id], [date_emprunt], [date_retour_prevue], [date_retour_reelle], [statut], [mode_identification_emprunt], [mode_identification_retour], [commentaire_depart], [commentaire_retour], [created_at], [updated_at]) VALUES
(1, 1, 1, 1, 1, 1, '2026-06-13 14:48:51', '2026-06-20 14:48:51', '2026-07-06 08:03:20', 'CLOTURE', 'QR_CODE', 'QR_CODE', NULL, NULL, '2026-06-15 14:48:53', '2026-07-06 08:03:20'),
(2, 2, 2, 1, 1, 1, '2026-05-26 14:48:51', '2026-06-09 14:48:51', '2026-07-07 12:33:18', 'CLOTURE', 'MAIL_ENSUP', 'MAIL_ENSUP', NULL, NULL, '2026-06-15 14:48:53', '2026-07-07 12:33:18'),
(3, 1, 3, 1, 1, 1, '2026-06-05 14:48:51', '2026-06-12 14:48:51', '2026-06-12 14:48:51', 'CLOTURE', 'QR_CODE', 'QR_CODE', NULL, NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(4, 3, 4, 1, 1, 1, '2026-06-10 14:48:51', '2026-06-14 14:48:51', '2026-06-14 14:48:51', 'RETOUR_NON_CONFORME', 'MAIL_ENSUP', 'MAIL_ENSUP', NULL, 'Housse non rapportee.', '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
(1002, 1, 3, 1, 1, 1, '2026-07-06 07:38:13', '2026-07-20 07:38:13', '2026-07-06 08:03:20', 'RETOUR_NON_CONFORME', 'QR_CODE', 'QR_CODE', 'RAS', 'element manquant', '2026-07-06 07:38:13', '2026-07-06 08:03:20'),
(2002, 2, 1006, 1, 1, 1, '2026-07-07 11:51:13', '2026-07-21 11:51:13', '2026-07-07 12:33:29', 'CLOTURE', 'MAIL_ENSUP', 'MAIL_ENSUP', NULL, NULL, '2026-07-07 11:51:13', '2026-07-07 12:33:29'),
(2003, 2, 1003, 1, 1, 1, '2026-07-07 11:58:06', '2026-07-21 11:58:06', '2026-07-07 12:33:43', 'CLOTURE', 'MAIL_ENSUP', 'MAIL_ENSUP', NULL, NULL, '2026-07-07 11:58:06', '2026-07-07 12:33:44'),
(3002, 2, 1002, 1, 1, 1, '2026-07-07 12:34:25', '2026-07-21 12:34:25', NULL, 'EN_COURS', 'MAIL_ENSUP', NULL, NULL, NULL, '2026-07-07 12:34:25', '2026-07-07 12:34:25'),
(4002, 2, 1007, 1, 1, 1, '2026-07-08 09:19:17', '2026-07-22 09:19:17', '2026-07-08 09:19:17', 'CLOTURE', 'MAIL_ENSUP', 'MAIL_ENSUP', 'Test stabilisation API', 'Retour test conforme', '2026-07-08 09:19:17', '2026-07-08 09:19:17'),
(4003, 2, 1009, 1, 1, 1, '2026-07-08 09:19:47', '2026-07-22 09:19:46', '2026-07-08 09:19:47', 'RETOUR_NON_CONFORME', 'MAIL_ENSUP', 'MAIL_ENSUP', 'Test non conforme API', 'Retour test non conforme', '2026-07-08 09:19:47', '2026-07-08 09:19:47'),
(4004, 2, 1003, 1, 1, 1, '2026-07-08 13:19:04', '2026-07-22 13:19:04', NULL, 'EN_COURS', 'MAIL_ENSUP', NULL, NULL, NULL, '2026-07-08 13:19:04', '2026-07-08 13:19:04');
SET IDENTITY_INSERT [dbo].[emprunt] OFF;
DROP TABLE IF EXISTS [dbo].[historique];
CREATE TABLE [dbo].[historique] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[utilisateur_id] int NOT NULL,
[emprunt_id] int NULL,
[materiel_id] int NULL,
[campus_id] int NULL,
[salle_pret_id] int NULL,
[poste_emprunt_id] int NULL,
[action] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[description] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[date_action] datetime2 NOT NULL,
[created_at] datetime2 NOT NULL DEFAULT '(getdate())'
);
SET IDENTITY_INSERT [dbo].[historique] ON;
INSERT INTO [dbo].[historique] ([id], [utilisateur_id], [emprunt_id], [materiel_id], [campus_id], [salle_pret_id], [poste_emprunt_id], [action], [description], [date_action], [created_at]) VALUES
(1, 1, 1, 1, 1, 1, 1, 'CREATION_EMPRUNT', 'Emprunt du PC portable Dell.', '2026-06-13 14:48:51', '2026-06-15 14:48:53'),
(2, 3, 4, 4, 1, 1, 1, 'CREATION_ANOMALIE', 'Anomalie detectee au retour de la Caméra Sony.', '2026-06-14 14:48:51', '2026-06-15 14:48:53');
SET IDENTITY_INSERT [dbo].[historique] OFF;
DROP TABLE IF EXISTS [dbo].[materiel];
CREATE TABLE [dbo].[materiel] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[marque] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[modele] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[reference] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[numero_inventaire] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[numero_serie] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[statut] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[etat_general] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[categorie_id] int NOT NULL,
[campus_id] int NOT NULL,
[actif] bit NOT NULL DEFAULT '((1))',
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[materiel] ON;
INSERT INTO [dbo].[materiel] ([id], [nom], [marque], [modele], [reference], [numero_inventaire], [numero_serie], [statut], [etat_general], [categorie_id], [campus_id], [actif], [created_at], [updated_at]) VALUES
(1, 'PC portable Dell Latitude', 'Dell', 'Latitude 5440', 'REF-PC-001', 'INV-PC-001', 'SN-DELL-001', 'DISPONIBLE', 'Bon etat', 1, 1, '1', '2026-06-15 14:48:52', '2026-07-06 08:03:20'),
(2, 'Vidéoprojecteur Epson', 'Epson', 'EB-W06', 'REF-VP-001', 'INV-VP-001', 'SN-EPSON-001', 'DISPONIBLE', 'Bon etat', 2, 1, '1', '2026-06-15 14:48:52', '2026-07-07 12:33:18'),
(3, 'Tablette iPad', 'Apple', 'iPad 10', 'REF-TAB-001', 'INV-TAB-001', 'SN-IPAD-001', 'NON_CONFORME', 'Bon etat', 3, 1, '1', '2026-06-15 14:48:52', '2026-07-06 08:03:20'),
(4, 'Caméra Sony', 'Sony', 'Alpha 6400', 'REF-CAM-001', 'INV-CAM-001', 'SN-SONY-001', 'DETERIORE', 'Housse manquante au retour', 4, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(5, 'Micro Rode', 'Rode', 'NT-USB', 'REF-MIC-001', 'INV-MIC-001', 'SN-RODE-001', 'DISPONIBLE', 'Bon etat', 5, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(6, 'PC portable HP', 'HP', 'ProBook 450', 'REF-PC-002', 'INV-PC-002', 'SN-HP-002', 'MAINTENANCE', 'En revision', 1, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(1002, 'PC Lenovo ThinkPad E14', 'Lenovo', 'ThinkPad E14', 'REF-PC-003', 'INV-PC-003', 'SN-LENOVO-003', 'EMPRUNTE', 'Bon etat', 1, 1, '1', '2026-07-07 11:39:38', '2026-07-07 12:34:25'),
(1003, 'MacBook Air M2', 'Apple', 'MacBook Air 13', 'REF-PC-004', 'INV-PC-004', 'SN-MBA-004', 'EMPRUNTE', 'Tres bon etat', 1, 1, '1', '2026-07-07 11:39:38', '2026-07-08 13:19:04'),
(1004, 'Microsoft Surface Pro', 'Microsoft', 'Surface Pro 9', 'REF-TAB-002', 'INV-TAB-002', 'SN-SURFACE-002', 'DISPONIBLE', 'Bon etat', 3, 1, '1', '2026-07-07 11:39:38', '2026-07-07 11:39:38'),
(1005, 'Vidéoprojecteur Epson EB-X49', 'Epson', 'EB-X49', 'REF-VP-002', 'INV-VP-002', 'SN-EPSON-002', 'DISPONIBLE', 'Bon etat', 2, 1, '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
(1006, 'Caméra Canon EOS M50', 'Canon', 'EOS M50', 'REF-CAM-002', 'INV-CAM-002', 'SN-CANON-002', 'DISPONIBLE', 'Bon etat', 4, 1, '1', '2026-07-07 11:39:39', '2026-07-07 12:33:29'),
(1007, 'Casque Jabra Evolve', 'Jabra', 'Evolve 40', 'REF-AUD-002', 'INV-AUD-002', 'SN-JABRA-002', 'DISPONIBLE', 'Bon etat', 5, 1, '1', '2026-07-07 11:39:39', '2026-07-08 09:19:17'),
(1008, 'Kit câbles HDMI / USB-C', 'ENSUP', 'Kit connectique', 'REF-CAB-001', 'INV-CAB-001', 'SN-CABLE-001', 'DISPONIBLE', 'Bon etat', 6, 1, '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
(1009, 'Enceinte Bluetooth JBL', 'JBL', 'Flip 6', 'REF-AUD-003', 'INV-AUD-003', 'SN-JBL-003', 'NON_CONFORME', 'Bon etat', 5, 1, '1', '2026-07-07 11:39:39', '2026-07-08 09:19:47');
SET IDENTITY_INSERT [dbo].[materiel] OFF;
DROP TABLE IF EXISTS [dbo].[materiel_accessoire];
CREATE TABLE [dbo].[materiel_accessoire] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[materiel_id] int NOT NULL,
[accessoire_id] int NOT NULL,
[quantite_attendue] int NOT NULL,
[obligatoire] bit NOT NULL
);
SET IDENTITY_INSERT [dbo].[materiel_accessoire] ON;
INSERT INTO [dbo].[materiel_accessoire] ([id], [materiel_id], [accessoire_id], [quantite_attendue], [obligatoire]) VALUES
(1, 1, 1, 1, '1'),
(2, 1, 2, 1, '0'),
(3, 4, 3, 1, '1'),
(4, 4, 1, 1, '1'),
(1002, 1002, 1, 1, '1'),
(1003, 1002, 2, 1, '1'),
(1004, 1002, 3, 1, '1'),
(1005, 1003, 1002, 1, '1'),
(1006, 1003, 3, 1, '1'),
(1007, 1004, 1003, 1, '1'),
(1008, 1004, 1004, 1, '1'),
(1009, 1005, 1005, 1, '1'),
(1010, 1005, 1006, 1, '1'),
(1011, 1006, 1007, 1, '1'),
(1012, 1006, 1008, 1, '1'),
(1013, 1006, 3, 1, '1'),
(1014, 1008, 1006, 1, '1'),
(1015, 1008, 1009, 1, '1'),
(1016, 1009, 1010, 1, '1');
SET IDENTITY_INSERT [dbo].[materiel_accessoire] OFF;
DROP TABLE IF EXISTS [dbo].[notification];
CREATE TABLE [dbo].[notification] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[utilisateur_id] int NOT NULL,
[anomalie_id] int NULL,
[titre] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[message] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[type] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[lu] bit NOT NULL DEFAULT '((0))',
[date_creation] datetime2 NOT NULL DEFAULT '(getdate())'
);
SET IDENTITY_INSERT [dbo].[notification] ON;
INSERT INTO [dbo].[notification] ([id], [utilisateur_id], [anomalie_id], [titre], [message], [type], [lu], [date_creation]) VALUES
(1, 4, 1, 'Nouvelle anomalie detectee', 'Retour non conforme sur la Caméra Sony (housse manquante).', 'ANOMALIE', '0', '2026-06-15 14:48:53'),
(2, 4, 2, 'Nouvelle anomalie detectee', 'Retour non conforme : Etat general manquant au retour.', 'ANOMALIE', '0', '2026-07-06 08:03:20'),
(1002, 4, 1002, 'Nouvelle anomalie detectee', 'Retour non conforme : Cable USB-C manquant au retour.', 'ANOMALIE', '0', '2026-07-08 09:19:47');
SET IDENTITY_INSERT [dbo].[notification] OFF;
DROP TABLE IF EXISTS [dbo].[poste_emprunt];
CREATE TABLE [dbo].[poste_emprunt] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[identifiant_poste] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[salle_pret_id] int NOT NULL,
[actif] bit NOT NULL DEFAULT '((1))',
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[poste_emprunt] ON;
INSERT INTO [dbo].[poste_emprunt] ([id], [nom], [identifiant_poste], [salle_pret_id], [actif], [created_at], [updated_at]) VALUES
(1, 'Poste 1', 'POSTE-01', 1, '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37');
SET IDENTITY_INSERT [dbo].[poste_emprunt] OFF;
DROP TABLE IF EXISTS [dbo].[role];
CREATE TABLE [dbo].[role] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[code] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[libelle] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[description] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
CONSTRAINT [role_code_key] UNIQUE ([code])
);
SET IDENTITY_INSERT [dbo].[role] ON;
INSERT INTO [dbo].[role] ([id], [code], [libelle], [description]) VALUES
(1, 'ETUDIANT', 'Étudiant', 'Emprunte et restitue du matériel via le poste dédié.'),
(2, 'RESPONSABLE', 'Responsable matériel', 'Supervise, gère le stock et traite les anomalies.');
SET IDENTITY_INSERT [dbo].[role] OFF;
DROP TABLE IF EXISTS [dbo].[salle_pret];
CREATE TABLE [dbo].[salle_pret] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[localisation] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[campus_id] int NOT NULL,
[actif] bit NOT NULL DEFAULT '((1))',
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL
);
SET IDENTITY_INSERT [dbo].[salle_pret] ON;
INSERT INTO [dbo].[salle_pret] ([id], [nom], [localisation], [campus_id], [actif], [created_at], [updated_at]) VALUES
(1, 'Salle de prêt principale', 'Campus Saint-Christophe', 1, '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37');
SET IDENTITY_INSERT [dbo].[salle_pret] OFF;
DROP TABLE IF EXISTS [dbo].[utilisateur];
CREATE TABLE [dbo].[utilisateur] (
[id] int NOT NULL IDENTITY PRIMARY KEY,
[microsoft_id] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[prenom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[email] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[classe] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
[role_id] int NOT NULL,
[campus_id] int NOT NULL,
[actif] bit NOT NULL DEFAULT '((1))',
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
[updated_at] datetime2 NOT NULL,
CONSTRAINT [utilisateur_microsoft_id_key] UNIQUE ([microsoft_id]),
CONSTRAINT [utilisateur_email_key] UNIQUE ([email])
);
SET IDENTITY_INSERT [dbo].[utilisateur] ON;
INSERT INTO [dbo].[utilisateur] ([id], [microsoft_id], [nom], [prenom], [email], [classe], [role_id], [campus_id], [actif], [created_at], [updated_at]) VALUES
(1, 'ms-marie-dupont', 'Dupont', 'Marie', 'marie.dupont@ensitech.eu', 'B3 Informatique', 1, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(2, 'ms-lucas-martin', 'Martin', 'Lucas', 'lucas.martin@ensitech.eu', 'B2 Informatique', 1, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(3, 'ms-sofia-bernard', 'Bernard', 'Sofia', 'sofia.bernard@ensup.eu', 'B1 Marketing', 1, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
(4, 'ms-karim-benali', 'Benali', 'Karim', 'karim.benali@ensup.eu', NULL, 2, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52');
SET IDENTITY_INSERT [dbo].[utilisateur] OFF;
ALTER TABLE [dbo].[anomalie] ADD
FOREIGN KEY ([emprunt_id]) REFERENCES [emprunt] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([materiel_id]) REFERENCES [materiel] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([utilisateur_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([traitee_par_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION;
ALTER TABLE [dbo].[carte_etudiante] ADD
FOREIGN KEY ([utilisateur_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE;
ALTER TABLE [dbo].[checklist] ADD
FOREIGN KEY ([emprunt_id]) REFERENCES [emprunt] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([utilisateur_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION;
ALTER TABLE [dbo].[checklist_element] ADD
FOREIGN KEY ([accessoire_id]) REFERENCES [accessoire] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([checklist_id]) REFERENCES [checklist] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION;
ALTER TABLE [dbo].[emprunt] ADD
FOREIGN KEY ([campus_id]) REFERENCES [campus] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([materiel_id]) REFERENCES [materiel] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([poste_emprunt_id]) REFERENCES [poste_emprunt] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([salle_pret_id]) REFERENCES [salle_pret] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([utilisateur_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION;
ALTER TABLE [dbo].[historique] ADD
FOREIGN KEY ([campus_id]) REFERENCES [campus] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([emprunt_id]) REFERENCES [emprunt] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([materiel_id]) REFERENCES [materiel] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([poste_emprunt_id]) REFERENCES [poste_emprunt] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([salle_pret_id]) REFERENCES [salle_pret] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([utilisateur_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION;
ALTER TABLE [dbo].[materiel] ADD
FOREIGN KEY ([campus_id]) REFERENCES [campus] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE,
FOREIGN KEY ([categorie_id]) REFERENCES [categorie_materiel] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE;
ALTER TABLE [dbo].[materiel_accessoire] ADD
FOREIGN KEY ([accessoire_id]) REFERENCES [accessoire] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE,
FOREIGN KEY ([materiel_id]) REFERENCES [materiel] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE;
ALTER TABLE [dbo].[notification] ADD
FOREIGN KEY ([anomalie_id]) REFERENCES [anomalie] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
FOREIGN KEY ([utilisateur_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION;
ALTER TABLE [dbo].[poste_emprunt] ADD
FOREIGN KEY ([salle_pret_id]) REFERENCES [salle_pret] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE;
ALTER TABLE [dbo].[salle_pret] ADD
FOREIGN KEY ([campus_id]) REFERENCES [campus] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE;
ALTER TABLE [dbo].[utilisateur] ADD
FOREIGN KEY ([campus_id]) REFERENCES [campus] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE,
FOREIGN KEY ([role_id]) REFERENCES [role] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE;
-- 2026-07-10 09:04:02 UTC
+45
View File
@@ -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
+30
View File
@@ -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'
+16
View File
@@ -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.
+6
View File
@@ -0,0 +1,6 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
# Convention projet (CLAUDE.md) : guillemets doubles en Dart.
prefer_single_quotes: false
+18
View File
@@ -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";
}
+21
View File
@@ -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();
}
}
+52
View File
@@ -0,0 +1,52 @@
import "materiel_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,
});
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,
);
}
final int id;
final MaterielItem materiel;
final String dateEmprunt;
final String retourPrevu;
final String statut;
EmpruntItem copyWith({MaterielItem? materiel, String? statut}) {
return EmpruntItem(
id: id,
materiel: materiel ?? this.materiel,
dateEmprunt: dateEmprunt,
retourPrevu: retourPrevu,
statut: statut ?? this.statut,
);
}
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,391 @@
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/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;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
} 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 lemprunt.",
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,288 @@
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 "confirmation_screen.dart";
/// Checklist de départ (RG11) : l'étudiant indique l'état de chaque accessoire
/// attendu avant de confirmer l'emprunt.
class ChecklistDepartScreen extends StatefulWidget {
const ChecklistDepartScreen({super.key, required this.item});
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;
@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;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
} 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: "Instructions",
texte:
"Vérifiez physiquement chaque élément du kit et indiquez son état : "
"Présent, Absent ou Détérioré. Signalez tout élément manquant ou "
"endommagé pour éviter tout litige au retour.",
),
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..."
: "Valider la checklist et 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,300 @@
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 "../services/emprunt_service.dart";
import "../widgets/ensup_top_bar.dart";
import "../widgets/brand_corners.dart";
import "../widgets/etat_checklist.dart";
import "resultat_retour_screen.dart";
/// Checklist de retour (RG16) : l'étudiant indique l'état de chaque accessoire
/// au retour, en vue de la comparaison avec la checklist de départ.
class ChecklistRetourScreen extends StatefulWidget {
const ChecklistRetourScreen({super.key, required this.emprunt});
final EmpruntItem emprunt;
@override
State<ChecklistRetourScreen> createState() => _ChecklistRetourScreenState();
}
class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
late final List<String> _elements =
widget.emprunt.materiel.accessoires.isEmpty
? <String>[widget.emprunt.materiel.nom]
: widget.emprunt.materiel.accessoires;
late final List<EtatChecklist> _etats = List<EtatChecklist>.filled(
_elements.length,
EtatChecklist.present,
);
final TextEditingController _commentaireController = TextEditingController();
bool _envoiEnCours = 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] == EtatChecklist.absent) {
anomalies.add(
AnomalieRetour(element: _elements[i], etatRetour: "Absent au retour"),
);
} else if (_etats[i] == EtatChecklist.deteriore) {
anomalies.add(
AnomalieRetour(
element: _elements[i],
etatRetour: "Détérioré au retour",
),
);
}
}
try {
final resultat = await EmpruntService.restituerEmprunt(
empruntId: widget.emprunt.id,
commentaireRetour: _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;
}
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;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
} 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 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: (index, etat) => setState(() => _etats[index] = etat),
),
],
);
}
Widget _colonneDroite() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ChecklistInstructions(
titre: "Rappel checklist de départ",
texte:
"Au départ, tous les accessoires du kit étaient présents. "
"Indiquez leur état au retour ; toute différence sera signalée.",
),
const SizedBox(height: 14),
ChecklistCommentaire(
controller: _commentaireController,
hint: "Commentaire optionnel...",
),
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..." : "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,250 @@
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";
/// Écran de succès clôturant l'emprunt : confirmation + récapitulatif.
class ConfirmationScreen extends StatelessWidget {
const ConfirmationScreen({super.key, required this.emprunt, this.note});
final EmpruntResponse emprunt;
final String? note;
MaterielItem get item => emprunt.materiel;
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() {
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: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(
colors: [Color(0xFF16A34A), Color(0xFF22C55E)],
),
boxShadow: [
BoxShadow(
color: const Color(0xFF16A34A).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(
"Emprunt enregistré !",
style: GoogleFonts.darkerGrotesque(
fontSize: 28,
fontWeight: FontWeight.w900,
color: const Color(0xFF15803D),
),
),
const SizedBox(height: 8),
Text(
"Votre emprunt a bien été enregistré. Vous pouvez récupérer le matériel.",
textAlign: TextAlign.center,
style: GoogleFonts.titilliumWeb(
fontSize: 14,
color: 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),
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,
),
),
),
],
),
);
}
}
+334
View File
@@ -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,
),
),
),
),
],
);
}
}
+168
View File
@@ -0,0 +1,168 @@
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/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;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
}
}
@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 lopération à réaliser sur ce poste.",
style: GoogleFonts.titilliumWeb(
fontSize: 14,
color: EnsupColors.muted,
),
),
const SizedBox(height: 20),
LayoutBuilder(
builder: (context, constraints) {
final emprunter = ActionCard(
icon: Icons.inventory_2_outlined,
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()),
],
),
);
}
}
+330
View File
@@ -0,0 +1,330 @@
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/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;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("La session Microsoft doit etre renouvelee."),
),
);
} finally {
if (mounted) {
setState(() => _connexionEnCours = false);
}
}
}
Future<void> _identifierParQr() async {
if (_connexionEnCours) return;
if (AuthService.mode == AuthenticationMode.azure) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"L'identification QR securisee n'est pas encore configuree.",
),
),
);
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;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
} 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,389 @@
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 "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;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
} 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,312 @@
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";
/// Résultat de la restitution (RG17-RG20) : conforme (succès) ou non conforme
/// (anomalie détectée + notification au responsable).
class ResultatRetourScreen extends StatelessWidget {
const ResultatRetourScreen({
super.key,
required this.emprunt,
required this.anomalies,
});
final EmpruntItem emprunt;
final List<AnomalieRetour> anomalies;
bool get _conforme => anomalies.isEmpty;
static String _deux(int valeur) => valeur.toString().padLeft(2, "0");
@override
Widget build(BuildContext context) {
final fond = _conforme
? const [Color(0xFFF0FDF4), 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 _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 _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,
),
),
),
],
),
);
}
}
+153
View File
@@ -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;
}
}
+122
View File
@@ -0,0 +1,122 @@
import "../models/authenticated_profile.dart";
import "msal_bridge.dart";
enum AuthenticationMode { demo, azure }
class AuthService {
AuthService._();
static const String _configuredMode = String.fromEnvironment(
"AUTH_MODE",
defaultValue: "demo",
);
static const String azureTenantId = String.fromEnvironment("AZURE_TENANT_ID");
static const String azureSpaClientId = String.fromEnvironment(
"AZURE_SPA_CLIENT_ID",
);
static const String azureApiScope = String.fromEnvironment("AZURE_API_SCOPE");
static const String demoStudentEmail = "lucas.martin@ensitech.eu";
static const String demoResponsableEmail = "karim.benali@ensup.eu";
static String? _demoEmail;
static AuthenticatedProfile? _currentProfile;
static bool _azureInitialized = false;
static AuthenticatedProfile? get currentProfile => _currentProfile;
static AuthenticationMode get mode {
return switch (_configuredMode) {
"demo" => AuthenticationMode.demo,
"azure" => AuthenticationMode.azure,
_ => throw StateError("AUTH_MODE doit valoir demo ou azure."),
};
}
static void selectDemoStudent() {
_ensureDemoMode();
_demoEmail = demoStudentEmail;
}
static void selectDemoResponsable() {
_ensureDemoMode();
_demoEmail = demoResponsableEmail;
}
static void clearSession() {
_demoEmail = null;
_currentProfile = null;
}
static AuthenticatedProfile setProfileFromApi(dynamic reponse) {
final profile = AuthenticatedProfile.fromApi(reponse);
_currentProfile = profile;
return profile;
}
static Future<void> signInWithMicrosoft() async {
await _initializeAzure();
await MsalBridge.signIn();
}
static Future<bool> hasMicrosoftAccount() async {
await _initializeAzure();
return MsalBridge.hasAccount();
}
static Future<void> signOut() async {
if (mode == AuthenticationMode.azure) {
await _initializeAzure();
await MsalBridge.signOut();
_currentProfile = null;
return;
}
clearSession();
}
static Future<Map<String, String>> requestHeaders() async {
if (mode == AuthenticationMode.azure) {
await _initializeAzure();
final token = await MsalBridge.getAccessToken();
return {"authorization": "Bearer $token"};
}
final email = _demoEmail;
if (email == null) {
throw StateError("Aucun utilisateur de demonstration selectionne.");
}
return {"x-user-email": email};
}
static Future<void> _initializeAzure() async {
if (mode != AuthenticationMode.azure) {
throw StateError("MSAL est disponible uniquement en mode Azure.");
}
if (_azureInitialized) {
return;
}
if (azureTenantId.isEmpty ||
azureSpaClientId.isEmpty ||
azureApiScope.isEmpty) {
throw StateError("La configuration Azure du frontend est incomplete.");
}
await MsalBridge.initialize(
tenantId: azureTenantId,
clientId: azureSpaClientId,
scope: azureApiScope,
redirectUri: Uri.base.origin,
);
_azureInitialized = true;
}
static void _ensureDemoMode() {
if (mode != AuthenticationMode.demo) {
throw StateError(
"Une identite de demonstration est interdite en mode Azure.",
);
}
}
}
@@ -0,0 +1,48 @@
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);
}
}
@@ -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,57 @@
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() =>
_liste("/responsable/emprunts");
static Future<List<Map<String, dynamic>>> materiels() =>
_liste("/responsable/materiels");
static Future<List<Map<String, dynamic>>> anomalies() =>
_liste("/responsable/anomalies");
static Future<List<Map<String, dynamic>>> notifications() =>
_liste("/responsable/notifications");
static Future<List<Map<String, dynamic>>> historique() =>
_liste("/responsable/historique");
static Future<ApiFile> exporterHistorique() => ApiClient.getFile(
"/responsable/historique/export.csv",
fallbackFileName: "historique-responsable.csv",
);
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<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();
}
}
+22
View File
@@ -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);
}
+23
View File
@@ -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,
),
);
}
+21
View File
@@ -0,0 +1,21 @@
// ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use
import "dart:html" as html;
import "dart:typed_data";
void downloadFile({
required Uint8List bytes,
required String fileName,
required String contentType,
}) {
final blob = html.Blob([bytes], contentType);
final url = html.Url.createObjectUrlFromBlob(blob);
final anchor = html.AnchorElement(href: url)
..download = fileName
..style.display = "none";
html.document.body?.children.add(anchor);
anchor.click();
anchor.remove();
html.Url.revokeObjectUrl(url);
}
+127
View File
@@ -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,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;
}
+181
View File
@@ -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,
),
),
],
),
),
),
),
),
);
}
}
+167
View File
@@ -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,
],
);
},
),
),
);
}
}
+137
View File
@@ -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,
),
),
],
),
),
],
],
);
},
),
);
}
}
+29
View File
@@ -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,
),
),
);
}
}
+520
View File
@@ -0,0 +1,520 @@
{
"name": "eme-frontend",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@azure/msal-browser": "^5.17.1"
},
"devDependencies": {
"esbuild": "^0.28.1"
}
},
"node_modules/@azure/msal-browser": {
"version": "5.17.1",
"resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.17.1.tgz",
"integrity": "sha512-zBhRGzABKSI7hfWh5EaZmril5ybZ7imBN1qEZl5sDTaelr+l8SnPjZO50Q4dnKnm347YPIlBMSnXKZyh3Yu5DQ==",
"license": "MIT",
"dependencies": {
"@azure/msal-common": "16.11.2"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@azure/msal-common": {
"version": "16.11.2",
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.11.2.tgz",
"integrity": "sha512-yDhtBOGDCdK9ipQ9g3+wmlMEPnZx2pXaDicDd9jYyR1L+7lEbvEohTDmF5qejZDutZY3m9pWPxeYxzNC701A2w==",
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "eme-frontend-auth",
"private": true,
"scripts": {
"build:auth": "esbuild ./web/auth/msal_bridge.js --bundle --minify --format=iife --outfile=./web/msal_bridge.bundle.js"
},
"dependencies": {
"@azure/msal-browser": "^5.17.1"
},
"devDependencies": {
"esbuild": "^0.28.1"
}
}
+373
View File
@@ -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"
+91
View File
@@ -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
+11
View File
@@ -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);
});
}
+105
View File
@@ -0,0 +1,105 @@
import {
InteractionRequiredAuthError,
PublicClientApplication,
} from "@azure/msal-browser";
let msalInstance = null;
let apiScope = "";
function requireInstance() {
if (!msalInstance) {
throw new Error("MSAL n'est pas initialise.");
}
return msalInstance;
}
function activeAccount() {
const instance = requireInstance();
return instance.getActiveAccount() ?? instance.getAllAccounts()[0] ?? null;
}
async function initialize(tenantId, clientId, scope, redirectUri) {
if (msalInstance) {
return;
}
if (!tenantId || !clientId || !scope || !redirectUri) {
throw new Error("La configuration Azure du frontend est incomplete.");
}
apiScope = scope;
msalInstance = await PublicClientApplication.createPublicClientApplication({
auth: {
clientId,
authority: `https://login.microsoftonline.com/${tenantId}`,
redirectUri,
postLogoutRedirectUri: redirectUri,
},
cache: {
cacheLocation: "sessionStorage",
},
});
const account = activeAccount();
if (account) {
msalInstance.setActiveAccount(account);
}
}
async function signIn() {
const instance = requireInstance();
const result = await instance.loginPopup({
scopes: ["openid", "profile", "email", apiScope],
});
instance.setActiveAccount(result.account);
}
async function getAccessToken() {
const instance = requireInstance();
const account = activeAccount();
if (!account) {
throw new Error("Aucun compte Microsoft connecte.");
}
try {
const result = await instance.acquireTokenSilent({
account,
scopes: [apiScope],
});
return result.accessToken;
} catch (error) {
if (!(error instanceof InteractionRequiredAuthError)) {
throw error;
}
const result = await instance.acquireTokenPopup({
account,
scopes: [apiScope],
});
return result.accessToken;
}
}
function hasAccount() {
return activeAccount() !== null;
}
async function signOut() {
const instance = requireInstance();
const account = activeAccount();
if (!account) {
return;
}
await instance.logoutPopup({ account });
}
globalThis.emeMsal = {
getAccessToken,
hasAccount,
initialize,
signIn,
signOut,
};
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

+39
View File
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html>
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="eme_frontend">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>eme_frontend</title>
<link rel="manifest" href="manifest.json">
</head>
<body>
<script src="msal_bridge.bundle.js"></script>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
{
"name": "eme_frontend",
"short_name": "eme_frontend",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "A new Flutter project.",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "icons/Icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/Icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/Icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/Icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
File diff suppressed because one or more lines are too long
+304 -8
View File
@@ -20,10 +20,73 @@ Ces décisions s'appliquent à tout le projet, sauf mention contraire.
| Champs nullable selon les cardinalités `0..1` du diagramme de classes | Un champ non connu à la création (ex. date de retour) ou optionnel doit être `NULL`, sinon le workflow métier est bloqué. |
| `onDelete: NoAction, onUpdate: NoAction` sur les données de traçabilité (Emprunt, Checklist, ChecklistElement, Anomalie, Historique) | Ces données ne doivent jamais être supprimées/altérées automatiquement en cascade. Toute suppression sera explicite côté service. Évite aussi l'erreur SQL Server "multiple cascade paths". |
| ERD tenu à jour en miroir du schéma (`docs/conceptions/uml/ERD.md`) | La source de vérité doit refléter le code réel (nullable, `UK`, etc.). |
| Workflow Git : commits sur `develop` uniquement | `master` est figé ; `develop` reste toujours à jour de `master` (aucune divergence puisque master ne bouge plus). |
| Workflow Git : `develop` comme branche d'intégration + branches dédiées `feat/...`, `fix/...`, `docs/...` | `master` reste figé ; chaque changement logique est isolé sur une branche puis mergé dans `develop`, conformément à `CLAUDE.md`. |
| Process avant chaque migration : `prisma format` -> `prisma validate` -> `npm run build` -> migration | Détecter toute erreur de schéma/typage avant de toucher la base. |
| DTO nommés `XxxResponse` (sortie) et `XxxRequest` (entrée) | Le sens est explicite dans le nom. Sortie = mapping (`toXxxResponse`), entrée = validation. |
---
## État actuel synthétique
Cette section résume l'état courant du projet. Le journal chronologique plus bas conserve
l'historique complet, y compris des étapes devenues obsolètes après branchement API.
| Bloc | État | Commentaire |
|---|---|---|
| Infrastructure | Terminé | Docker Compose SQL Server + Adminer, monorepo, backend Node/TS, frontend Flutter Web. |
| Base de données | Terminé pour la V1 | Schéma Prisma 16 entités, migrations, seeds et fixtures de démonstration. |
| API étudiant | Terminé pour la V1 | Catalogue, détail matériel, création d'emprunt, mes emprunts, restitution, anomalie automatique. |
| Frontend étudiant | Fonctionnel pour la V1 | Parcours API complet, ordre identification puis choix validé, interface premium et responsive fidèle à la maquette. |
| Authentification | Prête pour Azure, démo active | Double mode sécurisé : `x-user-email` seulement en développement, MSAL/OIDC + Bearer JWT en mode Azure. Validation réelle en attente des identifiants Entra ID. |
| API responsable | Terminé pour la V1 | Dashboard, emprunts, stock, anomalies, notifications, historique et export CSV, avec contrôle du rôle et du campus. |
| Frontend responsable | Fonctionnel pour la V1 | Espace de supervision responsive avec navigation dédiée, dashboard métier, vues API et export CSV opérationnel. |
| Tests automatisés | Non démarré | Tests backend/frontend/E2E à ajouter ; tests runtime manuels effectués. |
| Documentation | Partielle | README principal et documents de conception présents ; OpenAPI, guides utilisateur et captures restent à produire. |
## Commandes validées
Commandes utilisées et validées dans l'environnement de développement local :
```bash
docker compose up -d
cd eme-backend
npm run dev
npm run build
npm run lint
npm run seed
npm run fixtures
cd ../eme-frontend
npm run build:auth
flutter run -d web-server --web-port 5000
C:\flutter\flutter\bin\cache\dart-sdk\bin\dart.exe analyze
```
Notes :
- le backend écoute sur `http://localhost:3000` ;
- le frontend web de démo écoute sur `http://localhost:5000` ;
- Adminer est disponible sur `http://localhost:8081` ;
- le wrapper `flutter` peut rester bloqué dans l'environnement Codex ; l'analyse directe avec l'exécutable Dart fonctionne et ne signale aucune erreur ;
- la compilation Web complète a été validée avec l'exécutable Dart du SDK Flutter et produit `build/web`.
## Scénario de démo validé
Scénario étudiant validé avec SQL Server Docker et backend compilé :
1. Afficher le catalogue des matériels disponibles du campus.
2. Ouvrir le détail d'un matériel et vérifier les accessoires.
3. Créer un emprunt avec checklist de départ.
4. Vérifier que l'emprunt apparaît dans `mes-emprunts`.
5. Restituer conforme : l'emprunt passe `CLOTURE` et le matériel redevient disponible.
6. Restituer non conforme : l'emprunt passe `RETOUR_NON_CONFORME` et le matériel sort du catalogue disponible.
Scénario responsable validé avec SQL Server Docker et backend compilé :
1. Karim (`RESPONSABLE`) accède au dashboard, aux emprunts, au stock, aux anomalies, aux notifications et à l'historique de son campus.
2. Lucas (`ETUDIANT`) reçoit une réponse `403` sur les routes responsable.
3. Une anomalie peut avancer dans le cycle `DETECTEE -> EN_COURS_TRAITEMENT -> RESOLUE -> CLOTUREE`.
4. Chaque transition enregistre le responsable et crée une entrée d'historique.
5. L'API génère l'export CSV de l'historique et l'interface Flutter déclenche son téléchargement.
### Note outillage — migrations en environnement non-interactif
`prisma migrate dev` se bloque quand il doit demander une confirmation (ex. ajout de contrainte `@unique` sur table existante) car le terminal est non-interactif.
@@ -149,19 +212,252 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
- Préventif : clés/certificats (`*.pem`, `*.key`, `*.crt`, `*.cert`, `*.pfx`, `*.p12`) pour Azure AD/TLS à venir ; dumps de base (`*.bak`, `*.dump`) ; `**/coverage/` ; `*.tsbuildinfo`.
- Vérifié : aucun secret n'était déjà suivi ; les vrais `.env` restent ignorés ; `.env.example` (placeholders) reste versionné.
### Étape 19 — Block 4 : création d'emprunt (POST /api/emprunts)
- Écriture transactionnelle : réservation atomique du matériel (`updateMany where statut=DISPONIBLE`), création de l'emprunt (`EN_COURS`), de la checklist de départ et de ses éléments. Rollback global si une étape échoue.
- Règles : RG07 (matériel disponible), RG10 (matériel du campus de l'étudiant), RG11 (checklist obligatoire non vide), RG12 (matériel -> `EMPRUNTE`, horodatage).
- Validation d'entrée manuelle (`CreerEmpruntRequest`, `parseCreerEmpruntRequest`) : aucune dépendance ajoutée. Durée d'emprunt par défaut : +14 jours.
- Contrôle du poste (existe + même campus, RG05). Routage : `POST /api/emprunts` et `GET /api/mes-emprunts` sur des routeurs séparés.
- Vérifié au runtime : 401 sans auth, 400 checklist vide, 404 matériel inconnu, 201 création (matériel -> `EMPRUNTE`, +14j), 409 doublon (réservation atomique).
### Étape 20 — Block 4 : restitution + anomalie automatique (POST /api/emprunts/:id/restitution)
- Comparaison automatique départ/retour (RG17) : un élément présent au départ mais absent/détérioré au retour rend la restitution non conforme (RG20). Appariement par `accessoireId` sinon par `nomElement`.
- Restitution atomique : checklist de retour + mise à jour emprunt/matériel + (si non conforme) anomalie `DETECTEE` + notifications.
- RG15 (propriétaire), statut restituable, RG18 (conforme -> `CLOTURE` + matériel `DISPONIBLE`), RG19 (non conforme -> `RETOUR_NON_CONFORME` + matériel `DETERIORE`/`NON_CONFORME`).
- RG24 : notification à tous les responsables (`RESPONSABLE`) du campus de l'emprunt.
- Vérifié au runtime : 404/403/409 ; restitution conforme (CLOTURE + DISPONIBLE) ; non conforme (RETOUR_NON_CONFORME + NON_CONFORME + anomalie + notification à Karim).
- **Block 4 (API Étudiant) terminé : 6/6 endpoints.**
### Étape 21 — Frontend : setup Flutter Web + écran d'accueil ENSUP
- Projet Flutter Web créé (`eme-frontend/`), `google_fonts` ajouté (Darker Grotesque pour les titres, Titillium Web pour le corps).
- Thème ENSUP (`theme/ensup_colors.dart`, `theme/ensup_theme.dart`) : couleurs de la charte graphique.
- Composants réutilisables : `EnsupTopBar`, `ProfileCard`, `ActionCard`.
- Écran d'accueil (`screens/home_screen.dart`) fidèle à la maquette : topbar bleu foncé, carte profil, deux cartes Emprunter/Restituer. Données statiques (pas encore branché à l'API).
- Convention `CLAUDE.md` respectée : guillemets doubles en Dart (règle lint `prefer_single_quotes` désactivée en conséquence).
- `flutter analyze` : aucun problème. App lancée dans Chrome (http://localhost:5000).
### Étape 22 — Suppression du PDF de charte graphique
- PDF de charte ENSUP retiré du dépôt (redondant : la maquette validée applique la charte, et les couleurs/typos sont dans `CONTEXT.md` §7).
- Références mises à jour dans `CLAUDE.md` et `CONTEXT.md` pour pointer vers la maquette.
- Le PDF reste récupérable dans l'historique Git si besoin (logo officiel source).
### Étape 23 — Frontend : écran d'identification (écran de démarrage)
- Correction : l'écran de démarrage est l'identification (`s-login` de la maquette), pas l'accueil étudiant. Logo, "Bienvenue sur EME", deux options (scanner carte / compte ENSUP Microsoft 365), bouton accès responsable.
- `EnsupTopBar` rendue réutilisable (utilisateur optionnel, marque paramétrable) ; nouveau widget `IdentificationOption` ; coins ENSUP aussi sur cet écran.
- Navigation identification -> accueil étudiant (`HomeScreen`).
### Étape 24 — Frontend : écran catalogue matériel
- Écran catalogue (`s-catalogue`) : recherche **fonctionnelle** + filtres par catégorie (chips) + liste de matériels avec statut (Disponible/Emprunté) et état vide.
- Nouveaux composants : `MaterielCard`, `StatusPill`, modèle `MaterielItem`. Données statiques (4 matériels de la maquette).
- Navigation : Accueil (Emprunter) -> Catalogue ; bouton retour vers l'accueil. « Sélectionner » -> placeholder (détail à venir).
### Étape 25 — Frontend : écran détail matériel
- Écran détail (`s-detail`) : caractéristiques (référence, catégorie, marque, modèle, état, campus, statut) + accessoires du kit (tags) + aperçu + boutons « Démarrer l'emprunt » / « Retour au catalogue ».
- Modèle `MaterielItem` enrichi (marque, modèle, état, accessoires) ; données statiques mises à jour.
- Navigation : Catalogue (Sélectionner) -> Détail ; « Démarrer l'emprunt » -> placeholder (checklist à venir).
> Note historique : les étapes 21 à 25 décrivent l'état initial statique du frontend.
> Elles ont été remplacées fonctionnellement par les branchements API des étapes 26 à 32.
---
## Dette technique en attente
À traiter ensemble plus tard (avec mise à jour ERD en miroir) :
1. `CarteEtudiante.qr_code` -> `@unique` (identification, RG02)
2. `MaterielAccessoire` -> `@@unique([materielId, accessoireId])` (un accessoire une seule fois par matériel)
3. `Materiel.statut` -> `@default("DISPONIBLE")` (un matériel neuf est disponible, RG07)
4. Tailles de colonnes `@db.NVarChar(n)` (actuellement `NVARCHAR(1000)` partout)
5. Swagger/OpenAPI (Block 1, reporté après les endpoints)
6. Auth réelle Azure AD (Block 3) — remplacera l'auth simulée par en-tête
| Dette | Statut | Impact V1 |
|---|---|---|
| `CarteEtudiante.qr_code` -> `@unique` | À faire avec migration + ERD | Moyen : sécurise l'identification QR. |
| `MaterielAccessoire` -> `@@unique([materielId, accessoireId])` | À faire avec migration + ERD | Moyen : évite les doublons d'accessoires. |
| `Materiel.statut` -> `@default("DISPONIBLE")` | À faire avec migration + ERD | Faible : le code fournit déjà le statut explicitement. |
| Tailles de colonnes `@db.NVarChar(n)` | À cadrer | Faible V1, important pour qualité BDD. |
| Swagger/OpenAPI | À faire | Moyen : utile pour tester/documenter l'API. |
| Validation réelle Azure AD | En attente des identifiants Entra ID | Fort : le code OIDC/MSAL/JWT est prêt, mais une connexion Microsoft réelle doit encore être testée sur le tenant ENSUP. |
| Limite d'emprunts actifs par étudiant | À valider métier | Non bloquant V1 ; décision métier non présente dans les règles actuelles. |
---
*Dernière mise à jour : 2026-06-22 — Durcissement .gitignore (sécurité secrets).*
### Étape 26 — Connexion frontend ↔ API (catalogue)
- Package `http` ajouté. Couche `services/api_client.dart` : URL de base, en-tête d'auth simulée (`x-user-email: lucas.martin@ensitech.eu`), gestion des erreurs (serveur injoignable, message `{error:{message}}`).
- `services/materiel_service.dart` (`getCatalogue`) + `MaterielItem.fromJson` (icône déduite de la catégorie, champs absents complétés).
- Écran catalogue branché via `FutureBuilder` : chargement / données / erreur + bouton Réessayer. Filtres de catégorie déduits des vraies données.
- CORS backend assoupli en développement (`origin: true` si `NODE_ENV=development`), strict en production.
- Testé en réel côté backend : Docker SQL Server OK, backend compilé OK, `/health`, `/api/auth/me`, `/api/materiels` OK, 401 sans `x-user-email` OK.
### Étape 27 — Connexion frontend ↔ API (détail matériel)
- Correctif runtime backend : `npm run dev` lance maintenant `ts-node --files`, sinon l'augmentation `Express.Request.user` n'était pas chargée par `ts-node` malgré un build TypeScript vert.
- Endpoint `GET /api/materiels/:id` ajouté : détail filtré par campus utilisateur, avec catégorie, campus et accessoires du kit.
- Frontend : `MaterielItem` porte maintenant l'`id`, `MaterielService.getDetail(id)` consomme le nouvel endpoint, et le catalogue charge le détail réel avant de naviguer vers l'écran détail.
- Testé en réel côté backend : `/api/materiels/1` renvoie le PC avec accessoires (`Chargeur`, `Souris`) ; `/api/materiels/abc` renvoie 400.
- Limite environnement : `flutter analyze`, `flutter analyze --no-pub` et `dart format` restent bloqués jusqu'au timeout dans le sandbox. À relancer dans un terminal Flutter local.
### Étape 28 — Connexion frontend ↔ API (création d'emprunt)
- Branche dédiée `feat/frontend-emprunt-api` créée après merge de `feat/catalogue-detail-api` dans `develop`.
- `ApiClient.post` ajouté et `EmpruntService.creerEmprunt` consomme `POST /api/emprunts` avec l'auth simulée.
- Checklist de départ branchée sur l'API : construction du payload (`materielId`, `posteEmpruntId`, `modeIdentification`, commentaire, éléments), état de chargement, affichage d'erreur via snackbar.
- Un matériel sans accessoire génère un élément de checklist portant le nom du matériel, afin de respecter RG11 côté backend (checklist obligatoire non vide).
- Confirmation alimentée par la réponse API (`id`, `dateEmprunt`, matériel renvoyé).
- Testé sans mutation de données : `POST /api/emprunts` avec checklist vide renvoie bien 400 `La checklist de depart est obligatoire (RG11)`.
### Étape 29 — Données de démonstration catalogue
- Branche dédiée `feat/demo-catalogue-fixtures` créée après merge de `feat/frontend-emprunt-api` dans `develop`.
- `prisma/fixtures.ts` enrichi avec un catalogue de démonstration idempotent : si les fixtures de base existent déjà, le script assure seulement les matériels de catalogue manquants.
- 8 matériels disponibles ajoutés : Lenovo ThinkPad, MacBook Air, Surface Pro, vidéoprojecteur Epson, caméra Canon, casque Jabra, kit câbles HDMI/USB-C, enceinte JBL.
- Accessoires associés assurés sans doublon logique par recherche de nom/référence avant création.
- Vérifié en réel : `npm run fixtures` OK ; `GET /api/materiels` pour Lucas renvoie 10 matériels disponibles.
### Étape 30 — Connexion frontend ↔ API (restitution)
- Branche dédiée `feat/frontend-restitution-api` créée après merge de `feat/demo-catalogue-fixtures` dans `develop`.
- `EmpruntItem.fromJson` ajouté pour mapper `GET /api/mes-emprunts`.
- `EmpruntService.getMesEmprunts()` et `EmpruntService.restituerEmprunt()` ajoutés.
- Écran de sélection restitution branché sur les vrais emprunts en cours, avec chargement, erreur, état vide et chargement du détail matériel avant la checklist.
- Checklist retour branchée sur `POST /api/emprunts/:id/restitution`, avec commentaire optionnel, état de chargement et erreurs via snackbar.
- Le résultat affiché se base sur le statut renvoyé par le backend (`CLOTURE` => conforme, sinon anomalie), afin de rester cohérent avec la comparaison serveur.
- Vérifié sans mutation : `GET /api/mes-emprunts` renvoie les emprunts de Lucas ; restitution d'un identifiant inexistant renvoie 404.
### Étape 31 — Stabilisation parcours étudiant complet
- Branche dédiée `feat/student-flow-stabilization` créée après merge de `feat/frontend-restitution-api` dans `develop`.
- Docker SQL Server relancé puis tests runtime faits avec le backend compilé.
- Flux conforme validé en réel : catalogue -> détail -> création emprunt -> apparition dans `mes-emprunts` -> restitution conforme -> statut `CLOTURE` -> matériel de nouveau visible dans le catalogue.
- Flux non conforme validé en réel : création emprunt -> restitution avec élément absent -> statut `RETOUR_NON_CONFORME` -> matériel retiré du catalogue disponible.
- Aucun correctif code nécessaire après ces tests ; backend `build` et `lint` restent verts.
### Étape 32 — Polish V1 étudiant
- Branche dédiée `feat/v1-student-polish` créée après merge de `feat/student-flow-stabilization` dans `develop`.
- Identité simulée et campus MVP centralisés dans `lib/demo_identity.dart` tant que l'auth Azure AD n'est pas branchée.
- Libellés UI harmonisés sur le contexte réel du MVP : `Saint-Christophe · Cergy` / `Ensitech Cergy` au lieu de `Paris · Ensitech`.
- Commentaire obsolète du modèle `MaterielItem` corrigé : les données viennent maintenant de l'API.
- `TODO.md` mis à jour : les parcours emprunt et restitution étudiant sont maintenant branchés API.
### Étape 33 — Clarification du journal de décisions
- Branche dédiée `docs/review-current-state` créée depuis `develop`.
- Ajout d'une synthèse d'état courant pour éviter de devoir relire tout l'historique.
- Correction de la convention Git : `develop` est la branche d'intégration, les travaux passent par branches dédiées.
- Ajout des commandes validées et du scénario de démo étudiant testé.
- Dette technique reformatée en tableau avec statut et impact V1.
- Clarification des étapes frontend statiques historiques : elles sont conservées pour mémoire mais remplacées par les branchements API ultérieurs.
### Étape 34 — API responsable : dashboard
- Branche dédiée `feat/responsable-dashboard-api` créée depuis `develop`.
- Endpoint `GET /api/responsable/dashboard` ajouté, protégé par rôle `RESPONSABLE`.
- Périmètre campus appliqué via `user.campusId` (RG27).
- KPIs exposés : matériels par statut, emprunts par statut, anomalies par statut, notifications non lues.
- Activité récente exposée depuis l'historique, avec utilisateur, matériel et emprunt associé.
- Vérifié au runtime : Karim (`RESPONSABLE`) obtient 200 avec KPIs ; Lucas (`ETUDIANT`) obtient 403.
### Étape 35 — API responsable : consultation des emprunts
- Branche dédiée `feat/responsable-emprunts-api` créée après merge du dashboard responsable dans `develop`.
- Endpoint `GET /api/responsable/emprunts` ajouté, protégé par rôle `RESPONSABLE`.
- Filtrage par campus du responsable appliqué systématiquement (RG27).
- Filtre optionnel `statut` ajouté pour RG28 : `EN_COURS`, `EN_RETARD`, `CLOTURE`, `RETOUR_NON_CONFORME`, `ANNULE`.
- Réponse enrichie avec l'étudiant, le matériel, la catégorie, les dates et le statut.
- Vérifié au runtime : Karim obtient les emprunts du campus, `statut=EN_COURS` filtre correctement, statut invalide renvoie 400, Lucas obtient 403.
### Étape 36 — API responsable : consultation du stock
- Branche dédiée `feat/responsable-stock-api` créée après merge de la consultation des emprunts responsable dans `develop`.
- Endpoint `GET /api/responsable/materiels` ajouté, protégé par rôle `RESPONSABLE`.
- Filtrage par campus du responsable appliqué systématiquement (RG27/RG29).
- Filtres optionnels ajoutés : `statut`, `categorieId`, `q` (nom, marque, modèle, référence).
- Contrairement au catalogue étudiant, cet endpoint retourne tout le stock actif du campus, quel que soit le statut.
- Réponse enrichie avec catégorie et accessoires attendus.
- Vérifié au runtime : Karim obtient tout le stock du campus, `statut=DISPONIBLE` et `q=MacBook` filtrent correctement, statut invalide renvoie 400, Lucas obtient 403.
### Étape 37 — API responsable : consultation des anomalies
- Branche dédiée `feat/responsable-anomalies-api` créée après merge de la consultation du stock responsable dans `develop`.
- Endpoint `GET /api/responsable/anomalies` ajouté, protégé par rôle `RESPONSABLE`.
- Filtrage par campus du responsable appliqué systématiquement via l'emprunt lié à l'anomalie (RG27).
- Filtres optionnels ajoutés : `statut` (`DETECTEE`, `EN_COURS_TRAITEMENT`, `RESOLUE`, `CLOTUREE`) et `type`.
- Réponse enrichie avec anomalie, emprunt, étudiant concerné, matériel, catégorie et responsable de traitement si renseigné.
- Limite volontaire : consultation uniquement ; le cycle de traitement/résolution reste à développer.
### Étape 38 — API responsable : notifications
- Branche dédiée `feat/responsable-notifications-api` créée après merge de la consultation des anomalies responsable dans `develop`.
- Endpoint `GET /api/responsable/notifications` ajouté, protégé par rôle `RESPONSABLE`.
- Filtre optionnel `lu=true|false` ajouté pour séparer les notifications lues et non lues.
- Endpoint `PATCH /api/responsable/notifications/:id/lu` ajouté pour marquer une notification du responsable connecté comme lue.
- Endpoint `PATCH /api/responsable/notifications/lu-toutes` ajouté pour marquer toutes les notifications non lues du responsable connecté comme lues.
- Réponse enrichie avec l'anomalie liée et le matériel concerné lorsque la notification pointe vers une anomalie.
### Étape 39 — API responsable : historique et export
- Branche dédiée `feat/responsable-historique-api` créée après merge des notifications responsable dans `develop`.
- Endpoint `GET /api/responsable/historique` ajouté, protégé par rôle `RESPONSABLE`.
- Filtrage par campus du responsable appliqué systématiquement (RG27).
- Filtres optionnels ajoutés : `action`, `utilisateurId`, `materielId`, `empruntId`, `dateDebut`, `dateFin`.
- Endpoint `GET /api/responsable/historique/export.csv` ajouté avec les mêmes filtres.
- Export CSV généré côté API avec les colonnes principales : date, action, description, utilisateur, email, matériel, emprunt.
### Étape 40 — API responsable : cycle de statut des anomalies
- Branche dédiée `feat/responsable-anomalies-cycle-api` créée après merge de l'historique responsable dans `develop`.
- Endpoint `PATCH /api/responsable/anomalies/:id/statut` ajouté, protégé par rôle `RESPONSABLE`.
- Filtrage par campus appliqué avant toute modification pour empêcher le traitement d'une anomalie hors campus (RG27).
- Transitions autorisées : `DETECTEE -> EN_COURS_TRAITEMENT -> RESOLUE -> CLOTUREE`.
- Le responsable connecté est enregistré dans `traiteeParId`; une observation optionnelle peut être conservée.
- Une entrée d'historique `TRAITEMENT_ANOMALIE` est créée à chaque transition.
### Étape 41 — Frontend responsable : premier branchement API
- Branche dédiée `feat/responsable-frontend-api` créée pour isoler le travail frontend responsable.
- Le bouton "Accès responsable matériel" de l'écran d'identification ouvre maintenant un écran responsable.
- Client API étendu pour accepter l'e-mail simulé du responsable (`karim.benali@ensup.eu`) et les requêtes `PATCH`.
- Service frontend responsable ajouté pour consommer dashboard, emprunts, stock, anomalies, notifications et historique.
- Écran responsable ajouté avec onglets : dashboard, emprunts, stock, anomalies, notifications, historique.
- Les anomalies peuvent être avancées au prochain statut autorisé via l'API.
- Vérification statique effectuée avec l'exécutable Dart direct : `dart analyze` OK. Le wrapper `flutter` reste instable dans cette session et bloque au lancement web automatisé.
### Étape 42 — Frontend responsable : polish opérationnel
- Branche dédiée `feat/responsable-frontend-polish` créée après merge du premier branchement responsable.
- Statuts responsables remplacés par des libellés métier lisibles et des badges colorés.
- Recherche ajoutée sur les onglets emprunts, stock, anomalies, notifications et historique.
- Les lignes de listes affichent désormais une méta-information utile : identifiant emprunt, marque/modèle, date de détection, date de notification ou auteur historique.
- Action "Tout marquer lu" ajoutée dans l'onglet notifications.
- Action export CSV rendue visible dans l'onglet historique avec indication de l'endpoint disponible.
- Vérification statique : `dart analyze` OK.
### Étape 43 — Synchronisation de l'état courant
- Synthèse mise en cohérence avec les étapes 34 à 42 : API et frontend responsable désormais fonctionnels pour la V1.
- Documentation corrigée pour refléter l'existence du README principal.
- Limites restantes explicitées : authentification Azure AD, téléchargement CSV frontend, tests automatisés, OpenAPI et guides utilisateur.
- Commandes de vérification actualisées : build et lint backend verts, analyse Dart directe sans erreur.
### Étape 44 — Refonte premium de l'espace responsable
- Branche dédiée `style/responsable-premium-dashboard` créée depuis `develop`.
- L'espace responsable adopte une structure de supervision distincte du parcours étudiant : barre supérieure métier, navigation latérale sur ordinateur et navigation compacte sur petit écran.
- Dashboard enrichi avec disponibilités, emprunts en cours, retards, anomalies actives, notifications non lues, activité récente et taux de disponibilité du parc.
- Accès directs ajoutés depuis les points d'attention vers les vues emprunts, stock, anomalies et notifications.
- Les listes métier partagent désormais une hiérarchie visuelle, une recherche et des lignes responsives cohérentes avec la charte ENSUP.
- Aucun changement backend ni métier ; rendu validé manuellement dans le navigateur et `dart analyze` sans erreur.
### Étape 45 — Téléchargement CSV de l'historique responsable
- Branche dédiée `feat/responsable-historique-export` créée depuis `develop`.
- Client HTTP étendu pour récupérer un fichier binaire avec son type de contenu et son nom.
- Le bouton `Export CSV` appelle désormais l'endpoint responsable avec l'identité simulée de Karim et déclenche le téléchargement navigateur.
- Un état de chargement bloque les doubles clics et les erreurs API sont affichées dans l'interface.
- Endpoint vérifié au runtime : réponse `200`, type `text/csv`, nom `historique-responsable.csv` et contenu non vide.
- Vérification statique : `dart analyze` sans erreur.
### Étape 46 — Refonte premium de l'espace étudiant
- L'ordre actuel du parcours est confirmé : identification, puis choix `Emprunter` ou `Restituer`.
- Branche dédiée `style/student-premium-workspace` créée depuis `develop`.
- Charte et maquette conservées : couleurs ENSUP, typographies, barre supérieure, coins de marque et structure des écrans.
- Barre supérieure rendue responsive, avec priorité donnée au titre et à l'identité sur les petites largeurs.
- Accueil rééquilibré avec profil identifié, hiérarchie renforcée et cartes d'action adaptatives avec états de survol.
- Catalogue, détail matériel et sélection de restitution enrichis sans modifier leurs données ni leurs actions.
- Détail et checklists passent automatiquement de deux colonnes à une colonne sur écran étroit ; tableau de contrôle et lignes de matériel évitent les débordements.
- Écrans de confirmation et de résultat conservés fonctionnellement et harmonisés via les composants partagés.
- Vérification statique : `dart analyze` sans erreur.
### Étape 47 — Sécurisation SSO Microsoft Entra ID
- Branche dédiée `feat/block-3-azure-sso` créée depuis `develop`.
- Backend configuré avec deux modes : `demo` en développement et `azure` pour une authentification réelle ; le mode démo est explicitement interdit en production.
- Middleware d'authentification refactoré vers un service dédié. En mode Azure, `x-user-email` est ignoré et un Bearer Token Microsoft est obligatoire.
- Validation JWT ajoutée : signature RSA via les clés JWKS Microsoft, algorithme `RS256`, issuer, audience, tenant, scope `access_as_user`, identifiant `oid` et domaines `ensup.eu` / `ensitech.eu`.
- L'utilisateur, son rôle et son campus restent résolus depuis `eme_db`. Un étudiant obtient `403` sur la supervision, tandis que le responsable de démonstration obtient `200`.
- Frontend doté d'un double mode de session. La simulation sélectionne Lucas ou Karim sans pouvoir être utilisée en mode Azure.
- Bibliothèque officielle `@azure/msal-browser` intégrée via un bundle local construit avec esbuild et un pont typé `dart:js_interop`.
- Flux OpenID Connect préparé avec connexion Microsoft, acquisition silencieuse de l'Access Token, appel de `/api/auth/me`, navigation selon le rôle local, restauration après actualisation et déconnexion MSAL.
- Profil authentifié typé et affiché dans les espaces étudiant et responsable : nom, initiales, classe, rôle et campus ne sont plus figés sur les identités de démonstration.
- Vérifications réussies : build et lint backend, analyse Dart, compilation Flutter Web complète et contrôles runtime des rôles en mode démo.
- Limite actuelle : faute d'identifiants Entra ID, le flux Microsoft réel n'a pas encore pu être exécuté. Le QR sécurisé reste un sous-bloc séparé à développer après le SSO.
- Commits : `313bb1c`, `530bd52`, `ef31e2a`, `dc208fa`, `36224f3`.
---
*Dernière mise à jour : 2026-07-23 — SSO Microsoft Entra ID prêt à configurer, mode démo sécurisé actif.*