merge: squash develop into master

Intègre en un seul commit les évolutions récentes de develop vers master, incluant la modale admin/gestionnaire, les protections super admin, les ajustements API associés et la mise à jour documentaire des tickets/spec.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
MARTIN Julien 2026-02-24 22:58:40 +01:00
parent 33cc7a9191
commit 119edbcfb4
14 changed files with 990 additions and 110 deletions

View File

@ -36,10 +36,10 @@ export class CreateUserDto {
@MaxLength(100) @MaxLength(100)
nom: string; nom: string;
@ApiProperty({ enum: GenreType, required: false, default: GenreType.AUTRE }) @ApiProperty({ enum: GenreType, required: false })
@IsOptional() @IsOptional()
@IsEnum(GenreType) @IsEnum(GenreType)
genre?: GenreType = GenreType.AUTRE; genre?: GenreType;
@ApiProperty({ enum: RoleType }) @ApiProperty({ enum: RoleType })
@IsEnum(RoleType) @IsEnum(RoleType)
@ -86,7 +86,7 @@ export class CreateUserDto {
@ApiProperty({ default: false }) @ApiProperty({ default: false })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
consentement_photo?: boolean = false; consentement_photo?: boolean;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@ -96,7 +96,7 @@ export class CreateUserDto {
@ApiProperty({ default: false }) @ApiProperty({ default: false })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
changement_mdp_obligatoire?: boolean = false; changement_mdp_obligatoire?: boolean;
@ApiProperty({ example: true }) @ApiProperty({ example: true })
@IsBoolean() @IsBoolean()

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

@ -55,9 +55,9 @@ export class UserController {
return this.userService.findOne(id); return this.userService.findOne(id);
} }
// Modifier un utilisateur (réservé super_admin) // Modifier un utilisateur (réservé super_admin et admin)
@Patch(':id') @Patch(':id')
@Roles(RoleType.SUPER_ADMIN) @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Mettre à jour un utilisateur' }) @ApiOperation({ summary: 'Mettre à jour un utilisateur' })
@ApiParam({ name: 'id', description: "UUID de l'utilisateur" }) @ApiParam({ name: 'id', description: "UUID de l'utilisateur" })
updateUser( updateUser(

View File

@ -155,11 +155,26 @@ 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');
} }
// Un admin ne peut pas modifier un super admin
if (currentUser.role === RoleType.ADMINISTRATEUR && user.role === RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Vous ne pouvez pas modifier un super administrateur');
}
// Empêcher de modifier le flag changement_mdp_obligatoire pour admin/gestionnaire // Empêcher de modifier le flag changement_mdp_obligatoire pour admin/gestionnaire
if ( if (
(user.role === RoleType.ADMINISTRATEUR || user.role === RoleType.GESTIONNAIRE) && (user.role === RoleType.ADMINISTRATEUR || user.role === RoleType.GESTIONNAIRE) &&
@ -251,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

@ -30,10 +30,10 @@
| 17 | [Backend] API Création gestionnaire | ✅ Terminé | | 17 | [Backend] API Création gestionnaire | ✅ Terminé |
| 91 | [Frontend] Inscription AM Branchement soumission formulaire à l'API | Ouvert | | 91 | [Frontend] Inscription AM Branchement soumission formulaire à l'API | Ouvert |
| 92 | [Frontend] Dashboard Admin - Données réelles et branchement API | ✅ Terminé | | 92 | [Frontend] Dashboard Admin - Données réelles et branchement API | ✅ Terminé |
| 93 | [Frontend] Panneau Admin - Homogeneiser la presentation des onglets | Ouvert | | 93 | [Frontend] Panneau Admin - Homogeneiser la presentation des onglets | ✅ Fermé |
| 94 | [Backend] Relais - modele, API CRUD et liaison gestionnaire | ✅ Terminé | | 94 | [Backend] Relais - modele, API CRUD et liaison gestionnaire | ✅ Terminé |
| 95 | [Frontend] Admin - gestion des relais et rattachement gestionnaire | Ouvert | | 95 | [Frontend] Admin - gestion des relais et rattachement gestionnaire | ✅ Fermé |
| 96 | [Frontend] Admin - Création administrateur via modale (sans relais) | Ouvert | | 96 | [Frontend] Admin - Création administrateur via modale (sans relais) | ✅ Terminé |
| 97 | [Backend] Harmoniser API création administrateur avec le contrat frontend | ✅ Terminé | | 97 | [Backend] Harmoniser API création administrateur avec le contrat frontend | ✅ Terminé |
| 89 | Log des appels API en mode debug | Ouvert | | 89 | Log des appels API en mode debug | Ouvert |
@ -665,9 +665,10 @@ Le back-office admin doit gérer des Relais avec des données réelles en base,
--- ---
### Ticket #97 : [Backend] Harmoniser API création administrateur avec le contrat frontend ### Ticket #97 : [Backend] Harmoniser API création administrateur avec le contrat frontend
**Estimation** : 3h **Estimation** : 3h
**Labels** : `backend`, `p2`, `auth`, `admin` **Labels** : `backend`, `p2`, `auth`, `admin`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
**Description** : **Description** :
Rendre l'API de création administrateur cohérente et stable avec le besoin frontend (modale simplifiée), en définissant un contrat clair et minimal. Rendre l'API de création administrateur cohérente et stable avec le besoin frontend (modale simplifiée), en définissant un contrat clair et minimal.
@ -680,6 +681,7 @@ Rendre l'API de création administrateur cohérente et stable avec le besoin fro
- [ ] Validation stricte - [ ] Validation stricte
--- ---
## 🟢 PRIORITÉ 3 : Frontend - Interfaces ## 🟢 PRIORITÉ 3 : Frontend - Interfaces
### Ticket #35 : [Frontend] Écran Création Gestionnaire ### Ticket #35 : [Frontend] Écran Création Gestionnaire
@ -1073,9 +1075,10 @@ Branchement du formulaire d'inscription AM (étape 4) à l'endpoint d'inscriptio
--- ---
### Ticket #93 : [Frontend] Panneau Admin - Homogénéisation des onglets ### Ticket #93 : [Frontend] Panneau Admin - Homogénéisation des onglets
**Estimation** : 4h **Estimation** : 4h
**Labels** : `frontend`, `p3`, `admin`, `ux` **Labels** : `frontend`, `p3`, `admin`, `ux`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
**Description** : **Description** :
Uniformiser l'UI/UX des 4 onglets du dashboard admin (Gestionnaires, Parents, AM, Admins). Uniformiser l'UI/UX des 4 onglets du dashboard admin (Gestionnaires, Parents, AM, Admins).
@ -1088,9 +1091,10 @@ Uniformiser l'UI/UX des 4 onglets du dashboard admin (Gestionnaires, Parents, AM
--- ---
### Ticket #95 : [Frontend] Admin - Gestion des Relais et rattachement gestionnaire ### Ticket #95 : [Frontend] Admin - Gestion des Relais et rattachement gestionnaire
**Estimation** : 5h **Estimation** : 5h
**Labels** : `frontend`, `p3`, `admin` **Labels** : `frontend`, `p3`, `admin`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
**Description** : **Description** :
Interface de gestion des Relais dans le dashboard admin et rattachement des gestionnaires. Interface de gestion des Relais dans le dashboard admin et rattachement des gestionnaires.
@ -1103,24 +1107,22 @@ Interface de gestion des Relais dans le dashboard admin et rattachement des gest
--- ---
<<<<<<< HEAD ### Ticket #96 : [Frontend] Admin - Création administrateur via modale (sans relais) ✅
=======
### Ticket #96 : [Frontend] Admin - Création administrateur via modale (sans relais)
**Estimation** : 3h **Estimation** : 3h
**Labels** : `frontend`, `p3`, `admin` **Labels** : `frontend`, `p3`, `admin`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
**Description** : **Description** :
Permettre la création d'un administrateur via une modale simple depuis le dashboard admin. Permettre la création d'un administrateur via une modale simple depuis le dashboard admin.
**Tâches** : **Tâches** :
- [ ] Bouton "Créer administrateur" dans l'onglet Administrateurs - [x] Bouton "Créer administrateur" dans l'onglet Administrateurs
- [ ] Modale avec formulaire simplifié (Nom, Prénom, Email, MDP, Téléphone) - [x] Modale avec formulaire simplifié (Nom, Prénom, Email, MDP, Téléphone)
- [ ] Appel API `POST /users` (ou endpoint dédié si #97 implémenté) - [x] Appel API `POST /users` (ou endpoint dédié si #97 implémenté)
- [ ] Gestion succès/erreur et rafraîchissement liste - [x] Gestion succès/erreur et rafraîchissement liste
--- ---
>>>>>>> develop
## 🔵 PRIORITÉ 4 : Tests & Documentation ## 🔵 PRIORITÉ 4 : Tests & Documentation
### Ticket #52 : [Tests] Tests unitaires Backend ### Ticket #52 : [Tests] Tests unitaires Backend

View File

@ -1,6 +1,6 @@
# SuperNounou SSS-001 # SuperNounou SSS-001
## Spécification technique & opérationnelle unifiée ## Spécification technique & opérationnelle unifiée
_Version 0.2 24 avril 2025_ _Version 0.3 27 janvier 2026_
--- ---
@ -62,6 +62,13 @@ Collection Postman, scripts cURL, guide « Appeler lAPI ».
### B.4 Intégrations futures ### B.4 Intégrations futures
SSO LDAP/SAML, webhook `contract.validated`, export statistiques CSV. SSO LDAP/SAML, webhook `contract.validated`, export statistiques CSV.
### B.5 Contrat de gestion des comptes d'administration
- Création d'un administrateur avec un contrat minimal stable : `nom`, `prenom`, `email`, `password`, `telephone`.
- Le rôle n'est jamais fourni par le frontend pour ce flux ; le backend impose `ADMINISTRATEUR`.
- Les champs hors périmètre (adresse complète, photo, métadonnées métier non nécessaires) ne sont pas requis.
- Les protections d'autorisation restent actives : un `SUPER_ADMIN` n'est pas supprimable et son identité (`nom`, `prenom`) est non modifiable.
- Côté interface d'administration, les actions d'édition sont conditionnées aux droits ; les entrées non éditables restent consultables en lecture seule.
--- ---
# C Déploiement, CI/CD et Observabilité *(nouveau)* # C Déploiement, CI/CD et Observabilité *(nouveau)*
@ -106,3 +113,4 @@ AES-256, JWT, KMS, OpenAPI, RPO, RTO, rate-limit, HMAC, Compose, CI/CD…
|---------|------------|------------------|---------------------------------| |---------|------------|------------------|---------------------------------|
| 0.1-draft | 2025-04-24 | Équipe projet | Création du SSS unifié | | 0.1-draft | 2025-04-24 | Équipe projet | Création du SSS unifié |
| 0.2 | 2025-04-24 | ChatGPT & Julien | Ajout déploiement / CI/CD / logs | | 0.2 | 2025-04-24 | ChatGPT & Julien | Ajout déploiement / CI/CD / logs |
| 0.3 | 2026-01-27 | Équipe projet | Contrat admin harmonisé et règles d'autorisation |

View File

@ -0,0 +1,357 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/user_service.dart';
class AdminCreateDialog extends StatefulWidget {
final AppUser? initialUser;
const AdminCreateDialog({
super.key,
this.initialUser,
});
@override
State<AdminCreateDialog> createState() => _AdminCreateDialogState();
}
class _AdminCreateDialogState extends State<AdminCreateDialog> {
final _formKey = GlobalKey<FormState>();
final _nomController = TextEditingController();
final _prenomController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _telephoneController = TextEditingController();
bool _isSubmitting = false;
bool _obscurePassword = true;
bool get _isEditMode => widget.initialUser != null;
@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 = user.telephone ?? '';
// En édition, on ne préremplit jamais le mot de passe.
_passwordController.clear();
}
}
@override
void dispose() {
_nomController.dispose();
_prenomController.dispose();
_emailController.dispose();
_passwordController.dispose();
_telephoneController.dispose();
super.dispose();
}
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;
final email = value!.trim();
final ok = RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(email);
if (!ok) return 'Format email invalide';
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<void> _submit() async {
if (_isSubmitting) return;
if (!_formKey.currentState!.validate()) return;
setState(() {
_isSubmitting = true;
});
try {
if (_isEditMode) {
await UserService.updateAdmin(
adminId: widget.initialUser!.id,
nom: _nomController.text.trim(),
prenom: _prenomController.text.trim(),
email: _emailController.text.trim(),
telephone: _telephoneController.text.trim(),
password: _passwordController.text.trim().isEmpty
? null
: _passwordController.text,
);
} else {
await UserService.createAdmin(
nom: _nomController.text.trim(),
prenom: _prenomController.text.trim(),
email: _emailController.text.trim(),
password: _passwordController.text,
telephone: _telephoneController.text.trim(),
);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isEditMode
? 'Administrateur modifié avec succès.'
: 'Administrateur 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<void> _delete() async {
if (!_isEditMode || _isSubmitting) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) {
return AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text(
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
child: const Text('Supprimer'),
),
],
);
},
);
if (confirmed != true) return;
setState(() {
_isSubmitting = true;
});
try {
await UserService.deleteUser(widget.initialUser!.id);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Administrateur 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: [
Expanded(
child: Text(
_isEditMode
? 'Modifier un administrateur'
: 'Créer un administrateur',
),
),
if (_isEditMode)
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: _buildNomField()),
const SizedBox(width: 12),
Expanded(child: _buildPrenomField()),
],
),
const SizedBox(height: 12),
_buildEmailField(),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: _buildPasswordField()),
const SizedBox(width: 12),
Expanded(child: _buildTelephoneField()),
],
),
],
),
),
),
),
actions: [
if (_isEditMode) ...[
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,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Nom',
border: OutlineInputBorder(),
),
validator: (v) => _required(v, 'Nom'),
);
}
Widget _buildPrenomField() {
return TextFormField(
controller: _prenomController,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Prénom',
border: OutlineInputBorder(),
),
validator: (v) => _required(v, 'Prénom'),
);
}
Widget _buildEmailField() {
return TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
validator: _validateEmail,
);
}
Widget _buildPasswordField() {
return TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
enableSuggestions: false,
autocorrect: false,
autofillHints: _isEditMode
? const <String>[]
: const [AutofillHints.newPassword],
decoration: InputDecoration(
labelText: _isEditMode
? 'Nouveau mot de passe'
: 'Mot de passe',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
icon: Icon(
_obscurePassword ? Icons.visibility_off : Icons.visibility,
),
),
),
validator: _validatePassword,
);
}
Widget _buildTelephoneField() {
return TextFormField(
controller: _telephoneController,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: 'Téléphone',
border: OutlineInputBorder(),
),
validator: (v) => _required(v, 'Téléphone'),
);
}
}

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;
} }
if (widget.withRelais) {
_loadRelais(); _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(
adminId: widget.initialUser!.id,
nom: _isLockedAdminIdentity ? lockedNom : normalizedNom,
prenom: _isLockedAdminIdentity ? lockedPrenom : 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.'),
), ),
), ),
); );
@ -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,15 +465,23 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
Expanded(child: _buildTelephoneField()), Expanded(child: _buildTelephoneField()),
], ],
), ),
if (widget.withRelais) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
_buildRelaisField(), _buildRelaisField(),
], ],
],
), ),
), ),
), ),
), ),
actions: [ actions: [
if (_isEditMode) ...[ if (widget.readOnly) ...[
FilledButton(
onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(false),
child: const Text('Fermer'),
),
] else if (_isEditMode) ...[
if (!_isSuperAdminTarget)
OutlinedButton( OutlinedButton(
onPressed: _isSubmitting ? null : _delete, onPressed: _isSubmitting ? null : _delete,
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700), style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
@ -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,7 +578,11 @@ 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
? null
: ExcludeFocus(
child: IconButton(
focusNode: _passwordToggleFocusNode,
onPressed: () { onPressed: () {
setState(() { setState(() {
_obscurePassword = !_obscurePassword; _obscurePassword = !_obscurePassword;
@ -389,19 +593,28 @@ class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
), ),
), ),
), ),
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(() {
@ -449,3 +662,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(
@ -132,6 +167,82 @@ class UserService {
return []; return [];
} }
static Future<AppUser> createAdmin({
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);
}
static Future<AppUser> updateAdmin({
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> updateGestionnaireRelais({ static Future<void> updateGestionnaireRelais({
required String gestionnaireId, required String gestionnaireId,
required String? relaisId, required String? relaisId,
@ -156,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 {
@ -164,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();
} }
@ -194,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,5 +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/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';
@ -20,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();
} }
@ -51,6 +55,48 @@ 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 {
final canEdit = _canEditAdmin(user);
final changed = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return AdminUserFormDialog(
initialUser: user,
adminMode: true,
withRelais: false,
readOnly: !canEdit,
);
},
);
if (changed == true && canEdit) {
await _loadAdmins();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase(); final query = widget.searchQuery.toLowerCase();
@ -68,19 +114,36 @@ 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: () {
// TODO: Modifier admin _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

@ -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.',
),
),
);
} }
} }