diff --git a/eme-frontend/lib/screens/responsable_screen.dart b/eme-frontend/lib/screens/responsable_screen.dart index 41ced1d..32536d4 100644 --- a/eme-frontend/lib/screens/responsable_screen.dart +++ b/eme-frontend/lib/screens/responsable_screen.dart @@ -4,8 +4,9 @@ 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"; + +const _workspaceBackground = Color(0xFFF3F6F9); +const _orange = Color(0xFFD97706); class ResponsableScreen extends StatefulWidget { const ResponsableScreen({super.key}); @@ -90,71 +91,56 @@ class _ResponsableScreenState extends State { @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!); - }, - ), - ), - ], - ), + backgroundColor: _workspaceBackground, + body: SafeArea( + child: Column( + children: [ + _ResponsableTopBar( + onExit: () => Navigator.of(context).pop(), + onRefresh: _rafraichir, + ), + Expanded( + child: FutureBuilder<_ResponsableData>( + future: _future, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const _ChargementWorkspace(); + } + 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 kpis = Map.from(data.dashboard["kpis"] as Map); + final anomalies = _repartition(kpis["anomalies"]); + final anomaliesActives = + _statutCount(anomalies, "DETECTEE") + + _statutCount(anomalies, "EN_COURS_TRAITEMENT"); + final notificationsNonLues = kpis["notificationsNonLues"] as int? ?? 0; + final vues = [ - _VueDashboard(data: data), + _VueDashboard( + data: data, + onNavigate: (index) => setState(() => _onglet = index), + onRefresh: _rafraichir, + ), _VueListe( titre: "Emprunts", + description: + "Suivez les prêts en cours, les retards et les retours clôturés.", + icon: Icons.assignment_outlined, items: data.emprunts, builder: _empruntTile, searchableText: (item) { @@ -164,7 +150,10 @@ class _ResponsableScreenState extends State { }, ), _VueListe( - titre: "Stock", + titre: "Stock matériel", + description: + "Consultez la disponibilité et l’état du parc de votre campus.", + icon: Icons.inventory_2_outlined, items: data.materiels, builder: _materielTile, searchableText: (item) { @@ -174,6 +163,9 @@ class _ResponsableScreenState extends State { ), _VueListe( titre: "Anomalies", + description: + "Priorisez et faites avancer les incidents détectés lors des retours.", + icon: Icons.report_problem_outlined, items: data.anomalies, builder: _anomalieTile, searchableText: (item) { @@ -183,6 +175,9 @@ class _ResponsableScreenState extends State { ), _VueListe( titre: "Notifications", + description: + "Centralisez les alertes liées aux opérations et aux anomalies.", + icon: Icons.notifications_none_outlined, items: data.notifications, builder: _notificationTile, action: OutlinedButton.icon( @@ -194,6 +189,9 @@ class _ResponsableScreenState extends State { ), _VueListe( titre: "Historique", + description: + "Retrouvez la trace complète des actions réalisées sur le campus.", + icon: Icons.history, items: data.historique, builder: _historiqueTile, action: OutlinedButton.icon( @@ -213,22 +211,34 @@ class _ResponsableScreenState extends State { ), ]; - return Column( - children: [ - _Onglets( - index: _onglet, - labels: const [ - "Dashboard", - "Emprunts", - "Stock", - "Anomalies", - "Notifications", - "Historique", + return LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 900; + return Row( + children: [ + if (!compact) + _ResponsableSidebar( + index: _onglet, + anomaliesActives: anomaliesActives, + notificationsNonLues: notificationsNonLues, + onChanged: (index) => setState(() => _onglet = index), + onExit: () => Navigator.of(context).pop(), + ), + Expanded( + child: Column( + children: [ + if (compact) + _NavigationCompacte( + index: _onglet, + onChanged: (index) => setState(() => _onglet = index), + ), + Expanded(child: vues[_onglet]), + ], + ), + ), ], - onChanged: (index) => setState(() => _onglet = index), - ), - Expanded(child: vues[_onglet]), - ], + ); + }, ); } @@ -265,12 +275,12 @@ class _ResponsableScreenState extends State { title: "${item["type"]} · ${materiel["nom"]}", subtitle: "${item["description"]}", meta: "Détectée le ${_date(item["dateDetection"])}", - trailing: prochain == null - ? _StatusBadge("${item["statut"]}") - : FilledButton( - onPressed: _transitionEnCours ? null : () => _changerStatut(item), - child: Text(_statusLabel(prochain)), - ), + trailing: _AnomalieAction( + statut: "${item["statut"]}", + prochainStatut: prochain, + disabled: _transitionEnCours, + onPressed: () => _changerStatut(item), + ), ); } @@ -357,6 +367,509 @@ Color _statusColor(String statut) { }; } +Map _repartition(Object? value) { + if (value is! Map) return {}; + return Map.from(value); +} + +int _statutCount(Map repartition, String statut) { + final parStatut = repartition["parStatut"]; + if (parStatut is! Map) return 0; + final value = parStatut[statut]; + return value is int ? value : 0; +} + +String _dateLongue(DateTime date) { + const jours = [ + "lundi", + "mardi", + "mercredi", + "jeudi", + "vendredi", + "samedi", + "dimanche", + ]; + const mois = [ + "janvier", + "février", + "mars", + "avril", + "mai", + "juin", + "juillet", + "août", + "septembre", + "octobre", + "novembre", + "décembre", + ]; + return "${jours[date.weekday - 1]} ${date.day} ${mois[date.month - 1]} ${date.year}"; +} + +String _dateHeure(Object? value) { + final date = DateTime.tryParse("$value")?.toLocal(); + if (date == null) return "-"; + final jour = date.day.toString().padLeft(2, "0"); + final mois = date.month.toString().padLeft(2, "0"); + final heure = date.hour.toString().padLeft(2, "0"); + final minute = date.minute.toString().padLeft(2, "0"); + return "$jour/$mois · $heure:$minute"; +} + +class _ResponsableTopBar extends StatelessWidget { + const _ResponsableTopBar({required this.onExit, required this.onRefresh}); + + final VoidCallback onExit; + final VoidCallback onRefresh; + + @override + Widget build(BuildContext context) { + return Container( + height: 68, + padding: const EdgeInsets.symmetric(horizontal: 20), + decoration: const BoxDecoration( + color: Colors.white, + border: Border(bottom: BorderSide(color: EnsupColors.line)), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final showCampus = constraints.maxWidth >= 650; + final showName = constraints.maxWidth >= 820; + return Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: EnsupColors.blue3, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.grid_view_rounded, + color: Colors.white, + size: 19, + ), + ), + const SizedBox(width: 12), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "EME", + style: GoogleFonts.darkerGrotesque( + fontSize: 21, + height: 0.95, + fontWeight: FontWeight.w900, + color: EnsupColors.text, + ), + ), + Text( + "Pilotage matériel", + style: GoogleFonts.titilliumWeb( + fontSize: 11, + color: EnsupColors.muted, + ), + ), + ], + ), + const Spacer(), + if (showCampus) ...[ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 7, + ), + decoration: BoxDecoration( + color: _workspaceBackground, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: EnsupColors.line), + ), + child: Row( + children: [ + const Icon( + Icons.location_on_outlined, + size: 15, + color: EnsupColors.blue3, + ), + const SizedBox(width: 6), + Text( + DemoIdentity.loginCampusTag, + style: GoogleFonts.titilliumWeb( + fontSize: 12, + fontWeight: FontWeight.w600, + color: EnsupColors.text2, + ), + ), + ], + ), + ), + const SizedBox(width: 10), + ], + Tooltip( + message: "Actualiser les données", + child: IconButton( + onPressed: onRefresh, + icon: const Icon(Icons.refresh_rounded), + color: EnsupColors.text2, + ), + ), + const SizedBox(width: 4), + Container(width: 1, height: 28, color: EnsupColors.line), + const SizedBox(width: 12), + Container( + width: 34, + height: 34, + alignment: Alignment.center, + decoration: const BoxDecoration( + color: EnsupColors.purple, + shape: BoxShape.circle, + ), + child: Text( + "KB", + style: GoogleFonts.darkerGrotesque( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w900, + ), + ), + ), + if (showName) ...[ + const SizedBox(width: 9), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Karim Benali", + style: GoogleFonts.titilliumWeb( + fontSize: 13, + fontWeight: FontWeight.w700, + color: EnsupColors.text, + ), + ), + Text( + "Responsable matériel", + style: GoogleFonts.titilliumWeb( + fontSize: 11, + color: EnsupColors.muted, + ), + ), + ], + ), + ], + const SizedBox(width: 4), + Tooltip( + message: "Quitter l’espace responsable", + child: IconButton( + onPressed: onExit, + icon: const Icon(Icons.logout_rounded), + color: EnsupColors.muted, + ), + ), + ], + ); + }, + ), + ); + } +} + +class _ResponsableSidebar extends StatelessWidget { + const _ResponsableSidebar({ + required this.index, + required this.anomaliesActives, + required this.notificationsNonLues, + required this.onChanged, + required this.onExit, + }); + + final int index; + final int anomaliesActives; + final int notificationsNonLues; + final ValueChanged onChanged; + final VoidCallback onExit; + + static const _destinations = [ + ("Vue d’ensemble", Icons.space_dashboard_outlined), + ("Emprunts", Icons.assignment_outlined), + ("Stock matériel", Icons.inventory_2_outlined), + ("Anomalies", Icons.report_problem_outlined), + ("Notifications", Icons.notifications_none_outlined), + ("Historique", Icons.history), + ]; + + @override + Widget build(BuildContext context) { + return Container( + width: 244, + color: EnsupColors.blue3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 10), + child: Text( + "SUPERVISION", + style: GoogleFonts.titilliumWeb( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Colors.white.withValues(alpha: 0.58), + ), + ), + ), + ...List.generate(_destinations.length, (itemIndex) { + final destination = _destinations[itemIndex]; + final badge = switch (itemIndex) { + 3 => anomaliesActives, + 4 => notificationsNonLues, + _ => 0, + }; + return _SidebarItem( + label: destination.$1, + icon: destination.$2, + selected: itemIndex == index, + badge: badge, + alert: itemIndex == 3, + onTap: () => onChanged(itemIndex), + ); + }), + const Spacer(), + Padding( + padding: const EdgeInsets.all(16), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.white.withValues(alpha: 0.12)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.verified_user_outlined, + size: 17, + color: Colors.white, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + "Session responsable", + style: GoogleFonts.titilliumWeb( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + "Accès limité au campus actif", + style: GoogleFonts.titilliumWeb( + color: Colors.white.withValues(alpha: 0.62), + fontSize: 11, + ), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 18), + child: TextButton.icon( + onPressed: onExit, + style: TextButton.styleFrom( + foregroundColor: Colors.white.withValues(alpha: 0.78), + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + ), + icon: const Icon(Icons.logout_rounded, size: 18), + label: const Text("Quitter l’espace"), + ), + ), + ], + ), + ); + } +} + +class _SidebarItem extends StatelessWidget { + const _SidebarItem({ + required this.label, + required this.icon, + required this.selected, + required this.badge, + required this.alert, + required this.onTap, + }); + + final String label; + final IconData icon; + final bool selected; + final int badge; + final bool alert; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final foreground = selected + ? Colors.white + : Colors.white.withValues(alpha: 0.72); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3), + child: Material( + color: selected + ? Colors.white.withValues(alpha: 0.14) + : Colors.transparent, + borderRadius: BorderRadius.circular(7), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(7), + child: Container( + height: 46, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + border: selected + ? const Border( + left: BorderSide(color: EnsupColors.cyan, width: 3), + ) + : null, + ), + child: Row( + children: [ + Icon(icon, size: 19, color: foreground), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: GoogleFonts.titilliumWeb( + fontSize: 13, + fontWeight: selected ? FontWeight.w700 : FontWeight.w600, + color: foreground, + ), + ), + ), + if (badge > 0) + Container( + constraints: const BoxConstraints(minWidth: 22), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + alignment: Alignment.center, + decoration: BoxDecoration( + color: alert ? EnsupColors.red : EnsupColors.cyan, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + "$badge", + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _NavigationCompacte extends StatelessWidget { + const _NavigationCompacte({required this.index, required this.onChanged}); + + final int index; + final ValueChanged onChanged; + + static const _destinations = [ + ("Vue d’ensemble", Icons.space_dashboard_outlined), + ("Emprunts", Icons.assignment_outlined), + ("Stock", Icons.inventory_2_outlined), + ("Anomalies", Icons.report_problem_outlined), + ("Notifications", Icons.notifications_none_outlined), + ("Historique", Icons.history), + ]; + + @override + Widget build(BuildContext context) { + return Container( + height: 66, + decoration: const BoxDecoration( + color: Colors.white, + border: Border(bottom: BorderSide(color: EnsupColors.line)), + ), + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + scrollDirection: Axis.horizontal, + itemCount: _destinations.length, + separatorBuilder: (_, _) => const SizedBox(width: 6), + itemBuilder: (context, itemIndex) { + final selected = itemIndex == index; + final destination = _destinations[itemIndex]; + return Material( + color: selected + ? EnsupColors.cyan.withValues(alpha: 0.1) + : Colors.transparent, + borderRadius: BorderRadius.circular(7), + child: InkWell( + onTap: () => onChanged(itemIndex), + borderRadius: BorderRadius.circular(7), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + Icon( + destination.$2, + size: 18, + color: selected ? EnsupColors.blue3 : EnsupColors.muted, + ), + const SizedBox(width: 7), + Text( + destination.$1, + style: GoogleFonts.titilliumWeb( + fontSize: 12, + fontWeight: FontWeight.w700, + color: selected ? EnsupColors.blue3 : EnsupColors.muted, + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} + +class _ChargementWorkspace extends StatelessWidget { + const _ChargementWorkspace(); + + @override + Widget build(BuildContext context) { + return const Center( + child: SizedBox( + width: 34, + height: 34, + child: CircularProgressIndicator(strokeWidth: 3), + ), + ); + } +} + class _ResponsableData { const _ResponsableData({ required this.dashboard, @@ -376,53 +889,652 @@ class _ResponsableData { } class _VueDashboard extends StatelessWidget { - const _VueDashboard({required this.data}); + const _VueDashboard({ + required this.data, + required this.onNavigate, + required this.onRefresh, + }); final _ResponsableData data; + final ValueChanged onNavigate; + final VoidCallback onRefresh; @override Widget build(BuildContext context) { final kpis = Map.from(data.dashboard["kpis"] as Map); + final materiels = _repartition(kpis["materiels"]); + final emprunts = _repartition(kpis["emprunts"]); + final anomalies = _repartition(kpis["anomalies"]); + final activite = (data.dashboard["activiteRecente"] as List? ?? const []) + .whereType() + .map((item) => Map.from(item)) + .toList(); + + final totalMateriels = materiels["total"] as int? ?? 0; + final disponibles = _statutCount(materiels, "DISPONIBLE"); + final empruntsEnCours = _statutCount(emprunts, "EN_COURS"); + final retards = _statutCount(emprunts, "EN_RETARD"); + final anomaliesActives = + _statutCount(anomalies, "DETECTEE") + + _statutCount(anomalies, "EN_COURS_TRAITEMENT"); + final notificationsNonLues = kpis["notificationsNonLues"] as int? ?? 0; + final indisponibles = [ + "NON_CONFORME", + "DETERIORE", + "MAINTENANCE", + "INDISPONIBLE", + ].fold(0, (total, statut) => total + _statutCount(materiels, statut)); + return SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Wrap( - spacing: 14, - runSpacing: 14, + padding: const EdgeInsets.all(28), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1440), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _DashboardHeader(onRefresh: onRefresh), + const SizedBox(height: 24), + LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth >= 1120 + ? 4 + : constraints.maxWidth >= 560 + ? 2 + : 1; + const spacing = 14.0; + final width = + (constraints.maxWidth - spacing * (columns - 1)) / + columns; + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: [ + _KpiCard( + width: width, + label: "Matériels disponibles", + value: "$disponibles", + detail: "sur $totalMateriels au total", + icon: Icons.inventory_2_outlined, + color: EnsupColors.cyan, + ), + _KpiCard( + width: width, + label: "Emprunts en cours", + value: "$empruntsEnCours", + detail: retards > 0 + ? "$retards en retard à surveiller" + : "Aucun retard signalé", + icon: Icons.assignment_outlined, + color: retards > 0 ? _orange : EnsupColors.teal, + ), + _KpiCard( + width: width, + label: "Anomalies actives", + value: "$anomaliesActives", + detail: anomaliesActives > 0 + ? "Traitement requis" + : "Aucune anomalie active", + icon: Icons.report_problem_outlined, + color: anomaliesActives > 0 + ? EnsupColors.red + : EnsupColors.green, + ), + _KpiCard( + width: width, + label: "Notifications non lues", + value: "$notificationsNonLues", + detail: notificationsNonLues > 0 + ? "Nouvelles informations" + : "Vous êtes à jour", + icon: Icons.notifications_none_outlined, + color: EnsupColors.purple, + ), + ], + ); + }, + ), + const SizedBox(height: 20), + LayoutBuilder( + builder: (context, constraints) { + final activity = _ActivityPanel( + items: activite, + onSeeAll: () => onNavigate(5), + ); + final priorities = _PrioritiesPanel( + retards: retards, + anomalies: anomaliesActives, + notifications: notificationsNonLues, + indisponibles: indisponibles, + disponibles: disponibles, + totalMateriels: totalMateriels, + onNavigate: onNavigate, + ); + if (constraints.maxWidth < 980) { + return Column( + children: [ + activity, + const SizedBox(height: 16), + priorities, + ], + ); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: 2, child: activity), + const SizedBox(width: 16), + Expanded(child: priorities), + ], + ); + }, + ), + ], + ), + ), + ), + ); + } +} + +class _DashboardHeader extends StatelessWidget { + const _DashboardHeader({required this.onRefresh}); + + final VoidCallback onRefresh; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Vue d’ensemble", + style: GoogleFonts.darkerGrotesque( + fontSize: 32, + height: 1, + fontWeight: FontWeight.w900, + color: EnsupColors.text, + ), + ), + const SizedBox(height: 7), + Text( + "La situation opérationnelle du campus en un coup d’œil · ${_dateLongue(DateTime.now())}", + style: GoogleFonts.titilliumWeb( + fontSize: 14, + color: EnsupColors.muted, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + OutlinedButton.icon( + onPressed: onRefresh, + icon: const Icon(Icons.refresh_rounded, size: 17), + label: const Text("Actualiser"), + ), + ], + ); + } +} + +class _KpiCard extends StatelessWidget { + const _KpiCard({ + required this.width, + required this.label, + required this.value, + required this.detail, + required this.icon, + required this.color, + }); + + final double width; + final String label; + final String value; + final String detail; + final IconData icon; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: 132, + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: EnsupColors.line), + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: EnsupColors.text.withValues(alpha: 0.045), + blurRadius: 14, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( children: [ - _KpiCard( - label: "Matériels", - value: _total(kpis["materiels"]), - icon: Icons.inventory_2_outlined, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.titilliumWeb( + fontSize: 13, + fontWeight: FontWeight.w600, + color: EnsupColors.muted, + ), + ), + Text( + value, + style: GoogleFonts.darkerGrotesque( + fontSize: 36, + height: 0.9, + fontWeight: FontWeight.w900, + color: EnsupColors.text, + ), + ), + Text( + detail, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.titilliumWeb( + fontSize: 11, + color: color, + fontWeight: FontWeight.w600, + ), + ), + ], + ), ), - _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, + const SizedBox(width: 10), + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: color, size: 22), ), ], ), ); } +} - String _total(Object? repartition) { - final map = Map.from(repartition as Map); - return "${map["total"] ?? 0}"; +class _ActivityPanel extends StatelessWidget { + const _ActivityPanel({required this.items, required this.onSeeAll}); + + final List> items; + final VoidCallback onSeeAll; + + @override + Widget build(BuildContext context) { + return _DashboardPanel( + title: "Activité récente", + subtitle: "Dernières opérations enregistrées sur le campus", + action: TextButton( + onPressed: onSeeAll, + child: const Text("Voir l’historique"), + ), + child: items.isEmpty + ? const _PanelEmptyState( + icon: Icons.history_toggle_off_outlined, + message: "Aucune activité récente", + ) + : Column( + children: List.generate(items.take(6).length, (index) { + final item = items[index]; + return Column( + children: [ + _ActivityRow(item: item), + if (index < items.take(6).length - 1) + const Divider(height: 1, color: EnsupColors.line), + ], + ); + }), + ), + ); + } +} + +class _ActivityRow extends StatelessWidget { + const _ActivityRow({required this.item}); + + final Map item; + + @override + Widget build(BuildContext context) { + final utilisateur = _repartition(item["utilisateur"]); + final materiel = _repartition(item["materiel"]); + final action = "${item["action"] ?? ""}"; + final isAlert = action == "CREATION_ANOMALIE"; + final title = materiel.isNotEmpty + ? "${materiel["nom"]}" + : _actionLabel(action); + final userName = utilisateur.isEmpty + ? "Système" + : "${utilisateur["prenom"]} ${utilisateur["nom"]}"; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 13), + child: Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: (isAlert ? EnsupColors.red : EnsupColors.cyan).withValues( + alpha: 0.09, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + isAlert + ? Icons.report_problem_outlined + : Icons.swap_horiz_rounded, + size: 19, + color: isAlert ? EnsupColors.red : EnsupColors.blue3, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.titilliumWeb( + fontSize: 13, + fontWeight: FontWeight.w700, + color: EnsupColors.text, + ), + ), + Text( + "${item["description"] ?? _actionLabel(action)} · $userName", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.titilliumWeb( + fontSize: 12, + color: EnsupColors.muted, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Text( + _dateHeure(item["dateAction"]), + style: GoogleFonts.titilliumWeb( + fontSize: 11, + color: EnsupColors.muted, + ), + ), + ], + ), + ); + } +} + +class _PrioritiesPanel extends StatelessWidget { + const _PrioritiesPanel({ + required this.retards, + required this.anomalies, + required this.notifications, + required this.indisponibles, + required this.disponibles, + required this.totalMateriels, + required this.onNavigate, + }); + + final int retards; + final int anomalies; + final int notifications; + final int indisponibles; + final int disponibles; + final int totalMateriels; + final ValueChanged onNavigate; + + @override + Widget build(BuildContext context) { + final availability = totalMateriels == 0 + ? 0.0 + : disponibles / totalMateriels; + return _DashboardPanel( + title: "Points d’attention", + subtitle: "Éléments qui nécessitent votre suivi", + child: Column( + children: [ + _PriorityRow( + label: "Emprunts en retard", + value: retards, + icon: Icons.schedule_outlined, + color: _orange, + onTap: () => onNavigate(1), + ), + const Divider(height: 1, color: EnsupColors.line), + _PriorityRow( + label: "Anomalies actives", + value: anomalies, + icon: Icons.report_problem_outlined, + color: EnsupColors.red, + onTap: () => onNavigate(3), + ), + const Divider(height: 1, color: EnsupColors.line), + _PriorityRow( + label: "Notifications non lues", + value: notifications, + icon: Icons.notifications_none_outlined, + color: EnsupColors.purple, + onTap: () => onNavigate(4), + ), + const Divider(height: 1, color: EnsupColors.line), + _PriorityRow( + label: "Matériels indisponibles", + value: indisponibles, + icon: Icons.build_outlined, + color: EnsupColors.teal, + onTap: () => onNavigate(2), + ), + const SizedBox(height: 20), + Row( + children: [ + Text( + "Disponibilité du parc", + style: GoogleFonts.titilliumWeb( + fontSize: 12, + fontWeight: FontWeight.w700, + color: EnsupColors.text2, + ), + ), + const Spacer(), + Text( + "${(availability * 100).round()} %", + style: GoogleFonts.darkerGrotesque( + fontSize: 17, + fontWeight: FontWeight.w900, + color: EnsupColors.blue3, + ), + ), + ], + ), + const SizedBox(height: 7), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: availability, + minHeight: 7, + backgroundColor: EnsupColors.line, + color: EnsupColors.cyan, + ), + ), + ], + ), + ); + } +} + +class _PriorityRow extends StatelessWidget { + const _PriorityRow({ + required this.label, + required this.value, + required this.icon, + required this.color, + required this.onTap, + }); + + final String label; + final int value; + final IconData icon; + final Color color; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 11), + child: Row( + children: [ + Icon(icon, size: 18, color: color), + const SizedBox(width: 10), + Expanded( + child: Text( + label, + style: GoogleFonts.titilliumWeb( + fontSize: 12, + fontWeight: FontWeight.w600, + color: EnsupColors.text2, + ), + ), + ), + Text( + "$value", + style: GoogleFonts.darkerGrotesque( + fontSize: 20, + fontWeight: FontWeight.w900, + color: value > 0 ? color : EnsupColors.muted, + ), + ), + const SizedBox(width: 5), + const Icon( + Icons.chevron_right_rounded, + size: 18, + color: EnsupColors.muted, + ), + ], + ), + ), + ); + } +} + +class _DashboardPanel extends StatelessWidget { + const _DashboardPanel({ + required this.title, + required this.subtitle, + required this.child, + this.action, + }); + + final String title; + final String subtitle; + final Widget child; + final Widget? action; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: EnsupColors.line), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.darkerGrotesque( + fontSize: 21, + fontWeight: FontWeight.w900, + color: EnsupColors.text, + ), + ), + Text( + subtitle, + style: GoogleFonts.titilliumWeb( + fontSize: 12, + color: EnsupColors.muted, + ), + ), + ], + ), + ), + ?action, + ], + ), + const SizedBox(height: 12), + child, + ], + ), + ); + } +} + +class _PanelEmptyState extends StatelessWidget { + const _PanelEmptyState({required this.icon, required this.message}); + + final IconData icon; + final String message; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 180, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 32, color: EnsupColors.muted), + const SizedBox(height: 8), + Text( + message, + style: GoogleFonts.titilliumWeb(color: EnsupColors.muted), + ), + ], + ), + ), + ); } } class _VueListe extends StatefulWidget { const _VueListe({ required this.titre, + required this.description, + required this.icon, required this.items, required this.builder, required this.searchableText, @@ -430,6 +1542,8 @@ class _VueListe extends StatefulWidget { }); final String titre; + final String description; + final IconData icon; final List> items; final Widget Function(Map) builder; final String Function(Map) searchableText; @@ -454,146 +1568,156 @@ class _VueListeState extends State<_VueListe> { ) .toList(); - return Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - "${widget.titre} (${resultats.length})", - style: GoogleFonts.darkerGrotesque( - fontSize: 26, - fontWeight: FontWeight.w900, - color: EnsupColors.text, + return ColoredBox( + color: _workspaceBackground, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1440), + child: SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsets.all(28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 620; + final title = Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: EnsupColors.cyan.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + widget.icon, + color: EnsupColors.blue3, + size: 21, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.titre, + style: GoogleFonts.darkerGrotesque( + fontSize: 29, + height: 1, + fontWeight: FontWeight.w900, + color: EnsupColors.text, + ), + ), + Text( + widget.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.titilliumWeb( + fontSize: 13, + color: EnsupColors.muted, + ), + ), + ], + ), + ), + ], + ); + if (compact && widget.action != null) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + title, + const SizedBox(height: 14), + Align( + alignment: Alignment.centerLeft, + child: widget.action!, + ), + ], + ); + } + return Row( + children: [ + Expanded(child: title), + if (widget.action != null) ...[ + const SizedBox(width: 16), + widget.action!, + ], + ], + ); + }, ), - ), - ), - if (widget.action != null) widget.action!, - ], - ), - const SizedBox(height: 14), - TextField( - onChanged: (value) => setState(() => _recherche = value), - decoration: InputDecoration( - hintText: "Rechercher", - prefixIcon: const Icon(Icons.search), - isDense: true, - filled: true, - fillColor: EnsupColors.soft, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: EnsupColors.line), + const SizedBox(height: 22), + Container( + height: 46, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: EnsupColors.line), + boxShadow: [ + BoxShadow( + color: EnsupColors.text.withValues(alpha: 0.035), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: TextField( + onChanged: (value) => setState(() => _recherche = value), + decoration: InputDecoration( + hintText: + "Rechercher dans ${widget.titre.toLowerCase()}...", + prefixIcon: const Icon( + Icons.search_rounded, + size: 20, + color: EnsupColors.muted, + ), + suffixIcon: Padding( + padding: const EdgeInsets.only(right: 10), + child: Center( + widthFactor: 1, + child: Text( + "${resultats.length} résultat${resultats.length > 1 ? "s" : ""}", + style: GoogleFonts.titilliumWeb( + fontSize: 11, + color: EnsupColors.muted, + ), + ), + ), + ), + isDense: true, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + vertical: 13, + ), + ), + ), + ), + const SizedBox(height: 14), + Expanded( + child: resultats.isEmpty + ? const _EmptyState() + : ListView.separated( + itemCount: resultats.length, + separatorBuilder: (_, _) => + const SizedBox(height: 8), + itemBuilder: (_, index) => + widget.builder(resultats[index]), + ), + ), + ], ), ), ), - const SizedBox(height: 14), - Expanded( - child: resultats.isEmpty - ? const _EmptyState() - : ListView.separated( - itemCount: resultats.length, - separatorBuilder: (_, _) => const SizedBox(height: 10), - itemBuilder: (_, index) => widget.builder(resultats[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, @@ -612,49 +1736,132 @@ class _InfoTile extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.all(14), + padding: const EdgeInsets.all(16), 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, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: GoogleFonts.darkerGrotesque( - fontSize: 19, - fontWeight: FontWeight.w800, - ), - ), - Text(subtitle, maxLines: 2, overflow: TextOverflow.ellipsis), - if (meta != null) ...[ - const SizedBox(height: 4), - Text( - meta!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: GoogleFonts.titilliumWeb( - fontSize: 12, - color: EnsupColors.muted, - ), - ), - ], - ], - ), + boxShadow: [ + BoxShadow( + color: EnsupColors.text.withValues(alpha: 0.035), + blurRadius: 10, + offset: const Offset(0, 3), ), - const SizedBox(width: 12), - trailing, ], ), + child: LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 620; + final information = Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: EnsupColors.teal.withValues(alpha: 0.09), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 20, color: EnsupColors.teal), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: compact ? 2 : 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.darkerGrotesque( + fontSize: 19, + height: 1.05, + fontWeight: FontWeight.w800, + color: EnsupColors.text, + ), + ), + const SizedBox(height: 3), + Text( + subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.titilliumWeb( + fontSize: 13, + color: EnsupColors.text2, + ), + ), + if (meta != null) ...[ + const SizedBox(height: 4), + Text( + meta!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.titilliumWeb( + fontSize: 11, + color: EnsupColors.muted, + ), + ), + ], + ], + ), + ), + ], + ); + + if (compact) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + information, + const SizedBox(height: 12), + Align(alignment: Alignment.centerRight, child: trailing), + ], + ); + } + + return Row( + children: [ + Expanded(child: information), + const SizedBox(width: 16), + trailing, + ], + ); + }, + ), + ); + } +} + +class _AnomalieAction extends StatelessWidget { + const _AnomalieAction({ + required this.statut, + required this.prochainStatut, + required this.disabled, + required this.onPressed, + }); + + final String statut; + final String? prochainStatut; + final bool disabled; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final prochain = prochainStatut; + return Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + alignment: WrapAlignment.end, + children: [ + _StatusBadge(statut), + if (prochain != null) + FilledButton.icon( + onPressed: disabled ? null : onPressed, + icon: const Icon(Icons.arrow_forward_rounded, size: 16), + label: Text(_statusLabel(prochain)), + ), + ], ); } } diff --git a/review.md b/review.md index ca9606d..6521361 100644 --- a/review.md +++ b/review.md @@ -39,7 +39,7 @@ l'historique complet, y compris des étapes devenues obsolètes après brancheme | Frontend étudiant | Fonctionnel pour la V1 | Parcours emprunt et restitution branchés sur l'API et testés en réel ; conservation de l'action avant identification à finaliser. | | Authentification | Simulée | `x-user-email` côté backend et identité démo côté frontend ; Azure AD reste à faire. | | API responsable | Terminé pour la V1 | Dashboard, emprunts, stock, anomalies, notifications, historique et export CSV, avec contrôle du rôle et du campus. | -| Frontend responsable | Fonctionnel pour la V1 | Dashboard et vues métier branchés sur l'API ; téléchargement CSV dans le navigateur à finaliser. | +| Frontend responsable | Fonctionnel pour la V1 | Espace de supervision responsive avec navigation dédiée, dashboard métier et vues API ; téléchargement CSV à finaliser. | | Tests automatisés | Non démarré | Tests backend/frontend/E2E à ajouter ; tests runtime manuels effectués. | | Documentation | Partielle | README principal et documents de conception présents ; OpenAPI, guides utilisateur et captures restent à produire. | @@ -415,6 +415,14 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y - Limites restantes explicitées : authentification Azure AD, téléchargement CSV frontend, tests automatisés, OpenAPI et guides utilisateur. - Commandes de vérification actualisées : build et lint backend verts, analyse Dart directe sans erreur. +### Étape 44 — Refonte premium de l'espace responsable +- Branche dédiée `style/responsable-premium-dashboard` créée depuis `develop`. +- L'espace responsable adopte une structure de supervision distincte du parcours étudiant : barre supérieure métier, navigation latérale sur ordinateur et navigation compacte sur petit écran. +- Dashboard enrichi avec disponibilités, emprunts en cours, retards, anomalies actives, notifications non lues, activité récente et taux de disponibilité du parc. +- Accès directs ajoutés depuis les points d'attention vers les vues emprunts, stock, anomalies et notifications. +- Les listes métier partagent désormais une hiérarchie visuelle, une recherche et des lignes responsives cohérentes avec la charte ENSUP. +- Aucun changement backend ni métier ; rendu validé manuellement dans le navigateur et `dart analyze` sans erreur. + --- -*Dernière mise à jour : 2026-07-22 — Synthèse synchronisée avec l'état réel du projet après finalisation du premier parcours responsable.* +*Dernière mise à jour : 2026-07-22 — Espace responsable restructuré en interface de supervision premium et responsive.*