diff --git a/eme-backend/package.json b/eme-backend/package.json index d00c070..2d6661e 100644 --- a/eme-backend/package.json +++ b/eme-backend/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "tsc -p tsconfig.build.json", "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:migrate": "prisma migrate dev", "prisma:studio": "prisma studio", diff --git a/eme-backend/src/app.ts b/eme-backend/src/app.ts index 17fff57..94ce641 100644 --- a/eme-backend/src/app.ts +++ b/eme-backend/src/app.ts @@ -10,7 +10,9 @@ import { apiRouter } from './routes'; 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(requestLogger); diff --git a/eme-backend/src/controllers/materiel.controller.ts b/eme-backend/src/controllers/materiel.controller.ts index efea8fd..e8a144a 100644 --- a/eme-backend/src/controllers/materiel.controller.ts +++ b/eme-backend/src/controllers/materiel.controller.ts @@ -1,7 +1,7 @@ import { Request, Response } from 'express'; import { AppError } from '../errors/app-error'; -import { listerCatalogue } from '../services/materiel.service'; -import { toMaterielResponse } from '../dtos/materiel.dto'; +import { listerCatalogue, obtenirDetailMateriel } from '../services/materiel.service'; +import { toMaterielDetailResponse, toMaterielResponse } from '../dtos/materiel.dto'; function parseCategorieId(value: unknown): number | undefined { if (value === undefined) { @@ -16,6 +16,18 @@ function parseCategorieId(value: unknown): number | undefined { 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 { const user = req.user; if (!user) { @@ -28,3 +40,13 @@ export async function getCatalogue(req: Request, res: Response): Promise { const materiels = await listerCatalogue(user.campusId, { categorieId, recherche }); res.json({ data: materiels.map(toMaterielResponse) }); } + +export async function getMaterielDetail(req: Request, res: Response): Promise { + 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) }); +} diff --git a/eme-backend/src/dtos/materiel.dto.ts b/eme-backend/src/dtos/materiel.dto.ts index ea2ed50..67f2613 100644 --- a/eme-backend/src/dtos/materiel.dto.ts +++ b/eme-backend/src/dtos/materiel.dto.ts @@ -1,4 +1,4 @@ -import { MaterielAvecCategorie } from '../repositories/materiel.repository'; +import { MaterielAvecCategorie, MaterielDetail } from '../repositories/materiel.repository'; export interface CategorieResponse { id: number; @@ -16,6 +16,15 @@ export interface MaterielResponse { categorie: CategorieResponse; } +export interface MaterielDetailResponse extends MaterielResponse { + campus: { + id: number; + nom: string; + ville: string; + }; + accessoires: string[]; +} + export function toMaterielResponse(materiel: MaterielAvecCategorie): MaterielResponse { return { 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), + }; +} diff --git a/eme-backend/src/repositories/materiel.repository.ts b/eme-backend/src/repositories/materiel.repository.ts index dbd5328..1a59a44 100644 --- a/eme-backend/src/repositories/materiel.repository.ts +++ b/eme-backend/src/repositories/materiel.repository.ts @@ -2,6 +2,13 @@ import { Materiel, Prisma } from '@prisma/client'; import { prisma } from '../db/prisma'; 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 { categorieId?: number; @@ -39,3 +46,17 @@ export function findDisponiblesParCampus( export function findById(id: number): Promise { return prisma.materiel.findUnique({ where: { id } }); } + +export function findDetailParCampus(id: number, campusId: number): Promise { + return prisma.materiel.findFirst({ + where: { id, campusId, actif: true }, + include: { + categorie: true, + campus: true, + accessoires: { + include: { accessoire: true }, + orderBy: { accessoire: { nom: 'asc' } }, + }, + }, + }); +} diff --git a/eme-backend/src/routes/materiel.routes.ts b/eme-backend/src/routes/materiel.routes.ts index 385f897..c46f9da 100644 --- a/eme-backend/src/routes/materiel.routes.ts +++ b/eme-backend/src/routes/materiel.routes.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; -import { getCatalogue } from '../controllers/materiel.controller'; +import { getCatalogue, getMaterielDetail } from '../controllers/materiel.controller'; export const materielRoutes: Router = Router(); materielRoutes.get('/', getCatalogue); +materielRoutes.get('/:id', getMaterielDetail); diff --git a/eme-backend/src/services/materiel.service.ts b/eme-backend/src/services/materiel.service.ts index d23e09b..14c7fd9 100644 --- a/eme-backend/src/services/materiel.service.ts +++ b/eme-backend/src/services/materiel.service.ts @@ -1,8 +1,11 @@ import { findDisponiblesParCampus, + findDetailParCampus, CatalogueFiltres, + MaterielDetail, MaterielAvecCategorie, } from '../repositories/materiel.repository'; +import { AppError } from '../errors/app-error'; /* RG10 : après identification, seuls les matériels disponibles du campus de l'étudiant sont affichés. */ @@ -12,3 +15,11 @@ export function listerCatalogue( ): Promise { return findDisponiblesParCampus(campusId, filtres); } + +export async function obtenirDetailMateriel(id: number, campusId: number): Promise { + const materiel = await findDetailParCampus(id, campusId); + if (!materiel) { + throw new AppError(404, 'Materiel introuvable'); + } + return materiel; +} diff --git a/eme-frontend/lib/models/materiel_item.dart b/eme-frontend/lib/models/materiel_item.dart index 1fbcd20..7303da3 100644 --- a/eme-frontend/lib/models/materiel_item.dart +++ b/eme-frontend/lib/models/materiel_item.dart @@ -4,6 +4,7 @@ import "package:flutter/material.dart"; /// (données statiques pour l'instant ; sera alimenté par l'API plus tard). class MaterielItem { const MaterielItem({ + required this.id, required this.nom, required this.categorie, required this.marque, @@ -16,6 +17,28 @@ class MaterielItem { 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 json) { + final categorie = (json["categorie"] as Map)["nom"] as String; + final campus = json["campus"] as Map?; + final accessoires = json["accessoires"] as List?; + 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 [] : accessoires.cast(), + icon: iconePourCategorie(categorie), + ); + } + + final int id; final String nom; final String categorie; final String marque; @@ -27,3 +50,23 @@ class MaterielItem { final List accessoires; 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; + } +} diff --git a/eme-frontend/lib/screens/catalogue_screen.dart b/eme-frontend/lib/screens/catalogue_screen.dart index 9c88540..587f2c8 100644 --- a/eme-frontend/lib/screens/catalogue_screen.dart +++ b/eme-frontend/lib/screens/catalogue_screen.dart @@ -2,12 +2,14 @@ import "package:flutter/material.dart"; import "package:google_fonts/google_fonts.dart"; import "../theme/ensup_colors.dart"; import "../models/materiel_item.dart"; +import "../services/materiel_service.dart"; import "../widgets/ensup_top_bar.dart"; import "../widgets/brand_corners.dart"; import "../widgets/materiel_card.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 { const CatalogueScreen({super.key}); @@ -15,82 +17,62 @@ class CatalogueScreen extends StatefulWidget { State createState() => _CatalogueScreenState(); } -const _materiels = [ - 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 { + late Future> _future; String _recherche = ""; String _categorie = "Tous"; + int? _selectionEnCoursId; - List get _resultats { + @override + void initState() { + super.initState(); + _future = MaterielService.getCatalogue(); + } + + void _recharger() { + setState(() => _future = MaterielService.getCatalogue()); + } + + List _categories(List materiels) { + final distinctes = materiels.map((m) => m.categorie).toSet().toList()..sort(); + return ["Tous", ...distinctes]; + } + + List _filtrer(List materiels) { final terme = _recherche.trim().toLowerCase(); - return _materiels.where((materiel) { + return materiels.where((materiel) { 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; }).toList(); } - void _selectionner(BuildContext context, MaterielItem item) { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => DetailScreen(item: item)), - ); + Future _selectionner(MaterielItem item) async { + setState(() => _selectionEnCoursId = item.id); + 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 Widget build(BuildContext context) { - final resultats = _resultats; - return Scaffold( backgroundColor: EnsupColors.soft, body: Stack( @@ -131,34 +113,23 @@ class _CatalogueScreenState extends State { onChanged: (value) => setState(() => _recherche = value), ), 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( - child: resultats.isEmpty - ? _EmptyState() - : ListView.separated( - itemCount: resultats.length, - separatorBuilder: (_, _) => const SizedBox(height: 10), - itemBuilder: (context, index) { - final item = resultats[index]; - return MaterielCard( - item: item, - onSelect: () => _selectionner(context, item), - ); - }, - ), + child: FutureBuilder>( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return _ErreurState( + message: snapshot.error.toString(), + onRetry: _recharger, + ); + } + final materiels = snapshot.data ?? const []; + return _contenu(materiels); + }, + ), ), ], ), @@ -174,6 +145,53 @@ class _CatalogueScreenState extends State { ), ); } + + Widget _contenu(List 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 { @@ -235,7 +253,7 @@ class _CategoryChip extends StatelessWidget { child: Text( label, style: GoogleFonts.darkerGrotesque( - fontSize: 14, + fontSize: 13, fontWeight: FontWeight.w600, color: selected ? Colors.white : EnsupColors.muted, ), @@ -246,14 +264,16 @@ class _CategoryChip extends StatelessWidget { } class _EmptyState extends StatelessWidget { + const _EmptyState(); + @override Widget build(BuildContext context) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.search_off, size: 40, color: EnsupColors.muted), - const SizedBox(height: 12), + const Icon(Icons.inventory_2_outlined, size: 40, color: EnsupColors.muted), + const SizedBox(height: 10), Text( "Aucun matériel ne correspond", 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), + ), + ), + ], + ), + ); + } +} diff --git a/eme-frontend/lib/screens/restitution_select_screen.dart b/eme-frontend/lib/screens/restitution_select_screen.dart index 757e5b9..39c0191 100644 --- a/eme-frontend/lib/screens/restitution_select_screen.dart +++ b/eme-frontend/lib/screens/restitution_select_screen.dart @@ -8,6 +8,7 @@ import "../widgets/brand_corners.dart"; import "checklist_retour_screen.dart"; const _pcDell = MaterielItem( + id: 0, nom: "PC Dell Latitude 5420", categorie: "Ordinateur", marque: "Dell", diff --git a/eme-frontend/lib/services/api_client.dart b/eme-frontend/lib/services/api_client.dart new file mode 100644 index 0000000..bcb4d2f --- /dev/null +++ b/eme-frontend/lib/services/api_client.dart @@ -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 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}"; + } +} diff --git a/eme-frontend/lib/services/materiel_service.dart b/eme-frontend/lib/services/materiel_service.dart new file mode 100644 index 0000000..db175fb --- /dev/null +++ b/eme-frontend/lib/services/materiel_service.dart @@ -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> getCatalogue() async { + final reponse = await ApiClient.get("/materiels"); + final data = (reponse as Map)["data"] as List; + return data.map((json) => MaterielItem.fromJson(json as Map)).toList(); + } + + /// Récupère le détail d'un matériel, notamment les accessoires du kit. + static Future getDetail(int id) async { + final reponse = await ApiClient.get("/materiels/$id"); + final data = (reponse as Map)["data"] as Map; + return MaterielItem.fromJson(data); + } +} diff --git a/eme-frontend/pubspec.lock b/eme-frontend/pubspec.lock index d47afde..114184f 100644 --- a/eme-frontend/pubspec.lock +++ b/eme-frontend/pubspec.lock @@ -108,7 +108,7 @@ packages: source: hosted version: "8.1.0" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" diff --git a/eme-frontend/pubspec.yaml b/eme-frontend/pubspec.yaml index 875a7d7..debf5b9 100644 --- a/eme-frontend/pubspec.yaml +++ b/eme-frontend/pubspec.yaml @@ -35,6 +35,7 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 google_fonts: ^8.1.0 + http: ^1.6.0 dev_dependencies: flutter_test: diff --git a/review.md b/review.md index d0dac30..86b3b9f 100644 --- a/review.md +++ b/review.md @@ -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.*