feat(responsable): add anomaly and history tables

This commit is contained in:
SaidSoighiri94
2026-07-24 15:47:48 +02:00
parent ec36349bad
commit 38efe6a873
6 changed files with 1623 additions and 81 deletions
@@ -24,7 +24,9 @@ import {
parseStatutAnomalie, parseStatutAnomalie,
parseStatutMateriel, parseStatutMateriel,
parseStatutEmprunt, parseStatutEmprunt,
parseTriAnomalie,
parseTriEmprunt, parseTriEmprunt,
parseTriHistorique,
parseTriMateriel, parseTriMateriel,
} from '../services/responsable.service'; } from '../services/responsable.service';
import { import {
@@ -53,6 +55,9 @@ function getHistoriqueFiltres(req: Request) {
empruntId: parsePositiveIntQuery(req.query.empruntId, 'empruntId'), empruntId: parsePositiveIntQuery(req.query.empruntId, 'empruntId'),
dateDebut: parseDateQuery(req.query.dateDebut, 'dateDebut'), dateDebut: parseDateQuery(req.query.dateDebut, 'dateDebut'),
dateFin: parseDateQuery(req.query.dateFin, 'dateFin'), dateFin: parseDateQuery(req.query.dateFin, 'dateFin'),
recherche: typeof req.query.q === 'string' ? req.query.q : undefined,
tri: parseTriHistorique(req.query.tri),
ordre: parseOrdreTri(req.query.ordre),
}; };
} }
@@ -163,10 +168,16 @@ export async function getAnomalies(req: Request, res: Response): Promise<void> {
const statut = parseStatutAnomalie(req.query.statut); const statut = parseStatutAnomalie(req.query.statut);
const type = typeof req.query.type === 'string' ? req.query.type : undefined; const type = typeof req.query.type === 'string' ? req.query.type : undefined;
const recherche = typeof req.query.q === 'string' ? req.query.q : undefined;
const tri = parseTriAnomalie(req.query.tri);
const ordre = parseOrdreTri(req.query.ordre);
const anomalies = await listerAnomaliesResponsable(user.roleCode, user.campusId, { const anomalies = await listerAnomaliesResponsable(user.roleCode, user.campusId, {
statut, statut,
type, type,
recherche,
tri,
ordre,
}); });
res.json({ data: anomalies.map(toAnomalieResponsableResponse) }); res.json({ data: anomalies.map(toAnomalieResponsableResponse) });
} }
@@ -96,6 +96,9 @@ export interface MaterielResponsableFiltres {
export interface AnomalieResponsableFiltres { export interface AnomalieResponsableFiltres {
statut?: string; statut?: string;
type?: string; type?: string;
recherche?: string;
tri?: 'dateDetection' | 'type' | 'materiel' | 'etudiant' | 'statut';
ordre?: 'asc' | 'desc';
} }
export interface NotificationResponsableFiltres { export interface NotificationResponsableFiltres {
@@ -109,6 +112,9 @@ export interface HistoriqueResponsableFiltres {
empruntId?: number; empruntId?: number;
dateDebut?: Date; dateDebut?: Date;
dateFin?: Date; dateFin?: Date;
recherche?: string;
tri?: 'dateAction' | 'action' | 'utilisateur' | 'materiel' | 'emprunt';
ordre?: 'asc' | 'desc';
} }
export interface ChangerStatutAnomalieData { export interface ChangerStatutAnomalieData {
@@ -421,11 +427,40 @@ export function findAnomaliesResponsable(
campusId: number, campusId: number,
filtres: AnomalieResponsableFiltres, filtres: AnomalieResponsableFiltres,
): Promise<AnomalieResponsable[]> { ): Promise<AnomalieResponsable[]> {
const ordre = filtres.ordre ?? 'desc';
const orderBy: Prisma.AnomalieOrderByWithRelationInput[] = (() => {
switch (filtres.tri) {
case 'type':
return [{ type: ordre }, { dateDetection: 'desc' }, { id: 'desc' }];
case 'materiel':
return [{ materiel: { nom: ordre } }, { dateDetection: 'desc' }, { id: 'desc' }];
case 'etudiant':
return [{ etudiant: { nom: ordre } }, { dateDetection: 'desc' }, { id: 'desc' }];
case 'statut':
return [{ statut: ordre }, { dateDetection: 'desc' }, { id: 'desc' }];
default:
return [{ dateDetection: ordre }, { id: 'desc' }];
}
})();
return prisma.anomalie.findMany({ return prisma.anomalie.findMany({
where: { where: {
emprunt: { campusId }, emprunt: { campusId },
...(filtres.statut ? { statut: filtres.statut } : {}), ...(filtres.statut ? { statut: filtres.statut } : {}),
...(filtres.type ? { type: filtres.type } : {}), ...(filtres.type ? { type: filtres.type } : {}),
...(filtres.recherche
? {
OR: [
{ description: { contains: filtres.recherche } },
{ type: { contains: filtres.recherche } },
{ materiel: { nom: { contains: filtres.recherche } } },
{ materiel: { reference: { contains: filtres.recherche } } },
{ etudiant: { nom: { contains: filtres.recherche } } },
{ etudiant: { prenom: { contains: filtres.recherche } } },
{ etudiant: { email: { contains: filtres.recherche } } },
],
}
: {}),
}, },
include: { include: {
emprunt: true, emprunt: true,
@@ -433,7 +468,7 @@ export function findAnomaliesResponsable(
materiel: { include: { categorie: true } }, materiel: { include: { categorie: true } },
traitePar: true, traitePar: true,
}, },
orderBy: { dateDetection: 'desc' }, orderBy,
}); });
} }
@@ -548,6 +583,22 @@ export function findHistoriqueResponsable(
campusId: number, campusId: number,
filtres: HistoriqueResponsableFiltres, filtres: HistoriqueResponsableFiltres,
): Promise<HistoriqueResponsable[]> { ): Promise<HistoriqueResponsable[]> {
const ordre = filtres.ordre ?? 'desc';
const orderBy: Prisma.HistoriqueOrderByWithRelationInput[] = (() => {
switch (filtres.tri) {
case 'action':
return [{ action: ordre }, { dateAction: 'desc' }, { id: 'desc' }];
case 'utilisateur':
return [{ utilisateur: { nom: ordre } }, { dateAction: 'desc' }, { id: 'desc' }];
case 'materiel':
return [{ materiel: { nom: ordre } }, { dateAction: 'desc' }, { id: 'desc' }];
case 'emprunt':
return [{ empruntId: ordre }, { dateAction: 'desc' }, { id: 'desc' }];
default:
return [{ dateAction: ordre }, { id: 'desc' }];
}
})();
return prisma.historique.findMany({ return prisma.historique.findMany({
where: { where: {
campusId, campusId,
@@ -563,6 +614,19 @@ export function findHistoriqueResponsable(
}, },
} }
: {}), : {}),
...(filtres.recherche
? {
OR: [
{ action: { contains: filtres.recherche } },
{ description: { contains: filtres.recherche } },
{ utilisateur: { nom: { contains: filtres.recherche } } },
{ utilisateur: { prenom: { contains: filtres.recherche } } },
{ utilisateur: { email: { contains: filtres.recherche } } },
{ materiel: { nom: { contains: filtres.recherche } } },
{ materiel: { reference: { contains: filtres.recherche } } },
],
}
: {}),
}, },
include: { include: {
utilisateur: true, utilisateur: true,
@@ -571,6 +635,6 @@ export function findHistoriqueResponsable(
sallePret: true, sallePret: true,
posteEmprunt: true, posteEmprunt: true,
}, },
orderBy: { dateAction: 'desc' }, orderBy,
}); });
} }
@@ -80,8 +80,18 @@ export type TriMaterielResponsable =
export interface ListerAnomaliesResponsableFiltres { export interface ListerAnomaliesResponsableFiltres {
statut?: StatutAnomalie; statut?: StatutAnomalie;
type?: string; type?: string;
recherche?: string;
tri?: TriAnomalieResponsable;
ordre?: OrdreTri;
} }
export type TriAnomalieResponsable =
| 'dateDetection'
| 'type'
| 'materiel'
| 'etudiant'
| 'statut';
export interface ListerNotificationsResponsableFiltres { export interface ListerNotificationsResponsableFiltres {
lu?: boolean; lu?: boolean;
} }
@@ -93,8 +103,18 @@ export interface ListerHistoriqueResponsableFiltres {
empruntId?: number; empruntId?: number;
dateDebut?: Date; dateDebut?: Date;
dateFin?: Date; dateFin?: Date;
recherche?: string;
tri?: TriHistoriqueResponsable;
ordre?: OrdreTri;
} }
export type TriHistoriqueResponsable =
| 'dateAction'
| 'action'
| 'utilisateur'
| 'materiel'
| 'emprunt';
export interface ChangerStatutAnomalieResponsableRequest { export interface ChangerStatutAnomalieResponsableRequest {
statut: StatutAnomalie; statut: StatutAnomalie;
observation?: string; observation?: string;
@@ -244,6 +264,23 @@ export function parseStatutAnomalie(value: unknown): StatutAnomalie | undefined
return value as StatutAnomalie; return value as StatutAnomalie;
} }
export function parseTriAnomalie(value: unknown): TriAnomalieResponsable | undefined {
if (value === undefined) {
return undefined;
}
const valeurs: readonly TriAnomalieResponsable[] = [
'dateDetection',
'type',
'materiel',
'etudiant',
'statut',
];
if (typeof value !== 'string' || !valeurs.includes(value as TriAnomalieResponsable)) {
throw new AppError(400, 'tri invalide');
}
return value as TriAnomalieResponsable;
}
export function parseCategorieId(value: unknown): number | undefined { export function parseCategorieId(value: unknown): number | undefined {
if (value === undefined) { if (value === undefined) {
return undefined; return undefined;
@@ -328,6 +365,23 @@ export function parseDateQuery(value: unknown, champ: string): Date | undefined
return parsed; return parsed;
} }
export function parseTriHistorique(value: unknown): TriHistoriqueResponsable | undefined {
if (value === undefined) {
return undefined;
}
const valeurs: readonly TriHistoriqueResponsable[] = [
'dateAction',
'action',
'utilisateur',
'materiel',
'emprunt',
];
if (typeof value !== 'string' || !valeurs.includes(value as TriHistoriqueResponsable)) {
throw new AppError(400, 'tri invalide');
}
return value as TriHistoriqueResponsable;
}
export function parseLu(value: unknown): boolean | undefined { export function parseLu(value: unknown): boolean | undefined {
if (value === undefined) { if (value === undefined) {
return undefined; return undefined;
File diff suppressed because it is too large Load Diff
@@ -50,19 +50,86 @@ class ResponsableService {
return _liste("/responsable/materiels?$query"); return _liste("/responsable/materiels?$query");
} }
static Future<List<Map<String, dynamic>>> anomalies() => static Future<List<Map<String, dynamic>>> anomalies({
_liste("/responsable/anomalies"); String? statut,
String? type,
String? recherche,
String tri = "dateDetection",
String ordre = "desc",
}) {
final parametres = <String, String>{
"tri": tri,
"ordre": ordre,
"statut": ?statut,
"type": ?type,
if (recherche != null && recherche.trim().isNotEmpty)
"q": recherche.trim(),
};
final query = Uri(queryParameters: parametres).query;
return _liste("/responsable/anomalies?$query");
}
static Future<List<Map<String, dynamic>>> notifications() => static Future<List<Map<String, dynamic>>> notifications() =>
_liste("/responsable/notifications"); _liste("/responsable/notifications");
static Future<List<Map<String, dynamic>>> historique() => static Future<List<Map<String, dynamic>>> historique({
_liste("/responsable/historique"); String? action,
DateTime? dateDebut,
DateTime? dateFin,
String? recherche,
String tri = "dateAction",
String ordre = "desc",
}) {
return _liste(
"/responsable/historique?${_historiqueQuery(action: action, dateDebut: dateDebut, dateFin: dateFin, recherche: recherche, tri: tri, ordre: ordre)}",
);
}
static Future<ApiFile> exporterHistorique() => ApiClient.getFile( static Future<ApiFile> exporterHistorique({
"/responsable/historique/export.csv", String? action,
DateTime? dateDebut,
DateTime? dateFin,
String? recherche,
String tri = "dateAction",
String ordre = "desc",
}) {
final query = _historiqueQuery(
action: action,
dateDebut: dateDebut,
dateFin: dateFin,
recherche: recherche,
tri: tri,
ordre: ordre,
);
return ApiClient.getFile(
"/responsable/historique/export.csv?$query",
fallbackFileName: "historique-responsable.csv", fallbackFileName: "historique-responsable.csv",
); );
}
static String _historiqueQuery({
String? action,
DateTime? dateDebut,
DateTime? dateFin,
String? recherche,
required String tri,
required String ordre,
}) {
final finIncluse = dateFin == null
? null
: DateTime(dateFin.year, dateFin.month, dateFin.day, 23, 59, 59, 999);
return Uri(
queryParameters: {
"tri": tri,
"ordre": ordre,
"action": ?action,
if (dateDebut != null) "dateDebut": dateDebut.toUtc().toIso8601String(),
if (finIncluse != null) "dateFin": finIncluse.toUtc().toIso8601String(),
if (recherche != null && recherche.trim().isNotEmpty)
"q": recherche.trim(),
},
).query;
}
static Future<int> marquerNotificationsLues() async { static Future<int> marquerNotificationsLues() async {
final reponse = await ApiClient.patch( final reponse = await ApiClient.patch(
+17 -1
View File
@@ -501,6 +501,22 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
- Les menus de filtres restent sur une ligne et tronquent les libellés trop longs pour prévenir tout débordement. - Les menus de filtres restent sur une ligne et tronquent les libellés trop longs pour prévenir tout débordement.
- Vérifications réussies : build et lint backend, analyse Dart, tris et filtres combinés testés sur SQL Server. - Vérifications réussies : build et lint backend, analyse Dart, tris et filtres combinés testés sur SQL Server.
### Étape 52 — Tableau des anomalies responsable
- La liste des anomalies devient un tableau opérationnel sur ordinateur et une liste compacte sur petit écran.
- Les colonnes présentent la date de détection, le type et la description, le matériel, l'étudiant, le statut et l'action suivante.
- La recherche serveur couvre la description, le type, le matériel, la référence et l'identité de l'étudiant.
- Les anomalies peuvent être filtrées par statut et type, puis triées par date, type, matériel, étudiant ou statut.
- Le changement de statut existant reste disponible dans chaque ligne et conserve les transitions métier autorisées.
- Vérifications réussies : build et lint backend, analyse Dart, tris et filtres combinés testés sur SQL Server.
### Étape 53 — Tableau et export filtré de l'historique responsable
- L'historique devient un tableau d'audit sur ordinateur et une liste compacte sur petit écran.
- Les colonnes présentent la date et l'heure, l'action et sa description, l'utilisateur, le matériel, l'emprunt et le point d'opération.
- La recherche serveur couvre l'action, la description, l'utilisateur et le matériel.
- Les événements peuvent être filtrés par action et période, puis triés par date, action, utilisateur, matériel ou emprunt.
- L'export CSV reprend désormais les filtres, la période et l'ordre actifs dans l'interface.
- Vérifications réussies : build et lint backend, analyse Dart, filtre combiné sur 22 événements et export CSV filtré testés sur SQL Server.
--- ---
*Dernière mise à jour : 2026-07-24 — Tableau de supervision du stock matériel enrichi.* *Dernière mise à jour : 2026-07-24 — Supervision des anomalies et historique enrichie.*