feat(responsable): improve loan supervision table

This commit is contained in:
SaidSoighiri94
2026-07-24 15:00:16 +02:00
parent 4ee593c8d4
commit 5121cac657
6 changed files with 748 additions and 29 deletions
@@ -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;
+592 -24
View File
@@ -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 demprunt",
"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("")),
DataColumn(
label: const Text("Matériel"),
onSort: (_, _) => onTri("materiel"),
),
DataColumn(
label: const Text("Étudiant"),
onSort: (_, _) => onTri("etudiant"),
),
DataColumn(
label: const Text("Date demprunt"),
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");
+17 -1
View File
@@ -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.*