Compare commits
27 Commits
d8f044924e
...
3681ed5e44
| Author | SHA1 | Date | |
|---|---|---|---|
| 3681ed5e44 | |||
| cbc67c6c2f | |||
| eb3094f3a6 | |||
| 245a6905a3 | |||
| 8c710b6e3c | |||
| 9c74de53c4 | |||
| 78a0c1305e | |||
| d83aa7dd75 | |||
| e46ec29543 | |||
| 98ba359bc5 | |||
| 7aba1a930d | |||
| 27171562de | |||
| 9564dd1ade | |||
| 90d5a65f29 | |||
| e9d67e78f5 | |||
| 648ea21155 | |||
| 0f72c021ba | |||
| 9941ae97dc | |||
| b124d7a775 | |||
| 84bd2cc629 | |||
| 97a1ad9e84 | |||
| 97faf3e9ee | |||
| e492220af4 | |||
| b2c999026f | |||
| d12302e8a6 | |||
| be7d095fc3 | |||
| 3215a3c11f |
Binary file not shown.
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.build.json",
|
"build": "tsc -p tsconfig.build.json",
|
||||||
"start": "node dist/app.js",
|
"start": "node dist/app.js",
|
||||||
"dev": "nodemon --exec ts-node src/app.ts",
|
"dev": "nodemon --exec ts-node --files src/app.ts",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:studio": "prisma studio",
|
"prisma:studio": "prisma studio",
|
||||||
|
|||||||
@@ -51,14 +51,175 @@ function daysFromNow(n: number): Date {
|
|||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
interface DemoCategories {
|
||||||
// Idempotence : on ne recharge pas si déjà présent.
|
ordinateur: number;
|
||||||
const witness = await prisma.utilisateur.findUnique({ where: { email: WITNESS_EMAIL } });
|
video: number;
|
||||||
if (witness) {
|
tablette: number;
|
||||||
console.log('[fixtures] deja chargees — rien a faire');
|
camera: number;
|
||||||
return;
|
audio: number;
|
||||||
|
cable: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DemoMateriel {
|
||||||
|
nom: string;
|
||||||
|
marque: string;
|
||||||
|
modele: string;
|
||||||
|
reference: string;
|
||||||
|
numeroInventaire: string;
|
||||||
|
numeroSerie: string;
|
||||||
|
etatGeneral: string;
|
||||||
|
categorieId: number;
|
||||||
|
accessoires?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assurerAccessoire(nom: string, description: string): Promise<number> {
|
||||||
|
const existant = await prisma.accessoire.findFirst({ where: { nom } });
|
||||||
|
if (existant) {
|
||||||
|
return existant.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessoire = await prisma.accessoire.create({ data: { nom, description } });
|
||||||
|
return accessoire.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assurerMaterielDemo(data: DemoMateriel, campusId: number): Promise<void> {
|
||||||
|
let materiel = await prisma.materiel.findFirst({ where: { reference: data.reference } });
|
||||||
|
|
||||||
|
if (!materiel) {
|
||||||
|
materiel = await prisma.materiel.create({
|
||||||
|
data: {
|
||||||
|
nom: data.nom,
|
||||||
|
marque: data.marque,
|
||||||
|
modele: data.modele,
|
||||||
|
reference: data.reference,
|
||||||
|
numeroInventaire: data.numeroInventaire,
|
||||||
|
numeroSerie: data.numeroSerie,
|
||||||
|
statut: 'DISPONIBLE',
|
||||||
|
etatGeneral: data.etatGeneral,
|
||||||
|
categorieId: data.categorieId,
|
||||||
|
campusId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const nomAccessoire of data.accessoires ?? []) {
|
||||||
|
const accessoireId = await assurerAccessoire(nomAccessoire, `Accessoire ${nomAccessoire}.`);
|
||||||
|
const liaison = await prisma.materielAccessoire.findFirst({
|
||||||
|
where: { materielId: materiel.id, accessoireId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!liaison) {
|
||||||
|
await prisma.materielAccessoire.create({
|
||||||
|
data: {
|
||||||
|
materielId: materiel.id,
|
||||||
|
accessoireId,
|
||||||
|
quantiteAttendue: 1,
|
||||||
|
obligatoire: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assurerCatalogueDemo(campusId: number, categories: DemoCategories): Promise<void> {
|
||||||
|
const materiels: DemoMateriel[] = [
|
||||||
|
{
|
||||||
|
nom: 'PC Lenovo ThinkPad E14',
|
||||||
|
marque: 'Lenovo',
|
||||||
|
modele: 'ThinkPad E14',
|
||||||
|
reference: 'REF-PC-003',
|
||||||
|
numeroInventaire: 'INV-PC-003',
|
||||||
|
numeroSerie: 'SN-LENOVO-003',
|
||||||
|
etatGeneral: 'Bon etat',
|
||||||
|
categorieId: categories.ordinateur,
|
||||||
|
accessoires: ['Chargeur', 'Souris', 'Housse'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nom: 'MacBook Air M2',
|
||||||
|
marque: 'Apple',
|
||||||
|
modele: 'MacBook Air 13',
|
||||||
|
reference: 'REF-PC-004',
|
||||||
|
numeroInventaire: 'INV-PC-004',
|
||||||
|
numeroSerie: 'SN-MBA-004',
|
||||||
|
etatGeneral: 'Tres bon etat',
|
||||||
|
categorieId: categories.ordinateur,
|
||||||
|
accessoires: ['Chargeur USB-C', 'Housse'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nom: 'Microsoft Surface Pro',
|
||||||
|
marque: 'Microsoft',
|
||||||
|
modele: 'Surface Pro 9',
|
||||||
|
reference: 'REF-TAB-002',
|
||||||
|
numeroInventaire: 'INV-TAB-002',
|
||||||
|
numeroSerie: 'SN-SURFACE-002',
|
||||||
|
etatGeneral: 'Bon etat',
|
||||||
|
categorieId: categories.tablette,
|
||||||
|
accessoires: ['Clavier Surface', 'Stylet'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nom: 'Vidéoprojecteur Epson EB-X49',
|
||||||
|
marque: 'Epson',
|
||||||
|
modele: 'EB-X49',
|
||||||
|
reference: 'REF-VP-002',
|
||||||
|
numeroInventaire: 'INV-VP-002',
|
||||||
|
numeroSerie: 'SN-EPSON-002',
|
||||||
|
etatGeneral: 'Bon etat',
|
||||||
|
categorieId: categories.video,
|
||||||
|
accessoires: ['Telecommande', 'Cable HDMI'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nom: 'Caméra Canon EOS M50',
|
||||||
|
marque: 'Canon',
|
||||||
|
modele: 'EOS M50',
|
||||||
|
reference: 'REF-CAM-002',
|
||||||
|
numeroInventaire: 'INV-CAM-002',
|
||||||
|
numeroSerie: 'SN-CANON-002',
|
||||||
|
etatGeneral: 'Bon etat',
|
||||||
|
categorieId: categories.camera,
|
||||||
|
accessoires: ['Batterie', 'Carte SD', 'Housse'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nom: 'Casque Jabra Evolve',
|
||||||
|
marque: 'Jabra',
|
||||||
|
modele: 'Evolve 40',
|
||||||
|
reference: 'REF-AUD-002',
|
||||||
|
numeroInventaire: 'INV-AUD-002',
|
||||||
|
numeroSerie: 'SN-JABRA-002',
|
||||||
|
etatGeneral: 'Bon etat',
|
||||||
|
categorieId: categories.audio,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nom: 'Kit câbles HDMI / USB-C',
|
||||||
|
marque: 'ENSUP',
|
||||||
|
modele: 'Kit connectique',
|
||||||
|
reference: 'REF-CAB-001',
|
||||||
|
numeroInventaire: 'INV-CAB-001',
|
||||||
|
numeroSerie: 'SN-CABLE-001',
|
||||||
|
etatGeneral: 'Bon etat',
|
||||||
|
categorieId: categories.cable,
|
||||||
|
accessoires: ['Cable HDMI', 'Adaptateur USB-C'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nom: 'Enceinte Bluetooth JBL',
|
||||||
|
marque: 'JBL',
|
||||||
|
modele: 'Flip 6',
|
||||||
|
reference: 'REF-AUD-003',
|
||||||
|
numeroInventaire: 'INV-AUD-003',
|
||||||
|
numeroSerie: 'SN-JBL-003',
|
||||||
|
etatGeneral: 'Bon etat',
|
||||||
|
categorieId: categories.audio,
|
||||||
|
accessoires: ['Cable USB-C'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const materiel of materiels) {
|
||||||
|
await assurerMaterielDemo(materiel, campusId);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[fixtures] ${materiels.length} materiels de catalogue assures`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
// Références (doivent exister via le seed).
|
// Références (doivent exister via le seed).
|
||||||
const roleEtudiant = required(
|
const roleEtudiant = required(
|
||||||
await prisma.role.findUnique({ where: { code: 'ETUDIANT' } }),
|
await prisma.role.findUnique({ where: { code: 'ETUDIANT' } }),
|
||||||
@@ -100,6 +261,28 @@ async function main(): Promise<void> {
|
|||||||
await prisma.categorieMateriel.findFirst({ where: { nom: 'Périphérique audio' } }),
|
await prisma.categorieMateriel.findFirst({ where: { nom: 'Périphérique audio' } }),
|
||||||
'Categorie Périphérique audio manquante',
|
'Categorie Périphérique audio manquante',
|
||||||
);
|
);
|
||||||
|
const catCable = required(
|
||||||
|
await prisma.categorieMateriel.findFirst({ where: { nom: 'Câble / Adaptateur' } }),
|
||||||
|
'Categorie Câble / Adaptateur manquante',
|
||||||
|
);
|
||||||
|
|
||||||
|
const demoCategories: DemoCategories = {
|
||||||
|
ordinateur: catOrdi.id,
|
||||||
|
video: catVideo.id,
|
||||||
|
tablette: catTablette.id,
|
||||||
|
camera: catCamera.id,
|
||||||
|
audio: catAudio.id,
|
||||||
|
cable: catCable.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Idempotence : si les fixtures de base existent déjà, on assure seulement
|
||||||
|
// les matériels de catalogue ajoutés après coup.
|
||||||
|
const witness = await prisma.utilisateur.findUnique({ where: { email: WITNESS_EMAIL } });
|
||||||
|
if (witness) {
|
||||||
|
await assurerCatalogueDemo(campus.id, demoCategories);
|
||||||
|
console.log('[fixtures] deja chargees — catalogue demo assure');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Utilisateurs : 3 étudiants (2 info @ensitech.eu, 1 autre @ensup.eu) + 1 responsable.
|
// Utilisateurs : 3 étudiants (2 info @ensitech.eu, 1 autre @ensup.eu) + 1 responsable.
|
||||||
const marie = await prisma.utilisateur.create({
|
const marie = await prisma.utilisateur.create({
|
||||||
@@ -280,6 +463,7 @@ async function main(): Promise<void> {
|
|||||||
{ materielId: camera.id, accessoireId: chargeur.id, quantiteAttendue: 1, obligatoire: true },
|
{ materielId: camera.id, accessoireId: chargeur.id, quantiteAttendue: 1, obligatoire: true },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
await assurerCatalogueDemo(campus.id, demoCategories);
|
||||||
|
|
||||||
// Emprunt 1 — EN_COURS (Marie / PC Dell) + checklist de départ.
|
// Emprunt 1 — EN_COURS (Marie / PC Dell) + checklist de départ.
|
||||||
const empruntEnCours = await prisma.emprunt.create({
|
const empruntEnCours = await prisma.emprunt.create({
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import { apiRouter } from './routes';
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(cors({ origin: env.frontendUrl }));
|
// En développement, on accepte toute origine locale (le front Flutter tourne sur
|
||||||
|
// un port variable) ; en production, seule l'origine du frontend est autorisée.
|
||||||
|
app.use(cors({ origin: env.nodeEnv === 'development' ? true : env.frontendUrl }));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(requestLogger);
|
app.use(requestLogger);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { AppError } from '../errors/app-error';
|
import { AppError } from '../errors/app-error';
|
||||||
import { listerMesEmpruntsEnCours } from '../services/emprunt.service';
|
import {
|
||||||
import { toEmpruntResponse } from '../dtos/emprunt.dto';
|
creerEmprunt,
|
||||||
|
listerMesEmpruntsEnCours,
|
||||||
|
restituerEmprunt,
|
||||||
|
} from '../services/emprunt.service';
|
||||||
|
import {
|
||||||
|
parseCreerEmpruntRequest,
|
||||||
|
parseRestituerEmpruntRequest,
|
||||||
|
toEmpruntResponse,
|
||||||
|
} from '../dtos/emprunt.dto';
|
||||||
|
|
||||||
export async function getMesEmprunts(req: Request, res: Response): Promise<void> {
|
export async function getMesEmprunts(req: Request, res: Response): Promise<void> {
|
||||||
const user = req.user;
|
const user = req.user;
|
||||||
@@ -12,3 +20,30 @@ export async function getMesEmprunts(req: Request, res: Response): Promise<void>
|
|||||||
const emprunts = await listerMesEmpruntsEnCours(user.id);
|
const emprunts = await listerMesEmpruntsEnCours(user.id);
|
||||||
res.json({ data: emprunts.map(toEmpruntResponse) });
|
res.json({ data: emprunts.map(toEmpruntResponse) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function postEmprunt(req: Request, res: Response): Promise<void> {
|
||||||
|
const user = req.user;
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(401, 'Authentification requise');
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = parseCreerEmpruntRequest(req.body);
|
||||||
|
const emprunt = await creerEmprunt(user.id, user.campusId, request);
|
||||||
|
res.status(201).json({ data: toEmpruntResponse(emprunt) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postRestitution(req: Request, res: Response): Promise<void> {
|
||||||
|
const user = req.user;
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(401, 'Authentification requise');
|
||||||
|
}
|
||||||
|
|
||||||
|
const empruntId = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(empruntId) || empruntId <= 0) {
|
||||||
|
throw new AppError(400, 'Identifiant emprunt invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = parseRestituerEmpruntRequest(req.body);
|
||||||
|
const emprunt = await restituerEmprunt(user.id, empruntId, request);
|
||||||
|
res.json({ data: toEmpruntResponse(emprunt) });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { AppError } from '../errors/app-error';
|
import { AppError } from '../errors/app-error';
|
||||||
import { listerCatalogue } from '../services/materiel.service';
|
import { listerCatalogue, obtenirDetailMateriel } from '../services/materiel.service';
|
||||||
import { toMaterielResponse } from '../dtos/materiel.dto';
|
import { toMaterielDetailResponse, toMaterielResponse } from '../dtos/materiel.dto';
|
||||||
|
|
||||||
function parseCategorieId(value: unknown): number | undefined {
|
function parseCategorieId(value: unknown): number | undefined {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
@@ -16,6 +16,18 @@ function parseCategorieId(value: unknown): number | undefined {
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseId(value: unknown): number {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new AppError(400, 'id invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||||
|
throw new AppError(400, 'id invalide');
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCatalogue(req: Request, res: Response): Promise<void> {
|
export async function getCatalogue(req: Request, res: Response): Promise<void> {
|
||||||
const user = req.user;
|
const user = req.user;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -28,3 +40,13 @@ export async function getCatalogue(req: Request, res: Response): Promise<void> {
|
|||||||
const materiels = await listerCatalogue(user.campusId, { categorieId, recherche });
|
const materiels = await listerCatalogue(user.campusId, { categorieId, recherche });
|
||||||
res.json({ data: materiels.map(toMaterielResponse) });
|
res.json({ data: materiels.map(toMaterielResponse) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getMaterielDetail(req: Request, res: Response): Promise<void> {
|
||||||
|
const user = req.user;
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(401, 'Authentification requise');
|
||||||
|
}
|
||||||
|
|
||||||
|
const materiel = await obtenirDetailMateriel(parseId(req.params.id), user.campusId);
|
||||||
|
res.json({ data: toMaterielDetailResponse(materiel) });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { EmpruntAvecMateriel } from '../repositories/emprunt.repository';
|
import { EmpruntAvecMateriel } from '../repositories/emprunt.repository';
|
||||||
import { MaterielResponse, toMaterielResponse } from './materiel.dto';
|
import { MaterielResponse, toMaterielResponse } from './materiel.dto';
|
||||||
|
import { AppError } from '../errors/app-error';
|
||||||
|
import { ETAT_CHECKLIST_ELEMENT, MODE_IDENTIFICATION } from '../models/enums';
|
||||||
|
|
||||||
export interface EmpruntResponse {
|
export interface EmpruntResponse {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -20,3 +22,125 @@ export function toEmpruntResponse(emprunt: EmpruntAvecMateriel): EmpruntResponse
|
|||||||
materiel: toMaterielResponse(emprunt.materiel),
|
materiel: toMaterielResponse(emprunt.materiel),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChecklistElementRequest {
|
||||||
|
accessoireId?: number;
|
||||||
|
nomElement: string;
|
||||||
|
etat: string;
|
||||||
|
quantiteConstatee: number;
|
||||||
|
commentaire?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreerEmpruntRequest {
|
||||||
|
materielId: number;
|
||||||
|
posteEmpruntId: number;
|
||||||
|
modeIdentification: string;
|
||||||
|
commentaireDepart?: string;
|
||||||
|
elements: ChecklistElementRequest[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function asObject(value: unknown, message: string): Record<string, unknown> {
|
||||||
|
if (typeof value !== 'object' || value === null) {
|
||||||
|
throw new AppError(400, message);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asPositiveInt(value: unknown, champ: string): number {
|
||||||
|
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
|
||||||
|
throw new AppError(400, `${champ} doit etre un entier positif`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asIntOrZero(value: unknown, champ: string): number {
|
||||||
|
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
||||||
|
throw new AppError(400, `${champ} doit etre un entier positif ou nul`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asEnum<T extends string>(value: unknown, autorisees: readonly T[], champ: string): T {
|
||||||
|
if (typeof value !== 'string' || !(autorisees as readonly string[]).includes(value)) {
|
||||||
|
throw new AppError(400, `${champ} invalide`);
|
||||||
|
}
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseElement(value: unknown, index: number): ChecklistElementRequest {
|
||||||
|
const obj = asObject(value, `elements[${index}] invalide`);
|
||||||
|
|
||||||
|
if (typeof obj.nomElement !== 'string' || obj.nomElement.trim() === '') {
|
||||||
|
throw new AppError(400, `elements[${index}].nomElement requis`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const element: ChecklistElementRequest = {
|
||||||
|
nomElement: obj.nomElement,
|
||||||
|
etat: asEnum(obj.etat, ETAT_CHECKLIST_ELEMENT, `elements[${index}].etat`),
|
||||||
|
quantiteConstatee: asIntOrZero(obj.quantiteConstatee, `elements[${index}].quantiteConstatee`),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (obj.accessoireId !== undefined) {
|
||||||
|
element.accessoireId = asPositiveInt(obj.accessoireId, `elements[${index}].accessoireId`);
|
||||||
|
}
|
||||||
|
if (obj.commentaire !== undefined) {
|
||||||
|
if (typeof obj.commentaire !== 'string') {
|
||||||
|
throw new AppError(400, `elements[${index}].commentaire invalide`);
|
||||||
|
}
|
||||||
|
element.commentaire = obj.commentaire;
|
||||||
|
}
|
||||||
|
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCreerEmpruntRequest(body: unknown): CreerEmpruntRequest {
|
||||||
|
const obj = asObject(body, 'Corps de requete invalide');
|
||||||
|
|
||||||
|
if (!Array.isArray(obj.elements) || obj.elements.length === 0) {
|
||||||
|
throw new AppError(400, 'La checklist de depart est obligatoire (RG11)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: CreerEmpruntRequest = {
|
||||||
|
materielId: asPositiveInt(obj.materielId, 'materielId'),
|
||||||
|
posteEmpruntId: asPositiveInt(obj.posteEmpruntId, 'posteEmpruntId'),
|
||||||
|
modeIdentification: asEnum(obj.modeIdentification, MODE_IDENTIFICATION, 'modeIdentification'),
|
||||||
|
elements: obj.elements.map((element, index) => parseElement(element, index)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (obj.commentaireDepart !== undefined) {
|
||||||
|
if (typeof obj.commentaireDepart !== 'string') {
|
||||||
|
throw new AppError(400, 'commentaireDepart invalide');
|
||||||
|
}
|
||||||
|
request.commentaireDepart = obj.commentaireDepart;
|
||||||
|
}
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestituerEmpruntRequest {
|
||||||
|
modeIdentification: string;
|
||||||
|
commentaireRetour?: string;
|
||||||
|
elements: ChecklistElementRequest[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRestituerEmpruntRequest(body: unknown): RestituerEmpruntRequest {
|
||||||
|
const obj = asObject(body, 'Corps de requete invalide');
|
||||||
|
|
||||||
|
if (!Array.isArray(obj.elements) || obj.elements.length === 0) {
|
||||||
|
throw new AppError(400, 'La checklist de retour est obligatoire (RG16)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: RestituerEmpruntRequest = {
|
||||||
|
modeIdentification: asEnum(obj.modeIdentification, MODE_IDENTIFICATION, 'modeIdentification'),
|
||||||
|
elements: obj.elements.map((element, index) => parseElement(element, index)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (obj.commentaireRetour !== undefined) {
|
||||||
|
if (typeof obj.commentaireRetour !== 'string') {
|
||||||
|
throw new AppError(400, 'commentaireRetour invalide');
|
||||||
|
}
|
||||||
|
request.commentaireRetour = obj.commentaireRetour;
|
||||||
|
}
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MaterielAvecCategorie } from '../repositories/materiel.repository';
|
import { MaterielAvecCategorie, MaterielDetail } from '../repositories/materiel.repository';
|
||||||
|
|
||||||
export interface CategorieResponse {
|
export interface CategorieResponse {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -16,6 +16,15 @@ export interface MaterielResponse {
|
|||||||
categorie: CategorieResponse;
|
categorie: CategorieResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MaterielDetailResponse extends MaterielResponse {
|
||||||
|
campus: {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
ville: string;
|
||||||
|
};
|
||||||
|
accessoires: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export function toMaterielResponse(materiel: MaterielAvecCategorie): MaterielResponse {
|
export function toMaterielResponse(materiel: MaterielAvecCategorie): MaterielResponse {
|
||||||
return {
|
return {
|
||||||
id: materiel.id,
|
id: materiel.id,
|
||||||
@@ -31,3 +40,15 @@ export function toMaterielResponse(materiel: MaterielAvecCategorie): MaterielRes
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toMaterielDetailResponse(materiel: MaterielDetail): MaterielDetailResponse {
|
||||||
|
return {
|
||||||
|
...toMaterielResponse(materiel),
|
||||||
|
campus: {
|
||||||
|
id: materiel.campus.id,
|
||||||
|
nom: materiel.campus.nom,
|
||||||
|
ville: materiel.campus.ville,
|
||||||
|
},
|
||||||
|
accessoires: materiel.accessoires.map((liaison) => liaison.accessoire.nom),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { prisma } from '../db/prisma';
|
import { prisma } from '../db/prisma';
|
||||||
import { StatutEmprunt } from '../models/enums';
|
import { StatutEmprunt } from '../models/enums';
|
||||||
|
import { AppError } from '../errors/app-error';
|
||||||
|
|
||||||
export type EmpruntAvecMateriel = Prisma.EmpruntGetPayload<{
|
export type EmpruntAvecMateriel = Prisma.EmpruntGetPayload<{
|
||||||
include: { materiel: { include: { categorie: true } } };
|
include: { materiel: { include: { categorie: true } } };
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export type EmpruntAvecChecklists = Prisma.EmpruntGetPayload<{
|
||||||
|
include: { checklists: { include: { elements: true } } };
|
||||||
|
}>;
|
||||||
|
|
||||||
const STATUTS_EN_COURS: StatutEmprunt[] = ['EN_COURS', 'EN_RETARD'];
|
const STATUTS_EN_COURS: StatutEmprunt[] = ['EN_COURS', 'EN_RETARD'];
|
||||||
|
|
||||||
export function findEnCoursParUtilisateur(utilisateurId: number): Promise<EmpruntAvecMateriel[]> {
|
export function findEnCoursParUtilisateur(utilisateurId: number): Promise<EmpruntAvecMateriel[]> {
|
||||||
@@ -18,3 +23,173 @@ export function findEnCoursParUtilisateur(utilisateurId: number): Promise<Emprun
|
|||||||
orderBy: { dateRetourPrevue: 'asc' },
|
orderBy: { dateRetourPrevue: 'asc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findByIdAvecChecklists(id: number): Promise<EmpruntAvecChecklists | null> {
|
||||||
|
return prisma.emprunt.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { checklists: { include: { elements: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChecklistElementData {
|
||||||
|
accessoireId?: number;
|
||||||
|
nomElement: string;
|
||||||
|
etat: string;
|
||||||
|
quantiteConstatee: number;
|
||||||
|
commentaire?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreerEmpruntData {
|
||||||
|
utilisateurId: number;
|
||||||
|
materielId: number;
|
||||||
|
campusId: number;
|
||||||
|
sallePretId: number;
|
||||||
|
posteEmpruntId: number;
|
||||||
|
dateRetourPrevue: Date;
|
||||||
|
modeIdentification: string;
|
||||||
|
commentaireDepart?: string;
|
||||||
|
elements: ChecklistElementData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Création atomique : réservation du matériel (DISPONIBLE -> EMPRUNTE), emprunt,
|
||||||
|
checklist de départ et ses éléments. Si une étape échoue, tout est annulé. */
|
||||||
|
export function creerEmpruntComplet(data: CreerEmpruntData): Promise<EmpruntAvecMateriel> {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
// Réservation atomique : empêche un double emprunt du même matériel (RG07).
|
||||||
|
const reservation = await tx.materiel.updateMany({
|
||||||
|
where: { id: data.materielId, statut: 'DISPONIBLE' },
|
||||||
|
data: { statut: 'EMPRUNTE' },
|
||||||
|
});
|
||||||
|
if (reservation.count === 0) {
|
||||||
|
throw new AppError(409, 'Materiel non disponible');
|
||||||
|
}
|
||||||
|
|
||||||
|
const emprunt = await tx.emprunt.create({
|
||||||
|
data: {
|
||||||
|
utilisateurId: data.utilisateurId,
|
||||||
|
materielId: data.materielId,
|
||||||
|
campusId: data.campusId,
|
||||||
|
sallePretId: data.sallePretId,
|
||||||
|
posteEmpruntId: data.posteEmpruntId,
|
||||||
|
dateEmprunt: new Date(),
|
||||||
|
dateRetourPrevue: data.dateRetourPrevue,
|
||||||
|
statut: 'EN_COURS',
|
||||||
|
modeIdentificationEmprunt: data.modeIdentification,
|
||||||
|
commentaireDepart: data.commentaireDepart ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.checklist.create({
|
||||||
|
data: {
|
||||||
|
empruntId: emprunt.id,
|
||||||
|
utilisateurId: data.utilisateurId,
|
||||||
|
type: 'DEPART',
|
||||||
|
dateVerification: new Date(),
|
||||||
|
elements: {
|
||||||
|
create: data.elements.map((element) => ({
|
||||||
|
accessoireId: element.accessoireId ?? null,
|
||||||
|
nomElement: element.nomElement,
|
||||||
|
etat: element.etat,
|
||||||
|
quantiteConstatee: element.quantiteConstatee,
|
||||||
|
commentaire: element.commentaire ?? null,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return tx.emprunt.findUniqueOrThrow({
|
||||||
|
where: { id: emprunt.id },
|
||||||
|
include: { materiel: { include: { categorie: true } } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestituerEmpruntData {
|
||||||
|
empruntId: number;
|
||||||
|
utilisateurId: number;
|
||||||
|
materielId: number;
|
||||||
|
statutEmprunt: string;
|
||||||
|
statutMateriel: string;
|
||||||
|
modeIdentification: string;
|
||||||
|
commentaireRetour?: string;
|
||||||
|
elements: ChecklistElementData[];
|
||||||
|
anomalie?: {
|
||||||
|
type: string;
|
||||||
|
description: string;
|
||||||
|
responsablesIds: number[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Restitution atomique : checklist de retour, mise à jour de l'emprunt et du
|
||||||
|
matériel, et — si non conforme — création de l'anomalie et des notifications. */
|
||||||
|
export function restituerEmpruntComplet(data: RestituerEmpruntData): Promise<EmpruntAvecMateriel> {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const maintenant = new Date();
|
||||||
|
|
||||||
|
await tx.checklist.create({
|
||||||
|
data: {
|
||||||
|
empruntId: data.empruntId,
|
||||||
|
utilisateurId: data.utilisateurId,
|
||||||
|
type: 'RETOUR',
|
||||||
|
dateVerification: maintenant,
|
||||||
|
elements: {
|
||||||
|
create: data.elements.map((element) => ({
|
||||||
|
accessoireId: element.accessoireId ?? null,
|
||||||
|
nomElement: element.nomElement,
|
||||||
|
etat: element.etat,
|
||||||
|
quantiteConstatee: element.quantiteConstatee,
|
||||||
|
commentaire: element.commentaire ?? null,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.emprunt.update({
|
||||||
|
where: { id: data.empruntId },
|
||||||
|
data: {
|
||||||
|
statut: data.statutEmprunt,
|
||||||
|
dateRetourReelle: maintenant,
|
||||||
|
modeIdentificationRetour: data.modeIdentification,
|
||||||
|
commentaireRetour: data.commentaireRetour ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.materiel.update({
|
||||||
|
where: { id: data.materielId },
|
||||||
|
data: { statut: data.statutMateriel },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.anomalie) {
|
||||||
|
const infoAnomalie = data.anomalie;
|
||||||
|
const anomalie = await tx.anomalie.create({
|
||||||
|
data: {
|
||||||
|
empruntId: data.empruntId,
|
||||||
|
materielId: data.materielId,
|
||||||
|
utilisateurId: data.utilisateurId,
|
||||||
|
type: infoAnomalie.type,
|
||||||
|
description: infoAnomalie.description,
|
||||||
|
statut: 'DETECTEE',
|
||||||
|
detecteeAutomatiquement: true,
|
||||||
|
dateDetection: maintenant,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (infoAnomalie.responsablesIds.length > 0) {
|
||||||
|
await tx.notification.createMany({
|
||||||
|
data: infoAnomalie.responsablesIds.map((responsableId) => ({
|
||||||
|
utilisateurId: responsableId,
|
||||||
|
anomalieId: anomalie.id,
|
||||||
|
titre: 'Nouvelle anomalie detectee',
|
||||||
|
message: infoAnomalie.description,
|
||||||
|
type: 'ANOMALIE',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.emprunt.findUniqueOrThrow({
|
||||||
|
where: { id: data.empruntId },
|
||||||
|
include: { materiel: { include: { categorie: true } } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Materiel, Prisma } from '@prisma/client';
|
||||||
import { prisma } from '../db/prisma';
|
import { prisma } from '../db/prisma';
|
||||||
|
|
||||||
export type MaterielAvecCategorie = Prisma.MaterielGetPayload<{ include: { categorie: true } }>;
|
export type MaterielAvecCategorie = Prisma.MaterielGetPayload<{ include: { categorie: true } }>;
|
||||||
|
export type MaterielDetail = Prisma.MaterielGetPayload<{
|
||||||
|
include: {
|
||||||
|
categorie: true;
|
||||||
|
campus: true;
|
||||||
|
accessoires: { include: { accessoire: true } };
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
export interface CatalogueFiltres {
|
export interface CatalogueFiltres {
|
||||||
categorieId?: number;
|
categorieId?: number;
|
||||||
@@ -35,3 +42,21 @@ export function findDisponiblesParCampus(
|
|||||||
orderBy: { nom: 'asc' },
|
orderBy: { nom: 'asc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findById(id: number): Promise<Materiel | null> {
|
||||||
|
return prisma.materiel.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findDetailParCampus(id: number, campusId: number): Promise<MaterielDetail | null> {
|
||||||
|
return prisma.materiel.findFirst({
|
||||||
|
where: { id, campusId, actif: true },
|
||||||
|
include: {
|
||||||
|
categorie: true,
|
||||||
|
campus: true,
|
||||||
|
accessoires: {
|
||||||
|
include: { accessoire: true },
|
||||||
|
orderBy: { accessoire: { nom: 'asc' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { prisma } from '../db/prisma';
|
||||||
|
|
||||||
|
export type PosteAvecSalle = Prisma.PosteEmpruntGetPayload<{ include: { sallePret: true } }>;
|
||||||
|
|
||||||
|
export function findById(id: number): Promise<PosteAvecSalle | null> {
|
||||||
|
return prisma.posteEmprunt.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { sallePret: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -20,3 +20,10 @@ export function findById(id: number): Promise<UtilisateurProfil | null> {
|
|||||||
include: { role: true, campus: true },
|
include: { role: true, campus: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findResponsablesParCampus(campusId: number): Promise<{ id: number }[]> {
|
||||||
|
return prisma.utilisateur.findMany({
|
||||||
|
where: { campusId, actif: true, role: { code: 'RESPONSABLE' } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { getMesEmprunts } from '../controllers/emprunt.controller';
|
import { getMesEmprunts, postEmprunt, postRestitution } from '../controllers/emprunt.controller';
|
||||||
|
|
||||||
export const empruntRoutes: Router = Router();
|
export const empruntRoutes: Router = Router();
|
||||||
|
empruntRoutes.post('/', postEmprunt);
|
||||||
|
empruntRoutes.post('/:id/restitution', postRestitution);
|
||||||
|
|
||||||
empruntRoutes.get('/', getMesEmprunts);
|
export const mesEmpruntsRoutes: Router = Router();
|
||||||
|
mesEmpruntsRoutes.get('/', getMesEmprunts);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Router } from 'express';
|
|||||||
import { currentUser } from '../middlewares/current-user';
|
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 } from './emprunt.routes';
|
import { empruntRoutes, mesEmpruntsRoutes } from './emprunt.routes';
|
||||||
|
|
||||||
export const apiRouter: Router = Router();
|
export const apiRouter: Router = Router();
|
||||||
|
|
||||||
@@ -11,4 +11,5 @@ apiRouter.use('/auth', authRoutes);
|
|||||||
|
|
||||||
// Le reste exige un utilisateur identifié.
|
// Le reste exige un utilisateur identifié.
|
||||||
apiRouter.use('/materiels', currentUser, materielRoutes);
|
apiRouter.use('/materiels', currentUser, materielRoutes);
|
||||||
apiRouter.use('/mes-emprunts', currentUser, empruntRoutes);
|
apiRouter.use('/emprunts', currentUser, empruntRoutes);
|
||||||
|
apiRouter.use('/mes-emprunts', currentUser, mesEmpruntsRoutes);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { getCatalogue } from '../controllers/materiel.controller';
|
import { getCatalogue, getMaterielDetail } from '../controllers/materiel.controller';
|
||||||
|
|
||||||
export const materielRoutes: Router = Router();
|
export const materielRoutes: Router = Router();
|
||||||
|
|
||||||
materielRoutes.get('/', getCatalogue);
|
materielRoutes.get('/', getCatalogue);
|
||||||
|
materielRoutes.get('/:id', getMaterielDetail);
|
||||||
|
|||||||
@@ -1,7 +1,182 @@
|
|||||||
import { findEnCoursParUtilisateur, EmpruntAvecMateriel } from '../repositories/emprunt.repository';
|
import { AppError } from '../errors/app-error';
|
||||||
|
import {
|
||||||
|
CreerEmpruntRequest,
|
||||||
|
RestituerEmpruntRequest,
|
||||||
|
ChecklistElementRequest,
|
||||||
|
} from '../dtos/emprunt.dto';
|
||||||
|
import {
|
||||||
|
findEnCoursParUtilisateur,
|
||||||
|
creerEmpruntComplet,
|
||||||
|
findByIdAvecChecklists,
|
||||||
|
restituerEmpruntComplet,
|
||||||
|
EmpruntAvecMateriel,
|
||||||
|
RestituerEmpruntData,
|
||||||
|
} from '../repositories/emprunt.repository';
|
||||||
|
import { findById as findMaterielById } from '../repositories/materiel.repository';
|
||||||
|
import { findById as findPosteById } from '../repositories/poste-emprunt.repository';
|
||||||
|
import { findResponsablesParCampus } from '../repositories/utilisateur.repository';
|
||||||
|
import { StatutEmprunt } from '../models/enums';
|
||||||
|
|
||||||
/* RG15 : un étudiant ne consulte que ses propres emprunts, et uniquement ceux
|
const DUREE_EMPRUNT_JOURS = 14;
|
||||||
non encore restitués (en cours ou en retard). */
|
const STATUTS_RESTITUABLES: readonly string[] = ['EN_COURS', 'EN_RETARD'];
|
||||||
|
|
||||||
|
/* RG15 : un étudiant ne consulte que ses propres emprunts non restitués. */
|
||||||
export function listerMesEmpruntsEnCours(utilisateurId: number): Promise<EmpruntAvecMateriel[]> {
|
export function listerMesEmpruntsEnCours(utilisateurId: number): Promise<EmpruntAvecMateriel[]> {
|
||||||
return findEnCoursParUtilisateur(utilisateurId);
|
return findEnCoursParUtilisateur(utilisateurId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* RG07/RG10/RG11/RG12 : création d'un emprunt avec sa checklist de départ.
|
||||||
|
Les contrôles d'éligibilité (campus, disponibilité) sont faits ici ; l'écriture
|
||||||
|
atomique est déléguée au repository. */
|
||||||
|
export async function creerEmprunt(
|
||||||
|
utilisateurId: number,
|
||||||
|
campusId: number,
|
||||||
|
request: CreerEmpruntRequest,
|
||||||
|
): Promise<EmpruntAvecMateriel> {
|
||||||
|
const materiel = await findMaterielById(request.materielId);
|
||||||
|
if (!materiel) {
|
||||||
|
throw new AppError(404, 'Materiel introuvable');
|
||||||
|
}
|
||||||
|
if (materiel.campusId !== campusId) {
|
||||||
|
throw new AppError(403, 'Materiel rattache a un autre campus');
|
||||||
|
}
|
||||||
|
if (materiel.statut !== 'DISPONIBLE') {
|
||||||
|
throw new AppError(409, 'Materiel non disponible');
|
||||||
|
}
|
||||||
|
|
||||||
|
const poste = await findPosteById(request.posteEmpruntId);
|
||||||
|
if (!poste) {
|
||||||
|
throw new AppError(400, 'Poste introuvable');
|
||||||
|
}
|
||||||
|
if (poste.sallePret.campusId !== campusId) {
|
||||||
|
throw new AppError(403, 'Poste rattache a un autre campus');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateRetourPrevue = new Date();
|
||||||
|
dateRetourPrevue.setDate(dateRetourPrevue.getDate() + DUREE_EMPRUNT_JOURS);
|
||||||
|
|
||||||
|
return creerEmpruntComplet({
|
||||||
|
utilisateurId,
|
||||||
|
materielId: request.materielId,
|
||||||
|
campusId,
|
||||||
|
sallePretId: poste.sallePretId,
|
||||||
|
posteEmpruntId: request.posteEmpruntId,
|
||||||
|
dateRetourPrevue,
|
||||||
|
modeIdentification: request.modeIdentification,
|
||||||
|
commentaireDepart: request.commentaireDepart,
|
||||||
|
elements: request.elements,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElementDepart {
|
||||||
|
accessoireId: number | null;
|
||||||
|
nomElement: string;
|
||||||
|
etat: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResultatComparaison {
|
||||||
|
conforme: boolean;
|
||||||
|
auMoinsUnDeteriore: boolean;
|
||||||
|
details: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function trouverRetour(
|
||||||
|
depart: ElementDepart,
|
||||||
|
retour: ChecklistElementRequest[],
|
||||||
|
): ChecklistElementRequest | undefined {
|
||||||
|
if (depart.accessoireId !== null) {
|
||||||
|
const parId = retour.find((element) => element.accessoireId === depart.accessoireId);
|
||||||
|
if (parId) {
|
||||||
|
return parId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return retour.find((element) => element.nomElement === depart.nomElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RG17 : compare la checklist de départ à celle de retour. Un élément présent au
|
||||||
|
départ mais absent ou détérioré au retour rend la restitution non conforme (RG20). */
|
||||||
|
function comparerChecklists(
|
||||||
|
depart: ElementDepart[],
|
||||||
|
retour: ChecklistElementRequest[],
|
||||||
|
): ResultatComparaison {
|
||||||
|
const details: string[] = [];
|
||||||
|
let auMoinsUnDeteriore = false;
|
||||||
|
|
||||||
|
for (const elementDepart of depart) {
|
||||||
|
if (elementDepart.etat !== 'PRESENT') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elementRetour = trouverRetour(elementDepart, retour);
|
||||||
|
if (!elementRetour || elementRetour.etat === 'ABSENT') {
|
||||||
|
details.push(`${elementDepart.nomElement} manquant au retour`);
|
||||||
|
} else if (elementRetour.etat === 'DETERIORE') {
|
||||||
|
details.push(`${elementDepart.nomElement} deteriore au retour`);
|
||||||
|
auMoinsUnDeteriore = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { conforme: details.length === 0, auMoinsUnDeteriore, details };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RG14-RG20 : restitution d'un emprunt avec comparaison des checklists, clôture
|
||||||
|
ou passage en non conforme, et création automatique d'anomalie + notifications. */
|
||||||
|
export async function restituerEmprunt(
|
||||||
|
utilisateurId: number,
|
||||||
|
empruntId: number,
|
||||||
|
request: RestituerEmpruntRequest,
|
||||||
|
): Promise<EmpruntAvecMateriel> {
|
||||||
|
const emprunt = await findByIdAvecChecklists(empruntId);
|
||||||
|
if (!emprunt) {
|
||||||
|
throw new AppError(404, 'Emprunt introuvable');
|
||||||
|
}
|
||||||
|
if (emprunt.utilisateurId !== utilisateurId) {
|
||||||
|
throw new AppError(403, 'Cet emprunt ne vous appartient pas');
|
||||||
|
}
|
||||||
|
if (!STATUTS_RESTITUABLES.includes(emprunt.statut)) {
|
||||||
|
throw new AppError(409, 'Emprunt non restituable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const checklistDepart = emprunt.checklists.find((checklist) => checklist.type === 'DEPART');
|
||||||
|
if (!checklistDepart) {
|
||||||
|
throw new AppError(409, 'Checklist de depart introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const comparaison = comparerChecklists(
|
||||||
|
checklistDepart.elements.map((element) => ({
|
||||||
|
accessoireId: element.accessoireId,
|
||||||
|
nomElement: element.nomElement,
|
||||||
|
etat: element.etat,
|
||||||
|
})),
|
||||||
|
request.elements,
|
||||||
|
);
|
||||||
|
|
||||||
|
const statutEmprunt: StatutEmprunt = comparaison.conforme ? 'CLOTURE' : 'RETOUR_NON_CONFORME';
|
||||||
|
|
||||||
|
let statutMateriel = 'DISPONIBLE';
|
||||||
|
if (!comparaison.conforme) {
|
||||||
|
statutMateriel = comparaison.auMoinsUnDeteriore ? 'DETERIORE' : 'NON_CONFORME';
|
||||||
|
}
|
||||||
|
|
||||||
|
const donnees: RestituerEmpruntData = {
|
||||||
|
empruntId,
|
||||||
|
utilisateurId,
|
||||||
|
materielId: emprunt.materielId,
|
||||||
|
statutEmprunt,
|
||||||
|
statutMateriel,
|
||||||
|
modeIdentification: request.modeIdentification,
|
||||||
|
commentaireRetour: request.commentaireRetour,
|
||||||
|
elements: request.elements,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!comparaison.conforme) {
|
||||||
|
const responsables = await findResponsablesParCampus(emprunt.campusId);
|
||||||
|
donnees.anomalie = {
|
||||||
|
type: 'RETOUR_NON_CONFORME',
|
||||||
|
description: `Retour non conforme : ${comparaison.details.join(', ')}.`,
|
||||||
|
responsablesIds: responsables.map((responsable) => responsable.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return restituerEmpruntComplet(donnees);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
findDisponiblesParCampus,
|
findDisponiblesParCampus,
|
||||||
|
findDetailParCampus,
|
||||||
CatalogueFiltres,
|
CatalogueFiltres,
|
||||||
|
MaterielDetail,
|
||||||
MaterielAvecCategorie,
|
MaterielAvecCategorie,
|
||||||
} from '../repositories/materiel.repository';
|
} from '../repositories/materiel.repository';
|
||||||
|
import { AppError } from '../errors/app-error';
|
||||||
|
|
||||||
/* RG10 : après identification, seuls les matériels disponibles du campus de
|
/* RG10 : après identification, seuls les matériels disponibles du campus de
|
||||||
l'étudiant sont affichés. */
|
l'étudiant sont affichés. */
|
||||||
@@ -12,3 +15,11 @@ export function listerCatalogue(
|
|||||||
): Promise<MaterielAvecCategorie[]> {
|
): Promise<MaterielAvecCategorie[]> {
|
||||||
return findDisponiblesParCampus(campusId, filtres);
|
return findDisponiblesParCampus(campusId, filtres);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function obtenirDetailMateriel(id: number, campusId: number): Promise<MaterielDetail> {
|
||||||
|
const materiel = await findDetailParCampus(id, campusId);
|
||||||
|
if (!materiel) {
|
||||||
|
throw new AppError(404, 'Materiel introuvable');
|
||||||
|
}
|
||||||
|
return materiel;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Miscellaneous
|
||||||
|
*.class
|
||||||
|
*.log
|
||||||
|
*.pyc
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
.atom/
|
||||||
|
.build/
|
||||||
|
.buildlog/
|
||||||
|
.history
|
||||||
|
.svn/
|
||||||
|
.swiftpm/
|
||||||
|
migrate_working_dir/
|
||||||
|
|
||||||
|
# IntelliJ related
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# The .vscode folder contains launch configuration and tasks you configure in
|
||||||
|
# VS Code which you may wish to be included in version control, so this line
|
||||||
|
# is commented out by default.
|
||||||
|
#.vscode/
|
||||||
|
|
||||||
|
# Flutter/Dart/Pub related
|
||||||
|
**/doc/api/
|
||||||
|
**/ios/Flutter/.last_build_id
|
||||||
|
.dart_tool/
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.pub-cache/
|
||||||
|
.pub/
|
||||||
|
/build/
|
||||||
|
/coverage/
|
||||||
|
|
||||||
|
# Symbolication related
|
||||||
|
app.*.symbols
|
||||||
|
|
||||||
|
# Obfuscation related
|
||||||
|
app.*.map.json
|
||||||
|
|
||||||
|
# Android Studio will place build artifacts here
|
||||||
|
/android/app/debug
|
||||||
|
/android/app/profile
|
||||||
|
/android/app/release
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# This file tracks properties of this Flutter project.
|
||||||
|
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||||
|
#
|
||||||
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
|
version:
|
||||||
|
revision: "b45fa18946ecc2d9b4009952c636ba7e2ffbb787"
|
||||||
|
channel: "stable"
|
||||||
|
|
||||||
|
project_type: app
|
||||||
|
|
||||||
|
# Tracks metadata for the flutter migrate command
|
||||||
|
migration:
|
||||||
|
platforms:
|
||||||
|
- platform: root
|
||||||
|
create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
|
||||||
|
base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
|
||||||
|
- platform: web
|
||||||
|
create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
|
||||||
|
base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787
|
||||||
|
|
||||||
|
# User provided section
|
||||||
|
|
||||||
|
# List of Local paths (relative to this file) that should be
|
||||||
|
# ignored by the migrate tool.
|
||||||
|
#
|
||||||
|
# Files that are not part of the templates will be ignored by default.
|
||||||
|
unmanaged_files:
|
||||||
|
- 'lib/main.dart'
|
||||||
|
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# eme_frontend
|
||||||
|
|
||||||
|
A new Flutter project.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
This project is a starting point for a Flutter application.
|
||||||
|
|
||||||
|
A few resources to get you started if this is your first Flutter project:
|
||||||
|
|
||||||
|
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||||
|
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
|
||||||
|
|
||||||
|
For help getting started with Flutter development, view the
|
||||||
|
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||||
|
samples, guidance on mobile development, and a full API reference.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
linter:
|
||||||
|
rules:
|
||||||
|
# Convention projet (CLAUDE.md) : guillemets doubles en Dart.
|
||||||
|
prefer_single_quotes: false
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/// Identité simulée utilisée tant que l'authentification Azure AD n'est pas branchée.
|
||||||
|
class DemoIdentity {
|
||||||
|
DemoIdentity._();
|
||||||
|
|
||||||
|
static const userName = "Lucas Martin";
|
||||||
|
static const userInitials = "LM";
|
||||||
|
static const userSubtitle = "Étudiant · B2 Informatique · Ensitech Cergy";
|
||||||
|
static const campusTag = "Saint-Christophe · Cergy";
|
||||||
|
static const loginCampusTag = "Ensitech · Cergy";
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "theme/ensup_theme.dart";
|
||||||
|
import "screens/login_screen.dart";
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
runApp(const EmeApp());
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmeApp extends StatelessWidget {
|
||||||
|
const EmeApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp(
|
||||||
|
title: "EME — Emprunt Matériel ENSUP",
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
theme: buildEnsupTheme(),
|
||||||
|
home: const LoginScreen(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/// Une différence constatée au retour par rapport au départ (RG20).
|
||||||
|
class AnomalieRetour {
|
||||||
|
const AnomalieRetour({required this.element, required this.etatRetour});
|
||||||
|
|
||||||
|
final String element;
|
||||||
|
final String etatRetour;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import "materiel_item.dart";
|
||||||
|
|
||||||
|
/// Un emprunt en cours de l'étudiant, affiché dans le parcours de restitution
|
||||||
|
/// et renvoyé par l'API.
|
||||||
|
class EmpruntItem {
|
||||||
|
const EmpruntItem({
|
||||||
|
required this.id,
|
||||||
|
required this.materiel,
|
||||||
|
required this.dateEmprunt,
|
||||||
|
required this.retourPrevu,
|
||||||
|
required this.statut,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory EmpruntItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
final dateEmprunt = DateTime.parse(json["dateEmprunt"] as String);
|
||||||
|
final retourPrevu = DateTime.parse(json["dateRetourPrevue"] as String);
|
||||||
|
|
||||||
|
return EmpruntItem(
|
||||||
|
id: json["id"] as int,
|
||||||
|
materiel: MaterielItem.fromJson(json["materiel"] as Map<String, dynamic>),
|
||||||
|
dateEmprunt: _dateHeure(dateEmprunt),
|
||||||
|
retourPrevu: _date(retourPrevu),
|
||||||
|
statut: json["statut"] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final MaterielItem materiel;
|
||||||
|
final String dateEmprunt;
|
||||||
|
final String retourPrevu;
|
||||||
|
final String statut;
|
||||||
|
|
||||||
|
EmpruntItem copyWith({MaterielItem? materiel, String? statut}) {
|
||||||
|
return EmpruntItem(
|
||||||
|
id: id,
|
||||||
|
materiel: materiel ?? this.materiel,
|
||||||
|
dateEmprunt: dateEmprunt,
|
||||||
|
retourPrevu: retourPrevu,
|
||||||
|
statut: statut ?? this.statut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _deux(int valeur) => valeur.toString().padLeft(2, "0");
|
||||||
|
|
||||||
|
static String _date(DateTime date) {
|
||||||
|
return "${_deux(date.day)}/${_deux(date.month)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _dateHeure(DateTime date) {
|
||||||
|
return "${_date(date)} à ${_deux(date.hour)}:${_deux(date.minute)}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import "materiel_item.dart";
|
||||||
|
|
||||||
|
/// Réponse renvoyée par l'API après création d'un emprunt.
|
||||||
|
class EmpruntResponse {
|
||||||
|
const EmpruntResponse({
|
||||||
|
required this.id,
|
||||||
|
required this.dateEmprunt,
|
||||||
|
required this.dateRetourPrevue,
|
||||||
|
required this.statut,
|
||||||
|
required this.materiel,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory EmpruntResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
return EmpruntResponse(
|
||||||
|
id: json["id"] as int,
|
||||||
|
dateEmprunt: DateTime.parse(json["dateEmprunt"] as String),
|
||||||
|
dateRetourPrevue: DateTime.parse(json["dateRetourPrevue"] as String),
|
||||||
|
statut: json["statut"] as String,
|
||||||
|
materiel: MaterielItem.fromJson(json["materiel"] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final DateTime dateEmprunt;
|
||||||
|
final DateTime dateRetourPrevue;
|
||||||
|
final String statut;
|
||||||
|
final MaterielItem materiel;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "../demo_identity.dart";
|
||||||
|
|
||||||
|
/// Représentation d'un matériel affiché dans le catalogue et son détail
|
||||||
|
/// à partir des réponses API.
|
||||||
|
class MaterielItem {
|
||||||
|
const MaterielItem({
|
||||||
|
required this.id,
|
||||||
|
required this.nom,
|
||||||
|
required this.categorie,
|
||||||
|
required this.marque,
|
||||||
|
required this.modele,
|
||||||
|
required this.reference,
|
||||||
|
required this.campus,
|
||||||
|
required this.etatGeneral,
|
||||||
|
required this.disponible,
|
||||||
|
required this.accessoires,
|
||||||
|
required this.icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Construit un MaterielItem depuis la réponse JSON du catalogue.
|
||||||
|
/// Les champs non fournis par cet endpoint (accessoires, icône) sont complétés.
|
||||||
|
factory MaterielItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
final categorie = (json["categorie"] as Map<String, dynamic>)["nom"] as String;
|
||||||
|
final campus = json["campus"] as Map<String, dynamic>?;
|
||||||
|
final accessoires = json["accessoires"] as List<dynamic>?;
|
||||||
|
return MaterielItem(
|
||||||
|
id: json["id"] as int,
|
||||||
|
nom: json["nom"] as String,
|
||||||
|
categorie: categorie,
|
||||||
|
marque: json["marque"] as String,
|
||||||
|
modele: json["modele"] as String,
|
||||||
|
reference: json["reference"] as String,
|
||||||
|
campus: campus == null
|
||||||
|
? DemoIdentity.campusTag
|
||||||
|
: "${campus["nom"]} · ${campus["ville"]}",
|
||||||
|
etatGeneral: json["etatGeneral"] as String,
|
||||||
|
disponible: json["statut"] == "DISPONIBLE",
|
||||||
|
accessoires: accessoires == null ? const <String>[] : accessoires.cast<String>(),
|
||||||
|
icon: iconePourCategorie(categorie),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String nom;
|
||||||
|
final String categorie;
|
||||||
|
final String marque;
|
||||||
|
final String modele;
|
||||||
|
final String reference;
|
||||||
|
final String campus;
|
||||||
|
final String etatGeneral;
|
||||||
|
final bool disponible;
|
||||||
|
final List<String> accessoires;
|
||||||
|
final IconData icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Déduit une icône à partir du nom de catégorie renvoyé par l'API.
|
||||||
|
IconData iconePourCategorie(String categorie) {
|
||||||
|
switch (categorie) {
|
||||||
|
case "Ordinateur portable":
|
||||||
|
return Icons.laptop_mac;
|
||||||
|
case "Vidéoprojecteur":
|
||||||
|
return Icons.videocam_outlined;
|
||||||
|
case "Tablette":
|
||||||
|
return Icons.tablet_mac;
|
||||||
|
case "Caméra":
|
||||||
|
return Icons.photo_camera_outlined;
|
||||||
|
case "Périphérique audio":
|
||||||
|
return Icons.headphones;
|
||||||
|
case "Câble / Adaptateur":
|
||||||
|
return Icons.cable;
|
||||||
|
default:
|
||||||
|
return Icons.inventory_2_outlined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../demo_identity.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
import "../models/materiel_item.dart";
|
||||||
|
import "../services/materiel_service.dart";
|
||||||
|
import "../widgets/ensup_top_bar.dart";
|
||||||
|
import "../widgets/brand_corners.dart";
|
||||||
|
import "../widgets/materiel_card.dart";
|
||||||
|
import "detail_screen.dart";
|
||||||
|
|
||||||
|
/// Catalogue matériel : recherche, filtres par catégorie et liste chargée
|
||||||
|
/// depuis l'API.
|
||||||
|
class CatalogueScreen extends StatefulWidget {
|
||||||
|
const CatalogueScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CatalogueScreen> createState() => _CatalogueScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CatalogueScreenState extends State<CatalogueScreen> {
|
||||||
|
late Future<List<MaterielItem>> _future;
|
||||||
|
String _recherche = "";
|
||||||
|
String _categorie = "Tous";
|
||||||
|
int? _selectionEnCoursId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_future = MaterielService.getCatalogue();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _recharger() {
|
||||||
|
setState(() => _future = MaterielService.getCatalogue());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _categories(List<MaterielItem> materiels) {
|
||||||
|
final distinctes = materiels.map((m) => m.categorie).toSet().toList()..sort();
|
||||||
|
return ["Tous", ...distinctes];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<MaterielItem> _filtrer(List<MaterielItem> materiels) {
|
||||||
|
final terme = _recherche.trim().toLowerCase();
|
||||||
|
return materiels.where((materiel) {
|
||||||
|
final matchTexte = terme.isEmpty || materiel.nom.toLowerCase().contains(terme);
|
||||||
|
final matchCategorie = _categorie == "Tous" || materiel.categorie == _categorie;
|
||||||
|
return matchTexte && matchCategorie;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _selectionner(MaterielItem item) async {
|
||||||
|
setState(() => _selectionEnCoursId = item.id);
|
||||||
|
try {
|
||||||
|
final detail = await MaterielService.getDetail(item.id);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (_) => DetailScreen(item: detail)),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(error.toString())),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _selectionEnCoursId = null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: EnsupColors.soft,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: EnsupColors.text.withValues(alpha: 0.14),
|
||||||
|
blurRadius: 60,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
EnsupTopBar(
|
||||||
|
brandText: "Catalogue matériel",
|
||||||
|
campusTag: DemoIdentity.campusTag,
|
||||||
|
userName: DemoIdentity.userName,
|
||||||
|
userInitials: DemoIdentity.userInitials,
|
||||||
|
onBack: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(32, 24, 32, 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_SearchField(
|
||||||
|
onChanged: (value) => setState(() => _recherche = value),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Expanded(
|
||||||
|
child: FutureBuilder<List<MaterielItem>>(
|
||||||
|
future: _future,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return _ErreurState(
|
||||||
|
message: snapshot.error.toString(),
|
||||||
|
onRetry: _recharger,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final materiels = snapshot.data ?? const [];
|
||||||
|
return _contenu(materiels);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Positioned.fill(child: BrandCorners()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _contenu(List<MaterielItem> materiels) {
|
||||||
|
final resultats = _filtrer(materiels);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: _categories(materiels).map((categorie) {
|
||||||
|
return _CategoryChip(
|
||||||
|
label: categorie,
|
||||||
|
selected: categorie == _categorie,
|
||||||
|
onTap: () => setState(() => _categorie = categorie),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Divider(color: EnsupColors.line, height: 1),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: resultats.isEmpty
|
||||||
|
? const _EmptyState()
|
||||||
|
: ListView.separated(
|
||||||
|
itemCount: resultats.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = resultats[index];
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
MaterielCard(item: item, onSelect: () => _selectionner(item)),
|
||||||
|
if (_selectionEnCoursId == item.id)
|
||||||
|
const Positioned.fill(
|
||||||
|
child: ColoredBox(
|
||||||
|
color: Color(0x66FFFFFF),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SearchField extends StatelessWidget {
|
||||||
|
const _SearchField({required this.onChanged});
|
||||||
|
|
||||||
|
final ValueChanged<String> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
height: 44,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: EnsupColors.line, width: 1.5),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.search, size: 18, color: EnsupColors.muted),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
onChanged: onChanged,
|
||||||
|
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.text),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isCollapsed: true,
|
||||||
|
border: InputBorder.none,
|
||||||
|
hintText: "Rechercher un matériel...",
|
||||||
|
hintStyle: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.muted),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CategoryChip extends StatelessWidget {
|
||||||
|
const _CategoryChip({required this.label, required this.selected, required this.onTap});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final bool selected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected ? EnsupColors.cyan : Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(color: selected ? EnsupColors.cyan : EnsupColors.line, width: 1.5),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: selected ? Colors.white : EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmptyState extends StatelessWidget {
|
||||||
|
const _EmptyState();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.inventory_2_outlined, size: 40, color: EnsupColors.muted),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
"Aucun matériel ne correspond",
|
||||||
|
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.muted),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ErreurState extends StatelessWidget {
|
||||||
|
const _ErreurState({required this.message, required this.onRetry});
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
final VoidCallback onRetry;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.cloud_off_outlined, size: 40, color: EnsupColors.muted),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.text2),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: onRetry,
|
||||||
|
child: Text(
|
||||||
|
"Réessayer",
|
||||||
|
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../demo_identity.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
import "../models/materiel_item.dart";
|
||||||
|
import "../services/emprunt_service.dart";
|
||||||
|
import "../widgets/ensup_top_bar.dart";
|
||||||
|
import "../widgets/brand_corners.dart";
|
||||||
|
import "../widgets/etat_checklist.dart";
|
||||||
|
import "confirmation_screen.dart";
|
||||||
|
|
||||||
|
/// Checklist de départ (RG11) : l'étudiant indique l'état de chaque accessoire
|
||||||
|
/// attendu avant de confirmer l'emprunt.
|
||||||
|
class ChecklistDepartScreen extends StatefulWidget {
|
||||||
|
const ChecklistDepartScreen({super.key, required this.item});
|
||||||
|
|
||||||
|
final MaterielItem item;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChecklistDepartScreen> createState() => _ChecklistDepartScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
|
||||||
|
late final List<String> _elements = widget.item.accessoires.isEmpty
|
||||||
|
? <String>[widget.item.nom]
|
||||||
|
: widget.item.accessoires;
|
||||||
|
late final List<EtatChecklist> _etats = List<EtatChecklist>.filled(
|
||||||
|
_elements.length,
|
||||||
|
EtatChecklist.present,
|
||||||
|
);
|
||||||
|
final TextEditingController _commentaireController = TextEditingController();
|
||||||
|
bool _envoiEnCours = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_commentaireController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _valider() async {
|
||||||
|
setState(() => _envoiEnCours = true);
|
||||||
|
|
||||||
|
final anomalies = <String>[];
|
||||||
|
for (var i = 0; i < _etats.length; i++) {
|
||||||
|
if (_etats[i] == EtatChecklist.absent) {
|
||||||
|
anomalies.add("${_elements[i]} absent au départ");
|
||||||
|
} else if (_etats[i] == EtatChecklist.deteriore) {
|
||||||
|
anomalies.add("${_elements[i]} détérioré au départ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final note = anomalies.isEmpty ? null : anomalies.join(", ");
|
||||||
|
|
||||||
|
try {
|
||||||
|
final emprunt = await EmpruntService.creerEmprunt(
|
||||||
|
materielId: widget.item.id,
|
||||||
|
commentaireDepart: _commentaireController.text,
|
||||||
|
elements: _elements.asMap().entries.map((entry) {
|
||||||
|
return {
|
||||||
|
"nomElement": entry.value,
|
||||||
|
"etat": _etatApi(_etats[entry.key]),
|
||||||
|
"quantiteConstatee": _etats[entry.key] == EtatChecklist.absent ? 0 : 1,
|
||||||
|
};
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => ConfirmationScreen(emprunt: emprunt, note: note),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(error.toString())),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _envoiEnCours = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _etatApi(EtatChecklist etat) {
|
||||||
|
switch (etat) {
|
||||||
|
case EtatChecklist.present:
|
||||||
|
return "PRESENT";
|
||||||
|
case EtatChecklist.absent:
|
||||||
|
return "ABSENT";
|
||||||
|
case EtatChecklist.deteriore:
|
||||||
|
return "DETERIORE";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: EnsupColors.soft,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: EnsupColors.text.withValues(alpha: 0.14),
|
||||||
|
blurRadius: 60,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
EnsupTopBar(
|
||||||
|
brandText: "Checklist de départ",
|
||||||
|
campusTag: DemoIdentity.campusTag,
|
||||||
|
userName: DemoIdentity.userName,
|
||||||
|
userInitials: DemoIdentity.userInitials,
|
||||||
|
onBack: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(flex: 3, child: _colonneGauche()),
|
||||||
|
const SizedBox(width: 20),
|
||||||
|
Expanded(flex: 2, child: _colonneDroite()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Positioned.fill(child: BrandCorners()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _colonneGauche() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: EnsupColors.soft,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: EnsupColors.cyan.withValues(alpha: 0.15), width: 1.5),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: EnsupColors.cyan.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(9),
|
||||||
|
),
|
||||||
|
child: Icon(widget.item.icon, color: EnsupColors.cyan, size: 18),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.item.nom,
|
||||||
|
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w800, fontSize: 16),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
"Vérifiez chaque élément physiquement avant de valider",
|
||||||
|
style: GoogleFonts.titilliumWeb(fontSize: 12, color: EnsupColors.muted),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ChecklistTableau(
|
||||||
|
accessoires: _elements,
|
||||||
|
etats: _etats,
|
||||||
|
onChanged: (index, etat) => setState(() => _etats[index] = etat),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _colonneDroite() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ChecklistInstructions(
|
||||||
|
titre: "Instructions",
|
||||||
|
texte:
|
||||||
|
"Vérifiez physiquement chaque élément du kit et indiquez son état : "
|
||||||
|
"Présent, Absent ou Détérioré. Signalez tout élément manquant ou "
|
||||||
|
"endommagé pour éviter tout litige au retour.",
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
ChecklistCommentaire(controller: _commentaireController),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: _envoiEnCours ? null : _valider,
|
||||||
|
icon: _envoiEnCours
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.check, size: 18),
|
||||||
|
label: Text(
|
||||||
|
_envoiEnCours
|
||||||
|
? "Enregistrement..."
|
||||||
|
: "Valider la checklist et confirmer l'emprunt",
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 15),
|
||||||
|
),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: EnsupColors.blue3,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../demo_identity.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
import "../models/emprunt_item.dart";
|
||||||
|
import "../models/anomalie_retour.dart";
|
||||||
|
import "../services/emprunt_service.dart";
|
||||||
|
import "../widgets/ensup_top_bar.dart";
|
||||||
|
import "../widgets/brand_corners.dart";
|
||||||
|
import "../widgets/etat_checklist.dart";
|
||||||
|
import "resultat_retour_screen.dart";
|
||||||
|
|
||||||
|
/// Checklist de retour (RG16) : l'étudiant indique l'état de chaque accessoire
|
||||||
|
/// au retour, en vue de la comparaison avec la checklist de départ.
|
||||||
|
class ChecklistRetourScreen extends StatefulWidget {
|
||||||
|
const ChecklistRetourScreen({super.key, required this.emprunt});
|
||||||
|
|
||||||
|
final EmpruntItem emprunt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChecklistRetourScreen> createState() => _ChecklistRetourScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChecklistRetourScreenState extends State<ChecklistRetourScreen> {
|
||||||
|
late final List<String> _elements = widget.emprunt.materiel.accessoires.isEmpty
|
||||||
|
? <String>[widget.emprunt.materiel.nom]
|
||||||
|
: widget.emprunt.materiel.accessoires;
|
||||||
|
late final List<EtatChecklist> _etats = List<EtatChecklist>.filled(
|
||||||
|
_elements.length,
|
||||||
|
EtatChecklist.present,
|
||||||
|
);
|
||||||
|
final TextEditingController _commentaireController = TextEditingController();
|
||||||
|
bool _envoiEnCours = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_commentaireController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _valider() async {
|
||||||
|
setState(() => _envoiEnCours = true);
|
||||||
|
|
||||||
|
final anomalies = <AnomalieRetour>[];
|
||||||
|
for (var i = 0; i < _etats.length; i++) {
|
||||||
|
if (_etats[i] == EtatChecklist.absent) {
|
||||||
|
anomalies.add(AnomalieRetour(element: _elements[i], etatRetour: "Absent au retour"));
|
||||||
|
} else if (_etats[i] == EtatChecklist.deteriore) {
|
||||||
|
anomalies.add(AnomalieRetour(element: _elements[i], etatRetour: "Détérioré au retour"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final resultat = await EmpruntService.restituerEmprunt(
|
||||||
|
empruntId: widget.emprunt.id,
|
||||||
|
commentaireRetour: _commentaireController.text,
|
||||||
|
elements: _elements.asMap().entries.map((entry) {
|
||||||
|
return {
|
||||||
|
"nomElement": entry.value,
|
||||||
|
"etat": _etatApi(_etats[entry.key]),
|
||||||
|
"quantiteConstatee": _etats[entry.key] == EtatChecklist.absent ? 0 : 1,
|
||||||
|
};
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final anomaliesAffichees = resultat.statut == "CLOTURE" ? <AnomalieRetour>[] : anomalies;
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => ResultatRetourScreen(
|
||||||
|
emprunt: widget.emprunt.copyWith(statut: resultat.statut),
|
||||||
|
anomalies: anomaliesAffichees,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(error.toString())),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _envoiEnCours = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _etatApi(EtatChecklist etat) {
|
||||||
|
switch (etat) {
|
||||||
|
case EtatChecklist.present:
|
||||||
|
return "PRESENT";
|
||||||
|
case EtatChecklist.absent:
|
||||||
|
return "ABSENT";
|
||||||
|
case EtatChecklist.deteriore:
|
||||||
|
return "DETERIORE";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: EnsupColors.soft,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: EnsupColors.text.withValues(alpha: 0.14),
|
||||||
|
blurRadius: 60,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
EnsupTopBar(
|
||||||
|
brandText: "Checklist de retour",
|
||||||
|
campusTag: DemoIdentity.campusTag,
|
||||||
|
userName: DemoIdentity.userName,
|
||||||
|
userInitials: DemoIdentity.userInitials,
|
||||||
|
onBack: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(flex: 3, child: _colonneGauche()),
|
||||||
|
const SizedBox(width: 20),
|
||||||
|
Expanded(flex: 2, child: _colonneDroite()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Positioned.fill(child: BrandCorners()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _colonneGauche() {
|
||||||
|
final materiel = widget.emprunt.materiel;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: EnsupColors.soft,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: EnsupColors.teal.withValues(alpha: 0.15), width: 1.5),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: EnsupColors.teal.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(9),
|
||||||
|
),
|
||||||
|
child: Icon(materiel.icon, color: EnsupColors.teal, size: 18),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
materiel.nom,
|
||||||
|
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w800, fontSize: 16),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
"Emprunté le ${widget.emprunt.dateEmprunt}",
|
||||||
|
style: GoogleFonts.titilliumWeb(fontSize: 12, color: EnsupColors.muted),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ChecklistTableau(
|
||||||
|
accessoires: _elements,
|
||||||
|
etats: _etats,
|
||||||
|
onChanged: (index, etat) => setState(() => _etats[index] = etat),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _colonneDroite() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const ChecklistInstructions(
|
||||||
|
titre: "Rappel checklist de départ",
|
||||||
|
texte:
|
||||||
|
"Au départ, tous les accessoires du kit étaient présents. "
|
||||||
|
"Indiquez leur état au retour ; toute différence sera signalée.",
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
ChecklistCommentaire(
|
||||||
|
controller: _commentaireController,
|
||||||
|
hint: "Commentaire optionnel...",
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: _envoiEnCours ? null : _valider,
|
||||||
|
icon: _envoiEnCours
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.check, size: 18),
|
||||||
|
label: Text(
|
||||||
|
_envoiEnCours ? "Enregistrement..." : "Valider le retour",
|
||||||
|
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 16),
|
||||||
|
),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: EnsupColors.blue3,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../demo_identity.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
import "../models/emprunt_response.dart";
|
||||||
|
import "../models/materiel_item.dart";
|
||||||
|
import "../widgets/ensup_top_bar.dart";
|
||||||
|
import "../widgets/brand_corners.dart";
|
||||||
|
|
||||||
|
/// Écran de succès clôturant l'emprunt : confirmation + récapitulatif.
|
||||||
|
class ConfirmationScreen extends StatelessWidget {
|
||||||
|
const ConfirmationScreen({super.key, required this.emprunt, this.note});
|
||||||
|
|
||||||
|
final EmpruntResponse emprunt;
|
||||||
|
final String? note;
|
||||||
|
|
||||||
|
MaterielItem get item => emprunt.materiel;
|
||||||
|
|
||||||
|
static String _deuxChiffres(int valeur) => valeur.toString().padLeft(2, "0");
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final dateEmprunt = emprunt.dateEmprunt;
|
||||||
|
final date =
|
||||||
|
"${_deuxChiffres(dateEmprunt.day)}/${_deuxChiffres(dateEmprunt.month)}/${dateEmprunt.year}";
|
||||||
|
final heure = "${_deuxChiffres(dateEmprunt.hour)}:${_deuxChiffres(dateEmprunt.minute)}";
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: EnsupColors.soft,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: EnsupColors.text.withValues(alpha: 0.14),
|
||||||
|
blurRadius: 60,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const EnsupTopBar(
|
||||||
|
brandText: "EME · Confirmation",
|
||||||
|
campusTag: DemoIdentity.campusTag,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFFF0FDF4), Color(0xFFF8FAFC)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_boiteSucces(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_recapitulatif(date, heure),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: () => Navigator.of(context).popUntil(
|
||||||
|
(route) => route.settings.name == "home",
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.home_outlined, size: 18),
|
||||||
|
label: Text(
|
||||||
|
"Retour à l'accueil",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: EnsupColors.cyan,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Positioned.fill(child: BrandCorners()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _boiteSucces() {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(36),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(colors: [Color(0xFFF0FDF4), Color(0xFFECFDF5)]),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: const Color(0xFFBBF7D0), width: 2),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: const LinearGradient(colors: [Color(0xFF16A34A), Color(0xFF22C55E)]),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF16A34A).withValues(alpha: 0.3),
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.check, color: Colors.white, size: 36),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Text(
|
||||||
|
"Emprunt enregistré !",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: const Color(0xFF15803D),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
"Votre emprunt a bien été enregistré. Vous pouvez récupérer le matériel.",
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: GoogleFonts.titilliumWeb(fontSize: 14, color: const Color(0xFF166534)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _recapitulatif(String date, String heure) {
|
||||||
|
final note = this.note;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: EnsupColors.line, width: 1.5),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"RÉCAPITULATIF DE L'EMPRUNT",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_rrow("Matériel", item.nom),
|
||||||
|
_rrow("Référence", item.reference),
|
||||||
|
_rrow("N° emprunt", "#${emprunt.id}"),
|
||||||
|
_rrow("Emprunteur", DemoIdentity.userName),
|
||||||
|
_rrow("Date d'emprunt", date),
|
||||||
|
_rrow("Heure", heure),
|
||||||
|
_rrow("Campus", item.campus),
|
||||||
|
if (note != null) _rrow("Note", note, valueColor: const Color(0xFFD97706)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _rrow(String cle, String valeur, {Color? valueColor}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 7),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(cle, style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted)),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
valeur,
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: valueColor ?? EnsupColors.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../demo_identity.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
import "../models/materiel_item.dart";
|
||||||
|
import "../widgets/ensup_top_bar.dart";
|
||||||
|
import "../widgets/brand_corners.dart";
|
||||||
|
import "../widgets/status_pill.dart";
|
||||||
|
import "checklist_depart_screen.dart";
|
||||||
|
|
||||||
|
/// Détail d'un matériel : caractéristiques, accessoires du kit et lancement de
|
||||||
|
/// l'emprunt.
|
||||||
|
class DetailScreen extends StatelessWidget {
|
||||||
|
const DetailScreen({super.key, required this.item});
|
||||||
|
|
||||||
|
final MaterielItem item;
|
||||||
|
|
||||||
|
void _demarrer(BuildContext context) {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (_) => ChecklistDepartScreen(item: item)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: EnsupColors.soft,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: EnsupColors.text.withValues(alpha: 0.14),
|
||||||
|
blurRadius: 60,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
EnsupTopBar(
|
||||||
|
brandText: "Détail matériel",
|
||||||
|
campusTag: DemoIdentity.campusTag,
|
||||||
|
userName: DemoIdentity.userName,
|
||||||
|
userInitials: DemoIdentity.userInitials,
|
||||||
|
onBack: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(flex: 3, child: _colonneGauche()),
|
||||||
|
const SizedBox(width: 20),
|
||||||
|
Expanded(flex: 2, child: _colonneDroite(context)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Positioned.fill(child: BrandCorners()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _colonneGauche() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: EnsupColors.soft,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: EnsupColors.line, width: 1.5),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item.nom,
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: EnsupColors.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_row("Référence", item.reference),
|
||||||
|
_row("Catégorie", item.categorie),
|
||||||
|
_row("Marque", item.marque),
|
||||||
|
_row("Modèle", item.modele),
|
||||||
|
_row("État", item.etatGeneral, valueColor: EnsupColors.green),
|
||||||
|
_row("Campus", item.campus),
|
||||||
|
_rowStatut(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (item.accessoires.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Text(
|
||||||
|
"ACCESSOIRES INCLUS DANS LE KIT",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: item.accessoires.map(_tag).toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _row(String cle, String valeur, {Color? valueColor}) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
border: Border(bottom: BorderSide(color: EnsupColors.line)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(cle, style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted)),
|
||||||
|
Text(
|
||||||
|
valeur,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: valueColor ?? EnsupColors.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _rowStatut() {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text("Statut", style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted)),
|
||||||
|
StatusPill(disponible: item.disponible),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _tag(String label) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: EnsupColors.teal.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: EnsupColors.teal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _colonneDroite(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 180,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: EnsupColors.soft,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: EnsupColors.line, width: 1.5),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(item.icon, size: 48, color: EnsupColors.muted),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
"Aperçu du matériel",
|
||||||
|
style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: item.disponible ? () => _demarrer(context) : null,
|
||||||
|
icon: const Icon(Icons.arrow_forward, size: 18),
|
||||||
|
label: Text(
|
||||||
|
"Démarrer l'emprunt",
|
||||||
|
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 16),
|
||||||
|
),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: EnsupColors.cyan,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: EnsupColors.text2,
|
||||||
|
side: const BorderSide(color: EnsupColors.line, width: 1.5),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
"Retour au catalogue",
|
||||||
|
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 15),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../demo_identity.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
import "../widgets/ensup_top_bar.dart";
|
||||||
|
import "../widgets/profile_card.dart";
|
||||||
|
import "../widgets/action_card.dart";
|
||||||
|
import "../widgets/brand_corners.dart";
|
||||||
|
import "catalogue_screen.dart";
|
||||||
|
import "restitution_select_screen.dart";
|
||||||
|
|
||||||
|
/// Écran d'accueil étudiant : profil + choix Emprunter / Restituer.
|
||||||
|
class HomeScreen extends StatelessWidget {
|
||||||
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: EnsupColors.soft,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: EnsupColors.text.withValues(alpha: 0.14),
|
||||||
|
blurRadius: 60,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
EnsupTopBar(
|
||||||
|
campusTag: DemoIdentity.campusTag,
|
||||||
|
userName: DemoIdentity.userName,
|
||||||
|
userInitials: DemoIdentity.userInitials,
|
||||||
|
onBack: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const ProfileCard(
|
||||||
|
name: DemoIdentity.userName,
|
||||||
|
subtitle: DemoIdentity.userSubtitle,
|
||||||
|
initials: DemoIdentity.userInitials,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
"Que souhaitez-vous faire ?",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: EnsupColors.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
IntrinsicHeight(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ActionCard(
|
||||||
|
icon: Icons.inventory_2_outlined,
|
||||||
|
title: "EMPRUNTER",
|
||||||
|
description:
|
||||||
|
"Accéder au catalogue et emprunter du matériel éducatif",
|
||||||
|
color: EnsupColors.cyan,
|
||||||
|
onTap: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (_) => const CatalogueScreen()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: ActionCard(
|
||||||
|
icon: Icons.assignment_return_outlined,
|
||||||
|
title: "RESTITUER",
|
||||||
|
description:
|
||||||
|
"Restituer un matériel que vous avez emprunté",
|
||||||
|
color: EnsupColors.teal,
|
||||||
|
onTap: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const RestitutionSelectScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Positioned.fill(child: BrandCorners()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../demo_identity.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
import "../widgets/ensup_top_bar.dart";
|
||||||
|
import "../widgets/identification_option.dart";
|
||||||
|
import "../widgets/brand_corners.dart";
|
||||||
|
import "home_screen.dart";
|
||||||
|
|
||||||
|
/// Écran de démarrage : identification de l'étudiant (RG01/RG02).
|
||||||
|
class LoginScreen extends StatelessWidget {
|
||||||
|
const LoginScreen({super.key});
|
||||||
|
|
||||||
|
void _identifier(BuildContext context) {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const HomeScreen(),
|
||||||
|
settings: const RouteSettings(name: "home"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: EnsupColors.soft,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: EnsupColors.text.withValues(alpha: 0.14),
|
||||||
|
blurRadius: 60,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const EnsupTopBar(
|
||||||
|
brandText: "EME — Emprunt Matériel ENSUP",
|
||||||
|
campusTag: DemoIdentity.loginCampusTag,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFFF0F6FF), Color(0xFFF8FAFC)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 40),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
colors: [EnsupColors.blue3, EnsupColors.cyan],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(22),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: EnsupColors.blue3.withValues(alpha: 0.35),
|
||||||
|
blurRadius: 24,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.badge_outlined, color: Colors.white, size: 34),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
"Bienvenue sur EME",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 34,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: EnsupColors.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
"Emprunt Matériel ENSUP · Accès réservé aux membres du groupe ENSUP",
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 14,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
IdentificationOption(
|
||||||
|
icon: Icons.qr_code_2,
|
||||||
|
iconColor: EnsupColors.cyan,
|
||||||
|
label: "Scanner ma carte étudiante",
|
||||||
|
description:
|
||||||
|
"Présentez votre carte étudiante dématérialisée à la douchette ou à la tablette",
|
||||||
|
onTap: () => _identifier(context),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
IdentificationOption(
|
||||||
|
icon: Icons.mail_outline,
|
||||||
|
iconColor: EnsupColors.teal,
|
||||||
|
label: "Connexion compte professionnel ENSUP",
|
||||||
|
description: "Utiliser mon adresse e-mail Microsoft 365",
|
||||||
|
onTap: () => _identifier(context),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Expanded(child: Divider(color: EnsupColors.line)),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Text(
|
||||||
|
"ou",
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 12,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Expanded(child: Divider(color: EnsupColors.line)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () {},
|
||||||
|
icon: const Icon(Icons.person_outline, size: 16),
|
||||||
|
label: Text(
|
||||||
|
"Accès responsable matériel",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: EnsupColors.text2,
|
||||||
|
side: const BorderSide(color: EnsupColors.line, width: 1.5),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Positioned.fill(child: BrandCorners()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../demo_identity.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
import "../models/emprunt_item.dart";
|
||||||
|
import "../services/emprunt_service.dart";
|
||||||
|
import "../services/materiel_service.dart";
|
||||||
|
import "../widgets/ensup_top_bar.dart";
|
||||||
|
import "../widgets/brand_corners.dart";
|
||||||
|
import "checklist_retour_screen.dart";
|
||||||
|
|
||||||
|
/// Sélection de l'emprunt en cours à restituer (RG15 : ses propres emprunts).
|
||||||
|
class RestitutionSelectScreen extends StatefulWidget {
|
||||||
|
const RestitutionSelectScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<RestitutionSelectScreen> createState() => _RestitutionSelectScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RestitutionSelectScreenState extends State<RestitutionSelectScreen> {
|
||||||
|
late Future<List<EmpruntItem>> _future;
|
||||||
|
int? _chargementId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_future = EmpruntService.getMesEmprunts();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _recharger() {
|
||||||
|
setState(() => _future = EmpruntService.getMesEmprunts());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _restituer(EmpruntItem emprunt) async {
|
||||||
|
setState(() => _chargementId = emprunt.id);
|
||||||
|
try {
|
||||||
|
final detail = await MaterielService.getDetail(emprunt.materiel.id);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => ChecklistRetourScreen(emprunt: emprunt.copyWith(materiel: detail)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(error.toString())),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _chargementId = null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: EnsupColors.soft,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: EnsupColors.text.withValues(alpha: 0.14),
|
||||||
|
blurRadius: 60,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
EnsupTopBar(
|
||||||
|
brandText: "Restitution",
|
||||||
|
campusTag: DemoIdentity.campusTag,
|
||||||
|
userName: DemoIdentity.userName,
|
||||||
|
userInitials: DemoIdentity.userInitials,
|
||||||
|
onBack: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28),
|
||||||
|
child: FutureBuilder<List<EmpruntItem>>(
|
||||||
|
future: _future,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return _ErreurState(
|
||||||
|
message: snapshot.error.toString(),
|
||||||
|
onRetry: _recharger,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _contenu(snapshot.data ?? const []);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Positioned.fill(child: BrandCorners()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _contenu(List<EmpruntItem> emprunts) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"Sélectionner le matériel à restituer",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: EnsupColors.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: emprunts.isEmpty
|
||||||
|
? const _EmptyState()
|
||||||
|
: ListView.separated(
|
||||||
|
itemCount: emprunts.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final emprunt = emprunts[index];
|
||||||
|
return _EmpruntCard(
|
||||||
|
emprunt: emprunt,
|
||||||
|
loading: _chargementId == emprunt.id,
|
||||||
|
onRestituer: () => _restituer(emprunt),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmpruntCard extends StatelessWidget {
|
||||||
|
const _EmpruntCard({
|
||||||
|
required this.emprunt,
|
||||||
|
required this.loading,
|
||||||
|
required this.onRestituer,
|
||||||
|
});
|
||||||
|
|
||||||
|
final EmpruntItem emprunt;
|
||||||
|
final bool loading;
|
||||||
|
final VoidCallback onRestituer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: EnsupColors.line, width: 1.5),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: EnsupColors.teal.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Icon(emprunt.materiel.icon, color: EnsupColors.teal, size: 20),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
emprunt.materiel.nom,
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 16,
|
||||||
|
color: EnsupColors.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
"Emprunté le ${emprunt.dateEmprunt} · Retour prévu ${emprunt.retourPrevu}",
|
||||||
|
style: GoogleFonts.titilliumWeb(fontSize: 12, color: EnsupColors.muted),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFEF9C3),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
emprunt.statut == "EN_RETARD" ? "En retard" : "En cours",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: const Color(0xFFA16207),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: loading ? null : onRestituer,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: EnsupColors.cyan,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
loading ? "Chargement..." : "Restituer",
|
||||||
|
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmptyState extends StatelessWidget {
|
||||||
|
const _EmptyState();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
"Aucun emprunt en cours à restituer",
|
||||||
|
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.muted),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ErreurState extends StatelessWidget {
|
||||||
|
const _ErreurState({required this.message, required this.onRetry});
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
final VoidCallback onRetry;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.cloud_off_outlined, size: 40, color: EnsupColors.muted),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.text2),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: onRetry,
|
||||||
|
child: Text(
|
||||||
|
"Réessayer",
|
||||||
|
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../demo_identity.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
import "../models/emprunt_item.dart";
|
||||||
|
import "../models/anomalie_retour.dart";
|
||||||
|
import "../widgets/ensup_top_bar.dart";
|
||||||
|
import "../widgets/brand_corners.dart";
|
||||||
|
|
||||||
|
/// Résultat de la restitution (RG17-RG20) : conforme (succès) ou non conforme
|
||||||
|
/// (anomalie détectée + notification au responsable).
|
||||||
|
class ResultatRetourScreen extends StatelessWidget {
|
||||||
|
const ResultatRetourScreen({super.key, required this.emprunt, required this.anomalies});
|
||||||
|
|
||||||
|
final EmpruntItem emprunt;
|
||||||
|
final List<AnomalieRetour> anomalies;
|
||||||
|
|
||||||
|
bool get _conforme => anomalies.isEmpty;
|
||||||
|
|
||||||
|
static String _deux(int valeur) => valeur.toString().padLeft(2, "0");
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fond = _conforme
|
||||||
|
? const [Color(0xFFF0FDF4), Color(0xFFF8FAFC)]
|
||||||
|
: const [Color(0xFFFFF5F5), Color(0xFFF8FAFC)];
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: EnsupColors.soft,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1280, maxHeight: 820),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: EnsupColors.text.withValues(alpha: 0.14),
|
||||||
|
blurRadius: 60,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const EnsupTopBar(
|
||||||
|
brandText: "EME · Résultat du contrôle",
|
||||||
|
campusTag: DemoIdentity.campusTag,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: fond,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (_conforme) _succes() else _anomalie(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: () => Navigator.of(context).popUntil(
|
||||||
|
(route) => route.settings.name == "home",
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.home_outlined, size: 18),
|
||||||
|
label: Text(
|
||||||
|
"Retour à l'accueil",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: EnsupColors.cyan,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Positioned.fill(child: BrandCorners()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _succes() {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(36),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(colors: [Color(0xFFF0FDF4), Color(0xFFECFDF5)]),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: const Color(0xFFBBF7D0), width: 2),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: LinearGradient(colors: [Color(0xFF16A34A), Color(0xFF22C55E)]),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.check, color: Colors.white, size: 36),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Text(
|
||||||
|
"Retour conforme",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: const Color(0xFF15803D),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
"L'emprunt a été clôturé et le matériel est de nouveau disponible.",
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: GoogleFonts.titilliumWeb(fontSize: 14, color: const Color(0xFF166534)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _anomalie() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final date =
|
||||||
|
"${_deux(now.day)}/${_deux(now.month)}/${now.year} · ${_deux(now.hour)}:${_deux(now.minute)}";
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: EnsupColors.red.withValues(alpha: 0.06),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: EnsupColors.red.withValues(alpha: 0.2), width: 1.5),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.warning_amber_rounded, color: EnsupColors.red, size: 24),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"Retour non conforme — Anomalie détectée",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: EnsupColors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
"Une différence a été constatée avec la checklist de départ. Une anomalie "
|
||||||
|
"a été créée automatiquement et le responsable matériel a été notifié.",
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
color: EnsupColors.text2,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: EnsupColors.line, width: 1.5),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"DÉTAIL DE L'ANOMALIE",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
for (final anomalie in anomalies)
|
||||||
|
_drow(
|
||||||
|
anomalie.element,
|
||||||
|
anomalie.etatRetour,
|
||||||
|
valeurCouleur: EnsupColors.red,
|
||||||
|
),
|
||||||
|
_drow("Matériel", "${emprunt.materiel.nom} · ${emprunt.materiel.reference}"),
|
||||||
|
_drow("Emprunteur", DemoIdentity.userName),
|
||||||
|
_drow("Date détection", date),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Text(
|
||||||
|
"Le responsable matériel traitera cette anomalie. Vous pouvez retourner à l'accueil.",
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted, height: 1.6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _drow(String cle, String valeur, {Color? valeurCouleur}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 7),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(cle, style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted)),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
valeur,
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: valeurCouleur ?? EnsupColors.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import "dart:convert";
|
||||||
|
import "package:http/http.dart" as http;
|
||||||
|
|
||||||
|
/// Erreur levée par le client API (serveur injoignable ou réponse en échec).
|
||||||
|
class ApiException implements Exception {
|
||||||
|
const ApiException(this.message);
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point d'accès unique au backend : centralise l'URL de base, l'en-tête
|
||||||
|
/// d'authentification (simulée) et la gestion des erreurs.
|
||||||
|
class ApiClient {
|
||||||
|
ApiClient._();
|
||||||
|
|
||||||
|
static const String _baseUrl = "http://localhost:3000/api";
|
||||||
|
static const String _utilisateurEmail = "lucas.martin@ensitech.eu";
|
||||||
|
|
||||||
|
static Future<dynamic> get(String chemin) async {
|
||||||
|
final uri = Uri.parse("$_baseUrl$chemin");
|
||||||
|
|
||||||
|
late final http.Response reponse;
|
||||||
|
try {
|
||||||
|
reponse = await http.get(uri, headers: const {"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> post(String chemin, Map<String, dynamic> body) async {
|
||||||
|
final uri = Uri.parse("$_baseUrl$chemin");
|
||||||
|
|
||||||
|
late final http.Response reponse;
|
||||||
|
try {
|
||||||
|
reponse = await http.post(
|
||||||
|
uri,
|
||||||
|
headers: const {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-user-email": _utilisateurEmail,
|
||||||
|
},
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
throw const ApiException(
|
||||||
|
"Impossible de joindre le serveur. Vérifiez que le backend est démarré.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reponse.statusCode >= 200 && reponse.statusCode < 300) {
|
||||||
|
return jsonDecode(reponse.body);
|
||||||
|
}
|
||||||
|
throw ApiException(_messageErreur(reponse));
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _messageErreur(http.Response reponse) {
|
||||||
|
try {
|
||||||
|
final corps = jsonDecode(reponse.body);
|
||||||
|
if (corps is Map && corps["error"] is Map) {
|
||||||
|
final message = (corps["error"] as Map)["message"];
|
||||||
|
if (message is String) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Corps non-JSON : on retombe sur le code HTTP.
|
||||||
|
}
|
||||||
|
return "Erreur ${reponse.statusCode}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import "../models/emprunt_response.dart";
|
||||||
|
import "../models/emprunt_item.dart";
|
||||||
|
import "api_client.dart";
|
||||||
|
|
||||||
|
/// Appels liés aux emprunts.
|
||||||
|
class EmpruntService {
|
||||||
|
EmpruntService._();
|
||||||
|
|
||||||
|
static const int posteEmpruntId = 1;
|
||||||
|
|
||||||
|
static Future<EmpruntResponse> creerEmprunt({
|
||||||
|
required int materielId,
|
||||||
|
required String commentaireDepart,
|
||||||
|
required List<Map<String, dynamic>> elements,
|
||||||
|
}) async {
|
||||||
|
final reponse = await ApiClient.post("/emprunts", {
|
||||||
|
"materielId": materielId,
|
||||||
|
"posteEmpruntId": posteEmpruntId,
|
||||||
|
"modeIdentification": "MAIL_ENSUP",
|
||||||
|
if (commentaireDepart.trim().isNotEmpty) "commentaireDepart": commentaireDepart.trim(),
|
||||||
|
"elements": elements,
|
||||||
|
});
|
||||||
|
|
||||||
|
final data = (reponse as Map<String, dynamic>)["data"] as Map<String, dynamic>;
|
||||||
|
return EmpruntResponse.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<List<EmpruntItem>> getMesEmprunts() async {
|
||||||
|
final reponse = await ApiClient.get("/mes-emprunts");
|
||||||
|
final data = (reponse as Map<String, dynamic>)["data"] as List<dynamic>;
|
||||||
|
return data.map((json) => EmpruntItem.fromJson(json as Map<String, dynamic>)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<EmpruntResponse> restituerEmprunt({
|
||||||
|
required int empruntId,
|
||||||
|
required String commentaireRetour,
|
||||||
|
required List<Map<String, dynamic>> elements,
|
||||||
|
}) async {
|
||||||
|
final reponse = await ApiClient.post("/emprunts/$empruntId/restitution", {
|
||||||
|
"modeIdentification": "MAIL_ENSUP",
|
||||||
|
if (commentaireRetour.trim().isNotEmpty) "commentaireRetour": commentaireRetour.trim(),
|
||||||
|
"elements": elements,
|
||||||
|
});
|
||||||
|
|
||||||
|
final data = (reponse as Map<String, dynamic>)["data"] as Map<String, dynamic>;
|
||||||
|
return EmpruntResponse.fromJson(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import "../models/materiel_item.dart";
|
||||||
|
import "api_client.dart";
|
||||||
|
|
||||||
|
/// Appels liés au catalogue matériel.
|
||||||
|
class MaterielService {
|
||||||
|
MaterielService._();
|
||||||
|
|
||||||
|
/// Récupère le catalogue (matériels disponibles du campus de l'utilisateur).
|
||||||
|
static Future<List<MaterielItem>> getCatalogue() async {
|
||||||
|
final reponse = await ApiClient.get("/materiels");
|
||||||
|
final data = (reponse as Map<String, dynamic>)["data"] as List<dynamic>;
|
||||||
|
return data.map((json) => MaterielItem.fromJson(json as Map<String, dynamic>)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère le détail d'un matériel, notamment les accessoires du kit.
|
||||||
|
static Future<MaterielItem> getDetail(int id) async {
|
||||||
|
final reponse = await ApiClient.get("/materiels/$id");
|
||||||
|
final data = (reponse as Map<String, dynamic>)["data"] as Map<String, dynamic>;
|
||||||
|
return MaterielItem.fromJson(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
|
||||||
|
/// Couleurs de la charte graphique ENSUP.
|
||||||
|
class EnsupColors {
|
||||||
|
EnsupColors._();
|
||||||
|
|
||||||
|
static const Color cyan = Color(0xFF009CE0);
|
||||||
|
static const Color blue1 = Color(0xFF006DAC);
|
||||||
|
static const Color blue2 = Color(0xFF0090D7);
|
||||||
|
static const Color blue3 = Color(0xFF005CAA);
|
||||||
|
static const Color teal = Color(0xFF198AB4);
|
||||||
|
static const Color purple = Color(0xFF7E5AA2);
|
||||||
|
static const Color red = Color(0xFFDD1B3D);
|
||||||
|
static const Color green = Color(0xFF16A34A);
|
||||||
|
|
||||||
|
static const Color text = Color(0xFF0F172A);
|
||||||
|
static const Color text2 = Color(0xFF334155);
|
||||||
|
static const Color muted = Color(0xFF64748B);
|
||||||
|
static const Color line = Color(0xFFE2E8F0);
|
||||||
|
static const Color soft = Color(0xFFF8FAFC);
|
||||||
|
static const Color white = Color(0xFFFFFFFF);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "ensup_colors.dart";
|
||||||
|
|
||||||
|
/// Thème global : couleurs ENSUP et police de corps Titillium Web.
|
||||||
|
/// Les titres utilisent Darker Grotesque, appliqué directement dans les widgets.
|
||||||
|
ThemeData buildEnsupTheme() {
|
||||||
|
final base = ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: ColorScheme.fromSeed(
|
||||||
|
seedColor: EnsupColors.cyan,
|
||||||
|
primary: EnsupColors.cyan,
|
||||||
|
),
|
||||||
|
scaffoldBackgroundColor: EnsupColors.soft,
|
||||||
|
);
|
||||||
|
|
||||||
|
return base.copyWith(
|
||||||
|
textTheme: GoogleFonts.titilliumWebTextTheme(base.textTheme).apply(
|
||||||
|
bodyColor: EnsupColors.text,
|
||||||
|
displayColor: EnsupColors.text,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
|
||||||
|
/// Grande carte d'action de l'accueil (Emprunter / Restituer).
|
||||||
|
class ActionCard extends StatelessWidget {
|
||||||
|
const ActionCard({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.description,
|
||||||
|
required this.color,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final Color color;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.07),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.2), width: 2),
|
||||||
|
),
|
||||||
|
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(
|
||||||
|
color: color.withValues(alpha: 0.35),
|
||||||
|
blurRadius: 16,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: Colors.white, size: 28),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: EnsupColors.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
|
||||||
|
/// Coins décoratifs de la charte ENSUP : un L coloré en haut-gauche et en
|
||||||
|
/// bas-droit, en surimpression et non cliquables (comme dans la maquette).
|
||||||
|
class BrandCorners extends StatelessWidget {
|
||||||
|
const BrandCorners({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return IgnorePointer(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
child: CustomPaint(size: const Size(120, 120), painter: _TopLeftCornerPainter()),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
child: CustomPaint(size: const Size(120, 120), painter: _BottomRightCornerPainter()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const double _thickness = 22;
|
||||||
|
|
||||||
|
class _TopLeftCornerPainter extends CustomPainter {
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final paint = Paint()..color = EnsupColors.red;
|
||||||
|
// Barre verticale gauche (rouge) + segment haut rouge.
|
||||||
|
canvas.drawRect(Rect.fromLTWH(0, 0, _thickness, size.height), paint);
|
||||||
|
canvas.drawRect(const Rect.fromLTWH(0, 0, 28, _thickness), paint);
|
||||||
|
// Segments colorés de la barre horizontale.
|
||||||
|
paint.color = EnsupColors.purple;
|
||||||
|
canvas.drawRect(const Rect.fromLTWH(28, 0, 22, _thickness), paint);
|
||||||
|
paint.color = EnsupColors.teal;
|
||||||
|
canvas.drawRect(const Rect.fromLTWH(50, 0, 22, _thickness), paint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BottomRightCornerPainter extends CustomPainter {
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final paint = Paint()..color = EnsupColors.cyan;
|
||||||
|
// Barre verticale droite (cyan) + segment bas cyan.
|
||||||
|
canvas.drawRect(Rect.fromLTWH(size.width - _thickness, 0, _thickness, size.height), paint);
|
||||||
|
canvas.drawRect(Rect.fromLTWH(size.width - 28, size.height - _thickness, 28, _thickness), paint);
|
||||||
|
// Segments colorés de la barre horizontale.
|
||||||
|
paint.color = EnsupColors.blue2;
|
||||||
|
canvas.drawRect(Rect.fromLTWH(size.width - 50, size.height - _thickness, 22, _thickness), paint);
|
||||||
|
paint.color = EnsupColors.blue3;
|
||||||
|
canvas.drawRect(Rect.fromLTWH(size.width - 72, size.height - _thickness, 22, _thickness), paint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
|
||||||
|
/// Barre supérieure ENSUP. L'utilisateur et le bouton retour sont optionnels
|
||||||
|
/// (absents sur l'écran d'identification).
|
||||||
|
class EnsupTopBar extends StatelessWidget {
|
||||||
|
const EnsupTopBar({
|
||||||
|
super.key,
|
||||||
|
this.brandText = "EME",
|
||||||
|
required this.campusTag,
|
||||||
|
this.userName,
|
||||||
|
this.userInitials,
|
||||||
|
this.onBack,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String brandText;
|
||||||
|
final String campusTag;
|
||||||
|
final String? userName;
|
||||||
|
final String? userInitials;
|
||||||
|
final VoidCallback? onBack;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final name = userName;
|
||||||
|
final initials = userInitials;
|
||||||
|
final onBack = this.onBack;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
height: 56,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
color: EnsupColors.blue3,
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (onBack != null) ...[
|
||||||
|
_BackButton(onTap: onBack),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
const Icon(Icons.badge_outlined, color: Colors.white, size: 18),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
brandText,
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
fontSize: 22,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(color: Colors.white.withValues(alpha: 0.2)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
campusTag,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 12,
|
||||||
|
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 {
|
||||||
|
const _BackButton({required this.onTap});
|
||||||
|
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.arrow_back, size: 16, color: Colors.white.withValues(alpha: 0.85)),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
"Retour",
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
color: Colors.white.withValues(alpha: 0.85),
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Avatar extends StatelessWidget {
|
||||||
|
const _Avatar({required this.initials});
|
||||||
|
|
||||||
|
final String initials;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: const LinearGradient(colors: [EnsupColors.teal, EnsupColors.cyan]),
|
||||||
|
border: Border.all(color: Colors.white.withValues(alpha: 0.3), width: 2),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
initials,
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
|
||||||
|
/// État possible d'un élément de checklist (départ ou retour).
|
||||||
|
enum EtatChecklist { present, absent, deteriore }
|
||||||
|
|
||||||
|
/// Case cliquable colorée selon l'état sélectionné (vert / rouge / jaune).
|
||||||
|
class EtatBox extends StatelessWidget {
|
||||||
|
const EtatBox({
|
||||||
|
super.key,
|
||||||
|
required this.type,
|
||||||
|
required this.selected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final EtatChecklist type;
|
||||||
|
final bool selected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
var background = Colors.white;
|
||||||
|
var border = EnsupColors.line;
|
||||||
|
Widget? contenu;
|
||||||
|
|
||||||
|
if (selected) {
|
||||||
|
switch (type) {
|
||||||
|
case EtatChecklist.present:
|
||||||
|
background = const Color(0xFFF0FDF4);
|
||||||
|
border = const Color(0xFF86EFAC);
|
||||||
|
contenu = const Icon(Icons.check, size: 16, color: Color(0xFF16A34A));
|
||||||
|
case EtatChecklist.absent:
|
||||||
|
background = const Color(0xFFFEF2F2);
|
||||||
|
border = const Color(0xFFFCA5A5);
|
||||||
|
contenu = const Icon(Icons.close, size: 16, color: EnsupColors.red);
|
||||||
|
case EtatChecklist.deteriore:
|
||||||
|
background = const Color(0xFFFFFBEB);
|
||||||
|
border = const Color(0xFFFDE68A);
|
||||||
|
contenu = const Icon(Icons.priority_high, size: 16, color: Color(0xFFD97706));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: background,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: border, width: 2),
|
||||||
|
),
|
||||||
|
child: contenu,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tableau de checklist : une ligne par accessoire avec les 3 états possibles.
|
||||||
|
class ChecklistTableau extends StatelessWidget {
|
||||||
|
const ChecklistTableau({
|
||||||
|
super.key,
|
||||||
|
required this.accessoires,
|
||||||
|
required this.etats,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<String> accessoires;
|
||||||
|
final List<EtatChecklist> etats;
|
||||||
|
final void Function(int index, EtatChecklist etat) onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_entete(),
|
||||||
|
for (var i = 0; i < accessoires.length; i++) _ligne(i),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _entete() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
border: Border(bottom: BorderSide(color: EnsupColors.line, width: 2)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _texteEntete("ÉLÉMENT")),
|
||||||
|
_colonneEntete("PRÉSENT"),
|
||||||
|
_colonneEntete("ABSENT"),
|
||||||
|
_colonneEntete("DÉTÉRIORÉ"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _texteEntete(String label) {
|
||||||
|
return Text(
|
||||||
|
label,
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _colonneEntete(String label) {
|
||||||
|
return SizedBox(width: 72, child: Center(child: _texteEntete(label)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _ligne(int index) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
border: Border(bottom: BorderSide(color: EnsupColors.line)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
accessoires[index],
|
||||||
|
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.text2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_case(index, EtatChecklist.present),
|
||||||
|
_case(index, EtatChecklist.absent),
|
||||||
|
_case(index, EtatChecklist.deteriore),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _case(int index, EtatChecklist type) {
|
||||||
|
return SizedBox(
|
||||||
|
width: 72,
|
||||||
|
child: Center(
|
||||||
|
child: EtatBox(
|
||||||
|
type: type,
|
||||||
|
selected: etats[index] == type,
|
||||||
|
onTap: () => onChanged(index, type),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encadré d'instructions / rappel à côté d'une checklist.
|
||||||
|
class ChecklistInstructions extends StatelessWidget {
|
||||||
|
const ChecklistInstructions({super.key, required this.titre, required this.texte});
|
||||||
|
|
||||||
|
final String titre;
|
||||||
|
final String texte;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: EnsupColors.blue2.withValues(alpha: 0.05),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: EnsupColors.blue2.withValues(alpha: 0.15), width: 1.5),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
titre.toUpperCase(),
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
texte,
|
||||||
|
style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.text2, height: 1.6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zone de commentaire optionnel sous une checklist.
|
||||||
|
class ChecklistCommentaire extends StatelessWidget {
|
||||||
|
const ChecklistCommentaire({
|
||||||
|
super.key,
|
||||||
|
this.controller,
|
||||||
|
this.hint = "Commentaire ou observation optionnelle...",
|
||||||
|
});
|
||||||
|
|
||||||
|
final TextEditingController? controller;
|
||||||
|
final String hint;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextField(
|
||||||
|
controller: controller,
|
||||||
|
maxLines: 4,
|
||||||
|
style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.text),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: hint,
|
||||||
|
hintStyle: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.white,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(9),
|
||||||
|
borderSide: const BorderSide(color: EnsupColors.line, width: 1.5),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(9),
|
||||||
|
borderSide: const BorderSide(color: EnsupColors.cyan, width: 1.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
|
||||||
|
/// Option d'identification de l'écran de connexion (QR ou mail ENSUP).
|
||||||
|
class IdentificationOption extends StatelessWidget {
|
||||||
|
const IdentificationOption({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.iconColor,
|
||||||
|
required this.label,
|
||||||
|
required this.description,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final Color iconColor;
|
||||||
|
final String label;
|
||||||
|
final String description;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: EnsupColors.line, width: 2),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: iconColor.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: iconColor, size: 24),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 18,
|
||||||
|
color: EnsupColors.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 12,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(Icons.chevron_right, color: EnsupColors.muted, size: 20),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
import "../models/materiel_item.dart";
|
||||||
|
import "status_pill.dart";
|
||||||
|
|
||||||
|
/// Ligne du catalogue matériel (fidèle au `.mat-card` de la maquette).
|
||||||
|
class MaterielCard extends StatelessWidget {
|
||||||
|
const MaterielCard({super.key, required this.item, this.onSelect});
|
||||||
|
|
||||||
|
final MaterielItem item;
|
||||||
|
final VoidCallback? onSelect;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final disponible = item.disponible;
|
||||||
|
final accent = disponible ? EnsupColors.cyan : EnsupColors.muted;
|
||||||
|
|
||||||
|
return Opacity(
|
||||||
|
opacity: disponible ? 1 : 0.55,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: EnsupColors.line, width: 1.5),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accent.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Icon(item.icon, color: accent, size: 20),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item.nom,
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 16,
|
||||||
|
color: EnsupColors.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
"${item.categorie} · Réf. ${item.reference} · ${item.campus}",
|
||||||
|
style: GoogleFonts.titilliumWeb(fontSize: 12, color: EnsupColors.muted),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
StatusPill(disponible: disponible),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
if (disponible)
|
||||||
|
FilledButton(
|
||||||
|
onPressed: onSelect,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: EnsupColors.cyan,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
"Sélectionner",
|
||||||
|
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 14),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Text(
|
||||||
|
"Indisponible",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 14,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
|
||||||
|
/// Carte de profil de l'étudiant identifié (accueil).
|
||||||
|
class ProfileCard extends StatelessWidget {
|
||||||
|
const ProfileCard({
|
||||||
|
super.key,
|
||||||
|
required this.name,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.initials,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final String subtitle;
|
||||||
|
final String initials;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
EnsupColors.cyan.withValues(alpha: 0.06),
|
||||||
|
EnsupColors.teal.withValues(alpha: 0.04),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: EnsupColors.cyan.withValues(alpha: 0.15), width: 1.5),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: LinearGradient(colors: [EnsupColors.teal, EnsupColors.cyan]),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
initials,
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
fontSize: 20,
|
||||||
|
color: EnsupColors.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: GoogleFonts.titilliumWeb(fontSize: 12, color: EnsupColors.muted),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: EnsupColors.green.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
"Connecté",
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: EnsupColors.green,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
|
||||||
|
/// Pastille de statut d'un matériel (disponible / emprunté).
|
||||||
|
class StatusPill extends StatelessWidget {
|
||||||
|
const StatusPill({super.key, required this.disponible});
|
||||||
|
|
||||||
|
final bool disponible;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final label = disponible ? "Disponible" : "Emprunté";
|
||||||
|
final background = disponible ? const Color(0xFFDCFCE7) : const Color(0xFFFEF9C3);
|
||||||
|
final foreground = disponible ? const Color(0xFF15803D) : const Color(0xFFA16207);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(999)),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: foreground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
# Generated by pub
|
||||||
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
|
packages:
|
||||||
|
args:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: args
|
||||||
|
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
|
async:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: async
|
||||||
|
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.13.1"
|
||||||
|
boolean_selector:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: boolean_selector
|
||||||
|
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
|
characters:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: characters
|
||||||
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.1"
|
||||||
|
clock:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: clock
|
||||||
|
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.2"
|
||||||
|
collection:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: collection
|
||||||
|
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.19.1"
|
||||||
|
crypto:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: crypto
|
||||||
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.7"
|
||||||
|
cupertino_icons:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: cupertino_icons
|
||||||
|
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.9"
|
||||||
|
fake_async:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fake_async
|
||||||
|
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.3"
|
||||||
|
ffi:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: ffi
|
||||||
|
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
flutter:
|
||||||
|
dependency: "direct main"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
flutter_lints:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: flutter_lints
|
||||||
|
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.0.0"
|
||||||
|
flutter_test:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
google_fonts:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: google_fonts
|
||||||
|
sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.1.0"
|
||||||
|
http:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: http
|
||||||
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.6.0"
|
||||||
|
http_parser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_parser
|
||||||
|
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.2"
|
||||||
|
jni:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni
|
||||||
|
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
|
jni_flutter:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni_flutter
|
||||||
|
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.1"
|
||||||
|
leak_tracker:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker
|
||||||
|
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "11.0.2"
|
||||||
|
leak_tracker_flutter_testing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker_flutter_testing
|
||||||
|
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.10"
|
||||||
|
leak_tracker_testing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: leak_tracker_testing
|
||||||
|
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.2"
|
||||||
|
lints:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: lints
|
||||||
|
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.1.0"
|
||||||
|
matcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: matcher
|
||||||
|
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.12.19"
|
||||||
|
material_color_utilities:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: material_color_utilities
|
||||||
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.13.0"
|
||||||
|
meta:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: meta
|
||||||
|
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.18.0"
|
||||||
|
package_config:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_config
|
||||||
|
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
path:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path
|
||||||
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.9.1"
|
||||||
|
path_provider:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider
|
||||||
|
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.6"
|
||||||
|
path_provider_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_android
|
||||||
|
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.1"
|
||||||
|
path_provider_foundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_foundation
|
||||||
|
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.1"
|
||||||
|
path_provider_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_linux
|
||||||
|
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.2"
|
||||||
|
path_provider_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_platform_interface
|
||||||
|
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.3"
|
||||||
|
path_provider_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_windows
|
||||||
|
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.0"
|
||||||
|
platform:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: platform
|
||||||
|
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.6"
|
||||||
|
plugin_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: plugin_platform_interface
|
||||||
|
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.8"
|
||||||
|
sky_engine:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
source_span:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_span
|
||||||
|
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.10.2"
|
||||||
|
stack_trace:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stack_trace
|
||||||
|
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.12.1"
|
||||||
|
stream_channel:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stream_channel
|
||||||
|
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.4"
|
||||||
|
string_scanner:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: string_scanner
|
||||||
|
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.1"
|
||||||
|
term_glyph:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: term_glyph
|
||||||
|
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.2"
|
||||||
|
test_api:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: test_api
|
||||||
|
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.11"
|
||||||
|
typed_data:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: typed_data
|
||||||
|
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
|
vector_math:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vector_math
|
||||||
|
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
vm_service:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vm_service
|
||||||
|
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "15.2.0"
|
||||||
|
web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: web
|
||||||
|
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
|
xdg_directories:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xdg_directories
|
||||||
|
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
sdks:
|
||||||
|
dart: ">=3.10.0 <4.0.0"
|
||||||
|
flutter: ">=3.38.0"
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
name: eme_frontend
|
||||||
|
description: "A new Flutter project."
|
||||||
|
# The following line prevents the package from being accidentally published to
|
||||||
|
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||||
|
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
|
|
||||||
|
# The following defines the version and build number for your application.
|
||||||
|
# A version number is three numbers separated by dots, like 1.2.43
|
||||||
|
# followed by an optional build number separated by a +.
|
||||||
|
# Both the version and the builder number may be overridden in flutter
|
||||||
|
# build by specifying --build-name and --build-number, respectively.
|
||||||
|
# In Android, build-name is used as versionName while build-number used as versionCode.
|
||||||
|
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
|
||||||
|
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
|
||||||
|
# Read more about iOS versioning at
|
||||||
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
|
version: 1.0.0+1
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: ^3.10.0
|
||||||
|
|
||||||
|
# Dependencies specify other packages that your package needs in order to work.
|
||||||
|
# To automatically upgrade your package dependencies to the latest versions
|
||||||
|
# consider running `flutter pub upgrade --major-versions`. Alternatively,
|
||||||
|
# dependencies can be manually updated by changing the version numbers below to
|
||||||
|
# the latest version available on pub.dev. To see which dependencies have newer
|
||||||
|
# versions available, run `flutter pub outdated`.
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
# The following adds the Cupertino Icons font to your application.
|
||||||
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
|
cupertino_icons: ^1.0.8
|
||||||
|
google_fonts: ^8.1.0
|
||||||
|
http: ^1.6.0
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
# The "flutter_lints" package below contains a set of recommended lints to
|
||||||
|
# encourage good coding practices. The lint set provided by the package is
|
||||||
|
# activated in the `analysis_options.yaml` file located at the root of your
|
||||||
|
# package. See that file for information about deactivating specific lint
|
||||||
|
# rules and activating additional ones.
|
||||||
|
flutter_lints: ^6.0.0
|
||||||
|
|
||||||
|
# For information on the generic Dart part of this file, see the
|
||||||
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
|
|
||||||
|
# The following section is specific to Flutter packages.
|
||||||
|
flutter:
|
||||||
|
|
||||||
|
# The following line ensures that the Material Icons font is
|
||||||
|
# included with your application, so that you can use the icons in
|
||||||
|
# the material Icons class.
|
||||||
|
uses-material-design: true
|
||||||
|
|
||||||
|
# To add assets to your application, add an assets section, like this:
|
||||||
|
# assets:
|
||||||
|
# - images/a_dot_burr.jpeg
|
||||||
|
# - images/a_dot_ham.jpeg
|
||||||
|
|
||||||
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
|
# https://flutter.dev/to/resolution-aware-images
|
||||||
|
|
||||||
|
# For details regarding adding assets from package dependencies, see
|
||||||
|
# https://flutter.dev/to/asset-from-package
|
||||||
|
|
||||||
|
# To add custom fonts to your application, add a fonts section here,
|
||||||
|
# in this "flutter" section. Each entry in this list should have a
|
||||||
|
# "family" key with the font family name, and a "fonts" key with a
|
||||||
|
# list giving the asset and other descriptors for the font. For
|
||||||
|
# example:
|
||||||
|
# fonts:
|
||||||
|
# - family: Schyler
|
||||||
|
# fonts:
|
||||||
|
# - asset: fonts/Schyler-Regular.ttf
|
||||||
|
# - asset: fonts/Schyler-Italic.ttf
|
||||||
|
# style: italic
|
||||||
|
# - family: Trajan Pro
|
||||||
|
# fonts:
|
||||||
|
# - asset: fonts/TrajanPro.ttf
|
||||||
|
# - asset: fonts/TrajanPro_Bold.ttf
|
||||||
|
# weight: 700
|
||||||
|
#
|
||||||
|
# For details regarding fonts from package dependencies,
|
||||||
|
# see https://flutter.dev/to/font-from-package
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import "package:flutter_test/flutter_test.dart";
|
||||||
|
import "package:eme_frontend/main.dart";
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets("L'écran de démarrage affiche l'identification", (tester) async {
|
||||||
|
await tester.pumpWidget(const EmeApp());
|
||||||
|
|
||||||
|
expect(find.text("Bienvenue sur EME"), findsOneWidget);
|
||||||
|
expect(find.text("Scanner ma carte étudiante"), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 917 B |
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,38 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<!--
|
||||||
|
If you are serving your web app in a path other than the root, change the
|
||||||
|
href value below to reflect the base path you are serving from.
|
||||||
|
|
||||||
|
The path provided below has to start and end with a slash "/" in order for
|
||||||
|
it to work correctly.
|
||||||
|
|
||||||
|
For more details:
|
||||||
|
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
||||||
|
|
||||||
|
This is a placeholder for base href that will be replaced by the value of
|
||||||
|
the `--base-href` argument provided to `flutter build`.
|
||||||
|
-->
|
||||||
|
<base href="$FLUTTER_BASE_HREF">
|
||||||
|
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||||
|
<meta name="description" content="A new Flutter project.">
|
||||||
|
|
||||||
|
<!-- iOS meta tags & icons -->
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="eme_frontend">
|
||||||
|
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||||
|
|
||||||
|
<!-- Favicon -->
|
||||||
|
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||||
|
|
||||||
|
<title>eme_frontend</title>
|
||||||
|
<link rel="manifest" href="manifest.json">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script src="flutter_bootstrap.js" async></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "eme_frontend",
|
||||||
|
"short_name": "eme_frontend",
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0175C2",
|
||||||
|
"theme_color": "#0175C2",
|
||||||
|
"description": "A new Flutter project.",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"prefer_related_applications": false,
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "icons/Icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/Icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/Icon-maskable-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/Icon-maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -149,6 +149,49 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
|
|||||||
- Préventif : clés/certificats (`*.pem`, `*.key`, `*.crt`, `*.cert`, `*.pfx`, `*.p12`) pour Azure AD/TLS à venir ; dumps de base (`*.bak`, `*.dump`) ; `**/coverage/` ; `*.tsbuildinfo`.
|
- Préventif : clés/certificats (`*.pem`, `*.key`, `*.crt`, `*.cert`, `*.pfx`, `*.p12`) pour Azure AD/TLS à venir ; dumps de base (`*.bak`, `*.dump`) ; `**/coverage/` ; `*.tsbuildinfo`.
|
||||||
- Vérifié : aucun secret n'était déjà suivi ; les vrais `.env` restent ignorés ; `.env.example` (placeholders) reste versionné.
|
- Vérifié : aucun secret n'était déjà suivi ; les vrais `.env` restent ignorés ; `.env.example` (placeholders) reste versionné.
|
||||||
|
|
||||||
|
### Étape 19 — Block 4 : création d'emprunt (POST /api/emprunts)
|
||||||
|
- Écriture transactionnelle : réservation atomique du matériel (`updateMany where statut=DISPONIBLE`), création de l'emprunt (`EN_COURS`), de la checklist de départ et de ses éléments. Rollback global si une étape échoue.
|
||||||
|
- Règles : RG07 (matériel disponible), RG10 (matériel du campus de l'étudiant), RG11 (checklist obligatoire non vide), RG12 (matériel -> `EMPRUNTE`, horodatage).
|
||||||
|
- Validation d'entrée manuelle (`CreerEmpruntRequest`, `parseCreerEmpruntRequest`) : aucune dépendance ajoutée. Durée d'emprunt par défaut : +14 jours.
|
||||||
|
- Contrôle du poste (existe + même campus, RG05). Routage : `POST /api/emprunts` et `GET /api/mes-emprunts` sur des routeurs séparés.
|
||||||
|
- Vérifié au runtime : 401 sans auth, 400 checklist vide, 404 matériel inconnu, 201 création (matériel -> `EMPRUNTE`, +14j), 409 doublon (réservation atomique).
|
||||||
|
|
||||||
|
### Étape 20 — Block 4 : restitution + anomalie automatique (POST /api/emprunts/:id/restitution)
|
||||||
|
- Comparaison automatique départ/retour (RG17) : un élément présent au départ mais absent/détérioré au retour rend la restitution non conforme (RG20). Appariement par `accessoireId` sinon par `nomElement`.
|
||||||
|
- Restitution atomique : checklist de retour + mise à jour emprunt/matériel + (si non conforme) anomalie `DETECTEE` + notifications.
|
||||||
|
- RG15 (propriétaire), statut restituable, RG18 (conforme -> `CLOTURE` + matériel `DISPONIBLE`), RG19 (non conforme -> `RETOUR_NON_CONFORME` + matériel `DETERIORE`/`NON_CONFORME`).
|
||||||
|
- RG24 : notification à tous les responsables (`RESPONSABLE`) du campus de l'emprunt.
|
||||||
|
- Vérifié au runtime : 404/403/409 ; restitution conforme (CLOTURE + DISPONIBLE) ; non conforme (RETOUR_NON_CONFORME + NON_CONFORME + anomalie + notification à Karim).
|
||||||
|
- **Block 4 (API Étudiant) terminé : 6/6 endpoints.**
|
||||||
|
|
||||||
|
### Étape 21 — Frontend : setup Flutter Web + écran d'accueil ENSUP
|
||||||
|
- Projet Flutter Web créé (`eme-frontend/`), `google_fonts` ajouté (Darker Grotesque pour les titres, Titillium Web pour le corps).
|
||||||
|
- Thème ENSUP (`theme/ensup_colors.dart`, `theme/ensup_theme.dart`) : couleurs de la charte graphique.
|
||||||
|
- Composants réutilisables : `EnsupTopBar`, `ProfileCard`, `ActionCard`.
|
||||||
|
- Écran d'accueil (`screens/home_screen.dart`) fidèle à la maquette : topbar bleu foncé, carte profil, deux cartes Emprunter/Restituer. Données statiques (pas encore branché à l'API).
|
||||||
|
- Convention `CLAUDE.md` respectée : guillemets doubles en Dart (règle lint `prefer_single_quotes` désactivée en conséquence).
|
||||||
|
- `flutter analyze` : aucun problème. App lancée dans Chrome (http://localhost:5000).
|
||||||
|
|
||||||
|
### Étape 22 — Suppression du PDF de charte graphique
|
||||||
|
- PDF de charte ENSUP retiré du dépôt (redondant : la maquette validée applique la charte, et les couleurs/typos sont dans `CONTEXT.md` §7).
|
||||||
|
- Références mises à jour dans `CLAUDE.md` et `CONTEXT.md` pour pointer vers la maquette.
|
||||||
|
- Le PDF reste récupérable dans l'historique Git si besoin (logo officiel source).
|
||||||
|
|
||||||
|
### Étape 23 — Frontend : écran d'identification (écran de démarrage)
|
||||||
|
- Correction : l'écran de démarrage est l'identification (`s-login` de la maquette), pas l'accueil étudiant. Logo, "Bienvenue sur EME", deux options (scanner carte / compte ENSUP Microsoft 365), bouton accès responsable.
|
||||||
|
- `EnsupTopBar` rendue réutilisable (utilisateur optionnel, marque paramétrable) ; nouveau widget `IdentificationOption` ; coins ENSUP aussi sur cet écran.
|
||||||
|
- Navigation identification -> accueil étudiant (`HomeScreen`).
|
||||||
|
|
||||||
|
### Étape 24 — Frontend : écran catalogue matériel
|
||||||
|
- Écran catalogue (`s-catalogue`) : recherche **fonctionnelle** + filtres par catégorie (chips) + liste de matériels avec statut (Disponible/Emprunté) et état vide.
|
||||||
|
- Nouveaux composants : `MaterielCard`, `StatusPill`, modèle `MaterielItem`. Données statiques (4 matériels de la maquette).
|
||||||
|
- Navigation : Accueil (Emprunter) -> Catalogue ; bouton retour vers l'accueil. « Sélectionner » -> placeholder (détail à venir).
|
||||||
|
|
||||||
|
### Étape 25 — Frontend : écran détail matériel
|
||||||
|
- Écran détail (`s-detail`) : caractéristiques (référence, catégorie, marque, modèle, état, campus, statut) + accessoires du kit (tags) + aperçu + boutons « Démarrer l'emprunt » / « Retour au catalogue ».
|
||||||
|
- Modèle `MaterielItem` enrichi (marque, modèle, état, accessoires) ; données statiques mises à jour.
|
||||||
|
- Navigation : Catalogue (Sélectionner) -> Détail ; « Démarrer l'emprunt » -> placeholder (checklist à venir).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Dette technique en attente
|
## Dette technique en attente
|
||||||
@@ -164,4 +207,58 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Dernière mise à jour : 2026-06-22 — Durcissement .gitignore (sécurité secrets).*
|
### Étape 26 — Connexion frontend ↔ API (catalogue)
|
||||||
|
- Package `http` ajouté. Couche `services/api_client.dart` : URL de base, en-tête d'auth simulée (`x-user-email: lucas.martin@ensitech.eu`), gestion des erreurs (serveur injoignable, message `{error:{message}}`).
|
||||||
|
- `services/materiel_service.dart` (`getCatalogue`) + `MaterielItem.fromJson` (icône déduite de la catégorie, champs absents complétés).
|
||||||
|
- Écran catalogue branché via `FutureBuilder` : chargement / données / erreur + bouton Réessayer. Filtres de catégorie déduits des vraies données.
|
||||||
|
- CORS backend assoupli en développement (`origin: true` si `NODE_ENV=development`), strict en production.
|
||||||
|
- Testé en réel côté backend : Docker SQL Server OK, backend compilé OK, `/health`, `/api/auth/me`, `/api/materiels` OK, 401 sans `x-user-email` OK.
|
||||||
|
|
||||||
|
### Étape 27 — Connexion frontend ↔ API (détail matériel)
|
||||||
|
- Correctif runtime backend : `npm run dev` lance maintenant `ts-node --files`, sinon l'augmentation `Express.Request.user` n'était pas chargée par `ts-node` malgré un build TypeScript vert.
|
||||||
|
- Endpoint `GET /api/materiels/:id` ajouté : détail filtré par campus utilisateur, avec catégorie, campus et accessoires du kit.
|
||||||
|
- Frontend : `MaterielItem` porte maintenant l'`id`, `MaterielService.getDetail(id)` consomme le nouvel endpoint, et le catalogue charge le détail réel avant de naviguer vers l'écran détail.
|
||||||
|
- Testé en réel côté backend : `/api/materiels/1` renvoie le PC avec accessoires (`Chargeur`, `Souris`) ; `/api/materiels/abc` renvoie 400.
|
||||||
|
- Limite environnement : `flutter analyze`, `flutter analyze --no-pub` et `dart format` restent bloqués jusqu'au timeout dans le sandbox. À relancer dans un terminal Flutter local.
|
||||||
|
|
||||||
|
### Étape 28 — Connexion frontend ↔ API (création d'emprunt)
|
||||||
|
- Branche dédiée `feat/frontend-emprunt-api` créée après merge de `feat/catalogue-detail-api` dans `develop`.
|
||||||
|
- `ApiClient.post` ajouté et `EmpruntService.creerEmprunt` consomme `POST /api/emprunts` avec l'auth simulée.
|
||||||
|
- Checklist de départ branchée sur l'API : construction du payload (`materielId`, `posteEmpruntId`, `modeIdentification`, commentaire, éléments), état de chargement, affichage d'erreur via snackbar.
|
||||||
|
- Un matériel sans accessoire génère un élément de checklist portant le nom du matériel, afin de respecter RG11 côté backend (checklist obligatoire non vide).
|
||||||
|
- Confirmation alimentée par la réponse API (`id`, `dateEmprunt`, matériel renvoyé).
|
||||||
|
- Testé sans mutation de données : `POST /api/emprunts` avec checklist vide renvoie bien 400 `La checklist de depart est obligatoire (RG11)`.
|
||||||
|
|
||||||
|
### Étape 29 — Données de démonstration catalogue
|
||||||
|
- Branche dédiée `feat/demo-catalogue-fixtures` créée après merge de `feat/frontend-emprunt-api` dans `develop`.
|
||||||
|
- `prisma/fixtures.ts` enrichi avec un catalogue de démonstration idempotent : si les fixtures de base existent déjà, le script assure seulement les matériels de catalogue manquants.
|
||||||
|
- 8 matériels disponibles ajoutés : Lenovo ThinkPad, MacBook Air, Surface Pro, vidéoprojecteur Epson, caméra Canon, casque Jabra, kit câbles HDMI/USB-C, enceinte JBL.
|
||||||
|
- Accessoires associés assurés sans doublon logique par recherche de nom/référence avant création.
|
||||||
|
- Vérifié en réel : `npm run fixtures` OK ; `GET /api/materiels` pour Lucas renvoie 10 matériels disponibles.
|
||||||
|
|
||||||
|
### Étape 30 — Connexion frontend ↔ API (restitution)
|
||||||
|
- Branche dédiée `feat/frontend-restitution-api` créée après merge de `feat/demo-catalogue-fixtures` dans `develop`.
|
||||||
|
- `EmpruntItem.fromJson` ajouté pour mapper `GET /api/mes-emprunts`.
|
||||||
|
- `EmpruntService.getMesEmprunts()` et `EmpruntService.restituerEmprunt()` ajoutés.
|
||||||
|
- Écran de sélection restitution branché sur les vrais emprunts en cours, avec chargement, erreur, état vide et chargement du détail matériel avant la checklist.
|
||||||
|
- Checklist retour branchée sur `POST /api/emprunts/:id/restitution`, avec commentaire optionnel, état de chargement et erreurs via snackbar.
|
||||||
|
- Le résultat affiché se base sur le statut renvoyé par le backend (`CLOTURE` => conforme, sinon anomalie), afin de rester cohérent avec la comparaison serveur.
|
||||||
|
- Vérifié sans mutation : `GET /api/mes-emprunts` renvoie les emprunts de Lucas ; restitution d'un identifiant inexistant renvoie 404.
|
||||||
|
|
||||||
|
### Étape 31 — Stabilisation parcours étudiant complet
|
||||||
|
- Branche dédiée `feat/student-flow-stabilization` créée après merge de `feat/frontend-restitution-api` dans `develop`.
|
||||||
|
- Docker SQL Server relancé puis tests runtime faits avec le backend compilé.
|
||||||
|
- Flux conforme validé en réel : catalogue -> détail -> création emprunt -> apparition dans `mes-emprunts` -> restitution conforme -> statut `CLOTURE` -> matériel de nouveau visible dans le catalogue.
|
||||||
|
- Flux non conforme validé en réel : création emprunt -> restitution avec élément absent -> statut `RETOUR_NON_CONFORME` -> matériel retiré du catalogue disponible.
|
||||||
|
- Aucun correctif code nécessaire après ces tests ; backend `build` et `lint` restent verts.
|
||||||
|
|
||||||
|
### Étape 32 — Polish V1 étudiant
|
||||||
|
- Branche dédiée `feat/v1-student-polish` créée après merge de `feat/student-flow-stabilization` dans `develop`.
|
||||||
|
- Identité simulée et campus MVP centralisés dans `lib/demo_identity.dart` tant que l'auth Azure AD n'est pas branchée.
|
||||||
|
- Libellés UI harmonisés sur le contexte réel du MVP : `Saint-Christophe · Cergy` / `Ensitech Cergy` au lieu de `Paris · Ensitech`.
|
||||||
|
- Commentaire obsolète du modèle `MaterielItem` corrigé : les données viennent maintenant de l'API.
|
||||||
|
- `TODO.md` mis à jour : les parcours emprunt et restitution étudiant sont maintenant branchés API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Dernière mise à jour : 2026-07-08 — Parcours étudiant complet testé et libellés V1 harmonisés.*
|
||||||
|
|||||||
Reference in New Issue
Block a user