From 4b176b7083d735d867f236bf99b417276b00bfd7 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Mon, 23 Feb 2026 23:06:17 +0100 Subject: [PATCH] feat: livrer ticket #93 et finaliser #17 avec gestion des Relais (#95) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Homogénéise le dashboard admin (onglets/listes/cartes/états) via composants réutilisables, finalise la création gestionnaire côté backend, et intègre la gestion des Relais avec rattachement gestionnaire. Co-authored-by: Cursor --- docs/EVOLUTIONS_CDC.md | 22 +- frontend/lib/models/relais_model.dart | 33 + frontend/lib/models/user.dart | 13 + .../admin_dashboardScreen.dart | 52 +- frontend/lib/services/api/api_config.dart | 20 +- frontend/lib/services/relais_service.dart | 97 ++ frontend/lib/services/user_service.dart | 27 +- .../admin/admin_management_widget.dart | 134 +- ...sistante_maternelle_management_widget.dart | 216 ++-- .../admin/common/admin_detail_modal.dart | 138 ++ .../admin/common/admin_list_state.dart | 49 + .../widgets/admin/common/admin_user_card.dart | 134 ++ .../lib/widgets/admin/common/user_list.dart | 45 + .../lib/widgets/admin/dashboard_admin.dart | 130 +- .../lib/widgets/admin/gestionnaire_card.dart | 75 -- .../admin/gestionnaire_management_widget.dart | 208 +-- .../lib/widgets/admin/parametres_panel.dart | 149 ++- .../admin/parent_managmant_widget.dart | 239 ++-- .../admin/relais_management_panel.dart | 1134 +++++++++++++++++ .../widgets/admin/user_management_panel.dart | 176 +++ 20 files changed, 2516 insertions(+), 575 deletions(-) create mode 100644 frontend/lib/models/relais_model.dart create mode 100644 frontend/lib/services/relais_service.dart create mode 100644 frontend/lib/widgets/admin/common/admin_detail_modal.dart create mode 100644 frontend/lib/widgets/admin/common/admin_list_state.dart create mode 100644 frontend/lib/widgets/admin/common/admin_user_card.dart create mode 100644 frontend/lib/widgets/admin/common/user_list.dart delete mode 100644 frontend/lib/widgets/admin/gestionnaire_card.dart create mode 100644 frontend/lib/widgets/admin/relais_management_panel.dart create mode 100644 frontend/lib/widgets/admin/user_management_panel.dart diff --git a/docs/EVOLUTIONS_CDC.md b/docs/EVOLUTIONS_CDC.md index 6496fc6..6ee6d1e 100644 --- a/docs/EVOLUTIONS_CDC.md +++ b/docs/EVOLUTIONS_CDC.md @@ -255,4 +255,24 @@ Pour chaque évolution identifiée, ce document suivra la structure suivante : #### X.1.3 Impact sur l'application - Modification du flux de sélection d'image dans les écrans concernés (ex: `parent_register_step3_screen.dart`). - Ajout potentiel de nouvelles dépendances et configurations spécifiques aux plateformes. -- Mise à jour de la documentation utilisateur si cette fonctionnalité est implémentée. \ No newline at end of file +- Mise à jour de la documentation utilisateur si cette fonctionnalité est implémentée. + +## 8. Évolution future - Gouvernance intra-RPE + +### 8.1 Niveaux d'accès et rôles différenciés dans un même Relais + +#### 8.1.1 Situation actuelle +- Le périmètre actuel prévoit un rattachement simple entre gestionnaire et relais. +- Le rôle "gestionnaire" est traité de manière uniforme dans l'outil. + +#### 8.1.2 Évolution à prévoir +- Introduire un modèle de rôles internes au relais (par exemple : responsable/coordinatrice, animatrice/référente, administratif). +- Permettre des niveaux d'autorité différents selon les actions (pilotage, validation, consultation, administration locale). +- Définir des permissions fines par fonctionnalité (lecture, création, modification, suppression, validation). +- Prévoir une gestion multi-utilisateurs par relais avec traçabilité des décisions. + +#### 8.1.3 Impact attendu +- Évolution du modèle de données vers un RBAC intra-RPE. +- Adaptation des écrans d'administration pour gérer les rôles locaux. +- Renforcement des contrôles d'accès backend et des règles métier. +- Clarification des workflows décisionnels dans l'application. \ No newline at end of file diff --git a/frontend/lib/models/relais_model.dart b/frontend/lib/models/relais_model.dart new file mode 100644 index 0000000..f3d199e --- /dev/null +++ b/frontend/lib/models/relais_model.dart @@ -0,0 +1,33 @@ +class RelaisModel { + final String id; + final String nom; + final String adresse; + final Map? horairesOuverture; + final String? ligneFixe; + final bool actif; + final String? notes; + + const RelaisModel({ + required this.id, + required this.nom, + required this.adresse, + this.horairesOuverture, + this.ligneFixe, + required this.actif, + this.notes, + }); + + factory RelaisModel.fromJson(Map json) { + return RelaisModel( + id: (json['id'] ?? '').toString(), + nom: (json['nom'] ?? '').toString(), + adresse: (json['adresse'] ?? '').toString(), + horairesOuverture: json['horaires_ouverture'] is Map + ? json['horaires_ouverture'] as Map + : null, + ligneFixe: json['ligne_fixe'] as String?, + actif: json['actif'] as bool? ?? true, + notes: json['notes'] as String?, + ); + } +} diff --git a/frontend/lib/models/user.dart b/frontend/lib/models/user.dart index 7ecbe79..a07a176 100644 --- a/frontend/lib/models/user.dart +++ b/frontend/lib/models/user.dart @@ -13,6 +13,8 @@ class AppUser { final String? adresse; final String? ville; final String? codePostal; + final String? relaisId; + final String? relaisNom; AppUser({ required this.id, @@ -29,9 +31,15 @@ class AppUser { this.adresse, this.ville, this.codePostal, + this.relaisId, + this.relaisNom, }); factory AppUser.fromJson(Map json) { + final relaisJson = json['relais']; + final relaisMap = + relaisJson is Map ? relaisJson : {}; + return AppUser( id: json['id'] as String, email: json['email'] as String, @@ -56,6 +64,9 @@ class AppUser { adresse: json['adresse'] as String?, ville: json['ville'] as String?, codePostal: json['code_postal'] as String?, + relaisId: (json['relaisId'] ?? json['relais_id'] ?? relaisMap['id']) + ?.toString(), + relaisNom: relaisMap['nom']?.toString(), ); } @@ -75,6 +86,8 @@ class AppUser { 'adresse': adresse, 'ville': ville, 'code_postal': codePostal, + 'relais_id': relaisId, + 'relais_nom': relaisNom, }; } diff --git a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart index 4707621..892eb8b 100644 --- a/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart +++ b/frontend/lib/screens/administrateurs/admin_dashboardScreen.dart @@ -1,10 +1,7 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/services/configuration_service.dart'; -import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart'; -import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart'; -import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart'; -import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/parametres_panel.dart'; +import 'package:p_tits_pas/widgets/admin/user_management_panel.dart'; import 'package:p_tits_pas/widgets/app_footer.dart'; import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; @@ -18,7 +15,7 @@ class AdminDashboardScreen extends StatefulWidget { class _AdminDashboardScreenState extends State { bool? _setupCompleted; int mainTabIndex = 0; - int subIndex = 0; + int settingsSubIndex = 0; @override void initState() { @@ -26,6 +23,11 @@ class _AdminDashboardScreenState extends State { _loadSetupStatus(); } + @override + void dispose() { + super.dispose(); + } + Future _loadSetupStatus() async { try { final completed = await ConfigurationService.getSetupStatus(); @@ -35,10 +37,12 @@ class _AdminDashboardScreenState extends State { if (!completed) mainTabIndex = 1; }); } catch (e) { - if (mounted) setState(() { - _setupCompleted = false; - mainTabIndex = 1; - }); + if (mounted) { + setState(() { + _setupCompleted = false; + mainTabIndex = 1; + }); + } } } @@ -48,9 +52,9 @@ class _AdminDashboardScreenState extends State { }); } - void onSubTabChange(int index) { + void onSettingsSubTabChange(int index) { setState(() { - subIndex = index; + settingsSubIndex = index; }); } @@ -80,9 +84,11 @@ class _AdminDashboardScreenState extends State { body: Column( children: [ if (mainTabIndex == 0) - DashboardUserManagementSubBar( - selectedSubIndex: subIndex, - onSubTabChange: onSubTabChange, + const SizedBox.shrink() + else + DashboardSettingsSubBar( + selectedSubIndex: settingsSubIndex, + onSubTabChange: onSettingsSubTabChange, ), Expanded( child: _getBody(), @@ -95,19 +101,11 @@ class _AdminDashboardScreenState extends State { Widget _getBody() { if (mainTabIndex == 1) { - return ParametresPanel(redirectToLoginAfterSave: !_setupCompleted!); - } - switch (subIndex) { - case 0: - return const GestionnaireManagementWidget(); - case 1: - return const ParentManagementWidget(); - case 2: - return const AssistanteMaternelleManagementWidget(); - case 3: - return const AdminManagementWidget(); - default: - return const Center(child: Text('Page non trouvée')); + return ParametresPanel( + redirectToLoginAfterSave: !_setupCompleted!, + selectedSettingsTabIndex: settingsSubIndex, + ); } + return const AdminUserManagementPanel(); } } diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index a9b48fe..613146d 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -18,11 +18,13 @@ class ApiConfig { static const String gestionnaires = '/gestionnaires'; static const String parents = '/parents'; static const String assistantesMaternelles = '/assistantes-maternelles'; + static const String relais = '/relais'; // Configuration (admin) static const String configuration = '/configuration'; static const String configurationSetupStatus = '/configuration/setup/status'; - static const String configurationSetupComplete = '/configuration/setup/complete'; + static const String configurationSetupComplete = + '/configuration/setup/complete'; static const String configurationTestSmtp = '/configuration/test-smtp'; static const String configurationBulk = '/configuration/bulk'; @@ -33,14 +35,14 @@ class ApiConfig { static const String conversations = '/conversations'; static const String notifications = '/notifications'; - // Headers + // Headers static Map get headers => { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }; + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; static Map authHeaders(String token) => { - ...headers, - 'Authorization': 'Bearer $token', - }; -} \ No newline at end of file + ...headers, + 'Authorization': 'Bearer $token', + }; +} diff --git a/frontend/lib/services/relais_service.dart b/frontend/lib/services/relais_service.dart new file mode 100644 index 0000000..d873965 --- /dev/null +++ b/frontend/lib/services/relais_service.dart @@ -0,0 +1,97 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:p_tits_pas/models/relais_model.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/services/api/tokenService.dart'; + +class RelaisService { + static Future> _headers() async { + final token = await TokenService.getToken(); + return token != null + ? ApiConfig.authHeaders(token) + : Map.from(ApiConfig.headers); + } + + static String _extractError(String body, String fallback) { + try { + final decoded = jsonDecode(body); + if (decoded is Map) { + final message = decoded['message']; + if (message is String && message.trim().isNotEmpty) { + return message; + } + } + } catch (_) {} + return fallback; + } + + static Future> getRelais() async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}'), + headers: await _headers(), + ); + + if (response.statusCode != 200) { + throw Exception( + _extractError(response.body, 'Erreur chargement relais'), + ); + } + + final List data = jsonDecode(response.body); + return data + .whereType>() + .map(RelaisModel.fromJson) + .toList(); + } + + static Future createRelais(Map payload) async { + final response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}'), + headers: await _headers(), + body: jsonEncode(payload), + ); + + if (response.statusCode != 201 && response.statusCode != 200) { + throw Exception( + _extractError(response.body, 'Erreur création relais'), + ); + } + + return RelaisModel.fromJson( + jsonDecode(response.body) as Map); + } + + static Future updateRelais( + String id, + Map payload, + ) async { + final response = await http.patch( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}/$id'), + headers: await _headers(), + body: jsonEncode(payload), + ); + + if (response.statusCode != 200) { + throw Exception( + _extractError(response.body, 'Erreur mise à jour relais'), + ); + } + + return RelaisModel.fromJson( + jsonDecode(response.body) as Map); + } + + static Future deleteRelais(String id) async { + final response = await http.delete( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}/$id'), + headers: await _headers(), + ); + + if (response.statusCode != 200 && response.statusCode != 204) { + throw Exception( + _extractError(response.body, 'Erreur suppression relais'), + ); + } + } +} diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index 80c9cbd..da4f64e 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -29,7 +29,8 @@ class UserService { if (response.statusCode != 200) { final err = jsonDecode(response.body) as Map?; - throw Exception(_toStr(err?['message']) ?? 'Erreur chargement gestionnaires'); + throw Exception( + _toStr(err?['message']) ?? 'Erreur chargement gestionnaires'); } final List data = jsonDecode(response.body); @@ -53,7 +54,8 @@ class UserService { } // Récupérer la liste des assistantes maternelles - static Future> getAssistantesMaternelles() async { + static Future> + getAssistantesMaternelles() async { final response = await http.get( Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}'), headers: await _headers(), @@ -87,8 +89,27 @@ class UserService { .toList(); } } catch (e) { - print('Erreur chargement admins: $e'); + // On garde un fallback vide pour ne pas bloquer l'UI admin. } return []; } + + static Future updateGestionnaireRelais({ + required String gestionnaireId, + required String? relaisId, + }) async { + final response = await http.patch( + Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.gestionnaires}/$gestionnaireId'), + headers: await _headers(), + body: jsonEncode({'relaisId': relaisId}), + ); + + if (response.statusCode != 200 && response.statusCode != 204) { + final err = jsonDecode(response.body) as Map?; + throw Exception( + _toStr(err?['message']) ?? 'Erreur rattachement relais au gestionnaire', + ); + } + } } diff --git a/frontend/lib/widgets/admin/admin_management_widget.dart b/frontend/lib/widgets/admin/admin_management_widget.dart index 58e5eeb..666a64e 100644 --- a/frontend/lib/widgets/admin/admin_management_widget.dart +++ b/frontend/lib/widgets/admin/admin_management_widget.dart @@ -1,9 +1,16 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; class AdminManagementWidget extends StatefulWidget { - const AdminManagementWidget({super.key}); + final String searchQuery; + + const AdminManagementWidget({ + super.key, + required this.searchQuery, + }); @override State createState() => _AdminManagementWidgetState(); @@ -13,21 +20,15 @@ class _AdminManagementWidgetState extends State { bool _isLoading = false; String? _error; List _admins = []; - List _filteredAdmins = []; - final TextEditingController _searchController = TextEditingController(); @override void initState() { super.initState(); _loadAdmins(); - _searchController.addListener(_onSearchChanged); } @override - void dispose() { - _searchController.dispose(); - super.dispose(); - } + void dispose() => super.dispose(); Future _loadAdmins() async { setState(() { @@ -39,7 +40,6 @@ class _AdminManagementWidgetState extends State { if (!mounted) return; setState(() { _admins = list; - _filteredAdmins = list; _isLoading = false; }); } catch (e) { @@ -51,91 +51,41 @@ class _AdminManagementWidgetState extends State { } } - void _onSearchChanged() { - final query = _searchController.text.toLowerCase(); - setState(() { - _filteredAdmins = _admins.where((u) { - final name = u.fullName.toLowerCase(); - final email = u.email.toLowerCase(); - return name.contains(query) || email.contains(query); - }).toList(); - }); - } - @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: TextField( - controller: _searchController, - decoration: const InputDecoration( - hintText: "Rechercher un administrateur...", - prefixIcon: Icon(Icons.search), - border: OutlineInputBorder(), - ), - ), - ), - const SizedBox(width: 16), - ElevatedButton.icon( - onPressed: () { - // TODO: Créer admin - }, - icon: const Icon(Icons.add), - label: const Text("Créer un admin"), - ), - ], - ), - const SizedBox(height: 24), - if (_isLoading) - const Center(child: CircularProgressIndicator()) - else if (_error != null) - Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red))) - else if (_filteredAdmins.isEmpty) - const Center(child: Text("Aucun administrateur trouvé.")) - else - Expanded( - child: ListView.builder( - itemCount: _filteredAdmins.length, - itemBuilder: (context, index) { - final user = _filteredAdmins[index]; - return Card( - margin: const EdgeInsets.only(bottom: 12), - child: ListTile( - leading: CircleAvatar( - child: Text(user.fullName.isNotEmpty - ? user.fullName[0].toUpperCase() - : 'A'), - ), - title: Text(user.fullName.isNotEmpty - ? user.fullName - : 'Sans nom'), - subtitle: Text(user.email), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.edit), - onPressed: () {}, - ), - IconButton( - icon: const Icon(Icons.delete), - onPressed: () {}, - ), - ], - ), - ), - ); - }, - ), - ) - ], - ), + final query = widget.searchQuery.toLowerCase(); + final filteredAdmins = _admins.where((u) { + final name = u.fullName.toLowerCase(); + final email = u.email.toLowerCase(); + return name.contains(query) || email.contains(query); + }).toList(); + + return UserList( + isLoading: _isLoading, + error: _error, + isEmpty: filteredAdmins.isEmpty, + emptyMessage: 'Aucun administrateur trouvé.', + itemCount: filteredAdmins.length, + itemBuilder: (context, index) { + final user = filteredAdmins[index]; + return AdminUserCard( + title: user.fullName, + subtitleLines: [ + user.email, + 'Rôle : ${user.role}', + ], + avatarUrl: user.photoUrl, + actions: [ + IconButton( + icon: const Icon(Icons.edit), + tooltip: 'Modifier', + onPressed: () { + // TODO: Modifier admin + }, + ), + ], + ); + }, ); } } diff --git a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart index c8e1a19..d655055 100644 --- a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart +++ b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart @@ -1,9 +1,19 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; class AssistanteMaternelleManagementWidget extends StatefulWidget { - const AssistanteMaternelleManagementWidget({super.key}); + final String searchQuery; + final int? capacityMin; + + const AssistanteMaternelleManagementWidget({ + super.key, + required this.searchQuery, + this.capacityMin, + }); @override State createState() => @@ -15,25 +25,15 @@ class _AssistanteMaternelleManagementWidgetState bool _isLoading = false; String? _error; List _assistantes = []; - List _filteredAssistantes = []; - - final TextEditingController _zoneController = TextEditingController(); - final TextEditingController _capacityController = TextEditingController(); @override void initState() { super.initState(); _loadAssistantes(); - _zoneController.addListener(_filter); - _capacityController.addListener(_filter); } @override - void dispose() { - _zoneController.dispose(); - _capacityController.dispose(); - super.dispose(); - } + void dispose() => super.dispose(); Future _loadAssistantes() async { setState(() { @@ -45,7 +45,6 @@ class _AssistanteMaternelleManagementWidgetState if (!mounted) return; setState(() { _assistantes = list; - _filter(); _isLoading = false; }); } catch (e) { @@ -57,117 +56,100 @@ class _AssistanteMaternelleManagementWidgetState } } - void _filter() { - final zoneQuery = _zoneController.text.toLowerCase(); - final capacityQuery = int.tryParse(_capacityController.text); - - setState(() { - _filteredAssistantes = _assistantes.where((am) { - final matchesZone = zoneQuery.isEmpty || - (am.residenceCity?.toLowerCase().contains(zoneQuery) ?? false); - final matchesCapacity = capacityQuery == null || - (am.maxChildren != null && am.maxChildren! >= capacityQuery); - return matchesZone && matchesCapacity; - }).toList(); - }); - } - @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 🔎 Zone de filtre - _buildFilterSection(), + final query = widget.searchQuery.toLowerCase(); + final filteredAssistantes = _assistantes.where((am) { + final matchesName = am.user.fullName.toLowerCase().contains(query) || + am.user.email.toLowerCase().contains(query) || + (am.residenceCity?.toLowerCase().contains(query) ?? false); + final matchesCapacity = widget.capacityMin == null || + (am.maxChildren != null && am.maxChildren! >= widget.capacityMin!); + return matchesName && matchesCapacity; + }).toList(); - const SizedBox(height: 16), - - // 📋 Liste des assistantes - if (_isLoading) - const Center(child: CircularProgressIndicator()) - else if (_error != null) - Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red))) - else if (_filteredAssistantes.isEmpty) - const Center(child: Text("Aucune assistante maternelle trouvée.")) - else - Expanded( - child: ListView.builder( - itemCount: _filteredAssistantes.length, - itemBuilder: (context, index) { - final assistante = _filteredAssistantes[index]; - return Card( - margin: const EdgeInsets.symmetric(vertical: 8), - child: ListTile( - leading: CircleAvatar( - backgroundImage: assistante.user.photoUrl != null - ? NetworkImage(assistante.user.photoUrl!) - : null, - child: assistante.user.photoUrl == null - ? const Icon(Icons.face) - : null, - ), - title: Text(assistante.user.fullName.isNotEmpty - ? assistante.user.fullName - : 'Sans nom'), - subtitle: Text( - "N° Agrément : ${assistante.approvalNumber ?? 'N/A'}\nZone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}"), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.edit), - onPressed: () { - // TODO: Ajouter modification - }, - ), - IconButton( - icon: const Icon(Icons.delete), - onPressed: () { - // TODO: Ajouter suppression - }, - ), - ], - ), - ), - ); - }, - ), + return UserList( + isLoading: _isLoading, + error: _error, + isEmpty: filteredAssistantes.isEmpty, + emptyMessage: 'Aucune assistante maternelle trouvée.', + itemCount: filteredAssistantes.length, + itemBuilder: (context, index) { + final assistante = filteredAssistantes[index]; + return AdminUserCard( + title: assistante.user.fullName, + avatarUrl: assistante.user.photoUrl, + fallbackIcon: Icons.face, + subtitleLines: [ + assistante.user.email, + 'Zone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}', + ], + actions: [ + IconButton( + icon: const Icon(Icons.edit), + tooltip: 'Modifier', + onPressed: () { + _openAssistanteDetails(assistante); + }, ), + ], + ); + }, + ); + } + + void _openAssistanteDetails(AssistanteMaternelleModel assistante) { + showDialog( + context: context, + builder: (context) => AdminDetailModal( + title: assistante.user.fullName.isEmpty + ? 'Assistante maternelle' + : assistante.user.fullName, + subtitle: assistante.user.email, + fields: [ + AdminDetailField(label: 'ID', value: _v(assistante.user.id)), + AdminDetailField( + label: 'Numero agrement', + value: _v(assistante.approvalNumber), + ), + AdminDetailField( + label: 'Ville residence', + value: _v(assistante.residenceCity), + ), + AdminDetailField( + label: 'Capacite max', + value: assistante.maxChildren?.toString() ?? '-', + ), + AdminDetailField( + label: 'Places disponibles', + value: assistante.placesAvailable?.toString() ?? '-', + ), + AdminDetailField( + label: 'Telephone', + value: _v(assistante.user.telephone), + ), + AdminDetailField(label: 'Adresse', value: _v(assistante.user.adresse)), + AdminDetailField(label: 'Ville', value: _v(assistante.user.ville)), + AdminDetailField( + label: 'Code postal', + value: _v(assistante.user.codePostal), + ), ], + onEdit: () { + Navigator.of(context).pop(); + ScaffoldMessenger.of(this.context).showSnackBar( + const SnackBar(content: Text('Action Modifier a implementer')), + ); + }, + onDelete: () { + Navigator.of(context).pop(); + ScaffoldMessenger.of(this.context).showSnackBar( + const SnackBar(content: Text('Action Supprimer a implementer')), + ); + }, ), ); } - Widget _buildFilterSection() { - return Wrap( - spacing: 16, - runSpacing: 8, - children: [ - SizedBox( - width: 200, - child: TextField( - controller: _zoneController, - decoration: const InputDecoration( - labelText: "Zone géographique", - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.location_on), - ), - ), - ), - SizedBox( - width: 200, - child: TextField( - controller: _capacityController, - decoration: const InputDecoration( - labelText: "Capacité minimum", - border: OutlineInputBorder(), - ), - keyboardType: TextInputType.number, - ), - ), - ], - ); - } + String _v(String? value) => (value == null || value.isEmpty) ? '-' : value; } diff --git a/frontend/lib/widgets/admin/common/admin_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_detail_modal.dart new file mode 100644 index 0000000..affc0d8 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_detail_modal.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; + +class AdminDetailField { + final String label; + final String value; + + const AdminDetailField({ + required this.label, + required this.value, + }); +} + +class AdminDetailModal extends StatelessWidget { + final String title; + final String? subtitle; + final List fields; + final VoidCallback onEdit; + final VoidCallback onDelete; + + const AdminDetailModal({ + super.key, + required this.title, + this.subtitle, + required this.fields, + required this.onEdit, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 620), + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + if (subtitle != null && subtitle!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + subtitle!, + style: const TextStyle(color: Colors.black54), + ), + ], + ], + ), + ), + IconButton( + tooltip: 'Fermer', + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 8), + const Divider(height: 1), + const SizedBox(height: 12), + Flexible( + child: SingleChildScrollView( + child: Column( + children: fields + .map( + (field) => Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 180, + child: Text( + field.label, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + ), + Expanded( + child: Text( + field.value, + style: const TextStyle(color: Colors.black87), + ), + ), + ], + ), + ), + ) + .toList(), + ), + ), + ), + const SizedBox(height: 14), + Align( + alignment: Alignment.centerRight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + OutlinedButton.icon( + onPressed: onDelete, + icon: const Icon(Icons.delete_outline), + label: const Text('Supprimer'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red.shade700, + side: BorderSide(color: Colors.red.shade300), + ), + ), + const SizedBox(width: 10), + ElevatedButton.icon( + onPressed: onEdit, + icon: const Icon(Icons.edit), + label: const Text('Modifier'), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_list_state.dart b/frontend/lib/widgets/admin/common/admin_list_state.dart new file mode 100644 index 0000000..41b497c --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_list_state.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +class AdminListState extends StatelessWidget { + final bool isLoading; + final String? error; + final bool isEmpty; + final String emptyMessage; + final Widget list; + + const AdminListState({ + super.key, + required this.isLoading, + required this.error, + required this.isEmpty, + required this.emptyMessage, + required this.list, + }); + + @override + Widget build(BuildContext context) { + if (isLoading) { + return const Expanded( + child: Center(child: CircularProgressIndicator()), + ); + } + + if (error != null) { + return Expanded( + child: Center( + child: Text( + 'Erreur: $error', + style: const TextStyle(color: Colors.red), + textAlign: TextAlign.center, + ), + ), + ); + } + + if (isEmpty) { + return Expanded( + child: Center( + child: Text(emptyMessage), + ), + ); + } + + return Expanded(child: list); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_user_card.dart b/frontend/lib/widgets/admin/common/admin_user_card.dart new file mode 100644 index 0000000..914e218 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_user_card.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; + +class AdminUserCard extends StatefulWidget { + final String title; + final List subtitleLines; + final String? avatarUrl; + final IconData fallbackIcon; + final List actions; + + const AdminUserCard({ + super.key, + required this.title, + required this.subtitleLines, + this.avatarUrl, + this.fallbackIcon = Icons.person, + this.actions = const [], + }); + + @override + State createState() => _AdminUserCardState(); +} + +class _AdminUserCardState extends State { + bool _isHovered = false; + + @override + Widget build(BuildContext context) { + final infoLine = + widget.subtitleLines.where((e) => e.trim().isNotEmpty).join(' '); + final actionsWidth = + widget.actions.isNotEmpty ? widget.actions.length * 30.0 : 0.0; + + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(10), + child: InkWell( + onTap: () {}, + borderRadius: BorderRadius.circular(10), + hoverColor: const Color(0x149CC5C0), + child: Card( + margin: const EdgeInsets.only(bottom: 12), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide(color: Colors.grey.shade300), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + child: Row( + children: [ + CircleAvatar( + radius: 14, + backgroundColor: const Color(0xFFEDE5FA), + backgroundImage: widget.avatarUrl != null + ? NetworkImage(widget.avatarUrl!) + : null, + child: widget.avatarUrl == null + ? Icon( + widget.fallbackIcon, + size: 16, + color: const Color(0xFF6B3FA0), + ) + : null, + ), + const SizedBox(width: 10), + Expanded( + child: Row( + children: [ + Flexible( + fit: FlexFit.loose, + child: Text( + widget.title.isNotEmpty ? widget.title : 'Sans nom', + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + infoLine, + style: const TextStyle( + color: Colors.black54, + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + if (widget.actions.isNotEmpty) + SizedBox( + width: actionsWidth, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 120), + opacity: _isHovered ? 1 : 0, + child: IgnorePointer( + ignoring: !_isHovered, + child: IconTheme( + data: const IconThemeData(size: 17), + child: IconButtonTheme( + data: IconButtonThemeData( + style: IconButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.all(4), + minimumSize: const Size(28, 28), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: widget.actions, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/user_list.dart b/frontend/lib/widgets/admin/common/user_list.dart new file mode 100644 index 0000000..1df400c --- /dev/null +++ b/frontend/lib/widgets/admin/common/user_list.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_list_state.dart'; + +class UserList extends StatelessWidget { + final bool isLoading; + final String? error; + final bool isEmpty; + final String emptyMessage; + final int itemCount; + final Widget Function(BuildContext context, int index) itemBuilder; + final EdgeInsetsGeometry padding; + + const UserList({ + super.key, + required this.isLoading, + required this.error, + required this.isEmpty, + required this.emptyMessage, + required this.itemCount, + required this.itemBuilder, + this.padding = const EdgeInsets.all(16), + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: padding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AdminListState( + isLoading: isLoading, + error: error, + isEmpty: isEmpty, + emptyMessage: emptyMessage, + list: ListView.builder( + itemCount: itemCount, + itemBuilder: itemBuilder, + ), + ), + ], + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/dashboard_admin.dart b/frontend/lib/widgets/admin/dashboard_admin.dart index 12fbe8b..ff1aa3b 100644 --- a/frontend/lib/widgets/admin/dashboard_admin.dart +++ b/frontend/lib/widgets/admin/dashboard_admin.dart @@ -3,7 +3,8 @@ import 'package:go_router/go_router.dart'; import 'package:p_tits_pas/services/auth_service.dart'; /// Barre du dashboard admin : onglets Gestion des utilisateurs | Paramètres + déconnexion. -class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidget { +class DashboardAppBarAdmin extends StatelessWidget + implements PreferredSizeWidget { final int selectedIndex; final ValueChanged onTabChange; final bool setupCompleted; @@ -36,7 +37,8 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge child: Row( mainAxisSize: MainAxisSize.min, children: [ - _buildNavItem(context, 'Gestion des utilisateurs', 0, enabled: setupCompleted), + _buildNavItem(context, 'Gestion des utilisateurs', 0, + enabled: setupCompleted), const SizedBox(width: 24), _buildNavItem(context, 'Paramètres', 1, enabled: true), ], @@ -78,7 +80,8 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge ); } - Widget _buildNavItem(BuildContext context, String title, int index, {bool enabled = true}) { + Widget _buildNavItem(BuildContext context, String title, int index, + {bool enabled = true}) { final bool isActive = index == selectedIndex; return InkWell( onTap: enabled ? () => onTabChange(index) : null, @@ -133,11 +136,124 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge class DashboardUserManagementSubBar extends StatelessWidget { final int selectedSubIndex; final ValueChanged onSubTabChange; + final TextEditingController searchController; + final String searchHint; + final Widget? filterControl; + final VoidCallback? onAddPressed; + final String addLabel; const DashboardUserManagementSubBar({ Key? key, required this.selectedSubIndex, required this.onSubTabChange, + required this.searchController, + required this.searchHint, + this.filterControl, + this.onAddPressed, + this.addLabel = '+ Ajouter', + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + height: 56, + decoration: BoxDecoration( + color: Colors.grey.shade100, + border: Border(bottom: BorderSide(color: Colors.grey.shade300)), + ), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6), + child: Row( + children: [ + _buildSubNavItem(context, 'Gestionnaires', 0), + const SizedBox(width: 12), + _buildSubNavItem(context, 'Parents', 1), + const SizedBox(width: 12), + _buildSubNavItem(context, 'Assistantes maternelles', 2), + const SizedBox(width: 12), + _buildSubNavItem(context, 'Administrateurs', 3), + const SizedBox(width: 36), + _pillField( + width: 320, + child: TextField( + controller: searchController, + decoration: InputDecoration( + hintText: searchHint, + prefixIcon: const Icon(Icons.search, size: 18), + border: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + ), + ), + ), + if (filterControl != null) ...[ + const SizedBox(width: 12), + _pillField(width: 150, child: filterControl!), + ], + const Spacer(), + _buildAddButton(), + ], + ), + ); + } + + Widget _pillField({required double width, required Widget child}) { + return Container( + width: width, + height: 34, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: Colors.black26), + ), + alignment: Alignment.centerLeft, + child: child, + ); + } + + Widget _buildAddButton() { + return ElevatedButton.icon( + onPressed: onAddPressed, + icon: const Icon(Icons.add), + label: Text(addLabel), + ); + } + + Widget _buildSubNavItem(BuildContext context, String title, int index) { + final bool isActive = index == selectedSubIndex; + return InkWell( + onTap: () => onSubTabChange(index), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent, + borderRadius: BorderRadius.circular(16), + border: isActive ? null : Border.all(color: Colors.black26), + ), + child: Text( + title, + style: TextStyle( + color: isActive ? Colors.white : Colors.black87, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + fontSize: 13, + ), + ), + ), + ); + } +} + +/// Sous-barre Paramètres : Paramètres généraux | Paramètres territoriaux. +class DashboardSettingsSubBar extends StatelessWidget { + final int selectedSubIndex; + final ValueChanged onSubTabChange; + + const DashboardSettingsSubBar({ + Key? key, + required this.selectedSubIndex, + required this.onSubTabChange, }) : super(key: key); @override @@ -153,13 +269,9 @@ class DashboardUserManagementSubBar extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - _buildSubNavItem(context, 'Gestionnaires', 0), + _buildSubNavItem(context, 'Paramètres généraux', 0), const SizedBox(width: 16), - _buildSubNavItem(context, 'Parents', 1), - const SizedBox(width: 16), - _buildSubNavItem(context, 'Assistantes maternelles', 2), - const SizedBox(width: 16), - _buildSubNavItem(context, 'Administrateurs', 3), + _buildSubNavItem(context, 'Paramètres territoriaux', 1), ], ), ), diff --git a/frontend/lib/widgets/admin/gestionnaire_card.dart b/frontend/lib/widgets/admin/gestionnaire_card.dart deleted file mode 100644 index 5d80255..0000000 --- a/frontend/lib/widgets/admin/gestionnaire_card.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:flutter/material.dart'; - -class GestionnaireCard extends StatelessWidget { - final String name; - final String email; - - const GestionnaireCard({ - Key? key, - required this.name, - required this.email, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Card( - margin: const EdgeInsets.only(bottom: 12), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 🔹 Infos principales - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(name, style: const TextStyle(fontWeight: FontWeight.bold)), - Text(email, style: const TextStyle(color: Colors.grey)), - ], - ), - const SizedBox(height: 12), - - // 🔹 Attribution à des RPE (dropdown fictif ici) - Row( - children: [ - const Text("RPE attribué : "), - const SizedBox(width: 8), - DropdownButton( - value: "RPE 1", - items: const [ - DropdownMenuItem(value: "RPE 1", child: Text("RPE 1")), - DropdownMenuItem(value: "RPE 2", child: Text("RPE 2")), - DropdownMenuItem(value: "RPE 3", child: Text("RPE 3")), - ], - onChanged: (value) {}, - ), - ], - ), - const SizedBox(height: 12), - - // 🔹 Boutons d'action - Row( - children: [ - TextButton.icon( - onPressed: () { - // Réinitialisation mot de passe - }, - icon: const Icon(Icons.lock_reset), - label: const Text("Réinitialiser MDP"), - ), - const SizedBox(width: 12), - TextButton.icon( - onPressed: () { - // Suppression du compte - }, - icon: const Icon(Icons.delete, color: Colors.red), - label: const Text("Supprimer", style: TextStyle(color: Colors.red)), - ), - ], - ) - ], - ), - ), - ); - } -} diff --git a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart index 82fa52f..80d5d91 100644 --- a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart +++ b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart @@ -1,10 +1,18 @@ import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/relais_model.dart'; import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/services/relais_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; -import 'package:p_tits_pas/widgets/admin/gestionnaire_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; class GestionnaireManagementWidget extends StatefulWidget { - const GestionnaireManagementWidget({Key? key}) : super(key: key); + final String searchQuery; + + const GestionnaireManagementWidget({ + Key? key, + required this.searchQuery, + }) : super(key: key); @override State createState() => @@ -16,21 +24,16 @@ class _GestionnaireManagementWidgetState bool _isLoading = false; String? _error; List _gestionnaires = []; - List _filteredGestionnaires = []; - final TextEditingController _searchController = TextEditingController(); + List _relais = []; @override void initState() { super.initState(); _loadGestionnaires(); - _searchController.addListener(_onSearchChanged); } @override - void dispose() { - _searchController.dispose(); - super.dispose(); - } + void dispose() => super.dispose(); Future _loadGestionnaires() async { setState(() { @@ -38,11 +41,18 @@ class _GestionnaireManagementWidgetState _error = null; }); try { - final list = await UserService.getGestionnaires(); + final gestionnaires = await UserService.getGestionnaires(); + List relais = []; + try { + relais = await RelaisService.getRelais(); + } catch (_) { + // L'ecran reste utilisable meme si la route Relais n'est pas disponible. + } + if (!mounted) return; setState(() { - _gestionnaires = list; - _filteredGestionnaires = list; + _gestionnaires = gestionnaires; + _relais = relais; _isLoading = false; }); } catch (e) { @@ -54,71 +64,125 @@ class _GestionnaireManagementWidgetState } } - void _onSearchChanged() { - final query = _searchController.text.toLowerCase(); - setState(() { - _filteredGestionnaires = _gestionnaires.where((u) { - final name = u.fullName.toLowerCase(); - final email = u.email.toLowerCase(); - return name.contains(query) || email.contains(query); - }).toList(); - }); + Future _openRelaisAssignmentDialog(AppUser user) async { + String? selectedRelaisId = user.relaisId; + final saved = await showDialog( + context: context, + builder: (ctx) { + return StatefulBuilder( + builder: (context, setStateDialog) { + return AlertDialog( + title: Text( + 'Rattacher ${user.fullName.isEmpty ? user.email : user.fullName}', + ), + content: DropdownButtonFormField( + value: selectedRelaisId, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Relais principal', + border: OutlineInputBorder(), + ), + items: [ + const DropdownMenuItem( + value: null, + child: Text('Aucun relais'), + ), + ..._relais.map( + (relais) => DropdownMenuItem( + value: relais.id, + child: Text(relais.nom), + ), + ), + ], + onChanged: (value) { + setStateDialog(() { + selectedRelaisId = value; + }); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Annuler'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Enregistrer'), + ), + ], + ); + }, + ); + }, + ); + + if (saved != true) return; + + try { + await UserService.updateGestionnaireRelais( + gestionnaireId: user.id, + relaisId: selectedRelaisId, + ); + if (!mounted) return; + await _loadGestionnaires(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Rattachement relais mis a jour.')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e.toString().replaceAll('Exception: ', ''), + ), + backgroundColor: Colors.red.shade600, + ), + ); + } } @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // 🔹 Barre du haut avec bouton - Row( - children: [ - Expanded( - child: TextField( - controller: _searchController, - decoration: const InputDecoration( - hintText: "Rechercher un gestionnaire...", - prefixIcon: Icon(Icons.search), - border: OutlineInputBorder(), - ), - ), - ), - const SizedBox(width: 16), - ElevatedButton.icon( - onPressed: () { - // TODO: Rediriger vers la page de création - }, - icon: const Icon(Icons.add), - label: const Text("Créer un gestionnaire"), - ), - ], - ), - const SizedBox(height: 24), + final query = widget.searchQuery.toLowerCase(); + final filteredGestionnaires = _gestionnaires.where((u) { + final name = u.fullName.toLowerCase(); + final email = u.email.toLowerCase(); + return name.contains(query) || email.contains(query); + }).toList(); - // 🔹 Liste des gestionnaires - if (_isLoading) - const Center(child: CircularProgressIndicator()) - else if (_error != null) - Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red))) - else if (_filteredGestionnaires.isEmpty) - const Center(child: Text("Aucun gestionnaire trouvé.")) - else - Expanded( - child: ListView.builder( - itemCount: _filteredGestionnaires.length, - itemBuilder: (context, index) { - final user = _filteredGestionnaires[index]; - return GestionnaireCard( - name: user.fullName.isNotEmpty ? user.fullName : "Sans nom", - email: user.email, - ); - }, - ), - ) - ], - ), + return UserList( + isLoading: _isLoading, + error: _error, + isEmpty: filteredGestionnaires.isEmpty, + emptyMessage: 'Aucun gestionnaire trouvé.', + itemCount: filteredGestionnaires.length, + itemBuilder: (context, index) { + final user = filteredGestionnaires[index]; + return AdminUserCard( + title: user.fullName, + avatarUrl: user.photoUrl, + subtitleLines: [ + user.email, + 'Statut : ${user.statut ?? 'Inconnu'}', + 'Relais : ${user.relaisNom ?? 'Non rattaché'}', + ], + actions: [ + IconButton( + icon: const Icon(Icons.location_city_outlined), + tooltip: 'Rattacher un relais', + onPressed: () => _openRelaisAssignmentDialog(user), + ), + IconButton( + icon: const Icon(Icons.edit), + tooltip: 'Modifier', + onPressed: () { + // TODO: Modifier gestionnaire. + }, + ), + ], + ); + }, ); } } diff --git a/frontend/lib/widgets/admin/parametres_panel.dart b/frontend/lib/widgets/admin/parametres_panel.dart index 22e7b7c..6c445d8 100644 --- a/frontend/lib/widgets/admin/parametres_panel.dart +++ b/frontend/lib/widgets/admin/parametres_panel.dart @@ -1,13 +1,19 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:p_tits_pas/services/configuration_service.dart'; +import 'package:p_tits_pas/widgets/admin/relais_management_panel.dart'; /// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé. class ParametresPanel extends StatefulWidget { /// Si true, après sauvegarde on redirige vers le login (première config). Sinon on reste sur la page. final bool redirectToLoginAfterSave; + final int selectedSettingsTabIndex; - const ParametresPanel({super.key, this.redirectToLoginAfterSave = false}); + const ParametresPanel({ + super.key, + this.redirectToLoginAfterSave = false, + this.selectedSettingsTabIndex = 0, + }); @override State createState() => _ParametresPanelState(); @@ -33,10 +39,18 @@ class _ParametresPanelState extends State { void _createControllers() { final keys = [ - 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_password', - 'email_from_name', 'email_from_address', - 'app_name', 'app_url', 'app_logo_url', - 'password_reset_token_expiry_days', 'jwt_expiry_hours', 'max_upload_size_mb', + 'smtp_host', + 'smtp_port', + 'smtp_user', + 'smtp_password', + 'email_from_name', + 'email_from_address', + 'app_name', + 'app_url', + 'app_logo_url', + 'password_reset_token_expiry_days', + 'jwt_expiry_hours', + 'max_upload_size_mb', ]; for (final k in keys) { _controllers[k] = TextEditingController(); @@ -93,18 +107,29 @@ class _ParametresPanelState extends State { payload['smtp_auth_required'] = _smtpAuthRequired; payload['smtp_user'] = _controllers['smtp_user']!.text.trim(); final pwd = _controllers['smtp_password']!.text.trim(); - if (pwd.isNotEmpty && pwd != '***********') payload['smtp_password'] = pwd; + if (pwd.isNotEmpty && pwd != '***********') { + payload['smtp_password'] = pwd; + } payload['email_from_name'] = _controllers['email_from_name']!.text.trim(); - payload['email_from_address'] = _controllers['email_from_address']!.text.trim(); + payload['email_from_address'] = + _controllers['email_from_address']!.text.trim(); payload['app_name'] = _controllers['app_name']!.text.trim(); payload['app_url'] = _controllers['app_url']!.text.trim(); payload['app_logo_url'] = _controllers['app_logo_url']!.text.trim(); - final tokenDays = int.tryParse(_controllers['password_reset_token_expiry_days']!.text.trim()); - if (tokenDays != null) payload['password_reset_token_expiry_days'] = tokenDays; - final jwtHours = int.tryParse(_controllers['jwt_expiry_hours']!.text.trim()); - if (jwtHours != null) payload['jwt_expiry_hours'] = jwtHours; + final tokenDays = int.tryParse( + _controllers['password_reset_token_expiry_days']!.text.trim()); + if (tokenDays != null) { + payload['password_reset_token_expiry_days'] = tokenDays; + } + final jwtHours = + int.tryParse(_controllers['jwt_expiry_hours']!.text.trim()); + if (jwtHours != null) { + payload['jwt_expiry_hours'] = jwtHours; + } final maxMb = int.tryParse(_controllers['max_upload_size_mb']!.text.trim()); - if (maxMb != null) payload['max_upload_size_mb'] = maxMb; + if (maxMb != null) { + payload['max_upload_size_mb'] = maxMb; + } return payload; } @@ -191,6 +216,10 @@ class _ParametresPanelState extends State { @override Widget build(BuildContext context) { + if (widget.selectedSettingsTabIndex == 1) { + return const RelaisManagementPanel(); + } + if (_isLoading) { return const Center(child: CircularProgressIndicator()); } @@ -214,7 +243,8 @@ class _ParametresPanelState extends State { } final isSuccess = _message != null && - (_message!.startsWith('Configuration') || _message!.startsWith('Connexion')); + (_message!.startsWith('Configuration') || + _message!.startsWith('Connexion')); return Form( key: _formKey, @@ -234,12 +264,21 @@ class _ParametresPanelState extends State { context, icon: Icons.email_outlined, title: 'Configuration Email (SMTP)', - child: Column( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildField('smtp_host', 'Serveur SMTP', hint: 'mail.example.com'), + _buildField( + 'smtp_host', + 'Serveur SMTP', + hint: 'mail.example.com', + ), const SizedBox(height: 14), - _buildField('smtp_port', 'Port SMTP', keyboard: TextInputType.number, hint: '25, 465, 587'), + _buildField( + 'smtp_port', + 'Port SMTP', + keyboard: TextInputType.number, + hint: '25, 465, 587', + ), const SizedBox(height: 14), Padding( padding: const EdgeInsets.only(bottom: 14), @@ -247,14 +286,17 @@ class _ParametresPanelState extends State { children: [ Checkbox( value: _smtpSecure, - onChanged: (v) => setState(() => _smtpSecure = v ?? false), + onChanged: (v) => + setState(() => _smtpSecure = v ?? false), activeColor: const Color(0xFF9CC5C0), ), const Text('SSL/TLS (secure)'), const SizedBox(width: 24), Checkbox( value: _smtpAuthRequired, - onChanged: (v) => setState(() => _smtpAuthRequired = v ?? false), + onChanged: (v) => setState( + () => _smtpAuthRequired = v ?? false, + ), activeColor: const Color(0xFF9CC5C0), ), const Text('Authentification requise'), @@ -263,11 +305,19 @@ class _ParametresPanelState extends State { ), _buildField('smtp_user', 'Utilisateur SMTP'), const SizedBox(height: 14), - _buildField('smtp_password', 'Mot de passe SMTP', obscure: true), + _buildField( + 'smtp_password', + 'Mot de passe SMTP', + obscure: true, + ), const SizedBox(height: 14), _buildField('email_from_name', 'Nom expéditeur'), const SizedBox(height: 14), - _buildField('email_from_address', 'Email expéditeur', hint: 'no-reply@example.com'), + _buildField( + 'email_from_address', + 'Email expéditeur', + hint: 'no-reply@example.com', + ), const SizedBox(height: 18), Align( alignment: Alignment.centerRight, @@ -277,8 +327,13 @@ class _ParametresPanelState extends State { label: const Text('Tester la connexion SMTP'), style: OutlinedButton.styleFrom( foregroundColor: const Color(0xFF2D6A4F), - side: const BorderSide(color: Color(0xFF9CC5C0)), - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + side: const BorderSide( + color: Color(0xFF9CC5C0), + ), + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), ), ), ), @@ -290,14 +345,22 @@ class _ParametresPanelState extends State { context, icon: Icons.palette_outlined, title: 'Personnalisation', - child: Column( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildField('app_name', 'Nom de l\'application'), const SizedBox(height: 14), - _buildField('app_url', 'URL de l\'application', hint: 'https://app.example.com'), + _buildField( + 'app_url', + 'URL de l\'application', + hint: 'https://app.example.com', + ), const SizedBox(height: 14), - _buildField('app_logo_url', 'URL du logo', hint: '/assets/logo.png'), + _buildField( + 'app_logo_url', + 'URL du logo', + hint: '/assets/logo.png', + ), ], ), ), @@ -309,11 +372,23 @@ class _ParametresPanelState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildField('password_reset_token_expiry_days', 'Validité token MDP (jours)', keyboard: TextInputType.number), + _buildField( + 'password_reset_token_expiry_days', + 'Validité token MDP (jours)', + keyboard: TextInputType.number, + ), const SizedBox(height: 14), - _buildField('jwt_expiry_hours', 'Validité session JWT (heures)', keyboard: TextInputType.number), + _buildField( + 'jwt_expiry_hours', + 'Validité session JWT (heures)', + keyboard: TextInputType.number, + ), const SizedBox(height: 14), - _buildField('max_upload_size_mb', 'Taille max upload (MB)', keyboard: TextInputType.number), + _buildField( + 'max_upload_size_mb', + 'Taille max upload (MB)', + keyboard: TextInputType.number, + ), ], ), ), @@ -327,7 +402,14 @@ class _ParametresPanelState extends State { foregroundColor: Colors.white, ), child: _isSaving - ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) : const Text('Sauvegarder la configuration'), ), ), @@ -339,7 +421,8 @@ class _ParametresPanelState extends State { ); } - Widget _buildSectionCard(BuildContext context, {required IconData icon, required String title, required Widget child}) { + Widget _buildSectionCard(BuildContext context, + {required IconData icon, required String title, required Widget child}) { return Card( elevation: 2, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), @@ -369,7 +452,8 @@ class _ParametresPanelState extends State { ); } - Widget _buildField(String key, String label, {bool obscure = false, TextInputType? keyboard, String? hint}) { + Widget _buildField(String key, String label, + {bool obscure = false, TextInputType? keyboard, String? hint}) { final c = _controllers[key]; if (c == null) return const SizedBox.shrink(); return TextFormField( @@ -381,7 +465,8 @@ class _ParametresPanelState extends State { labelText: label, hintText: hint, border: const OutlineInputBorder(), - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 12), ), ); } diff --git a/frontend/lib/widgets/admin/parent_managmant_widget.dart b/frontend/lib/widgets/admin/parent_managmant_widget.dart index 1764056..cfa8637 100644 --- a/frontend/lib/widgets/admin/parent_managmant_widget.dart +++ b/frontend/lib/widgets/admin/parent_managmant_widget.dart @@ -1,9 +1,19 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/parent_model.dart'; import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; class ParentManagementWidget extends StatefulWidget { - const ParentManagementWidget({super.key}); + final String searchQuery; + final String? statusFilter; + + const ParentManagementWidget({ + super.key, + required this.searchQuery, + this.statusFilter, + }); @override State createState() => _ParentManagementWidgetState(); @@ -13,23 +23,15 @@ class _ParentManagementWidgetState extends State { bool _isLoading = false; String? _error; List _parents = []; - List _filteredParents = []; - - final TextEditingController _searchController = TextEditingController(); - String? _selectedStatus; @override void initState() { super.initState(); _loadParents(); - _searchController.addListener(_filter); } @override - void dispose() { - _searchController.dispose(); - super.dispose(); - } + void dispose() => super.dispose(); Future _loadParents() async { setState(() { @@ -41,7 +43,6 @@ class _ParentManagementWidgetState extends State { if (!mounted) return; setState(() { _parents = list; - _filter(); // Apply initial filter (if any) _isLoading = false; }); } catch (e) { @@ -53,139 +54,101 @@ class _ParentManagementWidgetState extends State { } } - void _filter() { - final query = _searchController.text.toLowerCase(); - setState(() { - _filteredParents = _parents.where((p) { - final matchesName = p.user.fullName.toLowerCase().contains(query) || - p.user.email.toLowerCase().contains(query); - final matchesStatus = _selectedStatus == null || - _selectedStatus == 'Tous' || - (p.user.statut?.toLowerCase() == _selectedStatus?.toLowerCase()); - - // Mapping simple pour le statut affiché vs backend - // Backend: en_attente, actif, suspendu - // Dropdown: En attente, Actif, Suspendu - - return matchesName && matchesStatus; - }).toList(); - }); - } - @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSearchSection(), - const SizedBox(height: 16), - if (_isLoading) - const Center(child: CircularProgressIndicator()) - else if (_error != null) - Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red))) - else if (_filteredParents.isEmpty) - const Center(child: Text("Aucun parent trouvé.")) - else - Expanded( - child: ListView.builder( - itemCount: _filteredParents.length, - itemBuilder: (context, index) { - final parent = _filteredParents[index]; - return Card( - margin: const EdgeInsets.symmetric(vertical: 8), - child: ListTile( - leading: CircleAvatar( - backgroundImage: parent.user.photoUrl != null - ? NetworkImage(parent.user.photoUrl!) - : null, - child: parent.user.photoUrl == null - ? const Icon(Icons.person) - : null, - ), - title: Text(parent.user.fullName.isNotEmpty - ? parent.user.fullName - : 'Sans nom'), - subtitle: Text( - "${parent.user.email}\nStatut : ${parent.user.statut ?? 'Inconnu'} | Enfants : ${parent.childrenCount}", - ), - isThreeLine: true, - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.visibility), - tooltip: "Voir dossier", - onPressed: () { - // TODO: Voir le statut du dossier - }, - ), - IconButton( - icon: const Icon(Icons.edit), - tooltip: "Modifier", - onPressed: () { - // TODO: Modifier parent - }, - ), - IconButton( - icon: const Icon(Icons.delete), - tooltip: "Supprimer", - onPressed: () { - // TODO: Supprimer compte - }, - ), - ], - ), - ), - ); - }, - ), + final query = widget.searchQuery.toLowerCase(); + final filteredParents = _parents.where((p) { + final matchesName = p.user.fullName.toLowerCase().contains(query) || + p.user.email.toLowerCase().contains(query); + final matchesStatus = + widget.statusFilter == null || p.user.statut == widget.statusFilter; + return matchesName && matchesStatus; + }).toList(); + + return UserList( + isLoading: _isLoading, + error: _error, + isEmpty: filteredParents.isEmpty, + emptyMessage: 'Aucun parent trouvé.', + itemCount: filteredParents.length, + itemBuilder: (context, index) { + final parent = filteredParents[index]; + return AdminUserCard( + title: parent.user.fullName, + avatarUrl: parent.user.photoUrl, + subtitleLines: [ + parent.user.email, + 'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.childrenCount}', + ], + actions: [ + IconButton( + icon: const Icon(Icons.edit), + tooltip: 'Modifier', + onPressed: () { + _openParentDetails(parent); + }, ), + ], + ); + }, + ); + } + + String _displayStatus(String? status) { + switch (status) { + case 'actif': + return 'Actif'; + case 'en_attente': + return 'En attente'; + case 'suspendu': + return 'Suspendu'; + default: + return 'Inconnu'; + } + } + + void _openParentDetails(ParentModel parent) { + showDialog( + context: context, + builder: (context) => AdminDetailModal( + title: parent.user.fullName.isEmpty ? 'Parent' : parent.user.fullName, + subtitle: parent.user.email, + fields: [ + AdminDetailField(label: 'ID', value: _v(parent.user.id)), + AdminDetailField( + label: 'Statut', + value: _displayStatus(parent.user.statut), + ), + AdminDetailField( + label: 'Telephone', + value: _v(parent.user.telephone), + ), + AdminDetailField(label: 'Adresse', value: _v(parent.user.adresse)), + AdminDetailField(label: 'Ville', value: _v(parent.user.ville)), + AdminDetailField( + label: 'Code postal', + value: _v(parent.user.codePostal), + ), + AdminDetailField( + label: 'Nombre d\'enfants', + value: parent.childrenCount.toString(), + ), ], + onEdit: () { + Navigator.of(context).pop(); + ScaffoldMessenger.of(this.context).showSnackBar( + const SnackBar(content: Text('Action Modifier a implementer')), + ); + }, + onDelete: () { + Navigator.of(context).pop(); + ScaffoldMessenger.of(this.context).showSnackBar( + const SnackBar(content: Text('Action Supprimer a implementer')), + ); + }, ), ); } - Widget _buildSearchSection() { - return Wrap( - spacing: 16, - runSpacing: 8, - children: [ - SizedBox( - width: 220, - child: TextField( - controller: _searchController, - decoration: const InputDecoration( - labelText: "Nom du parent", - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.search), - ), - ), - ), - SizedBox( - width: 220, - child: DropdownButtonFormField( - decoration: const InputDecoration( - labelText: "Statut", - border: OutlineInputBorder(), - ), - value: _selectedStatus, - items: const [ - DropdownMenuItem(value: null, child: Text("Tous")), - DropdownMenuItem(value: "actif", child: Text("Actif")), - DropdownMenuItem(value: "en_attente", child: Text("En attente")), - DropdownMenuItem(value: "suspendu", child: Text("Suspendu")), - ], - onChanged: (value) { - setState(() { - _selectedStatus = value; - _filter(); - }); - }, - ), - ), - ], - ); - } + String _v(String? value) => (value == null || value.isEmpty) ? '-' : value; } diff --git a/frontend/lib/widgets/admin/relais_management_panel.dart b/frontend/lib/widgets/admin/relais_management_panel.dart new file mode 100644 index 0000000..0940dcd --- /dev/null +++ b/frontend/lib/widgets/admin/relais_management_panel.dart @@ -0,0 +1,1134 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:p_tits_pas/models/relais_model.dart'; +import 'package:p_tits_pas/services/relais_service.dart'; + +class RelaisManagementPanel extends StatefulWidget { + const RelaisManagementPanel({super.key}); + + @override + State createState() => _RelaisManagementPanelState(); +} + +class _RelaisManagementPanelState extends State { + bool _isLoading = false; + String? _error; + List _relais = []; + String? _hoveredRelaisId; + + @override + void initState() { + super.initState(); + _loadRelais(); + } + + Future _loadRelais() async { + setState(() { + _isLoading = true; + _error = null; + }); + try { + final list = await RelaisService.getRelais(); + if (!mounted) return; + setState(() { + _relais = list; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString().replaceAll('Exception: ', ''); + _isLoading = false; + }); + } + } + + Future _openRelaisForm({RelaisModel? relais}) async { + final result = await showDialog<_RelaisDialogResult>( + context: context, + builder: (context) => _RelaisFormDialog(initial: relais), + ); + + if (result == null) return; + if (!mounted) return; + + try { + if (result.action == _RelaisDialogAction.delete) { + if (relais == null) return; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Supprimer le relais'), + content: Text('Confirmer la suppression de "${relais.nom}" ?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Annuler'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: FilledButton.styleFrom( + backgroundColor: Colors.red.shade700, + ), + child: const Text('Supprimer'), + ), + ], + ), + ); + + if (confirmed != true) return; + await RelaisService.deleteRelais(relais.id); + } else if (relais == null) { + await RelaisService.createRelais(result.payload!); + } else { + await RelaisService.updateRelais(relais.id, result.payload!); + } + if (!mounted) return; + await _loadRelais(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.action == _RelaisDialogAction.delete + ? 'Relais supprimé.' + : (relais == null ? 'Relais créé.' : 'Relais mis à jour.'), + ), + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e.toString().replaceAll('Exception: ', ''), + ), + backgroundColor: Colors.red.shade600, + ), + ); + } + } + + String _horairesSummary(Map? horaires) { + if (horaires == null || horaires.isEmpty) { + return 'Horaires non renseignés'; + } + final actifs = horaires.entries.where((entry) { + final value = entry.value; + if (value is Map) { + final ferme = value['ferme'] == true; + if (ferme) return false; + final matinOuverture = value['matin_ouverture']?.toString() ?? ''; + final matinFermeture = value['matin_fermeture']?.toString() ?? ''; + final soirOuverture = value['soir_ouverture']?.toString() ?? ''; + final soirFermeture = value['soir_fermeture']?.toString() ?? ''; + final matinOuvert = + matinOuverture.isNotEmpty && matinFermeture.isNotEmpty; + final soirOuvert = soirOuverture.isNotEmpty && soirFermeture.isNotEmpty; + return matinOuvert || soirOuvert; + } + return false; + }).length; + return '$actifs jour(s) ouvert(s)'; + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Icon( + Icons.location_city_outlined, + size: 22, + color: Color(0xFF9CC5C0), + ), + const SizedBox(width: 10), + Text( + 'Gestion des relais', + style: + Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: const Color(0xFF2D6A4F), + ), + ), + const Spacer(), + ElevatedButton.icon( + onPressed: () => _openRelaisForm(), + icon: const Icon(Icons.add), + label: const Text('Ajouter un relais'), + ), + ], + ), + const SizedBox(height: 16), + Container( + height: 390, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.black12), + ), + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? Center( + child: Text( + _error!, + style: TextStyle(color: Colors.red.shade700), + ), + ) + : _relais.isEmpty + ? const Center( + child: Text('Aucun relais configuré.'), + ) + : ListView.separated( + itemCount: _relais.length, + separatorBuilder: (_, __) => + const SizedBox(height: 10), + itemBuilder: (context, index) { + final relais = _relais[index]; + final isInactive = !relais.actif; + final isHovered = + _hoveredRelaisId == relais.id; + final subtitle = [ + relais.adresse, + if (relais.ligneFixe?.isNotEmpty == + true) + 'Ligne fixe : ${relais.ligneFixe}', + _horairesSummary( + relais.horairesOuverture), + 'Statut : ${relais.actif ? 'Actif' : 'Inactif'}', + if (relais.notes?.isNotEmpty == true) + 'Notes : ${relais.notes}', + ]; + + return MouseRegion( + onEnter: (_) => setState( + () => _hoveredRelaisId = relais.id, + ), + onExit: (_) => setState( + () => _hoveredRelaisId = null, + ), + child: Card( + color: isInactive + ? const Color(0xFFFFF4F4) + : null, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(10), + side: BorderSide( + color: isInactive + ? const Color(0xFFFFD0D0) + : Colors.transparent, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: Row( + children: [ + const Icon( + Icons.location_city_outlined, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment + .start, + children: [ + Text( + relais.nom, + style: TextStyle( + fontWeight: + FontWeight.w600, + color: isInactive + ? const Color( + 0xFF8A3A3A) + : null, + ), + ), + const SizedBox(height: 2), + Text( + subtitle + .join(' • '), + maxLines: 2, + overflow: TextOverflow + .ellipsis, + style: const TextStyle( + color: Colors.black54, + fontSize: 12, + ), + ), + ], + ), + ), + SizedBox( + width: 36, + child: AnimatedOpacity( + duration: const Duration( + milliseconds: 120, + ), + opacity: isHovered ? 1 : 0, + child: IgnorePointer( + ignoring: !isHovered, + child: IconButton( + onPressed: () => + _openRelaisForm( + relais: relais), + icon: const Icon( + Icons.edit), + tooltip: 'Modifier', + ), + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _RelaisFormDialog extends StatefulWidget { + final RelaisModel? initial; + + const _RelaisFormDialog({required this.initial}); + + @override + State<_RelaisFormDialog> createState() => _RelaisFormDialogState(); +} + +class _RelaisFormDialogState extends State<_RelaisFormDialog> { + static const double _targetTimeFieldWidth = 112; + static const double _minTimeFieldWidth = 96; + static const double _gap = 8; + static const double _dayLabelWidth = 70; + static const double _closedAreaWidth = 86; + static const double _separatorLineWidth = 1; + static const double _groupSeparatorWidth = (_gap * 2) + _separatorLineWidth; + static const double _modalInnerPadding = 16; + static const double _modalHorizontalSafetyMargin = 24; + + late final TextEditingController _nomCtrl; + late final TextEditingController _streetCtrl; + late final TextEditingController _postalCodeCtrl; + late final TextEditingController _cityCtrl; + late final TextEditingController _ligneFixeCtrl; + late final TextEditingController _notesCtrl; + final Map _morningOpenCtrls = {}; + final Map _morningCloseCtrls = {}; + final Map _eveningOpenCtrls = {}; + final Map _eveningCloseCtrls = {}; + final Map _closedByDay = {}; + bool _actif = true; + + static const List _days = [ + 'lundi', + 'mardi', + 'mercredi', + 'jeudi', + 'vendredi', + 'samedi', + 'dimanche', + ]; + + @override + void initState() { + super.initState(); + final initial = widget.initial; + _nomCtrl = TextEditingController(text: initial?.nom ?? ''); + final addressParts = _splitAddress(initial?.adresse); + _streetCtrl = TextEditingController(text: addressParts.street); + _postalCodeCtrl = TextEditingController(text: addressParts.postalCode); + _cityCtrl = TextEditingController(text: addressParts.city); + _ligneFixeCtrl = TextEditingController(text: initial?.ligneFixe ?? ''); + _notesCtrl = TextEditingController(text: initial?.notes ?? ''); + _actif = initial?.actif ?? true; + + final horaires = initial?.horairesOuverture ?? {}; + for (final day in _days) { + final value = horaires[day]; + String matinOuverture = ''; + String matinFermeture = ''; + String soirOuverture = ''; + String soirFermeture = ''; + bool ferme = false; + + if (value is Map) { + matinOuverture = _normalizeTime( + value['matin_ouverture']?.toString() ?? + value['ouverture']?.toString(), + ); + matinFermeture = _normalizeTime( + value['matin_fermeture']?.toString() ?? + value['fermeture']?.toString(), + ); + soirOuverture = _normalizeTime(value['soir_ouverture']?.toString()); + soirFermeture = _normalizeTime(value['soir_fermeture']?.toString()); + ferme = value['ferme'] == true; + } else if (value is String && value.contains('-')) { + final parts = value.split('-'); + if (parts.length == 2) { + matinOuverture = _normalizeTime(parts[0].trim()); + matinFermeture = _normalizeTime(parts[1].trim()); + } + } + + _morningOpenCtrls[day] = TextEditingController(text: matinOuverture); + _morningCloseCtrls[day] = TextEditingController(text: matinFermeture); + _eveningOpenCtrls[day] = TextEditingController(text: soirOuverture); + _eveningCloseCtrls[day] = TextEditingController(text: soirFermeture); + final isWeekend = day == 'samedi' || day == 'dimanche'; + _closedByDay[day] = widget.initial == null ? isWeekend : ferme; + } + } + + @override + void dispose() { + _nomCtrl.dispose(); + _streetCtrl.dispose(); + _postalCodeCtrl.dispose(); + _cityCtrl.dispose(); + _ligneFixeCtrl.dispose(); + _notesCtrl.dispose(); + for (final c in _morningOpenCtrls.values) { + c.dispose(); + } + for (final c in _morningCloseCtrls.values) { + c.dispose(); + } + for (final c in _eveningOpenCtrls.values) { + c.dispose(); + } + for (final c in _eveningCloseCtrls.values) { + c.dispose(); + } + super.dispose(); + } + + Map _buildPayload() { + final horaires = {}; + for (final day in _days) { + final ferme = _closedByDay[day] ?? false; + final matinOuverture = _morningOpenCtrls[day]!.text.trim(); + final matinFermeture = _morningCloseCtrls[day]!.text.trim(); + final soirOuverture = _eveningOpenCtrls[day]!.text.trim(); + final soirFermeture = _eveningCloseCtrls[day]!.text.trim(); + + horaires[day] = { + 'matin_ouverture': matinOuverture, + 'matin_fermeture': matinFermeture, + 'soir_ouverture': soirOuverture, + 'soir_fermeture': soirFermeture, + 'ferme': ferme, + }; + } + + final payload = { + 'nom': _nomCtrl.text.trim(), + 'adresse': _composeAddress(), + 'actif': _actif, + 'horaires_ouverture': horaires, + }; + + final ligneFixe = _ligneFixeCtrl.text.trim(); + if (ligneFixe.isNotEmpty) { + payload['ligne_fixe'] = ligneFixe; + } + + final notes = _notesCtrl.text.trim(); + if (notes.isNotEmpty) { + payload['notes'] = notes; + } + + return payload; + } + + bool _isValid() { + if (_nomCtrl.text.trim().isEmpty) { + return false; + } + if (_streetCtrl.text.trim().isEmpty || _cityCtrl.text.trim().isEmpty) { + return false; + } + if (!_isValidPostalCode(_postalCodeCtrl.text.trim())) { + return false; + } + + for (final day in _days) { + final ferme = _closedByDay[day] ?? false; + if (ferme) continue; + + final matinOuverture = _morningOpenCtrls[day]!.text.trim(); + final matinFermeture = _morningCloseCtrls[day]!.text.trim(); + final soirOuverture = _eveningOpenCtrls[day]!.text.trim(); + final soirFermeture = _eveningCloseCtrls[day]!.text.trim(); + + if (!_isValidSlot(matinOuverture, matinFermeture) || + !_isValidSlot(soirOuverture, soirFermeture)) { + return false; + } + } + + return true; + } + + bool _isValidSlot(String start, String end) { + final bothEmpty = start.isEmpty && end.isEmpty; + if (bothEmpty) return true; + if (start.isEmpty || end.isEmpty) return false; + return _isValidTime(start) && _isValidTime(end); + } + + bool _isValidPostalCode(String value) { + return RegExp(r'^\d{5}$').hasMatch(value); + } + + _AddressParts _splitAddress(String? rawAddress) { + if (rawAddress == null || rawAddress.trim().isEmpty) { + return const _AddressParts(street: '', postalCode: '', city: ''); + } + + final raw = rawAddress.trim().replaceAll(RegExp(r'\s+'), ' '); + final postalCityMatch = + RegExp(r'^(.+?)[,\s]+(\d{5})\s+(.+)$').firstMatch(raw); + + if (postalCityMatch != null) { + final street = (postalCityMatch.group(1) ?? '') + .replaceAll(RegExp(r'[,;\s]+$'), '') + .trim(); + final postalCode = (postalCityMatch.group(2) ?? '').trim(); + final city = (postalCityMatch.group(3) ?? '').trim(); + return _AddressParts( + street: street, + postalCode: postalCode, + city: city, + ); + } + + return _AddressParts(street: raw, postalCode: '', city: ''); + } + + String _composeAddress() { + final street = _streetCtrl.text.trim(); + final postalCode = _postalCodeCtrl.text.trim(); + final city = _cityCtrl.text.trim(); + if (postalCode.isEmpty && city.isEmpty) { + return street; + } + return '$street, $postalCode $city'.trim(); + } + + bool _isValidTime(String value) { + final match = RegExp(r'^([01]\d|2[0-3]):([0-5]\d)$').firstMatch(value); + return match != null; + } + + String _normalizeTime(String? raw) { + if (raw == null || raw.trim().isEmpty) return ''; + final compact = raw.replaceAll(RegExp(r'\D'), ''); + if (compact.length == 4) { + return '${compact.substring(0, 2)}:${compact.substring(2, 4)}'; + } + final trimmed = raw.trim(); + return _isValidTime(trimmed) ? trimmed : ''; + } + + Future _pickTime(TextEditingController controller) async { + final currentText = controller.text.trim(); + TimeOfDay initial = const TimeOfDay(hour: 9, minute: 0); + if (_isValidTime(currentText)) { + final parts = currentText.split(':'); + initial = TimeOfDay( + hour: int.parse(parts[0]), + minute: int.parse(parts[1]), + ); + } + + final picked = await showTimePicker( + context: context, + initialTime: initial, + builder: (context, child) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true), + child: child ?? const SizedBox.shrink(), + ); + }, + ); + + if (picked == null) return; + final hh = picked.hour.toString().padLeft(2, '0'); + final mm = picked.minute.toString().padLeft(2, '0'); + setState(() { + controller.text = '$hh:$mm'; + }); + } + + @override + Widget build(BuildContext context) { + final isCreation = widget.initial == null; + final availableWidth = + MediaQuery.of(context).size.width - _modalHorizontalSafetyMargin; + const preferredHoursWidth = _dayLabelWidth + + ((_targetTimeFieldWidth * 2) + _gap) + + _groupSeparatorWidth + + ((_targetTimeFieldWidth * 2) + _gap) + + _gap + + _closedAreaWidth; + const preferredDialogWidth = preferredHoursWidth + (_modalInnerPadding * 2); + final dialogWidth = + preferredDialogWidth.clamp(360.0, availableWidth).toDouble(); + + return Dialog( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: dialogWidth, + minWidth: dialogWidth, + maxHeight: 700, + ), + child: Padding( + padding: const EdgeInsets.all(_modalInnerPadding), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Text( + isCreation ? 'Nouveau relais' : 'Modifier relais', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + if (!isCreation) + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 8), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 6), + _buildTechniqueFields(), + const SizedBox(height: 16), + _buildTerritorialFields(), + ], + ), + ), + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isCreation) ...[ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Annuler'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _isValid() + ? () => Navigator.of(context).pop( + _RelaisDialogResult( + action: _RelaisDialogAction.save, + payload: _buildPayload(), + ), + ) + : null, + child: const Text('Créer'), + ), + ] else ...[ + OutlinedButton( + onPressed: () => Navigator.of(context).pop( + const _RelaisDialogResult( + action: _RelaisDialogAction.delete, + ), + ), + child: const Text('Supprimer'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _isValid() + ? () => Navigator.of(context).pop( + _RelaisDialogResult( + action: _RelaisDialogAction.save, + payload: _buildPayload(), + ), + ) + : null, + child: const Text('Modifier'), + ), + ], + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildTechniqueFields() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _nomCtrl, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: 'Nom du relais *', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _ligneFixeCtrl, + keyboardType: TextInputType.phone, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(10), + _FrenchPhoneNumberFormatter(), + ], + decoration: const InputDecoration( + labelText: 'Ligne fixe', + hintText: '01 23 45 67 89', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Relais actif'), + value: _actif, + onChanged: (value) => setState(() => _actif = value), + ), + ], + ); + } + + Widget _buildTerritorialFields() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _RelaisAddressFields( + streetController: _streetCtrl, + postalCodeController: _postalCodeCtrl, + cityController: _cityCtrl, + onChanged: () => setState(() {}), + ), + const SizedBox(height: 12), + TextField( + controller: _notesCtrl, + maxLines: 3, + decoration: const InputDecoration( + labelText: 'Notes', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: Text( + 'Horaires hebdomadaires', + style: TextStyle( + fontWeight: FontWeight.w600, + color: Colors.grey.shade800, + ), + ), + ), + const SizedBox(height: 6), + LayoutBuilder( + builder: (context, constraints) { + final availableHoursWidth = constraints.maxWidth; + const fixedWidth = _dayLabelWidth + + _groupSeparatorWidth + + _gap + + _closedAreaWidth + + (_gap * 2); + final computedTimeFieldWidth = + ((availableHoursWidth - fixedWidth) / 4) + .clamp(_minTimeFieldWidth, _targetTimeFieldWidth) + .toDouble(); + final groupWidth = (computedTimeFieldWidth * 2) + _gap; + final hoursContentWidth = _dayLabelWidth + + groupWidth + + _groupSeparatorWidth + + groupWidth + + _gap + + _closedAreaWidth; + + return SizedBox( + width: hoursContentWidth, + child: Stack( + children: [ + Positioned( + left: _dayLabelWidth + + groupWidth + + (_groupSeparatorWidth / 2), + top: 0, + bottom: 0, + child: Container( + width: _separatorLineWidth, + color: Colors.grey.shade400, + ), + ), + Column( + children: [ + Row( + children: [ + const SizedBox(width: _dayLabelWidth), + SizedBox( + width: groupWidth, + child: Center( + child: Text( + 'Matin', + style: TextStyle( + fontWeight: FontWeight.w600, + color: Colors.grey.shade800, + ), + ), + ), + ), + const SizedBox(width: _groupSeparatorWidth), + SizedBox( + width: groupWidth, + child: Center( + child: Text( + 'Après-midi', + style: TextStyle( + fontWeight: FontWeight.w600, + color: Colors.grey.shade800, + ), + ), + ), + ), + const SizedBox(width: _closedAreaWidth), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + const SizedBox(width: _dayLabelWidth), + SizedBox( + width: computedTimeFieldWidth, + child: const Center( + child: Text( + 'Début', + style: TextStyle(fontSize: 12), + ), + ), + ), + const SizedBox(width: _gap), + SizedBox( + width: computedTimeFieldWidth, + child: const Center( + child: Text( + 'Fin', + style: TextStyle(fontSize: 12), + ), + ), + ), + const SizedBox(width: _groupSeparatorWidth), + SizedBox( + width: computedTimeFieldWidth, + child: const Center( + child: Text( + 'Début', + style: TextStyle(fontSize: 12), + ), + ), + ), + const SizedBox(width: _gap), + SizedBox( + width: computedTimeFieldWidth, + child: const Center( + child: Text( + 'Fin', + style: TextStyle(fontSize: 12), + ), + ), + ), + const SizedBox(width: _gap), + const SizedBox(width: _closedAreaWidth), + ], + ), + const SizedBox(height: 6), + ..._days.map((day) { + final ferme = _closedByDay[day] ?? false; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + SizedBox( + width: _dayLabelWidth, + child: Text( + '${day[0].toUpperCase()}${day.substring(1)}', + style: const TextStyle(fontSize: 13), + ), + ), + _buildTimeField( + controller: _morningOpenCtrls[day]!, + enabled: !ferme, + placeholder: '. . : . .', + width: computedTimeFieldWidth, + ), + const SizedBox(width: _gap), + _buildTimeField( + controller: _morningCloseCtrls[day]!, + enabled: !ferme, + placeholder: '. . : . .', + width: computedTimeFieldWidth, + ), + const SizedBox(width: _groupSeparatorWidth), + _buildTimeField( + controller: _eveningOpenCtrls[day]!, + enabled: !ferme, + placeholder: '. . : . .', + width: computedTimeFieldWidth, + ), + const SizedBox(width: _gap), + _buildTimeField( + controller: _eveningCloseCtrls[day]!, + enabled: !ferme, + placeholder: '. . : . .', + width: computedTimeFieldWidth, + ), + const SizedBox(width: _gap), + Checkbox( + value: ferme, + onChanged: (value) { + setState(() { + _closedByDay[day] = value ?? false; + }); + }, + ), + const Text('Fermé'), + ], + ), + ); + }), + ], + ), + ], + ), + ); + }, + ), + ], + ); + } + + Widget _buildTimeField({ + required TextEditingController controller, + required bool enabled, + required String placeholder, + required double width, + }) { + return SizedBox( + width: width, + child: TextField( + controller: controller, + enabled: enabled, + onChanged: (_) => setState(() {}), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(4), + _HourMinuteFormatter(), + ], + decoration: InputDecoration( + hintText: placeholder, + border: const OutlineInputBorder(), + isDense: true, + suffixIcon: ExcludeFocus( + child: GestureDetector( + onTap: enabled ? () => _pickTime(controller) : null, + child: const Padding( + padding: EdgeInsets.all(8), + child: Icon(Icons.schedule, size: 18), + ), + ), + ), + ), + ), + ); + } +} + +class _FrenchPhoneNumberFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final digits = newValue.text.replaceAll(RegExp(r'\D'), ''); + final buffer = StringBuffer(); + + for (var i = 0; i < digits.length; i++) { + if (i > 0 && i.isEven) { + buffer.write(' '); + } + buffer.write(digits[i]); + } + + final formatted = buffer.toString(); + return TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); + } +} + +class _RelaisAddressFields extends StatelessWidget { + final TextEditingController streetController; + final TextEditingController postalCodeController; + final TextEditingController cityController; + final VoidCallback onChanged; + + const _RelaisAddressFields({ + required this.streetController, + required this.postalCodeController, + required this.cityController, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: streetController, + onChanged: (_) => onChanged(), + keyboardType: TextInputType.streetAddress, + textCapitalization: TextCapitalization.words, + autofillHints: const [AutofillHints.fullStreetAddress], + decoration: const InputDecoration( + labelText: 'Rue *', + hintText: 'Numéro et nom de rue', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.location_on_outlined), + ), + ), + const SizedBox(height: 10), + Row( + children: [ + SizedBox( + width: 140, + child: TextField( + controller: postalCodeController, + onChanged: (_) => onChanged(), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(5), + ], + decoration: const InputDecoration( + labelText: 'Code postal *', + hintText: '5 chiffres', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: TextField( + controller: cityController, + onChanged: (_) => onChanged(), + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration( + labelText: 'Ville *', + hintText: 'Ville', + border: OutlineInputBorder(), + ), + ), + ), + ], + ), + ], + ); + } +} + +class _AddressParts { + final String street; + final String postalCode; + final String city; + + const _AddressParts({ + required this.street, + required this.postalCode, + required this.city, + }); +} + +enum _RelaisDialogAction { save, delete } + +class _RelaisDialogResult { + final _RelaisDialogAction action; + final Map? payload; + + const _RelaisDialogResult({ + required this.action, + this.payload, + }); +} + +class _HourMinuteFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final digits = newValue.text.replaceAll(RegExp(r'\D'), ''); + final limited = digits.length > 4 ? digits.substring(0, 4) : digits; + + String text; + if (limited.length <= 2) { + text = limited; + } else { + text = '${limited.substring(0, 2)}:${limited.substring(2)}'; + } + + return TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ); + } +} diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart new file mode 100644 index 0000000..7f1e698 --- /dev/null +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart'; +import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart'; +import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; +import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart'; +import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart'; + +class AdminUserManagementPanel extends StatefulWidget { + const AdminUserManagementPanel({super.key}); + + @override + State createState() => + _AdminUserManagementPanelState(); +} + +class _AdminUserManagementPanelState extends State { + int _subIndex = 0; + final TextEditingController _searchController = TextEditingController(); + final TextEditingController _amCapacityController = TextEditingController(); + String? _parentStatus; + + @override + void initState() { + super.initState(); + _searchController.addListener(_onFilterChanged); + _amCapacityController.addListener(_onFilterChanged); + } + + @override + void dispose() { + _searchController.removeListener(_onFilterChanged); + _amCapacityController.removeListener(_onFilterChanged); + _searchController.dispose(); + _amCapacityController.dispose(); + super.dispose(); + } + + void _onFilterChanged() { + if (!mounted) return; + setState(() {}); + } + + void _onSubTabChange(int index) { + setState(() { + _subIndex = index; + _searchController.clear(); + _parentStatus = null; + _amCapacityController.clear(); + }); + } + + String _searchHintForTab() { + switch (_subIndex) { + case 0: + return 'Rechercher un gestionnaire...'; + case 1: + return 'Rechercher un parent...'; + case 2: + return 'Rechercher une assistante...'; + case 3: + return 'Rechercher un administrateur...'; + default: + return 'Rechercher...'; + } + } + + Widget? _subBarFilterControl() { + if (_subIndex == 1) { + return DropdownButtonHideUnderline( + child: DropdownButton( + value: _parentStatus, + isExpanded: true, + hint: const Padding( + padding: EdgeInsets.only(left: 10), + child: Text('Statut', style: TextStyle(fontSize: 12)), + ), + items: const [ + DropdownMenuItem( + value: null, + child: Padding( + padding: EdgeInsets.only(left: 10), + child: Text('Tous', style: TextStyle(fontSize: 12)), + ), + ), + DropdownMenuItem( + value: 'actif', + child: Padding( + padding: EdgeInsets.only(left: 10), + child: Text('Actif', style: TextStyle(fontSize: 12)), + ), + ), + DropdownMenuItem( + value: 'en_attente', + child: Padding( + padding: EdgeInsets.only(left: 10), + child: Text('En attente', style: TextStyle(fontSize: 12)), + ), + ), + DropdownMenuItem( + value: 'suspendu', + child: Padding( + padding: EdgeInsets.only(left: 10), + child: Text('Suspendu', style: TextStyle(fontSize: 12)), + ), + ), + ], + onChanged: (value) { + setState(() { + _parentStatus = value; + }); + }, + ), + ); + } + + if (_subIndex == 2) { + return TextField( + controller: _amCapacityController, + decoration: const InputDecoration( + hintText: 'Capacité min', + hintStyle: TextStyle(fontSize: 12), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8), + ), + keyboardType: TextInputType.number, + ); + } + return null; + } + + Widget _buildBody() { + switch (_subIndex) { + case 0: + return GestionnaireManagementWidget( + searchQuery: _searchController.text, + ); + case 1: + return ParentManagementWidget( + searchQuery: _searchController.text, + statusFilter: _parentStatus, + ); + case 2: + return AssistanteMaternelleManagementWidget( + searchQuery: _searchController.text, + capacityMin: int.tryParse(_amCapacityController.text), + ); + case 3: + return AdminManagementWidget( + searchQuery: _searchController.text, + ); + default: + return const Center(child: Text('Page non trouvée')); + } + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + DashboardUserManagementSubBar( + selectedSubIndex: _subIndex, + onSubTabChange: _onSubTabChange, + searchController: _searchController, + searchHint: _searchHintForTab(), + filterControl: _subBarFilterControl(), + onAddPressed: () { + // TODO: brancher création selon onglet actif + }, + addLabel: 'Ajouter', + ), + Expanded(child: _buildBody()), + ], + ); + } +}