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`). - 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,10 +37,12 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
if (!completed) mainTabIndex = 1; if (!completed) mainTabIndex = 1;
}); });
} catch (e) { } catch (e) {
if (mounted) setState(() { if (mounted) {
_setupCompleted = false; setState(() {
mainTabIndex = 1; _setupCompleted = false;
}); mainTabIndex = 1;
});
}
} }
} }
@ -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',
),
),
if (_isEditMode)
IconButton(
icon: const Icon(Icons.close),
tooltip: 'Fermer',
onPressed: _isSubmitting
? null
: () => Navigator.of(context).pop(false),
),
],
), ),
body: const Center( content: SizedBox(
child: Text('Formulaire de création de gestionnaire'), 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';
@ -33,14 +35,14 @@ class ApiConfig {
static const String conversations = '/conversations'; static const String conversations = '/conversations';
static const String notifications = '/notifications'; static const String notifications = '/notifications';
// Headers // Headers
static Map<String, String> get headers => { static Map<String, String> get headers => {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', 'Accept': 'application/json',
}; };
static Map<String, String> authHeaders(String token) => { static Map<String, String> authHeaders(String token) => {
...headers, ...headers,
'Authorization': 'Bearer $token', 'Authorization': 'Bearer $token',
}; };
} }

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() {
final query = _searchController.text.toLowerCase();
setState(() {
_filteredAdmins = _admins.where((u) {
final name = u.fullName.toLowerCase();
final email = u.email.toLowerCase();
return name.contains(query) || email.contains(query);
}).toList();
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( final query = widget.searchQuery.toLowerCase();
padding: const EdgeInsets.all(16), final filteredAdmins = _admins.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);
Row( }).toList();
children: [
Expanded( return UserList(
child: TextField( isLoading: _isLoading,
controller: _searchController, error: _error,
decoration: const InputDecoration( isEmpty: filteredAdmins.isEmpty,
hintText: "Rechercher un administrateur...", emptyMessage: 'Aucun administrateur trouvé.',
prefixIcon: Icon(Icons.search), itemCount: filteredAdmins.length,
border: OutlineInputBorder(), itemBuilder: (context, index) {
), final user = filteredAdmins[index];
), return AdminUserCard(
), title: user.fullName,
const SizedBox(width: 16), subtitleLines: [
ElevatedButton.icon( user.email,
onPressed: () { 'Rôle : ${user.role}',
// TODO: Créer admin ],
}, avatarUrl: user.photoUrl,
icon: const Icon(Icons.add), actions: [
label: const Text("Créer un admin"), IconButton(
), icon: const Icon(Icons.edit),
], tooltip: 'Modifier',
), onPressed: () {
const SizedBox(height: 24), // TODO: Modifier admin
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) {
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: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () {},
),
IconButton(
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))) itemBuilder: (context, index) {
else if (_filteredAssistantes.isEmpty) final assistante = filteredAssistantes[index];
const Center(child: Text("Aucune assistante maternelle trouvée.")) return AdminUserCard(
else title: assistante.user.fullName,
Expanded( avatarUrl: assistante.user.photoUrl,
child: ListView.builder( fallbackIcon: Icons.face,
itemCount: _filteredAssistantes.length, subtitleLines: [
itemBuilder: (context, index) { assistante.user.email,
final assistante = _filteredAssistantes[index]; 'Zone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}',
return Card( ],
margin: const EdgeInsets.symmetric(vertical: 8), actions: [
child: ListTile( IconButton(
leading: CircleAvatar( icon: const Icon(Icons.edit),
backgroundImage: assistante.user.photoUrl != null tooltip: 'Modifier',
? NetworkImage(assistante.user.photoUrl!) onPressed: () {
: null, _openAssistanteDetails(assistante);
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: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
// TODO: Ajouter modification
},
),
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é.")) itemBuilder: (context, index) {
else final user = filteredGestionnaires[index];
Expanded( return AdminUserCard(
child: ListView.builder( title: user.fullName,
itemCount: _filteredGestionnaires.length, avatarUrl: user.photoUrl,
itemBuilder: (context, index) { subtitleLines: [
final user = _filteredGestionnaires[index]; user.email,
return GestionnaireCard( 'Statut : ${user.statut ?? 'Inconnu'}',
name: user.fullName.isNotEmpty ? user.fullName : "Sans nom", 'Relais : ${user.relaisNom ?? 'Non rattaché'}',
email: user.email, ],
); 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,
@ -234,12 +264,21 @@ class _ParametresPanelState extends State<ParametresPanel> {
context, context,
icon: Icons.email_outlined, icon: Icons.email_outlined,
title: 'Configuration Email (SMTP)', title: 'Configuration Email (SMTP)',
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,
),
), ),
), ),
), ),
@ -290,14 +345,22 @@ class _ParametresPanelState extends State<ParametresPanel> {
context, context,
icon: Icons.palette_outlined, icon: Icons.palette_outlined,
title: 'Personnalisation', title: 'Personnalisation',
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
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( itemBuilder: (context, index) {
itemCount: _filteredParents.length, final parent = filteredParents[index];
itemBuilder: (context, index) { return AdminUserCard(
final parent = _filteredParents[index]; title: parent.user.fullName,
return Card( avatarUrl: parent.user.photoUrl,
margin: const EdgeInsets.symmetric(vertical: 8), subtitleLines: [
child: ListTile( parent.user.email,
leading: CircleAvatar( 'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.childrenCount}',
backgroundImage: parent.user.photoUrl != null ],
? NetworkImage(parent.user.photoUrl!) actions: [
: null, IconButton(
child: parent.user.photoUrl == null icon: const Icon(Icons.edit),
? const Icon(Icons.person) tooltip: 'Modifier',
: null, onPressed: () {
), _openParentDetails(parent);
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(
icon: const Icon(Icons.edit),
tooltip: "Modifier",
onPressed: () {
// TODO: Modifier 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++;
});
}
}
}