merge: frontend borrow api
This commit is contained in:
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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.*
|
||||||
|
|||||||
Reference in New Issue
Block a user