merge: frontend borrow api

This commit is contained in:
SaidSoighiri94
2026-07-07 13:38:17 +02:00
7 changed files with 180 additions and 21 deletions
@@ -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<String, dynamic> 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<String, dynamic>),
);
}
final int id;
final DateTime dateEmprunt;
final DateTime dateRetourPrevue;
final String statut;
final MaterielItem materiel;
}
@@ -2,6 +2,7 @@ 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/emprunt_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/etat_checklist.dart"; import "../widgets/etat_checklist.dart";
@@ -19,27 +20,79 @@ class ChecklistDepartScreen extends StatefulWidget {
} }
class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> { class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
late final List<String> _elements = widget.item.accessoires.isEmpty
? <String>[widget.item.nom]
: widget.item.accessoires;
late final List<EtatChecklist> _etats = List<EtatChecklist>.filled( late final List<EtatChecklist> _etats = List<EtatChecklist>.filled(
widget.item.accessoires.length, _elements.length,
EtatChecklist.present, EtatChecklist.present,
); );
final TextEditingController _commentaireController = TextEditingController();
bool _envoiEnCours = false;
@override
void dispose() {
_commentaireController.dispose();
super.dispose();
}
Future<void> _valider() async {
setState(() => _envoiEnCours = true);
void _valider() {
final anomalies = <String>[]; final anomalies = <String>[];
for (var i = 0; i < _etats.length; i++) { for (var i = 0; i < _etats.length; i++) {
if (_etats[i] == EtatChecklist.absent) { 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) { } 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(", "); final note = anomalies.isEmpty ? null : anomalies.join(", ");
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( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => ConfirmationScreen(item: widget.item, note: note), 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 @override
@@ -141,7 +194,7 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
ChecklistTableau( ChecklistTableau(
accessoires: widget.item.accessoires, accessoires: _elements,
etats: _etats, etats: _etats,
onChanged: (index, etat) => setState(() => _etats[index] = etat), onChanged: (index, etat) => setState(() => _etats[index] = etat),
), ),
@@ -161,15 +214,23 @@ class _ChecklistDepartScreenState extends State<ChecklistDepartScreen> {
"endommagé pour éviter tout litige au retour.", "endommagé pour éviter tout litige au retour.",
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
const ChecklistCommentaire(), ChecklistCommentaire(controller: _commentaireController),
const SizedBox(height: 14), const SizedBox(height: 14),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton.icon( child: FilledButton.icon(
onPressed: _valider, onPressed: _envoiEnCours ? null : _valider,
icon: const Icon(Icons.check, size: 18), icon: _envoiEnCours
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.check, size: 18),
label: Text( label: Text(
"Valider la checklist et confirmer l'emprunt", _envoiEnCours
? "Enregistrement..."
: "Valider la checklist et confirmer l'emprunt",
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 15), style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700, fontSize: 15),
), ),
@@ -1,24 +1,28 @@
import "package:flutter/material.dart"; 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/emprunt_response.dart";
import "../models/materiel_item.dart"; import "../models/materiel_item.dart";
import "../widgets/ensup_top_bar.dart"; import "../widgets/ensup_top_bar.dart";
import "../widgets/brand_corners.dart"; import "../widgets/brand_corners.dart";
/// Écran de succès clôturant l'emprunt : confirmation + récapitulatif. /// Écran de succès clôturant l'emprunt : confirmation + récapitulatif.
class ConfirmationScreen extends StatelessWidget { 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; final String? note;
MaterielItem get item => emprunt.materiel;
static String _deuxChiffres(int valeur) => valeur.toString().padLeft(2, "0"); static String _deuxChiffres(int valeur) => valeur.toString().padLeft(2, "0");
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final now = DateTime.now(); final dateEmprunt = emprunt.dateEmprunt;
final date = "${_deuxChiffres(now.day)}/${_deuxChiffres(now.month)}/${now.year}"; final date =
final heure = "${_deuxChiffres(now.hour)}:${_deuxChiffres(now.minute)}"; "${_deuxChiffres(dateEmprunt.day)}/${_deuxChiffres(dateEmprunt.month)}/${dateEmprunt.year}";
final heure = "${_deuxChiffres(dateEmprunt.hour)}:${_deuxChiffres(dateEmprunt.minute)}";
return Scaffold( return Scaffold(
backgroundColor: EnsupColors.soft, backgroundColor: EnsupColors.soft,
@@ -181,6 +185,7 @@ class ConfirmationScreen extends StatelessWidget {
const SizedBox(height: 10), const SizedBox(height: 10),
_rrow("Matériel", item.nom), _rrow("Matériel", item.nom),
_rrow("Référence", item.reference), _rrow("Référence", item.reference),
_rrow("N° emprunt", "#${emprunt.id}"),
_rrow("Emprunteur", "Lucas Martin"), _rrow("Emprunteur", "Lucas Martin"),
_rrow("Date d'emprunt", date), _rrow("Date d'emprunt", date),
_rrow("Heure", heure), _rrow("Heure", heure),
+25
View File
@@ -37,6 +37,31 @@ class ApiClient {
throw ApiException(_messageErreur(reponse)); throw ApiException(_messageErreur(reponse));
} }
static Future<dynamic> post(String chemin, Map<String, dynamic> 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) { static String _messageErreur(http.Response reponse) {
try { try {
final corps = jsonDecode(reponse.body); final corps = jsonDecode(reponse.body);
@@ -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<EmpruntResponse> creerEmprunt({
required int materielId,
required String commentaireDepart,
required List<Map<String, dynamic>> 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<String, dynamic>)["data"] as Map<String, dynamic>;
return EmpruntResponse.fromJson(data);
}
}
+7 -1
View File
@@ -191,13 +191,19 @@ class ChecklistInstructions extends StatelessWidget {
/// Zone de commentaire optionnel sous une checklist. /// Zone de commentaire optionnel sous une checklist.
class ChecklistCommentaire extends StatelessWidget { 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; final String hint;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return TextField( return TextField(
controller: controller,
maxLines: 4, maxLines: 4,
style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.text), style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.text),
decoration: InputDecoration( decoration: InputDecoration(
+9 -1
View File
@@ -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. - 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. - 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.*