From 7aba1a930d9db422ac547cf794cbac263393181b Mon Sep 17 00:00:00 2001 From: SaidSoighiri94 Date: Tue, 7 Jul 2026 13:22:53 +0200 Subject: [PATCH] feat(frontend): connect borrow flow to api --- eme-frontend/lib/models/emprunt_response.dart | 28 ++++++ .../lib/screens/checklist_depart_screen.dart | 89 ++++++++++++++++--- .../lib/screens/confirmation_screen.dart | 15 ++-- eme-frontend/lib/services/api_client.dart | 25 ++++++ .../lib/services/emprunt_service.dart | 26 ++++++ eme-frontend/lib/widgets/etat_checklist.dart | 8 +- review.md | 10 ++- 7 files changed, 180 insertions(+), 21 deletions(-) create mode 100644 eme-frontend/lib/models/emprunt_response.dart create mode 100644 eme-frontend/lib/services/emprunt_service.dart diff --git a/eme-frontend/lib/models/emprunt_response.dart b/eme-frontend/lib/models/emprunt_response.dart new file mode 100644 index 0000000..2ea4b40 --- /dev/null +++ b/eme-frontend/lib/models/emprunt_response.dart @@ -0,0 +1,28 @@ +import "materiel_item.dart"; + +/// Réponse renvoyée par l'API après création d'un emprunt. +class EmpruntResponse { + const EmpruntResponse({ + required this.id, + required this.dateEmprunt, + required this.dateRetourPrevue, + required this.statut, + required this.materiel, + }); + + factory EmpruntResponse.fromJson(Map json) { + return EmpruntResponse( + id: json["id"] as int, + dateEmprunt: DateTime.parse(json["dateEmprunt"] as String), + dateRetourPrevue: DateTime.parse(json["dateRetourPrevue"] as String), + statut: json["statut"] as String, + materiel: MaterielItem.fromJson(json["materiel"] as Map), + ); + } + + final int id; + final DateTime dateEmprunt; + final DateTime dateRetourPrevue; + final String statut; + final MaterielItem materiel; +} diff --git a/eme-frontend/lib/screens/checklist_depart_screen.dart b/eme-frontend/lib/screens/checklist_depart_screen.dart index 779cfb8..07c440d 100644 --- a/eme-frontend/lib/screens/checklist_depart_screen.dart +++ b/eme-frontend/lib/screens/checklist_depart_screen.dart @@ -2,6 +2,7 @@ import "package:flutter/material.dart"; import "package:google_fonts/google_fonts.dart"; import "../theme/ensup_colors.dart"; import "../models/materiel_item.dart"; +import "../services/emprunt_service.dart"; import "../widgets/ensup_top_bar.dart"; import "../widgets/brand_corners.dart"; import "../widgets/etat_checklist.dart"; @@ -19,27 +20,79 @@ class ChecklistDepartScreen extends StatefulWidget { } class _ChecklistDepartScreenState extends State { + late final List _elements = widget.item.accessoires.isEmpty + ? [widget.item.nom] + : widget.item.accessoires; late final List _etats = List.filled( - widget.item.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 anomalies = []; for (var i = 0; i < _etats.length; i++) { if (_etats[i] == EtatChecklist.absent) { - anomalies.add("${widget.item.accessoires[i]} absent au départ"); + anomalies.add("${_elements[i]} absent au départ"); } else if (_etats[i] == EtatChecklist.deteriore) { - anomalies.add("${widget.item.accessoires[i]} détérioré au départ"); + anomalies.add("${_elements[i]} détérioré au départ"); } } final note = anomalies.isEmpty ? null : anomalies.join(", "); - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ConfirmationScreen(item: widget.item, note: note), - ), - ); + try { + final emprunt = await EmpruntService.creerEmprunt( + materielId: widget.item.id, + commentaireDepart: _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; + } + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ConfirmationScreen(emprunt: emprunt, note: note), + ), + ); + } 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 @@ -141,7 +194,7 @@ class _ChecklistDepartScreenState extends State { ), const SizedBox(height: 16), ChecklistTableau( - accessoires: widget.item.accessoires, + accessoires: _elements, etats: _etats, onChanged: (index, etat) => setState(() => _etats[index] = etat), ), @@ -161,15 +214,23 @@ class _ChecklistDepartScreenState extends State { "endommagé pour éviter tout litige au retour.", ), const SizedBox(height: 14), - const ChecklistCommentaire(), + ChecklistCommentaire(controller: _commentaireController), 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 la checklist et confirmer l'emprunt", + _envoiEnCours + ? "Enregistrement..." + : "Valider la checklist et confirmer l'emprunt", textAlign: TextAlign.center, style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 15), ), diff --git a/eme-frontend/lib/screens/confirmation_screen.dart b/eme-frontend/lib/screens/confirmation_screen.dart index 14468d4..139b2fd 100644 --- a/eme-frontend/lib/screens/confirmation_screen.dart +++ b/eme-frontend/lib/screens/confirmation_screen.dart @@ -1,24 +1,28 @@ import "package:flutter/material.dart"; import "package:google_fonts/google_fonts.dart"; import "../theme/ensup_colors.dart"; +import "../models/emprunt_response.dart"; import "../models/materiel_item.dart"; import "../widgets/ensup_top_bar.dart"; import "../widgets/brand_corners.dart"; /// Écran de succès clôturant l'emprunt : confirmation + récapitulatif. class ConfirmationScreen extends StatelessWidget { - const ConfirmationScreen({super.key, required this.item, this.note}); + const ConfirmationScreen({super.key, required this.emprunt, this.note}); - final MaterielItem item; + final EmpruntResponse emprunt; final String? note; + MaterielItem get item => emprunt.materiel; + static String _deuxChiffres(int valeur) => valeur.toString().padLeft(2, "0"); @override Widget build(BuildContext context) { - final now = DateTime.now(); - final date = "${_deuxChiffres(now.day)}/${_deuxChiffres(now.month)}/${now.year}"; - final heure = "${_deuxChiffres(now.hour)}:${_deuxChiffres(now.minute)}"; + final dateEmprunt = emprunt.dateEmprunt; + final date = + "${_deuxChiffres(dateEmprunt.day)}/${_deuxChiffres(dateEmprunt.month)}/${dateEmprunt.year}"; + final heure = "${_deuxChiffres(dateEmprunt.hour)}:${_deuxChiffres(dateEmprunt.minute)}"; return Scaffold( backgroundColor: EnsupColors.soft, @@ -181,6 +185,7 @@ class ConfirmationScreen extends StatelessWidget { const SizedBox(height: 10), _rrow("Matériel", item.nom), _rrow("Référence", item.reference), + _rrow("N° emprunt", "#${emprunt.id}"), _rrow("Emprunteur", "Lucas Martin"), _rrow("Date d'emprunt", date), _rrow("Heure", heure), diff --git a/eme-frontend/lib/services/api_client.dart b/eme-frontend/lib/services/api_client.dart index bcb4d2f..dea9488 100644 --- a/eme-frontend/lib/services/api_client.dart +++ b/eme-frontend/lib/services/api_client.dart @@ -37,6 +37,31 @@ class ApiClient { throw ApiException(_messageErreur(reponse)); } + static Future post(String chemin, Map body) async { + final uri = Uri.parse("$_baseUrl$chemin"); + + late final http.Response reponse; + try { + reponse = await http.post( + uri, + headers: const { + "content-type": "application/json", + "x-user-email": _utilisateurEmail, + }, + body: jsonEncode(body), + ); + } 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); diff --git a/eme-frontend/lib/services/emprunt_service.dart b/eme-frontend/lib/services/emprunt_service.dart new file mode 100644 index 0000000..b621bb4 --- /dev/null +++ b/eme-frontend/lib/services/emprunt_service.dart @@ -0,0 +1,26 @@ +import "../models/emprunt_response.dart"; +import "api_client.dart"; + +/// Appels liés aux emprunts. +class EmpruntService { + EmpruntService._(); + + static const int posteEmpruntId = 1; + + static Future creerEmprunt({ + required int materielId, + required String commentaireDepart, + required List> elements, + }) async { + final reponse = await ApiClient.post("/emprunts", { + "materielId": materielId, + "posteEmpruntId": posteEmpruntId, + "modeIdentification": "MAIL_ENSUP", + if (commentaireDepart.trim().isNotEmpty) "commentaireDepart": commentaireDepart.trim(), + "elements": elements, + }); + + final data = (reponse as Map)["data"] as Map; + return EmpruntResponse.fromJson(data); + } +} diff --git a/eme-frontend/lib/widgets/etat_checklist.dart b/eme-frontend/lib/widgets/etat_checklist.dart index 74e280b..3efd452 100644 --- a/eme-frontend/lib/widgets/etat_checklist.dart +++ b/eme-frontend/lib/widgets/etat_checklist.dart @@ -191,13 +191,19 @@ class ChecklistInstructions extends StatelessWidget { /// Zone de commentaire optionnel sous une checklist. class ChecklistCommentaire extends StatelessWidget { - const ChecklistCommentaire({super.key, this.hint = "Commentaire ou observation optionnelle..."}); + const ChecklistCommentaire({ + super.key, + this.controller, + this.hint = "Commentaire ou observation optionnelle...", + }); + final TextEditingController? controller; final String hint; @override Widget build(BuildContext context) { return TextField( + controller: controller, maxLines: 4, style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.text), decoration: InputDecoration( diff --git a/review.md b/review.md index 86b3b9f..e16e1e7 100644 --- a/review.md +++ b/review.md @@ -221,6 +221,14 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y - 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. +### Étape 28 — Connexion frontend ↔ API (création d'emprunt) +- Branche dédiée `feat/frontend-emprunt-api` créée après merge de `feat/catalogue-detail-api` dans `develop`. +- `ApiClient.post` ajouté et `EmpruntService.creerEmprunt` consomme `POST /api/emprunts` avec l'auth simulée. +- Checklist de départ branchée sur l'API : construction du payload (`materielId`, `posteEmpruntId`, `modeIdentification`, commentaire, éléments), état de chargement, affichage d'erreur via snackbar. +- Un matériel sans accessoire génère un élément de checklist portant le nom du matériel, afin de respecter RG11 côté backend (checklist obligatoire non vide). +- Confirmation alimentée par la réponse API (`id`, `dateEmprunt`, matériel renvoyé). +- Testé sans mutation de données : `POST /api/emprunts` avec checklist vide renvoie bien 400 `La checklist de depart est obligatoire (RG11)`. + --- -*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.* +*Dernière mise à jour : 2026-07-07 — Catalogue, détail matériel et création d'emprunt frontend branchés sur l'API backend.*