Compare commits

..

12 Commits

Author SHA1 Message Date
bb92f010bd feat(#35): unifier la modale gestionnaire en création et édition
Branche la modale sur l'action Modifier, supprime l'action dédiée de rattachement relais, ajoute la suppression avec confirmation et sécurise le dropdown relais en édition.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 00:19:32 +01:00
42bb872c41 feat(#35): créer un gestionnaire via modale avec sélection de relais
Implémente la création de gestionnaire directement depuis le dashboard admin avec formulaire validé, appel API dédié et rattachement optionnel à un relais depuis une combobox.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 00:08:31 +01:00
fac3ae9baa Merge branch 'develop' of https://git.ptits-pas.fr/jmartin/petitspas into develop 2026-02-24 00:08:28 +01:00
57ce5af0f4 Merge branch 'develop' of https://git.ptits-pas.fr/jmartin/petitspas into develop 2026-02-23 23:24:37 +01:00
af06ab1e66 Merge branch 'feature/93-homogeneisation-onglets-admin' into develop 2026-02-23 23:05:34 +01:00
aa148354ec Merge branch 'develop' of https://git.ptits-pas.fr/jmartin/petitspas into develop 2026-02-23 23:01:53 +01:00
bc8362bdb7 refactor(#93): extraire un widget UserList réutilisable
Centralise le pattern d'affichage des listes utilisateurs pour garantir une UI homogène entre gestionnaires, parents, assistantes maternelles et administrateurs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-23 17:59:03 +01:00
ac3178903d docs(#93): tracer l'évolution RBAC intra-RPE dans le CDC
Documente la future gouvernance par rôles au sein d'un même relais pour cadrer les évolutions ultérieures sans l'intégrer au périmètre des tickets backend/frontend actuels.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-23 17:32:09 +01:00
aec1990ec9 refactor(#93): uniformiser la ligne utilisateur et afficher Modifier au survol
Met le rendu des lignes sur une seule ligne (icone, nom, infos) et n’affiche l’action Modifier qu’au hover pour alléger visuellement les listes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-23 17:32:09 +01:00
5da2ab9005 feat(#93): optimiser l’affichage Parents/AM avec modale de détails
Intègre un bandeau unique (onglets à gauche, recherche/filtre en pilule, bouton Ajouter à droite) et compacte les cartes Parents/AM avec ouverture d’une modale complète sur Modifier (croix, actions Modifier/Supprimer).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-23 17:32:03 +01:00
b2d6414fab refactor(#93): homogénéiser la présentation des onglets admin
Uniformise les 4 onglets de gestion admin avec des composants UI partagés (header, états de liste, carte utilisateur) pour garantir une expérience cohérente sans changement backend.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-23 17:31:50 +01:00
fbafef8f2c feat(#95): implémenter la gestion Relais admin et le rattachement gestionnaire
Ajoute la section Paramètres territoriaux avec CRUD Relais, modale de saisie structurée, états visuels harmonisés, et rattachement d'un relais principal aux gestionnaires via l'API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 20:06:17 +01:00
21 changed files with 3007 additions and 582 deletions

View File

@ -256,3 +256,23 @@ Pour chaque évolution identifiée, ce document suivra la structure suivante :
- Modification du flux de sélection d'image dans les écrans concernés (ex: `parent_register_step3_screen.dart`).
- Ajout potentiel de nouvelles dépendances et configurations spécifiques aux plateformes.
- Mise à jour de la documentation utilisateur si cette fonctionnalité est implémentée.
## 8. Évolution future - Gouvernance intra-RPE
### 8.1 Niveaux d'accès et rôles différenciés dans un même Relais
#### 8.1.1 Situation actuelle
- Le périmètre actuel prévoit un rattachement simple entre gestionnaire et relais.
- Le rôle "gestionnaire" est traité de manière uniforme dans l'outil.
#### 8.1.2 Évolution à prévoir
- Introduire un modèle de rôles internes au relais (par exemple : responsable/coordinatrice, animatrice/référente, administratif).
- Permettre des niveaux d'autorité différents selon les actions (pilotage, validation, consultation, administration locale).
- Définir des permissions fines par fonctionnalité (lecture, création, modification, suppression, validation).
- Prévoir une gestion multi-utilisateurs par relais avec traçabilité des décisions.
#### 8.1.3 Impact attendu
- Évolution du modèle de données vers un RBAC intra-RPE.
- Adaptation des écrans d'administration pour gérer les rôles locaux.
- Renforcement des contrôles d'accès backend et des règles métier.
- Clarification des workflows décisionnels dans l'application.

View File

@ -0,0 +1,33 @@
class RelaisModel {
final String id;
final String nom;
final String adresse;
final Map<String, dynamic>? horairesOuverture;
final String? ligneFixe;
final bool actif;
final String? notes;
const RelaisModel({
required this.id,
required this.nom,
required this.adresse,
this.horairesOuverture,
this.ligneFixe,
required this.actif,
this.notes,
});
factory RelaisModel.fromJson(Map<String, dynamic> json) {
return RelaisModel(
id: (json['id'] ?? '').toString(),
nom: (json['nom'] ?? '').toString(),
adresse: (json['adresse'] ?? '').toString(),
horairesOuverture: json['horaires_ouverture'] is Map<String, dynamic>
? json['horaires_ouverture'] as Map<String, dynamic>
: null,
ligneFixe: json['ligne_fixe'] as String?,
actif: json['actif'] as bool? ?? true,
notes: json['notes'] as String?,
);
}
}

View File

@ -13,6 +13,8 @@ class AppUser {
final String? adresse;
final String? ville;
final String? codePostal;
final String? relaisId;
final String? relaisNom;
AppUser({
required this.id,
@ -29,9 +31,15 @@ class AppUser {
this.adresse,
this.ville,
this.codePostal,
this.relaisId,
this.relaisNom,
});
factory AppUser.fromJson(Map<String, dynamic> json) {
final relaisJson = json['relais'];
final relaisMap =
relaisJson is Map<String, dynamic> ? relaisJson : <String, dynamic>{};
return AppUser(
id: json['id'] as String,
email: json['email'] as String,
@ -56,6 +64,9 @@ class AppUser {
adresse: json['adresse'] as String?,
ville: json['ville'] as String?,
codePostal: json['code_postal'] as String?,
relaisId: (json['relaisId'] ?? json['relais_id'] ?? relaisMap['id'])
?.toString(),
relaisNom: relaisMap['nom']?.toString(),
);
}
@ -75,6 +86,8 @@ class AppUser {
'adresse': adresse,
'ville': ville,
'code_postal': codePostal,
'relais_id': relaisId,
'relais_nom': relaisNom,
};
}

View File

@ -1,10 +1,7 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/services/configuration_service.dart';
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart';
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/parametres_panel.dart';
import 'package:p_tits_pas/widgets/admin/user_management_panel.dart';
import 'package:p_tits_pas/widgets/app_footer.dart';
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
@ -18,7 +15,7 @@ class AdminDashboardScreen extends StatefulWidget {
class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
bool? _setupCompleted;
int mainTabIndex = 0;
int subIndex = 0;
int settingsSubIndex = 0;
@override
void initState() {
@ -26,6 +23,11 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
_loadSetupStatus();
}
@override
void dispose() {
super.dispose();
}
Future<void> _loadSetupStatus() async {
try {
final completed = await ConfigurationService.getSetupStatus();
@ -35,12 +37,14 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
if (!completed) mainTabIndex = 1;
});
} catch (e) {
if (mounted) setState(() {
if (mounted) {
setState(() {
_setupCompleted = false;
mainTabIndex = 1;
});
}
}
}
void onMainTabChange(int index) {
setState(() {
@ -48,9 +52,9 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
});
}
void onSubTabChange(int index) {
void onSettingsSubTabChange(int index) {
setState(() {
subIndex = index;
settingsSubIndex = index;
});
}
@ -80,9 +84,11 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
body: Column(
children: [
if (mainTabIndex == 0)
DashboardUserManagementSubBar(
selectedSubIndex: subIndex,
onSubTabChange: onSubTabChange,
const SizedBox.shrink()
else
DashboardSettingsSubBar(
selectedSubIndex: settingsSubIndex,
onSubTabChange: onSettingsSubTabChange,
),
Expanded(
child: _getBody(),
@ -95,19 +101,11 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
Widget _getBody() {
if (mainTabIndex == 1) {
return ParametresPanel(redirectToLoginAfterSave: !_setupCompleted!);
}
switch (subIndex) {
case 0:
return const GestionnaireManagementWidget();
case 1:
return const ParentManagementWidget();
case 2:
return const AssistanteMaternelleManagementWidget();
case 3:
return const AdminManagementWidget();
default:
return const Center(child: Text('Page non trouvée'));
return ParametresPanel(
redirectToLoginAfterSave: !_setupCompleted!,
selectedSettingsTabIndex: settingsSubIndex,
);
}
return const AdminUserManagementPanel();
}
}

View File

@ -1,17 +1,451 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/relais_model.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/relais_service.dart';
import 'package:p_tits_pas/services/user_service.dart';
class GestionnairesCreate extends StatelessWidget {
const GestionnairesCreate({super.key});
class GestionnaireCreateDialog extends StatefulWidget {
final AppUser? initialUser;
const GestionnaireCreateDialog({
super.key,
this.initialUser,
});
@override
State<GestionnaireCreateDialog> createState() =>
_GestionnaireCreateDialogState();
}
class _GestionnaireCreateDialogState extends State<GestionnaireCreateDialog> {
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 _isLoadingRelais = true;
List<RelaisModel> _relais = [];
String? _selectedRelaisId;
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();
final initialRelaisId = user.relaisId?.trim();
_selectedRelaisId =
(initialRelaisId == null || initialRelaisId.isEmpty)
? null
: initialRelaisId;
}
_loadRelais();
}
@override
void dispose() {
_nomController.dispose();
_prenomController.dispose();
_emailController.dispose();
_passwordController.dispose();
_telephoneController.dispose();
super.dispose();
}
Future<void> _loadRelais() async {
try {
final list = await RelaisService.getRelais();
if (!mounted) return;
final uniqueById = <String, RelaisModel>{};
for (final relais in list) {
uniqueById[relais.id] = relais;
}
final filtered = uniqueById.values.where((r) => r.actif).toList();
if (_selectedRelaisId != null &&
!filtered.any((r) => r.id == _selectedRelaisId)) {
final selected = uniqueById[_selectedRelaisId!];
if (selected != null) {
filtered.add(selected);
} else {
_selectedRelaisId = null;
}
}
setState(() {
_relais = filtered;
_isLoadingRelais = false;
});
} catch (_) {
if (!mounted) return;
setState(() {
_selectedRelaisId = null;
_relais = [];
_isLoadingRelais = false;
});
}
}
String? _required(String? value, String field) {
if (value == null || value.trim().isEmpty) {
return '$field est requis';
}
return null;
}
String? _validateEmail(String? value) {
final base = _required(value, 'Email');
if (base != null) return base;
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.updateGestionnaire(
gestionnaireId: widget.initialUser!.id,
nom: _nomController.text.trim(),
prenom: _prenomController.text.trim(),
email: _emailController.text.trim(),
telephone: _telephoneController.text.trim(),
relaisId: _selectedRelaisId,
password: _passwordController.text.trim().isEmpty
? null
: _passwordController.text,
);
} else {
await UserService.createGestionnaire(
nom: _nomController.text.trim(),
prenom: _prenomController.text.trim(),
email: _emailController.text.trim(),
password: _passwordController.text,
telephone: _telephoneController.text.trim(),
relaisId: _selectedRelaisId,
);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isEditMode
? 'Gestionnaire modifié avec succès.'
: 'Gestionnaire créé avec succès.',
),
),
);
Navigator.of(context).pop(true);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e.toString().replaceFirst('Exception: ', ''),
),
backgroundColor: Colors.red.shade700,
),
);
} finally {
if (!mounted) return;
setState(() {
_isSubmitting = false;
});
}
}
Future<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('Gestionnaire supprimé.')),
);
Navigator.of(context).pop(true);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString().replaceFirst('Exception: ', '')),
backgroundColor: Colors.red.shade700,
),
);
setState(() {
_isSubmitting = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Créer un gestionnaire'),
return AlertDialog(
title: Row(
children: [
Expanded(
child: Text(
_isEditMode
? 'Modifier un gestionnaire'
: 'Créer un gestionnaire',
),
body: const Center(
child: Text('Formulaire de création de gestionnaire'),
),
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()),
],
),
const SizedBox(height: 12),
_buildRelaisField(),
],
),
),
),
),
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'),
);
}
Widget _buildRelaisField() {
final selectedValue = _selectedRelaisId != null &&
_relais.any((relais) => relais.id == _selectedRelaisId)
? _selectedRelaisId
: null;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<String?>(
isExpanded: true,
value: selectedValue,
decoration: const InputDecoration(
labelText: 'Relais principal',
border: OutlineInputBorder(),
),
items: [
const DropdownMenuItem<String?>(
value: null,
child: Text('Aucun relais'),
),
..._relais.map(
(relais) => DropdownMenuItem<String?>(
value: relais.id,
child: Text(relais.nom),
),
),
],
onChanged: _isLoadingRelais
? null
: (value) {
setState(() {
_selectedRelaisId = value;
});
},
),
if (_isLoadingRelais) ...[
const SizedBox(height: 8),
const LinearProgressIndicator(minHeight: 2),
],
],
);
}
}

View File

@ -18,11 +18,13 @@ class ApiConfig {
static const String gestionnaires = '/gestionnaires';
static const String parents = '/parents';
static const String assistantesMaternelles = '/assistantes-maternelles';
static const String relais = '/relais';
// Configuration (admin)
static const String configuration = '/configuration';
static const String configurationSetupStatus = '/configuration/setup/status';
static const String configurationSetupComplete = '/configuration/setup/complete';
static const String configurationSetupComplete =
'/configuration/setup/complete';
static const String configurationTestSmtp = '/configuration/test-smtp';
static const String configurationBulk = '/configuration/bulk';

View File

@ -0,0 +1,97 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:p_tits_pas/models/relais_model.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/services/api/tokenService.dart';
class RelaisService {
static Future<Map<String, String>> _headers() async {
final token = await TokenService.getToken();
return token != null
? ApiConfig.authHeaders(token)
: Map<String, String>.from(ApiConfig.headers);
}
static String _extractError(String body, String fallback) {
try {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is String && message.trim().isNotEmpty) {
return message;
}
}
} catch (_) {}
return fallback;
}
static Future<List<RelaisModel>> getRelais() async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}'),
headers: await _headers(),
);
if (response.statusCode != 200) {
throw Exception(
_extractError(response.body, 'Erreur chargement relais'),
);
}
final List<dynamic> data = jsonDecode(response.body);
return data
.whereType<Map<String, dynamic>>()
.map(RelaisModel.fromJson)
.toList();
}
static Future<RelaisModel> createRelais(Map<String, dynamic> payload) async {
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}'),
headers: await _headers(),
body: jsonEncode(payload),
);
if (response.statusCode != 201 && response.statusCode != 200) {
throw Exception(
_extractError(response.body, 'Erreur création relais'),
);
}
return RelaisModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>);
}
static Future<RelaisModel> updateRelais(
String id,
Map<String, dynamic> payload,
) async {
final response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}/$id'),
headers: await _headers(),
body: jsonEncode(payload),
);
if (response.statusCode != 200) {
throw Exception(
_extractError(response.body, 'Erreur mise à jour relais'),
);
}
return RelaisModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>);
}
static Future<void> deleteRelais(String id) async {
final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.relais}/$id'),
headers: await _headers(),
);
if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception(
_extractError(response.body, 'Erreur suppression relais'),
);
}
}
}

View File

@ -29,13 +29,52 @@ class UserService {
if (response.statusCode != 200) {
final err = jsonDecode(response.body) as Map<String, dynamic>?;
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement gestionnaires');
throw Exception(
_toStr(err?['message']) ?? 'Erreur chargement gestionnaires');
}
final List<dynamic> data = jsonDecode(response.body);
return data.map((e) => AppUser.fromJson(e)).toList();
}
static Future<AppUser> createGestionnaire({
required String nom,
required String prenom,
required String email,
required String password,
required String telephone,
String? relaisId,
}) async {
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.gestionnaires}'),
headers: await _headers(),
body: jsonEncode(<String, dynamic>{
'nom': nom,
'prenom': prenom,
'email': email,
'password': password,
'telephone': telephone,
'cguAccepted': true,
'relaisId': relaisId,
}),
);
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 gestionnaire');
}
throw Exception('Erreur création gestionnaire');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return AppUser.fromJson(data);
}
// Récupérer la liste des parents
static Future<List<ParentModel>> getParents() async {
final response = await http.get(
@ -53,7 +92,8 @@ class UserService {
}
// Récupérer la liste des assistantes maternelles
static Future<List<AssistanteMaternelleModel>> getAssistantesMaternelles() async {
static Future<List<AssistanteMaternelleModel>>
getAssistantesMaternelles() async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}'),
headers: await _headers(),
@ -87,8 +127,89 @@ class UserService {
.toList();
}
} catch (e) {
print('Erreur chargement admins: $e');
// On garde un fallback vide pour ne pas bloquer l'UI admin.
}
return [];
}
static Future<void> updateGestionnaireRelais({
required String gestionnaireId,
required String? relaisId,
}) async {
final response = await http.patch(
Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.gestionnaires}/$gestionnaireId'),
headers: await _headers(),
body: jsonEncode(<String, dynamic>{'relaisId': relaisId}),
);
if (response.statusCode != 200 && response.statusCode != 204) {
final err = jsonDecode(response.body) as Map<String, dynamic>?;
throw Exception(
_toStr(err?['message']) ?? 'Erreur rattachement relais au gestionnaire',
);
}
}
static Future<AppUser> updateGestionnaire({
required String gestionnaireId,
required String nom,
required String prenom,
required String email,
required String telephone,
required String? relaisId,
String? password,
}) async {
final body = <String, dynamic>{
'nom': nom,
'prenom': prenom,
'email': email,
'telephone': telephone,
'relaisId': relaisId,
};
if (password != null && password.trim().isNotEmpty) {
body['password'] = password.trim();
}
final response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.gestionnaires}/$gestionnaireId'),
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 gestionnaire');
}
throw Exception('Erreur modification gestionnaire');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return AppUser.fromJson(data);
}
static Future<void> deleteUser(String userId) async {
final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'),
headers: await _headers(),
);
if (response.statusCode != 200 && response.statusCode != 204) {
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 suppression utilisateur');
}
throw Exception('Erreur suppression utilisateur');
}
}
}

View File

@ -1,9 +1,16 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.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/user_list.dart';
class AdminManagementWidget extends StatefulWidget {
const AdminManagementWidget({super.key});
final String searchQuery;
const AdminManagementWidget({
super.key,
required this.searchQuery,
});
@override
State<AdminManagementWidget> createState() => _AdminManagementWidgetState();
@ -13,21 +20,15 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
bool _isLoading = false;
String? _error;
List<AppUser> _admins = [];
List<AppUser> _filteredAdmins = [];
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
_loadAdmins();
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void dispose() => super.dispose();
Future<void> _loadAdmins() async {
setState(() {
@ -39,7 +40,6 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
if (!mounted) return;
setState(() {
_admins = list;
_filteredAdmins = list;
_isLoading = false;
});
} catch (e) {
@ -51,91 +51,41 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
}
}
void _onSearchChanged() {
final query = _searchController.text.toLowerCase();
setState(() {
_filteredAdmins = _admins.where((u) {
@override
Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase();
final filteredAdmins = _admins.where((u) {
final name = u.fullName.toLowerCase();
final email = u.email.toLowerCase();
return name.contains(query) || email.contains(query);
}).toList();
});
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
decoration: const InputDecoration(
hintText: "Rechercher un administrateur...",
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 16),
ElevatedButton.icon(
onPressed: () {
// TODO: Créer admin
},
icon: const Icon(Icons.add),
label: const Text("Créer un admin"),
),
],
),
const SizedBox(height: 24),
if (_isLoading)
const Center(child: CircularProgressIndicator())
else if (_error != null)
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
else if (_filteredAdmins.isEmpty)
const Center(child: Text("Aucun administrateur trouvé."))
else
Expanded(
child: ListView.builder(
itemCount: _filteredAdmins.length,
return UserList(
isLoading: _isLoading,
error: _error,
isEmpty: filteredAdmins.isEmpty,
emptyMessage: 'Aucun administrateur trouvé.',
itemCount: filteredAdmins.length,
itemBuilder: (context, index) {
final user = _filteredAdmins[index];
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: CircleAvatar(
child: Text(user.fullName.isNotEmpty
? user.fullName[0].toUpperCase()
: 'A'),
),
title: Text(user.fullName.isNotEmpty
? user.fullName
: 'Sans nom'),
subtitle: Text(user.email),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
final user = filteredAdmins[index];
return AdminUserCard(
title: user.fullName,
subtitleLines: [
user.email,
'Rôle : ${user.role}',
],
avatarUrl: user.photoUrl,
actions: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () {},
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {},
),
],
),
),
);
tooltip: 'Modifier',
onPressed: () {
// TODO: Modifier admin
},
),
)
],
),
);
},
);
}
}

View File

@ -1,9 +1,19 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
class AssistanteMaternelleManagementWidget extends StatefulWidget {
const AssistanteMaternelleManagementWidget({super.key});
final String searchQuery;
final int? capacityMin;
const AssistanteMaternelleManagementWidget({
super.key,
required this.searchQuery,
this.capacityMin,
});
@override
State<AssistanteMaternelleManagementWidget> createState() =>
@ -15,25 +25,15 @@ class _AssistanteMaternelleManagementWidgetState
bool _isLoading = false;
String? _error;
List<AssistanteMaternelleModel> _assistantes = [];
List<AssistanteMaternelleModel> _filteredAssistantes = [];
final TextEditingController _zoneController = TextEditingController();
final TextEditingController _capacityController = TextEditingController();
@override
void initState() {
super.initState();
_loadAssistantes();
_zoneController.addListener(_filter);
_capacityController.addListener(_filter);
}
@override
void dispose() {
_zoneController.dispose();
_capacityController.dispose();
super.dispose();
}
void dispose() => super.dispose();
Future<void> _loadAssistantes() async {
setState(() {
@ -45,7 +45,6 @@ class _AssistanteMaternelleManagementWidgetState
if (!mounted) return;
setState(() {
_assistantes = list;
_filter();
_isLoading = false;
});
} catch (e) {
@ -57,117 +56,100 @@ class _AssistanteMaternelleManagementWidgetState
}
}
void _filter() {
final zoneQuery = _zoneController.text.toLowerCase();
final capacityQuery = int.tryParse(_capacityController.text);
setState(() {
_filteredAssistantes = _assistantes.where((am) {
final matchesZone = zoneQuery.isEmpty ||
(am.residenceCity?.toLowerCase().contains(zoneQuery) ?? false);
final matchesCapacity = capacityQuery == null ||
(am.maxChildren != null && am.maxChildren! >= capacityQuery);
return matchesZone && matchesCapacity;
}).toList();
});
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 🔎 Zone de filtre
_buildFilterSection(),
final query = widget.searchQuery.toLowerCase();
final filteredAssistantes = _assistantes.where((am) {
final matchesName = am.user.fullName.toLowerCase().contains(query) ||
am.user.email.toLowerCase().contains(query) ||
(am.residenceCity?.toLowerCase().contains(query) ?? false);
final matchesCapacity = widget.capacityMin == null ||
(am.maxChildren != null && am.maxChildren! >= widget.capacityMin!);
return matchesName && matchesCapacity;
}).toList();
const SizedBox(height: 16),
// 📋 Liste des assistantes
if (_isLoading)
const Center(child: CircularProgressIndicator())
else if (_error != null)
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
else if (_filteredAssistantes.isEmpty)
const Center(child: Text("Aucune assistante maternelle trouvée."))
else
Expanded(
child: ListView.builder(
itemCount: _filteredAssistantes.length,
return UserList(
isLoading: _isLoading,
error: _error,
isEmpty: filteredAssistantes.isEmpty,
emptyMessage: 'Aucune assistante maternelle trouvée.',
itemCount: filteredAssistantes.length,
itemBuilder: (context, index) {
final assistante = _filteredAssistantes[index];
return Card(
margin: const EdgeInsets.symmetric(vertical: 8),
child: ListTile(
leading: CircleAvatar(
backgroundImage: assistante.user.photoUrl != null
? NetworkImage(assistante.user.photoUrl!)
: null,
child: assistante.user.photoUrl == null
? const Icon(Icons.face)
: null,
),
title: Text(assistante.user.fullName.isNotEmpty
? assistante.user.fullName
: 'Sans nom'),
subtitle: Text(
"N° Agrément : ${assistante.approvalNumber ?? 'N/A'}\nZone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}"),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
final assistante = filteredAssistantes[index];
return AdminUserCard(
title: assistante.user.fullName,
avatarUrl: assistante.user.photoUrl,
fallbackIcon: Icons.face,
subtitleLines: [
assistante.user.email,
'Zone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}',
],
actions: [
IconButton(
icon: const Icon(Icons.edit),
tooltip: 'Modifier',
onPressed: () {
// TODO: Ajouter modification
},
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
// TODO: Ajouter suppression
_openAssistanteDetails(assistante);
},
),
],
),
),
);
},
);
}
void _openAssistanteDetails(AssistanteMaternelleModel assistante) {
showDialog<void>(
context: context,
builder: (context) => AdminDetailModal(
title: assistante.user.fullName.isEmpty
? 'Assistante maternelle'
: assistante.user.fullName,
subtitle: assistante.user.email,
fields: [
AdminDetailField(label: 'ID', value: _v(assistante.user.id)),
AdminDetailField(
label: 'Numero agrement',
value: _v(assistante.approvalNumber),
),
AdminDetailField(
label: 'Ville residence',
value: _v(assistante.residenceCity),
),
AdminDetailField(
label: 'Capacite max',
value: assistante.maxChildren?.toString() ?? '-',
),
AdminDetailField(
label: 'Places disponibles',
value: assistante.placesAvailable?.toString() ?? '-',
),
AdminDetailField(
label: 'Telephone',
value: _v(assistante.user.telephone),
),
AdminDetailField(label: 'Adresse', value: _v(assistante.user.adresse)),
AdminDetailField(label: 'Ville', value: _v(assistante.user.ville)),
AdminDetailField(
label: 'Code postal',
value: _v(assistante.user.codePostal),
),
],
onEdit: () {
Navigator.of(context).pop();
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Action Modifier a implementer')),
);
},
onDelete: () {
Navigator.of(context).pop();
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Action Supprimer a implementer')),
);
},
),
);
}
Widget _buildFilterSection() {
return Wrap(
spacing: 16,
runSpacing: 8,
children: [
SizedBox(
width: 200,
child: TextField(
controller: _zoneController,
decoration: const InputDecoration(
labelText: "Zone géographique",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.location_on),
),
),
),
SizedBox(
width: 200,
child: TextField(
controller: _capacityController,
decoration: const InputDecoration(
labelText: "Capacité minimum",
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
),
),
],
);
}
String _v(String? value) => (value == null || value.isEmpty) ? '-' : value;
}

View File

@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
class AdminDetailField {
final String label;
final String value;
const AdminDetailField({
required this.label,
required this.value,
});
}
class AdminDetailModal extends StatelessWidget {
final String title;
final String? subtitle;
final List<AdminDetailField> fields;
final VoidCallback onEdit;
final VoidCallback onDelete;
const AdminDetailModal({
super.key,
required this.title,
this.subtitle,
required this.fields,
required this.onEdit,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 620),
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
if (subtitle != null && subtitle!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
subtitle!,
style: const TextStyle(color: Colors.black54),
),
],
],
),
),
IconButton(
tooltip: 'Fermer',
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 8),
const Divider(height: 1),
const SizedBox(height: 12),
Flexible(
child: SingleChildScrollView(
child: Column(
children: fields
.map(
(field) => Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 180,
child: Text(
field.label,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
),
Expanded(
child: Text(
field.value,
style: const TextStyle(color: Colors.black87),
),
),
],
),
),
)
.toList(),
),
),
),
const SizedBox(height: 14),
Align(
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
OutlinedButton.icon(
onPressed: onDelete,
icon: const Icon(Icons.delete_outline),
label: const Text('Supprimer'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red.shade700,
side: BorderSide(color: Colors.red.shade300),
),
),
const SizedBox(width: 10),
ElevatedButton.icon(
onPressed: onEdit,
icon: const Icon(Icons.edit),
label: const Text('Modifier'),
),
],
),
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
class AdminListState extends StatelessWidget {
final bool isLoading;
final String? error;
final bool isEmpty;
final String emptyMessage;
final Widget list;
const AdminListState({
super.key,
required this.isLoading,
required this.error,
required this.isEmpty,
required this.emptyMessage,
required this.list,
});
@override
Widget build(BuildContext context) {
if (isLoading) {
return const Expanded(
child: Center(child: CircularProgressIndicator()),
);
}
if (error != null) {
return Expanded(
child: Center(
child: Text(
'Erreur: $error',
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
),
),
);
}
if (isEmpty) {
return Expanded(
child: Center(
child: Text(emptyMessage),
),
);
}
return Expanded(child: list);
}
}

View File

@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
class AdminUserCard extends StatefulWidget {
final String title;
final List<String> subtitleLines;
final String? avatarUrl;
final IconData fallbackIcon;
final List<Widget> actions;
const AdminUserCard({
super.key,
required this.title,
required this.subtitleLines,
this.avatarUrl,
this.fallbackIcon = Icons.person,
this.actions = const [],
});
@override
State<AdminUserCard> createState() => _AdminUserCardState();
}
class _AdminUserCardState extends State<AdminUserCard> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
final infoLine =
widget.subtitleLines.where((e) => e.trim().isNotEmpty).join(' ');
final actionsWidth =
widget.actions.isNotEmpty ? widget.actions.length * 30.0 : 0.0;
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: () {},
borderRadius: BorderRadius.circular(10),
hoverColor: const Color(0x149CC5C0),
child: Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: Colors.grey.shade300),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
child: Row(
children: [
CircleAvatar(
radius: 14,
backgroundColor: const Color(0xFFEDE5FA),
backgroundImage: widget.avatarUrl != null
? NetworkImage(widget.avatarUrl!)
: null,
child: widget.avatarUrl == null
? Icon(
widget.fallbackIcon,
size: 16,
color: const Color(0xFF6B3FA0),
)
: null,
),
const SizedBox(width: 10),
Expanded(
child: Row(
children: [
Flexible(
fit: FlexFit.loose,
child: Text(
widget.title.isNotEmpty ? widget.title : 'Sans nom',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
infoLine,
style: const TextStyle(
color: Colors.black54,
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
if (widget.actions.isNotEmpty)
SizedBox(
width: actionsWidth,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 120),
opacity: _isHovered ? 1 : 0,
child: IgnorePointer(
ignoring: !_isHovered,
child: IconTheme(
data: const IconThemeData(size: 17),
child: IconButtonTheme(
data: IconButtonThemeData(
style: IconButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.all(4),
minimumSize: const Size(28, 28),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: widget.actions,
),
),
),
),
),
),
],
),
),
),
),
),
);
}
}

View File

@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_list_state.dart';
class UserList extends StatelessWidget {
final bool isLoading;
final String? error;
final bool isEmpty;
final String emptyMessage;
final int itemCount;
final Widget Function(BuildContext context, int index) itemBuilder;
final EdgeInsetsGeometry padding;
const UserList({
super.key,
required this.isLoading,
required this.error,
required this.isEmpty,
required this.emptyMessage,
required this.itemCount,
required this.itemBuilder,
this.padding = const EdgeInsets.all(16),
});
@override
Widget build(BuildContext context) {
return Padding(
padding: padding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AdminListState(
isLoading: isLoading,
error: error,
isEmpty: isEmpty,
emptyMessage: emptyMessage,
list: ListView.builder(
itemCount: itemCount,
itemBuilder: itemBuilder,
),
),
],
),
);
}
}

View File

@ -3,7 +3,8 @@ import 'package:go_router/go_router.dart';
import 'package:p_tits_pas/services/auth_service.dart';
/// Barre du dashboard admin : onglets Gestion des utilisateurs | Paramètres + déconnexion.
class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidget {
class DashboardAppBarAdmin extends StatelessWidget
implements PreferredSizeWidget {
final int selectedIndex;
final ValueChanged<int> onTabChange;
final bool setupCompleted;
@ -36,7 +37,8 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildNavItem(context, 'Gestion des utilisateurs', 0, enabled: setupCompleted),
_buildNavItem(context, 'Gestion des utilisateurs', 0,
enabled: setupCompleted),
const SizedBox(width: 24),
_buildNavItem(context, 'Paramètres', 1, enabled: true),
],
@ -78,7 +80,8 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge
);
}
Widget _buildNavItem(BuildContext context, String title, int index, {bool enabled = true}) {
Widget _buildNavItem(BuildContext context, String title, int index,
{bool enabled = true}) {
final bool isActive = index == selectedIndex;
return InkWell(
onTap: enabled ? () => onTabChange(index) : null,
@ -133,11 +136,124 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge
class DashboardUserManagementSubBar extends StatelessWidget {
final int selectedSubIndex;
final ValueChanged<int> onSubTabChange;
final TextEditingController searchController;
final String searchHint;
final Widget? filterControl;
final VoidCallback? onAddPressed;
final String addLabel;
const DashboardUserManagementSubBar({
Key? key,
required this.selectedSubIndex,
required this.onSubTabChange,
required this.searchController,
required this.searchHint,
this.filterControl,
this.onAddPressed,
this.addLabel = '+ Ajouter',
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
height: 56,
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6),
child: Row(
children: [
_buildSubNavItem(context, 'Gestionnaires', 0),
const SizedBox(width: 12),
_buildSubNavItem(context, 'Parents', 1),
const SizedBox(width: 12),
_buildSubNavItem(context, 'Assistantes maternelles', 2),
const SizedBox(width: 12),
_buildSubNavItem(context, 'Administrateurs', 3),
const SizedBox(width: 36),
_pillField(
width: 320,
child: TextField(
controller: searchController,
decoration: InputDecoration(
hintText: searchHint,
prefixIcon: const Icon(Icons.search, size: 18),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
),
),
),
if (filterControl != null) ...[
const SizedBox(width: 12),
_pillField(width: 150, child: filterControl!),
],
const Spacer(),
_buildAddButton(),
],
),
);
}
Widget _pillField({required double width, required Widget child}) {
return Container(
width: width,
height: 34,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: Colors.black26),
),
alignment: Alignment.centerLeft,
child: child,
);
}
Widget _buildAddButton() {
return ElevatedButton.icon(
onPressed: onAddPressed,
icon: const Icon(Icons.add),
label: Text(addLabel),
);
}
Widget _buildSubNavItem(BuildContext context, String title, int index) {
final bool isActive = index == selectedSubIndex;
return InkWell(
onTap: () => onSubTabChange(index),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent,
borderRadius: BorderRadius.circular(16),
border: isActive ? null : Border.all(color: Colors.black26),
),
child: Text(
title,
style: TextStyle(
color: isActive ? Colors.white : Colors.black87,
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
fontSize: 13,
),
),
),
);
}
}
/// Sous-barre Paramètres : Paramètres généraux | Paramètres territoriaux.
class DashboardSettingsSubBar extends StatelessWidget {
final int selectedSubIndex;
final ValueChanged<int> onSubTabChange;
const DashboardSettingsSubBar({
Key? key,
required this.selectedSubIndex,
required this.onSubTabChange,
}) : super(key: key);
@override
@ -153,13 +269,9 @@ class DashboardUserManagementSubBar extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildSubNavItem(context, 'Gestionnaires', 0),
_buildSubNavItem(context, 'Paramètres généraux', 0),
const SizedBox(width: 16),
_buildSubNavItem(context, 'Parents', 1),
const SizedBox(width: 16),
_buildSubNavItem(context, 'Assistantes maternelles', 2),
const SizedBox(width: 16),
_buildSubNavItem(context, 'Administrateurs', 3),
_buildSubNavItem(context, 'Paramètres territoriaux', 1),
],
),
),

View File

@ -1,75 +0,0 @@
import 'package:flutter/material.dart';
class GestionnaireCard extends StatelessWidget {
final String name;
final String email;
const GestionnaireCard({
Key? key,
required this.name,
required this.email,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 🔹 Infos principales
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(name, style: const TextStyle(fontWeight: FontWeight.bold)),
Text(email, style: const TextStyle(color: Colors.grey)),
],
),
const SizedBox(height: 12),
// 🔹 Attribution à des RPE (dropdown fictif ici)
Row(
children: [
const Text("RPE attribué : "),
const SizedBox(width: 8),
DropdownButton<String>(
value: "RPE 1",
items: const [
DropdownMenuItem(value: "RPE 1", child: Text("RPE 1")),
DropdownMenuItem(value: "RPE 2", child: Text("RPE 2")),
DropdownMenuItem(value: "RPE 3", child: Text("RPE 3")),
],
onChanged: (value) {},
),
],
),
const SizedBox(height: 12),
// 🔹 Boutons d'action
Row(
children: [
TextButton.icon(
onPressed: () {
// Réinitialisation mot de passe
},
icon: const Icon(Icons.lock_reset),
label: const Text("Réinitialiser MDP"),
),
const SizedBox(width: 12),
TextButton.icon(
onPressed: () {
// Suppression du compte
},
icon: const Icon(Icons.delete, color: Colors.red),
label: const Text("Supprimer", style: TextStyle(color: Colors.red)),
),
],
)
],
),
),
);
}
}

View File

@ -1,10 +1,17 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/gestionnaire_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';
class GestionnaireManagementWidget extends StatefulWidget {
const GestionnaireManagementWidget({Key? key}) : super(key: key);
final String searchQuery;
const GestionnaireManagementWidget({
Key? key,
required this.searchQuery,
}) : super(key: key);
@override
State<GestionnaireManagementWidget> createState() =>
@ -16,21 +23,15 @@ class _GestionnaireManagementWidgetState
bool _isLoading = false;
String? _error;
List<AppUser> _gestionnaires = [];
List<AppUser> _filteredGestionnaires = [];
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
_loadGestionnaires();
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void dispose() => super.dispose();
Future<void> _loadGestionnaires() async {
setState(() {
@ -38,11 +39,10 @@ class _GestionnaireManagementWidgetState
_error = null;
});
try {
final list = await UserService.getGestionnaires();
final gestionnaires = await UserService.getGestionnaires();
if (!mounted) return;
setState(() {
_gestionnaires = list;
_filteredGestionnaires = list;
_gestionnaires = gestionnaires;
_isLoading = false;
});
} catch (e) {
@ -54,71 +54,55 @@ class _GestionnaireManagementWidgetState
}
}
void _onSearchChanged() {
final query = _searchController.text.toLowerCase();
setState(() {
_filteredGestionnaires = _gestionnaires.where((u) {
final name = u.fullName.toLowerCase();
final email = u.email.toLowerCase();
return name.contains(query) || email.contains(query);
}).toList();
});
Future<void> _openGestionnaireEditDialog(AppUser user) async {
final changed = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return GestionnaireCreateDialog(initialUser: user);
},
);
if (changed == true) {
await _loadGestionnaires();
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 🔹 Barre du haut avec bouton
Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
decoration: const InputDecoration(
hintText: "Rechercher un gestionnaire...",
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 16),
ElevatedButton.icon(
final query = widget.searchQuery.toLowerCase();
final filteredGestionnaires = _gestionnaires.where((u) {
final name = u.fullName.toLowerCase();
final email = u.email.toLowerCase();
return name.contains(query) || email.contains(query);
}).toList();
return UserList(
isLoading: _isLoading,
error: _error,
isEmpty: filteredGestionnaires.isEmpty,
emptyMessage: 'Aucun gestionnaire trouvé.',
itemCount: filteredGestionnaires.length,
itemBuilder: (context, index) {
final user = filteredGestionnaires[index];
return AdminUserCard(
title: user.fullName,
avatarUrl: user.photoUrl,
subtitleLines: [
user.email,
'Statut : ${user.statut ?? 'Inconnu'}',
'Relais : ${user.relaisNom ?? 'Non rattaché'}',
],
actions: [
IconButton(
icon: const Icon(Icons.edit),
tooltip: 'Modifier',
onPressed: () {
// TODO: Rediriger vers la page de création
_openGestionnaireEditDialog(user);
},
icon: const Icon(Icons.add),
label: const Text("Créer un gestionnaire"),
),
],
),
const SizedBox(height: 24),
// 🔹 Liste des gestionnaires
if (_isLoading)
const Center(child: CircularProgressIndicator())
else if (_error != null)
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
else if (_filteredGestionnaires.isEmpty)
const Center(child: Text("Aucun gestionnaire trouvé."))
else
Expanded(
child: ListView.builder(
itemCount: _filteredGestionnaires.length,
itemBuilder: (context, index) {
final user = _filteredGestionnaires[index];
return GestionnaireCard(
name: user.fullName.isNotEmpty ? user.fullName : "Sans nom",
email: user.email,
);
},
),
)
],
),
);
}
}

View File

@ -1,13 +1,19 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:p_tits_pas/services/configuration_service.dart';
import 'package:p_tits_pas/widgets/admin/relais_management_panel.dart';
/// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé.
class ParametresPanel extends StatefulWidget {
/// Si true, après sauvegarde on redirige vers le login (première config). Sinon on reste sur la page.
final bool redirectToLoginAfterSave;
final int selectedSettingsTabIndex;
const ParametresPanel({super.key, this.redirectToLoginAfterSave = false});
const ParametresPanel({
super.key,
this.redirectToLoginAfterSave = false,
this.selectedSettingsTabIndex = 0,
});
@override
State<ParametresPanel> createState() => _ParametresPanelState();
@ -33,10 +39,18 @@ class _ParametresPanelState extends State<ParametresPanel> {
void _createControllers() {
final keys = [
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_password',
'email_from_name', 'email_from_address',
'app_name', 'app_url', 'app_logo_url',
'password_reset_token_expiry_days', 'jwt_expiry_hours', 'max_upload_size_mb',
'smtp_host',
'smtp_port',
'smtp_user',
'smtp_password',
'email_from_name',
'email_from_address',
'app_name',
'app_url',
'app_logo_url',
'password_reset_token_expiry_days',
'jwt_expiry_hours',
'max_upload_size_mb',
];
for (final k in keys) {
_controllers[k] = TextEditingController();
@ -93,18 +107,29 @@ class _ParametresPanelState extends State<ParametresPanel> {
payload['smtp_auth_required'] = _smtpAuthRequired;
payload['smtp_user'] = _controllers['smtp_user']!.text.trim();
final pwd = _controllers['smtp_password']!.text.trim();
if (pwd.isNotEmpty && pwd != '***********') payload['smtp_password'] = pwd;
if (pwd.isNotEmpty && pwd != '***********') {
payload['smtp_password'] = pwd;
}
payload['email_from_name'] = _controllers['email_from_name']!.text.trim();
payload['email_from_address'] = _controllers['email_from_address']!.text.trim();
payload['email_from_address'] =
_controllers['email_from_address']!.text.trim();
payload['app_name'] = _controllers['app_name']!.text.trim();
payload['app_url'] = _controllers['app_url']!.text.trim();
payload['app_logo_url'] = _controllers['app_logo_url']!.text.trim();
final tokenDays = int.tryParse(_controllers['password_reset_token_expiry_days']!.text.trim());
if (tokenDays != null) payload['password_reset_token_expiry_days'] = tokenDays;
final jwtHours = int.tryParse(_controllers['jwt_expiry_hours']!.text.trim());
if (jwtHours != null) payload['jwt_expiry_hours'] = jwtHours;
final tokenDays = int.tryParse(
_controllers['password_reset_token_expiry_days']!.text.trim());
if (tokenDays != null) {
payload['password_reset_token_expiry_days'] = tokenDays;
}
final jwtHours =
int.tryParse(_controllers['jwt_expiry_hours']!.text.trim());
if (jwtHours != null) {
payload['jwt_expiry_hours'] = jwtHours;
}
final maxMb = int.tryParse(_controllers['max_upload_size_mb']!.text.trim());
if (maxMb != null) payload['max_upload_size_mb'] = maxMb;
if (maxMb != null) {
payload['max_upload_size_mb'] = maxMb;
}
return payload;
}
@ -191,6 +216,10 @@ class _ParametresPanelState extends State<ParametresPanel> {
@override
Widget build(BuildContext context) {
if (widget.selectedSettingsTabIndex == 1) {
return const RelaisManagementPanel();
}
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
@ -214,7 +243,8 @@ class _ParametresPanelState extends State<ParametresPanel> {
}
final isSuccess = _message != null &&
(_message!.startsWith('Configuration') || _message!.startsWith('Connexion'));
(_message!.startsWith('Configuration') ||
_message!.startsWith('Connexion'));
return Form(
key: _formKey,
@ -237,9 +267,18 @@ class _ParametresPanelState extends State<ParametresPanel> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildField('smtp_host', 'Serveur SMTP', hint: 'mail.example.com'),
_buildField(
'smtp_host',
'Serveur SMTP',
hint: 'mail.example.com',
),
const SizedBox(height: 14),
_buildField('smtp_port', 'Port SMTP', keyboard: TextInputType.number, hint: '25, 465, 587'),
_buildField(
'smtp_port',
'Port SMTP',
keyboard: TextInputType.number,
hint: '25, 465, 587',
),
const SizedBox(height: 14),
Padding(
padding: const EdgeInsets.only(bottom: 14),
@ -247,14 +286,17 @@ class _ParametresPanelState extends State<ParametresPanel> {
children: [
Checkbox(
value: _smtpSecure,
onChanged: (v) => setState(() => _smtpSecure = v ?? false),
onChanged: (v) =>
setState(() => _smtpSecure = v ?? false),
activeColor: const Color(0xFF9CC5C0),
),
const Text('SSL/TLS (secure)'),
const SizedBox(width: 24),
Checkbox(
value: _smtpAuthRequired,
onChanged: (v) => setState(() => _smtpAuthRequired = v ?? false),
onChanged: (v) => setState(
() => _smtpAuthRequired = v ?? false,
),
activeColor: const Color(0xFF9CC5C0),
),
const Text('Authentification requise'),
@ -263,11 +305,19 @@ class _ParametresPanelState extends State<ParametresPanel> {
),
_buildField('smtp_user', 'Utilisateur SMTP'),
const SizedBox(height: 14),
_buildField('smtp_password', 'Mot de passe SMTP', obscure: true),
_buildField(
'smtp_password',
'Mot de passe SMTP',
obscure: true,
),
const SizedBox(height: 14),
_buildField('email_from_name', 'Nom expéditeur'),
const SizedBox(height: 14),
_buildField('email_from_address', 'Email expéditeur', hint: 'no-reply@example.com'),
_buildField(
'email_from_address',
'Email expéditeur',
hint: 'no-reply@example.com',
),
const SizedBox(height: 18),
Align(
alignment: Alignment.centerRight,
@ -277,8 +327,13 @@ class _ParametresPanelState extends State<ParametresPanel> {
label: const Text('Tester la connexion SMTP'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF2D6A4F),
side: const BorderSide(color: Color(0xFF9CC5C0)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
side: const BorderSide(
color: Color(0xFF9CC5C0),
),
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
),
),
),
@ -295,9 +350,17 @@ class _ParametresPanelState extends State<ParametresPanel> {
children: [
_buildField('app_name', 'Nom de l\'application'),
const SizedBox(height: 14),
_buildField('app_url', 'URL de l\'application', hint: 'https://app.example.com'),
_buildField(
'app_url',
'URL de l\'application',
hint: 'https://app.example.com',
),
const SizedBox(height: 14),
_buildField('app_logo_url', 'URL du logo', hint: '/assets/logo.png'),
_buildField(
'app_logo_url',
'URL du logo',
hint: '/assets/logo.png',
),
],
),
),
@ -309,11 +372,23 @@ class _ParametresPanelState extends State<ParametresPanel> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildField('password_reset_token_expiry_days', 'Validité token MDP (jours)', keyboard: TextInputType.number),
_buildField(
'password_reset_token_expiry_days',
'Validité token MDP (jours)',
keyboard: TextInputType.number,
),
const SizedBox(height: 14),
_buildField('jwt_expiry_hours', 'Validité session JWT (heures)', keyboard: TextInputType.number),
_buildField(
'jwt_expiry_hours',
'Validité session JWT (heures)',
keyboard: TextInputType.number,
),
const SizedBox(height: 14),
_buildField('max_upload_size_mb', 'Taille max upload (MB)', keyboard: TextInputType.number),
_buildField(
'max_upload_size_mb',
'Taille max upload (MB)',
keyboard: TextInputType.number,
),
],
),
),
@ -327,7 +402,14 @@ class _ParametresPanelState extends State<ParametresPanel> {
foregroundColor: Colors.white,
),
child: _isSaving
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Sauvegarder la configuration'),
),
),
@ -339,7 +421,8 @@ class _ParametresPanelState extends State<ParametresPanel> {
);
}
Widget _buildSectionCard(BuildContext context, {required IconData icon, required String title, required Widget child}) {
Widget _buildSectionCard(BuildContext context,
{required IconData icon, required String title, required Widget child}) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
@ -369,7 +452,8 @@ class _ParametresPanelState extends State<ParametresPanel> {
);
}
Widget _buildField(String key, String label, {bool obscure = false, TextInputType? keyboard, String? hint}) {
Widget _buildField(String key, String label,
{bool obscure = false, TextInputType? keyboard, String? hint}) {
final c = _controllers[key];
if (c == null) return const SizedBox.shrink();
return TextFormField(
@ -381,7 +465,8 @@ class _ParametresPanelState extends State<ParametresPanel> {
labelText: label,
hintText: hint,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
),
);
}

View File

@ -1,9 +1,19 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
class ParentManagementWidget extends StatefulWidget {
const ParentManagementWidget({super.key});
final String searchQuery;
final String? statusFilter;
const ParentManagementWidget({
super.key,
required this.searchQuery,
this.statusFilter,
});
@override
State<ParentManagementWidget> createState() => _ParentManagementWidgetState();
@ -13,23 +23,15 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
bool _isLoading = false;
String? _error;
List<ParentModel> _parents = [];
List<ParentModel> _filteredParents = [];
final TextEditingController _searchController = TextEditingController();
String? _selectedStatus;
@override
void initState() {
super.initState();
_loadParents();
_searchController.addListener(_filter);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void dispose() => super.dispose();
Future<void> _loadParents() async {
setState(() {
@ -41,7 +43,6 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
if (!mounted) return;
setState(() {
_parents = list;
_filter(); // Apply initial filter (if any)
_isLoading = false;
});
} catch (e) {
@ -53,139 +54,101 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
}
}
void _filter() {
final query = _searchController.text.toLowerCase();
setState(() {
_filteredParents = _parents.where((p) {
final matchesName = p.user.fullName.toLowerCase().contains(query) ||
p.user.email.toLowerCase().contains(query);
final matchesStatus = _selectedStatus == null ||
_selectedStatus == 'Tous' ||
(p.user.statut?.toLowerCase() == _selectedStatus?.toLowerCase());
// Mapping simple pour le statut affiché vs backend
// Backend: en_attente, actif, suspendu
// Dropdown: En attente, Actif, Suspendu
return matchesName && matchesStatus;
}).toList();
});
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSearchSection(),
const SizedBox(height: 16),
if (_isLoading)
const Center(child: CircularProgressIndicator())
else if (_error != null)
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
else if (_filteredParents.isEmpty)
const Center(child: Text("Aucun parent trouvé."))
else
Expanded(
child: ListView.builder(
itemCount: _filteredParents.length,
final query = widget.searchQuery.toLowerCase();
final filteredParents = _parents.where((p) {
final matchesName = p.user.fullName.toLowerCase().contains(query) ||
p.user.email.toLowerCase().contains(query);
final matchesStatus =
widget.statusFilter == null || p.user.statut == widget.statusFilter;
return matchesName && matchesStatus;
}).toList();
return UserList(
isLoading: _isLoading,
error: _error,
isEmpty: filteredParents.isEmpty,
emptyMessage: 'Aucun parent trouvé.',
itemCount: filteredParents.length,
itemBuilder: (context, index) {
final parent = _filteredParents[index];
return Card(
margin: const EdgeInsets.symmetric(vertical: 8),
child: ListTile(
leading: CircleAvatar(
backgroundImage: parent.user.photoUrl != null
? NetworkImage(parent.user.photoUrl!)
: null,
child: parent.user.photoUrl == null
? const Icon(Icons.person)
: null,
),
title: Text(parent.user.fullName.isNotEmpty
? parent.user.fullName
: 'Sans nom'),
subtitle: Text(
"${parent.user.email}\nStatut : ${parent.user.statut ?? 'Inconnu'} | Enfants : ${parent.childrenCount}",
),
isThreeLine: true,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.visibility),
tooltip: "Voir dossier",
onPressed: () {
// TODO: Voir le statut du dossier
},
),
final parent = filteredParents[index];
return AdminUserCard(
title: parent.user.fullName,
avatarUrl: parent.user.photoUrl,
subtitleLines: [
parent.user.email,
'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.childrenCount}',
],
actions: [
IconButton(
icon: const Icon(Icons.edit),
tooltip: "Modifier",
tooltip: 'Modifier',
onPressed: () {
// TODO: Modifier parent
},
),
IconButton(
icon: const Icon(Icons.delete),
tooltip: "Supprimer",
onPressed: () {
// TODO: Supprimer compte
_openParentDetails(parent);
},
),
],
),
),
);
},
);
}
String _displayStatus(String? status) {
switch (status) {
case 'actif':
return 'Actif';
case 'en_attente':
return 'En attente';
case 'suspendu':
return 'Suspendu';
default:
return 'Inconnu';
}
}
void _openParentDetails(ParentModel parent) {
showDialog<void>(
context: context,
builder: (context) => AdminDetailModal(
title: parent.user.fullName.isEmpty ? 'Parent' : parent.user.fullName,
subtitle: parent.user.email,
fields: [
AdminDetailField(label: 'ID', value: _v(parent.user.id)),
AdminDetailField(
label: 'Statut',
value: _displayStatus(parent.user.statut),
),
AdminDetailField(
label: 'Telephone',
value: _v(parent.user.telephone),
),
AdminDetailField(label: 'Adresse', value: _v(parent.user.adresse)),
AdminDetailField(label: 'Ville', value: _v(parent.user.ville)),
AdminDetailField(
label: 'Code postal',
value: _v(parent.user.codePostal),
),
AdminDetailField(
label: 'Nombre d\'enfants',
value: parent.childrenCount.toString(),
),
],
onEdit: () {
Navigator.of(context).pop();
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Action Modifier a implementer')),
);
},
onDelete: () {
Navigator.of(context).pop();
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Action Supprimer a implementer')),
);
},
),
);
}
Widget _buildSearchSection() {
return Wrap(
spacing: 16,
runSpacing: 8,
children: [
SizedBox(
width: 220,
child: TextField(
controller: _searchController,
decoration: const InputDecoration(
labelText: "Nom du parent",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.search),
),
),
),
SizedBox(
width: 220,
child: DropdownButtonFormField<String>(
decoration: const InputDecoration(
labelText: "Statut",
border: OutlineInputBorder(),
),
value: _selectedStatus,
items: const [
DropdownMenuItem(value: null, child: Text("Tous")),
DropdownMenuItem(value: "actif", child: Text("Actif")),
DropdownMenuItem(value: "en_attente", child: Text("En attente")),
DropdownMenuItem(value: "suspendu", child: Text("Suspendu")),
],
onChanged: (value) {
setState(() {
_selectedStatus = value;
_filter();
});
},
),
),
],
);
}
String _v(String? value) => (value == null || value.isEmpty) ? '-' : value;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,206 @@
import 'package:flutter/material.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/assistante_maternelle_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart';
class AdminUserManagementPanel extends StatefulWidget {
const AdminUserManagementPanel({super.key});
@override
State<AdminUserManagementPanel> createState() =>
_AdminUserManagementPanelState();
}
class _AdminUserManagementPanelState extends State<AdminUserManagementPanel> {
int _subIndex = 0;
int _gestionnaireRefreshTick = 0;
final TextEditingController _searchController = TextEditingController();
final TextEditingController _amCapacityController = TextEditingController();
String? _parentStatus;
@override
void initState() {
super.initState();
_searchController.addListener(_onFilterChanged);
_amCapacityController.addListener(_onFilterChanged);
}
@override
void dispose() {
_searchController.removeListener(_onFilterChanged);
_amCapacityController.removeListener(_onFilterChanged);
_searchController.dispose();
_amCapacityController.dispose();
super.dispose();
}
void _onFilterChanged() {
if (!mounted) return;
setState(() {});
}
void _onSubTabChange(int index) {
setState(() {
_subIndex = index;
_searchController.clear();
_parentStatus = null;
_amCapacityController.clear();
});
}
String _searchHintForTab() {
switch (_subIndex) {
case 0:
return 'Rechercher un gestionnaire...';
case 1:
return 'Rechercher un parent...';
case 2:
return 'Rechercher une assistante...';
case 3:
return 'Rechercher un administrateur...';
default:
return 'Rechercher...';
}
}
Widget? _subBarFilterControl() {
if (_subIndex == 1) {
return DropdownButtonHideUnderline(
child: DropdownButton<String?>(
value: _parentStatus,
isExpanded: true,
hint: const Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Statut', style: TextStyle(fontSize: 12)),
),
items: const [
DropdownMenuItem<String?>(
value: null,
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Tous', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(
value: 'actif',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Actif', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(
value: 'en_attente',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('En attente', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(
value: 'suspendu',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Suspendu', style: TextStyle(fontSize: 12)),
),
),
],
onChanged: (value) {
setState(() {
_parentStatus = value;
});
},
),
);
}
if (_subIndex == 2) {
return TextField(
controller: _amCapacityController,
decoration: const InputDecoration(
hintText: 'Capacité min',
hintStyle: TextStyle(fontSize: 12),
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8),
),
keyboardType: TextInputType.number,
);
}
return null;
}
Widget _buildBody() {
switch (_subIndex) {
case 0:
return GestionnaireManagementWidget(
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
searchQuery: _searchController.text,
);
case 1:
return ParentManagementWidget(
searchQuery: _searchController.text,
statusFilter: _parentStatus,
);
case 2:
return AssistanteMaternelleManagementWidget(
searchQuery: _searchController.text,
capacityMin: int.tryParse(_amCapacityController.text),
);
case 3:
return AdminManagementWidget(
searchQuery: _searchController.text,
);
default:
return const Center(child: Text('Page non trouvée'));
}
}
@override
Widget build(BuildContext context) {
return Column(
children: [
DashboardUserManagementSubBar(
selectedSubIndex: _subIndex,
onSubTabChange: _onSubTabChange,
searchController: _searchController,
searchHint: _searchHintForTab(),
filterControl: _subBarFilterControl(),
onAddPressed: _handleAddPressed,
addLabel: 'Ajouter',
),
Expanded(child: _buildBody()),
],
);
}
Future<void> _handleAddPressed() async {
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>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return const GestionnaireCreateDialog();
},
);
if (!mounted) return;
if (created == true) {
setState(() {
_gestionnaireRefreshTick++;
});
}
}
}