Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb71b8fd8c | |||
| 81b23e0707 | |||
| 36224f30ee | |||
| dc208fa13a | |||
| ef31e2ab8c | |||
| 530bd52cb5 | |||
| 313bb1c6ef | |||
| f7ce98343e | |||
| 669ed85924 | |||
| 695b0d3965 | |||
| 1c8ed77ebf | |||
| 1f0a120ecb | |||
| cb3597874d | |||
| c85fab8ca3 | |||
| 95c9080777 | |||
| 6ac94becfb | |||
| ef74fd49bc | |||
| 1677015254 | |||
| 9a71eb3f34 | |||
| 2a533e3368 | |||
| 545d9f14ac | |||
| 4b635b8654 | |||
| 2a1281b0e5 | |||
| 736328c66b | |||
| 2772f4cfd4 | |||
| 9d8ae70063 | |||
| 92e25356bf | |||
| 832aa51e23 | |||
| d2d2245675 | |||
| a14d6827ee | |||
| 1ce023de71 | |||
| 26f036046d | |||
| c76f546f2a | |||
| 2c81bd7208 | |||
| f7f61a8ebd | |||
| b2a5f8cd94 | |||
| a3795b41a0 | |||
| e2165b9826 | |||
| 1d271e225c | |||
| 24e2793cf8 | |||
| 295dd03b8d | |||
| 3681ed5e44 | |||
| cbc67c6c2f | |||
| eb3094f3a6 | |||
| 245a6905a3 | |||
| 8c710b6e3c | |||
| 9c74de53c4 | |||
| 78a0c1305e | |||
| d83aa7dd75 | |||
| e46ec29543 | |||
| 98ba359bc5 | |||
| 7aba1a930d | |||
| 27171562de | |||
| 9564dd1ade | |||
| 90d5a65f29 | |||
| e9d67e78f5 | |||
| 648ea21155 | |||
| 0f72c021ba | |||
| 9941ae97dc | |||
| b124d7a775 | |||
| 84bd2cc629 | |||
| 97a1ad9e84 | |||
| 97faf3e9ee | |||
| e492220af4 | |||
| b2c999026f | |||
| d12302e8a6 | |||
| be7d095fc3 | |||
| 3215a3c11f | |||
| d8f044924e | |||
| ab1bf29b7f | |||
| 101e52236f | |||
| b5a7577e8d | |||
| 20490a05c5 | |||
| bee21b9fd8 | |||
| bc83308d05 | |||
| 63c56ce775 | |||
| 57266b5414 | |||
| d67cf8d65e |
+21
-1
@@ -1,8 +1,28 @@
|
||||
# Node.js (backend)
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
**/.env
|
||||
**/*.log
|
||||
*.tsbuildinfo
|
||||
|
||||
# Variables d'environnement (secrets) — seul l'exemple est versionné
|
||||
**/.env
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
|
||||
# Clés et certificats (Azure AD, TLS)
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
*.cert
|
||||
*.pfx
|
||||
*.p12
|
||||
|
||||
# Dumps / sauvegardes de base
|
||||
*.bak
|
||||
*.dump
|
||||
|
||||
# Tests
|
||||
**/coverage/
|
||||
|
||||
# Local project context
|
||||
CLAUDE.md
|
||||
|
||||
@@ -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.
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
dist
|
||||
node_modules
|
||||
prisma/migrations
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import globals from 'globals';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**', 'prisma/migrations/**'],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
prettier,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: { ...globals.node },
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/no-non-null-assertion': 'error',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'error',
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
'prefer-const': 'error',
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
);
|
||||
Generated
+1134
-4
File diff suppressed because it is too large
Load Diff
@@ -4,28 +4,38 @@
|
||||
"description": "EME — Emprunt Matériel ENSUP — API REST",
|
||||
"main": "dist/app.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"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",
|
||||
"seed": "ts-node prisma/seed.ts",
|
||||
"fixtures": "ts-node prisma/fixtures.ts"
|
||||
"fixtures": "ts-node prisma/fixtures.ts",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"prisma/**/*.ts\"",
|
||||
"format:check": "prettier --check \"src/**/*.ts\" \"prisma/**/*.ts\""
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^25.9.1",
|
||||
"eslint": "^10.5.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"globals": "^17.6.0",
|
||||
"nodemon": "^3.1.14",
|
||||
"prettier": "^3.8.4",
|
||||
"prisma": "^7.8.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^6.0.3"
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.61.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-mssql": "^7.8.0",
|
||||
@@ -33,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"
|
||||
}
|
||||
}
|
||||
|
||||
+239
-18
@@ -51,34 +51,195 @@ 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' } }),
|
||||
'Role ETUDIANT manquant — lance d\'abord `npm run seed`',
|
||||
"Role ETUDIANT manquant — lance d'abord `npm run seed`",
|
||||
);
|
||||
const roleResponsable = required(
|
||||
await prisma.role.findUnique({ where: { code: 'RESPONSABLE' } }),
|
||||
'Role RESPONSABLE manquant — lance d\'abord `npm run seed`',
|
||||
"Role RESPONSABLE manquant — lance d'abord `npm run seed`",
|
||||
);
|
||||
const campus = required(
|
||||
await prisma.campus.findFirst({ where: { nom: 'Campus Saint-Christophe' } }),
|
||||
'Campus manquant — lance d\'abord `npm run seed`',
|
||||
"Campus manquant — lance d'abord `npm run seed`",
|
||||
);
|
||||
const salle = required(
|
||||
await prisma.sallePret.findFirst({ where: { campusId: campus.id } }),
|
||||
'Salle de pret manquante — lance d\'abord `npm run seed`',
|
||||
"Salle de pret manquante — lance d'abord `npm run seed`",
|
||||
);
|
||||
const poste = required(
|
||||
await prisma.posteEmprunt.findFirst({ where: { sallePretId: salle.id } }),
|
||||
'Poste manquant — lance d\'abord `npm run seed`',
|
||||
"Poste manquant — lance d'abord `npm run seed`",
|
||||
);
|
||||
const catOrdi = required(
|
||||
await prisma.categorieMateriel.findFirst({ where: { nom: 'Ordinateur portable' } }),
|
||||
@@ -100,6 +261,28 @@ async function main(): Promise<void> {
|
||||
await prisma.categorieMateriel.findFirst({ where: { nom: 'Périphérique audio' } }),
|
||||
'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({
|
||||
@@ -305,8 +489,20 @@ async function main(): Promise<void> {
|
||||
});
|
||||
await prisma.checklistElement.createMany({
|
||||
data: [
|
||||
{ checklistId: clDepart1.id, accessoireId: chargeur.id, nomElement: 'Chargeur', etat: 'PRESENT', quantiteConstatee: 1 },
|
||||
{ checklistId: clDepart1.id, accessoireId: souris.id, nomElement: 'Souris', etat: 'PRESENT', quantiteConstatee: 1 },
|
||||
{
|
||||
checklistId: clDepart1.id,
|
||||
accessoireId: chargeur.id,
|
||||
nomElement: 'Chargeur',
|
||||
etat: 'PRESENT',
|
||||
quantiteConstatee: 1,
|
||||
},
|
||||
{
|
||||
checklistId: clDepart1.id,
|
||||
accessoireId: souris.id,
|
||||
nomElement: 'Souris',
|
||||
etat: 'PRESENT',
|
||||
quantiteConstatee: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -393,8 +589,20 @@ async function main(): Promise<void> {
|
||||
});
|
||||
await prisma.checklistElement.createMany({
|
||||
data: [
|
||||
{ checklistId: clDepart4.id, accessoireId: housse.id, nomElement: 'Housse', etat: 'PRESENT', quantiteConstatee: 1 },
|
||||
{ checklistId: clDepart4.id, accessoireId: chargeur.id, nomElement: 'Chargeur', etat: 'PRESENT', quantiteConstatee: 1 },
|
||||
{
|
||||
checklistId: clDepart4.id,
|
||||
accessoireId: housse.id,
|
||||
nomElement: 'Housse',
|
||||
etat: 'PRESENT',
|
||||
quantiteConstatee: 1,
|
||||
},
|
||||
{
|
||||
checklistId: clDepart4.id,
|
||||
accessoireId: chargeur.id,
|
||||
nomElement: 'Chargeur',
|
||||
etat: 'PRESENT',
|
||||
quantiteConstatee: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
const clRetour4 = await prisma.checklist.create({
|
||||
@@ -407,8 +615,21 @@ async function main(): Promise<void> {
|
||||
});
|
||||
await prisma.checklistElement.createMany({
|
||||
data: [
|
||||
{ checklistId: clRetour4.id, accessoireId: housse.id, nomElement: 'Housse', etat: 'ABSENT', quantiteConstatee: 0, commentaire: 'Manquante au retour' },
|
||||
{ checklistId: clRetour4.id, accessoireId: chargeur.id, nomElement: 'Chargeur', etat: 'PRESENT', quantiteConstatee: 1 },
|
||||
{
|
||||
checklistId: clRetour4.id,
|
||||
accessoireId: housse.id,
|
||||
nomElement: 'Housse',
|
||||
etat: 'ABSENT',
|
||||
quantiteConstatee: 0,
|
||||
commentaire: 'Manquante au retour',
|
||||
},
|
||||
{
|
||||
checklistId: clRetour4.id,
|
||||
accessoireId: chargeur.id,
|
||||
nomElement: 'Chargeur',
|
||||
etat: 'PRESENT',
|
||||
quantiteConstatee: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
console.log('[fixtures] 4 emprunts + checklists crees');
|
||||
@@ -464,7 +685,7 @@ async function main(): Promise<void> {
|
||||
},
|
||||
],
|
||||
});
|
||||
console.log('[fixtures] 2 entrees d\'historique creees');
|
||||
console.log("[fixtures] 2 entrees d'historique creees");
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -6,10 +6,13 @@ import { logger } from './utils/logger';
|
||||
import { requestLogger } from './middlewares/request-logger';
|
||||
import { notFoundHandler } from './middlewares/not-found';
|
||||
import { errorHandler } from './middlewares/error-handler';
|
||||
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);
|
||||
|
||||
@@ -17,6 +20,8 @@ app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', app: 'EME API' });
|
||||
});
|
||||
|
||||
app.use('/api', apiRouter);
|
||||
|
||||
/* notFoundHandler et errorHandler doivent rester après toutes les routes :
|
||||
le premier capture les URL non gérées, le second clôt la chaîne Express. */
|
||||
app.use(notFoundHandler);
|
||||
|
||||
@@ -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 d’environnement invalide: AUTH_MODE');
|
||||
}
|
||||
|
||||
if (nodeEnv === 'production' && value === 'demo') {
|
||||
throw new Error('AUTH_MODE=demo est interdit en production');
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function azureValue(name: string, authMode: AuthMode): string {
|
||||
const value = process.env[name];
|
||||
|
||||
if (authMode === 'azure' && !value) {
|
||||
throw new Error(`Variable d'environnement manquante en mode Azure: ${name}`);
|
||||
}
|
||||
|
||||
return value || '';
|
||||
}
|
||||
|
||||
function listFromEnv(name: string, defaultValue: string[]): string[] {
|
||||
const value = process.env[name];
|
||||
|
||||
if (!value) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
||||
const authMode = authModeFromEnv(nodeEnv);
|
||||
const azureApiClientId = azureValue('AZURE_API_CLIENT_ID', authMode);
|
||||
|
||||
export const env = {
|
||||
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',
|
||||
]),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { AppError } from '../errors/app-error';
|
||||
import { getProfil, identifierParCarte } from '../services/utilisateur.service';
|
||||
import { toUtilisateurResponse } from '../dtos/utilisateur.dto';
|
||||
|
||||
export async function getMe(req: Request, res: Response): Promise<void> {
|
||||
const user = req.user;
|
||||
if (!user) {
|
||||
throw new AppError(401, 'Authentification requise');
|
||||
}
|
||||
|
||||
const profil = await getProfil(user.id);
|
||||
res.json({ data: toUtilisateurResponse(profil) });
|
||||
}
|
||||
|
||||
export async function identifier(req: Request, res: Response): Promise<void> {
|
||||
const qr = req.params.qr;
|
||||
if (typeof qr !== 'string') {
|
||||
throw new AppError(400, 'QR code invalide');
|
||||
}
|
||||
|
||||
const profil = await identifierParCarte(qr);
|
||||
res.json({ data: toUtilisateurResponse(profil) });
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { AppError } from '../errors/app-error';
|
||||
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;
|
||||
if (!user) {
|
||||
throw new AppError(401, 'Authentification requise');
|
||||
}
|
||||
|
||||
const emprunts = await listerMesEmpruntsEnCours(user.id);
|
||||
res.json({ data: emprunts.map(toEmpruntResponse) });
|
||||
}
|
||||
|
||||
export async function postEmprunt(req: Request, res: Response): Promise<void> {
|
||||
const user = req.user;
|
||||
if (!user) {
|
||||
throw new AppError(401, 'Authentification requise');
|
||||
}
|
||||
|
||||
const request = parseCreerEmpruntRequest(req.body);
|
||||
const emprunt = await creerEmprunt(user.id, user.campusId, request);
|
||||
res.status(201).json({ data: toEmpruntResponse(emprunt) });
|
||||
}
|
||||
|
||||
export async function postRestitution(req: Request, res: Response): Promise<void> {
|
||||
const user = req.user;
|
||||
if (!user) {
|
||||
throw new AppError(401, 'Authentification requise');
|
||||
}
|
||||
|
||||
const empruntId = Number(req.params.id);
|
||||
if (!Number.isInteger(empruntId) || empruntId <= 0) {
|
||||
throw new AppError(400, 'Identifiant emprunt invalide');
|
||||
}
|
||||
|
||||
const request = parseRestituerEmpruntRequest(req.body);
|
||||
const emprunt = await restituerEmprunt(user.id, empruntId, request);
|
||||
res.json({ data: toEmpruntResponse(emprunt) });
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { AppError } from '../errors/app-error';
|
||||
import { listerCatalogue, obtenirDetailMateriel } from '../services/materiel.service';
|
||||
import { toMaterielDetailResponse, toMaterielResponse } from '../dtos/materiel.dto';
|
||||
|
||||
function parseCategorieId(value: unknown): number | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new AppError(400, 'categorieId invalide');
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseId(value: unknown): number {
|
||||
if (typeof value !== 'string') {
|
||||
throw new AppError(400, 'id invalide');
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new AppError(400, 'id invalide');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function getCatalogue(req: Request, res: Response): Promise<void> {
|
||||
const user = req.user;
|
||||
if (!user) {
|
||||
throw new AppError(401, 'Authentification requise');
|
||||
}
|
||||
|
||||
const recherche = typeof req.query.q === 'string' ? req.query.q : undefined;
|
||||
const categorieId = parseCategorieId(req.query.categorieId);
|
||||
|
||||
const materiels = await listerCatalogue(user.campusId, { categorieId, recherche });
|
||||
res.json({ data: materiels.map(toMaterielResponse) });
|
||||
}
|
||||
|
||||
export async function getMaterielDetail(req: Request, res: Response): Promise<void> {
|
||||
const user = req.user;
|
||||
if (!user) {
|
||||
throw new AppError(401, 'Authentification requise');
|
||||
}
|
||||
|
||||
const materiel = await obtenirDetailMateriel(parseId(req.params.id), user.campusId);
|
||||
res.json({ data: toMaterielDetailResponse(materiel) });
|
||||
}
|
||||
@@ -0,0 +1,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'));
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { EmpruntAvecMateriel } from '../repositories/emprunt.repository';
|
||||
import { MaterielResponse, toMaterielResponse } from './materiel.dto';
|
||||
import { AppError } from '../errors/app-error';
|
||||
import { ETAT_CHECKLIST_ELEMENT, MODE_IDENTIFICATION } from '../models/enums';
|
||||
|
||||
export interface EmpruntResponse {
|
||||
id: number;
|
||||
dateEmprunt: Date;
|
||||
dateRetourPrevue: Date;
|
||||
dateRetourReelle: Date | null;
|
||||
statut: string;
|
||||
materiel: MaterielResponse;
|
||||
}
|
||||
|
||||
export function toEmpruntResponse(emprunt: EmpruntAvecMateriel): EmpruntResponse {
|
||||
return {
|
||||
id: emprunt.id,
|
||||
dateEmprunt: emprunt.dateEmprunt,
|
||||
dateRetourPrevue: emprunt.dateRetourPrevue,
|
||||
dateRetourReelle: emprunt.dateRetourReelle,
|
||||
statut: emprunt.statut,
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { MaterielAvecCategorie, MaterielDetail } from '../repositories/materiel.repository';
|
||||
|
||||
export interface CategorieResponse {
|
||||
id: number;
|
||||
nom: string;
|
||||
}
|
||||
|
||||
export interface MaterielResponse {
|
||||
id: number;
|
||||
nom: string;
|
||||
marque: string;
|
||||
modele: string;
|
||||
reference: string;
|
||||
statut: string;
|
||||
etatGeneral: string;
|
||||
categorie: CategorieResponse;
|
||||
}
|
||||
|
||||
export interface MaterielDetailResponse extends MaterielResponse {
|
||||
campus: {
|
||||
id: number;
|
||||
nom: string;
|
||||
ville: string;
|
||||
};
|
||||
accessoires: string[];
|
||||
}
|
||||
|
||||
export function toMaterielResponse(materiel: MaterielAvecCategorie): MaterielResponse {
|
||||
return {
|
||||
id: materiel.id,
|
||||
nom: materiel.nom,
|
||||
marque: materiel.marque,
|
||||
modele: materiel.modele,
|
||||
reference: materiel.reference,
|
||||
statut: materiel.statut,
|
||||
etatGeneral: materiel.etatGeneral,
|
||||
categorie: {
|
||||
id: materiel.categorie.id,
|
||||
nom: materiel.categorie.nom,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function toMaterielDetailResponse(materiel: MaterielDetail): MaterielDetailResponse {
|
||||
return {
|
||||
...toMaterielResponse(materiel),
|
||||
campus: {
|
||||
id: materiel.campus.id,
|
||||
nom: materiel.campus.nom,
|
||||
ville: materiel.campus.ville,
|
||||
},
|
||||
accessoires: materiel.accessoires.map((liaison) => liaison.accessoire.nom),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { UtilisateurProfil } from '../repositories/utilisateur.repository';
|
||||
|
||||
export interface RoleResponse {
|
||||
code: string;
|
||||
libelle: string;
|
||||
}
|
||||
|
||||
export interface CampusResponse {
|
||||
id: number;
|
||||
nom: string;
|
||||
ville: string;
|
||||
}
|
||||
|
||||
export interface UtilisateurResponse {
|
||||
id: number;
|
||||
nom: string;
|
||||
prenom: string;
|
||||
email: string;
|
||||
classe: string | null;
|
||||
role: RoleResponse;
|
||||
campus: CampusResponse;
|
||||
}
|
||||
|
||||
export function toUtilisateurResponse(utilisateur: UtilisateurProfil): UtilisateurResponse {
|
||||
return {
|
||||
id: utilisateur.id,
|
||||
nom: utilisateur.nom,
|
||||
prenom: utilisateur.prenom,
|
||||
email: utilisateur.email,
|
||||
classe: utilisateur.classe,
|
||||
role: {
|
||||
code: utilisateur.role.code,
|
||||
libelle: utilisateur.role.libelle,
|
||||
},
|
||||
campus: {
|
||||
id: utilisateur.campus.id,
|
||||
nom: utilisateur.campus.nom,
|
||||
ville: utilisateur.campus.ville,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { authenticateUser } from '../services/authentication.service';
|
||||
|
||||
export async function currentUser(req: Request, _res: Response, next: NextFunction): Promise<void> {
|
||||
req.user = await authenticateUser({
|
||||
authorization: req.header('authorization'),
|
||||
demoEmail: req.header('x-user-email'),
|
||||
});
|
||||
next();
|
||||
}
|
||||
@@ -5,7 +5,12 @@ import { logger } from '../utils/logger';
|
||||
|
||||
/* Le 4e argument est requis par Express pour identifier un gestionnaire d'erreurs,
|
||||
même s'il n'est pas utilisé. */
|
||||
export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction): void {
|
||||
export function errorHandler(
|
||||
err: unknown,
|
||||
_req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction,
|
||||
): void {
|
||||
const isAppError = err instanceof AppError;
|
||||
const statusCode = isAppError ? err.statusCode : 500;
|
||||
const message = isAppError ? err.message : 'Erreur interne du serveur';
|
||||
|
||||
@@ -27,12 +27,7 @@ export const ETAT_CHECKLIST_ELEMENT = ['PRESENT', 'ABSENT', 'DETERIORE'] as cons
|
||||
|
||||
export type EtatChecklistElement = (typeof ETAT_CHECKLIST_ELEMENT)[number];
|
||||
|
||||
export const STATUT_ANOMALIE = [
|
||||
'DETECTEE',
|
||||
'EN_COURS_TRAITEMENT',
|
||||
'RESOLUE',
|
||||
'CLOTUREE',
|
||||
] as const;
|
||||
export const STATUT_ANOMALIE = ['DETECTEE', 'EN_COURS_TRAITEMENT', 'RESOLUE', 'CLOTUREE'] as const;
|
||||
|
||||
export type StatutAnomalie = (typeof STATUT_ANOMALIE)[number];
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { prisma } from '../db/prisma';
|
||||
|
||||
export type CarteAvecUtilisateur = Prisma.CarteEtudianteGetPayload<{
|
||||
include: { utilisateur: { include: { role: true; campus: true } } };
|
||||
}>;
|
||||
|
||||
export function findByQrCode(qrCode: string): Promise<CarteAvecUtilisateur | null> {
|
||||
return prisma.carteEtudiante.findFirst({
|
||||
where: { qrCode },
|
||||
include: { utilisateur: { include: { role: true, campus: true } } },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
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[]> {
|
||||
return prisma.emprunt.findMany({
|
||||
where: {
|
||||
utilisateurId,
|
||||
statut: { in: STATUTS_EN_COURS },
|
||||
},
|
||||
include: { materiel: { include: { categorie: true } } },
|
||||
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 } } },
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Materiel, Prisma } from '@prisma/client';
|
||||
import { prisma } from '../db/prisma';
|
||||
|
||||
export type MaterielAvecCategorie = Prisma.MaterielGetPayload<{ include: { categorie: true } }>;
|
||||
export type MaterielDetail = Prisma.MaterielGetPayload<{
|
||||
include: {
|
||||
categorie: true;
|
||||
campus: true;
|
||||
accessoires: { include: { accessoire: true } };
|
||||
};
|
||||
}>;
|
||||
|
||||
export interface CatalogueFiltres {
|
||||
categorieId?: number;
|
||||
recherche?: string;
|
||||
}
|
||||
|
||||
export function findDisponiblesParCampus(
|
||||
campusId: number,
|
||||
filtres: CatalogueFiltres,
|
||||
): Promise<MaterielAvecCategorie[]> {
|
||||
const { categorieId, recherche } = filtres;
|
||||
|
||||
return prisma.materiel.findMany({
|
||||
where: {
|
||||
campusId,
|
||||
actif: true,
|
||||
statut: 'DISPONIBLE',
|
||||
...(categorieId !== undefined ? { categorieId } : {}),
|
||||
...(recherche
|
||||
? {
|
||||
OR: [
|
||||
{ nom: { contains: recherche } },
|
||||
{ marque: { contains: recherche } },
|
||||
{ modele: { contains: recherche } },
|
||||
{ reference: { contains: recherche } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: { categorie: true },
|
||||
orderBy: { nom: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
export function findById(id: number): Promise<Materiel | null> {
|
||||
return prisma.materiel.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
export function findDetailParCampus(id: number, campusId: number): Promise<MaterielDetail | null> {
|
||||
return prisma.materiel.findFirst({
|
||||
where: { id, campusId, actif: true },
|
||||
include: {
|
||||
categorie: true,
|
||||
campus: true,
|
||||
accessoires: {
|
||||
include: { accessoire: true },
|
||||
orderBy: { accessoire: { nom: 'asc' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { prisma } from '../db/prisma';
|
||||
|
||||
export type PosteAvecSalle = Prisma.PosteEmpruntGetPayload<{ include: { sallePret: true } }>;
|
||||
|
||||
export function findById(id: number): Promise<PosteAvecSalle | null> {
|
||||
return prisma.posteEmprunt.findUnique({
|
||||
where: { id },
|
||||
include: { sallePret: true },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,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' },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { prisma } from '../db/prisma';
|
||||
|
||||
export type UtilisateurAvecRole = Prisma.UtilisateurGetPayload<{ include: { role: true } }>;
|
||||
|
||||
export type UtilisateurProfil = Prisma.UtilisateurGetPayload<{
|
||||
include: { role: true; campus: true };
|
||||
}>;
|
||||
|
||||
export function findByEmail(email: string): Promise<UtilisateurAvecRole | null> {
|
||||
return prisma.utilisateur.findUnique({
|
||||
where: { email },
|
||||
include: { role: true },
|
||||
});
|
||||
}
|
||||
|
||||
export function findByMicrosoftId(microsoftId: string): Promise<UtilisateurAvecRole | null> {
|
||||
return prisma.utilisateur.findUnique({
|
||||
where: { microsoftId },
|
||||
include: { role: true },
|
||||
});
|
||||
}
|
||||
|
||||
export function findById(id: number): Promise<UtilisateurProfil | null> {
|
||||
return prisma.utilisateur.findUnique({
|
||||
where: { id },
|
||||
include: { role: true, campus: true },
|
||||
});
|
||||
}
|
||||
|
||||
export function findResponsablesParCampus(campusId: number): Promise<{ id: number }[]> {
|
||||
return prisma.utilisateur.findMany({
|
||||
where: { campusId, actif: true, role: { code: 'RESPONSABLE' } },
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import { currentUser } from '../middlewares/current-user';
|
||||
import { getMe, identifier } from '../controllers/auth.controller';
|
||||
|
||||
export const authRoutes: Router = Router();
|
||||
|
||||
// Identification par QR code : point d'entrée du parcours, donc public.
|
||||
authRoutes.get('/carte/:qr', identifier);
|
||||
|
||||
// Profil de l'utilisateur déjà identifié : protégé.
|
||||
authRoutes.get('/me', currentUser, getMe);
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import { getMesEmprunts, postEmprunt, postRestitution } from '../controllers/emprunt.controller';
|
||||
|
||||
export const empruntRoutes: Router = Router();
|
||||
empruntRoutes.post('/', postEmprunt);
|
||||
empruntRoutes.post('/:id/restitution', postRestitution);
|
||||
|
||||
export const mesEmpruntsRoutes: Router = Router();
|
||||
mesEmpruntsRoutes.get('/', getMesEmprunts);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Router } from 'express';
|
||||
import { currentUser } from '../middlewares/current-user';
|
||||
import { authRoutes } from './auth.routes';
|
||||
import { materielRoutes } from './materiel.routes';
|
||||
import { empruntRoutes, mesEmpruntsRoutes } from './emprunt.routes';
|
||||
import { responsableRoutes } from './responsable.routes';
|
||||
|
||||
export const apiRouter: Router = Router();
|
||||
|
||||
// /auth gère lui-même ses routes publiques/protégées.
|
||||
apiRouter.use('/auth', authRoutes);
|
||||
|
||||
// Le reste exige un utilisateur identifié.
|
||||
apiRouter.use('/materiels', currentUser, materielRoutes);
|
||||
apiRouter.use('/emprunts', currentUser, empruntRoutes);
|
||||
apiRouter.use('/mes-emprunts', currentUser, mesEmpruntsRoutes);
|
||||
apiRouter.use('/responsable', currentUser, responsableRoutes);
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { getCatalogue, getMaterielDetail } from '../controllers/materiel.controller';
|
||||
|
||||
export const materielRoutes: Router = Router();
|
||||
|
||||
materielRoutes.get('/', getCatalogue);
|
||||
materielRoutes.get('/:id', getMaterielDetail);
|
||||
@@ -0,0 +1,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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
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';
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
findDisponiblesParCampus,
|
||||
findDetailParCampus,
|
||||
CatalogueFiltres,
|
||||
MaterielDetail,
|
||||
MaterielAvecCategorie,
|
||||
} from '../repositories/materiel.repository';
|
||||
import { AppError } from '../errors/app-error';
|
||||
|
||||
/* RG10 : après identification, seuls les matériels disponibles du campus de
|
||||
l'étudiant sont affichés. */
|
||||
export function listerCatalogue(
|
||||
campusId: number,
|
||||
filtres: CatalogueFiltres,
|
||||
): Promise<MaterielAvecCategorie[]> {
|
||||
return findDisponiblesParCampus(campusId, filtres);
|
||||
}
|
||||
|
||||
export async function obtenirDetailMateriel(id: number, campusId: number): Promise<MaterielDetail> {
|
||||
const materiel = await findDetailParCampus(id, campusId);
|
||||
if (!materiel) {
|
||||
throw new AppError(404, 'Materiel introuvable');
|
||||
}
|
||||
return materiel;
|
||||
}
|
||||
@@ -0,0 +1,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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { AppError } from '../errors/app-error';
|
||||
import { findById, UtilisateurProfil } from '../repositories/utilisateur.repository';
|
||||
import { findByQrCode } from '../repositories/carte-etudiante.repository';
|
||||
|
||||
export async function getProfil(utilisateurId: number): Promise<UtilisateurProfil> {
|
||||
const profil = await findById(utilisateurId);
|
||||
if (!profil) {
|
||||
throw new AppError(404, 'Utilisateur introuvable');
|
||||
}
|
||||
|
||||
return profil;
|
||||
}
|
||||
|
||||
/* RG02 : identification de l'étudiant par le QR code de sa carte. La carte doit
|
||||
exister, être active et non expirée, et le compte doit être actif. */
|
||||
export async function identifierParCarte(qrCode: string): Promise<UtilisateurProfil> {
|
||||
const carte = await findByQrCode(qrCode);
|
||||
if (!carte || !carte.actif) {
|
||||
throw new AppError(404, 'Carte non reconnue');
|
||||
}
|
||||
|
||||
if (carte.dateExpiration.getTime() < Date.now()) {
|
||||
throw new AppError(401, 'Carte expiree');
|
||||
}
|
||||
|
||||
if (!carte.utilisateur.actif) {
|
||||
throw new AppError(401, 'Compte inactif');
|
||||
}
|
||||
|
||||
return carte.utilisateur;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface AuthenticatedUser {
|
||||
id: number;
|
||||
email: string;
|
||||
roleCode: string;
|
||||
campusId: number;
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
import { AuthenticatedUser } from './authenticated-user';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: AuthenticatedUser;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -2,16 +2,15 @@
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true,
|
||||
"types": ["node"]
|
||||
"types": ["node"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"include": ["src/**/*", "prisma/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
}
|
||||
|
||||
+485
@@ -0,0 +1,485 @@
|
||||
-- Adminer 5.4.2 MS SQL dump
|
||||
|
||||
DROP TABLE IF EXISTS [dbo].[_prisma_migrations];
|
||||
CREATE TABLE [dbo].[_prisma_migrations] (
|
||||
[id] varchar(36) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[checksum] varchar(64) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[finished_at] datetimeoffset NULL,
|
||||
[migration_name] nvarchar(250) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[logs] nvarchar-05 COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
|
||||
[rolled_back_at] datetimeoffset NULL,
|
||||
[started_at] datetimeoffset NOT NULL DEFAULT '(getdate())',
|
||||
[applied_steps_count] int NOT NULL DEFAULT '((0))',
|
||||
CONSTRAINT [PK___prisma___3213E83F83C32F3A] PRIMARY KEY ([id])
|
||||
);
|
||||
|
||||
INSERT INTO [dbo].[_prisma_migrations] ([id], [checksum], [finished_at], [migration_name], [logs], [rolled_back_at], [started_at], [applied_steps_count]) VALUES
|
||||
('0f9bb926-166c-4082-8732-8a12e67b677d', 'c2faa7ef8d703d77e508f037850a7c2b07566b0eed00da21d85e1acfc5738a1e', 'Jun 4 2026 12:07:21:0885370PM', '20260604120720_add_materiel_models', NULL, NULL, 'Jun 4 2026 12:07:21:0042228PM', 1),
|
||||
('247934d2-3c7e-4798-a551-d3bdc81eb86d', 'fb4d09b02739fd6bf7cea5bfd0aad1bb0fbfcce21a04f1bd91ce300a4c8bbfec', 'Jun 5 2026 08:03:28:7441715AM', '20260605080328_add_transaction_models', NULL, NULL, 'Jun 5 2026 08:03:28:6232902AM', 1),
|
||||
('3266f9eb-7370-4b00-b749-f0ed39263d07', '2e7b96c74202f25fd3ab0a9438fcc8699059fd5501d66092148daa41252f146b', 'Jun 4 2026 11:52:51:4456229AM', '20260604115251_add_infrastructure_models', NULL, NULL, 'Jun 4 2026 11:52:51:3998600AM', 1),
|
||||
('3cced054-eb44-41f8-89ac-51a72201310a', '5e82403dcc57c7ab48dea2fe70e688714145f7f46b770a5c79ea868e6ba60f7a', 'Jun 4 2026 10:30:16:9084629AM', '20260604103016_add_defaults_and_timestamps', NULL, NULL, 'Jun 4 2026 10:30:16:8610742AM', 1),
|
||||
('3e9709da-458c-402d-8b32-bac51a84d241', 'c95d2b3e6c2454daa3fb9ba2ef7a99f9419e991174869612c81ab907e5a6d802', 'Jun 4 2026 12:02:27:3861796PM', '20260604120227_add_carte_etudiante', NULL, NULL, 'Jun 4 2026 12:02:27:3195110PM', 1),
|
||||
('5b298865-5e96-4b54-ad96-1ec3acc7080a', '5e3e0d880e67c5ba91ccdb04f0dee6c056da6a325510c9dd9e4f9a80c5625fc4', 'Jun 4 2026 10:54:28:1304306AM', '20260604125356_add_unique_constraints_and_nullable_classe', NULL, NULL, 'Jun 4 2026 10:54:28:0949933AM', 1),
|
||||
('66581e38-0e42-435a-9937-06479116e595', '4e632e5273ea555527c4726688865c7b21bc03d92df67e2f4679c6428d8a33e2', 'Jun 2 2026 02:19:19:8915005PM', '20260602141919_init', NULL, NULL, 'Jun 2 2026 02:19:19:8389748PM', 1),
|
||||
('fbf572ea-075e-4077-9d58-39fdf613d737', '29faca22003505a071ea6b845d527566c51086c6b17980f386b70abff64d9148', 'Jun 15 2026 07:57:00:9671170AM', '20260615075700_add_suivi_models', NULL, NULL, 'Jun 15 2026 07:57:00:8308160AM', 1);
|
||||
|
||||
DROP TABLE IF EXISTS [dbo].[accessoire];
|
||||
CREATE TABLE [dbo].[accessoire] (
|
||||
[id] int NOT NULL IDENTITY PRIMARY KEY,
|
||||
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[description] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[actif] bit NOT NULL DEFAULT '((1))',
|
||||
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
|
||||
[updated_at] datetime2 NOT NULL
|
||||
);
|
||||
|
||||
SET IDENTITY_INSERT [dbo].[accessoire] ON;
|
||||
INSERT INTO [dbo].[accessoire] ([id], [nom], [description], [actif], [created_at], [updated_at]) VALUES
|
||||
(1, 'Chargeur', 'Chargeur secteur.', '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
|
||||
(2, 'Souris', 'Souris filaire.', '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
|
||||
(3, 'Housse', 'Housse de protection.', '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
|
||||
(1002, 'Chargeur USB-C', 'Accessoire Chargeur USB-C.', '1', '2026-07-07 11:39:38', '2026-07-07 11:39:38'),
|
||||
(1003, 'Clavier Surface', 'Accessoire Clavier Surface.', '1', '2026-07-07 11:39:38', '2026-07-07 11:39:38'),
|
||||
(1004, 'Stylet', 'Accessoire Stylet.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
|
||||
(1005, 'Telecommande', 'Accessoire Telecommande.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
|
||||
(1006, 'Cable HDMI', 'Accessoire Cable HDMI.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
|
||||
(1007, 'Batterie', 'Accessoire Batterie.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
|
||||
(1008, 'Carte SD', 'Accessoire Carte SD.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
|
||||
(1009, 'Adaptateur USB-C', 'Accessoire Adaptateur USB-C.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
|
||||
(1010, 'Cable USB-C', 'Accessoire Cable USB-C.', '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39');
|
||||
SET IDENTITY_INSERT [dbo].[accessoire] OFF;
|
||||
|
||||
DROP TABLE IF EXISTS [dbo].[anomalie];
|
||||
CREATE TABLE [dbo].[anomalie] (
|
||||
[id] int NOT NULL IDENTITY PRIMARY KEY,
|
||||
[emprunt_id] int NOT NULL,
|
||||
[materiel_id] int NOT NULL,
|
||||
[utilisateur_id] int NOT NULL,
|
||||
[traitee_par_id] int NULL,
|
||||
[type] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[description] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[statut] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[detectee_automatiquement] bit NOT NULL DEFAULT '((1))',
|
||||
[observation] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
|
||||
[date_detection] datetime2 NOT NULL,
|
||||
[date_resolution] datetime2 NULL,
|
||||
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
|
||||
[updated_at] datetime2 NOT NULL
|
||||
);
|
||||
|
||||
SET IDENTITY_INSERT [dbo].[anomalie] ON;
|
||||
INSERT INTO [dbo].[anomalie] ([id], [emprunt_id], [materiel_id], [utilisateur_id], [traitee_par_id], [type], [description], [statut], [detectee_automatiquement], [observation], [date_detection], [date_resolution], [created_at], [updated_at]) VALUES
|
||||
(1, 4, 4, 3, NULL, 'ACCESSOIRE_MANQUANT', 'Housse declaree presente au depart, absente au retour.', 'DETECTEE', '1', NULL, '2026-06-14 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
|
||||
(2, 1002, 3, 1, NULL, 'RETOUR_NON_CONFORME', 'Retour non conforme : Etat general manquant au retour.', 'DETECTEE', '1', NULL, '2026-07-06 08:03:20', NULL, '2026-07-06 08:03:20', '2026-07-06 08:03:20'),
|
||||
(1002, 4003, 1009, 2, NULL, 'RETOUR_NON_CONFORME', 'Retour non conforme : Cable USB-C manquant au retour.', 'DETECTEE', '1', NULL, '2026-07-08 09:19:47', NULL, '2026-07-08 09:19:47', '2026-07-08 09:19:47');
|
||||
SET IDENTITY_INSERT [dbo].[anomalie] OFF;
|
||||
|
||||
DROP TABLE IF EXISTS [dbo].[campus];
|
||||
CREATE TABLE [dbo].[campus] (
|
||||
[id] int NOT NULL IDENTITY PRIMARY KEY,
|
||||
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[adresse] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[ville] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[code_postal] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[actif] bit NOT NULL DEFAULT '((1))',
|
||||
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
|
||||
[updated_at] datetime2 NOT NULL
|
||||
);
|
||||
|
||||
SET IDENTITY_INSERT [dbo].[campus] ON;
|
||||
INSERT INTO [dbo].[campus] ([id], [nom], [adresse], [ville], [code_postal], [actif], [created_at], [updated_at]) VALUES
|
||||
(1, 'Campus Saint-Christophe', 'À compléter — Cergy Saint-Christophe', 'Cergy', '95800', '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37');
|
||||
SET IDENTITY_INSERT [dbo].[campus] OFF;
|
||||
|
||||
DROP TABLE IF EXISTS [dbo].[carte_etudiante];
|
||||
CREATE TABLE [dbo].[carte_etudiante] (
|
||||
[id] int NOT NULL IDENTITY PRIMARY KEY,
|
||||
[utilisateur_id] int NOT NULL,
|
||||
[qr_code] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[date_activation] datetime2 NOT NULL,
|
||||
[date_expiration] datetime2 NOT NULL,
|
||||
[actif] bit NOT NULL DEFAULT '((1))',
|
||||
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
|
||||
[updated_at] datetime2 NOT NULL
|
||||
);
|
||||
|
||||
SET IDENTITY_INSERT [dbo].[carte_etudiante] ON;
|
||||
INSERT INTO [dbo].[carte_etudiante] ([id], [utilisateur_id], [qr_code], [date_activation], [date_expiration], [actif], [created_at], [updated_at]) VALUES
|
||||
(1, 1, 'QR-ETU-0001', '2025-08-19 14:48:51', '2027-07-20 14:48:51', '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
|
||||
(2, 2, 'QR-ETU-0002', '2025-08-19 14:48:51', '2027-07-20 14:48:51', '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
|
||||
(3, 3, 'QR-ETU-0003', '2025-08-19 14:48:51', '2027-07-20 14:48:51', '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52');
|
||||
SET IDENTITY_INSERT [dbo].[carte_etudiante] OFF;
|
||||
|
||||
DROP TABLE IF EXISTS [dbo].[categorie_materiel];
|
||||
CREATE TABLE [dbo].[categorie_materiel] (
|
||||
[id] int NOT NULL IDENTITY PRIMARY KEY,
|
||||
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[description] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[actif] bit NOT NULL DEFAULT '((1))',
|
||||
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
|
||||
[updated_at] datetime2 NOT NULL
|
||||
);
|
||||
|
||||
SET IDENTITY_INSERT [dbo].[categorie_materiel] ON;
|
||||
INSERT INTO [dbo].[categorie_materiel] ([id], [nom], [description], [actif], [created_at], [updated_at]) VALUES
|
||||
(1, 'Ordinateur portable', 'Ordinateurs portables pédagogiques.', '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37'),
|
||||
(2, 'Vidéoprojecteur', 'Vidéoprojecteurs et accessoires de projection.', '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37'),
|
||||
(3, 'Tablette', 'Tablettes tactiles.', '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37'),
|
||||
(4, 'Caméra', 'Caméras et matériel de captation vidéo.', '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37'),
|
||||
(5, 'Périphérique audio', 'Micros, casques et enceintes.', '1', '2026-06-15 09:03:38', '2026-06-15 09:03:38'),
|
||||
(6, 'Câble / Adaptateur', 'Câbles, adaptateurs et connectique.', '1', '2026-06-15 09:03:38', '2026-06-15 09:03:38');
|
||||
SET IDENTITY_INSERT [dbo].[categorie_materiel] OFF;
|
||||
|
||||
DROP TABLE IF EXISTS [dbo].[checklist];
|
||||
CREATE TABLE [dbo].[checklist] (
|
||||
[id] int NOT NULL IDENTITY PRIMARY KEY,
|
||||
[emprunt_id] int NOT NULL,
|
||||
[utilisateur_id] int NOT NULL,
|
||||
[type] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[date_verification] datetime2 NOT NULL,
|
||||
[commentaire] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
|
||||
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
|
||||
[updated_at] datetime2 NOT NULL
|
||||
);
|
||||
|
||||
SET IDENTITY_INSERT [dbo].[checklist] ON;
|
||||
INSERT INTO [dbo].[checklist] ([id], [emprunt_id], [utilisateur_id], [type], [date_verification], [commentaire], [created_at], [updated_at]) VALUES
|
||||
(1, 1, 1, 'DEPART', '2026-06-13 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
|
||||
(2, 2, 2, 'DEPART', '2026-05-26 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
|
||||
(3, 3, 1, 'DEPART', '2026-06-05 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
|
||||
(4, 3, 1, 'RETOUR', '2026-06-12 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
|
||||
(5, 4, 3, 'DEPART', '2026-06-10 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
|
||||
(6, 4, 3, 'RETOUR', '2026-06-14 14:48:51', NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
|
||||
(1002, 1002, 1, 'DEPART', '2026-07-06 07:38:13', NULL, '2026-07-06 07:38:13', '2026-07-06 07:38:13'),
|
||||
(1003, 1, 1, 'RETOUR', '2026-07-06 08:03:20', NULL, '2026-07-06 08:03:20', '2026-07-06 08:03:20'),
|
||||
(1004, 1002, 1, 'RETOUR', '2026-07-06 08:03:20', NULL, '2026-07-06 08:03:20', '2026-07-06 08:03:20'),
|
||||
(2002, 2002, 2, 'DEPART', '2026-07-07 11:51:13', NULL, '2026-07-07 11:51:13', '2026-07-07 11:51:13'),
|
||||
(2003, 2003, 2, 'DEPART', '2026-07-07 11:58:06', NULL, '2026-07-07 11:58:06', '2026-07-07 11:58:06'),
|
||||
(3002, 2, 2, 'RETOUR', '2026-07-07 12:33:18', NULL, '2026-07-07 12:33:18', '2026-07-07 12:33:18'),
|
||||
(3003, 2002, 2, 'RETOUR', '2026-07-07 12:33:29', NULL, '2026-07-07 12:33:29', '2026-07-07 12:33:29'),
|
||||
(3004, 2003, 2, 'RETOUR', '2026-07-07 12:33:43', NULL, '2026-07-07 12:33:43', '2026-07-07 12:33:43'),
|
||||
(3005, 3002, 2, 'DEPART', '2026-07-07 12:34:25', NULL, '2026-07-07 12:34:25', '2026-07-07 12:34:25'),
|
||||
(4002, 4002, 2, 'DEPART', '2026-07-08 09:19:17', NULL, '2026-07-08 09:19:17', '2026-07-08 09:19:17'),
|
||||
(4003, 4002, 2, 'RETOUR', '2026-07-08 09:19:17', NULL, '2026-07-08 09:19:17', '2026-07-08 09:19:17'),
|
||||
(4004, 4003, 2, 'DEPART', '2026-07-08 09:19:47', NULL, '2026-07-08 09:19:47', '2026-07-08 09:19:47'),
|
||||
(4005, 4003, 2, 'RETOUR', '2026-07-08 09:19:47', NULL, '2026-07-08 09:19:47', '2026-07-08 09:19:47'),
|
||||
(4006, 4004, 2, 'DEPART', '2026-07-08 13:19:04', NULL, '2026-07-08 13:19:04', '2026-07-08 13:19:04');
|
||||
SET IDENTITY_INSERT [dbo].[checklist] OFF;
|
||||
|
||||
DROP TABLE IF EXISTS [dbo].[checklist_element];
|
||||
CREATE TABLE [dbo].[checklist_element] (
|
||||
[id] int NOT NULL IDENTITY PRIMARY KEY,
|
||||
[checklist_id] int NOT NULL,
|
||||
[accessoire_id] int NULL,
|
||||
[nom_element] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[etat] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[quantite_constatee] int NOT NULL,
|
||||
[commentaire] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
|
||||
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
|
||||
[updated_at] datetime2 NOT NULL
|
||||
);
|
||||
|
||||
SET IDENTITY_INSERT [dbo].[checklist_element] ON;
|
||||
INSERT INTO [dbo].[checklist_element] ([id], [checklist_id], [accessoire_id], [nom_element], [etat], [quantite_constatee], [commentaire], [created_at], [updated_at]) VALUES
|
||||
(1, 1, 1, 'Chargeur', 'PRESENT', 1, NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
|
||||
(2, 1, 2, 'Souris', 'PRESENT', 1, NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
|
||||
(3, 5, 3, 'Housse', 'PRESENT', 1, NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
|
||||
(4, 5, 1, 'Chargeur', 'PRESENT', 1, NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
|
||||
(5, 6, 3, 'Housse', 'ABSENT', 0, 'Manquante au retour', '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
|
||||
(6, 6, 1, 'Chargeur', 'PRESENT', 1, NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
|
||||
(1002, 1002, NULL, 'Etat general', 'PRESENT', 1, NULL, '2026-07-06 07:38:13', '2026-07-06 07:38:13'),
|
||||
(1003, 1003, NULL, 'Chargeur', 'PRESENT', 1, NULL, '2026-07-06 08:03:20', '2026-07-06 08:03:20'),
|
||||
(1004, 1003, NULL, 'Souris', 'PRESENT', 1, NULL, '2026-07-06 08:03:20', '2026-07-06 08:03:20'),
|
||||
(1005, 1004, NULL, 'Etat general', 'ABSENT', 0, NULL, '2026-07-06 08:03:20', '2026-07-06 08:03:20'),
|
||||
(2002, 2002, NULL, 'Batterie', 'ABSENT', 0, NULL, '2026-07-07 11:51:13', '2026-07-07 11:51:13'),
|
||||
(2003, 2002, NULL, 'Carte SD', 'PRESENT', 1, NULL, '2026-07-07 11:51:13', '2026-07-07 11:51:13'),
|
||||
(2004, 2002, NULL, 'Housse', 'PRESENT', 1, NULL, '2026-07-07 11:51:13', '2026-07-07 11:51:13'),
|
||||
(2005, 2003, NULL, 'Chargeur USB-C', 'ABSENT', 0, NULL, '2026-07-07 11:58:06', '2026-07-07 11:58:06'),
|
||||
(2006, 2003, NULL, 'Housse', 'PRESENT', 1, NULL, '2026-07-07 11:58:06', '2026-07-07 11:58:06'),
|
||||
(3002, 3002, NULL, 'Vidéoprojecteur Epson', 'PRESENT', 1, NULL, '2026-07-07 12:33:18', '2026-07-07 12:33:18'),
|
||||
(3003, 3003, NULL, 'Batterie', 'PRESENT', 1, NULL, '2026-07-07 12:33:29', '2026-07-07 12:33:29'),
|
||||
(3004, 3003, NULL, 'Carte SD', 'PRESENT', 1, NULL, '2026-07-07 12:33:29', '2026-07-07 12:33:29'),
|
||||
(3005, 3003, NULL, 'Housse', 'PRESENT', 1, NULL, '2026-07-07 12:33:29', '2026-07-07 12:33:29'),
|
||||
(3006, 3004, NULL, 'Chargeur USB-C', 'ABSENT', 0, NULL, '2026-07-07 12:33:43', '2026-07-07 12:33:43'),
|
||||
(3007, 3004, NULL, 'Housse', 'PRESENT', 1, NULL, '2026-07-07 12:33:43', '2026-07-07 12:33:43'),
|
||||
(3008, 3005, NULL, 'Chargeur', 'PRESENT', 1, NULL, '2026-07-07 12:34:25', '2026-07-07 12:34:25'),
|
||||
(3009, 3005, NULL, 'Housse', 'ABSENT', 0, NULL, '2026-07-07 12:34:25', '2026-07-07 12:34:25'),
|
||||
(3010, 3005, NULL, 'Souris', 'PRESENT', 1, NULL, '2026-07-07 12:34:25', '2026-07-07 12:34:25'),
|
||||
(4002, 4002, NULL, 'Casque Jabra Evolve', 'PRESENT', 1, NULL, '2026-07-08 09:19:17', '2026-07-08 09:19:17'),
|
||||
(4003, 4003, NULL, 'Casque Jabra Evolve', 'PRESENT', 1, NULL, '2026-07-08 09:19:17', '2026-07-08 09:19:17'),
|
||||
(4004, 4004, NULL, 'Cable USB-C', 'PRESENT', 1, NULL, '2026-07-08 09:19:47', '2026-07-08 09:19:47'),
|
||||
(4005, 4005, NULL, 'Cable USB-C', 'ABSENT', 0, NULL, '2026-07-08 09:19:47', '2026-07-08 09:19:47'),
|
||||
(4006, 4006, NULL, 'Chargeur USB-C', 'ABSENT', 0, NULL, '2026-07-08 13:19:04', '2026-07-08 13:19:04'),
|
||||
(4007, 4006, NULL, 'Housse', 'PRESENT', 1, NULL, '2026-07-08 13:19:04', '2026-07-08 13:19:04');
|
||||
SET IDENTITY_INSERT [dbo].[checklist_element] OFF;
|
||||
|
||||
DROP TABLE IF EXISTS [dbo].[emprunt];
|
||||
CREATE TABLE [dbo].[emprunt] (
|
||||
[id] int NOT NULL IDENTITY PRIMARY KEY,
|
||||
[utilisateur_id] int NOT NULL,
|
||||
[materiel_id] int NOT NULL,
|
||||
[campus_id] int NOT NULL,
|
||||
[salle_pret_id] int NOT NULL,
|
||||
[poste_emprunt_id] int NOT NULL,
|
||||
[date_emprunt] datetime2 NOT NULL,
|
||||
[date_retour_prevue] datetime2 NOT NULL,
|
||||
[date_retour_reelle] datetime2 NULL,
|
||||
[statut] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[mode_identification_emprunt] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[mode_identification_retour] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
|
||||
[commentaire_depart] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
|
||||
[commentaire_retour] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
|
||||
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
|
||||
[updated_at] datetime2 NOT NULL
|
||||
);
|
||||
|
||||
SET IDENTITY_INSERT [dbo].[emprunt] ON;
|
||||
INSERT INTO [dbo].[emprunt] ([id], [utilisateur_id], [materiel_id], [campus_id], [salle_pret_id], [poste_emprunt_id], [date_emprunt], [date_retour_prevue], [date_retour_reelle], [statut], [mode_identification_emprunt], [mode_identification_retour], [commentaire_depart], [commentaire_retour], [created_at], [updated_at]) VALUES
|
||||
(1, 1, 1, 1, 1, 1, '2026-06-13 14:48:51', '2026-06-20 14:48:51', '2026-07-06 08:03:20', 'CLOTURE', 'QR_CODE', 'QR_CODE', NULL, NULL, '2026-06-15 14:48:53', '2026-07-06 08:03:20'),
|
||||
(2, 2, 2, 1, 1, 1, '2026-05-26 14:48:51', '2026-06-09 14:48:51', '2026-07-07 12:33:18', 'CLOTURE', 'MAIL_ENSUP', 'MAIL_ENSUP', NULL, NULL, '2026-06-15 14:48:53', '2026-07-07 12:33:18'),
|
||||
(3, 1, 3, 1, 1, 1, '2026-06-05 14:48:51', '2026-06-12 14:48:51', '2026-06-12 14:48:51', 'CLOTURE', 'QR_CODE', 'QR_CODE', NULL, NULL, '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
|
||||
(4, 3, 4, 1, 1, 1, '2026-06-10 14:48:51', '2026-06-14 14:48:51', '2026-06-14 14:48:51', 'RETOUR_NON_CONFORME', 'MAIL_ENSUP', 'MAIL_ENSUP', NULL, 'Housse non rapportee.', '2026-06-15 14:48:53', '2026-06-15 14:48:53'),
|
||||
(1002, 1, 3, 1, 1, 1, '2026-07-06 07:38:13', '2026-07-20 07:38:13', '2026-07-06 08:03:20', 'RETOUR_NON_CONFORME', 'QR_CODE', 'QR_CODE', 'RAS', 'element manquant', '2026-07-06 07:38:13', '2026-07-06 08:03:20'),
|
||||
(2002, 2, 1006, 1, 1, 1, '2026-07-07 11:51:13', '2026-07-21 11:51:13', '2026-07-07 12:33:29', 'CLOTURE', 'MAIL_ENSUP', 'MAIL_ENSUP', NULL, NULL, '2026-07-07 11:51:13', '2026-07-07 12:33:29'),
|
||||
(2003, 2, 1003, 1, 1, 1, '2026-07-07 11:58:06', '2026-07-21 11:58:06', '2026-07-07 12:33:43', 'CLOTURE', 'MAIL_ENSUP', 'MAIL_ENSUP', NULL, NULL, '2026-07-07 11:58:06', '2026-07-07 12:33:44'),
|
||||
(3002, 2, 1002, 1, 1, 1, '2026-07-07 12:34:25', '2026-07-21 12:34:25', NULL, 'EN_COURS', 'MAIL_ENSUP', NULL, NULL, NULL, '2026-07-07 12:34:25', '2026-07-07 12:34:25'),
|
||||
(4002, 2, 1007, 1, 1, 1, '2026-07-08 09:19:17', '2026-07-22 09:19:17', '2026-07-08 09:19:17', 'CLOTURE', 'MAIL_ENSUP', 'MAIL_ENSUP', 'Test stabilisation API', 'Retour test conforme', '2026-07-08 09:19:17', '2026-07-08 09:19:17'),
|
||||
(4003, 2, 1009, 1, 1, 1, '2026-07-08 09:19:47', '2026-07-22 09:19:46', '2026-07-08 09:19:47', 'RETOUR_NON_CONFORME', 'MAIL_ENSUP', 'MAIL_ENSUP', 'Test non conforme API', 'Retour test non conforme', '2026-07-08 09:19:47', '2026-07-08 09:19:47'),
|
||||
(4004, 2, 1003, 1, 1, 1, '2026-07-08 13:19:04', '2026-07-22 13:19:04', NULL, 'EN_COURS', 'MAIL_ENSUP', NULL, NULL, NULL, '2026-07-08 13:19:04', '2026-07-08 13:19:04');
|
||||
SET IDENTITY_INSERT [dbo].[emprunt] OFF;
|
||||
|
||||
DROP TABLE IF EXISTS [dbo].[historique];
|
||||
CREATE TABLE [dbo].[historique] (
|
||||
[id] int NOT NULL IDENTITY PRIMARY KEY,
|
||||
[utilisateur_id] int NOT NULL,
|
||||
[emprunt_id] int NULL,
|
||||
[materiel_id] int NULL,
|
||||
[campus_id] int NULL,
|
||||
[salle_pret_id] int NULL,
|
||||
[poste_emprunt_id] int NULL,
|
||||
[action] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[description] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[date_action] datetime2 NOT NULL,
|
||||
[created_at] datetime2 NOT NULL DEFAULT '(getdate())'
|
||||
);
|
||||
|
||||
SET IDENTITY_INSERT [dbo].[historique] ON;
|
||||
INSERT INTO [dbo].[historique] ([id], [utilisateur_id], [emprunt_id], [materiel_id], [campus_id], [salle_pret_id], [poste_emprunt_id], [action], [description], [date_action], [created_at]) VALUES
|
||||
(1, 1, 1, 1, 1, 1, 1, 'CREATION_EMPRUNT', 'Emprunt du PC portable Dell.', '2026-06-13 14:48:51', '2026-06-15 14:48:53'),
|
||||
(2, 3, 4, 4, 1, 1, 1, 'CREATION_ANOMALIE', 'Anomalie detectee au retour de la Caméra Sony.', '2026-06-14 14:48:51', '2026-06-15 14:48:53');
|
||||
SET IDENTITY_INSERT [dbo].[historique] OFF;
|
||||
|
||||
DROP TABLE IF EXISTS [dbo].[materiel];
|
||||
CREATE TABLE [dbo].[materiel] (
|
||||
[id] int NOT NULL IDENTITY PRIMARY KEY,
|
||||
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[marque] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[modele] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[reference] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[numero_inventaire] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[numero_serie] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[statut] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[etat_general] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[categorie_id] int NOT NULL,
|
||||
[campus_id] int NOT NULL,
|
||||
[actif] bit NOT NULL DEFAULT '((1))',
|
||||
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
|
||||
[updated_at] datetime2 NOT NULL
|
||||
);
|
||||
|
||||
SET IDENTITY_INSERT [dbo].[materiel] ON;
|
||||
INSERT INTO [dbo].[materiel] ([id], [nom], [marque], [modele], [reference], [numero_inventaire], [numero_serie], [statut], [etat_general], [categorie_id], [campus_id], [actif], [created_at], [updated_at]) VALUES
|
||||
(1, 'PC portable Dell Latitude', 'Dell', 'Latitude 5440', 'REF-PC-001', 'INV-PC-001', 'SN-DELL-001', 'DISPONIBLE', 'Bon etat', 1, 1, '1', '2026-06-15 14:48:52', '2026-07-06 08:03:20'),
|
||||
(2, 'Vidéoprojecteur Epson', 'Epson', 'EB-W06', 'REF-VP-001', 'INV-VP-001', 'SN-EPSON-001', 'DISPONIBLE', 'Bon etat', 2, 1, '1', '2026-06-15 14:48:52', '2026-07-07 12:33:18'),
|
||||
(3, 'Tablette iPad', 'Apple', 'iPad 10', 'REF-TAB-001', 'INV-TAB-001', 'SN-IPAD-001', 'NON_CONFORME', 'Bon etat', 3, 1, '1', '2026-06-15 14:48:52', '2026-07-06 08:03:20'),
|
||||
(4, 'Caméra Sony', 'Sony', 'Alpha 6400', 'REF-CAM-001', 'INV-CAM-001', 'SN-SONY-001', 'DETERIORE', 'Housse manquante au retour', 4, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
|
||||
(5, 'Micro Rode', 'Rode', 'NT-USB', 'REF-MIC-001', 'INV-MIC-001', 'SN-RODE-001', 'DISPONIBLE', 'Bon etat', 5, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
|
||||
(6, 'PC portable HP', 'HP', 'ProBook 450', 'REF-PC-002', 'INV-PC-002', 'SN-HP-002', 'MAINTENANCE', 'En revision', 1, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
|
||||
(1002, 'PC Lenovo ThinkPad E14', 'Lenovo', 'ThinkPad E14', 'REF-PC-003', 'INV-PC-003', 'SN-LENOVO-003', 'EMPRUNTE', 'Bon etat', 1, 1, '1', '2026-07-07 11:39:38', '2026-07-07 12:34:25'),
|
||||
(1003, 'MacBook Air M2', 'Apple', 'MacBook Air 13', 'REF-PC-004', 'INV-PC-004', 'SN-MBA-004', 'EMPRUNTE', 'Tres bon etat', 1, 1, '1', '2026-07-07 11:39:38', '2026-07-08 13:19:04'),
|
||||
(1004, 'Microsoft Surface Pro', 'Microsoft', 'Surface Pro 9', 'REF-TAB-002', 'INV-TAB-002', 'SN-SURFACE-002', 'DISPONIBLE', 'Bon etat', 3, 1, '1', '2026-07-07 11:39:38', '2026-07-07 11:39:38'),
|
||||
(1005, 'Vidéoprojecteur Epson EB-X49', 'Epson', 'EB-X49', 'REF-VP-002', 'INV-VP-002', 'SN-EPSON-002', 'DISPONIBLE', 'Bon etat', 2, 1, '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
|
||||
(1006, 'Caméra Canon EOS M50', 'Canon', 'EOS M50', 'REF-CAM-002', 'INV-CAM-002', 'SN-CANON-002', 'DISPONIBLE', 'Bon etat', 4, 1, '1', '2026-07-07 11:39:39', '2026-07-07 12:33:29'),
|
||||
(1007, 'Casque Jabra Evolve', 'Jabra', 'Evolve 40', 'REF-AUD-002', 'INV-AUD-002', 'SN-JABRA-002', 'DISPONIBLE', 'Bon etat', 5, 1, '1', '2026-07-07 11:39:39', '2026-07-08 09:19:17'),
|
||||
(1008, 'Kit câbles HDMI / USB-C', 'ENSUP', 'Kit connectique', 'REF-CAB-001', 'INV-CAB-001', 'SN-CABLE-001', 'DISPONIBLE', 'Bon etat', 6, 1, '1', '2026-07-07 11:39:39', '2026-07-07 11:39:39'),
|
||||
(1009, 'Enceinte Bluetooth JBL', 'JBL', 'Flip 6', 'REF-AUD-003', 'INV-AUD-003', 'SN-JBL-003', 'NON_CONFORME', 'Bon etat', 5, 1, '1', '2026-07-07 11:39:39', '2026-07-08 09:19:47');
|
||||
SET IDENTITY_INSERT [dbo].[materiel] OFF;
|
||||
|
||||
DROP TABLE IF EXISTS [dbo].[materiel_accessoire];
|
||||
CREATE TABLE [dbo].[materiel_accessoire] (
|
||||
[id] int NOT NULL IDENTITY PRIMARY KEY,
|
||||
[materiel_id] int NOT NULL,
|
||||
[accessoire_id] int NOT NULL,
|
||||
[quantite_attendue] int NOT NULL,
|
||||
[obligatoire] bit NOT NULL
|
||||
);
|
||||
|
||||
SET IDENTITY_INSERT [dbo].[materiel_accessoire] ON;
|
||||
INSERT INTO [dbo].[materiel_accessoire] ([id], [materiel_id], [accessoire_id], [quantite_attendue], [obligatoire]) VALUES
|
||||
(1, 1, 1, 1, '1'),
|
||||
(2, 1, 2, 1, '0'),
|
||||
(3, 4, 3, 1, '1'),
|
||||
(4, 4, 1, 1, '1'),
|
||||
(1002, 1002, 1, 1, '1'),
|
||||
(1003, 1002, 2, 1, '1'),
|
||||
(1004, 1002, 3, 1, '1'),
|
||||
(1005, 1003, 1002, 1, '1'),
|
||||
(1006, 1003, 3, 1, '1'),
|
||||
(1007, 1004, 1003, 1, '1'),
|
||||
(1008, 1004, 1004, 1, '1'),
|
||||
(1009, 1005, 1005, 1, '1'),
|
||||
(1010, 1005, 1006, 1, '1'),
|
||||
(1011, 1006, 1007, 1, '1'),
|
||||
(1012, 1006, 1008, 1, '1'),
|
||||
(1013, 1006, 3, 1, '1'),
|
||||
(1014, 1008, 1006, 1, '1'),
|
||||
(1015, 1008, 1009, 1, '1'),
|
||||
(1016, 1009, 1010, 1, '1');
|
||||
SET IDENTITY_INSERT [dbo].[materiel_accessoire] OFF;
|
||||
|
||||
DROP TABLE IF EXISTS [dbo].[notification];
|
||||
CREATE TABLE [dbo].[notification] (
|
||||
[id] int NOT NULL IDENTITY PRIMARY KEY,
|
||||
[utilisateur_id] int NOT NULL,
|
||||
[anomalie_id] int NULL,
|
||||
[titre] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[message] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[type] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[lu] bit NOT NULL DEFAULT '((0))',
|
||||
[date_creation] datetime2 NOT NULL DEFAULT '(getdate())'
|
||||
);
|
||||
|
||||
SET IDENTITY_INSERT [dbo].[notification] ON;
|
||||
INSERT INTO [dbo].[notification] ([id], [utilisateur_id], [anomalie_id], [titre], [message], [type], [lu], [date_creation]) VALUES
|
||||
(1, 4, 1, 'Nouvelle anomalie detectee', 'Retour non conforme sur la Caméra Sony (housse manquante).', 'ANOMALIE', '0', '2026-06-15 14:48:53'),
|
||||
(2, 4, 2, 'Nouvelle anomalie detectee', 'Retour non conforme : Etat general manquant au retour.', 'ANOMALIE', '0', '2026-07-06 08:03:20'),
|
||||
(1002, 4, 1002, 'Nouvelle anomalie detectee', 'Retour non conforme : Cable USB-C manquant au retour.', 'ANOMALIE', '0', '2026-07-08 09:19:47');
|
||||
SET IDENTITY_INSERT [dbo].[notification] OFF;
|
||||
|
||||
DROP TABLE IF EXISTS [dbo].[poste_emprunt];
|
||||
CREATE TABLE [dbo].[poste_emprunt] (
|
||||
[id] int NOT NULL IDENTITY PRIMARY KEY,
|
||||
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[identifiant_poste] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[salle_pret_id] int NOT NULL,
|
||||
[actif] bit NOT NULL DEFAULT '((1))',
|
||||
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
|
||||
[updated_at] datetime2 NOT NULL
|
||||
);
|
||||
|
||||
SET IDENTITY_INSERT [dbo].[poste_emprunt] ON;
|
||||
INSERT INTO [dbo].[poste_emprunt] ([id], [nom], [identifiant_poste], [salle_pret_id], [actif], [created_at], [updated_at]) VALUES
|
||||
(1, 'Poste 1', 'POSTE-01', 1, '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37');
|
||||
SET IDENTITY_INSERT [dbo].[poste_emprunt] OFF;
|
||||
|
||||
DROP TABLE IF EXISTS [dbo].[role];
|
||||
CREATE TABLE [dbo].[role] (
|
||||
[id] int NOT NULL IDENTITY PRIMARY KEY,
|
||||
[code] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[libelle] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[description] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
CONSTRAINT [role_code_key] UNIQUE ([code])
|
||||
);
|
||||
|
||||
SET IDENTITY_INSERT [dbo].[role] ON;
|
||||
INSERT INTO [dbo].[role] ([id], [code], [libelle], [description]) VALUES
|
||||
(1, 'ETUDIANT', 'Étudiant', 'Emprunte et restitue du matériel via le poste dédié.'),
|
||||
(2, 'RESPONSABLE', 'Responsable matériel', 'Supervise, gère le stock et traite les anomalies.');
|
||||
SET IDENTITY_INSERT [dbo].[role] OFF;
|
||||
|
||||
DROP TABLE IF EXISTS [dbo].[salle_pret];
|
||||
CREATE TABLE [dbo].[salle_pret] (
|
||||
[id] int NOT NULL IDENTITY PRIMARY KEY,
|
||||
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[localisation] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[campus_id] int NOT NULL,
|
||||
[actif] bit NOT NULL DEFAULT '((1))',
|
||||
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
|
||||
[updated_at] datetime2 NOT NULL
|
||||
);
|
||||
|
||||
SET IDENTITY_INSERT [dbo].[salle_pret] ON;
|
||||
INSERT INTO [dbo].[salle_pret] ([id], [nom], [localisation], [campus_id], [actif], [created_at], [updated_at]) VALUES
|
||||
(1, 'Salle de prêt principale', 'Campus Saint-Christophe', 1, '1', '2026-06-15 09:03:37', '2026-06-15 09:03:37');
|
||||
SET IDENTITY_INSERT [dbo].[salle_pret] OFF;
|
||||
|
||||
DROP TABLE IF EXISTS [dbo].[utilisateur];
|
||||
CREATE TABLE [dbo].[utilisateur] (
|
||||
[id] int NOT NULL IDENTITY PRIMARY KEY,
|
||||
[microsoft_id] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[nom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[prenom] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[email] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
|
||||
[classe] nvarchar(1000) COLLATE SQL_Latin1_General_CP1_CI_AS NULL,
|
||||
[role_id] int NOT NULL,
|
||||
[campus_id] int NOT NULL,
|
||||
[actif] bit NOT NULL DEFAULT '((1))',
|
||||
[created_at] datetime2 NOT NULL DEFAULT '(getdate())',
|
||||
[updated_at] datetime2 NOT NULL,
|
||||
CONSTRAINT [utilisateur_microsoft_id_key] UNIQUE ([microsoft_id]),
|
||||
CONSTRAINT [utilisateur_email_key] UNIQUE ([email])
|
||||
);
|
||||
|
||||
SET IDENTITY_INSERT [dbo].[utilisateur] ON;
|
||||
INSERT INTO [dbo].[utilisateur] ([id], [microsoft_id], [nom], [prenom], [email], [classe], [role_id], [campus_id], [actif], [created_at], [updated_at]) VALUES
|
||||
(1, 'ms-marie-dupont', 'Dupont', 'Marie', 'marie.dupont@ensitech.eu', 'B3 Informatique', 1, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
|
||||
(2, 'ms-lucas-martin', 'Martin', 'Lucas', 'lucas.martin@ensitech.eu', 'B2 Informatique', 1, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
|
||||
(3, 'ms-sofia-bernard', 'Bernard', 'Sofia', 'sofia.bernard@ensup.eu', 'B1 Marketing', 1, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52'),
|
||||
(4, 'ms-karim-benali', 'Benali', 'Karim', 'karim.benali@ensup.eu', NULL, 2, 1, '1', '2026-06-15 14:48:52', '2026-06-15 14:48:52');
|
||||
SET IDENTITY_INSERT [dbo].[utilisateur] OFF;
|
||||
|
||||
ALTER TABLE [dbo].[anomalie] ADD
|
||||
FOREIGN KEY ([emprunt_id]) REFERENCES [emprunt] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
|
||||
FOREIGN KEY ([materiel_id]) REFERENCES [materiel] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
|
||||
FOREIGN KEY ([utilisateur_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
|
||||
FOREIGN KEY ([traitee_par_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
ALTER TABLE [dbo].[carte_etudiante] ADD
|
||||
FOREIGN KEY ([utilisateur_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE [dbo].[checklist] ADD
|
||||
FOREIGN KEY ([emprunt_id]) REFERENCES [emprunt] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
|
||||
FOREIGN KEY ([utilisateur_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
ALTER TABLE [dbo].[checklist_element] ADD
|
||||
FOREIGN KEY ([accessoire_id]) REFERENCES [accessoire] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
|
||||
FOREIGN KEY ([checklist_id]) REFERENCES [checklist] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
ALTER TABLE [dbo].[emprunt] ADD
|
||||
FOREIGN KEY ([campus_id]) REFERENCES [campus] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
|
||||
FOREIGN KEY ([materiel_id]) REFERENCES [materiel] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
|
||||
FOREIGN KEY ([poste_emprunt_id]) REFERENCES [poste_emprunt] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
|
||||
FOREIGN KEY ([salle_pret_id]) REFERENCES [salle_pret] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
|
||||
FOREIGN KEY ([utilisateur_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
ALTER TABLE [dbo].[historique] ADD
|
||||
FOREIGN KEY ([campus_id]) REFERENCES [campus] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
|
||||
FOREIGN KEY ([emprunt_id]) REFERENCES [emprunt] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
|
||||
FOREIGN KEY ([materiel_id]) REFERENCES [materiel] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
|
||||
FOREIGN KEY ([poste_emprunt_id]) REFERENCES [poste_emprunt] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
|
||||
FOREIGN KEY ([salle_pret_id]) REFERENCES [salle_pret] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
|
||||
FOREIGN KEY ([utilisateur_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
ALTER TABLE [dbo].[materiel] ADD
|
||||
FOREIGN KEY ([campus_id]) REFERENCES [campus] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE,
|
||||
FOREIGN KEY ([categorie_id]) REFERENCES [categorie_materiel] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE [dbo].[materiel_accessoire] ADD
|
||||
FOREIGN KEY ([accessoire_id]) REFERENCES [accessoire] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE,
|
||||
FOREIGN KEY ([materiel_id]) REFERENCES [materiel] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE [dbo].[notification] ADD
|
||||
FOREIGN KEY ([anomalie_id]) REFERENCES [anomalie] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION,
|
||||
FOREIGN KEY ([utilisateur_id]) REFERENCES [utilisateur] ([id]) ON DELETE NO ACTION ON UPDATE NO ACTION;
|
||||
|
||||
ALTER TABLE [dbo].[poste_emprunt] ADD
|
||||
FOREIGN KEY ([salle_pret_id]) REFERENCES [salle_pret] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE [dbo].[salle_pret] ADD
|
||||
FOREIGN KEY ([campus_id]) REFERENCES [campus] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE [dbo].[utilisateur] ADD
|
||||
FOREIGN KEY ([campus_id]) REFERENCES [campus] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE,
|
||||
FOREIGN KEY ([role_id]) REFERENCES [role] ([id]) ON DELETE NO ACTION ON UPDATE CASCADE;
|
||||
|
||||
-- 2026-07-10 09:04:02 UTC
|
||||
@@ -0,0 +1,45 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
@@ -0,0 +1,30 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "b45fa18946ecc2d9b4009952c636ba7e2ffbb787"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
|
||||
base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
|
||||
- platform: web
|
||||
create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
|
||||
base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
@@ -0,0 +1,16 @@
|
||||
# eme_frontend
|
||||
|
||||
A new Flutter project.
|
||||
|
||||
## Getting Started
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
|
||||
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
@@ -0,0 +1,6 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
rules:
|
||||
# Convention projet (CLAUDE.md) : guillemets doubles en Dart.
|
||||
prefer_single_quotes: false
|
||||
@@ -0,0 +1,18 @@
|
||||
import "services/auth_service.dart";
|
||||
|
||||
/// Identité de secours utilisée lorsque aucun profil de session n'est chargé.
|
||||
class DemoIdentity {
|
||||
DemoIdentity._();
|
||||
|
||||
static String get userName =>
|
||||
AuthService.currentProfile?.fullName ?? "Lucas Martin";
|
||||
static String get userInitials =>
|
||||
AuthService.currentProfile?.initials ?? "LM";
|
||||
static String get userSubtitle =>
|
||||
AuthService.currentProfile?.subtitle ??
|
||||
"Étudiant · B2 Informatique · Ensitech Cergy";
|
||||
static String get campusTag =>
|
||||
AuthService.currentProfile?.campusTag ?? "Saint-Christophe · Cergy";
|
||||
|
||||
static const loginCampusTag = "Ensitech · Cergy";
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import "package:flutter/material.dart";
|
||||
import "theme/ensup_theme.dart";
|
||||
import "screens/login_screen.dart";
|
||||
|
||||
void main() {
|
||||
runApp(const EmeApp());
|
||||
}
|
||||
|
||||
class EmeApp extends StatelessWidget {
|
||||
const EmeApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: "EME — Emprunt Matériel ENSUP",
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildEnsupTheme(),
|
||||
home: const LoginScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/// Une différence constatée au retour par rapport au départ (RG20).
|
||||
class AnomalieRetour {
|
||||
const AnomalieRetour({required this.element, required this.etatRetour});
|
||||
|
||||
final String element;
|
||||
final String etatRetour;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
class AuthenticatedProfile {
|
||||
const AuthenticatedProfile({
|
||||
required this.id,
|
||||
required this.nom,
|
||||
required this.prenom,
|
||||
required this.email,
|
||||
required this.classe,
|
||||
required this.roleCode,
|
||||
required this.roleLabel,
|
||||
required this.campusId,
|
||||
required this.campusNom,
|
||||
required this.campusVille,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String nom;
|
||||
final String prenom;
|
||||
final String email;
|
||||
final String? classe;
|
||||
final String roleCode;
|
||||
final String roleLabel;
|
||||
final int campusId;
|
||||
final String campusNom;
|
||||
final String campusVille;
|
||||
|
||||
factory AuthenticatedProfile.fromApi(dynamic reponse) {
|
||||
if (reponse is! Map || reponse["data"] is! Map) {
|
||||
throw const FormatException("Profil utilisateur invalide.");
|
||||
}
|
||||
|
||||
final data = reponse["data"] as Map;
|
||||
final role = data["role"];
|
||||
final campus = data["campus"];
|
||||
if (role is! Map || campus is! Map) {
|
||||
throw const FormatException("Role ou campus utilisateur invalide.");
|
||||
}
|
||||
|
||||
final id = data["id"];
|
||||
final campusId = campus["id"];
|
||||
if (id is! int || campusId is! int) {
|
||||
throw const FormatException("Identifiant utilisateur invalide.");
|
||||
}
|
||||
|
||||
final classe = data["classe"];
|
||||
if (classe != null && classe is! String) {
|
||||
throw const FormatException("Classe utilisateur invalide.");
|
||||
}
|
||||
|
||||
return AuthenticatedProfile(
|
||||
id: id,
|
||||
nom: _requiredString(data, "nom"),
|
||||
prenom: _requiredString(data, "prenom"),
|
||||
email: _requiredString(data, "email"),
|
||||
classe: classe as String?,
|
||||
roleCode: _requiredString(role, "code"),
|
||||
roleLabel: _requiredString(role, "libelle"),
|
||||
campusId: campusId,
|
||||
campusNom: _requiredString(campus, "nom"),
|
||||
campusVille: _requiredString(campus, "ville"),
|
||||
);
|
||||
}
|
||||
|
||||
String get fullName => "$prenom $nom";
|
||||
|
||||
String get initials {
|
||||
final first = prenom.trim().isEmpty ? "" : prenom.trim()[0];
|
||||
final last = nom.trim().isEmpty ? "" : nom.trim()[0];
|
||||
return "$first$last".toUpperCase();
|
||||
}
|
||||
|
||||
String get campusTag => "$campusNom · $campusVille";
|
||||
|
||||
String get subtitle {
|
||||
final details = <String>[roleLabel];
|
||||
final studentClass = classe?.trim();
|
||||
if (studentClass != null && studentClass.isNotEmpty) {
|
||||
details.add(studentClass);
|
||||
}
|
||||
details.add(campusTag);
|
||||
return details.join(" · ");
|
||||
}
|
||||
|
||||
static String _requiredString(Map source, String key) {
|
||||
final value = source[key];
|
||||
if (value is! String || value.trim().isEmpty) {
|
||||
throw FormatException("Champ utilisateur invalide : $key.");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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 l’emprunt.",
|
||||
style: GoogleFonts.titilliumWeb(
|
||||
fontSize: 13,
|
||||
color: EnsupColors.muted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_SearchField(
|
||||
onChanged: (value) =>
|
||||
setState(() => _recherche = value),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Expanded(
|
||||
child: FutureBuilder<List<MaterielItem>>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState ==
|
||||
ConnectionState.waiting) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return _ErreurState(
|
||||
message: snapshot.error.toString(),
|
||||
onRetry: _recharger,
|
||||
);
|
||||
}
|
||||
final materiels = snapshot.data ?? const [];
|
||||
return _contenu(materiels);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Positioned.fill(child: BrandCorners()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _contenu(List<MaterielItem> materiels) {
|
||||
final resultats = _filtrer(materiels);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _categories(materiels).map((categorie) {
|
||||
return _CategoryChip(
|
||||
label: categorie,
|
||||
selected: categorie == _categorie,
|
||||
onTap: () => setState(() => _categorie = categorie),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"${resultats.length} équipement${resultats.length > 1 ? "s" : ""}",
|
||||
style: GoogleFonts.darkerGrotesque(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: EnsupColors.text2,
|
||||
),
|
||||
),
|
||||
const Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: 14),
|
||||
child: Divider(color: EnsupColors.line, height: 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: resultats.isEmpty
|
||||
? const _EmptyState()
|
||||
: ListView.separated(
|
||||
itemCount: resultats.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, index) {
|
||||
final item = resultats[index];
|
||||
return Stack(
|
||||
children: [
|
||||
MaterielCard(
|
||||
item: item,
|
||||
onSelect: () => _selectionner(item),
|
||||
),
|
||||
if (_selectionEnCoursId == item.id)
|
||||
const Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: Color(0x66FFFFFF),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchField extends StatelessWidget {
|
||||
const _SearchField({required this.onChanged});
|
||||
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 44,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: EnsupColors.line, width: 1.5),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.search, size: 18, color: EnsupColors.muted),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
onChanged: onChanged,
|
||||
style: GoogleFonts.titilliumWeb(
|
||||
fontSize: 14,
|
||||
color: EnsupColors.text,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
isCollapsed: true,
|
||||
border: InputBorder.none,
|
||||
hintText: "Rechercher un matériel...",
|
||||
hintStyle: GoogleFonts.titilliumWeb(
|
||||
fontSize: 14,
|
||||
color: EnsupColors.muted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryChip extends StatelessWidget {
|
||||
const _CategoryChip({
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? EnsupColors.cyan : Colors.white,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(
|
||||
color: selected ? EnsupColors.cyan : EnsupColors.line,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: GoogleFonts.darkerGrotesque(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: selected ? Colors.white : EnsupColors.muted,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
const _EmptyState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.inventory_2_outlined,
|
||||
size: 40,
|
||||
color: EnsupColors.muted,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
"Aucun matériel ne correspond",
|
||||
style: GoogleFonts.titilliumWeb(
|
||||
fontSize: 14,
|
||||
color: EnsupColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErreurState extends StatelessWidget {
|
||||
const _ErreurState({required this.message, required this.onRetry});
|
||||
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.cloud_off_outlined,
|
||||
size: 40,
|
||||
color: EnsupColors.muted,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: GoogleFonts.titilliumWeb(
|
||||
fontSize: 14,
|
||||
color: EnsupColors.text2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
OutlinedButton(
|
||||
onPressed: onRetry,
|
||||
child: Text(
|
||||
"Réessayer",
|
||||
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import "package:flutter/material.dart";
|
||||
import "package:google_fonts/google_fonts.dart";
|
||||
import "../demo_identity.dart";
|
||||
import "../theme/ensup_colors.dart";
|
||||
import "../models/materiel_item.dart";
|
||||
import "../widgets/ensup_top_bar.dart";
|
||||
import "../widgets/brand_corners.dart";
|
||||
import "../widgets/status_pill.dart";
|
||||
import "checklist_depart_screen.dart";
|
||||
|
||||
/// Détail d'un matériel : caractéristiques, accessoires du kit et lancement de
|
||||
/// l'emprunt.
|
||||
class DetailScreen extends StatelessWidget {
|
||||
const DetailScreen({super.key, required this.item});
|
||||
|
||||
final MaterielItem item;
|
||||
|
||||
void _demarrer(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => ChecklistDepartScreen(item: item)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: EnsupColors.soft,
|
||||
body: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: EnsupColors.text.withValues(alpha: 0.14),
|
||||
blurRadius: 60,
|
||||
offset: const Offset(0, 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
EnsupTopBar(
|
||||
brandText: "Détail matériel",
|
||||
campusTag: DemoIdentity.campusTag,
|
||||
userName: DemoIdentity.userName,
|
||||
userInitials: DemoIdentity.userInitials,
|
||||
onBack: () => Navigator.of(context).pop(),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 26,
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.maxWidth < 760) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_colonneGauche(),
|
||||
const SizedBox(height: 20),
|
||||
_colonneDroite(context),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(flex: 3, child: _colonneGauche()),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _colonneDroite(context),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Positioned.fill(child: BrandCorners()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _colonneGauche() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: EnsupColors.line, width: 1.5),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: EnsupColors.text.withValues(alpha: 0.04),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: EnsupColors.cyan.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(item.icon, color: EnsupColors.blue3, size: 21),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
item.nom,
|
||||
style: GoogleFonts.darkerGrotesque(
|
||||
fontSize: 24,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: EnsupColors.text,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_row("Référence", item.reference),
|
||||
_row("Catégorie", item.categorie),
|
||||
_row("Marque", item.marque),
|
||||
_row("Modèle", item.modele),
|
||||
_row("État", item.etatGeneral, valueColor: EnsupColors.green),
|
||||
_row("Campus", item.campus),
|
||||
_rowStatut(),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (item.accessoires.isNotEmpty) ...[
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
"ACCESSOIRES INCLUS DANS LE KIT",
|
||||
style: GoogleFonts.darkerGrotesque(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: EnsupColors.muted,
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: item.accessoires.map(_tag).toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(String cle, String valeur, {Color? valueColor}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: EnsupColors.line)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
cle,
|
||||
style: GoogleFonts.titilliumWeb(
|
||||
fontSize: 13,
|
||||
color: EnsupColors.muted,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
valeur,
|
||||
textAlign: TextAlign.right,
|
||||
style: GoogleFonts.titilliumWeb(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: valueColor ?? EnsupColors.text,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _rowStatut() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"Statut",
|
||||
style: GoogleFonts.titilliumWeb(
|
||||
fontSize: 13,
|
||||
color: EnsupColors.muted,
|
||||
),
|
||||
),
|
||||
StatusPill(disponible: item.disponible),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tag(String label) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: EnsupColors.teal.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: GoogleFonts.darkerGrotesque(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: EnsupColors.teal,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _colonneDroite(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 180,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: EnsupColors.cyan.withValues(alpha: 0.045),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: EnsupColors.cyan.withValues(alpha: 0.18),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: EnsupColors.blue3.withValues(alpha: 0.1),
|
||||
blurRadius: 14,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(item.icon, size: 36, color: EnsupColors.blue3),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
"Aperçu du matériel",
|
||||
style: GoogleFonts.titilliumWeb(
|
||||
fontSize: 13,
|
||||
color: EnsupColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: item.disponible ? () => _demarrer(context) : null,
|
||||
icon: const Icon(Icons.arrow_forward, size: 18),
|
||||
label: Text(
|
||||
"Démarrer l'emprunt",
|
||||
style: GoogleFonts.darkerGrotesque(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: EnsupColors.cyan,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: EnsupColors.text2,
|
||||
side: const BorderSide(color: EnsupColors.line, width: 1.5),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
"Retour au catalogue",
|
||||
style: GoogleFonts.darkerGrotesque(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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 l’opération à réaliser sur ce poste.",
|
||||
style: GoogleFonts.titilliumWeb(
|
||||
fontSize: 14,
|
||||
color: EnsupColors.muted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final emprunter = ActionCard(
|
||||
icon: Icons.inventory_2_outlined,
|
||||
title: "EMPRUNTER",
|
||||
description:
|
||||
"Consulter les équipements disponibles et démarrer un nouvel emprunt.",
|
||||
color: EnsupColors.cyan,
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
const CatalogueScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
final restituer = ActionCard(
|
||||
icon: Icons.assignment_return_outlined,
|
||||
title: "RESTITUER",
|
||||
description:
|
||||
"Sélectionner un emprunt en cours et contrôler le matériel rendu.",
|
||||
color: EnsupColors.teal,
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
const RestitutionSelectScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (constraints.maxWidth < 720) {
|
||||
return Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
emprunter,
|
||||
const SizedBox(height: 14),
|
||||
restituer,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(child: emprunter),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: restituer),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Positioned.fill(child: BrandCorners()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import "dart:convert";
|
||||
import "dart:typed_data";
|
||||
import "package:http/http.dart" as http;
|
||||
import "auth_service.dart";
|
||||
|
||||
/// Erreur levée par le client API (serveur injoignable ou réponse en échec).
|
||||
class ApiException implements Exception {
|
||||
const ApiException(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class ApiFile {
|
||||
const ApiFile({
|
||||
required this.bytes,
|
||||
required this.fileName,
|
||||
required this.contentType,
|
||||
});
|
||||
|
||||
final Uint8List bytes;
|
||||
final String fileName;
|
||||
final String contentType;
|
||||
}
|
||||
|
||||
/// Point d'accès unique au backend : centralise l'URL, l'authentification et
|
||||
/// la gestion des erreurs.
|
||||
class ApiClient {
|
||||
ApiClient._();
|
||||
|
||||
static const String _baseUrl = "http://localhost:3000/api";
|
||||
|
||||
static Future<dynamic> get(String chemin) async {
|
||||
final uri = Uri.parse("$_baseUrl$chemin");
|
||||
|
||||
late final http.Response reponse;
|
||||
try {
|
||||
final authHeaders = await AuthService.requestHeaders();
|
||||
reponse = await http.get(uri, headers: authHeaders);
|
||||
} catch (_) {
|
||||
throw const ApiException(
|
||||
"Impossible de joindre le serveur. Vérifiez que le backend est démarré.",
|
||||
);
|
||||
}
|
||||
|
||||
if (reponse.statusCode >= 200 && reponse.statusCode < 300) {
|
||||
return jsonDecode(reponse.body);
|
||||
}
|
||||
throw ApiException(_messageErreur(reponse));
|
||||
}
|
||||
|
||||
static Future<ApiFile> getFile(
|
||||
String chemin, {
|
||||
required String fallbackFileName,
|
||||
}) async {
|
||||
final uri = Uri.parse("$_baseUrl$chemin");
|
||||
|
||||
late final http.Response reponse;
|
||||
try {
|
||||
final authHeaders = await AuthService.requestHeaders();
|
||||
reponse = await http.get(uri, headers: authHeaders);
|
||||
} catch (_) {
|
||||
throw const ApiException(
|
||||
"Impossible de joindre le serveur. Vérifiez que le backend est démarré.",
|
||||
);
|
||||
}
|
||||
|
||||
if (reponse.statusCode < 200 || reponse.statusCode >= 300) {
|
||||
throw ApiException(_messageErreur(reponse));
|
||||
}
|
||||
|
||||
return ApiFile(
|
||||
bytes: reponse.bodyBytes,
|
||||
fileName: _fileName(reponse, fallbackFileName),
|
||||
contentType:
|
||||
reponse.headers["content-type"] ?? "application/octet-stream",
|
||||
);
|
||||
}
|
||||
|
||||
static Future<dynamic> post(String chemin, Map<String, dynamic> body) async {
|
||||
final uri = Uri.parse("$_baseUrl$chemin");
|
||||
|
||||
late final http.Response reponse;
|
||||
try {
|
||||
final authHeaders = await AuthService.requestHeaders();
|
||||
reponse = await http.post(
|
||||
uri,
|
||||
headers: {...authHeaders, "content-type": "application/json"},
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
} catch (_) {
|
||||
throw const ApiException(
|
||||
"Impossible de joindre le serveur. Vérifiez que le backend est démarré.",
|
||||
);
|
||||
}
|
||||
|
||||
if (reponse.statusCode >= 200 && reponse.statusCode < 300) {
|
||||
return jsonDecode(reponse.body);
|
||||
}
|
||||
throw ApiException(_messageErreur(reponse));
|
||||
}
|
||||
|
||||
static Future<dynamic> patch(String chemin, Map<String, dynamic> body) async {
|
||||
final uri = Uri.parse("$_baseUrl$chemin");
|
||||
|
||||
late final http.Response reponse;
|
||||
try {
|
||||
final authHeaders = await AuthService.requestHeaders();
|
||||
reponse = await http.patch(
|
||||
uri,
|
||||
headers: {...authHeaders, "content-type": "application/json"},
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
} catch (_) {
|
||||
throw const ApiException(
|
||||
"Impossible de joindre le serveur. Vérifiez que le backend est démarré.",
|
||||
);
|
||||
}
|
||||
|
||||
if (reponse.statusCode >= 200 && reponse.statusCode < 300) {
|
||||
return jsonDecode(reponse.body);
|
||||
}
|
||||
throw ApiException(_messageErreur(reponse));
|
||||
}
|
||||
|
||||
static String _messageErreur(http.Response reponse) {
|
||||
try {
|
||||
final corps = jsonDecode(reponse.body);
|
||||
if (corps is Map && corps["error"] is Map) {
|
||||
final message = (corps["error"] as Map)["message"];
|
||||
if (message is String) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Corps non-JSON : on retombe sur le code HTTP.
|
||||
}
|
||||
return "Erreur ${reponse.statusCode}";
|
||||
}
|
||||
|
||||
static String _fileName(http.Response reponse, String fallback) {
|
||||
final disposition = reponse.headers["content-disposition"];
|
||||
if (disposition == null) return fallback;
|
||||
|
||||
final match = RegExp(
|
||||
"filename=\"?([^\";]+)\"?",
|
||||
caseSensitive: false,
|
||||
).firstMatch(disposition);
|
||||
return match?.group(1) ?? fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import "../models/authenticated_profile.dart";
|
||||
import "msal_bridge.dart";
|
||||
|
||||
enum AuthenticationMode { demo, azure }
|
||||
|
||||
class AuthService {
|
||||
AuthService._();
|
||||
|
||||
static const String _configuredMode = String.fromEnvironment(
|
||||
"AUTH_MODE",
|
||||
defaultValue: "demo",
|
||||
);
|
||||
static const String azureTenantId = String.fromEnvironment("AZURE_TENANT_ID");
|
||||
static const String azureSpaClientId = String.fromEnvironment(
|
||||
"AZURE_SPA_CLIENT_ID",
|
||||
);
|
||||
static const String azureApiScope = String.fromEnvironment("AZURE_API_SCOPE");
|
||||
|
||||
static const String demoStudentEmail = "lucas.martin@ensitech.eu";
|
||||
static const String demoResponsableEmail = "karim.benali@ensup.eu";
|
||||
|
||||
static String? _demoEmail;
|
||||
static AuthenticatedProfile? _currentProfile;
|
||||
static bool _azureInitialized = false;
|
||||
|
||||
static AuthenticatedProfile? get currentProfile => _currentProfile;
|
||||
|
||||
static AuthenticationMode get mode {
|
||||
return switch (_configuredMode) {
|
||||
"demo" => AuthenticationMode.demo,
|
||||
"azure" => AuthenticationMode.azure,
|
||||
_ => throw StateError("AUTH_MODE doit valoir demo ou azure."),
|
||||
};
|
||||
}
|
||||
|
||||
static void selectDemoStudent() {
|
||||
_ensureDemoMode();
|
||||
_demoEmail = demoStudentEmail;
|
||||
}
|
||||
|
||||
static void selectDemoResponsable() {
|
||||
_ensureDemoMode();
|
||||
_demoEmail = demoResponsableEmail;
|
||||
}
|
||||
|
||||
static void clearSession() {
|
||||
_demoEmail = null;
|
||||
_currentProfile = null;
|
||||
}
|
||||
|
||||
static AuthenticatedProfile setProfileFromApi(dynamic reponse) {
|
||||
final profile = AuthenticatedProfile.fromApi(reponse);
|
||||
_currentProfile = profile;
|
||||
return profile;
|
||||
}
|
||||
|
||||
static Future<void> signInWithMicrosoft() async {
|
||||
await _initializeAzure();
|
||||
await MsalBridge.signIn();
|
||||
}
|
||||
|
||||
static Future<bool> hasMicrosoftAccount() async {
|
||||
await _initializeAzure();
|
||||
return MsalBridge.hasAccount();
|
||||
}
|
||||
|
||||
static Future<void> signOut() async {
|
||||
if (mode == AuthenticationMode.azure) {
|
||||
await _initializeAzure();
|
||||
await MsalBridge.signOut();
|
||||
_currentProfile = null;
|
||||
return;
|
||||
}
|
||||
|
||||
clearSession();
|
||||
}
|
||||
|
||||
static Future<Map<String, String>> requestHeaders() async {
|
||||
if (mode == AuthenticationMode.azure) {
|
||||
await _initializeAzure();
|
||||
final token = await MsalBridge.getAccessToken();
|
||||
return {"authorization": "Bearer $token"};
|
||||
}
|
||||
|
||||
final email = _demoEmail;
|
||||
if (email == null) {
|
||||
throw StateError("Aucun utilisateur de demonstration selectionne.");
|
||||
}
|
||||
|
||||
return {"x-user-email": email};
|
||||
}
|
||||
|
||||
static Future<void> _initializeAzure() async {
|
||||
if (mode != AuthenticationMode.azure) {
|
||||
throw StateError("MSAL est disponible uniquement en mode Azure.");
|
||||
}
|
||||
if (_azureInitialized) {
|
||||
return;
|
||||
}
|
||||
if (azureTenantId.isEmpty ||
|
||||
azureSpaClientId.isEmpty ||
|
||||
azureApiScope.isEmpty) {
|
||||
throw StateError("La configuration Azure du frontend est incomplete.");
|
||||
}
|
||||
|
||||
await MsalBridge.initialize(
|
||||
tenantId: azureTenantId,
|
||||
clientId: azureSpaClientId,
|
||||
scope: azureApiScope,
|
||||
redirectUri: Uri.base.origin,
|
||||
);
|
||||
_azureInitialized = true;
|
||||
}
|
||||
|
||||
static void _ensureDemoMode() {
|
||||
if (mode != AuthenticationMode.demo) {
|
||||
throw StateError(
|
||||
"Une identite de demonstration est interdite en mode Azure.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
/// Couleurs de la charte graphique ENSUP.
|
||||
class EnsupColors {
|
||||
EnsupColors._();
|
||||
|
||||
static const Color cyan = Color(0xFF009CE0);
|
||||
static const Color blue1 = Color(0xFF006DAC);
|
||||
static const Color blue2 = Color(0xFF0090D7);
|
||||
static const Color blue3 = Color(0xFF005CAA);
|
||||
static const Color teal = Color(0xFF198AB4);
|
||||
static const Color purple = Color(0xFF7E5AA2);
|
||||
static const Color red = Color(0xFFDD1B3D);
|
||||
static const Color green = Color(0xFF16A34A);
|
||||
|
||||
static const Color text = Color(0xFF0F172A);
|
||||
static const Color text2 = Color(0xFF334155);
|
||||
static const Color muted = Color(0xFF64748B);
|
||||
static const Color line = Color(0xFFE2E8F0);
|
||||
static const Color soft = Color(0xFFF8FAFC);
|
||||
static const Color white = Color(0xFFFFFFFF);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import "package:flutter/material.dart";
|
||||
import "package:google_fonts/google_fonts.dart";
|
||||
import "ensup_colors.dart";
|
||||
|
||||
/// Thème global : couleurs ENSUP et police de corps Titillium Web.
|
||||
/// Les titres utilisent Darker Grotesque, appliqué directement dans les widgets.
|
||||
ThemeData buildEnsupTheme() {
|
||||
final base = ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: EnsupColors.cyan,
|
||||
primary: EnsupColors.cyan,
|
||||
),
|
||||
scaffoldBackgroundColor: EnsupColors.soft,
|
||||
);
|
||||
|
||||
return base.copyWith(
|
||||
textTheme: GoogleFonts.titilliumWebTextTheme(base.textTheme).apply(
|
||||
bodyColor: EnsupColors.text,
|
||||
displayColor: EnsupColors.text,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use
|
||||
|
||||
import "dart:html" as html;
|
||||
import "dart:typed_data";
|
||||
|
||||
void downloadFile({
|
||||
required Uint8List bytes,
|
||||
required String fileName,
|
||||
required String contentType,
|
||||
}) {
|
||||
final blob = html.Blob([bytes], contentType);
|
||||
final url = html.Url.createObjectUrlFromBlob(blob);
|
||||
final anchor = html.AnchorElement(href: url)
|
||||
..download = fileName
|
||||
..style.display = "none";
|
||||
|
||||
html.document.body?.children.add(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
html.Url.revokeObjectUrl(url);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import "package:flutter/material.dart";
|
||||
import "package:google_fonts/google_fonts.dart";
|
||||
import "../theme/ensup_colors.dart";
|
||||
|
||||
/// Grande carte d'action de l'accueil (Emprunter / Restituer).
|
||||
class ActionCard extends StatefulWidget {
|
||||
const ActionCard({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.color,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String description;
|
||||
final Color color;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
State<ActionCard> createState() => _ActionCardState();
|
||||
}
|
||||
|
||||
class _ActionCardState extends State<ActionCard> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = widget.color;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOut,
|
||||
constraints: const BoxConstraints(minHeight: 230),
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered
|
||||
? color.withValues(alpha: 0.1)
|
||||
: color.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: color.withValues(alpha: _hovered ? 0.55 : 0.22),
|
||||
width: _hovered ? 2 : 1.5,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: color.withValues(alpha: _hovered ? 0.16 : 0.06),
|
||||
blurRadius: _hovered ? 22 : 10,
|
||||
offset: Offset(0, _hovered ? 8 : 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: color.withValues(alpha: 0.3),
|
||||
blurRadius: _hovered ? 18 : 12,
|
||||
offset: const Offset(0, 5),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(widget.icon, color: Colors.white, size: 28),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.title,
|
||||
textAlign: TextAlign.center,
|
||||
style: GoogleFonts.darkerGrotesque(
|
||||
fontSize: 25,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: EnsupColors.text,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.description,
|
||||
textAlign: TextAlign.center,
|
||||
style: GoogleFonts.titilliumWeb(
|
||||
fontSize: 13,
|
||||
color: EnsupColors.muted,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.arrow_forward_rounded,
|
||||
size: 17,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import "package:flutter/material.dart";
|
||||
import "../theme/ensup_colors.dart";
|
||||
|
||||
/// Coins décoratifs de la charte ENSUP : un L coloré en haut-gauche et en
|
||||
/// bas-droit, en surimpression et non cliquables (comme dans la maquette).
|
||||
class BrandCorners extends StatelessWidget {
|
||||
const BrandCorners({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
child: CustomPaint(size: const Size(120, 120), painter: _TopLeftCornerPainter()),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: CustomPaint(size: const Size(120, 120), painter: _BottomRightCornerPainter()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const double _thickness = 22;
|
||||
|
||||
class _TopLeftCornerPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()..color = EnsupColors.red;
|
||||
// Barre verticale gauche (rouge) + segment haut rouge.
|
||||
canvas.drawRect(Rect.fromLTWH(0, 0, _thickness, size.height), paint);
|
||||
canvas.drawRect(const Rect.fromLTWH(0, 0, 28, _thickness), paint);
|
||||
// Segments colorés de la barre horizontale.
|
||||
paint.color = EnsupColors.purple;
|
||||
canvas.drawRect(const Rect.fromLTWH(28, 0, 22, _thickness), paint);
|
||||
paint.color = EnsupColors.teal;
|
||||
canvas.drawRect(const Rect.fromLTWH(50, 0, 22, _thickness), paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
class _BottomRightCornerPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()..color = EnsupColors.cyan;
|
||||
// Barre verticale droite (cyan) + segment bas cyan.
|
||||
canvas.drawRect(Rect.fromLTWH(size.width - _thickness, 0, _thickness, size.height), paint);
|
||||
canvas.drawRect(Rect.fromLTWH(size.width - 28, size.height - _thickness, 28, _thickness), paint);
|
||||
// Segments colorés de la barre horizontale.
|
||||
paint.color = EnsupColors.blue2;
|
||||
canvas.drawRect(Rect.fromLTWH(size.width - 50, size.height - _thickness, 22, _thickness), paint);
|
||||
paint.color = EnsupColors.blue3;
|
||||
canvas.drawRect(Rect.fromLTWH(size.width - 72, size.height - _thickness, 22, _thickness), paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import "package:flutter/material.dart";
|
||||
import "package:google_fonts/google_fonts.dart";
|
||||
import "../theme/ensup_colors.dart";
|
||||
|
||||
/// Barre supérieure ENSUP. L'utilisateur et le bouton retour sont optionnels
|
||||
/// (absents sur l'écran d'identification).
|
||||
class EnsupTopBar extends StatelessWidget {
|
||||
const EnsupTopBar({
|
||||
super.key,
|
||||
this.brandText = "EME",
|
||||
required this.campusTag,
|
||||
this.userName,
|
||||
this.userInitials,
|
||||
this.onBack,
|
||||
});
|
||||
|
||||
final String brandText;
|
||||
final String campusTag;
|
||||
final String? userName;
|
||||
final String? userInitials;
|
||||
final VoidCallback? onBack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final name = userName;
|
||||
final initials = userInitials;
|
||||
final onBack = this.onBack;
|
||||
|
||||
return Container(
|
||||
height: 60,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18),
|
||||
color: EnsupColors.blue3,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final compact = constraints.maxWidth < 620;
|
||||
final showCampus = constraints.maxWidth >= 520;
|
||||
final showName = constraints.maxWidth >= 760;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
if (onBack != null) ...[
|
||||
_BackButton(onTap: onBack, compact: compact),
|
||||
SizedBox(width: compact ? 5 : 8),
|
||||
],
|
||||
const Icon(Icons.badge_outlined, color: Colors.white, size: 18),
|
||||
const SizedBox(width: 9),
|
||||
Expanded(
|
||||
child: Text(
|
||||
brandText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: GoogleFonts.darkerGrotesque(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: compact ? 18 : 21,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
if (showCampus)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 11,
|
||||
vertical: 5,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
campusTag,
|
||||
style: GoogleFonts.titilliumWeb(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (name != null && initials != null) ...[
|
||||
const SizedBox(width: 10),
|
||||
_Avatar(initials: initials),
|
||||
if (showName) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
name,
|
||||
style: GoogleFonts.titilliumWeb(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BackButton extends StatelessWidget {
|
||||
const _BackButton({required this.onTap, required this.compact});
|
||||
|
||||
final VoidCallback onTap;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: "Retour",
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
child: Container(
|
||||
height: 34,
|
||||
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.arrow_back_rounded,
|
||||
size: 17,
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
),
|
||||
if (!compact) ...[
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
"Retour",
|
||||
style: GoogleFonts.titilliumWeb(
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Avatar extends StatelessWidget {
|
||||
const _Avatar({required this.initials});
|
||||
|
||||
final String initials;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: EnsupColors.purple,
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
initials,
|
||||
style: GoogleFonts.darkerGrotesque(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import "package:flutter/material.dart";
|
||||
import "package:google_fonts/google_fonts.dart";
|
||||
import "../theme/ensup_colors.dart";
|
||||
|
||||
/// État possible d'un élément de checklist (départ ou retour).
|
||||
enum EtatChecklist { present, absent, deteriore }
|
||||
|
||||
/// Case cliquable colorée selon l'état sélectionné (vert / rouge / jaune).
|
||||
class EtatBox extends StatelessWidget {
|
||||
const EtatBox({
|
||||
super.key,
|
||||
required this.type,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final EtatChecklist type;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var background = Colors.white;
|
||||
var border = EnsupColors.line;
|
||||
Widget? contenu;
|
||||
|
||||
if (selected) {
|
||||
switch (type) {
|
||||
case EtatChecklist.present:
|
||||
background = const Color(0xFFF0FDF4);
|
||||
border = const Color(0xFF86EFAC);
|
||||
contenu = const Icon(Icons.check, size: 16, color: Color(0xFF16A34A));
|
||||
case EtatChecklist.absent:
|
||||
background = const Color(0xFFFEF2F2);
|
||||
border = const Color(0xFFFCA5A5);
|
||||
contenu = const Icon(Icons.close, size: 16, color: EnsupColors.red);
|
||||
case EtatChecklist.deteriore:
|
||||
background = const Color(0xFFFFFBEB);
|
||||
border = const Color(0xFFFDE68A);
|
||||
contenu = const Icon(
|
||||
Icons.priority_high,
|
||||
size: 16,
|
||||
color: Color(0xFFD97706),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: border, width: 2),
|
||||
),
|
||||
child: contenu,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tableau de checklist : une ligne par accessoire avec les 3 états possibles.
|
||||
class ChecklistTableau extends StatelessWidget {
|
||||
const ChecklistTableau({
|
||||
super.key,
|
||||
required this.accessoires,
|
||||
required this.etats,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final List<String> accessoires;
|
||||
final List<EtatChecklist> etats;
|
||||
final void Function(int index, EtatChecklist etat) onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final columnWidth = constraints.maxWidth < 520 ? 58.0 : 72.0;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: EnsupColors.line, width: 1.5),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: EnsupColors.text.withValues(alpha: 0.035),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_entete(columnWidth),
|
||||
for (var i = 0; i < accessoires.length; i++)
|
||||
_ligne(i, columnWidth),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _entete(double columnWidth) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: EnsupColors.line, width: 2)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: _texteEntete("ÉLÉMENT")),
|
||||
_colonneEntete("PRÉSENT", columnWidth),
|
||||
_colonneEntete("ABSENT", columnWidth),
|
||||
_colonneEntete("DÉTÉRIORÉ", columnWidth),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _texteEntete(String label) {
|
||||
return Text(
|
||||
label,
|
||||
style: GoogleFonts.darkerGrotesque(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: EnsupColors.muted,
|
||||
letterSpacing: 0.6,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _colonneEntete(String label, double width) {
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: Center(
|
||||
child: FittedBox(fit: BoxFit.scaleDown, child: _texteEntete(label)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _ligne(int index, double columnWidth) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: EnsupColors.line)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
accessoires[index],
|
||||
style: GoogleFonts.titilliumWeb(
|
||||
fontSize: 14,
|
||||
color: EnsupColors.text2,
|
||||
),
|
||||
),
|
||||
),
|
||||
_case(index, EtatChecklist.present, columnWidth),
|
||||
_case(index, EtatChecklist.absent, columnWidth),
|
||||
_case(index, EtatChecklist.deteriore, columnWidth),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _case(int index, EtatChecklist type, double width) {
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: Center(
|
||||
child: EtatBox(
|
||||
type: type,
|
||||
selected: etats[index] == type,
|
||||
onTap: () => onChanged(index, type),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Encadré d'instructions / rappel à côté d'une checklist.
|
||||
class ChecklistInstructions extends StatelessWidget {
|
||||
const ChecklistInstructions({
|
||||
super.key,
|
||||
required this.titre,
|
||||
required this.texte,
|
||||
});
|
||||
|
||||
final String titre;
|
||||
final String texte;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: EnsupColors.blue2.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: EnsupColors.blue2.withValues(alpha: 0.15),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
titre.toUpperCase(),
|
||||
style: GoogleFonts.darkerGrotesque(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: EnsupColors.muted,
|
||||
letterSpacing: 0.6,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
texte,
|
||||
style: GoogleFonts.titilliumWeb(
|
||||
fontSize: 13,
|
||||
color: EnsupColors.text2,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Zone de commentaire optionnel sous une checklist.
|
||||
class ChecklistCommentaire extends StatelessWidget {
|
||||
const ChecklistCommentaire({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.hint = "Commentaire ou observation optionnelle...",
|
||||
});
|
||||
|
||||
final TextEditingController? controller;
|
||||
final String hint;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
maxLines: 4,
|
||||
style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.text),
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: GoogleFonts.titilliumWeb(
|
||||
fontSize: 13,
|
||||
color: EnsupColors.muted,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 12,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
borderSide: const BorderSide(color: EnsupColors.line, width: 1.5),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
borderSide: const BorderSide(color: EnsupColors.cyan, width: 1.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import "package:flutter/material.dart";
|
||||
import "package:google_fonts/google_fonts.dart";
|
||||
import "../theme/ensup_colors.dart";
|
||||
|
||||
/// Option d'identification de l'écran de connexion (QR ou mail ENSUP).
|
||||
class IdentificationOption extends StatefulWidget {
|
||||
const IdentificationOption({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.label,
|
||||
required this.description,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
final String label;
|
||||
final String description;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
State<IdentificationOption> createState() => _IdentificationOptionState();
|
||||
}
|
||||
|
||||
class _IdentificationOptionState extends State<IdentificationOption> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: _hovered ? widget.iconColor : EnsupColors.line,
|
||||
width: 1.5,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: widget.iconColor.withValues(
|
||||
alpha: _hovered ? 0.12 : 0.035,
|
||||
),
|
||||
blurRadius: _hovered ? 16 : 8,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 17),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.iconColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(widget.icon, color: widget.iconColor, size: 24),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.label,
|
||||
style: GoogleFonts.darkerGrotesque(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 18,
|
||||
height: 1.05,
|
||||
color: EnsupColors.text,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.description,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: GoogleFonts.titilliumWeb(
|
||||
fontSize: 12,
|
||||
color: EnsupColors.muted,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.iconColor.withValues(alpha: 0.08),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: widget.iconColor,
|
||||
size: 19,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import "package:flutter/material.dart";
|
||||
import "package:google_fonts/google_fonts.dart";
|
||||
import "../theme/ensup_colors.dart";
|
||||
import "../models/materiel_item.dart";
|
||||
import "status_pill.dart";
|
||||
|
||||
/// Ligne du catalogue matériel (fidèle au `.mat-card` de la maquette).
|
||||
class MaterielCard extends StatelessWidget {
|
||||
const MaterielCard({super.key, required this.item, this.onSelect});
|
||||
|
||||
final MaterielItem item;
|
||||
final VoidCallback? onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final disponible = item.disponible;
|
||||
final accent = disponible ? EnsupColors.cyan : EnsupColors.muted;
|
||||
|
||||
return Opacity(
|
||||
opacity: disponible ? 1 : 0.55,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: EnsupColors.line, width: 1.5),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: EnsupColors.text.withValues(alpha: 0.035),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final compact = constraints.maxWidth < 620;
|
||||
final information = Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 46,
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.09),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(item.icon, color: accent, size: 21),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.nom,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: GoogleFonts.darkerGrotesque(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 17,
|
||||
height: 1.05,
|
||||
color: EnsupColors.text,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
"${item.categorie} · Réf. ${item.reference}",
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: GoogleFonts.titilliumWeb(
|
||||
fontSize: 12,
|
||||
color: EnsupColors.muted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.location_on_outlined,
|
||||
size: 13,
|
||||
color: EnsupColors.muted,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.campus,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: GoogleFonts.titilliumWeb(
|
||||
fontSize: 11,
|
||||
color: EnsupColors.muted,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
final actions = Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
alignment: WrapAlignment.end,
|
||||
children: [
|
||||
StatusPill(disponible: disponible),
|
||||
if (disponible)
|
||||
FilledButton.icon(
|
||||
onPressed: onSelect,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: EnsupColors.cyan,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 15,
|
||||
vertical: 11,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.arrow_forward_rounded, size: 16),
|
||||
label: Text(
|
||||
"Sélectionner",
|
||||
style: GoogleFonts.darkerGrotesque(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
"Indisponible",
|
||||
style: GoogleFonts.darkerGrotesque(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 14,
|
||||
color: EnsupColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
information,
|
||||
const SizedBox(height: 14),
|
||||
Align(alignment: Alignment.centerRight, child: actions),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: information),
|
||||
const SizedBox(width: 16),
|
||||
actions,
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import "package:flutter/material.dart";
|
||||
import "package:google_fonts/google_fonts.dart";
|
||||
import "../theme/ensup_colors.dart";
|
||||
|
||||
/// Carte de profil de l'étudiant identifié (accueil).
|
||||
class ProfileCard extends StatelessWidget {
|
||||
const ProfileCard({
|
||||
super.key,
|
||||
required this.name,
|
||||
required this.subtitle,
|
||||
required this.initials,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String subtitle;
|
||||
final String initials;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: EnsupColors.cyan.withValues(alpha: 0.055),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: EnsupColors.cyan.withValues(alpha: 0.15),
|
||||
width: 1.5,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: EnsupColors.blue3.withValues(alpha: 0.045),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final showStatus = constraints.maxWidth >= 430;
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: EnsupColors.teal,
|
||||
border: Border.all(color: Colors.white, width: 3),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: EnsupColors.teal.withValues(alpha: 0.22),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
initials,
|
||||
style: GoogleFonts.darkerGrotesque(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: GoogleFonts.darkerGrotesque(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 20,
|
||||
height: 1,
|
||||
color: EnsupColors.text,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: GoogleFonts.titilliumWeb(
|
||||
fontSize: 12,
|
||||
color: EnsupColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showStatus) ...[
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 11,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: EnsupColors.green.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(
|
||||
color: EnsupColors.green.withValues(alpha: 0.18),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.check_circle,
|
||||
size: 13,
|
||||
color: EnsupColors.green,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
"Identifié",
|
||||
style: GoogleFonts.titilliumWeb(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: EnsupColors.green,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import "package:flutter/material.dart";
|
||||
import "package:google_fonts/google_fonts.dart";
|
||||
|
||||
/// Pastille de statut d'un matériel (disponible / emprunté).
|
||||
class StatusPill extends StatelessWidget {
|
||||
const StatusPill({super.key, required this.disponible});
|
||||
|
||||
final bool disponible;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final label = disponible ? "Disponible" : "Emprunté";
|
||||
final background = disponible ? const Color(0xFFDCFCE7) : const Color(0xFFFEF9C3);
|
||||
final foreground = disponible ? const Color(0xFF15803D) : const Color(0xFFA16207);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(999)),
|
||||
child: Text(
|
||||
label,
|
||||
style: GoogleFonts.darkerGrotesque(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: foreground,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Generated
+520
@@ -0,0 +1,520 @@
|
||||
{
|
||||
"name": "eme-frontend",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^5.17.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/msal-browser": {
|
||||
"version": "5.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.17.1.tgz",
|
||||
"integrity": "sha512-zBhRGzABKSI7hfWh5EaZmril5ybZ7imBN1qEZl5sDTaelr+l8SnPjZO50Q4dnKnm347YPIlBMSnXKZyh3Yu5DQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@azure/msal-common": "16.11.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/msal-common": {
|
||||
"version": "16.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.11.2.tgz",
|
||||
"integrity": "sha512-yDhtBOGDCdK9ipQ9g3+wmlMEPnZx2pXaDicDd9jYyR1L+7lEbvEohTDmF5qejZDutZY3m9pWPxeYxzNC701A2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "eme-frontend-auth",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:auth": "esbuild ./web/auth/msal_bridge.js --bundle --minify --format=iife --outfile=./web/msal_bridge.bundle.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^5.17.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.28.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cupertino_icons
|
||||
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.9"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
google_fonts:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_fonts
|
||||
sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.0"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni
|
||||
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
jni_flutter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni_flutter
|
||||
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_config
|
||||
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.6"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.1"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.1"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.2"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.2.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
sdks:
|
||||
dart: ">=3.10.0 <4.0.0"
|
||||
flutter: ">=3.38.0"
|
||||
@@ -0,0 +1,91 @@
|
||||
name: eme_frontend
|
||||
description: "A new Flutter project."
|
||||
# The following line prevents the package from being accidentally published to
|
||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
|
||||
# The following defines the version and build number for your application.
|
||||
# A version number is three numbers separated by dots, like 1.2.43
|
||||
# followed by an optional build number separated by a +.
|
||||
# Both the version and the builder number may be overridden in flutter
|
||||
# build by specifying --build-name and --build-number, respectively.
|
||||
# In Android, build-name is used as versionName while build-number used as versionCode.
|
||||
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
|
||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
|
||||
# Dependencies specify other packages that your package needs in order to work.
|
||||
# To automatically upgrade your package dependencies to the latest versions
|
||||
# consider running `flutter pub upgrade --major-versions`. Alternatively,
|
||||
# dependencies can be manually updated by changing the version numbers below to
|
||||
# the latest version available on pub.dev. To see which dependencies have newer
|
||||
# versions available, run `flutter pub outdated`.
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
google_fonts: ^8.1.0
|
||||
http: ^1.6.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
# activated in the `analysis_options.yaml` file located at the root of your
|
||||
# package. See that file for information about deactivating specific lint
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
|
||||
# The following line ensures that the Material Icons font is
|
||||
# included with your application, so that you can use the icons in
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
# For details regarding adding assets from package dependencies, see
|
||||
# https://flutter.dev/to/asset-from-package
|
||||
|
||||
# To add custom fonts to your application, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts from package dependencies,
|
||||
# see https://flutter.dev/to/font-from-package
|
||||
@@ -0,0 +1,11 @@
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:eme_frontend/main.dart";
|
||||
|
||||
void main() {
|
||||
testWidgets("L'écran de démarrage affiche l'identification", (tester) async {
|
||||
await tester.pumpWidget(const EmeApp());
|
||||
|
||||
expect(find.text("Bienvenue sur EME"), findsOneWidget);
|
||||
expect(find.text("Scanner ma carte étudiante"), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
InteractionRequiredAuthError,
|
||||
PublicClientApplication,
|
||||
} from "@azure/msal-browser";
|
||||
|
||||
let msalInstance = null;
|
||||
let apiScope = "";
|
||||
|
||||
function requireInstance() {
|
||||
if (!msalInstance) {
|
||||
throw new Error("MSAL n'est pas initialise.");
|
||||
}
|
||||
|
||||
return msalInstance;
|
||||
}
|
||||
|
||||
function activeAccount() {
|
||||
const instance = requireInstance();
|
||||
return instance.getActiveAccount() ?? instance.getAllAccounts()[0] ?? null;
|
||||
}
|
||||
|
||||
async function initialize(tenantId, clientId, scope, redirectUri) {
|
||||
if (msalInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tenantId || !clientId || !scope || !redirectUri) {
|
||||
throw new Error("La configuration Azure du frontend est incomplete.");
|
||||
}
|
||||
|
||||
apiScope = scope;
|
||||
msalInstance = await PublicClientApplication.createPublicClientApplication({
|
||||
auth: {
|
||||
clientId,
|
||||
authority: `https://login.microsoftonline.com/${tenantId}`,
|
||||
redirectUri,
|
||||
postLogoutRedirectUri: redirectUri,
|
||||
},
|
||||
cache: {
|
||||
cacheLocation: "sessionStorage",
|
||||
},
|
||||
});
|
||||
|
||||
const account = activeAccount();
|
||||
if (account) {
|
||||
msalInstance.setActiveAccount(account);
|
||||
}
|
||||
}
|
||||
|
||||
async function signIn() {
|
||||
const instance = requireInstance();
|
||||
const result = await instance.loginPopup({
|
||||
scopes: ["openid", "profile", "email", apiScope],
|
||||
});
|
||||
|
||||
instance.setActiveAccount(result.account);
|
||||
}
|
||||
|
||||
async function getAccessToken() {
|
||||
const instance = requireInstance();
|
||||
const account = activeAccount();
|
||||
if (!account) {
|
||||
throw new Error("Aucun compte Microsoft connecte.");
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await instance.acquireTokenSilent({
|
||||
account,
|
||||
scopes: [apiScope],
|
||||
});
|
||||
return result.accessToken;
|
||||
} catch (error) {
|
||||
if (!(error instanceof InteractionRequiredAuthError)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const result = await instance.acquireTokenPopup({
|
||||
account,
|
||||
scopes: [apiScope],
|
||||
});
|
||||
return result.accessToken;
|
||||
}
|
||||
}
|
||||
|
||||
function hasAccount() {
|
||||
return activeAccount() !== null;
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
const instance = requireInstance();
|
||||
const account = activeAccount();
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
await instance.logoutPopup({ account });
|
||||
}
|
||||
|
||||
globalThis.emeMsal = {
|
||||
getAccessToken,
|
||||
hasAccount,
|
||||
initialize,
|
||||
signIn,
|
||||
signOut,
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 917 B |
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -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>
|
||||
@@ -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
@@ -20,8 +20,72 @@ 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
|
||||
|
||||
@@ -45,26 +109,26 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
|
||||
- Defaults + timestamps auto ajoutés sur Campus, Utilisateur, CategorieMateriel (Role n'a pas de timestamps -> non modifié).
|
||||
- `@unique` sur `Role.code`, `Utilisateur.microsoftId`, `Utilisateur.email`.
|
||||
- `Utilisateur.classe` -> nullable : un `RESPONSABLE` n'a pas de classe (seuls les étudiants en ont).
|
||||
- ERD mis à jour en miroir (`UK`, `nullable`).
|
||||
- Ajout dans l'ERD des annotations `UK` (sur `code`, `microsoft_id`, `email`) et `nullable` (sur `classe`).
|
||||
- Choix laissé de côté : tailles `@db.NVarChar(n)` (colonnes restées en `NVARCHAR(1000)` par défaut). À reconsidérer plus tard.
|
||||
|
||||
### Étape 3 — Bloc A : Infrastructure (SallePret, PosteEmprunt) — commit `d026b75`
|
||||
- `SallePret` rattachée à `Campus` ; `PosteEmprunt` rattaché à `SallePret`. Conforme à l'ERD.
|
||||
- `SallePret` rattachée à `Campus` (FK `campus_id`) ; `PosteEmprunt` rattaché à `SallePret` (FK `salle_pret_id`).
|
||||
|
||||
### Étape 4 — Bloc B : CarteEtudiante — commit `dfa73cd`
|
||||
- Rattachée à `Utilisateur`.
|
||||
- Dette notée : `qr_code` devrait être `@unique` (sert à l'identification, RG02) — non posé pour rester fidèle à l'ERD.
|
||||
|
||||
### Étape 5 — Bloc C : Matériel (Materiel, Accessoire, MaterielAccessoire) — commit `0774ee3`
|
||||
- Table de liaison `MaterielAccessoire` (sans timestamps, conforme ERD).
|
||||
- Table de liaison `MaterielAccessoire` entre `Materiel` et `Accessoire`, sans colonnes `created_at`/`updated_at`.
|
||||
- Dette notée : `@@unique([materielId, accessoireId])` et `Materiel.statut @default("DISPONIBLE")`.
|
||||
|
||||
### Étape 6 — Bloc D : Transactions (Emprunt, Checklist, ChecklistElement) — commit `6908a2b`
|
||||
- Champs de retour/optionnels nullable : `dateRetourReelle`, `modeIdentificationRetour`, `commentaireDepart`, `commentaireRetour`, `Checklist.commentaire`, `ChecklistElement.commentaire`.
|
||||
- Pourquoi : RG12 crée l'emprunt au départ ; les infos de retour n'existent pas encore. En `NOT NULL`, la création serait impossible.
|
||||
- `ChecklistElement.accessoireId` nullable : conforme au diagramme de classes (`Accessoire "0..1"`), un élément peut être un libellé libre sans accessoire catalogué.
|
||||
- `ChecklistElement.accessoireId` rendu nullable : un élément de checklist peut être un libellé libre, sans accessoire catalogué associé (cardinalité 0..1).
|
||||
- Les 9 FK en `NoAction` : traçabilité + évitement des cascade paths.
|
||||
- ERD mis à jour en miroir (champs annotés `nullable`).
|
||||
- Annotation `nullable` ajoutée dans l'ERD sur les champs de retour/commentaires d'`Emprunt` et `accessoire_id`/`commentaire` de checklist.
|
||||
|
||||
### Étape 7 — Stratégie Git : branche `develop`
|
||||
- Création de `develop` depuis `master`. Désormais tous les commits vont sur `develop` ; `master` figé au Bloc C.
|
||||
@@ -75,8 +139,8 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
|
||||
- Double relation `Anomalie` vers `Utilisateur` nommée : `@relation("AnomalieEtudiant")` (étudiant concerné) et `@relation("AnomalieResponsable")` (responsable qui traite).
|
||||
- Defaults : `Anomalie.detecteeAutomatiquement @default(true)`, `Notification.lu @default(false)`, `Notification.dateCreation @default(now())`.
|
||||
- Les 12 FK en `NoAction` (traçabilité).
|
||||
- `Notification` et `Historique` sans `updatedAt` (conforme ERD).
|
||||
- ERD mis à jour en miroir.
|
||||
- `Notification` et `Historique` créées sans colonne `updatedAt` (seulement `date_creation`/`created_at`).
|
||||
- Annotation `nullable` ajoutée dans l'ERD sur `traitee_par_id`, `observation`, `date_resolution`, `anomalie_id` et les 5 FK optionnelles d'`Historique`.
|
||||
|
||||
### Étape 9 — Seeds de référence — commit `2c3a5db`
|
||||
- Fichier `prisma/seed.ts` autonome (adapter MSSQL, lecture directe des variables DB), lancé via `npm run seed` (script ajouté au `package.json`).
|
||||
@@ -106,18 +170,294 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
|
||||
- Express 5 propage nativement les erreurs des handlers async : pas de wrapper `asyncHandler` nécessaire.
|
||||
- Vérifié au runtime : `/health` 200, route inconnue 404 en JSON, requêtes journalisées.
|
||||
|
||||
### Étape 12 — Block 1 : ESLint + Prettier
|
||||
- ESLint 10 (flat config `eslint.config.mjs`) + typescript-eslint 8 ; Prettier 3 (`.prettierrc.json`).
|
||||
- Règles de qualité strictes encodées : `no-explicit-any`, `no-non-null-assertion`, `explicit-module-boundary-types`, `no-unused-vars` (ignore le préfixe `_`), `prefer-const`. `eslint-config-prettier` désactive les règles en conflit avec Prettier.
|
||||
- Prettier : quotes simples, point-virgule, 100 colonnes, 2 espaces, trailing commas.
|
||||
- Scripts : `lint`, `lint:fix`, `format`, `format:check`.
|
||||
- État : lint vert sur tout le code existant ; formatage appliqué (3 fichiers) ; build OK.
|
||||
|
||||
### Étape 13 — Correctif tooling : type-check des scripts Prisma
|
||||
- Problème : `prisma/seed.ts` et `fixtures.ts` étaient hors du `include` du tsconfig ; l'IDE ne chargeait pas les types Node (`process` non reconnu, 2 erreurs).
|
||||
- Correctif : `tsconfig.json` élargi à `src` + `prisma` en `noEmit` (IDE et `tsc --noEmit`) ; nouveau `tsconfig.build.json` dédié à la compilation `src` -> `dist` (`npm run build`).
|
||||
- Bénéfice : les scripts Prisma sont désormais type-checkés (angle mort comblé).
|
||||
|
||||
### Étape 14 — Block 4 : architecture en couches + 1er endpoint (catalogue matériel)
|
||||
- Mise en place de l'architecture en couches : `routes/` -> `controllers/` -> `services/` -> `repositories/` (un dossier par couche).
|
||||
- Auth simulée temporaire : middleware `current-user` qui résout l'utilisateur via l'en-tête `x-user-email` et pose `req.user` (typé via `types/authenticated-user.ts` + augmentation `Express.Request`). À remplacer par la validation JWT au Block 3.
|
||||
- Endpoint `GET /api/materiels` (catalogue) : applique RG10 (matériels `DISPONIBLE` du campus de l'étudiant), filtres `categorieId` et `q` (recherche nom/marque/modèle/référence).
|
||||
- Format de réponse standardisé : `{ "data": ... }` (cohérent avec `{ "error": ... }`).
|
||||
- Vérifié au runtime : 401 sans en-tête / utilisateur inconnu, 200 avec les disponibles du campus, filtres OK, 400 sur `categorieId` invalide.
|
||||
|
||||
### Étape 15 — Block 4 : endpoint "mes emprunts en cours"
|
||||
- Endpoint `GET /api/mes-emprunts` : retourne les emprunts non restitués (`EN_COURS`, `EN_RETARD`) de l'utilisateur courant, triés par date de retour prévue, avec le matériel et sa catégorie inclus.
|
||||
- RG15 : filtrage par `utilisateurId` (chacun ne voit que ses propres emprunts).
|
||||
- Réutilise l'architecture en couches et l'auth simulée déjà en place.
|
||||
- Vérifié au runtime : Marie 1 (`EN_COURS`, son `CLOTURE` exclu), Lucas 1 (`EN_RETARD`), Sofia 0 (son `RETOUR_NON_CONFORME` exclu), 401 sans en-tête.
|
||||
|
||||
### Étape 16 — Block 4 : profil et identification
|
||||
- `GET /api/auth/me` (protégé) : renvoie le profil complet de l'utilisateur courant (role + campus).
|
||||
- `GET /api/auth/carte/:qr` (public) : identification par QR code (RG02/RG03) ; contrôles carte active, non expirée et compte actif ; renvoie le profil.
|
||||
- Refactor du routage : `currentUser` appliqué par sous-routeur (au lieu d'un middleware global) pour laisser la route d'identification publique.
|
||||
- Vérifié au runtime : `/me` 200 (profil Marie) et 401 sans en-tête ; `/carte` QR valide 200 sans en-tête (route publique), QR inconnu 404.
|
||||
|
||||
### Étape 17 — DTO de sortie sur les endpoints existants
|
||||
- Dossier `src/dtos/` : par ressource, une interface `XxxResponse` + un mapper pur `toXxxResponse` (utilisateur, matériel, emprunt).
|
||||
- Mapping effectué dans le controller (couche présentation) ; les services continuent de renvoyer les entités (réutilisables).
|
||||
- Champs internes désormais masqués : `microsoftId`, timestamps, FK brutes, `numeroInventaire`/`numeroSerie`.
|
||||
- Vérifié au runtime sur `/me`, `/materiels`, `/mes-emprunts`.
|
||||
|
||||
### Étape 18 — Durcissement du .gitignore (sécurité)
|
||||
- Trou comblé : `**/.env` ne couvrait pas les variantes (`.env.local`, `.env.production`...). Ajout de `**/.env.*` avec exception `!**/.env.example`.
|
||||
- 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. Block 1 : middleware d'erreurs global, logs, ESLint + Prettier, Swagger/OpenAPI
|
||||
| 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-15 — Middlewares backend (gestion d'erreurs, logs) en place.*
|
||||
### É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.*
|
||||
|
||||
Reference in New Issue
Block a user