feat(#96): finaliser la modale admin/gestionnaire et les règles d’édition

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 <cursoragent@cursor.com>
This commit is contained in:
MARTIN Julien 2026-02-24 17:25:15 +01:00
parent 222d7c702f
commit d8572e7fd6
6 changed files with 352 additions and 70 deletions

View File

@ -1,4 +1,4 @@
import { PartialType } from "@nestjs/swagger"; import { PartialType } from "@nestjs/swagger";
import { CreateUserDto } from "./create_user.dto"; import { CreateGestionnaireDto } from "./create_gestionnaire.dto";
export class UpdateGestionnaireDto extends PartialType(CreateUserDto) {} export class UpdateGestionnaireDto extends PartialType(CreateGestionnaireDto) {}

View File

@ -1,29 +1,35 @@
import 'package:flutter/material.dart'; 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/relais_model.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/relais_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/services/user_service.dart';
class GestionnaireCreateDialog extends StatefulWidget { class AdminUserFormDialog extends StatefulWidget {
final AppUser? initialUser; final AppUser? initialUser;
final bool withRelais;
final bool adminMode;
const GestionnaireCreateDialog({ const AdminUserFormDialog({
super.key, super.key,
this.initialUser, this.initialUser,
this.withRelais = true,
this.adminMode = false,
}); });
@override @override
State<GestionnaireCreateDialog> createState() => State<AdminUserFormDialog> createState() => _AdminUserFormDialogState();
_GestionnaireCreateDialogState();
} }
class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> { class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _nomController = TextEditingController(); final _nomController = TextEditingController();
final _prenomController = TextEditingController(); final _prenomController = TextEditingController();
final _emailController = TextEditingController(); final _emailController = TextEditingController();
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
final _telephoneController = TextEditingController(); final _telephoneController = TextEditingController();
final _passwordToggleFocusNode =
FocusNode(skipTraversal: true, canRequestFocus: false);
bool _isSubmitting = false; bool _isSubmitting = false;
bool _obscurePassword = true; bool _obscurePassword = true;
@ -40,7 +46,7 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
_nomController.text = user.nom ?? ''; _nomController.text = user.nom ?? '';
_prenomController.text = user.prenom ?? ''; _prenomController.text = user.prenom ?? '';
_emailController.text = user.email; _emailController.text = user.email;
_telephoneController.text = user.telephone ?? ''; _telephoneController.text = _formatPhoneForDisplay(user.telephone ?? '');
// En édition, on ne préremplit jamais le mot de passe. // En édition, on ne préremplit jamais le mot de passe.
_passwordController.clear(); _passwordController.clear();
final initialRelaisId = user.relaisId?.trim(); final initialRelaisId = user.relaisId?.trim();
@ -49,7 +55,11 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
? null ? null
: initialRelaisId; : initialRelaisId;
} }
if (widget.withRelais) {
_loadRelais(); _loadRelais();
} else {
_isLoadingRelais = false;
}
} }
@override @override
@ -59,6 +69,7 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
_emailController.dispose(); _emailController.dispose();
_passwordController.dispose(); _passwordController.dispose();
_telephoneController.dispose(); _telephoneController.dispose();
_passwordToggleFocusNode.dispose();
super.dispose(); super.dispose();
} }
@ -122,6 +133,66 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
return null; 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 = <String>{"-", "'", ""};
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<void> _submit() async { Future<void> _submit() async {
if (_isSubmitting) return; if (_isSubmitting) return;
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
@ -131,35 +202,85 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
}); });
try { 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 (_isEditMode) {
await UserService.updateGestionnaire( if (widget.adminMode) {
gestionnaireId: widget.initialUser!.id, await UserService.updateAdministrateur(
nom: _nomController.text.trim(), adminId: widget.initialUser!.id,
prenom: _prenomController.text.trim(), nom: normalizedNom,
prenom: normalizedPrenom,
email: _emailController.text.trim(), email: _emailController.text.trim(),
telephone: _telephoneController.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, relaisId: _selectedRelaisId,
password: _passwordController.text.trim().isEmpty );
? null } else {
: _passwordController.text, 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 { } else {
await UserService.createGestionnaire( await UserService.createGestionnaire(
nom: _nomController.text.trim(), nom: normalizedNom,
prenom: _prenomController.text.trim(), prenom: normalizedPrenom,
email: _emailController.text.trim(), email: _emailController.text.trim(),
password: _passwordController.text, password: _passwordController.text,
telephone: _telephoneController.text.trim(), telephone: _normalizePhone(_telephoneController.text),
relaisId: _selectedRelaisId, relaisId: _selectedRelaisId,
); );
} }
}
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
_isEditMode _isEditMode
? 'Gestionnaire modifié avec succès.' ? (widget.adminMode
: 'Gestionnaire créé avec succès.', ? '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<GestionnaireCreateDialog> {
Expanded( Expanded(
child: Text( child: Text(
_isEditMode _isEditMode
? 'Modifier un gestionnaire' ? (widget.adminMode
: 'Créer un gestionnaire', ? 'Modifier un administrateur'
: 'Modifier un gestionnaire')
: (widget.adminMode
? 'Créer un administrateur'
: 'Créer un gestionnaire'),
), ),
), ),
if (_isEditMode) if (_isEditMode)
@ -266,9 +391,9 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
children: [ children: [
Row( Row(
children: [ children: [
Expanded(child: _buildNomField()),
const SizedBox(width: 12),
Expanded(child: _buildPrenomField()), Expanded(child: _buildPrenomField()),
const SizedBox(width: 12),
Expanded(child: _buildNomField()),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -281,9 +406,11 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
Expanded(child: _buildTelephoneField()), Expanded(child: _buildTelephoneField()),
], ],
), ),
if (widget.withRelais) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
_buildRelaisField(), _buildRelaisField(),
], ],
],
), ),
), ),
), ),
@ -378,7 +505,9 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
? 'Nouveau mot de passe' ? 'Nouveau mot de passe'
: 'Mot de passe', : 'Mot de passe',
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
suffixIcon: IconButton( suffixIcon: ExcludeFocus(
child: IconButton(
focusNode: _passwordToggleFocusNode,
onPressed: () { onPressed: () {
setState(() { setState(() {
_obscurePassword = !_obscurePassword; _obscurePassword = !_obscurePassword;
@ -389,6 +518,7 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
), ),
), ),
), ),
),
validator: _validatePassword, validator: _validatePassword,
); );
} }
@ -397,11 +527,16 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
return TextFormField( return TextFormField(
controller: _telephoneController, controller: _telephoneController,
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
_FrenchPhoneNumberFormatter(),
],
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Téléphone', labelText: 'Téléphone (ex: 06 12 34 56 78)',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
validator: (v) => _required(v, 'Téléphone'), validator: _validatePhone,
); );
} }
@ -449,3 +584,27 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
); );
} }
} }
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),
);
}
}

View File

@ -75,6 +75,41 @@ class UserService {
return AppUser.fromJson(data); return AppUser.fromJson(data);
} }
static Future<AppUser> 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(<String, dynamic>{
'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<String, dynamic>) {
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<String, dynamic>;
return AppUser.fromJson(data);
}
// Récupérer la liste des parents // Récupérer la liste des parents
static Future<List<ParentModel>> getParents() async { static Future<List<ParentModel>> getParents() async {
final response = await http.get( final response = await http.get(
@ -156,7 +191,7 @@ class UserService {
required String nom, required String nom,
required String prenom, required String prenom,
required String email, required String email,
required String telephone, String? telephone,
required String? relaisId, required String? relaisId,
String? password, String? password,
}) async { }) async {
@ -164,10 +199,13 @@ class UserService {
'nom': nom, 'nom': nom,
'prenom': prenom, 'prenom': prenom,
'email': email, 'email': email,
'telephone': telephone,
'relaisId': relaisId, 'relaisId': relaisId,
}; };
if (telephone != null && telephone.trim().isNotEmpty) {
body['telephone'] = telephone.trim();
}
if (password != null && password.trim().isNotEmpty) { if (password != null && password.trim().isNotEmpty) {
body['password'] = password.trim(); body['password'] = password.trim();
} }
@ -194,6 +232,50 @@ class UserService {
return AppUser.fromJson(data); return AppUser.fromJson(data);
} }
static Future<AppUser> updateAdministrateur({
required String adminId,
required String nom,
required String prenom,
required String email,
String? telephone,
String? password,
}) async {
final body = <String, dynamic>{
'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<String, dynamic>) {
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<String, dynamic>;
return AppUser.fromJson(data);
}
static Future<void> deleteUser(String userId) async { static Future<void> deleteUser(String userId) async {
final response = await http.delete( final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'), Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'),

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.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/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_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'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
@ -51,6 +52,23 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
} }
} }
Future<void> _openAdminEditDialog(AppUser user) async {
final changed = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return AdminUserFormDialog(
initialUser: user,
adminMode: true,
withRelais: false,
);
},
);
if (changed == true) {
await _loadAdmins();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase(); final query = widget.searchQuery.toLowerCase();
@ -80,7 +98,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
icon: const Icon(Icons.edit), icon: const Icon(Icons.edit),
tooltip: 'Modifier', tooltip: 'Modifier',
onPressed: () { onPressed: () {
// TODO: Modifier admin _openAdminEditDialog(user);
}, },
), ),
], ],

View File

@ -59,7 +59,7 @@ class _GestionnaireManagementWidgetState
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (dialogContext) { builder: (dialogContext) {
return GestionnaireCreateDialog(initialUser: user); return AdminUserFormDialog(initialUser: user);
}, },
); );
if (changed == true) { if (changed == true) {

View File

@ -17,6 +17,7 @@ class AdminUserManagementPanel extends StatefulWidget {
class _AdminUserManagementPanelState extends State<AdminUserManagementPanel> { class _AdminUserManagementPanelState extends State<AdminUserManagementPanel> {
int _subIndex = 0; int _subIndex = 0;
int _gestionnaireRefreshTick = 0; int _gestionnaireRefreshTick = 0;
int _adminRefreshTick = 0;
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final TextEditingController _amCapacityController = TextEditingController(); final TextEditingController _amCapacityController = TextEditingController();
String? _parentStatus; String? _parentStatus;
@ -150,6 +151,7 @@ class _AdminUserManagementPanelState extends State<AdminUserManagementPanel> {
); );
case 3: case 3:
return AdminManagementWidget( return AdminManagementWidget(
key: ValueKey('admins-$_adminRefreshTick'),
searchQuery: _searchController.text, searchQuery: _searchController.text,
); );
default: default:
@ -176,23 +178,12 @@ class _AdminUserManagementPanelState extends State<AdminUserManagementPanel> {
} }
Future<void> _handleAddPressed() async { Future<void> _handleAddPressed() async {
if (_subIndex != 0) { if (_subIndex == 0) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'La création est disponible uniquement pour les gestionnaires.',
),
),
);
return;
}
final created = await showDialog<bool>( final created = await showDialog<bool>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (dialogContext) { builder: (dialogContext) {
return const GestionnaireCreateDialog(); return const AdminUserFormDialog();
}, },
); );
@ -202,5 +193,37 @@ class _AdminUserManagementPanelState extends State<AdminUserManagementPanel> {
_gestionnaireRefreshTick++; _gestionnaireRefreshTick++;
}); });
} }
return;
}
if (_subIndex == 3) {
final created = await showDialog<bool>(
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;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'La création est disponible pour les gestionnaires et administrateurs.',
),
),
);
} }
} }