Compare commits

...

3 Commits

Author SHA1 Message Date
f9477d3fbe feat: livrer ticket #35 et synchroniser les évolutions admin
Intègre en un seul commit les évolutions de develop, avec la création/édition/suppression de gestionnaires via modale unifiée (#35) et les correctifs associés sur la gestion admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 00:20:33 +01:00
4d37131301 Merge branch 'master' of https://git.ptits-pas.fr/jmartin/petitspas 2026-02-24 00:08:30 +01:00
4b176b7083 feat: livrer ticket #93 et finaliser #17 avec gestion des Relais (#95)
Homogénéise le dashboard admin (onglets/listes/cartes/états) via composants réutilisables, finalise la création gestionnaire côté backend, et intègre la gestion des Relais avec rattachement gestionnaire.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-23 23:07:04 +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`). - 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. - 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. - 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? adresse;
final String? ville; final String? ville;
final String? codePostal; final String? codePostal;
final String? relaisId;
final String? relaisNom;
AppUser({ AppUser({
required this.id, required this.id,
@ -29,9 +31,15 @@ class AppUser {
this.adresse, this.adresse,
this.ville, this.ville,
this.codePostal, this.codePostal,
this.relaisId,
this.relaisNom,
}); });
factory AppUser.fromJson(Map<String, dynamic> json) { factory AppUser.fromJson(Map<String, dynamic> json) {
final relaisJson = json['relais'];
final relaisMap =
relaisJson is Map<String, dynamic> ? relaisJson : <String, dynamic>{};
return AppUser( return AppUser(
id: json['id'] as String, id: json['id'] as String,
email: json['email'] as String, email: json['email'] as String,
@ -56,6 +64,9 @@ class AppUser {
adresse: json['adresse'] as String?, adresse: json['adresse'] as String?,
ville: json['ville'] as String?, ville: json['ville'] as String?,
codePostal: json['code_postal'] 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, 'adresse': adresse,
'ville': ville, 'ville': ville,
'code_postal': codePostal, 'code_postal': codePostal,
'relais_id': relaisId,
'relais_nom': relaisNom,
}; };
} }

View File

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

View File

@ -1,17 +1,451 @@
import 'package:flutter/material.dart'; 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 { class GestionnaireCreateDialog extends StatefulWidget {
const GestionnairesCreate({super.key}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return AlertDialog(
appBar: AppBar( title: Row(
title: const Text('Créer un gestionnaire'), 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 gestionnaires = '/gestionnaires';
static const String parents = '/parents'; static const String parents = '/parents';
static const String assistantesMaternelles = '/assistantes-maternelles'; static const String assistantesMaternelles = '/assistantes-maternelles';
static const String relais = '/relais';
// Configuration (admin) // Configuration (admin)
static const String configuration = '/configuration'; static const String configuration = '/configuration';
static const String configurationSetupStatus = '/configuration/setup/status'; 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 configurationTestSmtp = '/configuration/test-smtp';
static const String configurationBulk = '/configuration/bulk'; 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) { if (response.statusCode != 200) {
final err = jsonDecode(response.body) as Map<String, dynamic>?; 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); final List<dynamic> data = jsonDecode(response.body);
return data.map((e) => AppUser.fromJson(e)).toList(); 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 // 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(
@ -53,7 +92,8 @@ class UserService {
} }
// Récupérer la liste des assistantes maternelles // 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( final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}'), Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}'),
headers: await _headers(), headers: await _headers(),
@ -87,8 +127,89 @@ class UserService {
.toList(); .toList();
} }
} catch (e) { } catch (e) {
print('Erreur chargement admins: $e'); // On garde un fallback vide pour ne pas bloquer l'UI admin.
} }
return []; 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:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.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/user_list.dart';
class AdminManagementWidget extends StatefulWidget { class AdminManagementWidget extends StatefulWidget {
const AdminManagementWidget({super.key}); final String searchQuery;
const AdminManagementWidget({
super.key,
required this.searchQuery,
});
@override @override
State<AdminManagementWidget> createState() => _AdminManagementWidgetState(); State<AdminManagementWidget> createState() => _AdminManagementWidgetState();
@ -13,21 +20,15 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
bool _isLoading = false; bool _isLoading = false;
String? _error; String? _error;
List<AppUser> _admins = []; List<AppUser> _admins = [];
List<AppUser> _filteredAdmins = [];
final TextEditingController _searchController = TextEditingController();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadAdmins(); _loadAdmins();
_searchController.addListener(_onSearchChanged);
} }
@override @override
void dispose() { void dispose() => super.dispose();
_searchController.dispose();
super.dispose();
}
Future<void> _loadAdmins() async { Future<void> _loadAdmins() async {
setState(() { setState(() {
@ -39,7 +40,6 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_admins = list; _admins = list;
_filteredAdmins = list;
_isLoading = false; _isLoading = false;
}); });
} catch (e) { } catch (e) {
@ -51,91 +51,41 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
} }
} }
void _onSearchChanged() { @override
final query = _searchController.text.toLowerCase(); Widget build(BuildContext context) {
setState(() { final query = widget.searchQuery.toLowerCase();
_filteredAdmins = _admins.where((u) { final filteredAdmins = _admins.where((u) {
final name = u.fullName.toLowerCase(); final name = u.fullName.toLowerCase();
final email = u.email.toLowerCase(); final email = u.email.toLowerCase();
return name.contains(query) || email.contains(query); return name.contains(query) || email.contains(query);
}).toList(); }).toList();
});
}
@override return UserList(
Widget build(BuildContext context) { isLoading: _isLoading,
return Padding( error: _error,
padding: const EdgeInsets.all(16), isEmpty: filteredAdmins.isEmpty,
child: Column( emptyMessage: 'Aucun administrateur trouvé.',
crossAxisAlignment: CrossAxisAlignment.stretch, itemCount: filteredAdmins.length,
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,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final user = _filteredAdmins[index]; final user = filteredAdmins[index];
return Card( return AdminUserCard(
margin: const EdgeInsets.only(bottom: 12), title: user.fullName,
child: ListTile( subtitleLines: [
leading: CircleAvatar( user.email,
child: Text(user.fullName.isNotEmpty 'Rôle : ${user.role}',
? user.fullName[0].toUpperCase() ],
: 'A'), avatarUrl: user.photoUrl,
), actions: [
title: Text(user.fullName.isNotEmpty
? user.fullName
: 'Sans nom'),
subtitle: Text(user.email),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton( IconButton(
icon: const Icon(Icons.edit), icon: const Icon(Icons.edit),
onPressed: () {}, tooltip: 'Modifier',
), onPressed: () {
IconButton( // TODO: Modifier admin
icon: const Icon(Icons.delete),
onPressed: () {},
),
],
),
),
);
}, },
), ),
)
], ],
), );
},
); );
} }
} }

View File

@ -1,9 +1,19 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.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/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 { class AssistanteMaternelleManagementWidget extends StatefulWidget {
const AssistanteMaternelleManagementWidget({super.key}); final String searchQuery;
final int? capacityMin;
const AssistanteMaternelleManagementWidget({
super.key,
required this.searchQuery,
this.capacityMin,
});
@override @override
State<AssistanteMaternelleManagementWidget> createState() => State<AssistanteMaternelleManagementWidget> createState() =>
@ -15,25 +25,15 @@ class _AssistanteMaternelleManagementWidgetState
bool _isLoading = false; bool _isLoading = false;
String? _error; String? _error;
List<AssistanteMaternelleModel> _assistantes = []; List<AssistanteMaternelleModel> _assistantes = [];
List<AssistanteMaternelleModel> _filteredAssistantes = [];
final TextEditingController _zoneController = TextEditingController();
final TextEditingController _capacityController = TextEditingController();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadAssistantes(); _loadAssistantes();
_zoneController.addListener(_filter);
_capacityController.addListener(_filter);
} }
@override @override
void dispose() { void dispose() => super.dispose();
_zoneController.dispose();
_capacityController.dispose();
super.dispose();
}
Future<void> _loadAssistantes() async { Future<void> _loadAssistantes() async {
setState(() { setState(() {
@ -45,7 +45,6 @@ class _AssistanteMaternelleManagementWidgetState
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_assistantes = list; _assistantes = list;
_filter();
_isLoading = false; _isLoading = false;
}); });
} catch (e) { } 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( final query = widget.searchQuery.toLowerCase();
padding: const EdgeInsets.all(16), final filteredAssistantes = _assistantes.where((am) {
child: Column( final matchesName = am.user.fullName.toLowerCase().contains(query) ||
crossAxisAlignment: CrossAxisAlignment.start, am.user.email.toLowerCase().contains(query) ||
children: [ (am.residenceCity?.toLowerCase().contains(query) ?? false);
// 🔎 Zone de filtre final matchesCapacity = widget.capacityMin == null ||
_buildFilterSection(), (am.maxChildren != null && am.maxChildren! >= widget.capacityMin!);
return matchesName && matchesCapacity;
}).toList();
const SizedBox(height: 16), return UserList(
isLoading: _isLoading,
// 📋 Liste des assistantes error: _error,
if (_isLoading) isEmpty: filteredAssistantes.isEmpty,
const Center(child: CircularProgressIndicator()) emptyMessage: 'Aucune assistante maternelle trouvée.',
else if (_error != null) itemCount: filteredAssistantes.length,
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,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final assistante = _filteredAssistantes[index]; final assistante = filteredAssistantes[index];
return Card( return AdminUserCard(
margin: const EdgeInsets.symmetric(vertical: 8), title: assistante.user.fullName,
child: ListTile( avatarUrl: assistante.user.photoUrl,
leading: CircleAvatar( fallbackIcon: Icons.face,
backgroundImage: assistante.user.photoUrl != null subtitleLines: [
? NetworkImage(assistante.user.photoUrl!) assistante.user.email,
: null, 'Zone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}',
child: assistante.user.photoUrl == null ],
? const Icon(Icons.face) actions: [
: 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: [
IconButton( IconButton(
icon: const Icon(Icons.edit), icon: const Icon(Icons.edit),
tooltip: 'Modifier',
onPressed: () { onPressed: () {
// TODO: Ajouter modification _openAssistanteDetails(assistante);
},
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
// TODO: Ajouter suppression
}, },
), ),
], ],
),
),
); );
}, },
);
}
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() { String _v(String? value) => (value == null || value.isEmpty) ? '-' : value;
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,
),
),
],
);
}
} }

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'; import 'package:p_tits_pas/services/auth_service.dart';
/// Barre du dashboard admin : onglets Gestion des utilisateurs | Paramètres + déconnexion. /// 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 int selectedIndex;
final ValueChanged<int> onTabChange; final ValueChanged<int> onTabChange;
final bool setupCompleted; final bool setupCompleted;
@ -36,7 +37,8 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_buildNavItem(context, 'Gestion des utilisateurs', 0, enabled: setupCompleted), _buildNavItem(context, 'Gestion des utilisateurs', 0,
enabled: setupCompleted),
const SizedBox(width: 24), const SizedBox(width: 24),
_buildNavItem(context, 'Paramètres', 1, enabled: true), _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; final bool isActive = index == selectedIndex;
return InkWell( return InkWell(
onTap: enabled ? () => onTabChange(index) : null, onTap: enabled ? () => onTabChange(index) : null,
@ -133,11 +136,124 @@ class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidge
class DashboardUserManagementSubBar extends StatelessWidget { class DashboardUserManagementSubBar extends StatelessWidget {
final int selectedSubIndex; final int selectedSubIndex;
final ValueChanged<int> onSubTabChange; final ValueChanged<int> onSubTabChange;
final TextEditingController searchController;
final String searchHint;
final Widget? filterControl;
final VoidCallback? onAddPressed;
final String addLabel;
const DashboardUserManagementSubBar({ const DashboardUserManagementSubBar({
Key? key, Key? key,
required this.selectedSubIndex, required this.selectedSubIndex,
required this.onSubTabChange, 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); }) : super(key: key);
@override @override
@ -153,13 +269,9 @@ class DashboardUserManagementSubBar extends StatelessWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_buildSubNavItem(context, 'Gestionnaires', 0), _buildSubNavItem(context, 'Paramètres généraux', 0),
const SizedBox(width: 16), const SizedBox(width: 16),
_buildSubNavItem(context, 'Parents', 1), _buildSubNavItem(context, 'Paramètres territoriaux', 1),
const SizedBox(width: 16),
_buildSubNavItem(context, 'Assistantes maternelles', 2),
const SizedBox(width: 16),
_buildSubNavItem(context, 'Administrateurs', 3),
], ],
), ),
), ),

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

View File

@ -1,13 +1,19 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:p_tits_pas/services/configuration_service.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é. /// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé.
class ParametresPanel extends StatefulWidget { class ParametresPanel extends StatefulWidget {
/// Si true, après sauvegarde on redirige vers le login (première config). Sinon on reste sur la page. /// Si true, après sauvegarde on redirige vers le login (première config). Sinon on reste sur la page.
final bool redirectToLoginAfterSave; final bool redirectToLoginAfterSave;
final int selectedSettingsTabIndex;
const ParametresPanel({super.key, this.redirectToLoginAfterSave = false}); const ParametresPanel({
super.key,
this.redirectToLoginAfterSave = false,
this.selectedSettingsTabIndex = 0,
});
@override @override
State<ParametresPanel> createState() => _ParametresPanelState(); State<ParametresPanel> createState() => _ParametresPanelState();
@ -33,10 +39,18 @@ class _ParametresPanelState extends State<ParametresPanel> {
void _createControllers() { void _createControllers() {
final keys = [ final keys = [
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_password', 'smtp_host',
'email_from_name', 'email_from_address', 'smtp_port',
'app_name', 'app_url', 'app_logo_url', 'smtp_user',
'password_reset_token_expiry_days', 'jwt_expiry_hours', 'max_upload_size_mb', '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) { for (final k in keys) {
_controllers[k] = TextEditingController(); _controllers[k] = TextEditingController();
@ -93,18 +107,29 @@ class _ParametresPanelState extends State<ParametresPanel> {
payload['smtp_auth_required'] = _smtpAuthRequired; payload['smtp_auth_required'] = _smtpAuthRequired;
payload['smtp_user'] = _controllers['smtp_user']!.text.trim(); payload['smtp_user'] = _controllers['smtp_user']!.text.trim();
final pwd = _controllers['smtp_password']!.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_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_name'] = _controllers['app_name']!.text.trim();
payload['app_url'] = _controllers['app_url']!.text.trim(); payload['app_url'] = _controllers['app_url']!.text.trim();
payload['app_logo_url'] = _controllers['app_logo_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()); final tokenDays = int.tryParse(
if (tokenDays != null) payload['password_reset_token_expiry_days'] = tokenDays; _controllers['password_reset_token_expiry_days']!.text.trim());
final jwtHours = int.tryParse(_controllers['jwt_expiry_hours']!.text.trim()); if (tokenDays != null) {
if (jwtHours != null) payload['jwt_expiry_hours'] = jwtHours; 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()); 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; return payload;
} }
@ -191,6 +216,10 @@ class _ParametresPanelState extends State<ParametresPanel> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (widget.selectedSettingsTabIndex == 1) {
return const RelaisManagementPanel();
}
if (_isLoading) { if (_isLoading) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
@ -214,7 +243,8 @@ class _ParametresPanelState extends State<ParametresPanel> {
} }
final isSuccess = _message != null && final isSuccess = _message != null &&
(_message!.startsWith('Configuration') || _message!.startsWith('Connexion')); (_message!.startsWith('Configuration') ||
_message!.startsWith('Connexion'));
return Form( return Form(
key: _formKey, key: _formKey,
@ -237,9 +267,18 @@ class _ParametresPanelState extends State<ParametresPanel> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildField('smtp_host', 'Serveur SMTP', hint: 'mail.example.com'), _buildField(
'smtp_host',
'Serveur SMTP',
hint: 'mail.example.com',
),
const SizedBox(height: 14), 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), const SizedBox(height: 14),
Padding( Padding(
padding: const EdgeInsets.only(bottom: 14), padding: const EdgeInsets.only(bottom: 14),
@ -247,14 +286,17 @@ class _ParametresPanelState extends State<ParametresPanel> {
children: [ children: [
Checkbox( Checkbox(
value: _smtpSecure, value: _smtpSecure,
onChanged: (v) => setState(() => _smtpSecure = v ?? false), onChanged: (v) =>
setState(() => _smtpSecure = v ?? false),
activeColor: const Color(0xFF9CC5C0), activeColor: const Color(0xFF9CC5C0),
), ),
const Text('SSL/TLS (secure)'), const Text('SSL/TLS (secure)'),
const SizedBox(width: 24), const SizedBox(width: 24),
Checkbox( Checkbox(
value: _smtpAuthRequired, value: _smtpAuthRequired,
onChanged: (v) => setState(() => _smtpAuthRequired = v ?? false), onChanged: (v) => setState(
() => _smtpAuthRequired = v ?? false,
),
activeColor: const Color(0xFF9CC5C0), activeColor: const Color(0xFF9CC5C0),
), ),
const Text('Authentification requise'), const Text('Authentification requise'),
@ -263,11 +305,19 @@ class _ParametresPanelState extends State<ParametresPanel> {
), ),
_buildField('smtp_user', 'Utilisateur SMTP'), _buildField('smtp_user', 'Utilisateur SMTP'),
const SizedBox(height: 14), 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), const SizedBox(height: 14),
_buildField('email_from_name', 'Nom expéditeur'), _buildField('email_from_name', 'Nom expéditeur'),
const SizedBox(height: 14), 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), const SizedBox(height: 18),
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
@ -277,8 +327,13 @@ class _ParametresPanelState extends State<ParametresPanel> {
label: const Text('Tester la connexion SMTP'), label: const Text('Tester la connexion SMTP'),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF2D6A4F), foregroundColor: const Color(0xFF2D6A4F),
side: const BorderSide(color: Color(0xFF9CC5C0)), side: const BorderSide(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), color: Color(0xFF9CC5C0),
),
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
), ),
), ),
), ),
@ -295,9 +350,17 @@ class _ParametresPanelState extends State<ParametresPanel> {
children: [ children: [
_buildField('app_name', 'Nom de l\'application'), _buildField('app_name', 'Nom de l\'application'),
const SizedBox(height: 14), 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), 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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), 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), 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, foregroundColor: Colors.white,
), ),
child: _isSaving 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'), : 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( return Card(
elevation: 2, elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), 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]; final c = _controllers[key];
if (c == null) return const SizedBox.shrink(); if (c == null) return const SizedBox.shrink();
return TextFormField( return TextFormField(
@ -381,7 +465,8 @@ class _ParametresPanelState extends State<ParametresPanel> {
labelText: label, labelText: label,
hintText: hint, hintText: hint,
border: const OutlineInputBorder(), 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:flutter/material.dart';
import 'package:p_tits_pas/models/parent_model.dart'; import 'package:p_tits_pas/models/parent_model.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_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 { class ParentManagementWidget extends StatefulWidget {
const ParentManagementWidget({super.key}); final String searchQuery;
final String? statusFilter;
const ParentManagementWidget({
super.key,
required this.searchQuery,
this.statusFilter,
});
@override @override
State<ParentManagementWidget> createState() => _ParentManagementWidgetState(); State<ParentManagementWidget> createState() => _ParentManagementWidgetState();
@ -13,23 +23,15 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
bool _isLoading = false; bool _isLoading = false;
String? _error; String? _error;
List<ParentModel> _parents = []; List<ParentModel> _parents = [];
List<ParentModel> _filteredParents = [];
final TextEditingController _searchController = TextEditingController();
String? _selectedStatus;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadParents(); _loadParents();
_searchController.addListener(_filter);
} }
@override @override
void dispose() { void dispose() => super.dispose();
_searchController.dispose();
super.dispose();
}
Future<void> _loadParents() async { Future<void> _loadParents() async {
setState(() { setState(() {
@ -41,7 +43,6 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_parents = list; _parents = list;
_filter(); // Apply initial filter (if any)
_isLoading = false; _isLoading = false;
}); });
} catch (e) { } 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( final query = widget.searchQuery.toLowerCase();
padding: const EdgeInsets.all(16), final filteredParents = _parents.where((p) {
child: Column( final matchesName = p.user.fullName.toLowerCase().contains(query) ||
crossAxisAlignment: CrossAxisAlignment.start, p.user.email.toLowerCase().contains(query);
children: [ final matchesStatus =
_buildSearchSection(), widget.statusFilter == null || p.user.statut == widget.statusFilter;
const SizedBox(height: 16), return matchesName && matchesStatus;
if (_isLoading) }).toList();
const Center(child: CircularProgressIndicator())
else if (_error != null) return UserList(
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red))) isLoading: _isLoading,
else if (_filteredParents.isEmpty) error: _error,
const Center(child: Text("Aucun parent trouvé.")) isEmpty: filteredParents.isEmpty,
else emptyMessage: 'Aucun parent trouvé.',
Expanded( itemCount: filteredParents.length,
child: ListView.builder(
itemCount: _filteredParents.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final parent = _filteredParents[index]; final parent = filteredParents[index];
return Card( return AdminUserCard(
margin: const EdgeInsets.symmetric(vertical: 8), title: parent.user.fullName,
child: ListTile( avatarUrl: parent.user.photoUrl,
leading: CircleAvatar( subtitleLines: [
backgroundImage: parent.user.photoUrl != null parent.user.email,
? NetworkImage(parent.user.photoUrl!) 'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.childrenCount}',
: null, ],
child: parent.user.photoUrl == null actions: [
? 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
},
),
IconButton( IconButton(
icon: const Icon(Icons.edit), icon: const Icon(Icons.edit),
tooltip: "Modifier", tooltip: 'Modifier',
onPressed: () { onPressed: () {
// TODO: Modifier parent _openParentDetails(parent);
},
),
IconButton(
icon: const Icon(Icons.delete),
tooltip: "Supprimer",
onPressed: () {
// TODO: Supprimer compte
}, },
), ),
], ],
),
),
); );
}, },
);
}
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() { String _v(String? value) => (value == null || value.isEmpty) ? '-' : value;
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();
});
},
),
),
],
);
}
} }

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++;
});
}
}
}