From 78a0c1305e3890f8c78e2936b3b29b8240cb0207 Mon Sep 17 00:00:00 2001 From: SaidSoighiri94 Date: Tue, 7 Jul 2026 14:10:12 +0200 Subject: [PATCH] feat(frontend): connect return flow to api --- eme-frontend/lib/models/emprunt_item.dart | 39 +++- .../lib/screens/checklist_retour_screen.dart | 96 +++++++-- .../screens/restitution_select_screen.dart | 200 +++++++++++++----- .../lib/services/emprunt_service.dart | 22 ++ review.md | 11 +- 5 files changed, 301 insertions(+), 67 deletions(-) diff --git a/eme-frontend/lib/models/emprunt_item.dart b/eme-frontend/lib/models/emprunt_item.dart index 27f112e..1c9d6b8 100644 --- a/eme-frontend/lib/models/emprunt_item.dart +++ b/eme-frontend/lib/models/emprunt_item.dart @@ -1,15 +1,52 @@ import "materiel_item.dart"; /// Un emprunt en cours de l'étudiant, affiché dans le parcours de restitution -/// (données statiques pour l'instant). +/// et renvoyé par l'API. class EmpruntItem { const EmpruntItem({ + required this.id, required this.materiel, required this.dateEmprunt, required this.retourPrevu, + required this.statut, }); + factory EmpruntItem.fromJson(Map json) { + final dateEmprunt = DateTime.parse(json["dateEmprunt"] as String); + final retourPrevu = DateTime.parse(json["dateRetourPrevue"] as String); + + return EmpruntItem( + id: json["id"] as int, + materiel: MaterielItem.fromJson(json["materiel"] as Map), + dateEmprunt: _dateHeure(dateEmprunt), + retourPrevu: _date(retourPrevu), + statut: json["statut"] as String, + ); + } + + final int id; final MaterielItem materiel; final String dateEmprunt; final String retourPrevu; + final String statut; + + EmpruntItem copyWith({MaterielItem? materiel, String? statut}) { + return EmpruntItem( + id: id, + materiel: materiel ?? this.materiel, + dateEmprunt: dateEmprunt, + retourPrevu: retourPrevu, + statut: statut ?? this.statut, + ); + } + + static String _deux(int valeur) => valeur.toString().padLeft(2, "0"); + + static String _date(DateTime date) { + return "${_deux(date.day)}/${_deux(date.month)}"; + } + + static String _dateHeure(DateTime date) { + return "${_date(date)} à ${_deux(date.hour)}:${_deux(date.minute)}"; + } } diff --git a/eme-frontend/lib/screens/checklist_retour_screen.dart b/eme-frontend/lib/screens/checklist_retour_screen.dart index dedbc93..2526ed9 100644 --- a/eme-frontend/lib/screens/checklist_retour_screen.dart +++ b/eme-frontend/lib/screens/checklist_retour_screen.dart @@ -3,6 +3,7 @@ import "package:google_fonts/google_fonts.dart"; import "../theme/ensup_colors.dart"; import "../models/emprunt_item.dart"; import "../models/anomalie_retour.dart"; +import "../services/emprunt_service.dart"; import "../widgets/ensup_top_bar.dart"; import "../widgets/brand_corners.dart"; import "../widgets/etat_checklist.dart"; @@ -20,27 +21,83 @@ class ChecklistRetourScreen extends StatefulWidget { } class _ChecklistRetourScreenState extends State { + late final List _elements = widget.emprunt.materiel.accessoires.isEmpty + ? [widget.emprunt.materiel.nom] + : widget.emprunt.materiel.accessoires; late final List _etats = List.filled( - widget.emprunt.materiel.accessoires.length, + _elements.length, EtatChecklist.present, ); + final TextEditingController _commentaireController = TextEditingController(); + bool _envoiEnCours = false; + + @override + void dispose() { + _commentaireController.dispose(); + super.dispose(); + } + + Future _valider() async { + setState(() => _envoiEnCours = true); - void _valider() { - final accessoires = widget.emprunt.materiel.accessoires; final anomalies = []; for (var i = 0; i < _etats.length; i++) { if (_etats[i] == EtatChecklist.absent) { - anomalies.add(AnomalieRetour(element: accessoires[i], etatRetour: "Absent au retour")); + anomalies.add(AnomalieRetour(element: _elements[i], etatRetour: "Absent au retour")); } else if (_etats[i] == EtatChecklist.deteriore) { - anomalies.add(AnomalieRetour(element: accessoires[i], etatRetour: "Détérioré au retour")); + anomalies.add(AnomalieRetour(element: _elements[i], etatRetour: "Détérioré au retour")); } } - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ResultatRetourScreen(emprunt: widget.emprunt, anomalies: anomalies), - ), - ); + try { + final resultat = await EmpruntService.restituerEmprunt( + empruntId: widget.emprunt.id, + commentaireRetour: _commentaireController.text, + elements: _elements.asMap().entries.map((entry) { + return { + "nomElement": entry.value, + "etat": _etatApi(_etats[entry.key]), + "quantiteConstatee": _etats[entry.key] == EtatChecklist.absent ? 0 : 1, + }; + }).toList(), + ); + + if (!mounted) { + return; + } + + final anomaliesAffichees = resultat.statut == "CLOTURE" ? [] : anomalies; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ResultatRetourScreen( + emprunt: widget.emprunt.copyWith(statut: resultat.statut), + anomalies: anomaliesAffichees, + ), + ), + ); + } catch (error) { + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error.toString())), + ); + } finally { + if (mounted) { + setState(() => _envoiEnCours = false); + } + } + } + + String _etatApi(EtatChecklist etat) { + switch (etat) { + case EtatChecklist.present: + return "PRESENT"; + case EtatChecklist.absent: + return "ABSENT"; + case EtatChecklist.deteriore: + return "DETERIORE"; + } } @override @@ -144,7 +201,7 @@ class _ChecklistRetourScreenState extends State { ), const SizedBox(height: 16), ChecklistTableau( - accessoires: materiel.accessoires, + accessoires: _elements, etats: _etats, onChanged: (index, etat) => setState(() => _etats[index] = etat), ), @@ -163,15 +220,24 @@ class _ChecklistRetourScreenState extends State { "Indiquez leur état au retour ; toute différence sera signalée.", ), const SizedBox(height: 14), - const ChecklistCommentaire(hint: "Commentaire optionnel..."), + ChecklistCommentaire( + controller: _commentaireController, + hint: "Commentaire optionnel...", + ), const SizedBox(height: 14), SizedBox( width: double.infinity, child: FilledButton.icon( - onPressed: _valider, - icon: const Icon(Icons.check, size: 18), + onPressed: _envoiEnCours ? null : _valider, + icon: _envoiEnCours + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.check, size: 18), label: Text( - "Valider le retour", + _envoiEnCours ? "Enregistrement..." : "Valider le retour", style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 16), ), style: FilledButton.styleFrom( diff --git a/eme-frontend/lib/screens/restitution_select_screen.dart b/eme-frontend/lib/screens/restitution_select_screen.dart index 39c0191..885ad72 100644 --- a/eme-frontend/lib/screens/restitution_select_screen.dart +++ b/eme-frontend/lib/screens/restitution_select_screen.dart @@ -1,38 +1,59 @@ import "package:flutter/material.dart"; import "package:google_fonts/google_fonts.dart"; import "../theme/ensup_colors.dart"; -import "../models/materiel_item.dart"; import "../models/emprunt_item.dart"; +import "../services/emprunt_service.dart"; +import "../services/materiel_service.dart"; import "../widgets/ensup_top_bar.dart"; import "../widgets/brand_corners.dart"; import "checklist_retour_screen.dart"; -const _pcDell = MaterielItem( - id: 0, - nom: "PC Dell Latitude 5420", - categorie: "Ordinateur", - marque: "Dell", - modele: "Latitude 5420", - reference: "ENS-PC-001", - campus: "Paris", - etatGeneral: "Bon état", - disponible: false, - accessoires: ["Chargeur 65W", "Souris sans fil", "Housse de protection", "Câble HDMI"], - icon: Icons.laptop_mac, -); - -const _emprunts = [ - EmpruntItem(materiel: _pcDell, dateEmprunt: "28/05/2026 à 09:15", retourPrevu: "29/05"), -]; - /// Sélection de l'emprunt en cours à restituer (RG15 : ses propres emprunts). -class RestitutionSelectScreen extends StatelessWidget { +class RestitutionSelectScreen extends StatefulWidget { const RestitutionSelectScreen({super.key}); - void _restituer(BuildContext context, EmpruntItem emprunt) { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => ChecklistRetourScreen(emprunt: emprunt)), - ); + @override + State createState() => _RestitutionSelectScreenState(); +} + +class _RestitutionSelectScreenState extends State { + late Future> _future; + int? _chargementId; + + @override + void initState() { + super.initState(); + _future = EmpruntService.getMesEmprunts(); + } + + void _recharger() { + setState(() => _future = EmpruntService.getMesEmprunts()); + } + + Future _restituer(EmpruntItem emprunt) async { + setState(() => _chargementId = emprunt.id); + try { + final detail = await MaterielService.getDetail(emprunt.materiel.id); + if (!mounted) { + return; + } + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChecklistRetourScreen(emprunt: emprunt.copyWith(materiel: detail)), + ), + ); + } catch (error) { + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error.toString())), + ); + } finally { + if (mounted) { + setState(() => _chargementId = null); + } + } } @override @@ -68,29 +89,23 @@ class RestitutionSelectScreen extends StatelessWidget { onBack: () => Navigator.of(context).pop(), ), Expanded( - child: SingleChildScrollView( + child: Padding( padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 28), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Sélectionner le matériel à restituer", - style: GoogleFonts.darkerGrotesque( - fontSize: 24, - fontWeight: FontWeight.w900, - color: EnsupColors.text, - ), - ), - const SizedBox(height: 16), - for (final emprunt in _emprunts) - Padding( - padding: const EdgeInsets.only(bottom: 10), - child: _EmpruntCard( - emprunt: emprunt, - onRestituer: () => _restituer(context, emprunt), - ), - ), - ], + 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, + ); + } + + return _contenu(snapshot.data ?? const []); + }, ), ), ), @@ -104,12 +119,50 @@ class RestitutionSelectScreen extends StatelessWidget { ), ); } + + Widget _contenu(List emprunts) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Sélectionner le matériel à restituer", + style: GoogleFonts.darkerGrotesque( + fontSize: 24, + fontWeight: FontWeight.w900, + color: EnsupColors.text, + ), + ), + const SizedBox(height: 16), + Expanded( + child: emprunts.isEmpty + ? const _EmptyState() + : ListView.separated( + itemCount: emprunts.length, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final emprunt = emprunts[index]; + return _EmpruntCard( + emprunt: emprunt, + loading: _chargementId == emprunt.id, + onRestituer: () => _restituer(emprunt), + ); + }, + ), + ), + ], + ); + } } class _EmpruntCard extends StatelessWidget { - const _EmpruntCard({required this.emprunt, required this.onRestituer}); + const _EmpruntCard({ + required this.emprunt, + required this.loading, + required this.onRestituer, + }); final EmpruntItem emprunt; + final bool loading; final VoidCallback onRestituer; @override @@ -161,7 +214,7 @@ class _EmpruntCard extends StatelessWidget { borderRadius: BorderRadius.circular(999), ), child: Text( - "En cours", + emprunt.statut == "EN_RETARD" ? "En retard" : "En cours", style: GoogleFonts.darkerGrotesque( fontSize: 13, fontWeight: FontWeight.w700, @@ -171,14 +224,14 @@ class _EmpruntCard extends StatelessWidget { ), const SizedBox(width: 12), FilledButton( - onPressed: onRestituer, + onPressed: loading ? null : onRestituer, style: FilledButton.styleFrom( backgroundColor: EnsupColors.cyan, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), ), child: Text( - "Restituer", + loading ? "Chargement..." : "Restituer", style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 14), ), ), @@ -187,3 +240,50 @@ class _EmpruntCard extends StatelessWidget { ); } } + +class _EmptyState extends StatelessWidget { + const _EmptyState(); + + @override + Widget build(BuildContext context) { + return Center( + child: Text( + "Aucun emprunt en cours à restituer", + style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.muted), + ), + ); + } +} + +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/services/emprunt_service.dart b/eme-frontend/lib/services/emprunt_service.dart index b621bb4..69dd8cf 100644 --- a/eme-frontend/lib/services/emprunt_service.dart +++ b/eme-frontend/lib/services/emprunt_service.dart @@ -1,4 +1,5 @@ import "../models/emprunt_response.dart"; +import "../models/emprunt_item.dart"; import "api_client.dart"; /// Appels liés aux emprunts. @@ -23,4 +24,25 @@ class EmpruntService { final data = (reponse as Map)["data"] as Map; return EmpruntResponse.fromJson(data); } + + static Future> getMesEmprunts() async { + final reponse = await ApiClient.get("/mes-emprunts"); + final data = (reponse as Map)["data"] as List; + return data.map((json) => EmpruntItem.fromJson(json as Map)).toList(); + } + + static Future restituerEmprunt({ + required int empruntId, + required String commentaireRetour, + required List> elements, + }) async { + final reponse = await ApiClient.post("/emprunts/$empruntId/restitution", { + "modeIdentification": "MAIL_ENSUP", + if (commentaireRetour.trim().isNotEmpty) "commentaireRetour": commentaireRetour.trim(), + "elements": elements, + }); + + final data = (reponse as Map)["data"] as Map; + return EmpruntResponse.fromJson(data); + } } diff --git a/review.md b/review.md index 22d718e..3c3a170 100644 --- a/review.md +++ b/review.md @@ -236,6 +236,15 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y - Accessoires associés assurés sans doublon logique par recherche de nom/référence avant création. - Vérifié en réel : `npm run fixtures` OK ; `GET /api/materiels` pour Lucas renvoie 10 matériels disponibles. +### Étape 30 — Connexion frontend ↔ API (restitution) +- Branche dédiée `feat/frontend-restitution-api` créée après merge de `feat/demo-catalogue-fixtures` dans `develop`. +- `EmpruntItem.fromJson` ajouté pour mapper `GET /api/mes-emprunts`. +- `EmpruntService.getMesEmprunts()` et `EmpruntService.restituerEmprunt()` ajoutés. +- Écran de sélection restitution branché sur les vrais emprunts en cours, avec chargement, erreur, état vide et chargement du détail matériel avant la checklist. +- Checklist retour branchée sur `POST /api/emprunts/:id/restitution`, avec commentaire optionnel, état de chargement et erreurs via snackbar. +- Le résultat affiché se base sur le statut renvoyé par le backend (`CLOTURE` => conforme, sinon anomalie), afin de rester cohérent avec la comparaison serveur. +- Vérifié sans mutation : `GET /api/mes-emprunts` renvoie les emprunts de Lucas ; restitution d'un identifiant inexistant renvoie 404. + --- -*Dernière mise à jour : 2026-07-07 — Catalogue enrichi, détail matériel et création d'emprunt frontend branchés sur l'API backend.* +*Dernière mise à jour : 2026-07-07 — Parcours étudiant emprunt et restitution branchés sur l'API backend.*