From 2a1281b0e554e60fed420814cc5a2aa868ee55c8 Mon Sep 17 00:00:00 2001 From: SaidSoighiri94 Date: Fri, 17 Jul 2026 11:55:44 +0200 Subject: [PATCH] feat(frontend): add responsable dashboard screen --- eme-frontend/lib/screens/login_screen.dart | 68 ++- .../lib/screens/responsable_screen.dart | 536 ++++++++++++++++++ eme-frontend/lib/services/api_client.dart | 50 +- .../lib/services/responsable_service.dart | 48 ++ review.md | 11 +- 5 files changed, 693 insertions(+), 20 deletions(-) create mode 100644 eme-frontend/lib/screens/responsable_screen.dart create mode 100644 eme-frontend/lib/services/responsable_service.dart diff --git a/eme-frontend/lib/screens/login_screen.dart b/eme-frontend/lib/screens/login_screen.dart index 00a87e4..9570c13 100644 --- a/eme-frontend/lib/screens/login_screen.dart +++ b/eme-frontend/lib/screens/login_screen.dart @@ -6,6 +6,7 @@ import "../widgets/ensup_top_bar.dart"; import "../widgets/identification_option.dart"; import "../widgets/brand_corners.dart"; import "home_screen.dart"; +import "responsable_screen.dart"; /// Écran de démarrage : identification de l'étudiant (RG01/RG02). class LoginScreen extends StatelessWidget { @@ -60,7 +61,10 @@ class LoginScreen extends StatelessWidget { ), child: Center( child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 40), + padding: const EdgeInsets.symmetric( + horizontal: 28, + vertical: 40, + ), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560), child: Column( @@ -71,18 +75,27 @@ class LoginScreen extends StatelessWidget { height: 72, decoration: BoxDecoration( gradient: const LinearGradient( - colors: [EnsupColors.blue3, EnsupColors.cyan], + colors: [ + EnsupColors.blue3, + EnsupColors.cyan, + ], ), borderRadius: BorderRadius.circular(22), boxShadow: [ BoxShadow( - color: EnsupColors.blue3.withValues(alpha: 0.35), + color: EnsupColors.blue3.withValues( + alpha: 0.35, + ), blurRadius: 24, offset: const Offset(0, 8), ), ], ), - child: const Icon(Icons.badge_outlined, color: Colors.white, size: 34), + child: const Icon( + Icons.badge_outlined, + color: Colors.white, + size: 34, + ), ), const SizedBox(height: 20), Text( @@ -115,16 +128,22 @@ class LoginScreen extends StatelessWidget { IdentificationOption( icon: Icons.mail_outline, iconColor: EnsupColors.teal, - label: "Connexion compte professionnel ENSUP", - description: "Utiliser mon adresse e-mail Microsoft 365", + label: + "Connexion compte professionnel ENSUP", + description: + "Utiliser mon adresse e-mail Microsoft 365", onTap: () => _identifier(context), ), const SizedBox(height: 20), Row( children: [ - const Expanded(child: Divider(color: EnsupColors.line)), + const Expanded( + child: Divider(color: EnsupColors.line), + ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), + padding: const EdgeInsets.symmetric( + horizontal: 12, + ), child: Text( "ou", style: GoogleFonts.titilliumWeb( @@ -133,15 +152,29 @@ class LoginScreen extends StatelessWidget { ), ), ), - const Expanded(child: Divider(color: EnsupColors.line)), + const Expanded( + child: Divider(color: EnsupColors.line), + ), ], ), const SizedBox(height: 20), SizedBox( width: double.infinity, child: OutlinedButton.icon( - onPressed: () {}, - icon: const Icon(Icons.person_outline, size: 16), + onPressed: () => + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + const ResponsableScreen(), + settings: const RouteSettings( + name: "responsable", + ), + ), + ), + icon: const Icon( + Icons.person_outline, + size: 16, + ), label: Text( "Accès responsable matériel", style: GoogleFonts.darkerGrotesque( @@ -151,10 +184,17 @@ class LoginScreen extends StatelessWidget { ), style: OutlinedButton.styleFrom( foregroundColor: EnsupColors.text2, - side: const BorderSide(color: EnsupColors.line, width: 1.5), - padding: const EdgeInsets.symmetric(vertical: 14), + side: const BorderSide( + color: EnsupColors.line, + width: 1.5, + ), + padding: const EdgeInsets.symmetric( + vertical: 14, + ), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular( + 10, + ), ), ), ), diff --git a/eme-frontend/lib/screens/responsable_screen.dart b/eme-frontend/lib/screens/responsable_screen.dart new file mode 100644 index 0000000..42708a2 --- /dev/null +++ b/eme-frontend/lib/screens/responsable_screen.dart @@ -0,0 +1,536 @@ +import "package:flutter/material.dart"; +import "package:google_fonts/google_fonts.dart"; + +import "../demo_identity.dart"; +import "../services/responsable_service.dart"; +import "../theme/ensup_colors.dart"; +import "../widgets/brand_corners.dart"; +import "../widgets/ensup_top_bar.dart"; + +class ResponsableScreen extends StatefulWidget { + const ResponsableScreen({super.key}); + + @override + State createState() => _ResponsableScreenState(); +} + +class _ResponsableScreenState extends State { + late Future<_ResponsableData> _future = _charger(); + int _onglet = 0; + bool _transitionEnCours = false; + + Future<_ResponsableData> _charger() async { + final dashboard = await ResponsableService.dashboard(); + final emprunts = await ResponsableService.emprunts(); + final materiels = await ResponsableService.materiels(); + final anomalies = await ResponsableService.anomalies(); + final notifications = await ResponsableService.notifications(); + final historique = await ResponsableService.historique(); + + return _ResponsableData( + dashboard: dashboard, + emprunts: emprunts, + materiels: materiels, + anomalies: anomalies, + notifications: notifications, + historique: historique, + ); + } + + void _rafraichir() { + setState(() => _future = _charger()); + } + + Future _changerStatut(Map anomalie) async { + final prochain = _prochainStatut(anomalie["statut"] as String?); + if (prochain == null || _transitionEnCours) { + return; + } + + setState(() => _transitionEnCours = true); + try { + await ResponsableService.changerStatutAnomalie( + anomalie["id"] as int, + prochain, + ); + _rafraichir(); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error.toString()))); + } finally { + if (mounted) { + setState(() => _transitionEnCours = false); + } + } + } + + String? _prochainStatut(String? statut) { + return switch (statut) { + "DETECTEE" => "EN_COURS_TRAITEMENT", + "EN_COURS_TRAITEMENT" => "RESOLUE", + "RESOLUE" => "CLOTUREE", + _ => null, + }; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: EnsupColors.soft, + body: Stack( + children: [ + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1320, maxHeight: 860), + child: Container( + margin: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: EnsupColors.text.withValues(alpha: 0.14), + blurRadius: 60, + offset: const Offset(0, 18), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + EnsupTopBar( + brandText: "EME Responsable", + campusTag: DemoIdentity.loginCampusTag, + userName: "Karim Benali", + userInitials: "KB", + onBack: () => Navigator.of(context).pop(), + ), + Expanded( + child: FutureBuilder<_ResponsableData>( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState != + ConnectionState.done) { + return const Center( + child: CircularProgressIndicator(), + ); + } + if (snapshot.hasError) { + return _Erreur( + message: snapshot.error.toString(), + onRetry: _rafraichir, + ); + } + return _contenu(snapshot.data!); + }, + ), + ), + ], + ), + ), + ), + ), + const Positioned.fill(child: BrandCorners()), + ], + ), + ); + } + + Widget _contenu(_ResponsableData data) { + final vues = [ + _VueDashboard(data: data), + _VueListe(titre: "Emprunts", items: data.emprunts, builder: _empruntTile), + _VueListe(titre: "Stock", items: data.materiels, builder: _materielTile), + _VueListe( + titre: "Anomalies", + items: data.anomalies, + builder: _anomalieTile, + ), + _VueListe( + titre: "Notifications", + items: data.notifications, + builder: _notificationTile, + ), + _VueListe( + titre: "Historique", + items: data.historique, + builder: _historiqueTile, + ), + ]; + + return Column( + children: [ + _Onglets( + index: _onglet, + labels: const [ + "Dashboard", + "Emprunts", + "Stock", + "Anomalies", + "Notifications", + "Historique", + ], + onChanged: (index) => setState(() => _onglet = index), + ), + Expanded(child: vues[_onglet]), + ], + ); + } + + Widget _empruntTile(Map item) { + final etudiant = Map.from(item["etudiant"] as Map); + final materiel = Map.from(item["materiel"] as Map); + return _InfoTile( + icon: Icons.assignment_outlined, + title: "${materiel["nom"]}", + subtitle: + "${etudiant["prenom"]} ${etudiant["nom"]} · retour prévu ${_date(item["dateRetourPrevue"])}", + trailing: _Badge("${item["statut"]}"), + ); + } + + Widget _materielTile(Map item) { + final categorie = Map.from(item["categorie"] as Map); + return _InfoTile( + icon: Icons.inventory_2_outlined, + title: "${item["nom"]}", + subtitle: "${item["reference"]} · ${categorie["nom"]}", + trailing: _Badge("${item["statut"]}"), + ); + } + + Widget _anomalieTile(Map item) { + final materiel = Map.from(item["materiel"] as Map); + final prochain = _prochainStatut(item["statut"] as String?); + return _InfoTile( + icon: Icons.report_problem_outlined, + title: "${item["type"]} · ${materiel["nom"]}", + subtitle: "${item["description"]}", + trailing: prochain == null + ? _Badge("${item["statut"]}") + : FilledButton( + onPressed: _transitionEnCours ? null : () => _changerStatut(item), + child: Text(prochain), + ), + ); + } + + Widget _notificationTile(Map item) { + return _InfoTile( + icon: item["lu"] == true + ? Icons.notifications_none + : Icons.notifications_active_outlined, + title: "${item["titre"]}", + subtitle: "${item["message"]}", + trailing: _Badge(item["lu"] == true ? "LUE" : "NON LUE"), + ); + } + + Widget _historiqueTile(Map item) { + final utilisateur = Map.from(item["utilisateur"] as Map); + return _InfoTile( + icon: Icons.history, + title: "${item["action"]}", + subtitle: + "${_date(item["dateAction"])} · ${utilisateur["prenom"]} ${utilisateur["nom"]}", + trailing: const Icon(Icons.chevron_right, color: EnsupColors.muted), + ); + } + + String _date(Object? value) { + if (value == null) return "-"; + final parsed = DateTime.tryParse(value.toString()); + if (parsed == null) return value.toString(); + return "${parsed.day.toString().padLeft(2, "0")}/" + "${parsed.month.toString().padLeft(2, "0")}/${parsed.year}"; + } +} + +class _ResponsableData { + const _ResponsableData({ + required this.dashboard, + required this.emprunts, + required this.materiels, + required this.anomalies, + required this.notifications, + required this.historique, + }); + + final Map dashboard; + final List> emprunts; + final List> materiels; + final List> anomalies; + final List> notifications; + final List> historique; +} + +class _VueDashboard extends StatelessWidget { + const _VueDashboard({required this.data}); + + final _ResponsableData data; + + @override + Widget build(BuildContext context) { + final kpis = Map.from(data.dashboard["kpis"] as Map); + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Wrap( + spacing: 14, + runSpacing: 14, + children: [ + _KpiCard( + label: "Matériels", + value: _total(kpis["materiels"]), + icon: Icons.inventory_2_outlined, + ), + _KpiCard( + label: "Emprunts", + value: _total(kpis["emprunts"]), + icon: Icons.assignment_outlined, + ), + _KpiCard( + label: "Anomalies", + value: _total(kpis["anomalies"]), + icon: Icons.report_problem_outlined, + ), + _KpiCard( + label: "Notifications", + value: "${kpis["notificationsNonLues"] ?? 0}", + icon: Icons.notifications_outlined, + ), + ], + ), + ); + } + + String _total(Object? repartition) { + final map = Map.from(repartition as Map); + return "${map["total"] ?? 0}"; + } +} + +class _VueListe extends StatelessWidget { + const _VueListe({ + required this.titre, + required this.items, + required this.builder, + }); + + final String titre; + final List> items; + final Widget Function(Map) builder; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "$titre (${items.length})", + style: GoogleFonts.darkerGrotesque( + fontSize: 26, + fontWeight: FontWeight.w900, + color: EnsupColors.text, + ), + ), + const SizedBox(height: 14), + Expanded( + child: ListView.separated( + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (_, index) => builder(items[index]), + ), + ), + ], + ), + ); + } +} + +class _Onglets extends StatelessWidget { + const _Onglets({ + required this.index, + required this.labels, + required this.onChanged, + }); + + final int index; + final List labels; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + decoration: const BoxDecoration( + color: EnsupColors.soft, + border: Border(bottom: BorderSide(color: EnsupColors.line)), + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: List.generate(labels.length, (i) { + final selected = i == index; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + selected: selected, + label: Text(labels[i]), + onSelected: (_) => onChanged(i), + ), + ); + }), + ), + ), + ); + } +} + +class _KpiCard extends StatelessWidget { + const _KpiCard({ + required this.label, + required this.value, + required this.icon, + }); + + final String label; + final String value; + final IconData icon; + + @override + Widget build(BuildContext context) { + return Container( + width: 210, + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + border: Border.all(color: EnsupColors.line), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(icon, color: EnsupColors.blue3), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value, + style: GoogleFonts.darkerGrotesque( + fontSize: 30, + fontWeight: FontWeight.w900, + ), + ), + Text( + label, + style: GoogleFonts.titilliumWeb( + fontSize: 13, + color: EnsupColors.muted, + ), + ), + ], + ), + ], + ), + ); + } +} + +class _InfoTile extends StatelessWidget { + const _InfoTile({ + required this.icon, + required this.title, + required this.subtitle, + required this.trailing, + }); + + final IconData icon; + final String title; + final String subtitle; + final Widget trailing; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: EnsupColors.line), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(icon, color: EnsupColors.teal), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.darkerGrotesque( + fontSize: 19, + fontWeight: FontWeight.w800, + ), + ), + Text(subtitle, maxLines: 2, overflow: TextOverflow.ellipsis), + ], + ), + ), + const SizedBox(width: 12), + trailing, + ], + ), + ); + } +} + +class _Badge extends StatelessWidget { + const _Badge(this.label); + + final String label; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: EnsupColors.blue3.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700), + ), + ); + } +} + +class _Erreur extends StatelessWidget { + const _Erreur({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: [ + Text(message, textAlign: TextAlign.center), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text("Réessayer"), + ), + ], + ), + ); + } +} diff --git a/eme-frontend/lib/services/api_client.dart b/eme-frontend/lib/services/api_client.dart index dea9488..ed32478 100644 --- a/eme-frontend/lib/services/api_client.dart +++ b/eme-frontend/lib/services/api_client.dart @@ -18,13 +18,20 @@ class ApiClient { static const String _baseUrl = "http://localhost:3000/api"; static const String _utilisateurEmail = "lucas.martin@ensitech.eu"; + static const String responsableEmail = "karim.benali@ensup.eu"; - static Future get(String chemin) async { + static Future get( + String chemin, { + String utilisateurEmail = _utilisateurEmail, + }) async { final uri = Uri.parse("$_baseUrl$chemin"); late final http.Response reponse; try { - reponse = await http.get(uri, headers: const {"x-user-email": _utilisateurEmail}); + reponse = await http.get( + uri, + headers: {"x-user-email": utilisateurEmail}, + ); } catch (_) { throw const ApiException( "Impossible de joindre le serveur. Vérifiez que le backend est démarré.", @@ -37,16 +44,49 @@ class ApiClient { throw ApiException(_messageErreur(reponse)); } - static Future post(String chemin, Map body) async { + static Future post( + String chemin, + Map body, { + String utilisateurEmail = _utilisateurEmail, + }) async { final uri = Uri.parse("$_baseUrl$chemin"); late final http.Response reponse; try { reponse = await http.post( uri, - headers: const { + headers: { "content-type": "application/json", - "x-user-email": _utilisateurEmail, + "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 Future patch( + String chemin, + Map body, { + String utilisateurEmail = _utilisateurEmail, + }) async { + final uri = Uri.parse("$_baseUrl$chemin"); + + late final http.Response reponse; + try { + reponse = await http.patch( + uri, + headers: { + "content-type": "application/json", + "x-user-email": utilisateurEmail, }, body: jsonEncode(body), ); diff --git a/eme-frontend/lib/services/responsable_service.dart b/eme-frontend/lib/services/responsable_service.dart new file mode 100644 index 0000000..02ea17e --- /dev/null +++ b/eme-frontend/lib/services/responsable_service.dart @@ -0,0 +1,48 @@ +import "api_client.dart"; + +/// Appels API du tableau responsable matériel. +class ResponsableService { + ResponsableService._(); + + static const String _email = ApiClient.responsableEmail; + + static Future> dashboard() async { + final reponse = await ApiClient.get( + "/responsable/dashboard", + utilisateurEmail: _email, + ); + return Map.from(reponse["data"] as Map); + } + + static Future>> emprunts() => + _liste("/responsable/emprunts"); + + static Future>> materiels() => + _liste("/responsable/materiels"); + + static Future>> anomalies() => + _liste("/responsable/anomalies"); + + static Future>> notifications() => + _liste("/responsable/notifications"); + + static Future>> historique() => + _liste("/responsable/historique"); + + static Future> changerStatutAnomalie( + int id, + String statut, + ) async { + final reponse = await ApiClient.patch("/responsable/anomalies/$id/statut", { + "statut": statut, + "observation": "Traitement depuis l'interface responsable", + }, utilisateurEmail: _email); + return Map.from(reponse["data"] as Map); + } + + static Future>> _liste(String chemin) async { + final reponse = await ApiClient.get(chemin, utilisateurEmail: _email); + final data = reponse["data"] as List; + return data.map((item) => Map.from(item as Map)).toList(); + } +} diff --git a/review.md b/review.md index d94c1fc..3600ade 100644 --- a/review.md +++ b/review.md @@ -381,6 +381,15 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y - Le responsable connecté est enregistré dans `traiteeParId`; une observation optionnelle peut être conservée. - Une entrée d'historique `TRAITEMENT_ANOMALIE` est créée à chaque transition. +### Étape 41 — Frontend responsable : premier branchement API +- Branche dédiée `feat/responsable-frontend-api` créée pour isoler le travail frontend responsable. +- Le bouton "Accès responsable matériel" de l'écran d'identification ouvre maintenant un écran responsable. +- Client API étendu pour accepter l'e-mail simulé du responsable (`karim.benali@ensup.eu`) et les requêtes `PATCH`. +- Service frontend responsable ajouté pour consommer dashboard, emprunts, stock, anomalies, notifications et historique. +- Écran responsable ajouté avec onglets : dashboard, emprunts, stock, anomalies, notifications, historique. +- Les anomalies peuvent être avancées au prochain statut autorisé via l'API. +- Vérification statique effectuée avec l'exécutable Dart direct : `dart analyze` OK. Le wrapper `flutter` reste instable dans cette session et bloque au lancement web automatisé. + --- -*Dernière mise à jour : 2026-07-16 — Bloc API responsable complété côté consultation, notifications, historique/export et cycle anomalies.* +*Dernière mise à jour : 2026-07-17 — Premier écran frontend responsable branché aux APIs validées.*