feat(materiel): connect catalogue and detail to api

This commit is contained in:
SaidSoighiri94
2026-07-07 13:07:47 +02:00
parent 90d5a65f29
commit 9564dd1ade
15 changed files with 368 additions and 101 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"build": "tsc -p tsconfig.build.json", "build": "tsc -p tsconfig.build.json",
"start": "node dist/app.js", "start": "node dist/app.js",
"dev": "nodemon --exec ts-node src/app.ts", "dev": "nodemon --exec ts-node --files src/app.ts",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio", "prisma:studio": "prisma studio",
+3 -1
View File
@@ -10,7 +10,9 @@ import { apiRouter } from './routes';
const app = express(); const app = express();
app.use(cors({ origin: env.frontendUrl })); // En développement, on accepte toute origine locale (le front Flutter tourne sur
// un port variable) ; en production, seule l'origine du frontend est autorisée.
app.use(cors({ origin: env.nodeEnv === 'development' ? true : env.frontendUrl }));
app.use(express.json()); app.use(express.json());
app.use(requestLogger); app.use(requestLogger);
@@ -1,7 +1,7 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { AppError } from '../errors/app-error'; import { AppError } from '../errors/app-error';
import { listerCatalogue } from '../services/materiel.service'; import { listerCatalogue, obtenirDetailMateriel } from '../services/materiel.service';
import { toMaterielResponse } from '../dtos/materiel.dto'; import { toMaterielDetailResponse, toMaterielResponse } from '../dtos/materiel.dto';
function parseCategorieId(value: unknown): number | undefined { function parseCategorieId(value: unknown): number | undefined {
if (value === undefined) { if (value === undefined) {
@@ -16,6 +16,18 @@ function parseCategorieId(value: unknown): number | undefined {
return parsed; return parsed;
} }
function parseId(value: unknown): number {
if (typeof value !== 'string') {
throw new AppError(400, 'id invalide');
}
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new AppError(400, 'id invalide');
}
return parsed;
}
export async function getCatalogue(req: Request, res: Response): Promise<void> { export async function getCatalogue(req: Request, res: Response): Promise<void> {
const user = req.user; const user = req.user;
if (!user) { if (!user) {
@@ -28,3 +40,13 @@ export async function getCatalogue(req: Request, res: Response): Promise<void> {
const materiels = await listerCatalogue(user.campusId, { categorieId, recherche }); const materiels = await listerCatalogue(user.campusId, { categorieId, recherche });
res.json({ data: materiels.map(toMaterielResponse) }); res.json({ data: materiels.map(toMaterielResponse) });
} }
export async function getMaterielDetail(req: Request, res: Response): Promise<void> {
const user = req.user;
if (!user) {
throw new AppError(401, 'Authentification requise');
}
const materiel = await obtenirDetailMateriel(parseId(req.params.id), user.campusId);
res.json({ data: toMaterielDetailResponse(materiel) });
}
+22 -1
View File
@@ -1,4 +1,4 @@
import { MaterielAvecCategorie } from '../repositories/materiel.repository'; import { MaterielAvecCategorie, MaterielDetail } from '../repositories/materiel.repository';
export interface CategorieResponse { export interface CategorieResponse {
id: number; id: number;
@@ -16,6 +16,15 @@ export interface MaterielResponse {
categorie: CategorieResponse; categorie: CategorieResponse;
} }
export interface MaterielDetailResponse extends MaterielResponse {
campus: {
id: number;
nom: string;
ville: string;
};
accessoires: string[];
}
export function toMaterielResponse(materiel: MaterielAvecCategorie): MaterielResponse { export function toMaterielResponse(materiel: MaterielAvecCategorie): MaterielResponse {
return { return {
id: materiel.id, id: materiel.id,
@@ -31,3 +40,15 @@ export function toMaterielResponse(materiel: MaterielAvecCategorie): MaterielRes
}, },
}; };
} }
export function toMaterielDetailResponse(materiel: MaterielDetail): MaterielDetailResponse {
return {
...toMaterielResponse(materiel),
campus: {
id: materiel.campus.id,
nom: materiel.campus.nom,
ville: materiel.campus.ville,
},
accessoires: materiel.accessoires.map((liaison) => liaison.accessoire.nom),
};
}
@@ -2,6 +2,13 @@ import { Materiel, Prisma } from '@prisma/client';
import { prisma } from '../db/prisma'; import { prisma } from '../db/prisma';
export type MaterielAvecCategorie = Prisma.MaterielGetPayload<{ include: { categorie: true } }>; export type MaterielAvecCategorie = Prisma.MaterielGetPayload<{ include: { categorie: true } }>;
export type MaterielDetail = Prisma.MaterielGetPayload<{
include: {
categorie: true;
campus: true;
accessoires: { include: { accessoire: true } };
};
}>;
export interface CatalogueFiltres { export interface CatalogueFiltres {
categorieId?: number; categorieId?: number;
@@ -39,3 +46,17 @@ export function findDisponiblesParCampus(
export function findById(id: number): Promise<Materiel | null> { export function findById(id: number): Promise<Materiel | null> {
return prisma.materiel.findUnique({ where: { id } }); return prisma.materiel.findUnique({ where: { id } });
} }
export function findDetailParCampus(id: number, campusId: number): Promise<MaterielDetail | null> {
return prisma.materiel.findFirst({
where: { id, campusId, actif: true },
include: {
categorie: true,
campus: true,
accessoires: {
include: { accessoire: true },
orderBy: { accessoire: { nom: 'asc' } },
},
},
});
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import { getCatalogue } from '../controllers/materiel.controller'; import { getCatalogue, getMaterielDetail } from '../controllers/materiel.controller';
export const materielRoutes: Router = Router(); export const materielRoutes: Router = Router();
materielRoutes.get('/', getCatalogue); materielRoutes.get('/', getCatalogue);
materielRoutes.get('/:id', getMaterielDetail);
@@ -1,8 +1,11 @@
import { import {
findDisponiblesParCampus, findDisponiblesParCampus,
findDetailParCampus,
CatalogueFiltres, CatalogueFiltres,
MaterielDetail,
MaterielAvecCategorie, MaterielAvecCategorie,
} from '../repositories/materiel.repository'; } from '../repositories/materiel.repository';
import { AppError } from '../errors/app-error';
/* RG10 : après identification, seuls les matériels disponibles du campus de /* RG10 : après identification, seuls les matériels disponibles du campus de
l'étudiant sont affichés. */ l'étudiant sont affichés. */
@@ -12,3 +15,11 @@ export function listerCatalogue(
): Promise<MaterielAvecCategorie[]> { ): Promise<MaterielAvecCategorie[]> {
return findDisponiblesParCampus(campusId, filtres); return findDisponiblesParCampus(campusId, filtres);
} }
export async function obtenirDetailMateriel(id: number, campusId: number): Promise<MaterielDetail> {
const materiel = await findDetailParCampus(id, campusId);
if (!materiel) {
throw new AppError(404, 'Materiel introuvable');
}
return materiel;
}
@@ -4,6 +4,7 @@ import "package:flutter/material.dart";
/// (données statiques pour l'instant ; sera alimenté par l'API plus tard). /// (données statiques pour l'instant ; sera alimenté par l'API plus tard).
class MaterielItem { class MaterielItem {
const MaterielItem({ const MaterielItem({
required this.id,
required this.nom, required this.nom,
required this.categorie, required this.categorie,
required this.marque, required this.marque,
@@ -16,6 +17,28 @@ class MaterielItem {
required this.icon, required this.icon,
}); });
/// Construit un MaterielItem depuis la réponse JSON du catalogue.
/// Les champs non fournis par cet endpoint (accessoires, icône) sont complétés.
factory MaterielItem.fromJson(Map<String, dynamic> json) {
final categorie = (json["categorie"] as Map<String, dynamic>)["nom"] as String;
final campus = json["campus"] as Map<String, dynamic>?;
final accessoires = json["accessoires"] as List<dynamic>?;
return MaterielItem(
id: json["id"] as int,
nom: json["nom"] as String,
categorie: categorie,
marque: json["marque"] as String,
modele: json["modele"] as String,
reference: json["reference"] as String,
campus: campus == null ? "Saint-Christophe · Cergy" : "${campus["nom"]} · ${campus["ville"]}",
etatGeneral: json["etatGeneral"] as String,
disponible: json["statut"] == "DISPONIBLE",
accessoires: accessoires == null ? const <String>[] : accessoires.cast<String>(),
icon: iconePourCategorie(categorie),
);
}
final int id;
final String nom; final String nom;
final String categorie; final String categorie;
final String marque; final String marque;
@@ -27,3 +50,23 @@ class MaterielItem {
final List<String> accessoires; final List<String> accessoires;
final IconData icon; final IconData icon;
} }
/// Déduit une icône à partir du nom de catégorie renvoyé par l'API.
IconData iconePourCategorie(String categorie) {
switch (categorie) {
case "Ordinateur portable":
return Icons.laptop_mac;
case "Vidéoprojecteur":
return Icons.videocam_outlined;
case "Tablette":
return Icons.tablet_mac;
case "Caméra":
return Icons.photo_camera_outlined;
case "Périphérique audio":
return Icons.headphones;
case "Câble / Adaptateur":
return Icons.cable;
default:
return Icons.inventory_2_outlined;
}
}
+146 -93
View File
@@ -2,12 +2,14 @@ import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.dart"; import "package:google_fonts/google_fonts.dart";
import "../theme/ensup_colors.dart"; import "../theme/ensup_colors.dart";
import "../models/materiel_item.dart"; import "../models/materiel_item.dart";
import "../services/materiel_service.dart";
import "../widgets/ensup_top_bar.dart"; import "../widgets/ensup_top_bar.dart";
import "../widgets/brand_corners.dart"; import "../widgets/brand_corners.dart";
import "../widgets/materiel_card.dart"; import "../widgets/materiel_card.dart";
import "detail_screen.dart"; import "detail_screen.dart";
/// Catalogue matériel : recherche, filtres par catégorie et liste de matériels. /// Catalogue matériel : recherche, filtres par catégorie et liste chargée
/// depuis l'API.
class CatalogueScreen extends StatefulWidget { class CatalogueScreen extends StatefulWidget {
const CatalogueScreen({super.key}); const CatalogueScreen({super.key});
@@ -15,82 +17,62 @@ class CatalogueScreen extends StatefulWidget {
State<CatalogueScreen> createState() => _CatalogueScreenState(); State<CatalogueScreen> createState() => _CatalogueScreenState();
} }
const _materiels = <MaterielItem>[
MaterielItem(
nom: "PC Dell Latitude 5420",
categorie: "Ordinateur",
marque: "Dell",
modele: "Latitude 5420",
reference: "ENS-PC-001",
campus: "Paris · Ensitech",
etatGeneral: "Bon état",
disponible: true,
accessoires: ["Chargeur 65W", "Souris sans fil", "Housse de protection", "Câble HDMI"],
icon: Icons.laptop_mac,
),
MaterielItem(
nom: "Vidéoprojecteur Epson EB-X51",
categorie: "Projection",
marque: "Epson",
modele: "EB-X51",
reference: "ENS-VP-003",
campus: "Paris · Ensitech",
etatGeneral: "Bon état",
disponible: true,
accessoires: ["Télécommande", "Câble HDMI", "Câble VGA"],
icon: Icons.videocam_outlined,
),
MaterielItem(
nom: "Chargeur USB-C 65W",
categorie: "Accessoire",
marque: "Anker",
modele: "PowerPort III",
reference: "ENS-CH-012",
campus: "Paris · Ensitech",
etatGeneral: "Bon état",
disponible: false,
accessoires: [],
icon: Icons.power_outlined,
),
MaterielItem(
nom: "PC Lenovo ThinkPad E14",
categorie: "Ordinateur",
marque: "Lenovo",
modele: "ThinkPad E14",
reference: "ENS-PC-004",
campus: "Paris · Ensitech",
etatGeneral: "Bon état",
disponible: true,
accessoires: ["Chargeur 65W", "Souris sans fil", "Housse de protection"],
icon: Icons.laptop_mac,
),
];
const _categories = ["Tous", "Ordinateurs", "Projection", "Câbles", "Accessoires"];
class _CatalogueScreenState extends State<CatalogueScreen> { class _CatalogueScreenState extends State<CatalogueScreen> {
late Future<List<MaterielItem>> _future;
String _recherche = ""; String _recherche = "";
String _categorie = "Tous"; String _categorie = "Tous";
int? _selectionEnCoursId;
List<MaterielItem> get _resultats { @override
void initState() {
super.initState();
_future = MaterielService.getCatalogue();
}
void _recharger() {
setState(() => _future = MaterielService.getCatalogue());
}
List<String> _categories(List<MaterielItem> materiels) {
final distinctes = materiels.map((m) => m.categorie).toSet().toList()..sort();
return ["Tous", ...distinctes];
}
List<MaterielItem> _filtrer(List<MaterielItem> materiels) {
final terme = _recherche.trim().toLowerCase(); final terme = _recherche.trim().toLowerCase();
return _materiels.where((materiel) { return materiels.where((materiel) {
final matchTexte = terme.isEmpty || materiel.nom.toLowerCase().contains(terme); final matchTexte = terme.isEmpty || materiel.nom.toLowerCase().contains(terme);
final matchCategorie = _categorie == "Tous" || _categorie.startsWith(materiel.categorie); final matchCategorie = _categorie == "Tous" || materiel.categorie == _categorie;
return matchTexte && matchCategorie; return matchTexte && matchCategorie;
}).toList(); }).toList();
} }
void _selectionner(BuildContext context, MaterielItem item) { Future<void> _selectionner(MaterielItem item) async {
Navigator.of(context).push( setState(() => _selectionEnCoursId = item.id);
MaterialPageRoute(builder: (_) => DetailScreen(item: item)), try {
); final detail = await MaterielService.getDetail(item.id);
if (!mounted) {
return;
}
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => DetailScreen(item: detail)),
);
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error.toString())),
);
} finally {
if (mounted) {
setState(() => _selectionEnCoursId = null);
}
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final resultats = _resultats;
return Scaffold( return Scaffold(
backgroundColor: EnsupColors.soft, backgroundColor: EnsupColors.soft,
body: Stack( body: Stack(
@@ -131,34 +113,23 @@ class _CatalogueScreenState extends State<CatalogueScreen> {
onChanged: (value) => setState(() => _recherche = value), onChanged: (value) => setState(() => _recherche = value),
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: _categories.map((categorie) {
return _CategoryChip(
label: categorie,
selected: categorie == _categorie,
onTap: () => setState(() => _categorie = categorie),
);
}).toList(),
),
const SizedBox(height: 16),
const Divider(color: EnsupColors.line, height: 1),
const SizedBox(height: 16),
Expanded( Expanded(
child: resultats.isEmpty child: FutureBuilder<List<MaterielItem>>(
? _EmptyState() future: _future,
: ListView.separated( builder: (context, snapshot) {
itemCount: resultats.length, if (snapshot.connectionState == ConnectionState.waiting) {
separatorBuilder: (_, _) => const SizedBox(height: 10), return const Center(child: CircularProgressIndicator());
itemBuilder: (context, index) { }
final item = resultats[index]; if (snapshot.hasError) {
return MaterielCard( return _ErreurState(
item: item, message: snapshot.error.toString(),
onSelect: () => _selectionner(context, item), onRetry: _recharger,
); );
}, }
), final materiels = snapshot.data ?? const [];
return _contenu(materiels);
},
),
), ),
], ],
), ),
@@ -174,6 +145,53 @@ class _CatalogueScreenState extends State<CatalogueScreen> {
), ),
); );
} }
Widget _contenu(List<MaterielItem> materiels) {
final resultats = _filtrer(materiels);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 8,
runSpacing: 8,
children: _categories(materiels).map((categorie) {
return _CategoryChip(
label: categorie,
selected: categorie == _categorie,
onTap: () => setState(() => _categorie = categorie),
);
}).toList(),
),
const SizedBox(height: 16),
const Divider(color: EnsupColors.line, height: 1),
const SizedBox(height: 16),
Expanded(
child: resultats.isEmpty
? const _EmptyState()
: ListView.separated(
itemCount: resultats.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final item = resultats[index];
return Stack(
children: [
MaterielCard(item: item, onSelect: () => _selectionner(item)),
if (_selectionEnCoursId == item.id)
const Positioned.fill(
child: ColoredBox(
color: Color(0x66FFFFFF),
child: Center(child: CircularProgressIndicator()),
),
),
],
);
},
),
),
],
);
}
} }
class _SearchField extends StatelessWidget { class _SearchField extends StatelessWidget {
@@ -235,7 +253,7 @@ class _CategoryChip extends StatelessWidget {
child: Text( child: Text(
label, label,
style: GoogleFonts.darkerGrotesque( style: GoogleFonts.darkerGrotesque(
fontSize: 14, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: selected ? Colors.white : EnsupColors.muted, color: selected ? Colors.white : EnsupColors.muted,
), ),
@@ -246,14 +264,16 @@ class _CategoryChip extends StatelessWidget {
} }
class _EmptyState extends StatelessWidget { class _EmptyState extends StatelessWidget {
const _EmptyState();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Center( return Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.search_off, size: 40, color: EnsupColors.muted), const Icon(Icons.inventory_2_outlined, size: 40, color: EnsupColors.muted),
const SizedBox(height: 12), const SizedBox(height: 10),
Text( Text(
"Aucun matériel ne correspond", "Aucun matériel ne correspond",
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.muted), style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.muted),
@@ -263,3 +283,36 @@ class _EmptyState extends StatelessWidget {
); );
} }
} }
class _ErreurState extends StatelessWidget {
const _ErreurState({required this.message, required this.onRetry});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off_outlined, size: 40, color: EnsupColors.muted),
const SizedBox(height: 12),
Text(
message,
textAlign: TextAlign.center,
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.text2),
),
const SizedBox(height: 14),
OutlinedButton(
onPressed: onRetry,
child: Text(
"Réessayer",
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700),
),
),
],
),
);
}
}
@@ -8,6 +8,7 @@ import "../widgets/brand_corners.dart";
import "checklist_retour_screen.dart"; import "checklist_retour_screen.dart";
const _pcDell = MaterielItem( const _pcDell = MaterielItem(
id: 0,
nom: "PC Dell Latitude 5420", nom: "PC Dell Latitude 5420",
categorie: "Ordinateur", categorie: "Ordinateur",
marque: "Dell", marque: "Dell",
+54
View File
@@ -0,0 +1,54 @@
import "dart:convert";
import "package:http/http.dart" as http;
/// Erreur levée par le client API (serveur injoignable ou réponse en échec).
class ApiException implements Exception {
const ApiException(this.message);
final String message;
@override
String toString() => message;
}
/// Point d'accès unique au backend : centralise l'URL de base, l'en-tête
/// d'authentification (simulée) et la gestion des erreurs.
class ApiClient {
ApiClient._();
static const String _baseUrl = "http://localhost:3000/api";
static const String _utilisateurEmail = "lucas.martin@ensitech.eu";
static Future<dynamic> get(String chemin) async {
final uri = Uri.parse("$_baseUrl$chemin");
late final http.Response reponse;
try {
reponse = await http.get(uri, headers: const {"x-user-email": _utilisateurEmail});
} catch (_) {
throw const ApiException(
"Impossible de joindre le serveur. Vérifiez que le backend est démarré.",
);
}
if (reponse.statusCode >= 200 && reponse.statusCode < 300) {
return jsonDecode(reponse.body);
}
throw ApiException(_messageErreur(reponse));
}
static String _messageErreur(http.Response reponse) {
try {
final corps = jsonDecode(reponse.body);
if (corps is Map && corps["error"] is Map) {
final message = (corps["error"] as Map)["message"];
if (message is String) {
return message;
}
}
} catch (_) {
// Corps non-JSON : on retombe sur le code HTTP.
}
return "Erreur ${reponse.statusCode}";
}
}
@@ -0,0 +1,21 @@
import "../models/materiel_item.dart";
import "api_client.dart";
/// Appels liés au catalogue matériel.
class MaterielService {
MaterielService._();
/// Récupère le catalogue (matériels disponibles du campus de l'utilisateur).
static Future<List<MaterielItem>> getCatalogue() async {
final reponse = await ApiClient.get("/materiels");
final data = (reponse as Map<String, dynamic>)["data"] as List<dynamic>;
return data.map((json) => MaterielItem.fromJson(json as Map<String, dynamic>)).toList();
}
/// Récupère le détail d'un matériel, notamment les accessoires du kit.
static Future<MaterielItem> getDetail(int id) async {
final reponse = await ApiClient.get("/materiels/$id");
final data = (reponse as Map<String, dynamic>)["data"] as Map<String, dynamic>;
return MaterielItem.fromJson(data);
}
}
+1 -1
View File
@@ -108,7 +108,7 @@ packages:
source: hosted source: hosted
version: "8.1.0" version: "8.1.0"
http: http:
dependency: transitive dependency: "direct main"
description: description:
name: http name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
+1
View File
@@ -35,6 +35,7 @@ dependencies:
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
google_fonts: ^8.1.0 google_fonts: ^8.1.0
http: ^1.6.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
+17 -1
View File
@@ -207,4 +207,20 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
--- ---
*Dernière mise à jour : 2026-07-07 — Frontend : parcours étudiant complet (emprunt + restitution avec résultat conforme/anomalie).* ### Étape 26 — Connexion frontend ↔ API (catalogue)
- Package `http` ajouté. Couche `services/api_client.dart` : URL de base, en-tête d'auth simulée (`x-user-email: lucas.martin@ensitech.eu`), gestion des erreurs (serveur injoignable, message `{error:{message}}`).
- `services/materiel_service.dart` (`getCatalogue`) + `MaterielItem.fromJson` (icône déduite de la catégorie, champs absents complétés).
- Écran catalogue branché via `FutureBuilder` : chargement / données / erreur + bouton Réessayer. Filtres de catégorie déduits des vraies données.
- CORS backend assoupli en développement (`origin: true` si `NODE_ENV=development`), strict en production.
- Testé en réel côté backend : Docker SQL Server OK, backend compilé OK, `/health`, `/api/auth/me`, `/api/materiels` OK, 401 sans `x-user-email` OK.
### Étape 27 — Connexion frontend ↔ API (détail matériel)
- Correctif runtime backend : `npm run dev` lance maintenant `ts-node --files`, sinon l'augmentation `Express.Request.user` n'était pas chargée par `ts-node` malgré un build TypeScript vert.
- Endpoint `GET /api/materiels/:id` ajouté : détail filtré par campus utilisateur, avec catégorie, campus et accessoires du kit.
- Frontend : `MaterielItem` porte maintenant l'`id`, `MaterielService.getDetail(id)` consomme le nouvel endpoint, et le catalogue charge le détail réel avant de naviguer vers l'écran détail.
- Testé en réel côté backend : `/api/materiels/1` renvoie le PC avec accessoires (`Chargeur`, `Souris`) ; `/api/materiels/abc` renvoie 400.
- Limite environnement : `flutter analyze`, `flutter analyze --no-pub` et `dart format` restent bloqués jusqu'au timeout dans le sandbox. À relancer dans un terminal Flutter local.
---
*Dernière mise à jour : 2026-07-07 — Catalogue et détail matériel branchés sur l'API backend ; backend testé avec SQL Server Docker.*