Compare commits
37 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 |
@@ -9,5 +9,9 @@ DB_NAME=eme_db
|
|||||||
DB_USER=sa
|
DB_USER=sa
|
||||||
DB_PASSWORD=YourStrongPassword123!
|
DB_PASSWORD=YourStrongPassword123!
|
||||||
|
|
||||||
|
AUTH_MODE=demo
|
||||||
AZURE_TENANT_ID=
|
AZURE_TENANT_ID=
|
||||||
AZURE_CLIENT_ID=
|
AZURE_API_CLIENT_ID=
|
||||||
|
AZURE_API_AUDIENCE=
|
||||||
|
AZURE_API_SCOPE=access_as_user
|
||||||
|
AZURE_ALLOWED_EMAIL_DOMAINS=ensup.eu,ensitech.eu
|
||||||
|
|||||||
Generated
+58
-3
@@ -14,7 +14,8 @@
|
|||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"jsonwebtoken": "^9.0.3"
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"jwks-rsa": "^4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
@@ -1068,7 +1069,6 @@
|
|||||||
"version": "9.0.10",
|
"version": "9.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||||
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/ms": "*",
|
"@types/ms": "*",
|
||||||
@@ -1079,7 +1079,6 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
@@ -3074,6 +3073,15 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz",
|
||||||
|
"integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-md4": {
|
"node_modules/js-md4": {
|
||||||
"version": "0.3.2",
|
"version": "0.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz",
|
||||||
@@ -3134,6 +3142,23 @@
|
|||||||
"safe-buffer": "^5.0.1"
|
"safe-buffer": "^5.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jwks-rsa": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-sbkByqyATKYJP5F4RXj03N5TUNC0QLTjCAZvwTzC4BwJZ8e0/cWxN8YROnyUth2g1/ONWi4eSFHeu6oYalrc3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/jsonwebtoken": "^9.0.4",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"jose": "^6.1.3",
|
||||||
|
"limiter": "^1.1.5",
|
||||||
|
"lru-cache": "^11.0.0",
|
||||||
|
"lru-memoizer": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >= 23.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jws": {
|
"node_modules/jws": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
@@ -3168,6 +3193,11 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/limiter": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
|
||||||
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
@@ -3184,6 +3214,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.clonedeep": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.includes": {
|
"node_modules/lodash.includes": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
@@ -3233,6 +3269,25 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/lru-cache": {
|
||||||
|
"version": "11.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||||
|
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lru-memoizer": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash.clonedeep": "^4.5.0",
|
||||||
|
"lru-cache": "^11.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lru.min": {
|
"node_modules/lru.min": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"jsonwebtoken": "^9.0.3"
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"jwks-rsa": "^4.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import dotenv from 'dotenv';
|
|||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
|
type AuthMode = 'demo' | 'azure';
|
||||||
|
|
||||||
function required(name: string): string {
|
function required(name: string): string {
|
||||||
const value = process.env[name];
|
const value = process.env[name];
|
||||||
|
|
||||||
@@ -28,9 +30,50 @@ function numberFromEnv(name: string, defaultValue: number): number {
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function authModeFromEnv(nodeEnv: string): AuthMode {
|
||||||
|
const value = process.env.AUTH_MODE || (nodeEnv === 'production' ? 'azure' : 'demo');
|
||||||
|
|
||||||
|
if (value !== 'demo' && value !== 'azure') {
|
||||||
|
throw new Error('Variable d’environnement invalide: AUTH_MODE');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodeEnv === 'production' && value === 'demo') {
|
||||||
|
throw new Error('AUTH_MODE=demo est interdit en production');
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function azureValue(name: string, authMode: AuthMode): string {
|
||||||
|
const value = process.env[name];
|
||||||
|
|
||||||
|
if (authMode === 'azure' && !value) {
|
||||||
|
throw new Error(`Variable d'environnement manquante en mode Azure: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function listFromEnv(name: string, defaultValue: string[]): string[] {
|
||||||
|
const value = process.env[name];
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim().toLowerCase())
|
||||||
|
.filter((item) => item.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeEnv = process.env.NODE_ENV || 'development';
|
||||||
|
const authMode = authModeFromEnv(nodeEnv);
|
||||||
|
const azureApiClientId = azureValue('AZURE_API_CLIENT_ID', authMode);
|
||||||
|
|
||||||
export const env = {
|
export const env = {
|
||||||
port: numberFromEnv('PORT', 3000),
|
port: numberFromEnv('PORT', 3000),
|
||||||
nodeEnv: process.env.NODE_ENV || 'development',
|
nodeEnv,
|
||||||
frontendUrl: required('FRONTEND_URL'),
|
frontendUrl: required('FRONTEND_URL'),
|
||||||
databaseUrl: required('DATABASE_URL'),
|
databaseUrl: required('DATABASE_URL'),
|
||||||
db: {
|
db: {
|
||||||
@@ -40,8 +83,17 @@ export const env = {
|
|||||||
user: required('DB_USER'),
|
user: required('DB_USER'),
|
||||||
password: required('DB_PASSWORD'),
|
password: required('DB_PASSWORD'),
|
||||||
},
|
},
|
||||||
|
auth: {
|
||||||
|
mode: authMode,
|
||||||
azure: {
|
azure: {
|
||||||
tenantId: required('AZURE_TENANT_ID'),
|
tenantId: azureValue('AZURE_TENANT_ID', authMode),
|
||||||
clientId: required('AZURE_CLIENT_ID'),
|
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,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,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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,28 +1,10 @@
|
|||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import { AppError } from '../errors/app-error';
|
import { authenticateUser } from '../services/authentication.service';
|
||||||
import { findByEmail } from '../repositories/utilisateur.repository';
|
|
||||||
import { AuthenticatedUser } from '../types/authenticated-user';
|
|
||||||
|
|
||||||
/* Auth simulée (temporaire) : en attendant Azure AD, l'utilisateur courant est
|
|
||||||
résolu depuis l'en-tête `x-user-email`. À remplacer par la validation du JWT. */
|
|
||||||
export async function currentUser(req: Request, _res: Response, next: NextFunction): Promise<void> {
|
export async function currentUser(req: Request, _res: Response, next: NextFunction): Promise<void> {
|
||||||
const email = req.header('x-user-email');
|
req.user = await authenticateUser({
|
||||||
if (!email) {
|
authorization: req.header('authorization'),
|
||||||
throw new AppError(401, 'En-tete x-user-email requis (auth simulee)');
|
demoEmail: req.header('x-user-email'),
|
||||||
}
|
});
|
||||||
|
|
||||||
const utilisateur = await findByEmail(email);
|
|
||||||
if (!utilisateur || !utilisateur.actif) {
|
|
||||||
throw new AppError(401, 'Utilisateur non reconnu');
|
|
||||||
}
|
|
||||||
|
|
||||||
const authenticated: AuthenticatedUser = {
|
|
||||||
id: utilisateur.id,
|
|
||||||
email: utilisateur.email,
|
|
||||||
roleCode: utilisateur.role.code,
|
|
||||||
campusId: utilisateur.campusId,
|
|
||||||
};
|
|
||||||
|
|
||||||
req.user = authenticated;
|
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { prisma } from '../db/prisma';
|
||||||
|
|
||||||
|
export interface StatutCount {
|
||||||
|
statut: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActiviteResponsable = Prisma.HistoriqueGetPayload<{
|
||||||
|
include: {
|
||||||
|
utilisateur: true;
|
||||||
|
materiel: true;
|
||||||
|
emprunt: true;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type EmpruntResponsable = Prisma.EmpruntGetPayload<{
|
||||||
|
include: {
|
||||||
|
utilisateur: true;
|
||||||
|
materiel: { include: { categorie: true } };
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type MaterielResponsable = Prisma.MaterielGetPayload<{
|
||||||
|
include: {
|
||||||
|
categorie: true;
|
||||||
|
accessoires: { include: { accessoire: true } };
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type AnomalieResponsable = Prisma.AnomalieGetPayload<{
|
||||||
|
include: {
|
||||||
|
emprunt: true;
|
||||||
|
etudiant: true;
|
||||||
|
materiel: { include: { categorie: true } };
|
||||||
|
traitePar: true;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type NotificationResponsable = Prisma.NotificationGetPayload<{
|
||||||
|
include: {
|
||||||
|
anomalie: {
|
||||||
|
include: {
|
||||||
|
emprunt: true;
|
||||||
|
materiel: true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type HistoriqueResponsable = Prisma.HistoriqueGetPayload<{
|
||||||
|
include: {
|
||||||
|
utilisateur: true;
|
||||||
|
materiel: true;
|
||||||
|
emprunt: true;
|
||||||
|
sallePret: true;
|
||||||
|
posteEmprunt: true;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export interface EmpruntResponsableFiltres {
|
||||||
|
statut?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaterielResponsableFiltres {
|
||||||
|
statut?: string;
|
||||||
|
categorieId?: number;
|
||||||
|
recherche?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnomalieResponsableFiltres {
|
||||||
|
statut?: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationResponsableFiltres {
|
||||||
|
lu?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoriqueResponsableFiltres {
|
||||||
|
action?: string;
|
||||||
|
utilisateurId?: number;
|
||||||
|
materielId?: number;
|
||||||
|
empruntId?: number;
|
||||||
|
dateDebut?: Date;
|
||||||
|
dateFin?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChangerStatutAnomalieData {
|
||||||
|
anomalieId: number;
|
||||||
|
campusId: number;
|
||||||
|
responsableId: number;
|
||||||
|
statut: string;
|
||||||
|
observation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStatutCounts(rows: Array<{ statut: string; _count: { _all: number } }>): StatutCount[] {
|
||||||
|
return rows.map((row) => ({ statut: row.statut, count: row._count._all }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countMaterielsParStatut(campusId: number): Promise<StatutCount[]> {
|
||||||
|
const rows = await prisma.materiel.groupBy({
|
||||||
|
by: ['statut'],
|
||||||
|
where: { campusId, actif: true },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapStatutCounts(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countEmpruntsParStatut(campusId: number): Promise<StatutCount[]> {
|
||||||
|
const rows = await prisma.emprunt.groupBy({
|
||||||
|
by: ['statut'],
|
||||||
|
where: { campusId },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapStatutCounts(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countAnomaliesParStatut(campusId: number): Promise<StatutCount[]> {
|
||||||
|
const rows = await prisma.anomalie.groupBy({
|
||||||
|
by: ['statut'],
|
||||||
|
where: { emprunt: { campusId } },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapStatutCounts(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countNotificationsNonLues(utilisateurId: number): Promise<number> {
|
||||||
|
return prisma.notification.count({
|
||||||
|
where: { utilisateurId, lu: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findActiviteRecente(
|
||||||
|
campusId: number,
|
||||||
|
limite: number,
|
||||||
|
): Promise<ActiviteResponsable[]> {
|
||||||
|
return prisma.historique.findMany({
|
||||||
|
where: { campusId },
|
||||||
|
include: {
|
||||||
|
utilisateur: true,
|
||||||
|
materiel: true,
|
||||||
|
emprunt: true,
|
||||||
|
},
|
||||||
|
orderBy: { dateAction: 'desc' },
|
||||||
|
take: limite,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findEmpruntsResponsable(
|
||||||
|
campusId: number,
|
||||||
|
filtres: EmpruntResponsableFiltres,
|
||||||
|
): Promise<EmpruntResponsable[]> {
|
||||||
|
return prisma.emprunt.findMany({
|
||||||
|
where: {
|
||||||
|
campusId,
|
||||||
|
...(filtres.statut ? { statut: filtres.statut } : {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
utilisateur: true,
|
||||||
|
materiel: { include: { categorie: true } },
|
||||||
|
},
|
||||||
|
orderBy: { dateEmprunt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findMaterielsResponsable(
|
||||||
|
campusId: number,
|
||||||
|
filtres: MaterielResponsableFiltres,
|
||||||
|
): Promise<MaterielResponsable[]> {
|
||||||
|
return prisma.materiel.findMany({
|
||||||
|
where: {
|
||||||
|
campusId,
|
||||||
|
actif: true,
|
||||||
|
...(filtres.statut ? { statut: filtres.statut } : {}),
|
||||||
|
...(filtres.categorieId !== undefined ? { categorieId: filtres.categorieId } : {}),
|
||||||
|
...(filtres.recherche
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ nom: { contains: filtres.recherche } },
|
||||||
|
{ marque: { contains: filtres.recherche } },
|
||||||
|
{ modele: { contains: filtres.recherche } },
|
||||||
|
{ reference: { contains: filtres.recherche } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
categorie: true,
|
||||||
|
accessoires: { include: { accessoire: true } },
|
||||||
|
},
|
||||||
|
orderBy: { nom: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findAnomaliesResponsable(
|
||||||
|
campusId: number,
|
||||||
|
filtres: AnomalieResponsableFiltres,
|
||||||
|
): Promise<AnomalieResponsable[]> {
|
||||||
|
return prisma.anomalie.findMany({
|
||||||
|
where: {
|
||||||
|
emprunt: { campusId },
|
||||||
|
...(filtres.statut ? { statut: filtres.statut } : {}),
|
||||||
|
...(filtres.type ? { type: filtres.type } : {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
emprunt: true,
|
||||||
|
etudiant: true,
|
||||||
|
materiel: { include: { categorie: true } },
|
||||||
|
traitePar: true,
|
||||||
|
},
|
||||||
|
orderBy: { dateDetection: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findAnomalieResponsableById(
|
||||||
|
campusId: number,
|
||||||
|
anomalieId: number,
|
||||||
|
): Promise<AnomalieResponsable | null> {
|
||||||
|
return prisma.anomalie.findFirst({
|
||||||
|
where: {
|
||||||
|
id: anomalieId,
|
||||||
|
emprunt: { campusId },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
emprunt: true,
|
||||||
|
etudiant: true,
|
||||||
|
materiel: { include: { categorie: true } },
|
||||||
|
traitePar: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changerStatutAnomalieResponsable(
|
||||||
|
data: ChangerStatutAnomalieData,
|
||||||
|
): Promise<AnomalieResponsable> {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const anomalie = await tx.anomalie.update({
|
||||||
|
where: { id: data.anomalieId },
|
||||||
|
data: {
|
||||||
|
statut: data.statut,
|
||||||
|
traiteeParId: data.responsableId,
|
||||||
|
...(data.observation !== undefined ? { observation: data.observation } : {}),
|
||||||
|
...(data.statut === 'RESOLUE' || data.statut === 'CLOTUREE'
|
||||||
|
? { dateResolution: new Date() }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
emprunt: true,
|
||||||
|
etudiant: true,
|
||||||
|
materiel: { include: { categorie: true } },
|
||||||
|
traitePar: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.historique.create({
|
||||||
|
data: {
|
||||||
|
utilisateurId: data.responsableId,
|
||||||
|
empruntId: anomalie.empruntId,
|
||||||
|
materielId: anomalie.materielId,
|
||||||
|
campusId: data.campusId,
|
||||||
|
action: 'TRAITEMENT_ANOMALIE',
|
||||||
|
description: `Anomalie #${anomalie.id} passee au statut ${data.statut}`,
|
||||||
|
dateAction: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return anomalie;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findNotificationsResponsable(
|
||||||
|
utilisateurId: number,
|
||||||
|
filtres: NotificationResponsableFiltres,
|
||||||
|
): Promise<NotificationResponsable[]> {
|
||||||
|
return prisma.notification.findMany({
|
||||||
|
where: {
|
||||||
|
utilisateurId,
|
||||||
|
...(filtres.lu !== undefined ? { lu: filtres.lu } : {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
anomalie: {
|
||||||
|
include: {
|
||||||
|
emprunt: true,
|
||||||
|
materiel: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { dateCreation: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function marquerNotificationLue(
|
||||||
|
utilisateurId: number,
|
||||||
|
notificationId: number,
|
||||||
|
): Promise<NotificationResponsable> {
|
||||||
|
return prisma.notification.update({
|
||||||
|
where: {
|
||||||
|
id: notificationId,
|
||||||
|
utilisateurId,
|
||||||
|
},
|
||||||
|
data: { lu: true },
|
||||||
|
include: {
|
||||||
|
anomalie: {
|
||||||
|
include: {
|
||||||
|
emprunt: true,
|
||||||
|
materiel: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function marquerNotificationsResponsableLues(utilisateurId: number): Promise<number> {
|
||||||
|
const result = await prisma.notification.updateMany({
|
||||||
|
where: { utilisateurId, lu: false },
|
||||||
|
data: { lu: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findHistoriqueResponsable(
|
||||||
|
campusId: number,
|
||||||
|
filtres: HistoriqueResponsableFiltres,
|
||||||
|
): Promise<HistoriqueResponsable[]> {
|
||||||
|
return prisma.historique.findMany({
|
||||||
|
where: {
|
||||||
|
campusId,
|
||||||
|
...(filtres.action ? { action: filtres.action } : {}),
|
||||||
|
...(filtres.utilisateurId !== undefined ? { utilisateurId: filtres.utilisateurId } : {}),
|
||||||
|
...(filtres.materielId !== undefined ? { materielId: filtres.materielId } : {}),
|
||||||
|
...(filtres.empruntId !== undefined ? { empruntId: filtres.empruntId } : {}),
|
||||||
|
...(filtres.dateDebut || filtres.dateFin
|
||||||
|
? {
|
||||||
|
dateAction: {
|
||||||
|
...(filtres.dateDebut ? { gte: filtres.dateDebut } : {}),
|
||||||
|
...(filtres.dateFin ? { lte: filtres.dateFin } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
utilisateur: true,
|
||||||
|
materiel: true,
|
||||||
|
emprunt: true,
|
||||||
|
sallePret: true,
|
||||||
|
posteEmprunt: true,
|
||||||
|
},
|
||||||
|
orderBy: { dateAction: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -14,6 +14,13 @@ export function findByEmail(email: string): Promise<UtilisateurAvecRole | null>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findByMicrosoftId(microsoftId: string): Promise<UtilisateurAvecRole | null> {
|
||||||
|
return prisma.utilisateur.findUnique({
|
||||||
|
where: { microsoftId },
|
||||||
|
include: { role: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function findById(id: number): Promise<UtilisateurProfil | null> {
|
export function findById(id: number): Promise<UtilisateurProfil | null> {
|
||||||
return prisma.utilisateur.findUnique({
|
return prisma.utilisateur.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { currentUser } from '../middlewares/current-user';
|
|||||||
import { authRoutes } from './auth.routes';
|
import { authRoutes } from './auth.routes';
|
||||||
import { materielRoutes } from './materiel.routes';
|
import { materielRoutes } from './materiel.routes';
|
||||||
import { empruntRoutes, mesEmpruntsRoutes } from './emprunt.routes';
|
import { empruntRoutes, mesEmpruntsRoutes } from './emprunt.routes';
|
||||||
|
import { responsableRoutes } from './responsable.routes';
|
||||||
|
|
||||||
export const apiRouter: Router = Router();
|
export const apiRouter: Router = Router();
|
||||||
|
|
||||||
@@ -13,3 +14,4 @@ apiRouter.use('/auth', authRoutes);
|
|||||||
apiRouter.use('/materiels', currentUser, materielRoutes);
|
apiRouter.use('/materiels', currentUser, materielRoutes);
|
||||||
apiRouter.use('/emprunts', currentUser, empruntRoutes);
|
apiRouter.use('/emprunts', currentUser, empruntRoutes);
|
||||||
apiRouter.use('/mes-emprunts', currentUser, mesEmpruntsRoutes);
|
apiRouter.use('/mes-emprunts', currentUser, mesEmpruntsRoutes);
|
||||||
|
apiRouter.use('/responsable', currentUser, responsableRoutes);
|
||||||
|
|||||||
@@ -0,0 +1,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,321 @@
|
|||||||
|
import { AppError } from '../errors/app-error';
|
||||||
|
import {
|
||||||
|
changerStatutAnomalieResponsable,
|
||||||
|
countAnomaliesParStatut,
|
||||||
|
countEmpruntsParStatut,
|
||||||
|
countMaterielsParStatut,
|
||||||
|
countNotificationsNonLues,
|
||||||
|
findActiviteRecente,
|
||||||
|
findAnomalieResponsableById,
|
||||||
|
findAnomaliesResponsable,
|
||||||
|
findEmpruntsResponsable,
|
||||||
|
findHistoriqueResponsable,
|
||||||
|
findMaterielsResponsable,
|
||||||
|
findNotificationsResponsable,
|
||||||
|
marquerNotificationLue,
|
||||||
|
marquerNotificationsResponsableLues,
|
||||||
|
StatutCount,
|
||||||
|
ActiviteResponsable,
|
||||||
|
AnomalieResponsable,
|
||||||
|
EmpruntResponsable,
|
||||||
|
HistoriqueResponsable,
|
||||||
|
MaterielResponsable,
|
||||||
|
NotificationResponsable,
|
||||||
|
} from '../repositories/responsable.repository';
|
||||||
|
import {
|
||||||
|
STATUT_ANOMALIE,
|
||||||
|
STATUT_EMPRUNT,
|
||||||
|
STATUT_MATERIEL,
|
||||||
|
StatutAnomalie,
|
||||||
|
StatutEmprunt,
|
||||||
|
StatutMateriel,
|
||||||
|
} from '../models/enums';
|
||||||
|
|
||||||
|
export interface DashboardResponsableData {
|
||||||
|
materiels: StatutCount[];
|
||||||
|
emprunts: StatutCount[];
|
||||||
|
anomalies: StatutCount[];
|
||||||
|
notificationsNonLues: number;
|
||||||
|
activiteRecente: ActiviteResponsable[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListerEmpruntsResponsableFiltres {
|
||||||
|
statut?: StatutEmprunt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListerMaterielsResponsableFiltres {
|
||||||
|
statut?: StatutMateriel;
|
||||||
|
categorieId?: number;
|
||||||
|
recherche?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListerAnomaliesResponsableFiltres {
|
||||||
|
statut?: StatutAnomalie;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListerNotificationsResponsableFiltres {
|
||||||
|
lu?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListerHistoriqueResponsableFiltres {
|
||||||
|
action?: string;
|
||||||
|
utilisateurId?: number;
|
||||||
|
materielId?: number;
|
||||||
|
empruntId?: number;
|
||||||
|
dateDebut?: Date;
|
||||||
|
dateFin?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChangerStatutAnomalieResponsableRequest {
|
||||||
|
statut: StatutAnomalie;
|
||||||
|
observation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRANSITIONS_ANOMALIE: Record<StatutAnomalie, StatutAnomalie[]> = {
|
||||||
|
DETECTEE: ['EN_COURS_TRAITEMENT'],
|
||||||
|
EN_COURS_TRAITEMENT: ['RESOLUE'],
|
||||||
|
RESOLUE: ['CLOTUREE'],
|
||||||
|
CLOTUREE: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function verifierResponsable(roleCode: string): void {
|
||||||
|
if (roleCode !== 'RESPONSABLE') {
|
||||||
|
throw new AppError(403, 'Acces reserve au responsable materiel');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDashboardResponsable(
|
||||||
|
utilisateurId: number,
|
||||||
|
roleCode: string,
|
||||||
|
campusId: number,
|
||||||
|
): Promise<DashboardResponsableData> {
|
||||||
|
verifierResponsable(roleCode);
|
||||||
|
|
||||||
|
const [materiels, emprunts, anomalies, notificationsNonLues, activiteRecente] = await Promise.all(
|
||||||
|
[
|
||||||
|
countMaterielsParStatut(campusId),
|
||||||
|
countEmpruntsParStatut(campusId),
|
||||||
|
countAnomaliesParStatut(campusId),
|
||||||
|
countNotificationsNonLues(utilisateurId),
|
||||||
|
findActiviteRecente(campusId, 10),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
materiels,
|
||||||
|
emprunts,
|
||||||
|
anomalies,
|
||||||
|
notificationsNonLues,
|
||||||
|
activiteRecente,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseStatutEmprunt(value: unknown): StatutEmprunt | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string' || !(STATUT_EMPRUNT as readonly string[]).includes(value)) {
|
||||||
|
throw new AppError(400, 'statut invalide');
|
||||||
|
}
|
||||||
|
return value as StatutEmprunt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseStatutMateriel(value: unknown): StatutMateriel | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string' || !(STATUT_MATERIEL as readonly string[]).includes(value)) {
|
||||||
|
throw new AppError(400, 'statut invalide');
|
||||||
|
}
|
||||||
|
return value as StatutMateriel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseStatutAnomalie(value: unknown): StatutAnomalie | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string' || !(STATUT_ANOMALIE as readonly string[]).includes(value)) {
|
||||||
|
throw new AppError(400, 'statut invalide');
|
||||||
|
}
|
||||||
|
return value as StatutAnomalie;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCategorieId(value: unknown): number | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||||
|
throw new AppError(400, 'categorieId invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseNotificationId(value: unknown): number {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new AppError(400, 'notificationId invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||||
|
throw new AppError(400, 'notificationId invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAnomalieId(value: unknown): number {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new AppError(400, 'anomalieId invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||||
|
throw new AppError(400, 'anomalieId invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePositiveIntQuery(value: unknown, champ: string): number | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new AppError(400, `${champ} invalide`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||||
|
throw new AppError(400, `${champ} invalide`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDateQuery(value: unknown, champ: string): Date | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new AppError(400, `${champ} invalide`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
throw new AppError(400, `${champ} invalide`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLu(value: unknown): boolean | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (value === 'true') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (value === 'false') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw new AppError(400, 'lu invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listerEmpruntsResponsable(
|
||||||
|
roleCode: string,
|
||||||
|
campusId: number,
|
||||||
|
filtres: ListerEmpruntsResponsableFiltres,
|
||||||
|
): Promise<EmpruntResponsable[]> {
|
||||||
|
verifierResponsable(roleCode);
|
||||||
|
return findEmpruntsResponsable(campusId, filtres);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listerMaterielsResponsable(
|
||||||
|
roleCode: string,
|
||||||
|
campusId: number,
|
||||||
|
filtres: ListerMaterielsResponsableFiltres,
|
||||||
|
): Promise<MaterielResponsable[]> {
|
||||||
|
verifierResponsable(roleCode);
|
||||||
|
return findMaterielsResponsable(campusId, filtres);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listerAnomaliesResponsable(
|
||||||
|
roleCode: string,
|
||||||
|
campusId: number,
|
||||||
|
filtres: ListerAnomaliesResponsableFiltres,
|
||||||
|
): Promise<AnomalieResponsable[]> {
|
||||||
|
verifierResponsable(roleCode);
|
||||||
|
return findAnomaliesResponsable(campusId, filtres);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listerNotificationsResponsable(
|
||||||
|
utilisateurId: number,
|
||||||
|
roleCode: string,
|
||||||
|
filtres: ListerNotificationsResponsableFiltres,
|
||||||
|
): Promise<NotificationResponsable[]> {
|
||||||
|
verifierResponsable(roleCode);
|
||||||
|
return findNotificationsResponsable(utilisateurId, filtres);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function marquerNotificationResponsableLue(
|
||||||
|
utilisateurId: number,
|
||||||
|
roleCode: string,
|
||||||
|
notificationId: number,
|
||||||
|
): Promise<NotificationResponsable> {
|
||||||
|
verifierResponsable(roleCode);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await marquerNotificationLue(utilisateurId, notificationId);
|
||||||
|
} catch {
|
||||||
|
throw new AppError(404, 'Notification introuvable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function marquerToutesNotificationsResponsableLues(
|
||||||
|
utilisateurId: number,
|
||||||
|
roleCode: string,
|
||||||
|
): Promise<number> {
|
||||||
|
verifierResponsable(roleCode);
|
||||||
|
return marquerNotificationsResponsableLues(utilisateurId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listerHistoriqueResponsable(
|
||||||
|
roleCode: string,
|
||||||
|
campusId: number,
|
||||||
|
filtres: ListerHistoriqueResponsableFiltres,
|
||||||
|
): Promise<HistoriqueResponsable[]> {
|
||||||
|
verifierResponsable(roleCode);
|
||||||
|
return findHistoriqueResponsable(campusId, filtres);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changerStatutAnomalieResponsableService(
|
||||||
|
responsableId: number,
|
||||||
|
roleCode: string,
|
||||||
|
campusId: number,
|
||||||
|
anomalieId: number,
|
||||||
|
data: ChangerStatutAnomalieResponsableRequest,
|
||||||
|
): Promise<AnomalieResponsable> {
|
||||||
|
verifierResponsable(roleCode);
|
||||||
|
|
||||||
|
const anomalie = await findAnomalieResponsableById(campusId, anomalieId);
|
||||||
|
if (!anomalie) {
|
||||||
|
throw new AppError(404, 'Anomalie introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const statutActuel = anomalie.statut as StatutAnomalie;
|
||||||
|
if (!TRANSITIONS_ANOMALIE[statutActuel]?.includes(data.statut)) {
|
||||||
|
throw new AppError(400, `Transition invalide depuis ${anomalie.statut} vers ${data.statut}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return changerStatutAnomalieResponsable({
|
||||||
|
anomalieId,
|
||||||
|
campusId,
|
||||||
|
responsableId,
|
||||||
|
statut: data.statut,
|
||||||
|
observation: data.observation,
|
||||||
|
});
|
||||||
|
}
|
||||||
+485
@@ -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
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
/// Identité simulée utilisée tant que l'authentification Azure AD n'est pas branchée.
|
import "services/auth_service.dart";
|
||||||
|
|
||||||
|
/// Identité de secours utilisée lorsque aucun profil de session n'est chargé.
|
||||||
class DemoIdentity {
|
class DemoIdentity {
|
||||||
DemoIdentity._();
|
DemoIdentity._();
|
||||||
|
|
||||||
static const userName = "Lucas Martin";
|
static String get userName =>
|
||||||
static const userInitials = "LM";
|
AuthService.currentProfile?.fullName ?? "Lucas Martin";
|
||||||
static const userSubtitle = "Étudiant · B2 Informatique · Ensitech Cergy";
|
static String get userInitials =>
|
||||||
static const campusTag = "Saint-Christophe · Cergy";
|
AuthService.currentProfile?.initials ?? "LM";
|
||||||
|
static String get userSubtitle =>
|
||||||
|
AuthService.currentProfile?.subtitle ??
|
||||||
|
"Étudiant · B2 Informatique · Ensitech Cergy";
|
||||||
|
static String get campusTag =>
|
||||||
|
AuthService.currentProfile?.campusTag ?? "Saint-Christophe · Cergy";
|
||||||
|
|
||||||
static const loginCampusTag = "Ensitech · Cergy";
|
static const loginCampusTag = "Ensitech · Cergy";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
class AuthenticatedProfile {
|
||||||
|
const AuthenticatedProfile({
|
||||||
|
required this.id,
|
||||||
|
required this.nom,
|
||||||
|
required this.prenom,
|
||||||
|
required this.email,
|
||||||
|
required this.classe,
|
||||||
|
required this.roleCode,
|
||||||
|
required this.roleLabel,
|
||||||
|
required this.campusId,
|
||||||
|
required this.campusNom,
|
||||||
|
required this.campusVille,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String nom;
|
||||||
|
final String prenom;
|
||||||
|
final String email;
|
||||||
|
final String? classe;
|
||||||
|
final String roleCode;
|
||||||
|
final String roleLabel;
|
||||||
|
final int campusId;
|
||||||
|
final String campusNom;
|
||||||
|
final String campusVille;
|
||||||
|
|
||||||
|
factory AuthenticatedProfile.fromApi(dynamic reponse) {
|
||||||
|
if (reponse is! Map || reponse["data"] is! Map) {
|
||||||
|
throw const FormatException("Profil utilisateur invalide.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = reponse["data"] as Map;
|
||||||
|
final role = data["role"];
|
||||||
|
final campus = data["campus"];
|
||||||
|
if (role is! Map || campus is! Map) {
|
||||||
|
throw const FormatException("Role ou campus utilisateur invalide.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final id = data["id"];
|
||||||
|
final campusId = campus["id"];
|
||||||
|
if (id is! int || campusId is! int) {
|
||||||
|
throw const FormatException("Identifiant utilisateur invalide.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final classe = data["classe"];
|
||||||
|
if (classe != null && classe is! String) {
|
||||||
|
throw const FormatException("Classe utilisateur invalide.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return AuthenticatedProfile(
|
||||||
|
id: id,
|
||||||
|
nom: _requiredString(data, "nom"),
|
||||||
|
prenom: _requiredString(data, "prenom"),
|
||||||
|
email: _requiredString(data, "email"),
|
||||||
|
classe: classe as String?,
|
||||||
|
roleCode: _requiredString(role, "code"),
|
||||||
|
roleLabel: _requiredString(role, "libelle"),
|
||||||
|
campusId: campusId,
|
||||||
|
campusNom: _requiredString(campus, "nom"),
|
||||||
|
campusVille: _requiredString(campus, "ville"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get fullName => "$prenom $nom";
|
||||||
|
|
||||||
|
String get initials {
|
||||||
|
final first = prenom.trim().isEmpty ? "" : prenom.trim()[0];
|
||||||
|
final last = nom.trim().isEmpty ? "" : nom.trim()[0];
|
||||||
|
return "$first$last".toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
String get campusTag => "$campusNom · $campusVille";
|
||||||
|
|
||||||
|
String get subtitle {
|
||||||
|
final details = <String>[roleLabel];
|
||||||
|
final studentClass = classe?.trim();
|
||||||
|
if (studentClass != null && studentClass.isNotEmpty) {
|
||||||
|
details.add(studentClass);
|
||||||
|
}
|
||||||
|
details.add(campusTag);
|
||||||
|
return details.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _requiredString(Map source, String key) {
|
||||||
|
final value = source[key];
|
||||||
|
if (value is! String || value.trim().isEmpty) {
|
||||||
|
throw FormatException("Champ utilisateur invalide : $key.");
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,15 +35,18 @@ class _CatalogueScreenState extends State<CatalogueScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<String> _categories(List<MaterielItem> materiels) {
|
List<String> _categories(List<MaterielItem> materiels) {
|
||||||
final distinctes = materiels.map((m) => m.categorie).toSet().toList()..sort();
|
final distinctes = materiels.map((m) => m.categorie).toSet().toList()
|
||||||
|
..sort();
|
||||||
return ["Tous", ...distinctes];
|
return ["Tous", ...distinctes];
|
||||||
}
|
}
|
||||||
|
|
||||||
List<MaterielItem> _filtrer(List<MaterielItem> materiels) {
|
List<MaterielItem> _filtrer(List<MaterielItem> materiels) {
|
||||||
final terme = _recherche.trim().toLowerCase();
|
final terme = _recherche.trim().toLowerCase();
|
||||||
return materiels.where((materiel) {
|
return materiels.where((materiel) {
|
||||||
final matchTexte = terme.isEmpty || materiel.nom.toLowerCase().contains(terme);
|
final matchTexte =
|
||||||
final matchCategorie = _categorie == "Tous" || materiel.categorie == _categorie;
|
terme.isEmpty || materiel.nom.toLowerCase().contains(terme);
|
||||||
|
final matchCategorie =
|
||||||
|
_categorie == "Tous" || materiel.categorie == _categorie;
|
||||||
return matchTexte && matchCategorie;
|
return matchTexte && matchCategorie;
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
@@ -55,16 +58,16 @@ class _CatalogueScreenState extends State<CatalogueScreen> {
|
|||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Navigator.of(context).push(
|
Navigator.of(
|
||||||
MaterialPageRoute(builder: (_) => DetailScreen(item: detail)),
|
context,
|
||||||
);
|
).push(MaterialPageRoute(builder: (_) => DetailScreen(item: detail)));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text(error.toString())),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text(error.toString())));
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _selectionEnCoursId = null);
|
setState(() => _selectionEnCoursId = null);
|
||||||
@@ -106,20 +109,42 @@ class _CatalogueScreenState extends State<CatalogueScreen> {
|
|||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(32, 24, 32, 8),
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
Text(
|
||||||
|
"Matériel disponible",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 28,
|
||||||
|
height: 1,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: EnsupColors.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 5),
|
||||||
|
Text(
|
||||||
|
"Recherchez un équipement puis vérifiez son contenu avant l’emprunt.",
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
_SearchField(
|
_SearchField(
|
||||||
onChanged: (value) => setState(() => _recherche = value),
|
onChanged: (value) =>
|
||||||
|
setState(() => _recherche = value),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FutureBuilder<List<MaterielItem>>(
|
child: FutureBuilder<List<MaterielItem>>(
|
||||||
future: _future,
|
future: _future,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
if (snapshot.connectionState ==
|
||||||
return const Center(child: CircularProgressIndicator());
|
ConnectionState.waiting) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (snapshot.hasError) {
|
if (snapshot.hasError) {
|
||||||
return _ErreurState(
|
return _ErreurState(
|
||||||
@@ -165,8 +190,25 @@ class _CatalogueScreenState extends State<CatalogueScreen> {
|
|||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Divider(color: EnsupColors.line, height: 1),
|
Row(
|
||||||
const SizedBox(height: 16),
|
children: [
|
||||||
|
Text(
|
||||||
|
"${resultats.length} équipement${resultats.length > 1 ? "s" : ""}",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: EnsupColors.text2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(left: 14),
|
||||||
|
child: Divider(color: EnsupColors.line, height: 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: resultats.isEmpty
|
child: resultats.isEmpty
|
||||||
? const _EmptyState()
|
? const _EmptyState()
|
||||||
@@ -177,7 +219,10 @@ class _CatalogueScreenState extends State<CatalogueScreen> {
|
|||||||
final item = resultats[index];
|
final item = resultats[index];
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
MaterielCard(item: item, onSelect: () => _selectionner(item)),
|
MaterielCard(
|
||||||
|
item: item,
|
||||||
|
onSelect: () => _selectionner(item),
|
||||||
|
),
|
||||||
if (_selectionEnCoursId == item.id)
|
if (_selectionEnCoursId == item.id)
|
||||||
const Positioned.fill(
|
const Positioned.fill(
|
||||||
child: ColoredBox(
|
child: ColoredBox(
|
||||||
@@ -217,12 +262,18 @@ class _SearchField extends StatelessWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.text),
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 14,
|
||||||
|
color: EnsupColors.text,
|
||||||
|
),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
isCollapsed: true,
|
isCollapsed: true,
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
hintText: "Rechercher un matériel...",
|
hintText: "Rechercher un matériel...",
|
||||||
hintStyle: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.muted),
|
hintStyle: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 14,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -233,7 +284,11 @@ class _SearchField extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _CategoryChip extends StatelessWidget {
|
class _CategoryChip extends StatelessWidget {
|
||||||
const _CategoryChip({required this.label, required this.selected, required this.onTap});
|
const _CategoryChip({
|
||||||
|
required this.label,
|
||||||
|
required this.selected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
final String label;
|
final String label;
|
||||||
final bool selected;
|
final bool selected;
|
||||||
@@ -249,7 +304,10 @@ class _CategoryChip extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: selected ? EnsupColors.cyan : Colors.white,
|
color: selected ? EnsupColors.cyan : Colors.white,
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
border: Border.all(color: selected ? EnsupColors.cyan : EnsupColors.line, width: 1.5),
|
border: Border.all(
|
||||||
|
color: selected ? EnsupColors.cyan : EnsupColors.line,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
label,
|
label,
|
||||||
@@ -273,11 +331,18 @@ class _EmptyState extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.inventory_2_outlined, size: 40, color: EnsupColors.muted),
|
const Icon(
|
||||||
|
Icons.inventory_2_outlined,
|
||||||
|
size: 40,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Text(
|
Text(
|
||||||
"Aucun matériel ne correspond",
|
"Aucun matériel ne correspond",
|
||||||
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.muted),
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 14,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -297,12 +362,19 @@ class _ErreurState extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.cloud_off_outlined, size: 40, color: EnsupColors.muted),
|
const Icon(
|
||||||
|
Icons.cloud_off_outlined,
|
||||||
|
size: 40,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
message,
|
message,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.text2),
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 14,
|
||||||
|
color: EnsupColors.text2,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
|
|||||||
return {
|
return {
|
||||||
"nomElement": entry.value,
|
"nomElement": entry.value,
|
||||||
"etat": _etatApi(_etats[entry.key]),
|
"etat": _etatApi(_etats[entry.key]),
|
||||||
"quantiteConstatee": _etats[entry.key] == EtatChecklist.absent ? 0 : 1,
|
"quantiteConstatee": _etats[entry.key] == EtatChecklist.absent
|
||||||
|
? 0
|
||||||
|
: 1,
|
||||||
};
|
};
|
||||||
}).toList(),
|
}).toList(),
|
||||||
);
|
);
|
||||||
@@ -75,9 +77,9 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
|
|||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text(error.toString())),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text(error.toString())));
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _envoiEnCours = false);
|
setState(() => _envoiEnCours = false);
|
||||||
@@ -130,14 +132,31 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
|
|||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28),
|
padding: const EdgeInsets.symmetric(
|
||||||
child: Row(
|
horizontal: 24,
|
||||||
|
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,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(flex: 3, child: _colonneGauche()),
|
Expanded(flex: 3, child: _colonneGauche()),
|
||||||
const SizedBox(width: 20),
|
const SizedBox(width: 20),
|
||||||
Expanded(flex: 2, child: _colonneDroite()),
|
Expanded(flex: 2, child: _colonneDroite()),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -161,7 +180,10 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: EnsupColors.soft,
|
color: EnsupColors.soft,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: EnsupColors.cyan.withValues(alpha: 0.15), width: 1.5),
|
border: Border.all(
|
||||||
|
color: EnsupColors.cyan.withValues(alpha: 0.15),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -172,7 +194,11 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
|
|||||||
color: EnsupColors.cyan.withValues(alpha: 0.1),
|
color: EnsupColors.cyan.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(9),
|
borderRadius: BorderRadius.circular(9),
|
||||||
),
|
),
|
||||||
child: Icon(widget.item.icon, color: EnsupColors.cyan, size: 18),
|
child: Icon(
|
||||||
|
widget.item.icon,
|
||||||
|
color: EnsupColors.cyan,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -181,11 +207,17 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
widget.item.nom,
|
widget.item.nom,
|
||||||
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w800, fontSize: 16),
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
"Vérifiez chaque élément physiquement avant de valider",
|
"Vérifiez chaque élément physiquement avant de valider",
|
||||||
style: GoogleFonts.titilliumWeb(fontSize: 12, color: EnsupColors.muted),
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 12,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -225,7 +257,10 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
|
|||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 18,
|
width: 18,
|
||||||
height: 18,
|
height: 18,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.check, size: 18),
|
: const Icon(Icons.check, size: 18),
|
||||||
label: Text(
|
label: Text(
|
||||||
@@ -233,12 +268,17 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
|
|||||||
? "Enregistrement..."
|
? "Enregistrement..."
|
||||||
: "Valider la checklist et confirmer l'emprunt",
|
: "Valider la checklist et confirmer l'emprunt",
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 15),
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: EnsupColors.blue3,
|
backgroundColor: EnsupColors.blue3,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ class ChecklistRetourScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
|
class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
|
||||||
late final List<String> _elements = widget.emprunt.materiel.accessoires.isEmpty
|
late final List<String> _elements =
|
||||||
|
widget.emprunt.materiel.accessoires.isEmpty
|
||||||
? <String>[widget.emprunt.materiel.nom]
|
? <String>[widget.emprunt.materiel.nom]
|
||||||
: widget.emprunt.materiel.accessoires;
|
: widget.emprunt.materiel.accessoires;
|
||||||
late final List<EtatChecklist> _etats = List<EtatChecklist>.filled(
|
late final List<EtatChecklist> _etats = List<EtatChecklist>.filled(
|
||||||
@@ -44,9 +45,16 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
|
|||||||
final anomalies = <AnomalieRetour>[];
|
final anomalies = <AnomalieRetour>[];
|
||||||
for (var i = 0; i < _etats.length; i++) {
|
for (var i = 0; i < _etats.length; i++) {
|
||||||
if (_etats[i] == EtatChecklist.absent) {
|
if (_etats[i] == EtatChecklist.absent) {
|
||||||
anomalies.add(AnomalieRetour(element: _elements[i], etatRetour: "Absent au retour"));
|
anomalies.add(
|
||||||
|
AnomalieRetour(element: _elements[i], etatRetour: "Absent au retour"),
|
||||||
|
);
|
||||||
} else if (_etats[i] == EtatChecklist.deteriore) {
|
} else if (_etats[i] == EtatChecklist.deteriore) {
|
||||||
anomalies.add(AnomalieRetour(element: _elements[i], etatRetour: "Détérioré au retour"));
|
anomalies.add(
|
||||||
|
AnomalieRetour(
|
||||||
|
element: _elements[i],
|
||||||
|
etatRetour: "Détérioré au retour",
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +66,9 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
|
|||||||
return {
|
return {
|
||||||
"nomElement": entry.value,
|
"nomElement": entry.value,
|
||||||
"etat": _etatApi(_etats[entry.key]),
|
"etat": _etatApi(_etats[entry.key]),
|
||||||
"quantiteConstatee": _etats[entry.key] == EtatChecklist.absent ? 0 : 1,
|
"quantiteConstatee": _etats[entry.key] == EtatChecklist.absent
|
||||||
|
? 0
|
||||||
|
: 1,
|
||||||
};
|
};
|
||||||
}).toList(),
|
}).toList(),
|
||||||
);
|
);
|
||||||
@@ -67,7 +77,9 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final anomaliesAffichees = resultat.statut == "CLOTURE" ? <AnomalieRetour>[] : anomalies;
|
final anomaliesAffichees = resultat.statut == "CLOTURE"
|
||||||
|
? <AnomalieRetour>[]
|
||||||
|
: anomalies;
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => ResultatRetourScreen(
|
builder: (_) => ResultatRetourScreen(
|
||||||
@@ -80,9 +92,9 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
|
|||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text(error.toString())),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text(error.toString())));
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _envoiEnCours = false);
|
setState(() => _envoiEnCours = false);
|
||||||
@@ -135,14 +147,31 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
|
|||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28),
|
padding: const EdgeInsets.symmetric(
|
||||||
child: Row(
|
horizontal: 24,
|
||||||
|
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,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(flex: 3, child: _colonneGauche()),
|
Expanded(flex: 3, child: _colonneGauche()),
|
||||||
const SizedBox(width: 20),
|
const SizedBox(width: 20),
|
||||||
Expanded(flex: 2, child: _colonneDroite()),
|
Expanded(flex: 2, child: _colonneDroite()),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -168,7 +197,10 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: EnsupColors.soft,
|
color: EnsupColors.soft,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: EnsupColors.teal.withValues(alpha: 0.15), width: 1.5),
|
border: Border.all(
|
||||||
|
color: EnsupColors.teal.withValues(alpha: 0.15),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -188,11 +220,17 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
materiel.nom,
|
materiel.nom,
|
||||||
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w800, fontSize: 16),
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
"Emprunté le ${widget.emprunt.dateEmprunt}",
|
"Emprunté le ${widget.emprunt.dateEmprunt}",
|
||||||
style: GoogleFonts.titilliumWeb(fontSize: 12, color: EnsupColors.muted),
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 12,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -234,17 +272,25 @@ class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
|
|||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 18,
|
width: 18,
|
||||||
height: 18,
|
height: 18,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.check, size: 18),
|
: const Icon(Icons.check, size: 18),
|
||||||
label: Text(
|
label: Text(
|
||||||
_envoiEnCours ? "Enregistrement..." : "Valider le retour",
|
_envoiEnCours ? "Enregistrement..." : "Valider le retour",
|
||||||
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 16),
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: EnsupColors.blue3,
|
backgroundColor: EnsupColors.blue3,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ class ConfirmationScreen extends StatelessWidget {
|
|||||||
final dateEmprunt = emprunt.dateEmprunt;
|
final dateEmprunt = emprunt.dateEmprunt;
|
||||||
final date =
|
final date =
|
||||||
"${_deuxChiffres(dateEmprunt.day)}/${_deuxChiffres(dateEmprunt.month)}/${dateEmprunt.year}";
|
"${_deuxChiffres(dateEmprunt.day)}/${_deuxChiffres(dateEmprunt.month)}/${dateEmprunt.year}";
|
||||||
final heure = "${_deuxChiffres(dateEmprunt.hour)}:${_deuxChiffres(dateEmprunt.minute)}";
|
final heure =
|
||||||
|
"${_deuxChiffres(dateEmprunt.hour)}:${_deuxChiffres(dateEmprunt.minute)}";
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: EnsupColors.soft,
|
backgroundColor: EnsupColors.soft,
|
||||||
@@ -48,7 +49,7 @@ class ConfirmationScreen extends StatelessWidget {
|
|||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const EnsupTopBar(
|
EnsupTopBar(
|
||||||
brandText: "EME · Confirmation",
|
brandText: "EME · Confirmation",
|
||||||
campusTag: DemoIdentity.campusTag,
|
campusTag: DemoIdentity.campusTag,
|
||||||
),
|
),
|
||||||
@@ -63,7 +64,10 @@ class ConfirmationScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 28,
|
||||||
|
vertical: 32,
|
||||||
|
),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 560),
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -75,10 +79,15 @@ class ConfirmationScreen extends StatelessWidget {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: () => Navigator.of(context).popUntil(
|
onPressed: () =>
|
||||||
(route) => route.settings.name == "home",
|
Navigator.of(context).popUntil(
|
||||||
|
(route) =>
|
||||||
|
route.settings.name == "home",
|
||||||
|
),
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.home_outlined,
|
||||||
|
size: 18,
|
||||||
),
|
),
|
||||||
icon: const Icon(Icons.home_outlined, size: 18),
|
|
||||||
label: Text(
|
label: Text(
|
||||||
"Retour à l'accueil",
|
"Retour à l'accueil",
|
||||||
style: GoogleFonts.darkerGrotesque(
|
style: GoogleFonts.darkerGrotesque(
|
||||||
@@ -88,9 +97,13 @@ class ConfirmationScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: EnsupColors.cyan,
|
backgroundColor: EnsupColors.cyan,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 16,
|
||||||
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(
|
||||||
|
10,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -118,7 +131,9 @@ class ConfirmationScreen extends StatelessWidget {
|
|||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(36),
|
padding: const EdgeInsets.all(36),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: const LinearGradient(colors: [Color(0xFFF0FDF4), Color(0xFFECFDF5)]),
|
gradient: const LinearGradient(
|
||||||
|
colors: [Color(0xFFF0FDF4), Color(0xFFECFDF5)],
|
||||||
|
),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: const Color(0xFFBBF7D0), width: 2),
|
border: Border.all(color: const Color(0xFFBBF7D0), width: 2),
|
||||||
),
|
),
|
||||||
@@ -129,7 +144,9 @@ class ConfirmationScreen extends StatelessWidget {
|
|||||||
height: 72,
|
height: 72,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
gradient: const LinearGradient(colors: [Color(0xFF16A34A), Color(0xFF22C55E)]),
|
gradient: const LinearGradient(
|
||||||
|
colors: [Color(0xFF16A34A), Color(0xFF22C55E)],
|
||||||
|
),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: const Color(0xFF16A34A).withValues(alpha: 0.3),
|
color: const Color(0xFF16A34A).withValues(alpha: 0.3),
|
||||||
@@ -153,7 +170,10 @@ class ConfirmationScreen extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
"Votre emprunt a bien été enregistré. Vous pouvez récupérer le matériel.",
|
"Votre emprunt a bien été enregistré. Vous pouvez récupérer le matériel.",
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: GoogleFonts.titilliumWeb(fontSize: 14, color: const Color(0xFF166534)),
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 14,
|
||||||
|
color: const Color(0xFF166534),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -191,7 +211,8 @@ class ConfirmationScreen extends StatelessWidget {
|
|||||||
_rrow("Date d'emprunt", date),
|
_rrow("Date d'emprunt", date),
|
||||||
_rrow("Heure", heure),
|
_rrow("Heure", heure),
|
||||||
_rrow("Campus", item.campus),
|
_rrow("Campus", item.campus),
|
||||||
if (note != null) _rrow("Note", note, valueColor: const Color(0xFFD97706)),
|
if (note != null)
|
||||||
|
_rrow("Note", note, valueColor: const Color(0xFFD97706)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -203,7 +224,13 @@ class ConfirmationScreen extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(cle, style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted)),
|
Text(
|
||||||
|
cle,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|||||||
@@ -55,14 +55,34 @@ class DetailScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28),
|
padding: const EdgeInsets.symmetric(
|
||||||
child: Row(
|
horizontal: 24,
|
||||||
|
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,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(flex: 3, child: _colonneGauche()),
|
Expanded(flex: 3, child: _colonneGauche()),
|
||||||
const SizedBox(width: 20),
|
const SizedBox(width: 20),
|
||||||
Expanded(flex: 2, child: _colonneDroite(context)),
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: _colonneDroite(context),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -84,17 +104,35 @@ class DetailScreen extends StatelessWidget {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(18),
|
padding: const EdgeInsets.all(18),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: EnsupColors.soft,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: EnsupColors.line, width: 1.5),
|
border: Border.all(color: EnsupColors.line, width: 1.5),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: EnsupColors.text.withValues(alpha: 0.04),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: EnsupColors.cyan.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Icon(item.icon, color: EnsupColors.blue3, size: 21),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
item.nom,
|
item.nom,
|
||||||
style: GoogleFonts.darkerGrotesque(
|
style: GoogleFonts.darkerGrotesque(
|
||||||
fontSize: 20,
|
fontSize: 24,
|
||||||
|
height: 1,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w900,
|
||||||
color: EnsupColors.text,
|
color: EnsupColors.text,
|
||||||
),
|
),
|
||||||
@@ -139,17 +177,27 @@ class DetailScreen extends StatelessWidget {
|
|||||||
border: Border(bottom: BorderSide(color: EnsupColors.line)),
|
border: Border(bottom: BorderSide(color: EnsupColors.line)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(cle, style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted)),
|
|
||||||
Text(
|
Text(
|
||||||
|
cle,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
valeur,
|
valeur,
|
||||||
|
textAlign: TextAlign.right,
|
||||||
style: GoogleFonts.titilliumWeb(
|
style: GoogleFonts.titilliumWeb(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: valueColor ?? EnsupColors.text,
|
color: valueColor ?? EnsupColors.text,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -161,7 +209,13 @@ class DetailScreen extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text("Statut", style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted)),
|
Text(
|
||||||
|
"Statut",
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
StatusPill(disponible: item.disponible),
|
StatusPill(disponible: item.disponible),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -193,18 +247,39 @@ class DetailScreen extends StatelessWidget {
|
|||||||
height: 180,
|
height: 180,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: EnsupColors.soft,
|
color: EnsupColors.cyan.withValues(alpha: 0.045),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: EnsupColors.line, width: 1.5),
|
border: Border.all(
|
||||||
|
color: EnsupColors.cyan.withValues(alpha: 0.18),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(item.icon, size: 48, color: EnsupColors.muted),
|
Container(
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: EnsupColors.blue3.withValues(alpha: 0.1),
|
||||||
|
blurRadius: 14,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Icon(item.icon, size: 36, color: EnsupColors.blue3),
|
||||||
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Text(
|
Text(
|
||||||
"Aperçu du matériel",
|
"Aperçu du matériel",
|
||||||
style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted),
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -217,12 +292,17 @@ class DetailScreen extends StatelessWidget {
|
|||||||
icon: const Icon(Icons.arrow_forward, size: 18),
|
icon: const Icon(Icons.arrow_forward, size: 18),
|
||||||
label: Text(
|
label: Text(
|
||||||
"Démarrer l'emprunt",
|
"Démarrer l'emprunt",
|
||||||
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 16),
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: EnsupColors.cyan,
|
backgroundColor: EnsupColors.cyan,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -235,11 +315,16 @@ class DetailScreen extends StatelessWidget {
|
|||||||
foregroundColor: EnsupColors.text2,
|
foregroundColor: EnsupColors.text2,
|
||||||
side: const BorderSide(color: EnsupColors.line, width: 1.5),
|
side: const BorderSide(color: EnsupColors.line, width: 1.5),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
"Retour au catalogue",
|
"Retour au catalogue",
|
||||||
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 15),
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
import "package:google_fonts/google_fonts.dart";
|
import "package:google_fonts/google_fonts.dart";
|
||||||
import "../demo_identity.dart";
|
import "../demo_identity.dart";
|
||||||
|
import "../services/auth_service.dart";
|
||||||
import "../theme/ensup_colors.dart";
|
import "../theme/ensup_colors.dart";
|
||||||
import "../widgets/ensup_top_bar.dart";
|
import "../widgets/ensup_top_bar.dart";
|
||||||
import "../widgets/profile_card.dart";
|
import "../widgets/profile_card.dart";
|
||||||
@@ -13,6 +14,20 @@ import "restitution_select_screen.dart";
|
|||||||
class HomeScreen extends StatelessWidget {
|
class HomeScreen extends StatelessWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
|
Future<void> _quitter(BuildContext context) async {
|
||||||
|
try {
|
||||||
|
await AuthService.signOut();
|
||||||
|
if (context.mounted) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(error.toString())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -42,64 +57,101 @@ class HomeScreen extends StatelessWidget {
|
|||||||
campusTag: DemoIdentity.campusTag,
|
campusTag: DemoIdentity.campusTag,
|
||||||
userName: DemoIdentity.userName,
|
userName: DemoIdentity.userName,
|
||||||
userInitials: DemoIdentity.userInitials,
|
userInitials: DemoIdentity.userInitials,
|
||||||
onBack: () => Navigator.of(context).pop(),
|
onBack: () async => _quitter(context),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 28,
|
||||||
|
vertical: 26,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1040),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const ProfileCard(
|
ProfileCard(
|
||||||
name: DemoIdentity.userName,
|
name: DemoIdentity.userName,
|
||||||
subtitle: DemoIdentity.userSubtitle,
|
subtitle: DemoIdentity.userSubtitle,
|
||||||
initials: DemoIdentity.userInitials,
|
initials: DemoIdentity.userInitials,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 28),
|
||||||
Text(
|
Text(
|
||||||
"Que souhaitez-vous faire ?",
|
"Que souhaitez-vous faire ?",
|
||||||
style: GoogleFonts.darkerGrotesque(
|
style: GoogleFonts.darkerGrotesque(
|
||||||
fontSize: 28,
|
fontSize: 30,
|
||||||
|
height: 1,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w900,
|
||||||
color: EnsupColors.text,
|
color: EnsupColors.text,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 6),
|
||||||
IntrinsicHeight(
|
Text(
|
||||||
child: Row(
|
"Choisissez l’opération à réaliser sur ce poste.",
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
style: GoogleFonts.titilliumWeb(
|
||||||
children: [
|
fontSize: 14,
|
||||||
Expanded(
|
color: EnsupColors.muted,
|
||||||
child: ActionCard(
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final emprunter = ActionCard(
|
||||||
icon: Icons.inventory_2_outlined,
|
icon: Icons.inventory_2_outlined,
|
||||||
title: "EMPRUNTER",
|
title: "EMPRUNTER",
|
||||||
description:
|
description:
|
||||||
"Accéder au catalogue et emprunter du matériel éducatif",
|
"Consulter les équipements disponibles et démarrer un nouvel emprunt.",
|
||||||
color: EnsupColors.cyan,
|
color: EnsupColors.cyan,
|
||||||
onTap: () => Navigator.of(context).push(
|
onTap: () => Navigator.of(context).push(
|
||||||
MaterialPageRoute(builder: (_) => const CatalogueScreen()),
|
MaterialPageRoute(
|
||||||
|
builder: (_) =>
|
||||||
|
const CatalogueScreen(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
const SizedBox(width: 16),
|
final restituer = ActionCard(
|
||||||
Expanded(
|
|
||||||
child: ActionCard(
|
|
||||||
icon: Icons.assignment_return_outlined,
|
icon: Icons.assignment_return_outlined,
|
||||||
title: "RESTITUER",
|
title: "RESTITUER",
|
||||||
description:
|
description:
|
||||||
"Restituer un matériel que vous avez emprunté",
|
"Sélectionner un emprunt en cours et contrôler le matériel rendu.",
|
||||||
color: EnsupColors.teal,
|
color: EnsupColors.teal,
|
||||||
onTap: () => Navigator.of(context).push(
|
onTap: () => Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => const RestitutionSelectScreen(),
|
builder: (_) =>
|
||||||
|
const RestitutionSelectScreen(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (constraints.maxWidth < 720) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
emprunter,
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
restituer,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return IntrinsicHeight(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(child: emprunter),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(child: restituer),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,17 +1,35 @@
|
|||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
import "package:google_fonts/google_fonts.dart";
|
import "package:google_fonts/google_fonts.dart";
|
||||||
import "../demo_identity.dart";
|
import "../demo_identity.dart";
|
||||||
|
import "../services/api_client.dart";
|
||||||
|
import "../services/auth_service.dart";
|
||||||
import "../theme/ensup_colors.dart";
|
import "../theme/ensup_colors.dart";
|
||||||
import "../widgets/ensup_top_bar.dart";
|
import "../widgets/ensup_top_bar.dart";
|
||||||
import "../widgets/identification_option.dart";
|
import "../widgets/identification_option.dart";
|
||||||
import "../widgets/brand_corners.dart";
|
import "../widgets/brand_corners.dart";
|
||||||
import "home_screen.dart";
|
import "home_screen.dart";
|
||||||
|
import "responsable_screen.dart";
|
||||||
|
|
||||||
/// Écran de démarrage : identification de l'étudiant (RG01/RG02).
|
/// Écran de démarrage : identification de l'étudiant (RG01/RG02).
|
||||||
class LoginScreen extends StatelessWidget {
|
class LoginScreen extends StatefulWidget {
|
||||||
const LoginScreen({super.key});
|
const LoginScreen({super.key});
|
||||||
|
|
||||||
void _identifier(BuildContext context) {
|
@override
|
||||||
|
State<LoginScreen> createState() => _LoginScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginScreenState extends State<LoginScreen> {
|
||||||
|
bool _connexionEnCours = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (AuthService.mode == AuthenticationMode.azure) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _restaurerSession());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _ouvrirEtudiant() {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => const HomeScreen(),
|
builder: (_) => const HomeScreen(),
|
||||||
@@ -20,6 +38,99 @@ class LoginScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _ouvrirResponsable() {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const ResponsableScreen(),
|
||||||
|
settings: const RouteSettings(name: "responsable"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _ouvrirPourRole(String roleCode) {
|
||||||
|
switch (roleCode) {
|
||||||
|
case "ETUDIANT":
|
||||||
|
_ouvrirEtudiant();
|
||||||
|
case "RESPONSABLE":
|
||||||
|
_ouvrirResponsable();
|
||||||
|
default:
|
||||||
|
throw StateError("Role utilisateur non autorise.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _restaurerSession() async {
|
||||||
|
setState(() => _connexionEnCours = true);
|
||||||
|
try {
|
||||||
|
if (!await AuthService.hasMicrosoftAccount()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final reponse = await ApiClient.get("/auth/me");
|
||||||
|
final profile = AuthService.setProfileFromApi(reponse);
|
||||||
|
if (mounted) {
|
||||||
|
_ouvrirPourRole(profile.roleCode);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -60,7 +171,10 @@ class LoginScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 40),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 28,
|
||||||
|
vertical: 40,
|
||||||
|
),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 560),
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -71,18 +185,27 @@ class LoginScreen extends StatelessWidget {
|
|||||||
height: 72,
|
height: 72,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: const LinearGradient(
|
gradient: const LinearGradient(
|
||||||
colors: [EnsupColors.blue3, EnsupColors.cyan],
|
colors: [
|
||||||
|
EnsupColors.blue3,
|
||||||
|
EnsupColors.cyan,
|
||||||
|
],
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(22),
|
borderRadius: BorderRadius.circular(22),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: EnsupColors.blue3.withValues(alpha: 0.35),
|
color: EnsupColors.blue3.withValues(
|
||||||
|
alpha: 0.35,
|
||||||
|
),
|
||||||
blurRadius: 24,
|
blurRadius: 24,
|
||||||
offset: const Offset(0, 8),
|
offset: const Offset(0, 8),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.badge_outlined, color: Colors.white, size: 34),
|
child: const Icon(
|
||||||
|
Icons.badge_outlined,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 34,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
@@ -109,22 +232,34 @@ class LoginScreen extends StatelessWidget {
|
|||||||
label: "Scanner ma carte étudiante",
|
label: "Scanner ma carte étudiante",
|
||||||
description:
|
description:
|
||||||
"Présentez votre carte étudiante dématérialisée à la douchette ou à la tablette",
|
"Présentez votre carte étudiante dématérialisée à la douchette ou à la tablette",
|
||||||
onTap: () => _identifier(context),
|
onTap: _connexionEnCours
|
||||||
|
? null
|
||||||
|
: _identifierParQr,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
IdentificationOption(
|
IdentificationOption(
|
||||||
icon: Icons.mail_outline,
|
icon: Icons.mail_outline,
|
||||||
iconColor: EnsupColors.teal,
|
iconColor: EnsupColors.teal,
|
||||||
label: "Connexion compte professionnel ENSUP",
|
label:
|
||||||
description: "Utiliser mon adresse e-mail Microsoft 365",
|
"Connexion compte professionnel ENSUP",
|
||||||
onTap: () => _identifier(context),
|
description:
|
||||||
|
"Utiliser mon adresse e-mail Microsoft 365",
|
||||||
|
onTap: _connexionEnCours
|
||||||
|
? null
|
||||||
|
: () => _connecterMicrosoft(
|
||||||
|
responsableDemo: false,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Expanded(child: Divider(color: EnsupColors.line)),
|
const Expanded(
|
||||||
|
child: Divider(color: EnsupColors.line),
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
"ou",
|
"ou",
|
||||||
style: GoogleFonts.titilliumWeb(
|
style: GoogleFonts.titilliumWeb(
|
||||||
@@ -133,15 +268,24 @@ class LoginScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Expanded(child: Divider(color: EnsupColors.line)),
|
const Expanded(
|
||||||
|
child: Divider(color: EnsupColors.line),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: () {},
|
onPressed: _connexionEnCours
|
||||||
icon: const Icon(Icons.person_outline, size: 16),
|
? null
|
||||||
|
: () => _connecterMicrosoft(
|
||||||
|
responsableDemo: true,
|
||||||
|
),
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.person_outline,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
label: Text(
|
label: Text(
|
||||||
"Accès responsable matériel",
|
"Accès responsable matériel",
|
||||||
style: GoogleFonts.darkerGrotesque(
|
style: GoogleFonts.darkerGrotesque(
|
||||||
@@ -151,10 +295,17 @@ class LoginScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: EnsupColors.text2,
|
foregroundColor: EnsupColors.text2,
|
||||||
side: const BorderSide(color: EnsupColors.line, width: 1.5),
|
side: const BorderSide(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
color: EnsupColors.line,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 14,
|
||||||
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(
|
||||||
|
10,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,8 @@ class RestitutionSelectScreen extends StatefulWidget {
|
|||||||
const RestitutionSelectScreen({super.key});
|
const RestitutionSelectScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<RestitutionSelectScreen> createState() => _RestitutionSelectScreenState();
|
State<RestitutionSelectScreen> createState() =>
|
||||||
|
_RestitutionSelectScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RestitutionSelectScreenState extends State<RestitutionSelectScreen> {
|
class _RestitutionSelectScreenState extends State<RestitutionSelectScreen> {
|
||||||
@@ -40,16 +41,18 @@ class _RestitutionSelectScreenState extends State<RestitutionSelectScreen> {
|
|||||||
}
|
}
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => ChecklistRetourScreen(emprunt: emprunt.copyWith(materiel: detail)),
|
builder: (_) => ChecklistRetourScreen(
|
||||||
|
emprunt: emprunt.copyWith(materiel: detail),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text(error.toString())),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text(error.toString())));
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _chargementId = null);
|
setState(() => _chargementId = null);
|
||||||
@@ -91,12 +94,18 @@ class _RestitutionSelectScreenState extends State<RestitutionSelectScreen> {
|
|||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 32,
|
||||||
|
vertical: 28,
|
||||||
|
),
|
||||||
child: FutureBuilder<List<EmpruntItem>>(
|
child: FutureBuilder<List<EmpruntItem>>(
|
||||||
future: _future,
|
future: _future,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
if (snapshot.connectionState ==
|
||||||
return const Center(child: CircularProgressIndicator());
|
ConnectionState.waiting) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (snapshot.hasError) {
|
if (snapshot.hasError) {
|
||||||
return _ErreurState(
|
return _ErreurState(
|
||||||
@@ -128,12 +137,21 @@ class _RestitutionSelectScreenState extends State<RestitutionSelectScreen> {
|
|||||||
Text(
|
Text(
|
||||||
"Sélectionner le matériel à restituer",
|
"Sélectionner le matériel à restituer",
|
||||||
style: GoogleFonts.darkerGrotesque(
|
style: GoogleFonts.darkerGrotesque(
|
||||||
fontSize: 24,
|
fontSize: 28,
|
||||||
|
height: 1,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w900,
|
||||||
color: EnsupColors.text,
|
color: EnsupColors.text,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
"${emprunts.length} emprunt${emprunts.length > 1 ? "s" : ""} en cours associé${emprunts.length > 1 ? "s" : ""} à votre compte.",
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: emprunts.isEmpty
|
child: emprunts.isEmpty
|
||||||
? const _EmptyState()
|
? const _EmptyState()
|
||||||
@@ -174,17 +192,31 @@ class _EmpruntCard extends StatelessWidget {
|
|||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: EnsupColors.line, width: 1.5),
|
border: Border.all(color: EnsupColors.line, width: 1.5),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: EnsupColors.text.withValues(alpha: 0.035),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 3),
|
||||||
),
|
),
|
||||||
child: Row(
|
],
|
||||||
|
),
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final compact = constraints.maxWidth < 650;
|
||||||
|
final information = Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 44,
|
width: 46,
|
||||||
height: 44,
|
height: 46,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: EnsupColors.teal.withValues(alpha: 0.08),
|
color: EnsupColors.teal.withValues(alpha: 0.09),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
child: Icon(emprunt.materiel.icon, color: EnsupColors.teal, size: 20),
|
child: Icon(
|
||||||
|
emprunt.materiel.icon,
|
||||||
|
color: EnsupColors.teal,
|
||||||
|
size: 21,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -193,25 +225,45 @@ class _EmpruntCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
emprunt.materiel.nom,
|
emprunt.materiel.nom,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: GoogleFonts.darkerGrotesque(
|
style: GoogleFonts.darkerGrotesque(
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w800,
|
||||||
fontSize: 16,
|
fontSize: 17,
|
||||||
|
height: 1.05,
|
||||||
color: EnsupColors.text,
|
color: EnsupColors.text,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
"Emprunté le ${emprunt.dateEmprunt} · Retour prévu ${emprunt.retourPrevu}",
|
"Emprunté le ${emprunt.dateEmprunt} · Retour prévu ${emprunt.retourPrevu}",
|
||||||
style: GoogleFonts.titilliumWeb(fontSize: 12, color: EnsupColors.muted),
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 12,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
],
|
||||||
|
);
|
||||||
|
final actions = Wrap(
|
||||||
|
spacing: 10,
|
||||||
|
runSpacing: 8,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
alignment: WrapAlignment.end,
|
||||||
|
children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 11,
|
||||||
|
vertical: 5,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFFEF9C3),
|
color: emprunt.statut == "EN_RETARD"
|
||||||
|
? EnsupColors.red.withValues(alpha: 0.09)
|
||||||
|
: const Color(0xFFFEF9C3),
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -219,24 +271,64 @@ class _EmpruntCard extends StatelessWidget {
|
|||||||
style: GoogleFonts.darkerGrotesque(
|
style: GoogleFonts.darkerGrotesque(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: const Color(0xFFA16207),
|
color: emprunt.statut == "EN_RETARD"
|
||||||
|
? EnsupColors.red
|
||||||
|
: const Color(0xFFA16207),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
FilledButton.icon(
|
||||||
FilledButton(
|
|
||||||
onPressed: loading ? null : onRestituer,
|
onPressed: loading ? null : onRestituer,
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: EnsupColors.cyan,
|
backgroundColor: EnsupColors.cyan,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
padding: const EdgeInsets.symmetric(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
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,
|
||||||
),
|
),
|
||||||
child: Text(
|
|
||||||
loading ? "Chargement..." : "Restituer",
|
|
||||||
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 14),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
information,
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Align(alignment: Alignment.centerRight, child: actions),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: information),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
actions,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -268,12 +360,19 @@ class _ErreurState extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.cloud_off_outlined, size: 40, color: EnsupColors.muted),
|
const Icon(
|
||||||
|
Icons.cloud_off_outlined,
|
||||||
|
size: 40,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
message,
|
message,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.text2),
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 14,
|
||||||
|
color: EnsupColors.text2,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import "../widgets/brand_corners.dart";
|
|||||||
/// Résultat de la restitution (RG17-RG20) : conforme (succès) ou non conforme
|
/// Résultat de la restitution (RG17-RG20) : conforme (succès) ou non conforme
|
||||||
/// (anomalie détectée + notification au responsable).
|
/// (anomalie détectée + notification au responsable).
|
||||||
class ResultatRetourScreen extends StatelessWidget {
|
class ResultatRetourScreen extends StatelessWidget {
|
||||||
const ResultatRetourScreen({super.key, required this.emprunt, required this.anomalies});
|
const ResultatRetourScreen({
|
||||||
|
super.key,
|
||||||
|
required this.emprunt,
|
||||||
|
required this.anomalies,
|
||||||
|
});
|
||||||
|
|
||||||
final EmpruntItem emprunt;
|
final EmpruntItem emprunt;
|
||||||
final List<AnomalieRetour> anomalies;
|
final List<AnomalieRetour> anomalies;
|
||||||
@@ -48,7 +52,7 @@ class ResultatRetourScreen extends StatelessWidget {
|
|||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const EnsupTopBar(
|
EnsupTopBar(
|
||||||
brandText: "EME · Résultat du contrôle",
|
brandText: "EME · Résultat du contrôle",
|
||||||
campusTag: DemoIdentity.campusTag,
|
campusTag: DemoIdentity.campusTag,
|
||||||
),
|
),
|
||||||
@@ -63,7 +67,10 @@ class ResultatRetourScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 28,
|
||||||
|
vertical: 32,
|
||||||
|
),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 560),
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -73,10 +80,15 @@ class ResultatRetourScreen extends StatelessWidget {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: () => Navigator.of(context).popUntil(
|
onPressed: () =>
|
||||||
(route) => route.settings.name == "home",
|
Navigator.of(context).popUntil(
|
||||||
|
(route) =>
|
||||||
|
route.settings.name == "home",
|
||||||
|
),
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.home_outlined,
|
||||||
|
size: 18,
|
||||||
),
|
),
|
||||||
icon: const Icon(Icons.home_outlined, size: 18),
|
|
||||||
label: Text(
|
label: Text(
|
||||||
"Retour à l'accueil",
|
"Retour à l'accueil",
|
||||||
style: GoogleFonts.darkerGrotesque(
|
style: GoogleFonts.darkerGrotesque(
|
||||||
@@ -86,9 +98,13 @@ class ResultatRetourScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: EnsupColors.cyan,
|
backgroundColor: EnsupColors.cyan,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 16,
|
||||||
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(
|
||||||
|
10,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -116,7 +132,9 @@ class ResultatRetourScreen extends StatelessWidget {
|
|||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(36),
|
padding: const EdgeInsets.all(36),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: const LinearGradient(colors: [Color(0xFFF0FDF4), Color(0xFFECFDF5)]),
|
gradient: const LinearGradient(
|
||||||
|
colors: [Color(0xFFF0FDF4), Color(0xFFECFDF5)],
|
||||||
|
),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: const Color(0xFFBBF7D0), width: 2),
|
border: Border.all(color: const Color(0xFFBBF7D0), width: 2),
|
||||||
),
|
),
|
||||||
@@ -127,7 +145,9 @@ class ResultatRetourScreen extends StatelessWidget {
|
|||||||
height: 72,
|
height: 72,
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
gradient: LinearGradient(colors: [Color(0xFF16A34A), Color(0xFF22C55E)]),
|
gradient: LinearGradient(
|
||||||
|
colors: [Color(0xFF16A34A), Color(0xFF22C55E)],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.check, color: Colors.white, size: 36),
|
child: const Icon(Icons.check, color: Colors.white, size: 36),
|
||||||
),
|
),
|
||||||
@@ -144,7 +164,10 @@ class ResultatRetourScreen extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
"L'emprunt a été clôturé et le matériel est de nouveau disponible.",
|
"L'emprunt a été clôturé et le matériel est de nouveau disponible.",
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: GoogleFonts.titilliumWeb(fontSize: 14, color: const Color(0xFF166534)),
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 14,
|
||||||
|
color: const Color(0xFF166534),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -164,12 +187,19 @@ class ResultatRetourScreen extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: EnsupColors.red.withValues(alpha: 0.06),
|
color: EnsupColors.red.withValues(alpha: 0.06),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: EnsupColors.red.withValues(alpha: 0.2), width: 1.5),
|
border: Border.all(
|
||||||
|
color: EnsupColors.red.withValues(alpha: 0.2),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.warning_amber_rounded, color: EnsupColors.red, size: 24),
|
const Icon(
|
||||||
|
Icons.warning_amber_rounded,
|
||||||
|
color: EnsupColors.red,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -227,7 +257,10 @@ class ResultatRetourScreen extends StatelessWidget {
|
|||||||
anomalie.etatRetour,
|
anomalie.etatRetour,
|
||||||
valeurCouleur: EnsupColors.red,
|
valeurCouleur: EnsupColors.red,
|
||||||
),
|
),
|
||||||
_drow("Matériel", "${emprunt.materiel.nom} · ${emprunt.materiel.reference}"),
|
_drow(
|
||||||
|
"Matériel",
|
||||||
|
"${emprunt.materiel.nom} · ${emprunt.materiel.reference}",
|
||||||
|
),
|
||||||
_drow("Emprunteur", DemoIdentity.userName),
|
_drow("Emprunteur", DemoIdentity.userName),
|
||||||
_drow("Date détection", date),
|
_drow("Date détection", date),
|
||||||
],
|
],
|
||||||
@@ -237,7 +270,11 @@ class ResultatRetourScreen extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
"Le responsable matériel traitera cette anomalie. Vous pouvez retourner à l'accueil.",
|
"Le responsable matériel traitera cette anomalie. Vous pouvez retourner à l'accueil.",
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted, height: 1.6),
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
height: 1.6,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -249,7 +286,13 @@ class ResultatRetourScreen extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(cle, style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted)),
|
Text(
|
||||||
|
cle,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import "dart:convert";
|
import "dart:convert";
|
||||||
|
import "dart:typed_data";
|
||||||
import "package:http/http.dart" as http;
|
import "package:http/http.dart" as http;
|
||||||
|
import "auth_service.dart";
|
||||||
|
|
||||||
/// Erreur levée par le client API (serveur injoignable ou réponse en échec).
|
/// Erreur levée par le client API (serveur injoignable ou réponse en échec).
|
||||||
class ApiException implements Exception {
|
class ApiException implements Exception {
|
||||||
@@ -11,20 +13,32 @@ class ApiException implements Exception {
|
|||||||
String toString() => message;
|
String toString() => message;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Point d'accès unique au backend : centralise l'URL de base, l'en-tête
|
class ApiFile {
|
||||||
/// d'authentification (simulée) et la gestion des erreurs.
|
const ApiFile({
|
||||||
|
required this.bytes,
|
||||||
|
required this.fileName,
|
||||||
|
required this.contentType,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Uint8List bytes;
|
||||||
|
final String fileName;
|
||||||
|
final String contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point d'accès unique au backend : centralise l'URL, l'authentification et
|
||||||
|
/// la gestion des erreurs.
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
ApiClient._();
|
ApiClient._();
|
||||||
|
|
||||||
static const String _baseUrl = "http://localhost:3000/api";
|
static const String _baseUrl = "http://localhost:3000/api";
|
||||||
static const String _utilisateurEmail = "lucas.martin@ensitech.eu";
|
|
||||||
|
|
||||||
static Future<dynamic> get(String chemin) async {
|
static Future<dynamic> get(String chemin) async {
|
||||||
final uri = Uri.parse("$_baseUrl$chemin");
|
final uri = Uri.parse("$_baseUrl$chemin");
|
||||||
|
|
||||||
late final http.Response reponse;
|
late final http.Response reponse;
|
||||||
try {
|
try {
|
||||||
reponse = await http.get(uri, headers: const {"x-user-email": _utilisateurEmail});
|
final authHeaders = await AuthService.requestHeaders();
|
||||||
|
reponse = await http.get(uri, headers: authHeaders);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
throw const ApiException(
|
throw const ApiException(
|
||||||
"Impossible de joindre le serveur. Vérifiez que le backend est démarré.",
|
"Impossible de joindre le serveur. Vérifiez que le backend est démarré.",
|
||||||
@@ -37,17 +51,66 @@ class ApiClient {
|
|||||||
throw ApiException(_messageErreur(reponse));
|
throw ApiException(_messageErreur(reponse));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<ApiFile> getFile(
|
||||||
|
String chemin, {
|
||||||
|
required String fallbackFileName,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse("$_baseUrl$chemin");
|
||||||
|
|
||||||
|
late final http.Response reponse;
|
||||||
|
try {
|
||||||
|
final authHeaders = await AuthService.requestHeaders();
|
||||||
|
reponse = await http.get(uri, headers: authHeaders);
|
||||||
|
} catch (_) {
|
||||||
|
throw const ApiException(
|
||||||
|
"Impossible de joindre le serveur. Vérifiez que le backend est démarré.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reponse.statusCode < 200 || reponse.statusCode >= 300) {
|
||||||
|
throw ApiException(_messageErreur(reponse));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiFile(
|
||||||
|
bytes: reponse.bodyBytes,
|
||||||
|
fileName: _fileName(reponse, fallbackFileName),
|
||||||
|
contentType:
|
||||||
|
reponse.headers["content-type"] ?? "application/octet-stream",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static Future<dynamic> post(String chemin, Map<String, dynamic> body) async {
|
static Future<dynamic> post(String chemin, Map<String, dynamic> body) async {
|
||||||
final uri = Uri.parse("$_baseUrl$chemin");
|
final uri = Uri.parse("$_baseUrl$chemin");
|
||||||
|
|
||||||
late final http.Response reponse;
|
late final http.Response reponse;
|
||||||
try {
|
try {
|
||||||
|
final authHeaders = await AuthService.requestHeaders();
|
||||||
reponse = await http.post(
|
reponse = await http.post(
|
||||||
uri,
|
uri,
|
||||||
headers: const {
|
headers: {...authHeaders, "content-type": "application/json"},
|
||||||
"content-type": "application/json",
|
body: jsonEncode(body),
|
||||||
"x-user-email": _utilisateurEmail,
|
);
|
||||||
},
|
} catch (_) {
|
||||||
|
throw const ApiException(
|
||||||
|
"Impossible de joindre le serveur. Vérifiez que le backend est démarré.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reponse.statusCode >= 200 && reponse.statusCode < 300) {
|
||||||
|
return jsonDecode(reponse.body);
|
||||||
|
}
|
||||||
|
throw ApiException(_messageErreur(reponse));
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<dynamic> patch(String chemin, Map<String, dynamic> body) async {
|
||||||
|
final uri = Uri.parse("$_baseUrl$chemin");
|
||||||
|
|
||||||
|
late final http.Response reponse;
|
||||||
|
try {
|
||||||
|
final authHeaders = await AuthService.requestHeaders();
|
||||||
|
reponse = await http.patch(
|
||||||
|
uri,
|
||||||
|
headers: {...authHeaders, "content-type": "application/json"},
|
||||||
body: jsonEncode(body),
|
body: jsonEncode(body),
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -76,4 +139,15 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
return "Erreur ${reponse.statusCode}";
|
return "Erreur ${reponse.statusCode}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String _fileName(http.Response reponse, String fallback) {
|
||||||
|
final disposition = reponse.headers["content-disposition"];
|
||||||
|
if (disposition == null) return fallback;
|
||||||
|
|
||||||
|
final match = RegExp(
|
||||||
|
"filename=\"?([^\";]+)\"?",
|
||||||
|
caseSensitive: false,
|
||||||
|
).firstMatch(disposition);
|
||||||
|
return match?.group(1) ?? fallback;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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,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);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import "package:google_fonts/google_fonts.dart";
|
|||||||
import "../theme/ensup_colors.dart";
|
import "../theme/ensup_colors.dart";
|
||||||
|
|
||||||
/// Grande carte d'action de l'accueil (Emprunter / Restituer).
|
/// Grande carte d'action de l'accueil (Emprunter / Restituer).
|
||||||
class ActionCard extends StatelessWidget {
|
class ActionCard extends StatefulWidget {
|
||||||
const ActionCard({
|
const ActionCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
@@ -19,58 +19,109 @@ class ActionCard extends StatelessWidget {
|
|||||||
final Color color;
|
final Color color;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ActionCard> createState() => _ActionCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ActionCardState extends State<ActionCard> {
|
||||||
|
bool _hovered = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return InkWell(
|
final color = widget.color;
|
||||||
onTap: onTap,
|
return MouseRegion(
|
||||||
borderRadius: BorderRadius.circular(16),
|
onEnter: (_) => setState(() => _hovered = true),
|
||||||
child: Container(
|
onExit: (_) => setState(() => _hovered = false),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
cursor: SystemMouseCursors.click,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
constraints: const BoxConstraints(minHeight: 230),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color.withValues(alpha: 0.07),
|
color: _hovered
|
||||||
|
? color.withValues(alpha: 0.1)
|
||||||
|
: color.withValues(alpha: 0.06),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: color.withValues(alpha: 0.2), width: 2),
|
border: Border.all(
|
||||||
|
color: color.withValues(alpha: _hovered ? 0.55 : 0.22),
|
||||||
|
width: _hovered ? 2 : 1.5,
|
||||||
),
|
),
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 60,
|
|
||||||
height: 60,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(colors: [color, color.withValues(alpha: 0.75)]),
|
|
||||||
borderRadius: BorderRadius.circular(18),
|
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: color.withValues(alpha: 0.35),
|
color: color.withValues(alpha: _hovered ? 0.16 : 0.06),
|
||||||
blurRadius: 16,
|
blurRadius: _hovered ? 22 : 10,
|
||||||
offset: const Offset(0, 4),
|
offset: Offset(0, _hovered ? 8 : 3),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Icon(icon, color: Colors.white, size: 28),
|
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),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
],
|
||||||
|
),
|
||||||
|
child: Icon(widget.icon, color: Colors.white, size: 28),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
title,
|
widget.title,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
style: GoogleFonts.darkerGrotesque(
|
style: GoogleFonts.darkerGrotesque(
|
||||||
fontSize: 26,
|
fontSize: 25,
|
||||||
|
height: 1,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w900,
|
||||||
color: EnsupColors.text,
|
color: EnsupColors.text,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
description,
|
widget.description,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: GoogleFonts.titilliumWeb(
|
style: GoogleFonts.titilliumWeb(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: EnsupColors.muted,
|
color: EnsupColors.muted,
|
||||||
height: 1.5,
|
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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,96 +27,122 @@ class EnsupTopBar extends StatelessWidget {
|
|||||||
final onBack = this.onBack;
|
final onBack = this.onBack;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
height: 56,
|
height: 60,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 18),
|
||||||
color: EnsupColors.blue3,
|
color: EnsupColors.blue3,
|
||||||
child: Row(
|
child: LayoutBuilder(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
builder: (context, constraints) {
|
||||||
children: [
|
final compact = constraints.maxWidth < 620;
|
||||||
Row(
|
final showCampus = constraints.maxWidth >= 520;
|
||||||
|
final showName = constraints.maxWidth >= 760;
|
||||||
|
|
||||||
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
if (onBack != null) ...[
|
if (onBack != null) ...[
|
||||||
_BackButton(onTap: onBack),
|
_BackButton(onTap: onBack, compact: compact),
|
||||||
const SizedBox(width: 8),
|
SizedBox(width: compact ? 5 : 8),
|
||||||
],
|
],
|
||||||
const Icon(Icons.badge_outlined, color: Colors.white, size: 18),
|
const Icon(Icons.badge_outlined, color: Colors.white, size: 18),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 9),
|
||||||
Text(
|
Expanded(
|
||||||
|
child: Text(
|
||||||
brandText,
|
brandText,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: GoogleFonts.darkerGrotesque(
|
style: GoogleFonts.darkerGrotesque(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w900,
|
||||||
fontSize: 22,
|
fontSize: compact ? 18 : 21,
|
||||||
letterSpacing: 0.3,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
Row(
|
const SizedBox(width: 10),
|
||||||
children: [
|
if (showCampus)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 11,
|
||||||
|
vertical: 5,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withValues(alpha: 0.15),
|
color: Colors.white.withValues(alpha: 0.14),
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
border: Border.all(color: Colors.white.withValues(alpha: 0.2)),
|
border: Border.all(
|
||||||
|
color: Colors.white.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
campusTag,
|
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(
|
style: GoogleFonts.titilliumWeb(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
if (name != null && initials != null) ...[
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
_Avatar(initials: initials),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
name,
|
|
||||||
style: GoogleFonts.titilliumWeb(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BackButton extends StatelessWidget {
|
class _BackButton extends StatelessWidget {
|
||||||
const _BackButton({required this.onTap});
|
const _BackButton({required this.onTap, required this.compact});
|
||||||
|
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
final bool compact;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return InkWell(
|
return Tooltip(
|
||||||
|
message: "Retour",
|
||||||
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(7),
|
||||||
child: Padding(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
height: 34,
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(7),
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.arrow_back, size: 16, color: Colors.white.withValues(alpha: 0.85)),
|
Icon(
|
||||||
|
Icons.arrow_back_rounded,
|
||||||
|
size: 17,
|
||||||
|
color: Colors.white.withValues(alpha: 0.9),
|
||||||
|
),
|
||||||
|
if (!compact) ...[
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
"Retour",
|
"Retour",
|
||||||
style: GoogleFonts.titilliumWeb(
|
style: GoogleFonts.titilliumWeb(
|
||||||
color: Colors.white.withValues(alpha: 0.85),
|
color: Colors.white.withValues(alpha: 0.9),
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -135,8 +161,11 @@ class _Avatar extends StatelessWidget {
|
|||||||
height: 30,
|
height: 30,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
gradient: const LinearGradient(colors: [EnsupColors.teal, EnsupColors.cyan]),
|
color: EnsupColors.purple,
|
||||||
border: Border.all(color: Colors.white.withValues(alpha: 0.3), width: 2),
|
border: Border.all(
|
||||||
|
color: Colors.white.withValues(alpha: 0.3),
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ class EtatBox extends StatelessWidget {
|
|||||||
case EtatChecklist.deteriore:
|
case EtatChecklist.deteriore:
|
||||||
background = const Color(0xFFFFFBEB);
|
background = const Color(0xFFFFFBEB);
|
||||||
border = const Color(0xFFFDE68A);
|
border = const Color(0xFFFDE68A);
|
||||||
contenu = const Icon(Icons.priority_high, size: 16, color: Color(0xFFD97706));
|
contenu = const Icon(
|
||||||
|
Icons.priority_high,
|
||||||
|
size: 16,
|
||||||
|
color: Color(0xFFD97706),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,15 +77,36 @@ class ChecklistTableau extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return LayoutBuilder(
|
||||||
children: [
|
builder: (context, constraints) {
|
||||||
_entete(),
|
final columnWidth = constraints.maxWidth < 520 ? 58.0 : 72.0;
|
||||||
for (var i = 0; i < accessoires.length; i++) _ligne(i),
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: EnsupColors.line, width: 1.5),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: EnsupColors.text.withValues(alpha: 0.035),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 3),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_entete(columnWidth),
|
||||||
|
for (var i = 0; i < accessoires.length; i++)
|
||||||
|
_ligne(i, columnWidth),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _entete() {
|
Widget _entete(double columnWidth) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
@@ -90,9 +115,9 @@ class ChecklistTableau extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: _texteEntete("ÉLÉMENT")),
|
Expanded(child: _texteEntete("ÉLÉMENT")),
|
||||||
_colonneEntete("PRÉSENT"),
|
_colonneEntete("PRÉSENT", columnWidth),
|
||||||
_colonneEntete("ABSENT"),
|
_colonneEntete("ABSENT", columnWidth),
|
||||||
_colonneEntete("DÉTÉRIORÉ"),
|
_colonneEntete("DÉTÉRIORÉ", columnWidth),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -110,11 +135,16 @@ class ChecklistTableau extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _colonneEntete(String label) {
|
Widget _colonneEntete(String label, double width) {
|
||||||
return SizedBox(width: 72, child: Center(child: _texteEntete(label)));
|
return SizedBox(
|
||||||
|
width: width,
|
||||||
|
child: Center(
|
||||||
|
child: FittedBox(fit: BoxFit.scaleDown, child: _texteEntete(label)),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _ligne(int index) {
|
Widget _ligne(int index, double columnWidth) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
@@ -125,20 +155,23 @@ class ChecklistTableau extends StatelessWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
accessoires[index],
|
accessoires[index],
|
||||||
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.text2),
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 14,
|
||||||
|
color: EnsupColors.text2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_case(index, EtatChecklist.present),
|
),
|
||||||
_case(index, EtatChecklist.absent),
|
_case(index, EtatChecklist.present, columnWidth),
|
||||||
_case(index, EtatChecklist.deteriore),
|
_case(index, EtatChecklist.absent, columnWidth),
|
||||||
|
_case(index, EtatChecklist.deteriore, columnWidth),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _case(int index, EtatChecklist type) {
|
Widget _case(int index, EtatChecklist type, double width) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: 72,
|
width: width,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: EtatBox(
|
child: EtatBox(
|
||||||
type: type,
|
type: type,
|
||||||
@@ -152,7 +185,11 @@ class ChecklistTableau extends StatelessWidget {
|
|||||||
|
|
||||||
/// Encadré d'instructions / rappel à côté d'une checklist.
|
/// Encadré d'instructions / rappel à côté d'une checklist.
|
||||||
class ChecklistInstructions extends StatelessWidget {
|
class ChecklistInstructions extends StatelessWidget {
|
||||||
const ChecklistInstructions({super.key, required this.titre, required this.texte});
|
const ChecklistInstructions({
|
||||||
|
super.key,
|
||||||
|
required this.titre,
|
||||||
|
required this.texte,
|
||||||
|
});
|
||||||
|
|
||||||
final String titre;
|
final String titre;
|
||||||
final String texte;
|
final String texte;
|
||||||
@@ -164,7 +201,10 @@ class ChecklistInstructions extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: EnsupColors.blue2.withValues(alpha: 0.05),
|
color: EnsupColors.blue2.withValues(alpha: 0.05),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
border: Border.all(color: EnsupColors.blue2.withValues(alpha: 0.15), width: 1.5),
|
border: Border.all(
|
||||||
|
color: EnsupColors.blue2.withValues(alpha: 0.15),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -181,7 +221,11 @@ class ChecklistInstructions extends StatelessWidget {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
texte,
|
texte,
|
||||||
style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.text2, height: 1.6),
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
color: EnsupColors.text2,
|
||||||
|
height: 1.6,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -208,10 +252,16 @@ class ChecklistCommentaire extends StatelessWidget {
|
|||||||
style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.text),
|
style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.text),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: hint,
|
hintText: hint,
|
||||||
hintStyle: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted),
|
hintStyle: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.white,
|
fillColor: Colors.white,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(9),
|
borderRadius: BorderRadius.circular(9),
|
||||||
borderSide: const BorderSide(color: EnsupColors.line, width: 1.5),
|
borderSide: const BorderSide(color: EnsupColors.line, width: 1.5),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import "package:google_fonts/google_fonts.dart";
|
|||||||
import "../theme/ensup_colors.dart";
|
import "../theme/ensup_colors.dart";
|
||||||
|
|
||||||
/// Option d'identification de l'écran de connexion (QR ou mail ENSUP).
|
/// Option d'identification de l'écran de connexion (QR ou mail ENSUP).
|
||||||
class IdentificationOption extends StatelessWidget {
|
class IdentificationOption extends StatefulWidget {
|
||||||
const IdentificationOption({
|
const IdentificationOption({
|
||||||
super.key,
|
super.key,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
@@ -19,58 +19,104 @@ class IdentificationOption extends StatelessWidget {
|
|||||||
final String description;
|
final String description;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<IdentificationOption> createState() => _IdentificationOptionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _IdentificationOptionState extends State<IdentificationOption> {
|
||||||
|
bool _hovered = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return InkWell(
|
return MouseRegion(
|
||||||
onTap: onTap,
|
onEnter: (_) => setState(() => _hovered = true),
|
||||||
borderRadius: BorderRadius.circular(12),
|
onExit: (_) => setState(() => _hovered = false),
|
||||||
child: Container(
|
cursor: SystemMouseCursors.click,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 160),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: EnsupColors.line, width: 2),
|
border: Border.all(
|
||||||
|
color: _hovered ? widget.iconColor : EnsupColors.line,
|
||||||
|
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(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: iconColor.withValues(alpha: 0.1),
|
color: widget.iconColor.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Icon(icon, color: iconColor, size: 24),
|
child: Icon(widget.icon, color: widget.iconColor, size: 24),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
label,
|
widget.label,
|
||||||
style: GoogleFonts.darkerGrotesque(
|
style: GoogleFonts.darkerGrotesque(
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w800,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
|
height: 1.05,
|
||||||
color: EnsupColors.text,
|
color: EnsupColors.text,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 3),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
description,
|
widget.description,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: GoogleFonts.titilliumWeb(
|
style: GoogleFonts.titilliumWeb(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: EnsupColors.muted,
|
color: EnsupColors.muted,
|
||||||
height: 1.4,
|
height: 1.35,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Icon(Icons.chevron_right, color: EnsupColors.muted, size: 20),
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,17 +24,28 @@ class MaterielCard extends StatelessWidget {
|
|||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: EnsupColors.line, width: 1.5),
|
border: Border.all(color: EnsupColors.line, width: 1.5),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: EnsupColors.text.withValues(alpha: 0.035),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 3),
|
||||||
),
|
),
|
||||||
child: Row(
|
],
|
||||||
|
),
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final compact = constraints.maxWidth < 620;
|
||||||
|
final information = Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 44,
|
width: 46,
|
||||||
height: 44,
|
height: 46,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: accent.withValues(alpha: 0.08),
|
color: accent.withValues(alpha: 0.09),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
child: Icon(item.icon, color: accent, size: 20),
|
child: Icon(item.icon, color: accent, size: 21),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -43,34 +54,79 @@ class MaterielCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
item.nom,
|
item.nom,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: GoogleFonts.darkerGrotesque(
|
style: GoogleFonts.darkerGrotesque(
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w800,
|
||||||
fontSize: 16,
|
fontSize: 17,
|
||||||
|
height: 1.05,
|
||||||
color: EnsupColors.text,
|
color: EnsupColors.text,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
"${item.categorie} · Réf. ${item.reference} · ${item.campus}",
|
"${item.categorie} · Réf. ${item.reference}",
|
||||||
style: GoogleFonts.titilliumWeb(fontSize: 12, color: EnsupColors.muted),
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
],
|
||||||
|
);
|
||||||
|
final actions = Wrap(
|
||||||
|
spacing: 10,
|
||||||
|
runSpacing: 8,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
alignment: WrapAlignment.end,
|
||||||
|
children: [
|
||||||
StatusPill(disponible: disponible),
|
StatusPill(disponible: disponible),
|
||||||
const SizedBox(width: 12),
|
|
||||||
if (disponible)
|
if (disponible)
|
||||||
FilledButton(
|
FilledButton.icon(
|
||||||
onPressed: onSelect,
|
onPressed: onSelect,
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: EnsupColors.cyan,
|
backgroundColor: EnsupColors.cyan,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
padding: const EdgeInsets.symmetric(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
horizontal: 15,
|
||||||
|
vertical: 11,
|
||||||
),
|
),
|
||||||
child: Text(
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(9),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.arrow_forward_rounded, size: 16),
|
||||||
|
label: Text(
|
||||||
"Sélectionner",
|
"Sélectionner",
|
||||||
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 14),
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
@@ -83,6 +139,27 @@ class MaterielCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,25 +18,41 @@ class ProfileCard extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
color: EnsupColors.cyan.withValues(alpha: 0.055),
|
||||||
colors: [
|
borderRadius: BorderRadius.circular(12),
|
||||||
EnsupColors.cyan.withValues(alpha: 0.06),
|
border: Border.all(
|
||||||
EnsupColors.teal.withValues(alpha: 0.04),
|
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),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(12),
|
child: LayoutBuilder(
|
||||||
border: Border.all(color: EnsupColors.cyan.withValues(alpha: 0.15), width: 1.5),
|
builder: (context, constraints) {
|
||||||
),
|
final showStatus = constraints.maxWidth >= 430;
|
||||||
child: Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 48,
|
width: 50,
|
||||||
height: 48,
|
height: 50,
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
gradient: LinearGradient(colors: [EnsupColors.teal, EnsupColors.cyan]),
|
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,
|
alignment: Alignment.center,
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -55,36 +71,66 @@ class ProfileCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
name,
|
name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: GoogleFonts.darkerGrotesque(
|
style: GoogleFonts.darkerGrotesque(
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
|
height: 1,
|
||||||
color: EnsupColors.text,
|
color: EnsupColors.text,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
subtitle,
|
subtitle,
|
||||||
style: GoogleFonts.titilliumWeb(fontSize: 12, color: EnsupColors.muted),
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 12,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (showStatus) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
padding: const EdgeInsets.symmetric(
|
||||||
decoration: BoxDecoration(
|
horizontal: 11,
|
||||||
color: EnsupColors.green.withValues(alpha: 0.12),
|
vertical: 6,
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
),
|
),
|
||||||
child: Text(
|
decoration: BoxDecoration(
|
||||||
"Connecté",
|
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(
|
style: GoogleFonts.titilliumWeb(
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w700,
|
||||||
color: EnsupColors.green,
|
color: EnsupColors.green,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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,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,
|
||||||
|
};
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<script src="msal_bridge.bundle.js"></script>
|
||||||
<script src="flutter_bootstrap.js" async></script>
|
<script src="flutter_bootstrap.js" async></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -36,11 +36,12 @@ l'historique complet, y compris des étapes devenues obsolètes après brancheme
|
|||||||
| Infrastructure | Terminé | Docker Compose SQL Server + Adminer, monorepo, backend Node/TS, frontend Flutter Web. |
|
| Infrastructure | Terminé | Docker Compose SQL Server + Adminer, monorepo, backend Node/TS, frontend Flutter Web. |
|
||||||
| Base de données | Terminé pour la V1 | Schéma Prisma 16 entités, migrations, seeds et fixtures de démonstration. |
|
| Base de données | Terminé pour la V1 | Schéma Prisma 16 entités, migrations, seeds et fixtures de démonstration. |
|
||||||
| API étudiant | Terminé pour la V1 | Catalogue, détail matériel, création d'emprunt, mes emprunts, restitution, anomalie automatique. |
|
| API étudiant | Terminé pour la V1 | Catalogue, détail matériel, création d'emprunt, mes emprunts, restitution, anomalie automatique. |
|
||||||
| Frontend étudiant | Terminé pour la V1 | Parcours emprunt et restitution branchés sur l'API et testés en réel. |
|
| Frontend étudiant | Fonctionnel pour la V1 | Parcours API complet, ordre identification puis choix validé, interface premium et responsive fidèle à la maquette. |
|
||||||
| Authentification | Simulée | `x-user-email` côté backend et identité démo côté frontend ; Azure AD reste à faire. |
|
| Authentification | Prête pour Azure, démo active | Double mode sécurisé : `x-user-email` seulement en développement, MSAL/OIDC + Bearer JWT en mode Azure. Validation réelle en attente des identifiants Entra ID. |
|
||||||
| Responsable matériel | Non démarré | Dashboard, stock, anomalies, historique à développer. |
|
| API responsable | 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. |
|
| Tests automatisés | Non démarré | Tests backend/frontend/E2E à ajouter ; tests runtime manuels effectués. |
|
||||||
| Documentation | Partielle | `CONTEXT.md`, `TODO.md`, `review.md` existent ; README principal et OpenAPI restent à créer. |
|
| Documentation | Partielle | README principal et documents de conception présents ; OpenAPI, guides utilisateur et captures restent à produire. |
|
||||||
|
|
||||||
## Commandes validées
|
## Commandes validées
|
||||||
|
|
||||||
@@ -55,14 +56,17 @@ npm run lint
|
|||||||
npm run seed
|
npm run seed
|
||||||
npm run fixtures
|
npm run fixtures
|
||||||
cd ../eme-frontend
|
cd ../eme-frontend
|
||||||
|
npm run build:auth
|
||||||
flutter run -d web-server --web-port 5000
|
flutter run -d web-server --web-port 5000
|
||||||
|
C:\flutter\flutter\bin\cache\dart-sdk\bin\dart.exe analyze
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes :
|
Notes :
|
||||||
- le backend écoute sur `http://localhost:3000` ;
|
- le backend écoute sur `http://localhost:3000` ;
|
||||||
- le frontend web de démo écoute sur `http://localhost:5000` ;
|
- le frontend web de démo écoute sur `http://localhost:5000` ;
|
||||||
- Adminer est disponible sur `http://localhost:8081` ;
|
- Adminer est disponible sur `http://localhost:8081` ;
|
||||||
- dans l'environnement Codex, `flutter analyze`, `flutter analyze --no-pub` et `dart format` ont déjà bloqué au timeout ; à relancer dans un terminal local Flutter.
|
- le wrapper `flutter` peut rester bloqué dans l'environnement Codex ; l'analyse directe avec l'exécutable Dart fonctionne et ne signale aucune erreur ;
|
||||||
|
- la compilation Web complète a été validée avec l'exécutable Dart du SDK Flutter et produit `build/web`.
|
||||||
|
|
||||||
## Scénario de démo validé
|
## Scénario de démo validé
|
||||||
|
|
||||||
@@ -75,6 +79,14 @@ Scénario étudiant validé avec SQL Server Docker et backend compilé :
|
|||||||
5. Restituer conforme : l'emprunt passe `CLOTURE` et le matériel redevient disponible.
|
5. Restituer conforme : l'emprunt passe `CLOTURE` et le matériel redevient disponible.
|
||||||
6. Restituer non conforme : l'emprunt passe `RETOUR_NON_CONFORME` et le matériel sort du catalogue disponible.
|
6. Restituer non conforme : l'emprunt passe `RETOUR_NON_CONFORME` et le matériel sort du catalogue disponible.
|
||||||
|
|
||||||
|
Scénario responsable validé avec SQL Server Docker et backend compilé :
|
||||||
|
|
||||||
|
1. Karim (`RESPONSABLE`) accède au dashboard, aux emprunts, au stock, aux anomalies, aux notifications et à l'historique de son campus.
|
||||||
|
2. Lucas (`ETUDIANT`) reçoit une réponse `403` sur les routes responsable.
|
||||||
|
3. Une anomalie peut avancer dans le cycle `DETECTEE -> EN_COURS_TRAITEMENT -> RESOLUE -> CLOTUREE`.
|
||||||
|
4. Chaque transition enregistre le responsable et crée une entrée d'historique.
|
||||||
|
5. L'API génère l'export CSV de l'historique et l'interface Flutter déclenche son téléchargement.
|
||||||
|
|
||||||
### Note outillage — migrations en environnement non-interactif
|
### Note outillage — migrations en environnement non-interactif
|
||||||
|
|
||||||
`prisma migrate dev` se bloque quand il doit demander une confirmation (ex. ajout de contrainte `@unique` sur table existante) car le terminal est non-interactif.
|
`prisma migrate dev` se bloque quand il doit demander une confirmation (ex. ajout de contrainte `@unique` sur table existante) car le terminal est non-interactif.
|
||||||
@@ -259,7 +271,7 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
|
|||||||
| `Materiel.statut` -> `@default("DISPONIBLE")` | À faire avec migration + ERD | Faible : le code fournit déjà le statut explicitement. |
|
| `Materiel.statut` -> `@default("DISPONIBLE")` | À faire avec migration + ERD | Faible : le code fournit déjà le statut explicitement. |
|
||||||
| Tailles de colonnes `@db.NVarChar(n)` | À cadrer | Faible V1, important pour qualité BDD. |
|
| Tailles de colonnes `@db.NVarChar(n)` | À cadrer | Faible V1, important pour qualité BDD. |
|
||||||
| Swagger/OpenAPI | À faire | Moyen : utile pour tester/documenter l'API. |
|
| Swagger/OpenAPI | À faire | Moyen : utile pour tester/documenter l'API. |
|
||||||
| Auth réelle Azure AD | À faire | Fort : requis pour sortir de la démo avec auth simulée. |
|
| Validation réelle Azure AD | En attente des identifiants Entra ID | Fort : le code OIDC/MSAL/JWT est prêt, mais une connexion Microsoft réelle doit encore être testée sur le tenant ENSUP. |
|
||||||
| Limite d'emprunts actifs par étudiant | À valider métier | Non bloquant V1 ; décision métier non présente dans les règles actuelles. |
|
| Limite d'emprunts actifs par étudiant | À valider métier | Non bloquant V1 ; décision métier non présente dans les règles actuelles. |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -324,6 +336,128 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
|
|||||||
- Dette technique reformatée en tableau avec statut et impact V1.
|
- Dette technique reformatée en tableau avec statut et impact V1.
|
||||||
- Clarification des étapes frontend statiques historiques : elles sont conservées pour mémoire mais remplacées par les branchements API ultérieurs.
|
- Clarification des étapes frontend statiques historiques : elles sont conservées pour mémoire mais remplacées par les branchements API ultérieurs.
|
||||||
|
|
||||||
|
### Étape 34 — API responsable : dashboard
|
||||||
|
- Branche dédiée `feat/responsable-dashboard-api` créée depuis `develop`.
|
||||||
|
- Endpoint `GET /api/responsable/dashboard` ajouté, protégé par rôle `RESPONSABLE`.
|
||||||
|
- Périmètre campus appliqué via `user.campusId` (RG27).
|
||||||
|
- KPIs exposés : matériels par statut, emprunts par statut, anomalies par statut, notifications non lues.
|
||||||
|
- Activité récente exposée depuis l'historique, avec utilisateur, matériel et emprunt associé.
|
||||||
|
- Vérifié au runtime : Karim (`RESPONSABLE`) obtient 200 avec KPIs ; Lucas (`ETUDIANT`) obtient 403.
|
||||||
|
|
||||||
|
### Étape 35 — API responsable : consultation des emprunts
|
||||||
|
- Branche dédiée `feat/responsable-emprunts-api` créée après merge du dashboard responsable dans `develop`.
|
||||||
|
- Endpoint `GET /api/responsable/emprunts` ajouté, protégé par rôle `RESPONSABLE`.
|
||||||
|
- Filtrage par campus du responsable appliqué systématiquement (RG27).
|
||||||
|
- Filtre optionnel `statut` ajouté pour RG28 : `EN_COURS`, `EN_RETARD`, `CLOTURE`, `RETOUR_NON_CONFORME`, `ANNULE`.
|
||||||
|
- Réponse enrichie avec l'étudiant, le matériel, la catégorie, les dates et le statut.
|
||||||
|
- Vérifié au runtime : Karim obtient les emprunts du campus, `statut=EN_COURS` filtre correctement, statut invalide renvoie 400, Lucas obtient 403.
|
||||||
|
|
||||||
|
### Étape 36 — API responsable : consultation du stock
|
||||||
|
- Branche dédiée `feat/responsable-stock-api` créée après merge de la consultation des emprunts responsable dans `develop`.
|
||||||
|
- Endpoint `GET /api/responsable/materiels` ajouté, protégé par rôle `RESPONSABLE`.
|
||||||
|
- Filtrage par campus du responsable appliqué systématiquement (RG27/RG29).
|
||||||
|
- Filtres optionnels ajoutés : `statut`, `categorieId`, `q` (nom, marque, modèle, référence).
|
||||||
|
- Contrairement au catalogue étudiant, cet endpoint retourne tout le stock actif du campus, quel que soit le statut.
|
||||||
|
- Réponse enrichie avec catégorie et accessoires attendus.
|
||||||
|
- Vérifié au runtime : Karim obtient tout le stock du campus, `statut=DISPONIBLE` et `q=MacBook` filtrent correctement, statut invalide renvoie 400, Lucas obtient 403.
|
||||||
|
|
||||||
|
### Étape 37 — API responsable : consultation des anomalies
|
||||||
|
- Branche dédiée `feat/responsable-anomalies-api` créée après merge de la consultation du stock responsable dans `develop`.
|
||||||
|
- Endpoint `GET /api/responsable/anomalies` ajouté, protégé par rôle `RESPONSABLE`.
|
||||||
|
- Filtrage par campus du responsable appliqué systématiquement via l'emprunt lié à l'anomalie (RG27).
|
||||||
|
- Filtres optionnels ajoutés : `statut` (`DETECTEE`, `EN_COURS_TRAITEMENT`, `RESOLUE`, `CLOTUREE`) et `type`.
|
||||||
|
- Réponse enrichie avec anomalie, emprunt, étudiant concerné, matériel, catégorie et responsable de traitement si renseigné.
|
||||||
|
- Limite volontaire : consultation uniquement ; le cycle de traitement/résolution reste à développer.
|
||||||
|
|
||||||
|
### Étape 38 — API responsable : notifications
|
||||||
|
- Branche dédiée `feat/responsable-notifications-api` créée après merge de la consultation des anomalies responsable dans `develop`.
|
||||||
|
- Endpoint `GET /api/responsable/notifications` ajouté, protégé par rôle `RESPONSABLE`.
|
||||||
|
- Filtre optionnel `lu=true|false` ajouté pour séparer les notifications lues et non lues.
|
||||||
|
- Endpoint `PATCH /api/responsable/notifications/:id/lu` ajouté pour marquer une notification du responsable connecté comme lue.
|
||||||
|
- Endpoint `PATCH /api/responsable/notifications/lu-toutes` ajouté pour marquer toutes les notifications non lues du responsable connecté comme lues.
|
||||||
|
- Réponse enrichie avec l'anomalie liée et le matériel concerné lorsque la notification pointe vers une anomalie.
|
||||||
|
|
||||||
|
### Étape 39 — API responsable : historique et export
|
||||||
|
- Branche dédiée `feat/responsable-historique-api` créée après merge des notifications responsable dans `develop`.
|
||||||
|
- Endpoint `GET /api/responsable/historique` ajouté, protégé par rôle `RESPONSABLE`.
|
||||||
|
- Filtrage par campus du responsable appliqué systématiquement (RG27).
|
||||||
|
- Filtres optionnels ajoutés : `action`, `utilisateurId`, `materielId`, `empruntId`, `dateDebut`, `dateFin`.
|
||||||
|
- Endpoint `GET /api/responsable/historique/export.csv` ajouté avec les mêmes filtres.
|
||||||
|
- Export CSV généré côté API avec les colonnes principales : date, action, description, utilisateur, email, matériel, emprunt.
|
||||||
|
|
||||||
|
### Étape 40 — API responsable : cycle de statut des anomalies
|
||||||
|
- Branche dédiée `feat/responsable-anomalies-cycle-api` créée après merge de l'historique responsable dans `develop`.
|
||||||
|
- Endpoint `PATCH /api/responsable/anomalies/:id/statut` ajouté, protégé par rôle `RESPONSABLE`.
|
||||||
|
- Filtrage par campus appliqué avant toute modification pour empêcher le traitement d'une anomalie hors campus (RG27).
|
||||||
|
- Transitions autorisées : `DETECTEE -> EN_COURS_TRAITEMENT -> RESOLUE -> CLOTUREE`.
|
||||||
|
- Le responsable connecté est enregistré dans `traiteeParId`; une observation optionnelle peut être conservée.
|
||||||
|
- Une entrée d'historique `TRAITEMENT_ANOMALIE` est créée à chaque transition.
|
||||||
|
|
||||||
|
### Étape 41 — Frontend responsable : premier branchement API
|
||||||
|
- Branche dédiée `feat/responsable-frontend-api` créée pour isoler le travail frontend responsable.
|
||||||
|
- Le bouton "Accès responsable matériel" de l'écran d'identification ouvre maintenant un écran responsable.
|
||||||
|
- Client API étendu pour accepter l'e-mail simulé du responsable (`karim.benali@ensup.eu`) et les requêtes `PATCH`.
|
||||||
|
- Service frontend responsable ajouté pour consommer dashboard, emprunts, stock, anomalies, notifications et historique.
|
||||||
|
- Écran responsable ajouté avec onglets : dashboard, emprunts, stock, anomalies, notifications, historique.
|
||||||
|
- Les anomalies peuvent être avancées au prochain statut autorisé via l'API.
|
||||||
|
- Vérification statique effectuée avec l'exécutable Dart direct : `dart analyze` OK. Le wrapper `flutter` reste instable dans cette session et bloque au lancement web automatisé.
|
||||||
|
|
||||||
|
### Étape 42 — Frontend responsable : polish opérationnel
|
||||||
|
- Branche dédiée `feat/responsable-frontend-polish` créée après merge du premier branchement responsable.
|
||||||
|
- Statuts responsables remplacés par des libellés métier lisibles et des badges colorés.
|
||||||
|
- Recherche ajoutée sur les onglets emprunts, stock, anomalies, notifications et historique.
|
||||||
|
- Les lignes de listes affichent désormais une méta-information utile : identifiant emprunt, marque/modèle, date de détection, date de notification ou auteur historique.
|
||||||
|
- Action "Tout marquer lu" ajoutée dans l'onglet notifications.
|
||||||
|
- Action export CSV rendue visible dans l'onglet historique avec indication de l'endpoint disponible.
|
||||||
|
- Vérification statique : `dart analyze` OK.
|
||||||
|
|
||||||
|
### Étape 43 — Synchronisation de l'état courant
|
||||||
|
- Synthèse mise en cohérence avec les étapes 34 à 42 : API et frontend responsable désormais fonctionnels pour la V1.
|
||||||
|
- Documentation corrigée pour refléter l'existence du README principal.
|
||||||
|
- Limites restantes explicitées : authentification Azure AD, téléchargement CSV frontend, tests automatisés, OpenAPI et guides utilisateur.
|
||||||
|
- Commandes de vérification actualisées : build et lint backend verts, analyse Dart directe sans erreur.
|
||||||
|
|
||||||
|
### Étape 44 — Refonte premium de l'espace responsable
|
||||||
|
- Branche dédiée `style/responsable-premium-dashboard` créée depuis `develop`.
|
||||||
|
- L'espace responsable adopte une structure de supervision distincte du parcours étudiant : barre supérieure métier, navigation latérale sur ordinateur et navigation compacte sur petit écran.
|
||||||
|
- Dashboard enrichi avec disponibilités, emprunts en cours, retards, anomalies actives, notifications non lues, activité récente et taux de disponibilité du parc.
|
||||||
|
- Accès directs ajoutés depuis les points d'attention vers les vues emprunts, stock, anomalies et notifications.
|
||||||
|
- Les listes métier partagent désormais une hiérarchie visuelle, une recherche et des lignes responsives cohérentes avec la charte ENSUP.
|
||||||
|
- Aucun changement backend ni métier ; rendu validé manuellement dans le navigateur et `dart analyze` sans erreur.
|
||||||
|
|
||||||
|
### Étape 45 — Téléchargement CSV de l'historique responsable
|
||||||
|
- Branche dédiée `feat/responsable-historique-export` créée depuis `develop`.
|
||||||
|
- Client HTTP étendu pour récupérer un fichier binaire avec son type de contenu et son nom.
|
||||||
|
- Le bouton `Export CSV` appelle désormais l'endpoint responsable avec l'identité simulée de Karim et déclenche le téléchargement navigateur.
|
||||||
|
- Un état de chargement bloque les doubles clics et les erreurs API sont affichées dans l'interface.
|
||||||
|
- Endpoint vérifié au runtime : réponse `200`, type `text/csv`, nom `historique-responsable.csv` et contenu non vide.
|
||||||
|
- Vérification statique : `dart analyze` sans erreur.
|
||||||
|
|
||||||
|
### Étape 46 — Refonte premium de l'espace étudiant
|
||||||
|
- L'ordre actuel du parcours est confirmé : identification, puis choix `Emprunter` ou `Restituer`.
|
||||||
|
- Branche dédiée `style/student-premium-workspace` créée depuis `develop`.
|
||||||
|
- Charte et maquette conservées : couleurs ENSUP, typographies, barre supérieure, coins de marque et structure des écrans.
|
||||||
|
- Barre supérieure rendue responsive, avec priorité donnée au titre et à l'identité sur les petites largeurs.
|
||||||
|
- Accueil rééquilibré avec profil identifié, hiérarchie renforcée et cartes d'action adaptatives avec états de survol.
|
||||||
|
- Catalogue, détail matériel et sélection de restitution enrichis sans modifier leurs données ni leurs actions.
|
||||||
|
- Détail et checklists passent automatiquement de deux colonnes à une colonne sur écran étroit ; tableau de contrôle et lignes de matériel évitent les débordements.
|
||||||
|
- Écrans de confirmation et de résultat conservés fonctionnellement et harmonisés via les composants partagés.
|
||||||
|
- Vérification statique : `dart analyze` sans erreur.
|
||||||
|
|
||||||
|
### Étape 47 — Sécurisation SSO Microsoft Entra ID
|
||||||
|
- Branche dédiée `feat/block-3-azure-sso` créée depuis `develop`.
|
||||||
|
- Backend configuré avec deux modes : `demo` en développement et `azure` pour une authentification réelle ; le mode démo est explicitement interdit en production.
|
||||||
|
- Middleware d'authentification refactoré vers un service dédié. En mode Azure, `x-user-email` est ignoré et un Bearer Token Microsoft est obligatoire.
|
||||||
|
- Validation JWT ajoutée : signature RSA via les clés JWKS Microsoft, algorithme `RS256`, issuer, audience, tenant, scope `access_as_user`, identifiant `oid` et domaines `ensup.eu` / `ensitech.eu`.
|
||||||
|
- L'utilisateur, son rôle et son campus restent résolus depuis `eme_db`. Un étudiant obtient `403` sur la supervision, tandis que le responsable de démonstration obtient `200`.
|
||||||
|
- Frontend doté d'un double mode de session. La simulation sélectionne Lucas ou Karim sans pouvoir être utilisée en mode Azure.
|
||||||
|
- Bibliothèque officielle `@azure/msal-browser` intégrée via un bundle local construit avec esbuild et un pont typé `dart:js_interop`.
|
||||||
|
- Flux OpenID Connect préparé avec connexion Microsoft, acquisition silencieuse de l'Access Token, appel de `/api/auth/me`, navigation selon le rôle local, restauration après actualisation et déconnexion MSAL.
|
||||||
|
- Profil authentifié typé et affiché dans les espaces étudiant et responsable : nom, initiales, classe, rôle et campus ne sont plus figés sur les identités de démonstration.
|
||||||
|
- Vérifications réussies : build et lint backend, analyse Dart, compilation Flutter Web complète et contrôles runtime des rôles en mode démo.
|
||||||
|
- Limite actuelle : faute d'identifiants Entra ID, le flux Microsoft réel n'a pas encore pu être exécuté. Le QR sécurisé reste un sous-bloc séparé à développer après le SSO.
|
||||||
|
- Commits : `313bb1c`, `530bd52`, `ef31e2a`, `dc208fa`, `36224f3`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Dernière mise à jour : 2026-07-09 — État courant du projet et journal de décisions clarifiés.*
|
*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