From f745079f0aba779f8cbb26fab033ca05a0730170 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Mon, 14 Sep 2026 17:34:25 +0200 Subject: [PATCH] feat(#164): uniformisation modale staff gestionnaire / admin (squash develop). Co-authored-by: Cursor --- ...64-staff-modal-uniformisation-mini-spec.md | 14 + .../creation/gestionnaires_create.dart | 675 --------------- .../admin/admin_management_widget.dart | 4 +- .../gestionnaire_management_widget.dart | 4 +- .../dashboard/staff_user_form_modal.dart | 794 ++++++++++++++++++ .../dashboard/user_management_panel.dart | 6 +- 6 files changed, 815 insertions(+), 682 deletions(-) create mode 100644 docs/tmp/164-staff-modal-uniformisation-mini-spec.md delete mode 100644 frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart create mode 100644 frontend/lib/widgets/dashboard/staff_user_form_modal.dart diff --git a/docs/tmp/164-staff-modal-uniformisation-mini-spec.md b/docs/tmp/164-staff-modal-uniformisation-mini-spec.md new file mode 100644 index 0000000..f035ca8 --- /dev/null +++ b/docs/tmp/164-staff-modal-uniformisation-mini-spec.md @@ -0,0 +1,14 @@ +# Mini-spec — Uniformisation modale staff (Gestionnaire / Administrateur) + +**Ticket** : **#164** — https://git.ptits-pas.fr/jmartin/petitspas/issues/164 +**Branche** : `feature/164-staff-modal-uniformisation` (depuis `develop`) +**Périmètre** : **front only** — pas d’API / BDD +**Milestone** : **0.1.0** + +Voir le corps du ticket #164 pour la spec complète. + +## Livré + +- `frontend/lib/widgets/dashboard/staff_user_form_modal.dart` → `StaffUserFormModal` +- Ancien `AdminUserFormDialog` / `gestionnaires_create.dart` retiré +- Imports : `user_management_panel`, `gestionnaire_management_widget`, `admin_management_widget` diff --git a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart deleted file mode 100644 index 89c1d55..0000000 --- a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart +++ /dev/null @@ -1,675 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:p_tits_pas/models/relais_model.dart'; -import 'package:p_tits_pas/utils/phone_utils.dart'; -import 'package:p_tits_pas/utils/email_utils.dart'; -import 'package:p_tits_pas/widgets/email_text_field.dart'; -import 'package:p_tits_pas/widgets/french_phone_field.dart'; -import 'package:p_tits_pas/models/user.dart'; -import 'package:p_tits_pas/services/auth_service.dart'; -import 'package:p_tits_pas/services/relais_service.dart'; -import 'package:p_tits_pas/services/user_service.dart'; -import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; -import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart'; - -class AdminUserFormDialog extends StatefulWidget { - final AppUser? initialUser; - final bool withRelais; - final bool adminMode; - final bool readOnly; - - const AdminUserFormDialog({ - super.key, - this.initialUser, - this.withRelais = true, - this.adminMode = false, - this.readOnly = false, - }); - - @override - State createState() => _AdminUserFormDialogState(); -} - -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; - bool _isLoadingRelais = true; - List _relais = []; - String? _selectedRelaisId; - String? _currentUserId; - String? _currentUserRole; - bool get _isEditMode => widget.initialUser != null; - bool get _isSuperAdminTarget => - (widget.initialUser?.role ?? '').toLowerCase() == 'super_admin'; - bool get _isSelfTarget => - _isEditMode && - _currentUserId != null && - widget.initialUser!.id == _currentUserId; - /// Gestionnaire : pas de delete staff. Admin+ seulement (#154 / #160). - bool get _canDeleteTarget { - if (!_isEditMode || widget.readOnly) return false; - if (_isSelfTarget || _isSuperAdminTarget) return false; - return canDeleteGestionnaire(_currentUserRole); - } - bool get _isLockedAdminIdentity => - _isEditMode && widget.adminMode && _isSuperAdminTarget; - String get _targetRoleKey { - if (widget.initialUser != null) { - return (widget.initialUser!.role).toLowerCase(); - } - return widget.adminMode ? 'administrateur' : 'gestionnaire'; - } - - String get _targetRoleLabel { - switch (_targetRoleKey) { - case 'super_admin': - return 'Super administrateur'; - case 'administrateur': - return 'Administrateur'; - case 'gestionnaire': - return 'Gestionnaire'; - case 'assistante_maternelle': - return 'Assistante maternelle'; - case 'parent': - return 'Parent'; - default: - return 'Utilisateur'; - } - } - - IconData get _targetRoleIcon { - switch (_targetRoleKey) { - case 'super_admin': - return Icons.verified_user_outlined; - case 'administrateur': - return Icons.admin_panel_settings_outlined; - case 'gestionnaire': - return Icons.assignment_ind_outlined; - case 'assistante_maternelle': - return Icons.child_care_outlined; - case 'parent': - return Icons.supervisor_account_outlined; - default: - return Icons.person_outline; - } - } - - @override - void initState() { - super.initState(); - final user = widget.initialUser; - if (user != null) { - _nomController.text = user.nom ?? ''; - _prenomController.text = user.prenom ?? ''; - _emailController.text = user.email; - _telephoneController.text = formatPhoneForDisplay(user.telephone ?? ''); - // En édition, on ne préremplit jamais le mot de passe. - _passwordController.clear(); - final initialRelaisId = user.relaisId?.trim(); - _selectedRelaisId = - (initialRelaisId == null || initialRelaisId.isEmpty) - ? null - : initialRelaisId; - } - if (widget.withRelais) { - _loadRelais(); - } else { - _isLoadingRelais = false; - } - _loadCurrentUserId(); - } - - Future _loadCurrentUserId() async { - final cached = await AuthService.getCurrentUser(); - if (!mounted) return; - if (cached != null) { - setState(() { - _currentUserId = cached.id; - _currentUserRole = cached.role; - }); - return; - } - final refreshed = await AuthService.refreshCurrentUser(); - if (!mounted || refreshed == null) return; - setState(() { - _currentUserId = refreshed.id; - _currentUserRole = refreshed.role; - }); - } - - @override - void dispose() { - _nomController.dispose(); - _prenomController.dispose(); - _emailController.dispose(); - _passwordController.dispose(); - _telephoneController.dispose(); - _passwordToggleFocusNode.dispose(); - super.dispose(); - } - - /// Fallback si GET /relais échoue : conserve le relais déjà connu sur l'utilisateur. - List _fallbackRelaisFromUser() { - final id = _selectedRelaisId?.trim(); - if (id == null || id.isEmpty) return const []; - final nom = (widget.initialUser?.relaisNom ?? '').trim(); - return [ - RelaisModel( - id: id, - nom: nom.isNotEmpty ? nom : 'Relais actuel', - adresse: '', - actif: true, - ), - ]; - } - - Future _loadRelais() async { - try { - final list = await RelaisService.getRelais(); - if (!mounted) return; - final uniqueById = {}; - for (final relais in list) { - uniqueById[relais.id] = relais; - } - - final filtered = uniqueById.values.where((r) => r.actif).toList(); - if (_selectedRelaisId != null && - !filtered.any((r) => r.id == _selectedRelaisId)) { - final selected = uniqueById[_selectedRelaisId!]; - if (selected != null) { - filtered.add(selected); - } else { - // Garder l'id sélectionné et afficher un item de secours (nom carte). - filtered.addAll(_fallbackRelaisFromUser()); - } - } - - setState(() { - _relais = filtered; - _isLoadingRelais = false; - }); - } catch (_) { - if (!mounted) return; - // Ne pas nullifier _selectedRelaisId (#151) — la carte a déjà le bon libellé. - setState(() { - _relais = _fallbackRelaisFromUser(); - _isLoadingRelais = false; - }); - } - } - - String? _required(String? value, String field) { - if (value == null || value.trim().isEmpty) { - return '$field est requis'; - } - return null; - } - - String? _validateEmail(String? value) { - final base = _required(value, 'Email'); - if (base != null) { - return base; - } - return validateEmail(value, allowEmpty: true); - } - - String? _validatePassword(String? value) { - if (_isEditMode && (value == null || value.trim().isEmpty)) { - return null; - } - final base = _required(value, 'Mot de passe'); - if (base != null) return base; - if (value!.trim().length < 6) return 'Minimum 6 caractères'; - 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; - } - return validateFrenchNationalPhone(value, allowEmpty: false); - } - - 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 (widget.readOnly) return; - if (_isSubmitting) return; - if (!_formKey.currentState!.validate()) return; - - setState(() { - _isSubmitting = true; - }); - - try { - final normalizedNom = _toTitleCase(_nomController.text); - final normalizedPrenom = _toTitleCase(_prenomController.text); - final normalizedPhone = normalizePhone(_telephoneController.text); - final passwordProvided = _passwordController.text.trim().isNotEmpty; - - if (_isEditMode) { - if (widget.adminMode) { - final lockedNom = _toTitleCase(widget.initialUser!.nom ?? ''); - final lockedPrenom = _toTitleCase(widget.initialUser!.prenom ?? ''); - await UserService.updateAdministrateur( - adminId: widget.initialUser!.id, - nom: _isLockedAdminIdentity ? lockedNom : normalizedNom, - prenom: _isLockedAdminIdentity ? lockedPrenom : 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 { - 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 - ? (widget.adminMode - ? 'Administrateur modifié avec succès.' - : 'Gestionnaire modifié avec succès.') - : (widget.adminMode - ? 'Administrateur créé avec succès.' - : 'Gestionnaire créé avec succès.'), - ), - ), - ); - Navigator.of(context).pop(true); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - e.toString().replaceFirst('Exception: ', ''), - ), - backgroundColor: Colors.red.shade700, - ), - ); - } finally { - if (!mounted) return; - setState(() { - _isSubmitting = false; - }); - } - } - - Future _delete() async { - if (widget.readOnly) return; - if (!_canDeleteTarget) return; - if (!_isEditMode || _isSubmitting) return; - - final name = widget.initialUser!.fullName.isEmpty - ? widget.initialUser!.email - : widget.initialUser!.fullName; - final confirmed = await showSuppressionConfirmDialog( - context, - title: widget.adminMode - ? 'Supprimer l\'administrateur' - : 'Supprimer le gestionnaire', - people: [ - widget.adminMode - ? SuppressionPersonLine.administrateur(name) - : SuppressionPersonLine.gestionnaire(name), - ], - footnotes: const [ - 'Le compte sera définitivement supprimé.', - ], - ); - - if (!confirmed) return; - - setState(() { - _isSubmitting = true; - }); - try { - await UserService.deleteUser(widget.initialUser!.id); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Gestionnaire supprimé.')), - ); - Navigator.of(context).pop(true); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(e.toString().replaceFirst('Exception: ', '')), - backgroundColor: Colors.red.shade700, - ), - ); - setState(() { - _isSubmitting = false; - }); - } - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: Row( - children: [ - CircleAvatar( - radius: 16, - backgroundColor: const Color(0xFFEDE5FA), - child: Icon( - _targetRoleIcon, - size: 20, - color: const Color(0xFF6B3FA0), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - _isEditMode - ? (widget.readOnly - ? 'Consulter un "$_targetRoleLabel"' - : 'Modifier un "$_targetRoleLabel"') - : 'Créer un "$_targetRoleLabel"', - ), - ), - if (_isEditMode && !widget.readOnly) - IconButton( - icon: const Icon(Icons.close), - tooltip: 'Fermer', - onPressed: _isSubmitting - ? null - : () => Navigator.of(context).pop(false), - ), - ], - ), - content: SizedBox( - width: 620, - child: Form( - key: _formKey, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Expanded(child: _buildPrenomField()), - const SizedBox(width: 12), - Expanded(child: _buildNomField()), - ], - ), - const SizedBox(height: 12), - _buildEmailField(), - const SizedBox(height: 12), - Row( - children: [ - Expanded(child: _buildPasswordField()), - const SizedBox(width: 12), - Expanded(child: _buildTelephoneField()), - ], - ), - if (widget.withRelais) ...[ - const SizedBox(height: 12), - _buildRelaisField(), - ], - ], - ), - ), - ), - ), - actions: [ - if (widget.readOnly) ...[ - FilledButton( - onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(false), - child: const Text('Fermer'), - ), - ] else if (_isEditMode) ...[ - if (_canDeleteTarget) - OutlinedButton( - onPressed: _isSubmitting ? null : _delete, - style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700), - child: const Text('Supprimer'), - ), - FilledButton.icon( - onPressed: _isSubmitting ? null : _submit, - icon: _isSubmitting - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.edit), - label: Text(_isSubmitting ? 'Modification...' : 'Modifier'), - ), - ] else ...[ - OutlinedButton( - onPressed: - _isSubmitting ? null : () => Navigator.of(context).pop(false), - child: const Text('Annuler'), - ), - FilledButton.icon( - onPressed: _isSubmitting ? null : _submit, - icon: _isSubmitting - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.person_add_alt_1), - label: Text(_isSubmitting ? 'Création...' : 'Créer'), - ), - ], - ], - ); - } - - Widget _buildNomField() { - return TextFormField( - controller: _nomController, - readOnly: widget.readOnly || _isLockedAdminIdentity, - textCapitalization: TextCapitalization.words, - decoration: const InputDecoration( - labelText: 'Nom', - border: OutlineInputBorder(), - ), - validator: (widget.readOnly || _isLockedAdminIdentity) - ? null - : (v) => _required(v, 'Nom'), - ); - } - - Widget _buildPrenomField() { - return TextFormField( - controller: _prenomController, - readOnly: widget.readOnly || _isLockedAdminIdentity, - textCapitalization: TextCapitalization.words, - decoration: const InputDecoration( - labelText: 'Prénom', - border: OutlineInputBorder(), - ), - validator: (widget.readOnly || _isLockedAdminIdentity) - ? null - : (v) => _required(v, 'Prénom'), - ); - } - - Widget _buildEmailField() { - return EmailTextFormField( - controller: _emailController, - readOnly: widget.readOnly, - label: 'Email', - validator: widget.readOnly ? null : _validateEmail, - ); - } - - Widget _buildPasswordField() { - return TextFormField( - controller: _passwordController, - readOnly: widget.readOnly, - obscureText: _obscurePassword, - enableSuggestions: false, - autocorrect: false, - autofillHints: _isEditMode - ? const [] - : const [AutofillHints.newPassword], - decoration: InputDecoration( - labelText: _isEditMode - ? 'Nouveau mot de passe' - : 'Mot de passe', - border: const OutlineInputBorder(), - suffixIcon: widget.readOnly - ? null - : ExcludeFocus( - child: IconButton( - focusNode: _passwordToggleFocusNode, - onPressed: () { - setState(() { - _obscurePassword = !_obscurePassword; - }); - }, - icon: Icon( - _obscurePassword ? Icons.visibility_off : Icons.visibility, - ), - ), - ), - ), - validator: widget.readOnly ? null : _validatePassword, - ); - } - - Widget _buildTelephoneField() { - return FrenchPhoneTextFormField( - controller: _telephoneController, - readOnly: widget.readOnly, - label: 'Téléphone (ex: 06 12 34 56 78)', - validator: widget.readOnly ? null : _validatePhone, - ); - } - - Widget _buildRelaisField() { - final selectedValue = _selectedRelaisId != null && - _relais.any((relais) => relais.id == _selectedRelaisId) - ? _selectedRelaisId - : null; - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - DropdownButtonFormField( - isExpanded: true, - value: selectedValue, - 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: (_isLoadingRelais || widget.readOnly) - ? null - : (value) { - setState(() { - _selectedRelaisId = value; - }); - }, - ), - if (_isLoadingRelais) ...[ - const SizedBox(height: 8), - const LinearProgressIndicator(minHeight: 2), - ], - ], - ); - } -} diff --git a/frontend/lib/widgets/admin/admin_management_widget.dart b/frontend/lib/widgets/admin/admin_management_widget.dart index facaa8b..95d186d 100644 --- a/frontend/lib/widgets/admin/admin_management_widget.dart +++ b/frontend/lib/widgets/admin/admin_management_widget.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; -import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart'; +import 'package:p_tits_pas/widgets/dashboard/staff_user_form_modal.dart'; import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; @@ -100,7 +100,7 @@ class _AdminManagementWidgetState extends State { context: context, barrierDismissible: false, builder: (dialogContext) { - return AdminUserFormDialog( + return StaffUserFormModal( initialUser: user, adminMode: true, withRelais: false, diff --git a/frontend/lib/widgets/dashboard/gestionnaire_management_widget.dart b/frontend/lib/widgets/dashboard/gestionnaire_management_widget.dart index 3e824e7..970c90d 100644 --- a/frontend/lib/widgets/dashboard/gestionnaire_management_widget.dart +++ b/frontend/lib/widgets/dashboard/gestionnaire_management_widget.dart @@ -1,6 +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/widgets/dashboard/staff_user_form_modal.dart'; import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; @@ -74,7 +74,7 @@ class _GestionnaireManagementWidgetState context: context, barrierDismissible: false, builder: (dialogContext) { - return AdminUserFormDialog(initialUser: user); + return StaffUserFormModal(initialUser: user); }, ); if (changed == true) { diff --git a/frontend/lib/widgets/dashboard/staff_user_form_modal.dart b/frontend/lib/widgets/dashboard/staff_user_form_modal.dart new file mode 100644 index 0000000..abcf2f0 --- /dev/null +++ b/frontend/lib/widgets/dashboard/staff_user_form_modal.dart @@ -0,0 +1,794 @@ +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/auth_service.dart'; +import 'package:p_tits_pas/services/relais_service.dart'; +import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/utils/email_utils.dart'; +import 'package:p_tits_pas/utils/name_format_utils.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; +import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart'; +import 'package:p_tits_pas/widgets/dashboard/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart'; + +/// Modale création / édition / consultation staff (gestionnaire / admin) — #164. +class StaffUserFormModal extends StatefulWidget { + final AppUser? initialUser; + final bool withRelais; + final bool adminMode; + final bool readOnly; + + const StaffUserFormModal({ + super.key, + this.initialUser, + this.withRelais = true, + this.adminMode = false, + this.readOnly = false, + }); + + @override + State createState() => _StaffUserFormModalState(); +} + +class _StaffUserFormModalState extends State { + static const double _modalWidth = 930; + + 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; + bool _isLoadingRelais = true; + bool _dirty = false; + List _relais = []; + String? _selectedRelaisId; + String? _currentUserId; + String? _currentUserRole; + + String _baselineNom = ''; + String _baselinePrenom = ''; + String _baselineEmail = ''; + String _baselinePhone = ''; + String? _baselineRelaisId; + + bool get _isEditMode => widget.initialUser != null; + bool get _isSuperAdminTarget => + (widget.initialUser?.role ?? '').toLowerCase() == 'super_admin'; + bool get _isSelfTarget => + _isEditMode && + _currentUserId != null && + widget.initialUser!.id == _currentUserId; + bool get _canDeleteTarget { + if (!_isEditMode || widget.readOnly) return false; + if (_isSelfTarget || _isSuperAdminTarget) return false; + return canDeleteGestionnaire(_currentUserRole); + } + + bool get _isLockedAdminIdentity => + _isEditMode && widget.adminMode && _isSuperAdminTarget; + + bool get _fieldsEnabled => !widget.readOnly && !_isSubmitting; + + String get _targetRoleKey { + if (widget.initialUser != null) { + return (widget.initialUser!.role).toLowerCase(); + } + return widget.adminMode ? 'administrateur' : 'gestionnaire'; + } + + String get _targetRoleLabel { + switch (_targetRoleKey) { + case 'super_admin': + return 'Super administrateur'; + case 'administrateur': + return 'Administrateur'; + case 'gestionnaire': + return 'Gestionnaire'; + default: + return 'Utilisateur'; + } + } + + IconData get _targetRoleIcon { + switch (_targetRoleKey) { + case 'super_admin': + return Icons.verified_user_outlined; + case 'administrateur': + return Icons.admin_panel_settings_outlined; + case 'gestionnaire': + return Icons.assignment_ind_outlined; + default: + return Icons.person_outline; + } + } + + @override + void initState() { + super.initState(); + final user = widget.initialUser; + if (user != null) { + _nomController.text = user.nom ?? ''; + _prenomController.text = user.prenom ?? ''; + _emailController.text = user.email; + _telephoneController.text = formatPhoneForDisplay(user.telephone ?? ''); + _passwordController.clear(); + final initialRelaisId = user.relaisId?.trim(); + _selectedRelaisId = + (initialRelaisId == null || initialRelaisId.isEmpty) + ? null + : initialRelaisId; + _captureBaseline(); + } + for (final c in [ + _nomController, + _prenomController, + _emailController, + _passwordController, + _telephoneController, + ]) { + c.addListener(_onFieldChanged); + } + if (widget.withRelais) { + _loadRelais(); + } else { + _isLoadingRelais = false; + } + _loadCurrentUserId(); + } + + void _captureBaseline() { + _baselineNom = formatPersonNameCase(_nomController.text); + _baselinePrenom = formatPersonNameCase(_prenomController.text); + _baselineEmail = normalizeEmailText(_emailController.text); + _baselinePhone = normalizePhone(_telephoneController.text); + _baselineRelaisId = _selectedRelaisId; + } + + void _onFieldChanged() { + if (widget.readOnly || !_isEditMode) return; + final dirty = _computeDirty(); + if (dirty != _dirty) setState(() => _dirty = dirty); + } + + bool _computeDirty() { + if (!_isEditMode) return true; + final nom = formatPersonNameCase(_nomController.text); + final prenom = formatPersonNameCase(_prenomController.text); + final email = normalizeEmailText(_emailController.text); + final phone = normalizePhone(_telephoneController.text); + final passwordProvided = _passwordController.text.trim().isNotEmpty; + return nom != _baselineNom || + prenom != _baselinePrenom || + email != _baselineEmail || + phone != _baselinePhone || + passwordProvided || + _selectedRelaisId != _baselineRelaisId; + } + + Future _loadCurrentUserId() async { + final cached = await AuthService.getCurrentUser(); + if (!mounted) return; + if (cached != null) { + setState(() { + _currentUserId = cached.id; + _currentUserRole = cached.role; + }); + return; + } + final refreshed = await AuthService.refreshCurrentUser(); + if (!mounted || refreshed == null) return; + setState(() { + _currentUserId = refreshed.id; + _currentUserRole = refreshed.role; + }); + } + + @override + void dispose() { + for (final c in [ + _nomController, + _prenomController, + _emailController, + _passwordController, + _telephoneController, + ]) { + c.removeListener(_onFieldChanged); + c.dispose(); + } + _passwordToggleFocusNode.dispose(); + super.dispose(); + } + + List _fallbackRelaisFromUser() { + final id = _selectedRelaisId?.trim(); + if (id == null || id.isEmpty) return const []; + final nom = (widget.initialUser?.relaisNom ?? '').trim(); + return [ + RelaisModel( + id: id, + nom: nom.isNotEmpty ? nom : 'Relais actuel', + adresse: '', + actif: true, + ), + ]; + } + + Future _loadRelais() async { + try { + final list = await RelaisService.getRelais(); + if (!mounted) return; + final uniqueById = {}; + for (final relais in list) { + uniqueById[relais.id] = relais; + } + + final filtered = uniqueById.values.where((r) => r.actif).toList(); + if (_selectedRelaisId != null && + !filtered.any((r) => r.id == _selectedRelaisId)) { + final selected = uniqueById[_selectedRelaisId!]; + if (selected != null) { + filtered.add(selected); + } else { + filtered.addAll(_fallbackRelaisFromUser()); + } + } + + setState(() { + _relais = filtered; + _isLoadingRelais = false; + }); + } catch (_) { + if (!mounted) return; + setState(() { + _relais = _fallbackRelaisFromUser(); + _isLoadingRelais = false; + }); + } + } + + String? _required(String? value, String field) { + if (value == null || value.trim().isEmpty) { + return '$field est requis'; + } + return null; + } + + String? _validatePassword(String? value) { + if (_isEditMode && (value == null || value.trim().isEmpty)) { + return null; + } + final base = _required(value, 'Mot de passe'); + if (base != null) return base; + if (value!.trim().length < 6) return 'Minimum 6 caractères'; + return null; + } + + Future _submit() async { + if (widget.readOnly) return; + if (_isSubmitting) return; + if (_isEditMode && !_dirty) return; + if (!_formKey.currentState!.validate()) return; + + setState(() => _isSubmitting = true); + + try { + final normalizedNom = formatPersonNameCase(_nomController.text); + final normalizedPrenom = formatPersonNameCase(_prenomController.text); + final normalizedPhone = normalizePhone(_telephoneController.text); + final passwordProvided = _passwordController.text.trim().isNotEmpty; + + if (_isEditMode) { + if (widget.adminMode) { + final lockedNom = formatPersonNameCase(widget.initialUser!.nom ?? ''); + final lockedPrenom = + formatPersonNameCase(widget.initialUser!.prenom ?? ''); + await UserService.updateAdministrateur( + adminId: widget.initialUser!.id, + nom: _isLockedAdminIdentity ? lockedNom : normalizedNom, + prenom: _isLockedAdminIdentity ? lockedPrenom : normalizedPrenom, + email: normalizeEmailText(_emailController.text), + telephone: normalizedPhone.isEmpty + ? normalizePhone(widget.initialUser!.telephone ?? '') + : normalizedPhone, + password: passwordProvided ? _passwordController.text : null, + ); + } else { + final currentUser = widget.initialUser!; + final initialNom = formatPersonNameCase(currentUser.nom ?? ''); + final initialPrenom = formatPersonNameCase(currentUser.prenom ?? ''); + final initialEmail = normalizeEmailText(currentUser.email); + final initialPhone = normalizePhone(currentUser.telephone ?? ''); + + final onlyRelaisChanged = normalizedNom == initialNom && + normalizedPrenom == initialPrenom && + normalizeEmailText(_emailController.text) == 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: normalizeEmailText(_emailController.text), + telephone: + normalizedPhone.isEmpty ? initialPhone : normalizedPhone, + relaisId: _selectedRelaisId, + password: passwordProvided ? _passwordController.text : null, + ); + } + } + } else { + if (widget.adminMode) { + await UserService.createAdministrateur( + nom: normalizedNom, + prenom: normalizedPrenom, + email: normalizeEmailText(_emailController.text), + password: _passwordController.text, + telephone: normalizePhone(_telephoneController.text), + ); + } else { + await UserService.createGestionnaire( + nom: normalizedNom, + prenom: normalizedPrenom, + email: normalizeEmailText(_emailController.text), + password: _passwordController.text, + telephone: normalizePhone(_telephoneController.text), + relaisId: _selectedRelaisId, + ); + } + } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + _isEditMode + ? (widget.adminMode + ? 'Administrateur modifié avec succès.' + : 'Gestionnaire modifié avec succès.') + : (widget.adminMode + ? 'Administrateur créé avec succès.' + : 'Gestionnaire créé avec succès.'), + ), + ), + ); + Navigator.of(context).pop(true); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.toString().replaceFirst('Exception: ', '')), + backgroundColor: Colors.red.shade700, + ), + ); + } finally { + if (mounted) { + setState(() => _isSubmitting = false); + } + } + } + + Future _delete() async { + if (widget.readOnly) return; + if (!_canDeleteTarget) return; + if (!_isEditMode || _isSubmitting) return; + + final name = widget.initialUser!.fullName.isEmpty + ? widget.initialUser!.email + : widget.initialUser!.fullName; + final confirmed = await showSuppressionConfirmDialog( + context, + title: widget.adminMode + ? 'Supprimer l\'administrateur' + : 'Supprimer le gestionnaire', + people: [ + widget.adminMode + ? SuppressionPersonLine.administrateur(name) + : SuppressionPersonLine.gestionnaire(name), + ], + footnotes: const [ + 'Le compte sera définitivement supprimé.', + ], + ); + + if (!confirmed) return; + + setState(() => _isSubmitting = true); + try { + await UserService.deleteUser(widget.initialUser!.id); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + widget.adminMode + ? 'Administrateur supprimé.' + : 'Gestionnaire supprimé.', + ), + ), + ); + Navigator.of(context).pop(true); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.toString().replaceFirst('Exception: ', '')), + backgroundColor: Colors.red.shade700, + ), + ); + setState(() => _isSubmitting = false); + } + } + + String _headerTitle() { + if (!_isEditMode) { + return widget.adminMode + ? 'Créer un administrateur' + : 'Créer un gestionnaire'; + } + final prenom = (_prenomController.text.trim().isNotEmpty + ? _prenomController.text + : (widget.initialUser?.prenom ?? '')) + .trim(); + final nom = (_nomController.text.trim().isNotEmpty + ? _nomController.text + : (widget.initialUser?.nom ?? '')) + .trim(); + final full = '$prenom $nom'.trim(); + if (full.isNotEmpty) return full; + return widget.initialUser?.email ?? _targetRoleLabel; + } + + Widget _buildFooter() { + if (widget.readOnly) { + return Row( + children: [ + TextButton( + onPressed: + _isSubmitting ? null : () => Navigator.of(context).pop(false), + child: const Text('Fermer'), + ), + ], + ); + } + + if (!_isEditMode) { + return Row( + children: [ + TextButton( + onPressed: + _isSubmitting ? null : () => Navigator.of(context).pop(false), + child: const Text('Annuler'), + ), + const Spacer(), + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: _isSubmitting ? null : _submit, + child: _isSubmitting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text('Créer'), + ), + ], + ); + } + + return Row( + children: [ + if (_canDeleteTarget) + OutlinedButton( + onPressed: _isSubmitting ? null : _delete, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red.shade700, + ), + child: const Text('Supprimer'), + ), + if (_canDeleteTarget) const SizedBox(width: 8), + TextButton( + onPressed: + _isSubmitting ? null : () => Navigator.of(context).pop(false), + child: const Text('Fermer'), + ), + const Spacer(), + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: !_dirty || _isSubmitting ? null : _submit, + child: _isSubmitting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text(_dirty ? 'Sauvegarder' : 'Aucune modification'), + ), + ], + ); + } + + Widget _namedField({ + required String label, + required TextEditingController controller, + required String requiredLabel, + bool enabled = true, + }) { + return ValidationLabeledField( + label: label, + field: SizedBox( + height: ValidationFormMetrics.fieldHeight, + child: TextFormField( + controller: controller, + enabled: enabled, + textCapitalization: TextCapitalization.words, + textAlignVertical: TextAlignVertical.center, + inputFormatters: const [PersonNameInputFormatter()], + style: ValidationFormMetrics.fieldTextStyle, + decoration: ValidationFieldDecoration.input(), + validator: (!enabled || widget.readOnly) + ? null + : (v) => _required(v, requiredLabel), + ), + ), + ); + } + + Widget _passwordField() { + return ValidationLabeledField( + label: _isEditMode ? 'Nouveau mot de passe' : 'Mot de passe', + field: SizedBox( + height: ValidationFormMetrics.fieldHeight, + child: TextFormField( + controller: _passwordController, + enabled: _fieldsEnabled, + obscureText: _obscurePassword, + enableSuggestions: false, + autocorrect: false, + autofillHints: _isEditMode + ? const [] + : const [AutofillHints.newPassword], + textAlignVertical: TextAlignVertical.center, + style: ValidationFormMetrics.fieldTextStyle, + decoration: ValidationFieldDecoration.input().copyWith( + suffixIcon: widget.readOnly + ? null + : ExcludeFocus( + child: IconButton( + focusNode: _passwordToggleFocusNode, + onPressed: !_fieldsEnabled + ? null + : () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + icon: Icon( + _obscurePassword + ? Icons.visibility_off + : Icons.visibility, + ), + ), + ), + errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11), + errorMaxLines: 2, + ), + validator: widget.readOnly ? null : _validatePassword, + ), + ), + ); + } + + Widget _relaisField() { + final selectedValue = _selectedRelaisId != null && + _relais.any((relais) => relais.id == _selectedRelaisId) + ? _selectedRelaisId + : null; + + return ValidationLabeledField( + label: 'Relais principal', + field: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: ValidationFormMetrics.fieldHeight, + child: DropdownButtonFormField( + isExpanded: true, + value: selectedValue, + decoration: ValidationFieldDecoration.input(), + style: ValidationFormMetrics.fieldTextStyle, + items: [ + const DropdownMenuItem( + value: null, + child: Text('Aucun relais'), + ), + ..._relais.map( + (relais) => DropdownMenuItem( + value: relais.id, + child: Text(relais.nom), + ), + ), + ], + onChanged: (_isLoadingRelais || !_fieldsEnabled) + ? null + : (value) { + setState(() { + _selectedRelaisId = value; + if (_isEditMode) { + _dirty = _computeDirty(); + } + }); + }, + ), + ), + if (_isLoadingRelais) ...[ + const SizedBox(height: 8), + const LinearProgressIndicator(minHeight: 2), + ], + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final nameEnabled = + _fieldsEnabled && !_isLockedAdminIdentity; + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _modalWidth), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 4, 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2, right: 10), + child: Icon( + _targetRoleIcon, + size: 22, + color: ValidationModalTheme.primaryActionBackground, + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _headerTitle(), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + if (_isEditMode) ...[ + const SizedBox(height: 4), + Text( + _targetRoleLabel, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade700, + ), + ), + ], + ], + ), + ), + IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 40, + minHeight: 40, + ), + icon: const Icon(Icons.close), + onPressed: _isSubmitting + ? null + : () => Navigator.of(context).pop(false), + tooltip: 'Fermer', + ), + ], + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _namedField( + label: 'Prénom', + controller: _prenomController, + requiredLabel: 'Prénom', + enabled: nameEnabled, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _namedField( + label: 'Nom', + controller: _nomController, + requiredLabel: 'Nom', + enabled: nameEnabled, + ), + ), + ], + ), + SizedBox(height: ValidationFormMetrics.rowGapBelow), + ValidationLabeledField( + label: 'Email', + field: IgnorePointer( + ignoring: !_fieldsEnabled, + child: ValidationEmailField( + controller: _emailController, + hintText: 'ex. nom@domaine.fr', + ), + ), + ), + SizedBox(height: ValidationFormMetrics.rowGapBelow), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _passwordField()), + const SizedBox(width: 12), + Expanded( + child: ValidationLabeledField( + label: 'Téléphone', + field: IgnorePointer( + ignoring: !_fieldsEnabled, + child: ValidationPhoneField( + controller: _telephoneController, + hintText: '06 12 34 56 78', + allowEmpty: _isEditMode, + ), + ), + ), + ), + ], + ), + if (widget.withRelais) ...[ + SizedBox(height: ValidationFormMetrics.rowGapBelow), + _relaisField(), + ], + ], + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 16, 14), + child: _buildFooter(), + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/dashboard/user_management_panel.dart b/frontend/lib/widgets/dashboard/user_management_panel.dart index 800435c..9a87d93 100644 --- a/frontend/lib/widgets/dashboard/user_management_panel.dart +++ b/frontend/lib/widgets/dashboard/user_management_panel.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart'; +import 'package:p_tits_pas/widgets/dashboard/staff_user_form_modal.dart'; import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart'; import 'package:p_tits_pas/widgets/dashboard/am_dossier_create_modal.dart'; import 'package:p_tits_pas/widgets/dashboard/assistante_maternelle_management_widget.dart'; @@ -385,7 +385,7 @@ class _UserManagementPanelState extends State { context: context, barrierDismissible: false, builder: (dialogContext) { - return const AdminUserFormDialog(); + return const StaffUserFormModal(); }, ); @@ -403,7 +403,7 @@ class _UserManagementPanelState extends State { context: context, barrierDismissible: false, builder: (dialogContext) { - return const AdminUserFormDialog( + return const StaffUserFormModal( adminMode: true, withRelais: false, );