feat(#96): unifier la modale admin et fiabiliser la saisie téléphone

Branche le flux création/édition des administrateurs sur la modale partagée et renforce le champ téléphone avec formatage/validation pour garantir un payload API cohérent.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
MARTIN Julien 2026-02-24 11:36:00 +01:00
parent 222d7c702f
commit 34e2a06751
5 changed files with 273 additions and 65 deletions

View File

@ -1,23 +1,27 @@
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();
@ -49,7 +53,11 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
? null ? null
: initialRelaisId; : initialRelaisId;
} }
if (widget.withRelais) {
_loadRelais(); _loadRelais();
} else {
_isLoadingRelais = false;
}
} }
@override @override
@ -122,6 +130,23 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
return null; return null;
} }
String? _validatePhone(String? value) {
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'), '');
}
Future<void> _submit() async { Future<void> _submit() async {
if (_isSubmitting) return; if (_isSubmitting) return;
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
@ -132,34 +157,61 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
try { try {
if (_isEditMode) { if (_isEditMode) {
if (widget.adminMode) {
await UserService.updateAdministrateur(
adminId: widget.initialUser!.id,
nom: _nomController.text.trim(),
prenom: _prenomController.text.trim(),
email: _emailController.text.trim(),
telephone: _normalizePhone(_telephoneController.text),
password: _passwordController.text.trim().isEmpty
? null
: _passwordController.text,
);
} else {
await UserService.updateGestionnaire( await UserService.updateGestionnaire(
gestionnaireId: widget.initialUser!.id, gestionnaireId: widget.initialUser!.id,
nom: _nomController.text.trim(), nom: _nomController.text.trim(),
prenom: _prenomController.text.trim(), prenom: _prenomController.text.trim(),
email: _emailController.text.trim(), email: _emailController.text.trim(),
telephone: _telephoneController.text.trim(), telephone: _normalizePhone(_telephoneController.text),
relaisId: _selectedRelaisId, relaisId: _selectedRelaisId,
password: _passwordController.text.trim().isEmpty password: _passwordController.text.trim().isEmpty
? null ? null
: _passwordController.text, : _passwordController.text,
); );
}
} else {
if (widget.adminMode) {
await UserService.createAdministrateur(
nom: _nomController.text.trim(),
prenom: _prenomController.text.trim(),
email: _emailController.text.trim(),
password: _passwordController.text,
telephone: _normalizePhone(_telephoneController.text),
);
} else { } else {
await UserService.createGestionnaire( await UserService.createGestionnaire(
nom: _nomController.text.trim(), nom: _nomController.text.trim(),
prenom: _prenomController.text.trim(), prenom: _prenomController.text.trim(),
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 +294,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 +322,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 +337,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 +436,10 @@ 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: Focus(
skipTraversal: true,
canRequestFocus: false,
child: IconButton(
onPressed: () { onPressed: () {
setState(() { setState(() {
_obscurePassword = !_obscurePassword; _obscurePassword = !_obscurePassword;
@ -389,6 +450,7 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
), ),
), ),
), ),
),
validator: _validatePassword, validator: _validatePassword,
); );
} }
@ -397,11 +459,16 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
return TextFormField( return TextFormField(
controller: _telephoneController, controller: _telephoneController,
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
inputFormatters: const [
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 +516,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(
@ -194,6 +229,47 @@ 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,
required String telephone,
String? password,
}) async {
final body = <String, dynamic>{
'nom': nom,
'prenom': prenom,
'email': email,
'telephone': telephone,
};
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.',
),
),
);
} }
} }