From ec36349bada1803b41af92056afa24a2c0a24592 Mon Sep 17 00:00:00 2001 From: SaidSoighiri94 Date: Fri, 24 Jul 2026 15:22:58 +0200 Subject: [PATCH] feat(responsable): add material stock table --- .../src/controllers/responsable.controller.ts | 5 + .../repositories/responsable.repository.ts | 22 +- .../src/services/responsable.service.ts | 28 + .../lib/screens/responsable_screen.dart | 612 +++++++++++++++++- .../lib/services/responsable_service.dart | 20 +- review.md | 11 +- 6 files changed, 672 insertions(+), 26 deletions(-) diff --git a/eme-backend/src/controllers/responsable.controller.ts b/eme-backend/src/controllers/responsable.controller.ts index 7593ca4..bbf6190 100644 --- a/eme-backend/src/controllers/responsable.controller.ts +++ b/eme-backend/src/controllers/responsable.controller.ts @@ -25,6 +25,7 @@ import { parseStatutMateriel, parseStatutEmprunt, parseTriEmprunt, + parseTriMateriel, } from '../services/responsable.service'; import { toAnomalieResponsableResponse, @@ -141,11 +142,15 @@ export async function getMateriels(req: Request, res: Response): Promise { const statut = parseStatutMateriel(req.query.statut); const categorieId = parseCategorieId(req.query.categorieId); const recherche = typeof req.query.q === 'string' ? req.query.q : undefined; + const tri = parseTriMateriel(req.query.tri); + const ordre = parseOrdreTri(req.query.ordre); const materiels = await listerMaterielsResponsable(user.roleCode, user.campusId, { statut, categorieId, recherche, + tri, + ordre, }); res.json({ data: materiels.map(toMaterielResponsableResponse) }); } diff --git a/eme-backend/src/repositories/responsable.repository.ts b/eme-backend/src/repositories/responsable.repository.ts index 765a021..e4086b9 100644 --- a/eme-backend/src/repositories/responsable.repository.ts +++ b/eme-backend/src/repositories/responsable.repository.ts @@ -89,6 +89,8 @@ export interface MaterielResponsableFiltres { statut?: string; categorieId?: number; recherche?: string; + tri?: 'nom' | 'reference' | 'categorie' | 'marque' | 'etatGeneral' | 'statut'; + ordre?: 'asc' | 'desc'; } export interface AnomalieResponsableFiltres { @@ -372,6 +374,24 @@ export function findMaterielsResponsable( campusId: number, filtres: MaterielResponsableFiltres, ): Promise { + const ordre = filtres.ordre ?? 'asc'; + const orderBy: Prisma.MaterielOrderByWithRelationInput[] = (() => { + switch (filtres.tri) { + case 'reference': + return [{ reference: ordre }, { id: 'asc' }]; + case 'categorie': + return [{ categorie: { nom: ordre } }, { nom: 'asc' }, { id: 'asc' }]; + case 'marque': + return [{ marque: ordre }, { modele: ordre }, { nom: 'asc' }, { id: 'asc' }]; + case 'etatGeneral': + return [{ etatGeneral: ordre }, { nom: 'asc' }, { id: 'asc' }]; + case 'statut': + return [{ statut: ordre }, { nom: 'asc' }, { id: 'asc' }]; + default: + return [{ nom: ordre }, { id: 'asc' }]; + } + })(); + return prisma.materiel.findMany({ where: { campusId, @@ -393,7 +413,7 @@ export function findMaterielsResponsable( categorie: true, accessoires: { include: { accessoire: true } }, }, - orderBy: { nom: 'asc' }, + orderBy, }); } diff --git a/eme-backend/src/services/responsable.service.ts b/eme-backend/src/services/responsable.service.ts index 3a06d24..7c83377 100644 --- a/eme-backend/src/services/responsable.service.ts +++ b/eme-backend/src/services/responsable.service.ts @@ -65,8 +65,18 @@ export interface ListerMaterielsResponsableFiltres { statut?: StatutMateriel; categorieId?: number; recherche?: string; + tri?: TriMaterielResponsable; + ordre?: OrdreTri; } +export type TriMaterielResponsable = + | 'nom' + | 'reference' + | 'categorie' + | 'marque' + | 'etatGeneral' + | 'statut'; + export interface ListerAnomaliesResponsableFiltres { statut?: StatutAnomalie; type?: string; @@ -206,6 +216,24 @@ export function parseStatutMateriel(value: unknown): StatutMateriel | undefined return value as StatutMateriel; } +export function parseTriMateriel(value: unknown): TriMaterielResponsable | undefined { + if (value === undefined) { + return undefined; + } + const valeurs: readonly TriMaterielResponsable[] = [ + 'nom', + 'reference', + 'categorie', + 'marque', + 'etatGeneral', + 'statut', + ]; + if (typeof value !== 'string' || !valeurs.includes(value as TriMaterielResponsable)) { + throw new AppError(400, 'tri invalide'); + } + return value as TriMaterielResponsable; +} + export function parseStatutAnomalie(value: unknown): StatutAnomalie | undefined { if (value === undefined) { return undefined; diff --git a/eme-frontend/lib/screens/responsable_screen.dart b/eme-frontend/lib/screens/responsable_screen.dart index 93057fb..9e028c4 100644 --- a/eme-frontend/lib/screens/responsable_screen.dart +++ b/eme-frontend/lib/screens/responsable_screen.dart @@ -20,12 +20,19 @@ class ResponsableScreen extends StatefulWidget { class _ResponsableScreenState extends State { final TextEditingController _rechercheEmpruntsController = TextEditingController(); + final TextEditingController _rechercheMaterielsController = + TextEditingController(); + final Map _categoriesMateriels = {}; late Future<_ResponsableData> _future = _charger(); int _onglet = 0; String? _statutEmprunts; int? _anneeEmprunts; String _triEmprunts = "dateEmprunt"; String _ordreEmprunts = "desc"; + String? _statutMateriels; + int? _categorieMateriels; + String _triMateriels = "nom"; + String _ordreMateriels = "asc"; bool _transitionEnCours = false; bool _decisionEnCours = false; bool _exportEnCours = false; @@ -40,7 +47,17 @@ class _ResponsableScreenState extends State { tri: _triEmprunts, ordre: _ordreEmprunts, ); - final materiels = await ResponsableService.materiels(); + final materiels = await ResponsableService.materiels( + statut: _statutMateriels, + categorieId: _categorieMateriels, + recherche: _rechercheMaterielsController.text, + tri: _triMateriels, + ordre: _ordreMateriels, + ); + for (final materiel in materiels) { + final categorie = Map.from(materiel["categorie"] as Map); + _categoriesMateriels[categorie["id"] as int] = categorie["nom"] as String; + } final anomalies = await ResponsableService.anomalies(); final notifications = await ResponsableService.notifications(); final historique = await ResponsableService.historique(); @@ -91,9 +108,41 @@ class _ResponsableScreenState extends State { }); } + void _trierMateriels(String tri) { + setState(() { + if (_triMateriels == tri) { + _ordreMateriels = _ordreMateriels == "asc" ? "desc" : "asc"; + } else { + _triMateriels = tri; + _ordreMateriels = "asc"; + } + _future = _charger(); + }); + } + + void _filtrerMateriels({String? statut, int? categorieId}) { + setState(() { + _statutMateriels = statut; + _categorieMateriels = categorieId; + _future = _charger(); + }); + } + + void _reinitialiserFiltresMateriels() { + _rechercheMaterielsController.clear(); + setState(() { + _statutMateriels = null; + _categorieMateriels = null; + _triMateriels = "nom"; + _ordreMateriels = "asc"; + _future = _charger(); + }); + } + @override void dispose() { _rechercheEmpruntsController.dispose(); + _rechercheMaterielsController.dispose(); super.dispose(); } @@ -298,17 +347,23 @@ class _ResponsableScreenState extends State { onTri: _trierEmprunts, onReset: _reinitialiserFiltresEmprunts, ), - _VueListe( - titre: "Stock matériel", - description: - "Consultez la disponibilité et l’état du parc de votre campus.", - icon: Icons.inventory_2_outlined, + _VueMaterielsTableau( items: data.materiels, - builder: _materielTile, - searchableText: (item) { - final categorie = Map.from(item["categorie"] as Map); - return "${item["nom"]} ${item["reference"]} ${item["statut"]} ${categorie["nom"]}"; + categories: _categoriesMateriels.entries.toList(), + rechercheController: _rechercheMaterielsController, + statut: _statutMateriels, + categorieId: _categorieMateriels, + tri: _triMateriels, + ordre: _ordreMateriels, + onRecherche: _rafraichir, + onStatutChanged: (statut) { + _filtrerMateriels(statut: statut, categorieId: _categorieMateriels); }, + onCategorieChanged: (categorieId) { + _filtrerMateriels(statut: _statutMateriels, categorieId: categorieId); + }, + onTri: _trierMateriels, + onReset: _reinitialiserFiltresMateriels, ), _VueListe( titre: "Anomalies", @@ -412,18 +467,6 @@ class _ResponsableScreenState extends State { ); } - Widget _materielTile(Map item) { - final categorie = Map.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 item) { final materiel = Map.from(item["materiel"] as Map); final prochain = _prochainStatut(item["statut"] as String?); @@ -2612,6 +2655,531 @@ class _VueEmpruntsTableau extends StatelessWidget { } } +class _VueMaterielsTableau extends StatelessWidget { + const _VueMaterielsTableau({ + required this.items, + required this.categories, + required this.rechercheController, + required this.statut, + required this.categorieId, + required this.tri, + required this.ordre, + required this.onRecherche, + required this.onStatutChanged, + required this.onCategorieChanged, + required this.onTri, + required this.onReset, + }); + + final List> items; + final List> categories; + final TextEditingController rechercheController; + final String? statut; + final int? categorieId; + final String tri; + final String ordre; + final VoidCallback onRecherche; + final ValueChanged onStatutChanged; + final ValueChanged onCategorieChanged; + final ValueChanged onTri; + final VoidCallback onReset; + + static const _statuts = [ + "DISPONIBLE", + "RESERVE", + "EMPRUNTE", + "NON_CONFORME", + "DETERIORE", + "MAINTENANCE", + "INDISPONIBLE", + ]; + + static const _tris = { + "nom": "Matériel", + "reference": "Référence", + "categorie": "Catégorie", + "marque": "Marque", + "etatGeneral": "État", + "statut": "Statut", + }; + + int? get _sortColumnIndex { + return switch (tri) { + "reference" => 0, + "nom" => 1, + "categorie" => 2, + "marque" => 3, + "etatGeneral" => 4, + "statut" => 5, + _ => 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} matériel${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.inventory_2_outlined, + color: EnsupColors.blue3, + size: 21, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Stock matériel", + style: GoogleFonts.darkerGrotesque( + fontSize: 29, + height: 1, + fontWeight: FontWeight.w900, + color: EnsupColors.text, + ), + ), + Text( + "Comparez la disponibilité et l’état du parc de votre campus.", + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.titilliumWeb( + fontSize: 13, + color: EnsupColors.muted, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _filtres() { + final categoriesTriees = [...categories] + ..sort((a, b) => a.value.toLowerCase().compareTo(b.value.toLowerCase())); + + 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, marque ou modèle", + 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: 200, + child: DropdownButtonFormField( + key: ValueKey("statut-materiel-$statut"), + initialValue: statut ?? "", + isExpanded: true, + decoration: const InputDecoration( + labelText: "Statut", + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder(), + ), + items: [ + const DropdownMenuItem( + value: "", + child: Text( + "Tous les statuts", + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ..._statuts.map( + (value) => DropdownMenuItem( + value: value, + child: Text( + _statusLabel(value), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + onChanged: (value) { + onStatutChanged(value == null || value.isEmpty ? null : value); + }, + ), + ), + SizedBox( + width: 210, + child: DropdownButtonFormField( + key: ValueKey("categorie-materiel-$categorieId"), + initialValue: categorieId ?? 0, + isExpanded: true, + decoration: const InputDecoration( + labelText: "Catégorie", + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder(), + ), + items: [ + const DropdownMenuItem( + value: 0, + child: Text( + "Toutes les catégories", + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ...categoriesTriees.map( + (categorie) => DropdownMenuItem( + value: categorie.key, + child: Text( + categorie.value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + onChanged: (value) { + onCategorieChanged(value == null || value == 0 ? null : value); + }, + ), + ), + SizedBox( + width: 180, + child: DropdownButtonFormField( + key: ValueKey("tri-materiel-$tri"), + initialValue: tri, + isExpanded: true, + 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, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ) + .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: [ + DataColumn( + label: const Text("Référence"), + onSort: (_, _) => onTri("reference"), + ), + DataColumn( + label: const Text("Matériel"), + onSort: (_, _) => onTri("nom"), + ), + DataColumn( + label: const Text("Catégorie"), + onSort: (_, _) => onTri("categorie"), + ), + DataColumn( + label: const Text("Marque / modèle"), + onSort: (_, _) => onTri("marque"), + ), + DataColumn( + label: const Text("État"), + onSort: (_, _) => onTri("etatGeneral"), + ), + DataColumn( + label: const Text("Statut"), + onSort: (_, _) => onTri("statut"), + ), + ], + rows: items.map(_ligneTableau).toList(), + ), + ), + ), + ), + ), + ); + } + + DataRow _ligneTableau(Map item) { + final categorie = Map.from(item["categorie"] as Map); + return DataRow( + cells: [ + DataCell( + Text( + "${item["reference"]}", + style: const TextStyle(fontWeight: FontWeight.w700), + ), + ), + DataCell( + SizedBox( + width: 190, + child: Text( + "${item["nom"]}", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + ), + ), + DataCell( + SizedBox( + width: 150, + child: Text( + "${categorie["nom"]}", + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + DataCell( + SizedBox( + width: 170, + child: Text( + "${item["marque"] ?? "-"} ${item["modele"] ?? ""}".trim(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + DataCell(Text(_statusLabel("${item["etatGeneral"]}"))), + 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 categorie = Map.from(item["categorie"] 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( + "${item["nom"]}", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.darkerGrotesque( + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(width: 8), + _StatusBadge("${item["statut"]}"), + ], + ), + Text( + "${item["reference"]}", + style: GoogleFonts.titilliumWeb( + fontSize: 12, + color: EnsupColors.muted, + ), + ), + const Divider(height: 20, color: EnsupColors.line), + _detailCompact(Icons.category_outlined, "${categorie["nom"]}"), + _detailCompact( + Icons.precision_manufacturing_outlined, + "${item["marque"] ?? "-"} ${item["modele"] ?? ""}".trim(), + ), + _detailCompact( + Icons.fact_check_outlined, + "État : ${_statusLabel("${item["etatGeneral"]}")}", + ), + ], + ), + ); + }, + ); + } + + 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, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.titilliumWeb( + fontSize: 12, + color: EnsupColors.text2, + ), + ), + ), + ], + ), + ); + } +} + class _VueListe extends StatefulWidget { const _VueListe({ required this.titre, diff --git a/eme-frontend/lib/services/responsable_service.dart b/eme-frontend/lib/services/responsable_service.dart index 3440c33..b049010 100644 --- a/eme-frontend/lib/services/responsable_service.dart +++ b/eme-frontend/lib/services/responsable_service.dart @@ -31,8 +31,24 @@ class ResponsableService { static Future>> ecarts() => _liste("/responsable/ecarts"); - static Future>> materiels() => - _liste("/responsable/materiels"); + static Future>> materiels({ + String? statut, + int? categorieId, + String? recherche, + String tri = "nom", + String ordre = "asc", + }) { + final parametres = { + "tri": tri, + "ordre": ordre, + "statut": ?statut, + if (categorieId != null) "categorieId": "$categorieId", + if (recherche != null && recherche.trim().isNotEmpty) + "q": recherche.trim(), + }; + final query = Uri(queryParameters: parametres).query; + return _liste("/responsable/materiels?$query"); + } static Future>> anomalies() => _liste("/responsable/anomalies"); diff --git a/review.md b/review.md index 95ab479..6984a80 100644 --- a/review.md +++ b/review.md @@ -489,9 +489,18 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y - 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 menus déroulants contraignent les libellés longs afin d'éviter les débordements Flutter. - 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. +### Étape 51 — Tableau du stock matériel responsable +- La liste du stock devient un tableau opérationnel sur ordinateur et conserve une vue compacte sur petit écran. +- Les colonnes présentent la référence, le matériel, la catégorie, la marque et le modèle, l'état général et le statut. +- La recherche et les filtres serveur permettent de cibler un statut ou une catégorie du campus. +- Le tri serveur accepte le matériel, la référence, la catégorie, la marque, l'état général et le statut, dans les deux ordres. +- 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. + --- -*Dernière mise à jour : 2026-07-24 — Tableau de supervision des emprunts enrichi.* +*Dernière mise à jour : 2026-07-24 — Tableau de supervision du stock matériel enrichi.*