Compare commits
20 Commits
e2165b9826
...
1677015254
| Author | SHA1 | Date | |
|---|---|---|---|
| 1677015254 | |||
| 9a71eb3f34 | |||
| 2a533e3368 | |||
| 545d9f14ac | |||
| 4b635b8654 | |||
| 2a1281b0e5 | |||
| 736328c66b | |||
| 2772f4cfd4 | |||
| 9d8ae70063 | |||
| 92e25356bf | |||
| 832aa51e23 | |||
| d2d2245675 | |||
| a14d6827ee | |||
| 1ce023de71 | |||
| 26f036046d | |||
| c76f546f2a | |||
| 2c81bd7208 | |||
| f7f61a8ebd | |||
| b2a5f8cd94 | |||
| a3795b41a0 |
@@ -0,0 +1,222 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { AppError } from '../errors/app-error';
|
||||||
|
import {
|
||||||
|
changerStatutAnomalieResponsableService,
|
||||||
|
getDashboardResponsable,
|
||||||
|
listerAnomaliesResponsable,
|
||||||
|
listerHistoriqueResponsable,
|
||||||
|
listerNotificationsResponsable,
|
||||||
|
listerMaterielsResponsable,
|
||||||
|
listerEmpruntsResponsable,
|
||||||
|
marquerNotificationResponsableLue,
|
||||||
|
marquerToutesNotificationsResponsableLues,
|
||||||
|
parseAnomalieId,
|
||||||
|
parseCategorieId,
|
||||||
|
parseLu,
|
||||||
|
parseNotificationId,
|
||||||
|
parseDateQuery,
|
||||||
|
parsePositiveIntQuery,
|
||||||
|
parseStatutAnomalie,
|
||||||
|
parseStatutMateriel,
|
||||||
|
parseStatutEmprunt,
|
||||||
|
} from '../services/responsable.service';
|
||||||
|
import {
|
||||||
|
toAnomalieResponsableResponse,
|
||||||
|
toDashboardResponsableResponse,
|
||||||
|
toEmpruntResponsableResponse,
|
||||||
|
toHistoriqueResponsableResponse,
|
||||||
|
toMaterielResponsableResponse,
|
||||||
|
toNotificationResponsableResponse,
|
||||||
|
} from '../dtos/responsable.dto';
|
||||||
|
|
||||||
|
function asBodyObject(value: unknown): Record<string, unknown> {
|
||||||
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||||
|
throw new AppError(400, 'Corps de requete invalide');
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHistoriqueFiltres(req: Request) {
|
||||||
|
const action = typeof req.query.action === 'string' ? req.query.action : undefined;
|
||||||
|
return {
|
||||||
|
action,
|
||||||
|
utilisateurId: parsePositiveIntQuery(req.query.utilisateurId, 'utilisateurId'),
|
||||||
|
materielId: parsePositiveIntQuery(req.query.materielId, 'materielId'),
|
||||||
|
empruntId: parsePositiveIntQuery(req.query.empruntId, 'empruntId'),
|
||||||
|
dateDebut: parseDateQuery(req.query.dateDebut, 'dateDebut'),
|
||||||
|
dateFin: parseDateQuery(req.query.dateFin, 'dateFin'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeCsv(value: string | number | Date | null): string {
|
||||||
|
if (value === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const raw = value instanceof Date ? value.toISOString() : String(value);
|
||||||
|
return `"${raw.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDashboard(req: Request, res: Response): Promise<void> {
|
||||||
|
const user = req.user;
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(401, 'Authentification requise');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboard = await getDashboardResponsable(user.id, user.roleCode, user.campusId);
|
||||||
|
res.json({ data: toDashboardResponsableResponse(dashboard) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEmprunts(req: Request, res: Response): Promise<void> {
|
||||||
|
const user = req.user;
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(401, 'Authentification requise');
|
||||||
|
}
|
||||||
|
|
||||||
|
const statut = parseStatutEmprunt(req.query.statut);
|
||||||
|
const emprunts = await listerEmpruntsResponsable(user.roleCode, user.campusId, { statut });
|
||||||
|
res.json({ data: emprunts.map(toEmpruntResponsableResponse) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMateriels(req: Request, res: Response): Promise<void> {
|
||||||
|
const user = req.user;
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(401, 'Authentification requise');
|
||||||
|
}
|
||||||
|
|
||||||
|
const statut = parseStatutMateriel(req.query.statut);
|
||||||
|
const categorieId = parseCategorieId(req.query.categorieId);
|
||||||
|
const recherche = typeof req.query.q === 'string' ? req.query.q : undefined;
|
||||||
|
|
||||||
|
const materiels = await listerMaterielsResponsable(user.roleCode, user.campusId, {
|
||||||
|
statut,
|
||||||
|
categorieId,
|
||||||
|
recherche,
|
||||||
|
});
|
||||||
|
res.json({ data: materiels.map(toMaterielResponsableResponse) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAnomalies(req: Request, res: Response): Promise<void> {
|
||||||
|
const user = req.user;
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(401, 'Authentification requise');
|
||||||
|
}
|
||||||
|
|
||||||
|
const statut = parseStatutAnomalie(req.query.statut);
|
||||||
|
const type = typeof req.query.type === 'string' ? req.query.type : undefined;
|
||||||
|
|
||||||
|
const anomalies = await listerAnomaliesResponsable(user.roleCode, user.campusId, {
|
||||||
|
statut,
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
res.json({ data: anomalies.map(toAnomalieResponsableResponse) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchAnomalieStatut(req: Request, res: Response): Promise<void> {
|
||||||
|
const user = req.user;
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(401, 'Authentification requise');
|
||||||
|
}
|
||||||
|
|
||||||
|
const anomalieId = parseAnomalieId(req.params.id);
|
||||||
|
const body = asBodyObject(req.body);
|
||||||
|
const statut = parseStatutAnomalie(body.statut);
|
||||||
|
if (!statut) {
|
||||||
|
throw new AppError(400, 'statut requis');
|
||||||
|
}
|
||||||
|
const observation = typeof body.observation === 'string' ? body.observation : undefined;
|
||||||
|
|
||||||
|
const anomalie = await changerStatutAnomalieResponsableService(
|
||||||
|
user.id,
|
||||||
|
user.roleCode,
|
||||||
|
user.campusId,
|
||||||
|
anomalieId,
|
||||||
|
{ statut, observation },
|
||||||
|
);
|
||||||
|
res.json({ data: toAnomalieResponsableResponse(anomalie) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNotifications(req: Request, res: Response): Promise<void> {
|
||||||
|
const user = req.user;
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(401, 'Authentification requise');
|
||||||
|
}
|
||||||
|
|
||||||
|
const lu = parseLu(req.query.lu);
|
||||||
|
const notifications = await listerNotificationsResponsable(user.id, user.roleCode, { lu });
|
||||||
|
res.json({ data: notifications.map(toNotificationResponsableResponse) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchNotificationLue(req: Request, res: Response): Promise<void> {
|
||||||
|
const user = req.user;
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(401, 'Authentification requise');
|
||||||
|
}
|
||||||
|
|
||||||
|
const notificationId = parseNotificationId(req.params.id);
|
||||||
|
const notification = await marquerNotificationResponsableLue(
|
||||||
|
user.id,
|
||||||
|
user.roleCode,
|
||||||
|
notificationId,
|
||||||
|
);
|
||||||
|
res.json({ data: toNotificationResponsableResponse(notification) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchNotificationsLues(req: Request, res: Response): Promise<void> {
|
||||||
|
const user = req.user;
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(401, 'Authentification requise');
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await marquerToutesNotificationsResponsableLues(user.id, user.roleCode);
|
||||||
|
res.json({ data: { count } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHistorique(req: Request, res: Response): Promise<void> {
|
||||||
|
const user = req.user;
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(401, 'Authentification requise');
|
||||||
|
}
|
||||||
|
|
||||||
|
const historique = await listerHistoriqueResponsable(
|
||||||
|
user.roleCode,
|
||||||
|
user.campusId,
|
||||||
|
getHistoriqueFiltres(req),
|
||||||
|
);
|
||||||
|
res.json({ data: historique.map(toHistoriqueResponsableResponse) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHistoriqueCsv(req: Request, res: Response): Promise<void> {
|
||||||
|
const user = req.user;
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(401, 'Authentification requise');
|
||||||
|
}
|
||||||
|
|
||||||
|
const historique = await listerHistoriqueResponsable(
|
||||||
|
user.roleCode,
|
||||||
|
user.campusId,
|
||||||
|
getHistoriqueFiltres(req),
|
||||||
|
);
|
||||||
|
const lignes = [
|
||||||
|
['id', 'dateAction', 'action', 'description', 'utilisateur', 'email', 'materiel', 'empruntId']
|
||||||
|
.map(escapeCsv)
|
||||||
|
.join(','),
|
||||||
|
...historique.map((item) =>
|
||||||
|
[
|
||||||
|
item.id,
|
||||||
|
item.dateAction,
|
||||||
|
item.action,
|
||||||
|
item.description,
|
||||||
|
`${item.utilisateur.prenom} ${item.utilisateur.nom}`,
|
||||||
|
item.utilisateur.email,
|
||||||
|
item.materiel ? `${item.materiel.nom} (${item.materiel.reference})` : null,
|
||||||
|
item.empruntId,
|
||||||
|
]
|
||||||
|
.map(escapeCsv)
|
||||||
|
.join(','),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="historique-responsable.csv"');
|
||||||
|
res.send(lignes.join('\n'));
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
import {
|
||||||
|
ActiviteResponsable,
|
||||||
|
AnomalieResponsable,
|
||||||
|
EmpruntResponsable,
|
||||||
|
HistoriqueResponsable,
|
||||||
|
MaterielResponsable,
|
||||||
|
NotificationResponsable,
|
||||||
|
StatutCount,
|
||||||
|
} from '../repositories/responsable.repository';
|
||||||
|
import { DashboardResponsableData } from '../services/responsable.service';
|
||||||
|
|
||||||
|
export interface RepartitionStatutResponse {
|
||||||
|
total: number;
|
||||||
|
parStatut: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiviteResponsableResponse {
|
||||||
|
id: number;
|
||||||
|
action: string;
|
||||||
|
description: string;
|
||||||
|
dateAction: Date;
|
||||||
|
utilisateur: {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
prenom: string;
|
||||||
|
};
|
||||||
|
materiel: {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
reference: string;
|
||||||
|
} | null;
|
||||||
|
empruntId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardResponsableResponse {
|
||||||
|
kpis: {
|
||||||
|
materiels: RepartitionStatutResponse;
|
||||||
|
emprunts: RepartitionStatutResponse;
|
||||||
|
anomalies: RepartitionStatutResponse;
|
||||||
|
notificationsNonLues: number;
|
||||||
|
};
|
||||||
|
activiteRecente: ActiviteResponsableResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmpruntResponsableResponse {
|
||||||
|
id: number;
|
||||||
|
statut: string;
|
||||||
|
dateEmprunt: Date;
|
||||||
|
dateRetourPrevue: Date;
|
||||||
|
dateRetourReelle: Date | null;
|
||||||
|
etudiant: {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
prenom: string;
|
||||||
|
email: string;
|
||||||
|
classe: string | null;
|
||||||
|
};
|
||||||
|
materiel: {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
reference: string;
|
||||||
|
statut: string;
|
||||||
|
categorie: {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaterielResponsableResponse {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
marque: string;
|
||||||
|
modele: string;
|
||||||
|
reference: string;
|
||||||
|
statut: string;
|
||||||
|
etatGeneral: string;
|
||||||
|
categorie: {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
};
|
||||||
|
accessoires: Array<{
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
quantiteAttendue: number;
|
||||||
|
obligatoire: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnomalieResponsableResponse {
|
||||||
|
id: number;
|
||||||
|
type: string;
|
||||||
|
description: string;
|
||||||
|
statut: string;
|
||||||
|
detecteeAutomatiquement: boolean;
|
||||||
|
observation: string | null;
|
||||||
|
dateDetection: Date;
|
||||||
|
dateResolution: Date | null;
|
||||||
|
emprunt: {
|
||||||
|
id: number;
|
||||||
|
statut: string;
|
||||||
|
dateEmprunt: Date;
|
||||||
|
dateRetourPrevue: Date;
|
||||||
|
dateRetourReelle: Date | null;
|
||||||
|
};
|
||||||
|
etudiant: {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
prenom: string;
|
||||||
|
email: string;
|
||||||
|
classe: string | null;
|
||||||
|
};
|
||||||
|
materiel: {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
reference: string;
|
||||||
|
statut: string;
|
||||||
|
categorie: {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
traitePar: {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
prenom: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationResponsableResponse {
|
||||||
|
id: number;
|
||||||
|
titre: string;
|
||||||
|
message: string;
|
||||||
|
type: string;
|
||||||
|
lu: boolean;
|
||||||
|
dateCreation: Date;
|
||||||
|
anomalie: {
|
||||||
|
id: number;
|
||||||
|
statut: string;
|
||||||
|
type: string;
|
||||||
|
empruntId: number;
|
||||||
|
materiel: {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
reference: string;
|
||||||
|
};
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoriqueResponsableResponse {
|
||||||
|
id: number;
|
||||||
|
action: string;
|
||||||
|
description: string;
|
||||||
|
dateAction: Date;
|
||||||
|
utilisateur: {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
prenom: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
materiel: {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
reference: string;
|
||||||
|
} | null;
|
||||||
|
empruntId: number | null;
|
||||||
|
sallePret: {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
} | null;
|
||||||
|
posteEmprunt: {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRepartition(rows: StatutCount[]): RepartitionStatutResponse {
|
||||||
|
const parStatut: Record<string, number> = {};
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
parStatut[row.statut] = row.count;
|
||||||
|
total += row.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { total, parStatut };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toActiviteResponse(activite: ActiviteResponsable): ActiviteResponsableResponse {
|
||||||
|
return {
|
||||||
|
id: activite.id,
|
||||||
|
action: activite.action,
|
||||||
|
description: activite.description,
|
||||||
|
dateAction: activite.dateAction,
|
||||||
|
utilisateur: {
|
||||||
|
id: activite.utilisateur.id,
|
||||||
|
nom: activite.utilisateur.nom,
|
||||||
|
prenom: activite.utilisateur.prenom,
|
||||||
|
},
|
||||||
|
materiel: activite.materiel
|
||||||
|
? {
|
||||||
|
id: activite.materiel.id,
|
||||||
|
nom: activite.materiel.nom,
|
||||||
|
reference: activite.materiel.reference,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
empruntId: activite.empruntId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDashboardResponsableResponse(
|
||||||
|
data: DashboardResponsableData,
|
||||||
|
): DashboardResponsableResponse {
|
||||||
|
return {
|
||||||
|
kpis: {
|
||||||
|
materiels: toRepartition(data.materiels),
|
||||||
|
emprunts: toRepartition(data.emprunts),
|
||||||
|
anomalies: toRepartition(data.anomalies),
|
||||||
|
notificationsNonLues: data.notificationsNonLues,
|
||||||
|
},
|
||||||
|
activiteRecente: data.activiteRecente.map(toActiviteResponse),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toEmpruntResponsableResponse(
|
||||||
|
emprunt: EmpruntResponsable,
|
||||||
|
): EmpruntResponsableResponse {
|
||||||
|
return {
|
||||||
|
id: emprunt.id,
|
||||||
|
statut: emprunt.statut,
|
||||||
|
dateEmprunt: emprunt.dateEmprunt,
|
||||||
|
dateRetourPrevue: emprunt.dateRetourPrevue,
|
||||||
|
dateRetourReelle: emprunt.dateRetourReelle,
|
||||||
|
etudiant: {
|
||||||
|
id: emprunt.utilisateur.id,
|
||||||
|
nom: emprunt.utilisateur.nom,
|
||||||
|
prenom: emprunt.utilisateur.prenom,
|
||||||
|
email: emprunt.utilisateur.email,
|
||||||
|
classe: emprunt.utilisateur.classe,
|
||||||
|
},
|
||||||
|
materiel: {
|
||||||
|
id: emprunt.materiel.id,
|
||||||
|
nom: emprunt.materiel.nom,
|
||||||
|
reference: emprunt.materiel.reference,
|
||||||
|
statut: emprunt.materiel.statut,
|
||||||
|
categorie: {
|
||||||
|
id: emprunt.materiel.categorie.id,
|
||||||
|
nom: emprunt.materiel.categorie.nom,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toMaterielResponsableResponse(
|
||||||
|
materiel: MaterielResponsable,
|
||||||
|
): MaterielResponsableResponse {
|
||||||
|
return {
|
||||||
|
id: materiel.id,
|
||||||
|
nom: materiel.nom,
|
||||||
|
marque: materiel.marque,
|
||||||
|
modele: materiel.modele,
|
||||||
|
reference: materiel.reference,
|
||||||
|
statut: materiel.statut,
|
||||||
|
etatGeneral: materiel.etatGeneral,
|
||||||
|
categorie: {
|
||||||
|
id: materiel.categorie.id,
|
||||||
|
nom: materiel.categorie.nom,
|
||||||
|
},
|
||||||
|
accessoires: materiel.accessoires.map((liaison) => ({
|
||||||
|
id: liaison.accessoire.id,
|
||||||
|
nom: liaison.accessoire.nom,
|
||||||
|
quantiteAttendue: liaison.quantiteAttendue,
|
||||||
|
obligatoire: liaison.obligatoire,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAnomalieResponsableResponse(
|
||||||
|
anomalie: AnomalieResponsable,
|
||||||
|
): AnomalieResponsableResponse {
|
||||||
|
return {
|
||||||
|
id: anomalie.id,
|
||||||
|
type: anomalie.type,
|
||||||
|
description: anomalie.description,
|
||||||
|
statut: anomalie.statut,
|
||||||
|
detecteeAutomatiquement: anomalie.detecteeAutomatiquement,
|
||||||
|
observation: anomalie.observation,
|
||||||
|
dateDetection: anomalie.dateDetection,
|
||||||
|
dateResolution: anomalie.dateResolution,
|
||||||
|
emprunt: {
|
||||||
|
id: anomalie.emprunt.id,
|
||||||
|
statut: anomalie.emprunt.statut,
|
||||||
|
dateEmprunt: anomalie.emprunt.dateEmprunt,
|
||||||
|
dateRetourPrevue: anomalie.emprunt.dateRetourPrevue,
|
||||||
|
dateRetourReelle: anomalie.emprunt.dateRetourReelle,
|
||||||
|
},
|
||||||
|
etudiant: {
|
||||||
|
id: anomalie.etudiant.id,
|
||||||
|
nom: anomalie.etudiant.nom,
|
||||||
|
prenom: anomalie.etudiant.prenom,
|
||||||
|
email: anomalie.etudiant.email,
|
||||||
|
classe: anomalie.etudiant.classe,
|
||||||
|
},
|
||||||
|
materiel: {
|
||||||
|
id: anomalie.materiel.id,
|
||||||
|
nom: anomalie.materiel.nom,
|
||||||
|
reference: anomalie.materiel.reference,
|
||||||
|
statut: anomalie.materiel.statut,
|
||||||
|
categorie: {
|
||||||
|
id: anomalie.materiel.categorie.id,
|
||||||
|
nom: anomalie.materiel.categorie.nom,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
traitePar: anomalie.traitePar
|
||||||
|
? {
|
||||||
|
id: anomalie.traitePar.id,
|
||||||
|
nom: anomalie.traitePar.nom,
|
||||||
|
prenom: anomalie.traitePar.prenom,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toNotificationResponsableResponse(
|
||||||
|
notification: NotificationResponsable,
|
||||||
|
): NotificationResponsableResponse {
|
||||||
|
return {
|
||||||
|
id: notification.id,
|
||||||
|
titre: notification.titre,
|
||||||
|
message: notification.message,
|
||||||
|
type: notification.type,
|
||||||
|
lu: notification.lu,
|
||||||
|
dateCreation: notification.dateCreation,
|
||||||
|
anomalie: notification.anomalie
|
||||||
|
? {
|
||||||
|
id: notification.anomalie.id,
|
||||||
|
statut: notification.anomalie.statut,
|
||||||
|
type: notification.anomalie.type,
|
||||||
|
empruntId: notification.anomalie.emprunt.id,
|
||||||
|
materiel: {
|
||||||
|
id: notification.anomalie.materiel.id,
|
||||||
|
nom: notification.anomalie.materiel.nom,
|
||||||
|
reference: notification.anomalie.materiel.reference,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toHistoriqueResponsableResponse(
|
||||||
|
historique: HistoriqueResponsable,
|
||||||
|
): HistoriqueResponsableResponse {
|
||||||
|
return {
|
||||||
|
id: historique.id,
|
||||||
|
action: historique.action,
|
||||||
|
description: historique.description,
|
||||||
|
dateAction: historique.dateAction,
|
||||||
|
utilisateur: {
|
||||||
|
id: historique.utilisateur.id,
|
||||||
|
nom: historique.utilisateur.nom,
|
||||||
|
prenom: historique.utilisateur.prenom,
|
||||||
|
email: historique.utilisateur.email,
|
||||||
|
},
|
||||||
|
materiel: historique.materiel
|
||||||
|
? {
|
||||||
|
id: historique.materiel.id,
|
||||||
|
nom: historique.materiel.nom,
|
||||||
|
reference: historique.materiel.reference,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
empruntId: historique.empruntId,
|
||||||
|
sallePret: historique.sallePret
|
||||||
|
? {
|
||||||
|
id: historique.sallePret.id,
|
||||||
|
nom: historique.sallePret.nom,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
posteEmprunt: historique.posteEmprunt
|
||||||
|
? {
|
||||||
|
id: historique.posteEmprunt.id,
|
||||||
|
nom: historique.posteEmprunt.nom,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { prisma } from '../db/prisma';
|
||||||
|
|
||||||
|
export interface StatutCount {
|
||||||
|
statut: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActiviteResponsable = Prisma.HistoriqueGetPayload<{
|
||||||
|
include: {
|
||||||
|
utilisateur: true;
|
||||||
|
materiel: true;
|
||||||
|
emprunt: true;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type EmpruntResponsable = Prisma.EmpruntGetPayload<{
|
||||||
|
include: {
|
||||||
|
utilisateur: true;
|
||||||
|
materiel: { include: { categorie: true } };
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type MaterielResponsable = Prisma.MaterielGetPayload<{
|
||||||
|
include: {
|
||||||
|
categorie: true;
|
||||||
|
accessoires: { include: { accessoire: true } };
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type AnomalieResponsable = Prisma.AnomalieGetPayload<{
|
||||||
|
include: {
|
||||||
|
emprunt: true;
|
||||||
|
etudiant: true;
|
||||||
|
materiel: { include: { categorie: true } };
|
||||||
|
traitePar: true;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type NotificationResponsable = Prisma.NotificationGetPayload<{
|
||||||
|
include: {
|
||||||
|
anomalie: {
|
||||||
|
include: {
|
||||||
|
emprunt: true;
|
||||||
|
materiel: true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type HistoriqueResponsable = Prisma.HistoriqueGetPayload<{
|
||||||
|
include: {
|
||||||
|
utilisateur: true;
|
||||||
|
materiel: true;
|
||||||
|
emprunt: true;
|
||||||
|
sallePret: true;
|
||||||
|
posteEmprunt: true;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export interface EmpruntResponsableFiltres {
|
||||||
|
statut?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaterielResponsableFiltres {
|
||||||
|
statut?: string;
|
||||||
|
categorieId?: number;
|
||||||
|
recherche?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnomalieResponsableFiltres {
|
||||||
|
statut?: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationResponsableFiltres {
|
||||||
|
lu?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoriqueResponsableFiltres {
|
||||||
|
action?: string;
|
||||||
|
utilisateurId?: number;
|
||||||
|
materielId?: number;
|
||||||
|
empruntId?: number;
|
||||||
|
dateDebut?: Date;
|
||||||
|
dateFin?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChangerStatutAnomalieData {
|
||||||
|
anomalieId: number;
|
||||||
|
campusId: number;
|
||||||
|
responsableId: number;
|
||||||
|
statut: string;
|
||||||
|
observation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStatutCounts(rows: Array<{ statut: string; _count: { _all: number } }>): StatutCount[] {
|
||||||
|
return rows.map((row) => ({ statut: row.statut, count: row._count._all }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countMaterielsParStatut(campusId: number): Promise<StatutCount[]> {
|
||||||
|
const rows = await prisma.materiel.groupBy({
|
||||||
|
by: ['statut'],
|
||||||
|
where: { campusId, actif: true },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapStatutCounts(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countEmpruntsParStatut(campusId: number): Promise<StatutCount[]> {
|
||||||
|
const rows = await prisma.emprunt.groupBy({
|
||||||
|
by: ['statut'],
|
||||||
|
where: { campusId },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapStatutCounts(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countAnomaliesParStatut(campusId: number): Promise<StatutCount[]> {
|
||||||
|
const rows = await prisma.anomalie.groupBy({
|
||||||
|
by: ['statut'],
|
||||||
|
where: { emprunt: { campusId } },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapStatutCounts(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countNotificationsNonLues(utilisateurId: number): Promise<number> {
|
||||||
|
return prisma.notification.count({
|
||||||
|
where: { utilisateurId, lu: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findActiviteRecente(
|
||||||
|
campusId: number,
|
||||||
|
limite: number,
|
||||||
|
): Promise<ActiviteResponsable[]> {
|
||||||
|
return prisma.historique.findMany({
|
||||||
|
where: { campusId },
|
||||||
|
include: {
|
||||||
|
utilisateur: true,
|
||||||
|
materiel: true,
|
||||||
|
emprunt: true,
|
||||||
|
},
|
||||||
|
orderBy: { dateAction: 'desc' },
|
||||||
|
take: limite,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findEmpruntsResponsable(
|
||||||
|
campusId: number,
|
||||||
|
filtres: EmpruntResponsableFiltres,
|
||||||
|
): Promise<EmpruntResponsable[]> {
|
||||||
|
return prisma.emprunt.findMany({
|
||||||
|
where: {
|
||||||
|
campusId,
|
||||||
|
...(filtres.statut ? { statut: filtres.statut } : {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
utilisateur: true,
|
||||||
|
materiel: { include: { categorie: true } },
|
||||||
|
},
|
||||||
|
orderBy: { dateEmprunt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findMaterielsResponsable(
|
||||||
|
campusId: number,
|
||||||
|
filtres: MaterielResponsableFiltres,
|
||||||
|
): Promise<MaterielResponsable[]> {
|
||||||
|
return prisma.materiel.findMany({
|
||||||
|
where: {
|
||||||
|
campusId,
|
||||||
|
actif: true,
|
||||||
|
...(filtres.statut ? { statut: filtres.statut } : {}),
|
||||||
|
...(filtres.categorieId !== undefined ? { categorieId: filtres.categorieId } : {}),
|
||||||
|
...(filtres.recherche
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ nom: { contains: filtres.recherche } },
|
||||||
|
{ marque: { contains: filtres.recherche } },
|
||||||
|
{ modele: { contains: filtres.recherche } },
|
||||||
|
{ reference: { contains: filtres.recherche } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
categorie: true,
|
||||||
|
accessoires: { include: { accessoire: true } },
|
||||||
|
},
|
||||||
|
orderBy: { nom: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findAnomaliesResponsable(
|
||||||
|
campusId: number,
|
||||||
|
filtres: AnomalieResponsableFiltres,
|
||||||
|
): Promise<AnomalieResponsable[]> {
|
||||||
|
return prisma.anomalie.findMany({
|
||||||
|
where: {
|
||||||
|
emprunt: { campusId },
|
||||||
|
...(filtres.statut ? { statut: filtres.statut } : {}),
|
||||||
|
...(filtres.type ? { type: filtres.type } : {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
emprunt: true,
|
||||||
|
etudiant: true,
|
||||||
|
materiel: { include: { categorie: true } },
|
||||||
|
traitePar: true,
|
||||||
|
},
|
||||||
|
orderBy: { dateDetection: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findAnomalieResponsableById(
|
||||||
|
campusId: number,
|
||||||
|
anomalieId: number,
|
||||||
|
): Promise<AnomalieResponsable | null> {
|
||||||
|
return prisma.anomalie.findFirst({
|
||||||
|
where: {
|
||||||
|
id: anomalieId,
|
||||||
|
emprunt: { campusId },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
emprunt: true,
|
||||||
|
etudiant: true,
|
||||||
|
materiel: { include: { categorie: true } },
|
||||||
|
traitePar: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changerStatutAnomalieResponsable(
|
||||||
|
data: ChangerStatutAnomalieData,
|
||||||
|
): Promise<AnomalieResponsable> {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const anomalie = await tx.anomalie.update({
|
||||||
|
where: { id: data.anomalieId },
|
||||||
|
data: {
|
||||||
|
statut: data.statut,
|
||||||
|
traiteeParId: data.responsableId,
|
||||||
|
...(data.observation !== undefined ? { observation: data.observation } : {}),
|
||||||
|
...(data.statut === 'RESOLUE' || data.statut === 'CLOTUREE'
|
||||||
|
? { dateResolution: new Date() }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
emprunt: true,
|
||||||
|
etudiant: true,
|
||||||
|
materiel: { include: { categorie: true } },
|
||||||
|
traitePar: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.historique.create({
|
||||||
|
data: {
|
||||||
|
utilisateurId: data.responsableId,
|
||||||
|
empruntId: anomalie.empruntId,
|
||||||
|
materielId: anomalie.materielId,
|
||||||
|
campusId: data.campusId,
|
||||||
|
action: 'TRAITEMENT_ANOMALIE',
|
||||||
|
description: `Anomalie #${anomalie.id} passee au statut ${data.statut}`,
|
||||||
|
dateAction: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return anomalie;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findNotificationsResponsable(
|
||||||
|
utilisateurId: number,
|
||||||
|
filtres: NotificationResponsableFiltres,
|
||||||
|
): Promise<NotificationResponsable[]> {
|
||||||
|
return prisma.notification.findMany({
|
||||||
|
where: {
|
||||||
|
utilisateurId,
|
||||||
|
...(filtres.lu !== undefined ? { lu: filtres.lu } : {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
anomalie: {
|
||||||
|
include: {
|
||||||
|
emprunt: true,
|
||||||
|
materiel: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { dateCreation: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function marquerNotificationLue(
|
||||||
|
utilisateurId: number,
|
||||||
|
notificationId: number,
|
||||||
|
): Promise<NotificationResponsable> {
|
||||||
|
return prisma.notification.update({
|
||||||
|
where: {
|
||||||
|
id: notificationId,
|
||||||
|
utilisateurId,
|
||||||
|
},
|
||||||
|
data: { lu: true },
|
||||||
|
include: {
|
||||||
|
anomalie: {
|
||||||
|
include: {
|
||||||
|
emprunt: true,
|
||||||
|
materiel: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function marquerNotificationsResponsableLues(utilisateurId: number): Promise<number> {
|
||||||
|
const result = await prisma.notification.updateMany({
|
||||||
|
where: { utilisateurId, lu: false },
|
||||||
|
data: { lu: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findHistoriqueResponsable(
|
||||||
|
campusId: number,
|
||||||
|
filtres: HistoriqueResponsableFiltres,
|
||||||
|
): Promise<HistoriqueResponsable[]> {
|
||||||
|
return prisma.historique.findMany({
|
||||||
|
where: {
|
||||||
|
campusId,
|
||||||
|
...(filtres.action ? { action: filtres.action } : {}),
|
||||||
|
...(filtres.utilisateurId !== undefined ? { utilisateurId: filtres.utilisateurId } : {}),
|
||||||
|
...(filtres.materielId !== undefined ? { materielId: filtres.materielId } : {}),
|
||||||
|
...(filtres.empruntId !== undefined ? { empruntId: filtres.empruntId } : {}),
|
||||||
|
...(filtres.dateDebut || filtres.dateFin
|
||||||
|
? {
|
||||||
|
dateAction: {
|
||||||
|
...(filtres.dateDebut ? { gte: filtres.dateDebut } : {}),
|
||||||
|
...(filtres.dateFin ? { lte: filtres.dateFin } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
utilisateur: true,
|
||||||
|
materiel: true,
|
||||||
|
emprunt: true,
|
||||||
|
sallePret: true,
|
||||||
|
posteEmprunt: true,
|
||||||
|
},
|
||||||
|
orderBy: { dateAction: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { currentUser } from '../middlewares/current-user';
|
|||||||
import { authRoutes } from './auth.routes';
|
import { authRoutes } from './auth.routes';
|
||||||
import { materielRoutes } from './materiel.routes';
|
import { materielRoutes } from './materiel.routes';
|
||||||
import { empruntRoutes, mesEmpruntsRoutes } from './emprunt.routes';
|
import { empruntRoutes, mesEmpruntsRoutes } from './emprunt.routes';
|
||||||
|
import { responsableRoutes } from './responsable.routes';
|
||||||
|
|
||||||
export const apiRouter: Router = Router();
|
export const apiRouter: Router = Router();
|
||||||
|
|
||||||
@@ -13,3 +14,4 @@ apiRouter.use('/auth', authRoutes);
|
|||||||
apiRouter.use('/materiels', currentUser, materielRoutes);
|
apiRouter.use('/materiels', currentUser, materielRoutes);
|
||||||
apiRouter.use('/emprunts', currentUser, empruntRoutes);
|
apiRouter.use('/emprunts', currentUser, empruntRoutes);
|
||||||
apiRouter.use('/mes-emprunts', currentUser, mesEmpruntsRoutes);
|
apiRouter.use('/mes-emprunts', currentUser, mesEmpruntsRoutes);
|
||||||
|
apiRouter.use('/responsable', currentUser, responsableRoutes);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import {
|
||||||
|
getAnomalies,
|
||||||
|
getDashboard,
|
||||||
|
getEmprunts,
|
||||||
|
getHistorique,
|
||||||
|
getHistoriqueCsv,
|
||||||
|
getMateriels,
|
||||||
|
getNotifications,
|
||||||
|
patchAnomalieStatut,
|
||||||
|
patchNotificationLue,
|
||||||
|
patchNotificationsLues,
|
||||||
|
} from '../controllers/responsable.controller';
|
||||||
|
|
||||||
|
export const responsableRoutes: Router = Router();
|
||||||
|
|
||||||
|
responsableRoutes.get('/dashboard', getDashboard);
|
||||||
|
responsableRoutes.get('/emprunts', getEmprunts);
|
||||||
|
responsableRoutes.get('/materiels', getMateriels);
|
||||||
|
responsableRoutes.get('/anomalies', getAnomalies);
|
||||||
|
responsableRoutes.patch('/anomalies/:id/statut', patchAnomalieStatut);
|
||||||
|
responsableRoutes.get('/notifications', getNotifications);
|
||||||
|
responsableRoutes.patch('/notifications/lu-toutes', patchNotificationsLues);
|
||||||
|
responsableRoutes.patch('/notifications/:id/lu', patchNotificationLue);
|
||||||
|
responsableRoutes.get('/historique', getHistorique);
|
||||||
|
responsableRoutes.get('/historique/export.csv', getHistoriqueCsv);
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import { AppError } from '../errors/app-error';
|
||||||
|
import {
|
||||||
|
changerStatutAnomalieResponsable,
|
||||||
|
countAnomaliesParStatut,
|
||||||
|
countEmpruntsParStatut,
|
||||||
|
countMaterielsParStatut,
|
||||||
|
countNotificationsNonLues,
|
||||||
|
findActiviteRecente,
|
||||||
|
findAnomalieResponsableById,
|
||||||
|
findAnomaliesResponsable,
|
||||||
|
findEmpruntsResponsable,
|
||||||
|
findHistoriqueResponsable,
|
||||||
|
findMaterielsResponsable,
|
||||||
|
findNotificationsResponsable,
|
||||||
|
marquerNotificationLue,
|
||||||
|
marquerNotificationsResponsableLues,
|
||||||
|
StatutCount,
|
||||||
|
ActiviteResponsable,
|
||||||
|
AnomalieResponsable,
|
||||||
|
EmpruntResponsable,
|
||||||
|
HistoriqueResponsable,
|
||||||
|
MaterielResponsable,
|
||||||
|
NotificationResponsable,
|
||||||
|
} from '../repositories/responsable.repository';
|
||||||
|
import {
|
||||||
|
STATUT_ANOMALIE,
|
||||||
|
STATUT_EMPRUNT,
|
||||||
|
STATUT_MATERIEL,
|
||||||
|
StatutAnomalie,
|
||||||
|
StatutEmprunt,
|
||||||
|
StatutMateriel,
|
||||||
|
} from '../models/enums';
|
||||||
|
|
||||||
|
export interface DashboardResponsableData {
|
||||||
|
materiels: StatutCount[];
|
||||||
|
emprunts: StatutCount[];
|
||||||
|
anomalies: StatutCount[];
|
||||||
|
notificationsNonLues: number;
|
||||||
|
activiteRecente: ActiviteResponsable[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListerEmpruntsResponsableFiltres {
|
||||||
|
statut?: StatutEmprunt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListerMaterielsResponsableFiltres {
|
||||||
|
statut?: StatutMateriel;
|
||||||
|
categorieId?: number;
|
||||||
|
recherche?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListerAnomaliesResponsableFiltres {
|
||||||
|
statut?: StatutAnomalie;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListerNotificationsResponsableFiltres {
|
||||||
|
lu?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListerHistoriqueResponsableFiltres {
|
||||||
|
action?: string;
|
||||||
|
utilisateurId?: number;
|
||||||
|
materielId?: number;
|
||||||
|
empruntId?: number;
|
||||||
|
dateDebut?: Date;
|
||||||
|
dateFin?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChangerStatutAnomalieResponsableRequest {
|
||||||
|
statut: StatutAnomalie;
|
||||||
|
observation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRANSITIONS_ANOMALIE: Record<StatutAnomalie, StatutAnomalie[]> = {
|
||||||
|
DETECTEE: ['EN_COURS_TRAITEMENT'],
|
||||||
|
EN_COURS_TRAITEMENT: ['RESOLUE'],
|
||||||
|
RESOLUE: ['CLOTUREE'],
|
||||||
|
CLOTUREE: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function verifierResponsable(roleCode: string): void {
|
||||||
|
if (roleCode !== 'RESPONSABLE') {
|
||||||
|
throw new AppError(403, 'Acces reserve au responsable materiel');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDashboardResponsable(
|
||||||
|
utilisateurId: number,
|
||||||
|
roleCode: string,
|
||||||
|
campusId: number,
|
||||||
|
): Promise<DashboardResponsableData> {
|
||||||
|
verifierResponsable(roleCode);
|
||||||
|
|
||||||
|
const [materiels, emprunts, anomalies, notificationsNonLues, activiteRecente] = await Promise.all(
|
||||||
|
[
|
||||||
|
countMaterielsParStatut(campusId),
|
||||||
|
countEmpruntsParStatut(campusId),
|
||||||
|
countAnomaliesParStatut(campusId),
|
||||||
|
countNotificationsNonLues(utilisateurId),
|
||||||
|
findActiviteRecente(campusId, 10),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
materiels,
|
||||||
|
emprunts,
|
||||||
|
anomalies,
|
||||||
|
notificationsNonLues,
|
||||||
|
activiteRecente,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseStatutEmprunt(value: unknown): StatutEmprunt | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string' || !(STATUT_EMPRUNT as readonly string[]).includes(value)) {
|
||||||
|
throw new AppError(400, 'statut invalide');
|
||||||
|
}
|
||||||
|
return value as StatutEmprunt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseStatutMateriel(value: unknown): StatutMateriel | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string' || !(STATUT_MATERIEL as readonly string[]).includes(value)) {
|
||||||
|
throw new AppError(400, 'statut invalide');
|
||||||
|
}
|
||||||
|
return value as StatutMateriel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseStatutAnomalie(value: unknown): StatutAnomalie | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string' || !(STATUT_ANOMALIE as readonly string[]).includes(value)) {
|
||||||
|
throw new AppError(400, 'statut invalide');
|
||||||
|
}
|
||||||
|
return value as StatutAnomalie;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCategorieId(value: unknown): number | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||||
|
throw new AppError(400, 'categorieId invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseNotificationId(value: unknown): number {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new AppError(400, 'notificationId invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||||
|
throw new AppError(400, 'notificationId invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAnomalieId(value: unknown): number {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new AppError(400, 'anomalieId invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||||
|
throw new AppError(400, 'anomalieId invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePositiveIntQuery(value: unknown, champ: string): number | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new AppError(400, `${champ} invalide`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||||
|
throw new AppError(400, `${champ} invalide`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDateQuery(value: unknown, champ: string): Date | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new AppError(400, `${champ} invalide`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
throw new AppError(400, `${champ} invalide`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLu(value: unknown): boolean | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (value === 'true') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (value === 'false') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw new AppError(400, 'lu invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listerEmpruntsResponsable(
|
||||||
|
roleCode: string,
|
||||||
|
campusId: number,
|
||||||
|
filtres: ListerEmpruntsResponsableFiltres,
|
||||||
|
): Promise<EmpruntResponsable[]> {
|
||||||
|
verifierResponsable(roleCode);
|
||||||
|
return findEmpruntsResponsable(campusId, filtres);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listerMaterielsResponsable(
|
||||||
|
roleCode: string,
|
||||||
|
campusId: number,
|
||||||
|
filtres: ListerMaterielsResponsableFiltres,
|
||||||
|
): Promise<MaterielResponsable[]> {
|
||||||
|
verifierResponsable(roleCode);
|
||||||
|
return findMaterielsResponsable(campusId, filtres);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listerAnomaliesResponsable(
|
||||||
|
roleCode: string,
|
||||||
|
campusId: number,
|
||||||
|
filtres: ListerAnomaliesResponsableFiltres,
|
||||||
|
): Promise<AnomalieResponsable[]> {
|
||||||
|
verifierResponsable(roleCode);
|
||||||
|
return findAnomaliesResponsable(campusId, filtres);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listerNotificationsResponsable(
|
||||||
|
utilisateurId: number,
|
||||||
|
roleCode: string,
|
||||||
|
filtres: ListerNotificationsResponsableFiltres,
|
||||||
|
): Promise<NotificationResponsable[]> {
|
||||||
|
verifierResponsable(roleCode);
|
||||||
|
return findNotificationsResponsable(utilisateurId, filtres);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function marquerNotificationResponsableLue(
|
||||||
|
utilisateurId: number,
|
||||||
|
roleCode: string,
|
||||||
|
notificationId: number,
|
||||||
|
): Promise<NotificationResponsable> {
|
||||||
|
verifierResponsable(roleCode);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await marquerNotificationLue(utilisateurId, notificationId);
|
||||||
|
} catch {
|
||||||
|
throw new AppError(404, 'Notification introuvable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function marquerToutesNotificationsResponsableLues(
|
||||||
|
utilisateurId: number,
|
||||||
|
roleCode: string,
|
||||||
|
): Promise<number> {
|
||||||
|
verifierResponsable(roleCode);
|
||||||
|
return marquerNotificationsResponsableLues(utilisateurId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listerHistoriqueResponsable(
|
||||||
|
roleCode: string,
|
||||||
|
campusId: number,
|
||||||
|
filtres: ListerHistoriqueResponsableFiltres,
|
||||||
|
): Promise<HistoriqueResponsable[]> {
|
||||||
|
verifierResponsable(roleCode);
|
||||||
|
return findHistoriqueResponsable(campusId, filtres);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changerStatutAnomalieResponsableService(
|
||||||
|
responsableId: number,
|
||||||
|
roleCode: string,
|
||||||
|
campusId: number,
|
||||||
|
anomalieId: number,
|
||||||
|
data: ChangerStatutAnomalieResponsableRequest,
|
||||||
|
): Promise<AnomalieResponsable> {
|
||||||
|
verifierResponsable(roleCode);
|
||||||
|
|
||||||
|
const anomalie = await findAnomalieResponsableById(campusId, anomalieId);
|
||||||
|
if (!anomalie) {
|
||||||
|
throw new AppError(404, 'Anomalie introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const statutActuel = anomalie.statut as StatutAnomalie;
|
||||||
|
if (!TRANSITIONS_ANOMALIE[statutActuel]?.includes(data.statut)) {
|
||||||
|
throw new AppError(400, `Transition invalide depuis ${anomalie.statut} vers ${data.statut}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return changerStatutAnomalieResponsable({
|
||||||
|
anomalieId,
|
||||||
|
campusId,
|
||||||
|
responsableId,
|
||||||
|
statut: data.statut,
|
||||||
|
observation: data.observation,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import "../widgets/ensup_top_bar.dart";
|
|||||||
import "../widgets/identification_option.dart";
|
import "../widgets/identification_option.dart";
|
||||||
import "../widgets/brand_corners.dart";
|
import "../widgets/brand_corners.dart";
|
||||||
import "home_screen.dart";
|
import "home_screen.dart";
|
||||||
|
import "responsable_screen.dart";
|
||||||
|
|
||||||
/// Écran de démarrage : identification de l'étudiant (RG01/RG02).
|
/// Écran de démarrage : identification de l'étudiant (RG01/RG02).
|
||||||
class LoginScreen extends StatelessWidget {
|
class LoginScreen extends StatelessWidget {
|
||||||
@@ -60,7 +61,10 @@ class LoginScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 40),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 28,
|
||||||
|
vertical: 40,
|
||||||
|
),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 560),
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -71,18 +75,27 @@ class LoginScreen extends StatelessWidget {
|
|||||||
height: 72,
|
height: 72,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: const LinearGradient(
|
gradient: const LinearGradient(
|
||||||
colors: [EnsupColors.blue3, EnsupColors.cyan],
|
colors: [
|
||||||
|
EnsupColors.blue3,
|
||||||
|
EnsupColors.cyan,
|
||||||
|
],
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(22),
|
borderRadius: BorderRadius.circular(22),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: EnsupColors.blue3.withValues(alpha: 0.35),
|
color: EnsupColors.blue3.withValues(
|
||||||
|
alpha: 0.35,
|
||||||
|
),
|
||||||
blurRadius: 24,
|
blurRadius: 24,
|
||||||
offset: const Offset(0, 8),
|
offset: const Offset(0, 8),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.badge_outlined, color: Colors.white, size: 34),
|
child: const Icon(
|
||||||
|
Icons.badge_outlined,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 34,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
@@ -115,16 +128,22 @@ class LoginScreen extends StatelessWidget {
|
|||||||
IdentificationOption(
|
IdentificationOption(
|
||||||
icon: Icons.mail_outline,
|
icon: Icons.mail_outline,
|
||||||
iconColor: EnsupColors.teal,
|
iconColor: EnsupColors.teal,
|
||||||
label: "Connexion compte professionnel ENSUP",
|
label:
|
||||||
description: "Utiliser mon adresse e-mail Microsoft 365",
|
"Connexion compte professionnel ENSUP",
|
||||||
|
description:
|
||||||
|
"Utiliser mon adresse e-mail Microsoft 365",
|
||||||
onTap: () => _identifier(context),
|
onTap: () => _identifier(context),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Expanded(child: Divider(color: EnsupColors.line)),
|
const Expanded(
|
||||||
|
child: Divider(color: EnsupColors.line),
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
"ou",
|
"ou",
|
||||||
style: GoogleFonts.titilliumWeb(
|
style: GoogleFonts.titilliumWeb(
|
||||||
@@ -133,15 +152,29 @@ class LoginScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Expanded(child: Divider(color: EnsupColors.line)),
|
const Expanded(
|
||||||
|
child: Divider(color: EnsupColors.line),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: () {},
|
onPressed: () =>
|
||||||
icon: const Icon(Icons.person_outline, size: 16),
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) =>
|
||||||
|
const ResponsableScreen(),
|
||||||
|
settings: const RouteSettings(
|
||||||
|
name: "responsable",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.person_outline,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
label: Text(
|
label: Text(
|
||||||
"Accès responsable matériel",
|
"Accès responsable matériel",
|
||||||
style: GoogleFonts.darkerGrotesque(
|
style: GoogleFonts.darkerGrotesque(
|
||||||
@@ -151,10 +184,17 @@ class LoginScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: EnsupColors.text2,
|
foregroundColor: EnsupColors.text2,
|
||||||
side: const BorderSide(color: EnsupColors.line, width: 1.5),
|
side: const BorderSide(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
color: EnsupColors.line,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 14,
|
||||||
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(
|
||||||
|
10,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,726 @@
|
|||||||
|
import "package:flutter/material.dart";
|
||||||
|
import "package:google_fonts/google_fonts.dart";
|
||||||
|
|
||||||
|
import "../demo_identity.dart";
|
||||||
|
import "../services/responsable_service.dart";
|
||||||
|
import "../theme/ensup_colors.dart";
|
||||||
|
import "../widgets/brand_corners.dart";
|
||||||
|
import "../widgets/ensup_top_bar.dart";
|
||||||
|
|
||||||
|
class ResponsableScreen extends StatefulWidget {
|
||||||
|
const ResponsableScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ResponsableScreen> createState() => _ResponsableScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ResponsableScreenState extends State<ResponsableScreen> {
|
||||||
|
late Future<_ResponsableData> _future = _charger();
|
||||||
|
int _onglet = 0;
|
||||||
|
bool _transitionEnCours = false;
|
||||||
|
|
||||||
|
Future<_ResponsableData> _charger() async {
|
||||||
|
final dashboard = await ResponsableService.dashboard();
|
||||||
|
final emprunts = await ResponsableService.emprunts();
|
||||||
|
final materiels = await ResponsableService.materiels();
|
||||||
|
final anomalies = await ResponsableService.anomalies();
|
||||||
|
final notifications = await ResponsableService.notifications();
|
||||||
|
final historique = await ResponsableService.historique();
|
||||||
|
|
||||||
|
return _ResponsableData(
|
||||||
|
dashboard: dashboard,
|
||||||
|
emprunts: emprunts,
|
||||||
|
materiels: materiels,
|
||||||
|
anomalies: anomalies,
|
||||||
|
notifications: notifications,
|
||||||
|
historique: historique,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _rafraichir() {
|
||||||
|
setState(() => _future = _charger());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _changerStatut(Map<String, dynamic> anomalie) async {
|
||||||
|
final prochain = _prochainStatut(anomalie["statut"] as String?);
|
||||||
|
if (prochain == null || _transitionEnCours) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _transitionEnCours = true);
|
||||||
|
try {
|
||||||
|
await ResponsableService.changerStatutAnomalie(
|
||||||
|
anomalie["id"] as int,
|
||||||
|
prochain,
|
||||||
|
);
|
||||||
|
_rafraichir();
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(error.toString())));
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _transitionEnCours = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _marquerNotificationsLues() async {
|
||||||
|
try {
|
||||||
|
await ResponsableService.marquerNotificationsLues();
|
||||||
|
_rafraichir();
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(error.toString())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _prochainStatut(String? statut) {
|
||||||
|
return switch (statut) {
|
||||||
|
"DETECTEE" => "EN_COURS_TRAITEMENT",
|
||||||
|
"EN_COURS_TRAITEMENT" => "RESOLUE",
|
||||||
|
"RESOLUE" => "CLOTUREE",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: EnsupColors.soft,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1320, maxHeight: 860),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: EnsupColors.text.withValues(alpha: 0.14),
|
||||||
|
blurRadius: 60,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
EnsupTopBar(
|
||||||
|
brandText: "EME Responsable",
|
||||||
|
campusTag: DemoIdentity.loginCampusTag,
|
||||||
|
userName: "Karim Benali",
|
||||||
|
userInitials: "KB",
|
||||||
|
onBack: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: FutureBuilder<_ResponsableData>(
|
||||||
|
future: _future,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState !=
|
||||||
|
ConnectionState.done) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return _Erreur(
|
||||||
|
message: snapshot.error.toString(),
|
||||||
|
onRetry: _rafraichir,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _contenu(snapshot.data!);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Positioned.fill(child: BrandCorners()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _contenu(_ResponsableData data) {
|
||||||
|
final vues = [
|
||||||
|
_VueDashboard(data: data),
|
||||||
|
_VueListe(
|
||||||
|
titre: "Emprunts",
|
||||||
|
items: data.emprunts,
|
||||||
|
builder: _empruntTile,
|
||||||
|
searchableText: (item) {
|
||||||
|
final etudiant = Map<String, dynamic>.from(item["etudiant"] as Map);
|
||||||
|
final materiel = Map<String, dynamic>.from(item["materiel"] as Map);
|
||||||
|
return "${materiel["nom"]} ${materiel["reference"]} ${etudiant["prenom"]} ${etudiant["nom"]} ${item["statut"]}";
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_VueListe(
|
||||||
|
titre: "Stock",
|
||||||
|
items: data.materiels,
|
||||||
|
builder: _materielTile,
|
||||||
|
searchableText: (item) {
|
||||||
|
final categorie = Map<String, dynamic>.from(item["categorie"] as Map);
|
||||||
|
return "${item["nom"]} ${item["reference"]} ${item["statut"]} ${categorie["nom"]}";
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_VueListe(
|
||||||
|
titre: "Anomalies",
|
||||||
|
items: data.anomalies,
|
||||||
|
builder: _anomalieTile,
|
||||||
|
searchableText: (item) {
|
||||||
|
final materiel = Map<String, dynamic>.from(item["materiel"] as Map);
|
||||||
|
return "${item["type"]} ${item["description"]} ${item["statut"]} ${materiel["nom"]}";
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_VueListe(
|
||||||
|
titre: "Notifications",
|
||||||
|
items: data.notifications,
|
||||||
|
builder: _notificationTile,
|
||||||
|
action: OutlinedButton.icon(
|
||||||
|
onPressed: _marquerNotificationsLues,
|
||||||
|
icon: const Icon(Icons.done_all, size: 16),
|
||||||
|
label: const Text("Tout marquer lu"),
|
||||||
|
),
|
||||||
|
searchableText: (item) => "${item["titre"]} ${item["message"]}",
|
||||||
|
),
|
||||||
|
_VueListe(
|
||||||
|
titre: "Historique",
|
||||||
|
items: data.historique,
|
||||||
|
builder: _historiqueTile,
|
||||||
|
action: OutlinedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
"Export disponible via /api/responsable/historique/export.csv",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.download_outlined, size: 16),
|
||||||
|
label: const Text("Export CSV"),
|
||||||
|
),
|
||||||
|
searchableText: (item) => "${item["action"]} ${item["description"]}",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_Onglets(
|
||||||
|
index: _onglet,
|
||||||
|
labels: const [
|
||||||
|
"Dashboard",
|
||||||
|
"Emprunts",
|
||||||
|
"Stock",
|
||||||
|
"Anomalies",
|
||||||
|
"Notifications",
|
||||||
|
"Historique",
|
||||||
|
],
|
||||||
|
onChanged: (index) => setState(() => _onglet = index),
|
||||||
|
),
|
||||||
|
Expanded(child: vues[_onglet]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _empruntTile(Map<String, dynamic> item) {
|
||||||
|
final etudiant = Map<String, dynamic>.from(item["etudiant"] as Map);
|
||||||
|
final materiel = Map<String, dynamic>.from(item["materiel"] as Map);
|
||||||
|
return _InfoTile(
|
||||||
|
icon: Icons.assignment_outlined,
|
||||||
|
title: "${materiel["nom"]}",
|
||||||
|
subtitle:
|
||||||
|
"${etudiant["prenom"]} ${etudiant["nom"]} · retour prévu ${_date(item["dateRetourPrevue"])}",
|
||||||
|
meta: "Emprunt #${item["id"]}",
|
||||||
|
trailing: _StatusBadge("${item["statut"]}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _materielTile(Map<String, dynamic> item) {
|
||||||
|
final categorie = Map<String, dynamic>.from(item["categorie"] as Map);
|
||||||
|
return _InfoTile(
|
||||||
|
icon: Icons.inventory_2_outlined,
|
||||||
|
title: "${item["nom"]}",
|
||||||
|
subtitle:
|
||||||
|
"${item["reference"]} · ${categorie["nom"]} · ${item["etatGeneral"]}",
|
||||||
|
meta: "${item["marque"]} ${item["modele"]}",
|
||||||
|
trailing: _StatusBadge("${item["statut"]}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _anomalieTile(Map<String, dynamic> item) {
|
||||||
|
final materiel = Map<String, dynamic>.from(item["materiel"] as Map);
|
||||||
|
final prochain = _prochainStatut(item["statut"] as String?);
|
||||||
|
return _InfoTile(
|
||||||
|
icon: Icons.report_problem_outlined,
|
||||||
|
title: "${item["type"]} · ${materiel["nom"]}",
|
||||||
|
subtitle: "${item["description"]}",
|
||||||
|
meta: "Détectée le ${_date(item["dateDetection"])}",
|
||||||
|
trailing: prochain == null
|
||||||
|
? _StatusBadge("${item["statut"]}")
|
||||||
|
: FilledButton(
|
||||||
|
onPressed: _transitionEnCours ? null : () => _changerStatut(item),
|
||||||
|
child: Text(_statusLabel(prochain)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _notificationTile(Map<String, dynamic> item) {
|
||||||
|
return _InfoTile(
|
||||||
|
icon: item["lu"] == true
|
||||||
|
? Icons.notifications_none
|
||||||
|
: Icons.notifications_active_outlined,
|
||||||
|
title: "${item["titre"]}",
|
||||||
|
subtitle: "${item["message"]}",
|
||||||
|
meta: _date(item["dateCreation"]),
|
||||||
|
trailing: _StatusBadge(item["lu"] == true ? "LUE" : "NON_LUE"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _historiqueTile(Map<String, dynamic> item) {
|
||||||
|
final utilisateur = Map<String, dynamic>.from(item["utilisateur"] as Map);
|
||||||
|
return _InfoTile(
|
||||||
|
icon: Icons.history,
|
||||||
|
title: _actionLabel("${item["action"]}"),
|
||||||
|
subtitle: "${item["description"]}",
|
||||||
|
meta:
|
||||||
|
"${_date(item["dateAction"])} · ${utilisateur["prenom"]} ${utilisateur["nom"]}",
|
||||||
|
trailing: const Icon(Icons.chevron_right, color: EnsupColors.muted),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _date(Object? value) {
|
||||||
|
if (value == null) return "-";
|
||||||
|
final parsed = DateTime.tryParse(value.toString());
|
||||||
|
if (parsed == null) return value.toString();
|
||||||
|
return "${parsed.day.toString().padLeft(2, "0")}/"
|
||||||
|
"${parsed.month.toString().padLeft(2, "0")}/${parsed.year}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _statusLabel(String statut) {
|
||||||
|
return switch (statut) {
|
||||||
|
"DISPONIBLE" => "Disponible",
|
||||||
|
"EMPRUNTE" => "Emprunté",
|
||||||
|
"NON_CONFORME" => "Non conforme",
|
||||||
|
"DETERIORE" => "Détérioré",
|
||||||
|
"MAINTENANCE" => "Maintenance",
|
||||||
|
"INDISPONIBLE" => "Indisponible",
|
||||||
|
"EN_COURS" => "En cours",
|
||||||
|
"EN_RETARD" => "En retard",
|
||||||
|
"CLOTURE" => "Clôturé",
|
||||||
|
"RETOUR_NON_CONFORME" => "Retour non conforme",
|
||||||
|
"ANNULE" => "Annulé",
|
||||||
|
"DETECTEE" => "À traiter",
|
||||||
|
"EN_COURS_TRAITEMENT" => "En traitement",
|
||||||
|
"RESOLUE" => "Résolue",
|
||||||
|
"CLOTUREE" => "Clôturée",
|
||||||
|
"LUE" => "Lue",
|
||||||
|
"NON_LUE" => "Non lue",
|
||||||
|
_ => statut.replaceAll("_", " ").toLowerCase(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String _actionLabel(String action) {
|
||||||
|
return switch (action) {
|
||||||
|
"CREATION_EMPRUNT" => "Création d'emprunt",
|
||||||
|
"CREATION_ANOMALIE" => "Création d'anomalie",
|
||||||
|
"TRAITEMENT_ANOMALIE" => "Traitement d'anomalie",
|
||||||
|
_ => action.replaceAll("_", " ").toLowerCase(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _statusColor(String statut) {
|
||||||
|
return switch (statut) {
|
||||||
|
"DISPONIBLE" ||
|
||||||
|
"CLOTURE" ||
|
||||||
|
"RESOLUE" ||
|
||||||
|
"CLOTUREE" ||
|
||||||
|
"LUE" => EnsupColors.green,
|
||||||
|
"EN_RETARD" ||
|
||||||
|
"RETOUR_NON_CONFORME" ||
|
||||||
|
"NON_CONFORME" ||
|
||||||
|
"DETECTEE" ||
|
||||||
|
"NON_LUE" => EnsupColors.red,
|
||||||
|
"EN_COURS" || "EN_COURS_TRAITEMENT" || "EMPRUNTE" => EnsupColors.cyan,
|
||||||
|
"MAINTENANCE" || "INDISPONIBLE" || "DETERIORE" => EnsupColors.purple,
|
||||||
|
_ => EnsupColors.blue3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ResponsableData {
|
||||||
|
const _ResponsableData({
|
||||||
|
required this.dashboard,
|
||||||
|
required this.emprunts,
|
||||||
|
required this.materiels,
|
||||||
|
required this.anomalies,
|
||||||
|
required this.notifications,
|
||||||
|
required this.historique,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Map<String, dynamic> dashboard;
|
||||||
|
final List<Map<String, dynamic>> emprunts;
|
||||||
|
final List<Map<String, dynamic>> materiels;
|
||||||
|
final List<Map<String, dynamic>> anomalies;
|
||||||
|
final List<Map<String, dynamic>> notifications;
|
||||||
|
final List<Map<String, dynamic>> historique;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VueDashboard extends StatelessWidget {
|
||||||
|
const _VueDashboard({required this.data});
|
||||||
|
|
||||||
|
final _ResponsableData data;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final kpis = Map<String, dynamic>.from(data.dashboard["kpis"] as Map);
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 14,
|
||||||
|
runSpacing: 14,
|
||||||
|
children: [
|
||||||
|
_KpiCard(
|
||||||
|
label: "Matériels",
|
||||||
|
value: _total(kpis["materiels"]),
|
||||||
|
icon: Icons.inventory_2_outlined,
|
||||||
|
),
|
||||||
|
_KpiCard(
|
||||||
|
label: "Emprunts",
|
||||||
|
value: _total(kpis["emprunts"]),
|
||||||
|
icon: Icons.assignment_outlined,
|
||||||
|
),
|
||||||
|
_KpiCard(
|
||||||
|
label: "Anomalies",
|
||||||
|
value: _total(kpis["anomalies"]),
|
||||||
|
icon: Icons.report_problem_outlined,
|
||||||
|
),
|
||||||
|
_KpiCard(
|
||||||
|
label: "Notifications",
|
||||||
|
value: "${kpis["notificationsNonLues"] ?? 0}",
|
||||||
|
icon: Icons.notifications_outlined,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _total(Object? repartition) {
|
||||||
|
final map = Map<String, dynamic>.from(repartition as Map);
|
||||||
|
return "${map["total"] ?? 0}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VueListe extends StatefulWidget {
|
||||||
|
const _VueListe({
|
||||||
|
required this.titre,
|
||||||
|
required this.items,
|
||||||
|
required this.builder,
|
||||||
|
required this.searchableText,
|
||||||
|
this.action,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String titre;
|
||||||
|
final List<Map<String, dynamic>> items;
|
||||||
|
final Widget Function(Map<String, dynamic>) builder;
|
||||||
|
final String Function(Map<String, dynamic>) searchableText;
|
||||||
|
final Widget? action;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_VueListe> createState() => _VueListeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VueListeState extends State<_VueListe> {
|
||||||
|
String _recherche = "";
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final terme = _recherche.trim().toLowerCase();
|
||||||
|
final resultats = terme.isEmpty
|
||||||
|
? widget.items
|
||||||
|
: widget.items
|
||||||
|
.where(
|
||||||
|
(item) =>
|
||||||
|
widget.searchableText(item).toLowerCase().contains(terme),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
"${widget.titre} (${resultats.length})",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: EnsupColors.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.action != null) widget.action!,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
TextField(
|
||||||
|
onChanged: (value) => setState(() => _recherche = value),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: "Rechercher",
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
isDense: true,
|
||||||
|
filled: true,
|
||||||
|
fillColor: EnsupColors.soft,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: const BorderSide(color: EnsupColors.line),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Expanded(
|
||||||
|
child: resultats.isEmpty
|
||||||
|
? const _EmptyState()
|
||||||
|
: ListView.separated(
|
||||||
|
itemCount: resultats.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||||
|
itemBuilder: (_, index) => widget.builder(resultats[index]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Onglets extends StatelessWidget {
|
||||||
|
const _Onglets({
|
||||||
|
required this.index,
|
||||||
|
required this.labels,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int index;
|
||||||
|
final List<String> labels;
|
||||||
|
final ValueChanged<int> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: EnsupColors.soft,
|
||||||
|
border: Border(bottom: BorderSide(color: EnsupColors.line)),
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: List.generate(labels.length, (i) {
|
||||||
|
final selected = i == index;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: ChoiceChip(
|
||||||
|
selected: selected,
|
||||||
|
label: Text(labels[i]),
|
||||||
|
onSelected: (_) => onChanged(i),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _KpiCard extends StatelessWidget {
|
||||||
|
const _KpiCard({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: 210,
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: EnsupColors.line),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: EnsupColors.blue3),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InfoTile extends StatelessWidget {
|
||||||
|
const _InfoTile({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.trailing,
|
||||||
|
this.meta,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final Widget trailing;
|
||||||
|
final String? meta;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border.all(color: EnsupColors.line),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: EnsupColors.teal),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 19,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(subtitle, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||||
|
if (meta != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
meta!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 12,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
trailing,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatusBadge extends StatelessWidget {
|
||||||
|
const _StatusBadge(this.statut);
|
||||||
|
|
||||||
|
final String statut;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final color = _statusColor(statut);
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.18)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_statusLabel(statut),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmptyState extends StatelessWidget {
|
||||||
|
const _EmptyState();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
"Aucun résultat",
|
||||||
|
style: GoogleFonts.titilliumWeb(color: EnsupColors.muted),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Erreur extends StatelessWidget {
|
||||||
|
const _Erreur({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: [
|
||||||
|
Text(message, textAlign: TextAlign.center),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: onRetry,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text("Réessayer"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,13 +18,20 @@ class ApiClient {
|
|||||||
|
|
||||||
static const String _baseUrl = "http://localhost:3000/api";
|
static const String _baseUrl = "http://localhost:3000/api";
|
||||||
static const String _utilisateurEmail = "lucas.martin@ensitech.eu";
|
static const String _utilisateurEmail = "lucas.martin@ensitech.eu";
|
||||||
|
static const String responsableEmail = "karim.benali@ensup.eu";
|
||||||
|
|
||||||
static Future<dynamic> get(String chemin) async {
|
static Future<dynamic> get(
|
||||||
|
String chemin, {
|
||||||
|
String utilisateurEmail = _utilisateurEmail,
|
||||||
|
}) async {
|
||||||
final uri = Uri.parse("$_baseUrl$chemin");
|
final uri = Uri.parse("$_baseUrl$chemin");
|
||||||
|
|
||||||
late final http.Response reponse;
|
late final http.Response reponse;
|
||||||
try {
|
try {
|
||||||
reponse = await http.get(uri, headers: const {"x-user-email": _utilisateurEmail});
|
reponse = await http.get(
|
||||||
|
uri,
|
||||||
|
headers: {"x-user-email": utilisateurEmail},
|
||||||
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
throw const ApiException(
|
throw const ApiException(
|
||||||
"Impossible de joindre le serveur. Vérifiez que le backend est démarré.",
|
"Impossible de joindre le serveur. Vérifiez que le backend est démarré.",
|
||||||
@@ -37,16 +44,49 @@ class ApiClient {
|
|||||||
throw ApiException(_messageErreur(reponse));
|
throw ApiException(_messageErreur(reponse));
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<dynamic> post(String chemin, Map<String, dynamic> body) async {
|
static Future<dynamic> post(
|
||||||
|
String chemin,
|
||||||
|
Map<String, dynamic> body, {
|
||||||
|
String utilisateurEmail = _utilisateurEmail,
|
||||||
|
}) async {
|
||||||
final uri = Uri.parse("$_baseUrl$chemin");
|
final uri = Uri.parse("$_baseUrl$chemin");
|
||||||
|
|
||||||
late final http.Response reponse;
|
late final http.Response reponse;
|
||||||
try {
|
try {
|
||||||
reponse = await http.post(
|
reponse = await http.post(
|
||||||
uri,
|
uri,
|
||||||
headers: const {
|
headers: {
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
"x-user-email": _utilisateurEmail,
|
"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 Future<dynamic> patch(
|
||||||
|
String chemin,
|
||||||
|
Map<String, dynamic> body, {
|
||||||
|
String utilisateurEmail = _utilisateurEmail,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse("$_baseUrl$chemin");
|
||||||
|
|
||||||
|
late final http.Response reponse;
|
||||||
|
try {
|
||||||
|
reponse = await http.patch(
|
||||||
|
uri,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-user-email": utilisateurEmail,
|
||||||
},
|
},
|
||||||
body: jsonEncode(body),
|
body: jsonEncode(body),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import "api_client.dart";
|
||||||
|
|
||||||
|
/// Appels API du tableau responsable matériel.
|
||||||
|
class ResponsableService {
|
||||||
|
ResponsableService._();
|
||||||
|
|
||||||
|
static const String _email = ApiClient.responsableEmail;
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>> dashboard() async {
|
||||||
|
final reponse = await ApiClient.get(
|
||||||
|
"/responsable/dashboard",
|
||||||
|
utilisateurEmail: _email,
|
||||||
|
);
|
||||||
|
return Map<String, dynamic>.from(reponse["data"] as Map);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<List<Map<String, dynamic>>> emprunts() =>
|
||||||
|
_liste("/responsable/emprunts");
|
||||||
|
|
||||||
|
static Future<List<Map<String, dynamic>>> materiels() =>
|
||||||
|
_liste("/responsable/materiels");
|
||||||
|
|
||||||
|
static Future<List<Map<String, dynamic>>> anomalies() =>
|
||||||
|
_liste("/responsable/anomalies");
|
||||||
|
|
||||||
|
static Future<List<Map<String, dynamic>>> notifications() =>
|
||||||
|
_liste("/responsable/notifications");
|
||||||
|
|
||||||
|
static Future<List<Map<String, dynamic>>> historique() =>
|
||||||
|
_liste("/responsable/historique");
|
||||||
|
|
||||||
|
static Future<int> marquerNotificationsLues() async {
|
||||||
|
final reponse = await ApiClient.patch(
|
||||||
|
"/responsable/notifications/lu-toutes",
|
||||||
|
{},
|
||||||
|
utilisateurEmail: _email,
|
||||||
|
);
|
||||||
|
final data = Map<String, dynamic>.from(reponse["data"] as Map);
|
||||||
|
return data["count"] as int;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>> changerStatutAnomalie(
|
||||||
|
int id,
|
||||||
|
String statut,
|
||||||
|
) async {
|
||||||
|
final reponse = await ApiClient.patch("/responsable/anomalies/$id/statut", {
|
||||||
|
"statut": statut,
|
||||||
|
"observation": "Traitement depuis l'interface responsable",
|
||||||
|
}, utilisateurEmail: _email);
|
||||||
|
return Map<String, dynamic>.from(reponse["data"] as Map);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<List<Map<String, dynamic>>> _liste(String chemin) async {
|
||||||
|
final reponse = await ApiClient.get(chemin, utilisateurEmail: _email);
|
||||||
|
final data = reponse["data"] as List;
|
||||||
|
return data.map((item) => Map<String, dynamic>.from(item as Map)).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,11 +36,12 @@ l'historique complet, y compris des étapes devenues obsolètes après brancheme
|
|||||||
| Infrastructure | Terminé | Docker Compose SQL Server + Adminer, monorepo, backend Node/TS, frontend Flutter Web. |
|
| Infrastructure | Terminé | Docker Compose SQL Server + Adminer, monorepo, backend Node/TS, frontend Flutter Web. |
|
||||||
| Base de données | Terminé pour la V1 | Schéma Prisma 16 entités, migrations, seeds et fixtures de démonstration. |
|
| Base de données | Terminé pour la V1 | Schéma Prisma 16 entités, migrations, seeds et fixtures de démonstration. |
|
||||||
| API étudiant | Terminé pour la V1 | Catalogue, détail matériel, création d'emprunt, mes emprunts, restitution, anomalie automatique. |
|
| API étudiant | Terminé pour la V1 | Catalogue, détail matériel, création d'emprunt, mes emprunts, restitution, anomalie automatique. |
|
||||||
| Frontend étudiant | Terminé pour la V1 | Parcours emprunt et restitution branchés sur l'API et testés en réel. |
|
| Frontend étudiant | Fonctionnel pour la V1 | Parcours emprunt et restitution branchés sur l'API et testés en réel ; conservation de l'action avant identification à finaliser. |
|
||||||
| Authentification | Simulée | `x-user-email` côté backend et identité démo côté frontend ; Azure AD reste à faire. |
|
| Authentification | Simulée | `x-user-email` côté backend et identité démo côté frontend ; Azure AD reste à faire. |
|
||||||
| Responsable matériel | Non démarré | Dashboard, stock, anomalies, historique à développer. |
|
| API responsable | Terminé pour la V1 | Dashboard, emprunts, stock, anomalies, notifications, historique et export CSV, avec contrôle du rôle et du campus. |
|
||||||
|
| Frontend responsable | Fonctionnel pour la V1 | Dashboard et vues métier branchés sur l'API ; téléchargement CSV dans le navigateur à finaliser. |
|
||||||
| Tests automatisés | Non démarré | Tests backend/frontend/E2E à ajouter ; tests runtime manuels effectués. |
|
| Tests automatisés | Non démarré | Tests backend/frontend/E2E à ajouter ; tests runtime manuels effectués. |
|
||||||
| Documentation | Partielle | `CONTEXT.md`, `TODO.md`, `review.md` existent ; README principal et OpenAPI restent à créer. |
|
| Documentation | Partielle | README principal et documents de conception présents ; OpenAPI, guides utilisateur et captures restent à produire. |
|
||||||
|
|
||||||
## Commandes validées
|
## Commandes validées
|
||||||
|
|
||||||
@@ -56,13 +57,14 @@ npm run seed
|
|||||||
npm run fixtures
|
npm run fixtures
|
||||||
cd ../eme-frontend
|
cd ../eme-frontend
|
||||||
flutter run -d web-server --web-port 5000
|
flutter run -d web-server --web-port 5000
|
||||||
|
C:\flutter\flutter\bin\cache\dart-sdk\bin\dart.exe analyze
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes :
|
Notes :
|
||||||
- le backend écoute sur `http://localhost:3000` ;
|
- le backend écoute sur `http://localhost:3000` ;
|
||||||
- le frontend web de démo écoute sur `http://localhost:5000` ;
|
- le frontend web de démo écoute sur `http://localhost:5000` ;
|
||||||
- Adminer est disponible sur `http://localhost:8081` ;
|
- Adminer est disponible sur `http://localhost:8081` ;
|
||||||
- dans l'environnement Codex, `flutter analyze`, `flutter analyze --no-pub` et `dart format` ont déjà bloqué au timeout ; à relancer dans un terminal local Flutter.
|
- le wrapper `flutter` peut rester bloqué dans l'environnement Codex ; l'analyse directe avec l'exécutable Dart fonctionne et ne signale aucune erreur.
|
||||||
|
|
||||||
## Scénario de démo validé
|
## Scénario de démo validé
|
||||||
|
|
||||||
@@ -75,6 +77,14 @@ Scénario étudiant validé avec SQL Server Docker et backend compilé :
|
|||||||
5. Restituer conforme : l'emprunt passe `CLOTURE` et le matériel redevient disponible.
|
5. Restituer conforme : l'emprunt passe `CLOTURE` et le matériel redevient disponible.
|
||||||
6. Restituer non conforme : l'emprunt passe `RETOUR_NON_CONFORME` et le matériel sort du catalogue disponible.
|
6. Restituer non conforme : l'emprunt passe `RETOUR_NON_CONFORME` et le matériel sort du catalogue disponible.
|
||||||
|
|
||||||
|
Scénario responsable validé avec SQL Server Docker et backend compilé :
|
||||||
|
|
||||||
|
1. Karim (`RESPONSABLE`) accède au dashboard, aux emprunts, au stock, aux anomalies, aux notifications et à l'historique de son campus.
|
||||||
|
2. Lucas (`ETUDIANT`) reçoit une réponse `403` sur les routes responsable.
|
||||||
|
3. Une anomalie peut avancer dans le cycle `DETECTEE -> EN_COURS_TRAITEMENT -> RESOLUE -> CLOTUREE`.
|
||||||
|
4. Chaque transition enregistre le responsable et crée une entrée d'historique.
|
||||||
|
5. L'API génère l'export CSV de l'historique ; son téléchargement depuis l'interface Flutter reste à ajouter.
|
||||||
|
|
||||||
### Note outillage — migrations en environnement non-interactif
|
### Note outillage — migrations en environnement non-interactif
|
||||||
|
|
||||||
`prisma migrate dev` se bloque quand il doit demander une confirmation (ex. ajout de contrainte `@unique` sur table existante) car le terminal est non-interactif.
|
`prisma migrate dev` se bloque quand il doit demander une confirmation (ex. ajout de contrainte `@unique` sur table existante) car le terminal est non-interactif.
|
||||||
@@ -324,6 +334,87 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
|
|||||||
- Dette technique reformatée en tableau avec statut et impact V1.
|
- Dette technique reformatée en tableau avec statut et impact V1.
|
||||||
- Clarification des étapes frontend statiques historiques : elles sont conservées pour mémoire mais remplacées par les branchements API ultérieurs.
|
- Clarification des étapes frontend statiques historiques : elles sont conservées pour mémoire mais remplacées par les branchements API ultérieurs.
|
||||||
|
|
||||||
|
### Étape 34 — API responsable : dashboard
|
||||||
|
- Branche dédiée `feat/responsable-dashboard-api` créée depuis `develop`.
|
||||||
|
- Endpoint `GET /api/responsable/dashboard` ajouté, protégé par rôle `RESPONSABLE`.
|
||||||
|
- Périmètre campus appliqué via `user.campusId` (RG27).
|
||||||
|
- KPIs exposés : matériels par statut, emprunts par statut, anomalies par statut, notifications non lues.
|
||||||
|
- Activité récente exposée depuis l'historique, avec utilisateur, matériel et emprunt associé.
|
||||||
|
- Vérifié au runtime : Karim (`RESPONSABLE`) obtient 200 avec KPIs ; Lucas (`ETUDIANT`) obtient 403.
|
||||||
|
|
||||||
|
### Étape 35 — API responsable : consultation des emprunts
|
||||||
|
- Branche dédiée `feat/responsable-emprunts-api` créée après merge du dashboard responsable dans `develop`.
|
||||||
|
- Endpoint `GET /api/responsable/emprunts` ajouté, protégé par rôle `RESPONSABLE`.
|
||||||
|
- Filtrage par campus du responsable appliqué systématiquement (RG27).
|
||||||
|
- Filtre optionnel `statut` ajouté pour RG28 : `EN_COURS`, `EN_RETARD`, `CLOTURE`, `RETOUR_NON_CONFORME`, `ANNULE`.
|
||||||
|
- Réponse enrichie avec l'étudiant, le matériel, la catégorie, les dates et le statut.
|
||||||
|
- Vérifié au runtime : Karim obtient les emprunts du campus, `statut=EN_COURS` filtre correctement, statut invalide renvoie 400, Lucas obtient 403.
|
||||||
|
|
||||||
|
### Étape 36 — API responsable : consultation du stock
|
||||||
|
- Branche dédiée `feat/responsable-stock-api` créée après merge de la consultation des emprunts responsable dans `develop`.
|
||||||
|
- Endpoint `GET /api/responsable/materiels` ajouté, protégé par rôle `RESPONSABLE`.
|
||||||
|
- Filtrage par campus du responsable appliqué systématiquement (RG27/RG29).
|
||||||
|
- Filtres optionnels ajoutés : `statut`, `categorieId`, `q` (nom, marque, modèle, référence).
|
||||||
|
- Contrairement au catalogue étudiant, cet endpoint retourne tout le stock actif du campus, quel que soit le statut.
|
||||||
|
- Réponse enrichie avec catégorie et accessoires attendus.
|
||||||
|
- Vérifié au runtime : Karim obtient tout le stock du campus, `statut=DISPONIBLE` et `q=MacBook` filtrent correctement, statut invalide renvoie 400, Lucas obtient 403.
|
||||||
|
|
||||||
|
### Étape 37 — API responsable : consultation des anomalies
|
||||||
|
- Branche dédiée `feat/responsable-anomalies-api` créée après merge de la consultation du stock responsable dans `develop`.
|
||||||
|
- Endpoint `GET /api/responsable/anomalies` ajouté, protégé par rôle `RESPONSABLE`.
|
||||||
|
- Filtrage par campus du responsable appliqué systématiquement via l'emprunt lié à l'anomalie (RG27).
|
||||||
|
- Filtres optionnels ajoutés : `statut` (`DETECTEE`, `EN_COURS_TRAITEMENT`, `RESOLUE`, `CLOTUREE`) et `type`.
|
||||||
|
- Réponse enrichie avec anomalie, emprunt, étudiant concerné, matériel, catégorie et responsable de traitement si renseigné.
|
||||||
|
- Limite volontaire : consultation uniquement ; le cycle de traitement/résolution reste à développer.
|
||||||
|
|
||||||
|
### Étape 38 — API responsable : notifications
|
||||||
|
- Branche dédiée `feat/responsable-notifications-api` créée après merge de la consultation des anomalies responsable dans `develop`.
|
||||||
|
- Endpoint `GET /api/responsable/notifications` ajouté, protégé par rôle `RESPONSABLE`.
|
||||||
|
- Filtre optionnel `lu=true|false` ajouté pour séparer les notifications lues et non lues.
|
||||||
|
- Endpoint `PATCH /api/responsable/notifications/:id/lu` ajouté pour marquer une notification du responsable connecté comme lue.
|
||||||
|
- Endpoint `PATCH /api/responsable/notifications/lu-toutes` ajouté pour marquer toutes les notifications non lues du responsable connecté comme lues.
|
||||||
|
- Réponse enrichie avec l'anomalie liée et le matériel concerné lorsque la notification pointe vers une anomalie.
|
||||||
|
|
||||||
|
### Étape 39 — API responsable : historique et export
|
||||||
|
- Branche dédiée `feat/responsable-historique-api` créée après merge des notifications responsable dans `develop`.
|
||||||
|
- Endpoint `GET /api/responsable/historique` ajouté, protégé par rôle `RESPONSABLE`.
|
||||||
|
- Filtrage par campus du responsable appliqué systématiquement (RG27).
|
||||||
|
- Filtres optionnels ajoutés : `action`, `utilisateurId`, `materielId`, `empruntId`, `dateDebut`, `dateFin`.
|
||||||
|
- Endpoint `GET /api/responsable/historique/export.csv` ajouté avec les mêmes filtres.
|
||||||
|
- Export CSV généré côté API avec les colonnes principales : date, action, description, utilisateur, email, matériel, emprunt.
|
||||||
|
|
||||||
|
### Étape 40 — API responsable : cycle de statut des anomalies
|
||||||
|
- Branche dédiée `feat/responsable-anomalies-cycle-api` créée après merge de l'historique responsable dans `develop`.
|
||||||
|
- Endpoint `PATCH /api/responsable/anomalies/:id/statut` ajouté, protégé par rôle `RESPONSABLE`.
|
||||||
|
- Filtrage par campus appliqué avant toute modification pour empêcher le traitement d'une anomalie hors campus (RG27).
|
||||||
|
- Transitions autorisées : `DETECTEE -> EN_COURS_TRAITEMENT -> RESOLUE -> CLOTUREE`.
|
||||||
|
- Le responsable connecté est enregistré dans `traiteeParId`; une observation optionnelle peut être conservée.
|
||||||
|
- Une entrée d'historique `TRAITEMENT_ANOMALIE` est créée à chaque transition.
|
||||||
|
|
||||||
|
### Étape 41 — Frontend responsable : premier branchement API
|
||||||
|
- Branche dédiée `feat/responsable-frontend-api` créée pour isoler le travail frontend responsable.
|
||||||
|
- Le bouton "Accès responsable matériel" de l'écran d'identification ouvre maintenant un écran responsable.
|
||||||
|
- Client API étendu pour accepter l'e-mail simulé du responsable (`karim.benali@ensup.eu`) et les requêtes `PATCH`.
|
||||||
|
- Service frontend responsable ajouté pour consommer dashboard, emprunts, stock, anomalies, notifications et historique.
|
||||||
|
- Écran responsable ajouté avec onglets : dashboard, emprunts, stock, anomalies, notifications, historique.
|
||||||
|
- Les anomalies peuvent être avancées au prochain statut autorisé via l'API.
|
||||||
|
- Vérification statique effectuée avec l'exécutable Dart direct : `dart analyze` OK. Le wrapper `flutter` reste instable dans cette session et bloque au lancement web automatisé.
|
||||||
|
|
||||||
|
### Étape 42 — Frontend responsable : polish opérationnel
|
||||||
|
- Branche dédiée `feat/responsable-frontend-polish` créée après merge du premier branchement responsable.
|
||||||
|
- Statuts responsables remplacés par des libellés métier lisibles et des badges colorés.
|
||||||
|
- Recherche ajoutée sur les onglets emprunts, stock, anomalies, notifications et historique.
|
||||||
|
- Les lignes de listes affichent désormais une méta-information utile : identifiant emprunt, marque/modèle, date de détection, date de notification ou auteur historique.
|
||||||
|
- Action "Tout marquer lu" ajoutée dans l'onglet notifications.
|
||||||
|
- Action export CSV rendue visible dans l'onglet historique avec indication de l'endpoint disponible.
|
||||||
|
- Vérification statique : `dart analyze` OK.
|
||||||
|
|
||||||
|
### Étape 43 — Synchronisation de l'état courant
|
||||||
|
- Synthèse mise en cohérence avec les étapes 34 à 42 : API et frontend responsable désormais fonctionnels pour la V1.
|
||||||
|
- Documentation corrigée pour refléter l'existence du README principal.
|
||||||
|
- Limites restantes explicitées : authentification Azure AD, téléchargement CSV frontend, tests automatisés, OpenAPI et guides utilisateur.
|
||||||
|
- Commandes de vérification actualisées : build et lint backend verts, analyse Dart directe sans erreur.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Dernière mise à jour : 2026-07-09 — État courant du projet et journal de décisions clarifiés.*
|
*Dernière mise à jour : 2026-07-22 — Synthèse synchronisée avec l'état réel du projet après finalisation du premier parcours responsable.*
|
||||||
|
|||||||
Reference in New Issue
Block a user