merge: intégrer feature/96-creation-admin-modale dans develop

Fusionne le ticket #96 avec résolution des conflits sur la modale partagée, les droits admin/super admin et l’harmonisation visuelle des listes utilisateurs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
MARTIN Julien 2026-02-24 22:36:58 +01:00
commit 0579fda553
9 changed files with 473 additions and 82 deletions

View File

@ -1,10 +1,4 @@
import { PartialType, ApiProperty } from "@nestjs/swagger"; import { PartialType } from "@nestjs/swagger";
import { CreateUserDto } from "./create_user.dto"; import { CreateGestionnaireDto } from "./create_gestionnaire.dto";
import { IsOptional, IsUUID } from "class-validator";
export class UpdateGestionnaireDto extends PartialType(CreateUserDto) { export class UpdateGestionnaireDto extends PartialType(CreateGestionnaireDto) {}
@ApiProperty({ required: false, description: 'ID du relais de rattachement' })
@IsOptional()
@IsUUID()
relaisId?: string;
}

View File

@ -155,6 +155,16 @@ export class UserService {
async updateUser(id: string, dto: UpdateUserDto, currentUser: Users): Promise<Users> { async updateUser(id: string, dto: UpdateUserDto, currentUser: Users): Promise<Users> {
const user = await this.findOne(id); const user = await this.findOne(id);
// Le super administrateur conserve une identité figée.
if (
user.role === RoleType.SUPER_ADMIN &&
(dto.nom !== undefined || dto.prenom !== undefined)
) {
throw new ForbiddenException(
'Le nom et le prénom du super administrateur ne peuvent pas être modifiés',
);
}
// Interdire changement de rôle si pas super admin // Interdire changement de rôle si pas super admin
if (dto.role && currentUser.role !== RoleType.SUPER_ADMIN) { if (dto.role && currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Accès réservé aux super admins'); throw new ForbiddenException('Accès réservé aux super admins');
@ -256,6 +266,12 @@ export class UserService {
if (currentUser.role !== RoleType.SUPER_ADMIN) { if (currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Accès réservé aux super admins'); throw new ForbiddenException('Accès réservé aux super admins');
} }
const user = await this.findOne(id);
if (user.role === RoleType.SUPER_ADMIN) {
throw new ForbiddenException(
'Le super administrateur ne peut pas être supprimé',
);
}
const result = await this.usersRepository.delete(id); const result = await this.usersRepository.delete(id);
if (result.affected === 0) { if (result.affected === 0) {
throw new NotFoundException('Utilisateur introuvable'); throw new NotFoundException('Utilisateur introuvable');

View File

@ -1,29 +1,37 @@
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;
final bool readOnly;
const GestionnaireCreateDialog({ const AdminUserFormDialog({
super.key, super.key,
this.initialUser, this.initialUser,
this.withRelais = true,
this.adminMode = false,
this.readOnly = 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;
@ -31,6 +39,50 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
List<RelaisModel> _relais = []; List<RelaisModel> _relais = [];
String? _selectedRelaisId; String? _selectedRelaisId;
bool get _isEditMode => widget.initialUser != null; bool get _isEditMode => widget.initialUser != null;
bool get _isSuperAdminTarget =>
widget.initialUser?.role.toLowerCase() == 'super_admin';
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 @override
void initState() { void initState() {
@ -40,7 +92,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 +101,11 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
? null ? null
: initialRelaisId; : initialRelaisId;
} }
_loadRelais(); if (widget.withRelais) {
_loadRelais();
} else {
_isLoadingRelais = false;
}
} }
@override @override
@ -59,6 +115,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,7 +179,68 @@ 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 (widget.readOnly) return;
if (_isSubmitting) return; if (_isSubmitting) return;
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
@ -131,35 +249,87 @@ 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, final lockedNom = _toTitleCase(widget.initialUser!.nom ?? '');
nom: _nomController.text.trim(), final lockedPrenom = _toTitleCase(widget.initialUser!.prenom ?? '');
prenom: _prenomController.text.trim(), await UserService.updateAdministrateur(
email: _emailController.text.trim(), adminId: widget.initialUser!.id,
telephone: _telephoneController.text.trim(), nom: _isLockedAdminIdentity ? lockedNom : normalizedNom,
relaisId: _selectedRelaisId, prenom: _isLockedAdminIdentity ? lockedPrenom : normalizedPrenom,
password: _passwordController.text.trim().isEmpty email: _emailController.text.trim(),
? null telephone: normalizedPhone.isEmpty
: _passwordController.text, ? _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 { } else {
await UserService.createGestionnaire( if (widget.adminMode) {
nom: _nomController.text.trim(), await UserService.createAdministrateur(
prenom: _prenomController.text.trim(), nom: normalizedNom,
email: _emailController.text.trim(), prenom: normalizedPrenom,
password: _passwordController.text, email: _emailController.text.trim(),
telephone: _telephoneController.text.trim(), password: _passwordController.text,
relaisId: _selectedRelaisId, 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; 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.'),
), ),
), ),
); );
@ -183,6 +353,8 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
} }
Future<void> _delete() async { Future<void> _delete() async {
if (widget.readOnly) return;
if (_isSuperAdminTarget) return;
if (!_isEditMode || _isSubmitting) return; if (!_isEditMode || _isSubmitting) return;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
@ -239,14 +411,26 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
return AlertDialog( return AlertDialog(
title: Row( title: Row(
children: [ children: [
CircleAvatar(
radius: 16,
backgroundColor: const Color(0xFFEDE5FA),
child: Icon(
_targetRoleIcon,
size: 20,
color: const Color(0xFF6B3FA0),
),
),
const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
_isEditMode _isEditMode
? 'Modifier un gestionnaire' ? (widget.readOnly
: 'Créer un gestionnaire', ? 'Consulter un "$_targetRoleLabel"'
: 'Modifier un "$_targetRoleLabel"')
: 'Créer un "$_targetRoleLabel"',
), ),
), ),
if (_isEditMode) if (_isEditMode && !widget.readOnly)
IconButton( IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
tooltip: 'Fermer', tooltip: 'Fermer',
@ -266,9 +450,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,20 +465,28 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
Expanded(child: _buildTelephoneField()), Expanded(child: _buildTelephoneField()),
], ],
), ),
const SizedBox(height: 12), if (widget.withRelais) ...[
_buildRelaisField(), const SizedBox(height: 12),
_buildRelaisField(),
],
], ],
), ),
), ),
), ),
), ),
actions: [ actions: [
if (_isEditMode) ...[ if (widget.readOnly) ...[
OutlinedButton( FilledButton(
onPressed: _isSubmitting ? null : _delete, onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(false),
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700), child: const Text('Fermer'),
child: const Text('Supprimer'),
), ),
] else if (_isEditMode) ...[
if (!_isSuperAdminTarget)
OutlinedButton(
onPressed: _isSubmitting ? null : _delete,
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
child: const Text('Supprimer'),
),
FilledButton.icon( FilledButton.icon(
onPressed: _isSubmitting ? null : _submit, onPressed: _isSubmitting ? null : _submit,
icon: _isSubmitting icon: _isSubmitting
@ -331,42 +523,50 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
Widget _buildNomField() { Widget _buildNomField() {
return TextFormField( return TextFormField(
controller: _nomController, controller: _nomController,
readOnly: widget.readOnly || _isLockedAdminIdentity,
textCapitalization: TextCapitalization.words, textCapitalization: TextCapitalization.words,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Nom', labelText: 'Nom',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
validator: (v) => _required(v, 'Nom'), validator: (widget.readOnly || _isLockedAdminIdentity)
? null
: (v) => _required(v, 'Nom'),
); );
} }
Widget _buildPrenomField() { Widget _buildPrenomField() {
return TextFormField( return TextFormField(
controller: _prenomController, controller: _prenomController,
readOnly: widget.readOnly || _isLockedAdminIdentity,
textCapitalization: TextCapitalization.words, textCapitalization: TextCapitalization.words,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Prénom', labelText: 'Prénom',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
validator: (v) => _required(v, 'Prénom'), validator: (widget.readOnly || _isLockedAdminIdentity)
? null
: (v) => _required(v, 'Prénom'),
); );
} }
Widget _buildEmailField() { Widget _buildEmailField() {
return TextFormField( return TextFormField(
controller: _emailController, controller: _emailController,
readOnly: widget.readOnly,
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Email', labelText: 'Email',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
validator: _validateEmail, validator: widget.readOnly ? null : _validateEmail,
); );
} }
Widget _buildPasswordField() { Widget _buildPasswordField() {
return TextFormField( return TextFormField(
controller: _passwordController, controller: _passwordController,
readOnly: widget.readOnly,
obscureText: _obscurePassword, obscureText: _obscurePassword,
enableSuggestions: false, enableSuggestions: false,
autocorrect: false, autocorrect: false,
@ -378,30 +578,43 @@ 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: widget.readOnly
onPressed: () { ? null
setState(() { : ExcludeFocus(
_obscurePassword = !_obscurePassword; child: IconButton(
}); focusNode: _passwordToggleFocusNode,
}, onPressed: () {
icon: Icon( setState(() {
_obscurePassword ? Icons.visibility_off : Icons.visibility, _obscurePassword = !_obscurePassword;
), });
), },
icon: Icon(
_obscurePassword ? Icons.visibility_off : Icons.visibility,
),
),
),
), ),
validator: _validatePassword, validator: widget.readOnly ? null : _validatePassword,
); );
} }
Widget _buildTelephoneField() { Widget _buildTelephoneField() {
return TextFormField( return TextFormField(
controller: _telephoneController, controller: _telephoneController,
readOnly: widget.readOnly,
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
inputFormatters: widget.readOnly
? null
: [
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: widget.readOnly ? null : _validatePhone,
); );
} }
@ -433,7 +646,7 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
), ),
), ),
], ],
onChanged: _isLoadingRelais onChanged: (_isLoadingRelais || widget.readOnly)
? null ? null
: (value) { : (value) {
setState(() { setState(() {
@ -448,4 +661,28 @@ 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(
@ -232,7 +267,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 {
@ -240,10 +275,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();
} }
@ -270,6 +308,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,6 +1,7 @@
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/admin_create.dart'; import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
import 'package:p_tits_pas/services/auth_service.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';
@ -21,10 +22,12 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
bool _isLoading = false; bool _isLoading = false;
String? _error; String? _error;
List<AppUser> _admins = []; List<AppUser> _admins = [];
String? _currentUserRole;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadCurrentUserRole();
_loadAdmins(); _loadAdmins();
} }
@ -52,15 +55,44 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
} }
} }
Future<void> _loadCurrentUserRole() async {
final cached = await AuthService.getCurrentUser();
if (!mounted) return;
if (cached != null) {
setState(() {
_currentUserRole = cached.role.toLowerCase();
});
return;
}
final refreshed = await AuthService.refreshCurrentUser();
if (!mounted || refreshed == null) return;
setState(() {
_currentUserRole = refreshed.role.toLowerCase();
});
}
bool _isSuperAdmin(AppUser user) => user.role.toLowerCase() == 'super_admin';
bool _canEditAdmin(AppUser target) {
if (!_isSuperAdmin(target)) return true;
return _currentUserRole == 'super_admin';
}
Future<void> _openAdminEditDialog(AppUser user) async { Future<void> _openAdminEditDialog(AppUser user) async {
final canEdit = _canEditAdmin(user);
final changed = await showDialog<bool>( final changed = await showDialog<bool>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (dialogContext) { builder: (dialogContext) {
return AdminCreateDialog(initialUser: user); return AdminUserFormDialog(
initialUser: user,
adminMode: true,
withRelais: false,
readOnly: !canEdit,
);
}, },
); );
if (changed == true) { if (changed == true && canEdit) {
await _loadAdmins(); await _loadAdmins();
} }
} }
@ -82,17 +114,34 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
itemCount: filteredAdmins.length, itemCount: filteredAdmins.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final user = filteredAdmins[index]; final user = filteredAdmins[index];
final isSuperAdmin = _isSuperAdmin(user);
final canEdit = _canEditAdmin(user);
return AdminUserCard( return AdminUserCard(
title: user.fullName, title: user.fullName,
fallbackIcon: isSuperAdmin
? Icons.verified_user_outlined
: Icons.manage_accounts_outlined,
subtitleLines: [ subtitleLines: [
user.email, user.email,
'Rôle : ${user.role}', 'Téléphone : ${user.telephone?.trim().isNotEmpty == true ? user.telephone : 'Non renseigné'}',
], ],
avatarUrl: user.photoUrl, avatarUrl: user.photoUrl,
borderColor: isSuperAdmin
? const Color(0xFF8E6AC8)
: Colors.grey.shade300,
backgroundColor: isSuperAdmin
? const Color(0xFFF4EEFF)
: Colors.white,
titleColor: isSuperAdmin ? const Color(0xFF5D2F99) : null,
infoColor: isSuperAdmin
? const Color(0xFF6D4EA1)
: Colors.black54,
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.edit), icon: Icon(
tooltip: 'Modifier', canEdit ? Icons.edit_outlined : Icons.visibility_outlined,
),
tooltip: canEdit ? 'Modifier' : 'Consulter',
onPressed: () { onPressed: () {
_openAdminEditDialog(user); _openAdminEditDialog(user);
}, },

View File

@ -6,6 +6,10 @@ class AdminUserCard extends StatefulWidget {
final String? avatarUrl; final String? avatarUrl;
final IconData fallbackIcon; final IconData fallbackIcon;
final List<Widget> actions; final List<Widget> actions;
final Color? borderColor;
final Color? backgroundColor;
final Color? titleColor;
final Color? infoColor;
const AdminUserCard({ const AdminUserCard({
super.key, super.key,
@ -14,6 +18,10 @@ class AdminUserCard extends StatefulWidget {
this.avatarUrl, this.avatarUrl,
this.fallbackIcon = Icons.person, this.fallbackIcon = Icons.person,
this.actions = const [], this.actions = const [],
this.borderColor,
this.backgroundColor,
this.titleColor,
this.infoColor,
}); });
@override @override
@ -43,9 +51,10 @@ class _AdminUserCardState extends State<AdminUserCard> {
child: Card( child: Card(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
elevation: 0, elevation: 0,
color: widget.backgroundColor,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
side: BorderSide(color: Colors.grey.shade300), side: BorderSide(color: widget.borderColor ?? Colors.grey.shade300),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
@ -76,7 +85,7 @@ class _AdminUserCardState extends State<AdminUserCard> {
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 14, fontSize: 14,
), ).copyWith(color: widget.titleColor),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@ -88,7 +97,7 @@ class _AdminUserCardState extends State<AdminUserCard> {
style: const TextStyle( style: const TextStyle(
color: Colors.black54, color: Colors.black54,
fontSize: 12, fontSize: 12,
), ).copyWith(color: widget.infoColor),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),

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) {
@ -86,6 +86,7 @@ class _GestionnaireManagementWidgetState
final user = filteredGestionnaires[index]; final user = filteredGestionnaires[index];
return AdminUserCard( return AdminUserCard(
title: user.fullName, title: user.fullName,
fallbackIcon: Icons.assignment_ind_outlined,
avatarUrl: user.photoUrl, avatarUrl: user.photoUrl,
subtitleLines: [ subtitleLines: [
user.email, user.email,

View File

@ -75,6 +75,7 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
final parent = filteredParents[index]; final parent = filteredParents[index];
return AdminUserCard( return AdminUserCard(
title: parent.user.fullName, title: parent.user.fullName,
fallbackIcon: Icons.supervisor_account_outlined,
avatarUrl: parent.user.photoUrl, avatarUrl: parent.user.photoUrl,
subtitleLines: [ subtitleLines: [
parent.user.email, parent.user.email,

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/screens/administrateurs/creation/admin_create.dart';
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart'; import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
@ -184,7 +183,7 @@ class _AdminUserManagementPanelState extends State<AdminUserManagementPanel> {
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (dialogContext) { builder: (dialogContext) {
return const GestionnaireCreateDialog(); return const AdminUserFormDialog();
}, },
); );
@ -202,7 +201,10 @@ class _AdminUserManagementPanelState extends State<AdminUserManagementPanel> {
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (dialogContext) { builder: (dialogContext) {
return const AdminCreateDialog(); return const AdminUserFormDialog(
adminMode: true,
withRelais: false,
);
}, },
); );
@ -219,7 +221,7 @@ class _AdminUserManagementPanelState extends State<AdminUserManagementPanel> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text( content: Text(
'La création est disponible uniquement pour les gestionnaires et les administrateurs.', 'La création est disponible pour les gestionnaires et administrateurs.',
), ),
), ),
); );