From d8572e7fd6a0690a6713160116c8607649c506f0 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 24 Feb 2026 17:25:15 +0100 Subject: [PATCH] =?UTF-8?q?feat(#96):=20finaliser=20la=20modale=20admin/ge?= =?UTF-8?q?stionnaire=20et=20les=20r=C3=A8gles=20d=E2=80=99=C3=A9dition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unifie la modale utilisateur pour création/édition admin et gestionnaire, fiabilise la saisie/normalisation (téléphone, nom/prénom) et corrige la mise à jour backend pour accepter le rattachement relais sans erreur 400. Co-authored-by: Cursor --- .../user/dto/update_gestionnaire.dto.ts | 4 +- .../creation/gestionnaires_create.dart | 247 ++++++++++++++---- frontend/lib/services/user_service.dart | 86 +++++- .../admin/admin_management_widget.dart | 20 +- .../admin/gestionnaire_management_widget.dart | 2 +- .../widgets/admin/user_management_panel.dart | 63 +++-- 6 files changed, 352 insertions(+), 70 deletions(-) diff --git a/backend/src/routes/user/dto/update_gestionnaire.dto.ts b/backend/src/routes/user/dto/update_gestionnaire.dto.ts index f9a2360..ab035db 100644 --- a/backend/src/routes/user/dto/update_gestionnaire.dto.ts +++ b/backend/src/routes/user/dto/update_gestionnaire.dto.ts @@ -1,4 +1,4 @@ import { PartialType } from "@nestjs/swagger"; -import { CreateUserDto } from "./create_user.dto"; +import { CreateGestionnaireDto } from "./create_gestionnaire.dto"; -export class UpdateGestionnaireDto extends PartialType(CreateUserDto) {} \ No newline at end of file +export class UpdateGestionnaireDto extends PartialType(CreateGestionnaireDto) {} \ No newline at end of file diff --git a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart index e658063..d248dce 100644 --- a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart +++ b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart @@ -1,29 +1,35 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.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'; -class GestionnaireCreateDialog extends StatefulWidget { +class AdminUserFormDialog extends StatefulWidget { final AppUser? initialUser; + final bool withRelais; + final bool adminMode; - const GestionnaireCreateDialog({ + const AdminUserFormDialog({ super.key, this.initialUser, + this.withRelais = true, + this.adminMode = false, }); @override - State createState() => - _GestionnaireCreateDialogState(); + State createState() => _AdminUserFormDialogState(); } -class _GestionnaireCreateDialogState extends State { +class _AdminUserFormDialogState extends State { final _formKey = GlobalKey(); final _nomController = TextEditingController(); final _prenomController = TextEditingController(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); final _telephoneController = TextEditingController(); + final _passwordToggleFocusNode = + FocusNode(skipTraversal: true, canRequestFocus: false); bool _isSubmitting = false; bool _obscurePassword = true; @@ -40,7 +46,7 @@ class _GestionnaireCreateDialogState extends State { _nomController.text = user.nom ?? ''; _prenomController.text = user.prenom ?? ''; _emailController.text = user.email; - _telephoneController.text = user.telephone ?? ''; + _telephoneController.text = _formatPhoneForDisplay(user.telephone ?? ''); // En édition, on ne préremplit jamais le mot de passe. _passwordController.clear(); final initialRelaisId = user.relaisId?.trim(); @@ -49,7 +55,11 @@ class _GestionnaireCreateDialogState extends State { ? null : initialRelaisId; } - _loadRelais(); + if (widget.withRelais) { + _loadRelais(); + } else { + _isLoadingRelais = false; + } } @override @@ -59,6 +69,7 @@ class _GestionnaireCreateDialogState extends State { _emailController.dispose(); _passwordController.dispose(); _telephoneController.dispose(); + _passwordToggleFocusNode.dispose(); super.dispose(); } @@ -122,6 +133,66 @@ class _GestionnaireCreateDialogState extends State { return null; } + String? _validatePhone(String? value) { + if (_isEditMode && (value == null || value.trim().isEmpty)) { + return null; + } + final base = _required(value, 'Téléphone'); + if (base != null) return base; + final digits = _normalizePhone(value!); + if (digits.length != 10) { + return 'Le téléphone doit contenir 10 chiffres'; + } + if (!digits.startsWith('0')) { + return 'Le téléphone doit commencer par 0'; + } + return null; + } + + String _normalizePhone(String raw) { + return raw.replaceAll(RegExp(r'\D'), ''); + } + + String _formatPhoneForDisplay(String raw) { + final normalized = _normalizePhone(raw); + final digits = + normalized.length > 10 ? normalized.substring(0, 10) : normalized; + final buffer = StringBuffer(); + for (var i = 0; i < digits.length; i++) { + if (i > 0 && i.isEven) buffer.write(' '); + buffer.write(digits[i]); + } + return buffer.toString(); + } + + String _toTitleCase(String raw) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) return trimmed; + final words = trimmed.split(RegExp(r'\s+')); + final normalizedWords = words.map(_capitalizeComposedWord).toList(); + return normalizedWords.join(' '); + } + + String _capitalizeComposedWord(String word) { + if (word.isEmpty) return word; + final lower = word.toLowerCase(); + final separators = {"-", "'", "’"}; + final buffer = StringBuffer(); + var capitalizeNext = true; + + for (var i = 0; i < lower.length; i++) { + final char = lower[i]; + if (capitalizeNext && RegExp(r'[a-zà-öø-ÿ]').hasMatch(char)) { + buffer.write(char.toUpperCase()); + capitalizeNext = false; + } else { + buffer.write(char); + capitalizeNext = separators.contains(char); + } + } + return buffer.toString(); + } + Future _submit() async { if (_isSubmitting) return; if (!_formKey.currentState!.validate()) return; @@ -131,35 +202,85 @@ class _GestionnaireCreateDialogState extends State { }); try { + final normalizedNom = _toTitleCase(_nomController.text); + final normalizedPrenom = _toTitleCase(_prenomController.text); + final normalizedPhone = _normalizePhone(_telephoneController.text); + final passwordProvided = _passwordController.text.trim().isNotEmpty; + if (_isEditMode) { - await UserService.updateGestionnaire( - gestionnaireId: widget.initialUser!.id, - nom: _nomController.text.trim(), - prenom: _prenomController.text.trim(), - email: _emailController.text.trim(), - telephone: _telephoneController.text.trim(), - relaisId: _selectedRelaisId, - password: _passwordController.text.trim().isEmpty - ? null - : _passwordController.text, - ); + if (widget.adminMode) { + await UserService.updateAdministrateur( + adminId: widget.initialUser!.id, + nom: normalizedNom, + prenom: normalizedPrenom, + email: _emailController.text.trim(), + telephone: normalizedPhone.isEmpty + ? _normalizePhone(widget.initialUser!.telephone ?? '') + : normalizedPhone, + password: passwordProvided ? _passwordController.text : null, + ); + } else { + final currentUser = widget.initialUser!; + final initialNom = _toTitleCase(currentUser.nom ?? ''); + final initialPrenom = _toTitleCase(currentUser.prenom ?? ''); + final initialEmail = currentUser.email.trim(); + final initialPhone = _normalizePhone(currentUser.telephone ?? ''); + + final onlyRelaisChanged = + normalizedNom == initialNom && + normalizedPrenom == initialPrenom && + _emailController.text.trim() == initialEmail && + normalizedPhone == initialPhone && + !passwordProvided; + + if (onlyRelaisChanged) { + await UserService.updateGestionnaireRelais( + gestionnaireId: currentUser.id, + relaisId: _selectedRelaisId, + ); + } else { + await UserService.updateGestionnaire( + gestionnaireId: currentUser.id, + nom: normalizedNom, + prenom: normalizedPrenom, + email: _emailController.text.trim(), + telephone: normalizedPhone.isEmpty ? initialPhone : normalizedPhone, + relaisId: _selectedRelaisId, + password: passwordProvided ? _passwordController.text : null, + ); + } + } } else { - await UserService.createGestionnaire( - nom: _nomController.text.trim(), - prenom: _prenomController.text.trim(), - email: _emailController.text.trim(), - password: _passwordController.text, - telephone: _telephoneController.text.trim(), - relaisId: _selectedRelaisId, - ); + if (widget.adminMode) { + await UserService.createAdministrateur( + nom: normalizedNom, + prenom: normalizedPrenom, + email: _emailController.text.trim(), + password: _passwordController.text, + telephone: _normalizePhone(_telephoneController.text), + ); + } else { + await UserService.createGestionnaire( + nom: normalizedNom, + prenom: normalizedPrenom, + email: _emailController.text.trim(), + password: _passwordController.text, + telephone: _normalizePhone(_telephoneController.text), + relaisId: _selectedRelaisId, + ); + } } if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( _isEditMode - ? 'Gestionnaire modifié avec succès.' - : 'Gestionnaire créé avec succès.', + ? (widget.adminMode + ? 'Administrateur modifié avec succès.' + : 'Gestionnaire modifié avec succès.') + : (widget.adminMode + ? 'Administrateur créé avec succès.' + : 'Gestionnaire créé avec succès.'), ), ), ); @@ -242,8 +363,12 @@ class _GestionnaireCreateDialogState extends State { Expanded( child: Text( _isEditMode - ? 'Modifier un gestionnaire' - : 'Créer un gestionnaire', + ? (widget.adminMode + ? 'Modifier un administrateur' + : 'Modifier un gestionnaire') + : (widget.adminMode + ? 'Créer un administrateur' + : 'Créer un gestionnaire'), ), ), if (_isEditMode) @@ -266,9 +391,9 @@ class _GestionnaireCreateDialogState extends State { children: [ Row( children: [ - Expanded(child: _buildNomField()), - const SizedBox(width: 12), Expanded(child: _buildPrenomField()), + const SizedBox(width: 12), + Expanded(child: _buildNomField()), ], ), const SizedBox(height: 12), @@ -281,8 +406,10 @@ class _GestionnaireCreateDialogState extends State { Expanded(child: _buildTelephoneField()), ], ), - const SizedBox(height: 12), - _buildRelaisField(), + if (widget.withRelais) ...[ + const SizedBox(height: 12), + _buildRelaisField(), + ], ], ), ), @@ -378,14 +505,17 @@ class _GestionnaireCreateDialogState extends State { ? 'Nouveau mot de passe' : 'Mot de passe', border: const OutlineInputBorder(), - suffixIcon: IconButton( - onPressed: () { - setState(() { - _obscurePassword = !_obscurePassword; - }); - }, - icon: Icon( - _obscurePassword ? Icons.visibility_off : Icons.visibility, + suffixIcon: ExcludeFocus( + child: IconButton( + focusNode: _passwordToggleFocusNode, + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + icon: Icon( + _obscurePassword ? Icons.visibility_off : Icons.visibility, + ), ), ), ), @@ -397,11 +527,16 @@ class _GestionnaireCreateDialogState extends State { return TextFormField( controller: _telephoneController, keyboardType: TextInputType.phone, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(10), + _FrenchPhoneNumberFormatter(), + ], decoration: const InputDecoration( - labelText: 'Téléphone', + labelText: 'Téléphone (ex: 06 12 34 56 78)', border: OutlineInputBorder(), ), - validator: (v) => _required(v, 'Téléphone'), + validator: _validatePhone, ); } @@ -448,4 +583,28 @@ class _GestionnaireCreateDialogState extends State { ], ); } +} + +class _FrenchPhoneNumberFormatter extends TextInputFormatter { + const _FrenchPhoneNumberFormatter(); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final digits = newValue.text.replaceAll(RegExp(r'\D'), ''); + final normalized = digits.length > 10 ? digits.substring(0, 10) : digits; + final buffer = StringBuffer(); + for (var i = 0; i < normalized.length; i++) { + if (i > 0 && i.isEven) buffer.write(' '); + buffer.write(normalized[i]); + } + final formatted = buffer.toString(); + + return TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); + } } \ No newline at end of file diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index d069bca..84659c8 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -75,6 +75,41 @@ class UserService { return AppUser.fromJson(data); } + static Future createAdministrateur({ + required String nom, + required String prenom, + required String email, + required String password, + required String telephone, + }) async { + final response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/admin'), + headers: await _headers(), + body: jsonEncode({ + 'nom': nom, + 'prenom': prenom, + 'email': email, + 'password': password, + 'telephone': telephone, + }), + ); + + if (response.statusCode != 200 && response.statusCode != 201) { + final decoded = jsonDecode(response.body); + if (decoded is Map) { + final message = decoded['message']; + if (message is List && message.isNotEmpty) { + throw Exception(message.join(' - ')); + } + throw Exception(_toStr(message) ?? 'Erreur création administrateur'); + } + throw Exception('Erreur création administrateur'); + } + + final data = jsonDecode(response.body) as Map; + return AppUser.fromJson(data); + } + // Récupérer la liste des parents static Future> getParents() async { final response = await http.get( @@ -156,7 +191,7 @@ class UserService { required String nom, required String prenom, required String email, - required String telephone, + String? telephone, required String? relaisId, String? password, }) async { @@ -164,10 +199,13 @@ class UserService { 'nom': nom, 'prenom': prenom, 'email': email, - 'telephone': telephone, 'relaisId': relaisId, }; + if (telephone != null && telephone.trim().isNotEmpty) { + body['telephone'] = telephone.trim(); + } + if (password != null && password.trim().isNotEmpty) { body['password'] = password.trim(); } @@ -194,6 +232,50 @@ class UserService { return AppUser.fromJson(data); } + static Future updateAdministrateur({ + required String adminId, + required String nom, + required String prenom, + required String email, + String? telephone, + String? password, + }) async { + final body = { + 'nom': nom, + 'prenom': prenom, + 'email': email, + }; + + if (telephone != null && telephone.trim().isNotEmpty) { + body['telephone'] = telephone.trim(); + } + + if (password != null && password.trim().isNotEmpty) { + body['password'] = password.trim(); + } + + final response = await http.patch( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$adminId'), + headers: await _headers(), + body: jsonEncode(body), + ); + + if (response.statusCode != 200) { + final decoded = jsonDecode(response.body); + if (decoded is Map) { + final message = decoded['message']; + if (message is List && message.isNotEmpty) { + throw Exception(message.join(' - ')); + } + throw Exception(_toStr(message) ?? 'Erreur modification administrateur'); + } + throw Exception('Erreur modification administrateur'); + } + + final data = jsonDecode(response.body) as Map; + return AppUser.fromJson(data); + } + static Future deleteUser(String userId) async { final response = await http.delete( Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'), diff --git a/frontend/lib/widgets/admin/admin_management_widget.dart b/frontend/lib/widgets/admin/admin_management_widget.dart index 666a64e..a8e2db5 100644 --- a/frontend/lib/widgets/admin/admin_management_widget.dart +++ b/frontend/lib/widgets/admin/admin_management_widget.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.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'; @@ -51,6 +52,23 @@ class _AdminManagementWidgetState extends State { } } + Future _openAdminEditDialog(AppUser user) async { + final changed = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return AdminUserFormDialog( + initialUser: user, + adminMode: true, + withRelais: false, + ); + }, + ); + if (changed == true) { + await _loadAdmins(); + } + } + @override Widget build(BuildContext context) { final query = widget.searchQuery.toLowerCase(); @@ -80,7 +98,7 @@ class _AdminManagementWidgetState extends State { icon: const Icon(Icons.edit), tooltip: 'Modifier', onPressed: () { - // TODO: Modifier admin + _openAdminEditDialog(user); }, ), ], diff --git a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart index 58b78e6..4ea3ead 100644 --- a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart +++ b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart @@ -59,7 +59,7 @@ class _GestionnaireManagementWidgetState context: context, barrierDismissible: false, builder: (dialogContext) { - return GestionnaireCreateDialog(initialUser: user); + return AdminUserFormDialog(initialUser: user); }, ); if (changed == true) { diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index aa408c4..63bc921 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -17,6 +17,7 @@ class AdminUserManagementPanel extends StatefulWidget { class _AdminUserManagementPanelState extends State { int _subIndex = 0; int _gestionnaireRefreshTick = 0; + int _adminRefreshTick = 0; final TextEditingController _searchController = TextEditingController(); final TextEditingController _amCapacityController = TextEditingController(); String? _parentStatus; @@ -150,6 +151,7 @@ class _AdminUserManagementPanelState extends State { ); case 3: return AdminManagementWidget( + key: ValueKey('admins-$_adminRefreshTick'), searchQuery: _searchController.text, ); default: @@ -176,31 +178,52 @@ class _AdminUserManagementPanelState extends State { } Future _handleAddPressed() async { - if (_subIndex != 0) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'La création est disponible uniquement pour les gestionnaires.', - ), - ), + if (_subIndex == 0) { + final created = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return const AdminUserFormDialog(); + }, ); + + if (!mounted) return; + if (created == true) { + setState(() { + _gestionnaireRefreshTick++; + }); + } return; } - final created = await showDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) { - return const GestionnaireCreateDialog(); - }, - ); + if (_subIndex == 3) { + final created = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return const AdminUserFormDialog( + adminMode: true, + withRelais: false, + ); + }, + ); + + if (!mounted) return; + if (created == true) { + setState(() { + _adminRefreshTick++; + }); + } + return; + } if (!mounted) return; - if (created == true) { - setState(() { - _gestionnaireRefreshTick++; - }); - } + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'La création est disponible pour les gestionnaires et administrateurs.', + ), + ), + ); } }