feat(responsable): improve loan supervision table
This commit is contained in:
@@ -13,15 +13,18 @@ import {
|
|||||||
marquerNotificationResponsableLue,
|
marquerNotificationResponsableLue,
|
||||||
marquerToutesNotificationsResponsableLues,
|
marquerToutesNotificationsResponsableLues,
|
||||||
parseAnomalieId,
|
parseAnomalieId,
|
||||||
|
parseAnneeEmprunt,
|
||||||
parseEmpruntId,
|
parseEmpruntId,
|
||||||
parseCategorieId,
|
parseCategorieId,
|
||||||
parseLu,
|
parseLu,
|
||||||
parseNotificationId,
|
parseNotificationId,
|
||||||
|
parseOrdreTri,
|
||||||
parseDateQuery,
|
parseDateQuery,
|
||||||
parsePositiveIntQuery,
|
parsePositiveIntQuery,
|
||||||
parseStatutAnomalie,
|
parseStatutAnomalie,
|
||||||
parseStatutMateriel,
|
parseStatutMateriel,
|
||||||
parseStatutEmprunt,
|
parseStatutEmprunt,
|
||||||
|
parseTriEmprunt,
|
||||||
} from '../services/responsable.service';
|
} from '../services/responsable.service';
|
||||||
import {
|
import {
|
||||||
toAnomalieResponsableResponse,
|
toAnomalieResponsableResponse,
|
||||||
@@ -77,7 +80,17 @@ export async function getEmprunts(req: Request, res: Response): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statut = parseStatutEmprunt(req.query.statut);
|
const statut = parseStatutEmprunt(req.query.statut);
|
||||||
const emprunts = await listerEmpruntsResponsable(user.roleCode, user.campusId, { statut });
|
const annee = parseAnneeEmprunt(req.query.annee);
|
||||||
|
const recherche = typeof req.query.q === 'string' ? req.query.q.trim() : undefined;
|
||||||
|
const tri = parseTriEmprunt(req.query.tri);
|
||||||
|
const ordre = parseOrdreTri(req.query.ordre);
|
||||||
|
const emprunts = await listerEmpruntsResponsable(user.roleCode, user.campusId, {
|
||||||
|
statut,
|
||||||
|
annee,
|
||||||
|
recherche: recherche || undefined,
|
||||||
|
tri,
|
||||||
|
ordre,
|
||||||
|
});
|
||||||
res.json({ data: emprunts.map(toEmpruntResponsableResponse) });
|
res.json({ data: emprunts.map(toEmpruntResponsableResponse) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,16 @@ export type HistoriqueResponsable = Prisma.HistoriqueGetPayload<{
|
|||||||
|
|
||||||
export interface EmpruntResponsableFiltres {
|
export interface EmpruntResponsableFiltres {
|
||||||
statut?: string;
|
statut?: string;
|
||||||
|
annee?: number;
|
||||||
|
recherche?: string;
|
||||||
|
tri?:
|
||||||
|
| 'dateEmprunt'
|
||||||
|
| 'dateRetourPrevue'
|
||||||
|
| 'dateRetourReelle'
|
||||||
|
| 'materiel'
|
||||||
|
| 'etudiant'
|
||||||
|
| 'statut';
|
||||||
|
ordre?: 'asc' | 'desc';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MaterielResponsableFiltres {
|
export interface MaterielResponsableFiltres {
|
||||||
@@ -184,16 +194,55 @@ export function findEmpruntsResponsable(
|
|||||||
campusId: number,
|
campusId: number,
|
||||||
filtres: EmpruntResponsableFiltres,
|
filtres: EmpruntResponsableFiltres,
|
||||||
): Promise<EmpruntResponsable[]> {
|
): Promise<EmpruntResponsable[]> {
|
||||||
|
const ordre = filtres.ordre ?? 'desc';
|
||||||
|
const periode = filtres.annee
|
||||||
|
? {
|
||||||
|
gte: new Date(Date.UTC(filtres.annee, 0, 1)),
|
||||||
|
lt: new Date(Date.UTC(filtres.annee + 1, 0, 1)),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
const orderBy: Prisma.EmpruntOrderByWithRelationInput[] = (() => {
|
||||||
|
switch (filtres.tri) {
|
||||||
|
case 'dateRetourPrevue':
|
||||||
|
return [{ dateRetourPrevue: ordre }, { id: 'desc' }];
|
||||||
|
case 'dateRetourReelle':
|
||||||
|
return [{ dateRetourReelle: ordre }, { id: 'desc' }];
|
||||||
|
case 'materiel':
|
||||||
|
return [{ materiel: { nom: ordre } }, { id: 'desc' }];
|
||||||
|
case 'etudiant':
|
||||||
|
return [{ utilisateur: { nom: ordre } }, { id: 'desc' }];
|
||||||
|
case 'statut':
|
||||||
|
return [{ statut: ordre }, { id: 'desc' }];
|
||||||
|
default:
|
||||||
|
return [{ dateEmprunt: ordre }, { id: 'desc' }];
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
return prisma.emprunt.findMany({
|
return prisma.emprunt.findMany({
|
||||||
where: {
|
where: {
|
||||||
campusId,
|
campusId,
|
||||||
...(filtres.statut ? { statut: filtres.statut } : {}),
|
...(filtres.statut ? { statut: filtres.statut } : {}),
|
||||||
|
...(periode ? { dateEmprunt: periode } : {}),
|
||||||
|
...(filtres.recherche
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ materiel: { nom: { contains: filtres.recherche } } },
|
||||||
|
{ materiel: { reference: { contains: filtres.recherche } } },
|
||||||
|
{ materiel: { marque: { contains: filtres.recherche } } },
|
||||||
|
{ materiel: { modele: { contains: filtres.recherche } } },
|
||||||
|
{ utilisateur: { nom: { contains: filtres.recherche } } },
|
||||||
|
{ utilisateur: { prenom: { contains: filtres.recherche } } },
|
||||||
|
{ utilisateur: { email: { contains: filtres.recherche } } },
|
||||||
|
{ utilisateur: { classe: { contains: filtres.recherche } } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
utilisateur: true,
|
utilisateur: true,
|
||||||
materiel: { include: { categorie: true } },
|
materiel: { include: { categorie: true } },
|
||||||
},
|
},
|
||||||
orderBy: { dateEmprunt: 'desc' },
|
orderBy,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,22 @@ export interface DashboardResponsableData {
|
|||||||
|
|
||||||
export interface ListerEmpruntsResponsableFiltres {
|
export interface ListerEmpruntsResponsableFiltres {
|
||||||
statut?: StatutEmprunt;
|
statut?: StatutEmprunt;
|
||||||
|
annee?: number;
|
||||||
|
recherche?: string;
|
||||||
|
tri?: TriEmpruntResponsable;
|
||||||
|
ordre?: OrdreTri;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TriEmpruntResponsable =
|
||||||
|
| 'dateEmprunt'
|
||||||
|
| 'dateRetourPrevue'
|
||||||
|
| 'dateRetourReelle'
|
||||||
|
| 'materiel'
|
||||||
|
| 'etudiant'
|
||||||
|
| 'statut';
|
||||||
|
|
||||||
|
export type OrdreTri = 'asc' | 'desc';
|
||||||
|
|
||||||
export interface ListerMaterielsResponsableFiltres {
|
export interface ListerMaterielsResponsableFiltres {
|
||||||
statut?: StatutMateriel;
|
statut?: StatutMateriel;
|
||||||
categorieId?: number;
|
categorieId?: number;
|
||||||
@@ -139,6 +153,49 @@ export function parseStatutEmprunt(value: unknown): StatutEmprunt | undefined {
|
|||||||
return value as StatutEmprunt;
|
return value as StatutEmprunt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseAnneeEmprunt(value: unknown): number | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new AppError(400, 'annee invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
const annee = Number(value);
|
||||||
|
if (!Number.isInteger(annee) || annee < 2000 || annee > 2100) {
|
||||||
|
throw new AppError(400, 'annee invalide');
|
||||||
|
}
|
||||||
|
return annee;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTriEmprunt(value: unknown): TriEmpruntResponsable | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const valeurs: readonly TriEmpruntResponsable[] = [
|
||||||
|
'dateEmprunt',
|
||||||
|
'dateRetourPrevue',
|
||||||
|
'dateRetourReelle',
|
||||||
|
'materiel',
|
||||||
|
'etudiant',
|
||||||
|
'statut',
|
||||||
|
];
|
||||||
|
if (typeof value !== 'string' || !valeurs.includes(value as TriEmpruntResponsable)) {
|
||||||
|
throw new AppError(400, 'tri invalide');
|
||||||
|
}
|
||||||
|
return value as TriEmpruntResponsable;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOrdreTri(value: unknown): OrdreTri | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (value !== 'asc' && value !== 'desc') {
|
||||||
|
throw new AppError(400, 'ordre invalide');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseStatutMateriel(value: unknown): StatutMateriel | undefined {
|
export function parseStatutMateriel(value: unknown): StatutMateriel | undefined {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -18,8 +18,14 @@ class ResponsableScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ResponsableScreenState extends State<ResponsableScreen> {
|
class _ResponsableScreenState extends State<ResponsableScreen> {
|
||||||
|
final TextEditingController _rechercheEmpruntsController =
|
||||||
|
TextEditingController();
|
||||||
late Future<_ResponsableData> _future = _charger();
|
late Future<_ResponsableData> _future = _charger();
|
||||||
int _onglet = 0;
|
int _onglet = 0;
|
||||||
|
String? _statutEmprunts;
|
||||||
|
int? _anneeEmprunts;
|
||||||
|
String _triEmprunts = "dateEmprunt";
|
||||||
|
String _ordreEmprunts = "desc";
|
||||||
bool _transitionEnCours = false;
|
bool _transitionEnCours = false;
|
||||||
bool _decisionEnCours = false;
|
bool _decisionEnCours = false;
|
||||||
bool _exportEnCours = false;
|
bool _exportEnCours = false;
|
||||||
@@ -27,7 +33,13 @@ class _ResponsableScreenState extends State<ResponsableScreen> {
|
|||||||
Future<_ResponsableData> _charger() async {
|
Future<_ResponsableData> _charger() async {
|
||||||
final dashboard = await ResponsableService.dashboard();
|
final dashboard = await ResponsableService.dashboard();
|
||||||
final ecarts = await ResponsableService.ecarts();
|
final ecarts = await ResponsableService.ecarts();
|
||||||
final emprunts = await ResponsableService.emprunts();
|
final emprunts = await ResponsableService.emprunts(
|
||||||
|
statut: _statutEmprunts,
|
||||||
|
annee: _anneeEmprunts,
|
||||||
|
recherche: _rechercheEmpruntsController.text,
|
||||||
|
tri: _triEmprunts,
|
||||||
|
ordre: _ordreEmprunts,
|
||||||
|
);
|
||||||
final materiels = await ResponsableService.materiels();
|
final materiels = await ResponsableService.materiels();
|
||||||
final anomalies = await ResponsableService.anomalies();
|
final anomalies = await ResponsableService.anomalies();
|
||||||
final notifications = await ResponsableService.notifications();
|
final notifications = await ResponsableService.notifications();
|
||||||
@@ -48,6 +60,43 @@ class _ResponsableScreenState extends State<ResponsableScreen> {
|
|||||||
setState(() => _future = _charger());
|
setState(() => _future = _charger());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _trierEmprunts(String tri) {
|
||||||
|
setState(() {
|
||||||
|
if (_triEmprunts == tri) {
|
||||||
|
_ordreEmprunts = _ordreEmprunts == "asc" ? "desc" : "asc";
|
||||||
|
} else {
|
||||||
|
_triEmprunts = tri;
|
||||||
|
_ordreEmprunts = tri.startsWith("date") ? "desc" : "asc";
|
||||||
|
}
|
||||||
|
_future = _charger();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _filtrerEmprunts({String? statut, int? annee}) {
|
||||||
|
setState(() {
|
||||||
|
_statutEmprunts = statut;
|
||||||
|
_anneeEmprunts = annee;
|
||||||
|
_future = _charger();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reinitialiserFiltresEmprunts() {
|
||||||
|
_rechercheEmpruntsController.clear();
|
||||||
|
setState(() {
|
||||||
|
_statutEmprunts = null;
|
||||||
|
_anneeEmprunts = null;
|
||||||
|
_triEmprunts = "dateEmprunt";
|
||||||
|
_ordreEmprunts = "desc";
|
||||||
|
_future = _charger();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_rechercheEmpruntsController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _quitter() async {
|
Future<void> _quitter() async {
|
||||||
try {
|
try {
|
||||||
await AuthService.signOut();
|
await AuthService.signOut();
|
||||||
@@ -232,18 +281,22 @@ class _ResponsableScreenState extends State<ResponsableScreen> {
|
|||||||
return "${item["typeEcart"]} ${materiel["nom"]} ${materiel["reference"]} ${etudiant["prenom"]} ${etudiant["nom"]}";
|
return "${item["typeEcart"]} ${materiel["nom"]} ${materiel["reference"]} ${etudiant["prenom"]} ${etudiant["nom"]}";
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_VueListe(
|
_VueEmpruntsTableau(
|
||||||
titre: "Emprunts",
|
|
||||||
description:
|
|
||||||
"Suivez les prêts en cours, les retards et les retours clôturés.",
|
|
||||||
icon: Icons.assignment_outlined,
|
|
||||||
items: data.emprunts,
|
items: data.emprunts,
|
||||||
builder: _empruntTile,
|
rechercheController: _rechercheEmpruntsController,
|
||||||
searchableText: (item) {
|
statut: _statutEmprunts,
|
||||||
final etudiant = Map<String, dynamic>.from(item["etudiant"] as Map);
|
annee: _anneeEmprunts,
|
||||||
final materiel = Map<String, dynamic>.from(item["materiel"] as Map);
|
tri: _triEmprunts,
|
||||||
return "${materiel["nom"]} ${materiel["reference"]} ${etudiant["prenom"]} ${etudiant["nom"]} ${item["statut"]}";
|
ordre: _ordreEmprunts,
|
||||||
|
onRecherche: _rafraichir,
|
||||||
|
onStatutChanged: (statut) {
|
||||||
|
_filtrerEmprunts(statut: statut, annee: _anneeEmprunts);
|
||||||
},
|
},
|
||||||
|
onAnneeChanged: (annee) {
|
||||||
|
_filtrerEmprunts(statut: _statutEmprunts, annee: annee);
|
||||||
|
},
|
||||||
|
onTri: _trierEmprunts,
|
||||||
|
onReset: _reinitialiserFiltresEmprunts,
|
||||||
),
|
),
|
||||||
_VueListe(
|
_VueListe(
|
||||||
titre: "Stock matériel",
|
titre: "Stock matériel",
|
||||||
@@ -337,19 +390,6 @@ class _ResponsableScreenState extends State<ResponsableScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 _ecartTile(Map<String, dynamic> item) {
|
Widget _ecartTile(Map<String, dynamic> item) {
|
||||||
final etudiant = Map<String, dynamic>.from(item["etudiant"] as Map);
|
final etudiant = Map<String, dynamic>.from(item["etudiant"] as Map);
|
||||||
final materiel = Map<String, dynamic>.from(item["materiel"] as Map);
|
final materiel = Map<String, dynamic>.from(item["materiel"] as Map);
|
||||||
@@ -905,6 +945,15 @@ String _dateHeure(Object? value) {
|
|||||||
return "$jour/$mois · $heure:$minute";
|
return "$jour/$mois · $heure:$minute";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _dateComplete(Object? value) {
|
||||||
|
if (value == null) return "-";
|
||||||
|
final date = DateTime.tryParse("$value")?.toLocal();
|
||||||
|
if (date == null) return "-";
|
||||||
|
final jour = date.day.toString().padLeft(2, "0");
|
||||||
|
final mois = date.month.toString().padLeft(2, "0");
|
||||||
|
return "$jour/$mois/${date.year}";
|
||||||
|
}
|
||||||
|
|
||||||
class _ResponsableTopBar extends StatelessWidget {
|
class _ResponsableTopBar extends StatelessWidget {
|
||||||
const _ResponsableTopBar({required this.onExit, required this.onRefresh});
|
const _ResponsableTopBar({required this.onExit, required this.onRefresh});
|
||||||
|
|
||||||
@@ -2044,6 +2093,525 @@ class _PanelEmptyState extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _VueEmpruntsTableau extends StatelessWidget {
|
||||||
|
const _VueEmpruntsTableau({
|
||||||
|
required this.items,
|
||||||
|
required this.rechercheController,
|
||||||
|
required this.statut,
|
||||||
|
required this.annee,
|
||||||
|
required this.tri,
|
||||||
|
required this.ordre,
|
||||||
|
required this.onRecherche,
|
||||||
|
required this.onStatutChanged,
|
||||||
|
required this.onAnneeChanged,
|
||||||
|
required this.onTri,
|
||||||
|
required this.onReset,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<Map<String, dynamic>> items;
|
||||||
|
final TextEditingController rechercheController;
|
||||||
|
final String? statut;
|
||||||
|
final int? annee;
|
||||||
|
final String tri;
|
||||||
|
final String ordre;
|
||||||
|
final VoidCallback onRecherche;
|
||||||
|
final ValueChanged<String?> onStatutChanged;
|
||||||
|
final ValueChanged<int?> onAnneeChanged;
|
||||||
|
final ValueChanged<String> onTri;
|
||||||
|
final VoidCallback onReset;
|
||||||
|
|
||||||
|
static const _statuts = [
|
||||||
|
"EN_ATTENTE_VALIDATION_DEPART",
|
||||||
|
"EN_COURS",
|
||||||
|
"EN_RETARD",
|
||||||
|
"EN_ATTENTE_VALIDATION_RETOUR",
|
||||||
|
"CLOTURE",
|
||||||
|
"RETOUR_NON_CONFORME",
|
||||||
|
"ANNULE",
|
||||||
|
];
|
||||||
|
|
||||||
|
static const _tris = {
|
||||||
|
"dateEmprunt": "Date d’emprunt",
|
||||||
|
"dateRetourPrevue": "Retour prévu",
|
||||||
|
"dateRetourReelle": "Retour réel",
|
||||||
|
"materiel": "Matériel",
|
||||||
|
"etudiant": "Étudiant",
|
||||||
|
"statut": "Statut",
|
||||||
|
};
|
||||||
|
|
||||||
|
int? get _sortColumnIndex {
|
||||||
|
return switch (tri) {
|
||||||
|
"materiel" => 1,
|
||||||
|
"etudiant" => 2,
|
||||||
|
"dateEmprunt" => 3,
|
||||||
|
"dateRetourPrevue" => 4,
|
||||||
|
"dateRetourReelle" => 5,
|
||||||
|
"statut" => 6,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ColoredBox(
|
||||||
|
color: _workspaceBackground,
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1440),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(28),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_entete(),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
_filtres(),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"${items.length} emprunt${items.length > 1 ? "s" : ""}",
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: EnsupColors.text2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
"Tri : ${_tris[tri]} · ${ordre == "asc" ? "croissant" : "décroissant"}",
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 11,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Expanded(
|
||||||
|
child: items.isEmpty
|
||||||
|
? const _EmptyState()
|
||||||
|
: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
if (constraints.maxWidth < 820) {
|
||||||
|
return _listeCompacte();
|
||||||
|
}
|
||||||
|
return _tableau(constraints.maxWidth);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _entete() {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: EnsupColors.cyan.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.assignment_outlined,
|
||||||
|
color: EnsupColors.blue3,
|
||||||
|
size: 21,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"Emprunts",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 29,
|
||||||
|
height: 1,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: EnsupColors.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
"Comparez les prêts, échéances et statuts du campus.",
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 13,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _filtres() {
|
||||||
|
final anneeCourante = DateTime.now().year;
|
||||||
|
final annees = List<int>.generate(8, (index) => anneeCourante - index);
|
||||||
|
|
||||||
|
return Wrap(
|
||||||
|
spacing: 10,
|
||||||
|
runSpacing: 10,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 310,
|
||||||
|
height: 48,
|
||||||
|
child: TextField(
|
||||||
|
controller: rechercheController,
|
||||||
|
textInputAction: TextInputAction.search,
|
||||||
|
onSubmitted: (_) => onRecherche(),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: "Matériel, référence ou étudiant",
|
||||||
|
prefixIcon: const Icon(Icons.search, size: 20),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
tooltip: "Lancer la recherche",
|
||||||
|
onPressed: onRecherche,
|
||||||
|
icon: const Icon(Icons.arrow_forward, size: 18),
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.white,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: const BorderSide(color: EnsupColors.line),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: const BorderSide(color: EnsupColors.line),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 220,
|
||||||
|
child: DropdownButtonFormField<String>(
|
||||||
|
key: ValueKey("statut-$statut"),
|
||||||
|
initialValue: statut ?? "",
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: "Statut",
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.white,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem<String>(
|
||||||
|
value: "",
|
||||||
|
child: Text(
|
||||||
|
"Tous les statuts",
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
..._statuts.map(
|
||||||
|
(value) => DropdownMenuItem<String>(
|
||||||
|
value: value,
|
||||||
|
child: Text(
|
||||||
|
_statusLabel(value),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
onStatutChanged(value == null || value.isEmpty ? null : value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 150,
|
||||||
|
child: DropdownButtonFormField<int>(
|
||||||
|
key: ValueKey("annee-$annee"),
|
||||||
|
initialValue: annee ?? 0,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: "Année",
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.white,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem<int>(value: 0, child: Text("Toutes")),
|
||||||
|
...annees.map(
|
||||||
|
(value) =>
|
||||||
|
DropdownMenuItem<int>(value: value, child: Text("$value")),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
onAnneeChanged(value == null || value == 0 ? null : value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 180,
|
||||||
|
child: DropdownButtonFormField<String>(
|
||||||
|
key: ValueKey("tri-$tri"),
|
||||||
|
initialValue: tri,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: "Trier par",
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.white,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: _tris.entries
|
||||||
|
.map(
|
||||||
|
(entry) => DropdownMenuItem(
|
||||||
|
value: entry.key,
|
||||||
|
child: Text(entry.value),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null && value != tri) {
|
||||||
|
onTri(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Tooltip(
|
||||||
|
message: ordre == "asc"
|
||||||
|
? "Passer en ordre décroissant"
|
||||||
|
: "Passer en ordre croissant",
|
||||||
|
child: IconButton.outlined(
|
||||||
|
onPressed: () => onTri(tri),
|
||||||
|
icon: Icon(
|
||||||
|
ordre == "asc"
|
||||||
|
? Icons.arrow_upward_rounded
|
||||||
|
: Icons.arrow_downward_rounded,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Tooltip(
|
||||||
|
message: "Réinitialiser les filtres",
|
||||||
|
child: IconButton.outlined(
|
||||||
|
onPressed: onReset,
|
||||||
|
icon: const Icon(Icons.filter_alt_off_outlined),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _tableau(double largeurDisponible) {
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border.all(color: EnsupColors.line),
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(minWidth: largeurDisponible),
|
||||||
|
child: DataTable(
|
||||||
|
sortColumnIndex: _sortColumnIndex,
|
||||||
|
sortAscending: ordre == "asc",
|
||||||
|
headingRowColor: WidgetStatePropertyAll(
|
||||||
|
EnsupColors.soft.withValues(alpha: 0.8),
|
||||||
|
),
|
||||||
|
headingTextStyle: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: EnsupColors.text2,
|
||||||
|
),
|
||||||
|
dataTextStyle: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 12,
|
||||||
|
color: EnsupColors.text,
|
||||||
|
),
|
||||||
|
columns: [
|
||||||
|
const DataColumn(label: Text("N°")),
|
||||||
|
DataColumn(
|
||||||
|
label: const Text("Matériel"),
|
||||||
|
onSort: (_, _) => onTri("materiel"),
|
||||||
|
),
|
||||||
|
DataColumn(
|
||||||
|
label: const Text("Étudiant"),
|
||||||
|
onSort: (_, _) => onTri("etudiant"),
|
||||||
|
),
|
||||||
|
DataColumn(
|
||||||
|
label: const Text("Date d’emprunt"),
|
||||||
|
onSort: (_, _) => onTri("dateEmprunt"),
|
||||||
|
),
|
||||||
|
DataColumn(
|
||||||
|
label: const Text("Retour prévu"),
|
||||||
|
onSort: (_, _) => onTri("dateRetourPrevue"),
|
||||||
|
),
|
||||||
|
DataColumn(
|
||||||
|
label: const Text("Retour réel"),
|
||||||
|
onSort: (_, _) => onTri("dateRetourReelle"),
|
||||||
|
),
|
||||||
|
DataColumn(
|
||||||
|
label: const Text("Statut"),
|
||||||
|
onSort: (_, _) => onTri("statut"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
rows: items.map(_ligneTableau).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DataRow _ligneTableau(Map<String, dynamic> item) {
|
||||||
|
final materiel = Map<String, dynamic>.from(item["materiel"] as Map);
|
||||||
|
final etudiant = Map<String, dynamic>.from(item["etudiant"] as Map);
|
||||||
|
return DataRow(
|
||||||
|
cells: [
|
||||||
|
DataCell(
|
||||||
|
Text(
|
||||||
|
"#${item["id"]}",
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DataCell(
|
||||||
|
SizedBox(
|
||||||
|
width: 190,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"${materiel["nom"]}",
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
"${materiel["reference"]}",
|
||||||
|
style: const TextStyle(color: EnsupColors.muted),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DataCell(
|
||||||
|
SizedBox(
|
||||||
|
width: 170,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"${etudiant["prenom"]} ${etudiant["nom"]}",
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
"${etudiant["classe"] ?? "-"}",
|
||||||
|
style: const TextStyle(color: EnsupColors.muted),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DataCell(Text(_dateComplete(item["dateEmprunt"]))),
|
||||||
|
DataCell(Text(_dateComplete(item["dateRetourPrevue"]))),
|
||||||
|
DataCell(Text(_dateComplete(item["dateRetourReelle"]))),
|
||||||
|
DataCell(_StatusBadge("${item["statut"]}")),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _listeCompacte() {
|
||||||
|
return ListView.separated(
|
||||||
|
itemCount: items.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
final materiel = Map<String, dynamic>.from(item["materiel"] as Map);
|
||||||
|
final etudiant = Map<String, dynamic>.from(item["etudiant"] as Map);
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: EnsupColors.line),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
"${materiel["nom"]}",
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_StatusBadge("${item["statut"]}"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
"#${item["id"]} · ${materiel["reference"]}",
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 12,
|
||||||
|
color: EnsupColors.muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 20, color: EnsupColors.line),
|
||||||
|
_detailCompact(
|
||||||
|
Icons.person_outline,
|
||||||
|
"${etudiant["prenom"]} ${etudiant["nom"]}",
|
||||||
|
),
|
||||||
|
_detailCompact(
|
||||||
|
Icons.calendar_today_outlined,
|
||||||
|
"Emprunt ${_dateComplete(item["dateEmprunt"])} · prévu ${_dateComplete(item["dateRetourPrevue"])}",
|
||||||
|
),
|
||||||
|
_detailCompact(
|
||||||
|
Icons.assignment_turned_in_outlined,
|
||||||
|
"Retour réel ${_dateComplete(item["dateRetourReelle"])}",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _detailCompact(IconData icon, String texte) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 5),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 15, color: EnsupColors.muted),
|
||||||
|
const SizedBox(width: 7),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
texte,
|
||||||
|
style: GoogleFonts.titilliumWeb(
|
||||||
|
fontSize: 12,
|
||||||
|
color: EnsupColors.text2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _VueListe extends StatefulWidget {
|
class _VueListe extends StatefulWidget {
|
||||||
const _VueListe({
|
const _VueListe({
|
||||||
required this.titre,
|
required this.titre,
|
||||||
|
|||||||
@@ -9,8 +9,24 @@ class ResponsableService {
|
|||||||
return Map<String, dynamic>.from(reponse["data"] as Map);
|
return Map<String, dynamic>.from(reponse["data"] as Map);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<List<Map<String, dynamic>>> emprunts() =>
|
static Future<List<Map<String, dynamic>>> emprunts({
|
||||||
_liste("/responsable/emprunts");
|
String? statut,
|
||||||
|
int? annee,
|
||||||
|
String? recherche,
|
||||||
|
String tri = "dateEmprunt",
|
||||||
|
String ordre = "desc",
|
||||||
|
}) {
|
||||||
|
final parametres = <String, String>{
|
||||||
|
"tri": tri,
|
||||||
|
"ordre": ordre,
|
||||||
|
"statut": ?statut,
|
||||||
|
if (annee != null) "annee": "$annee",
|
||||||
|
if (recherche != null && recherche.trim().isNotEmpty)
|
||||||
|
"q": recherche.trim(),
|
||||||
|
};
|
||||||
|
final query = Uri(queryParameters: parametres).query;
|
||||||
|
return _liste("/responsable/emprunts?$query");
|
||||||
|
}
|
||||||
|
|
||||||
static Future<List<Map<String, dynamic>>> ecarts() =>
|
static Future<List<Map<String, dynamic>>> ecarts() =>
|
||||||
_liste("/responsable/ecarts");
|
_liste("/responsable/ecarts");
|
||||||
|
|||||||
@@ -476,6 +476,22 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
|
|||||||
- `CLAUDE.md`, `CONTEXT.md`, les règles métier, `TODO.md`, le README et la maquette textuelle sont synchronisés avant toute modification du code.
|
- `CLAUDE.md`, `CONTEXT.md`, les règles métier, `TODO.md`, le README et la maquette textuelle sont synchronisés avant toute modification du code.
|
||||||
- Un diagramme Mermaid devient la source de vérité du parcours ; le PNG contradictoire est conservé uniquement comme historique.
|
- Un diagramme Mermaid devient la source de vérité du parcours ; le PNG contradictoire est conservé uniquement comme historique.
|
||||||
|
|
||||||
|
### Étape 49 — Messages applicatifs visibles
|
||||||
|
- Les `SnackBar` Flutter noir et blanc sont remplacés par un composant global affiché en haut de l'écran.
|
||||||
|
- Quatre niveaux visuels sont distingués : information, succès, alerte et erreur.
|
||||||
|
- Chaque message dispose d'une icône, d'un titre, d'un texte explicite, d'une fermeture manuelle et d'une disparition automatique.
|
||||||
|
- Le composant est utilisé dans les parcours étudiant, la connexion et l'espace responsable.
|
||||||
|
- Vérification réussie avec `dart analyze`.
|
||||||
|
- Commit : `4ee593c`.
|
||||||
|
|
||||||
|
### Étape 50 — Tableau des emprunts responsable
|
||||||
|
- La liste des emprunts responsable devient un tableau opérationnel sur ordinateur avec une colonne par information : numéro, matériel, étudiant, dates et statut.
|
||||||
|
- Les en-têtes permettent de trier par matériel, étudiant, date d'emprunt, retour prévu, retour réel et statut.
|
||||||
|
- Des filtres serveur sont ajoutés pour l'année, le statut et la recherche par matériel, référence ou étudiant.
|
||||||
|
- Le tableau devient une liste compacte sur petit écran afin d'éviter tout débordement horizontal.
|
||||||
|
- Les requêtes restent filtrées par campus côté API.
|
||||||
|
- Vérifications réussies : build et lint backend, analyse Dart, tris et recherches testés sur SQL Server.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Dernière mise à jour : 2026-07-24 — Confirmation responsable uniquement en cas d'écart déclaré.*
|
*Dernière mise à jour : 2026-07-24 — Tableau de supervision des emprunts enrichi.*
|
||||||
|
|||||||
Reference in New Issue
Block a user