feat(responsable): add material stock table

This commit is contained in:
SaidSoighiri94
2026-07-24 15:22:58 +02:00
parent 5121cac657
commit ec36349bad
6 changed files with 672 additions and 26 deletions
@@ -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<void> {
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) });
}
@@ -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<MaterielResponsable[]> {
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,
});
}
@@ -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;
+590 -22
View File
@@ -20,12 +20,19 @@ class ResponsableScreen extends StatefulWidget {
class _ResponsableScreenState extends State<ResponsableScreen> {
final TextEditingController _rechercheEmpruntsController =
TextEditingController();
final TextEditingController _rechercheMaterielsController =
TextEditingController();
final Map<int, String> _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<ResponsableScreen> {
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<String, dynamic>.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<ResponsableScreen> {
});
}
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<ResponsableScreen> {
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<String, dynamic>.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<ResponsableScreen> {
);
}
Widget _materielTile(Map<String, dynamic> item) {
final categorie = Map<String, dynamic>.from(item["categorie"] as Map);
return _InfoTile(
icon: Icons.inventory_2_outlined,
title: "${item["nom"]}",
subtitle:
"${item["reference"]} · ${categorie["nom"]} · ${item["etatGeneral"]}",
meta: "${item["marque"]} ${item["modele"]}",
trailing: _StatusBadge("${item["statut"]}"),
);
}
Widget _anomalieTile(Map<String, dynamic> item) {
final materiel = Map<String, dynamic>.from(item["materiel"] as Map);
final prochain = _prochainStatut(item["statut"] as String?);
@@ -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<Map<String, dynamic>> items;
final List<MapEntry<int, String>> categories;
final TextEditingController rechercheController;
final String? statut;
final int? categorieId;
final String tri;
final String ordre;
final VoidCallback onRecherche;
final ValueChanged<String?> onStatutChanged;
final ValueChanged<int?> onCategorieChanged;
final ValueChanged<String> 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<String>(
key: ValueKey("statut-materiel-$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: 210,
child: DropdownButtonFormField<int>(
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<int>(
value: 0,
child: Text(
"Toutes les catégories",
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
...categoriesTriees.map(
(categorie) => DropdownMenuItem<int>(
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<String>(
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<String>(
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<String, dynamic> item) {
final categorie = Map<String, dynamic>.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<String, dynamic>.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,
@@ -31,8 +31,24 @@ class ResponsableService {
static Future<List<Map<String, dynamic>>> ecarts() =>
_liste("/responsable/ecarts");
static Future<List<Map<String, dynamic>>> materiels() =>
_liste("/responsable/materiels");
static Future<List<Map<String, dynamic>>> materiels({
String? statut,
int? categorieId,
String? recherche,
String tri = "nom",
String ordre = "asc",
}) {
final parametres = <String, String>{
"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<List<Map<String, dynamic>>> anomalies() =>
_liste("/responsable/anomalies");
+10 -1
View File
@@ -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.*