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>
This commit is contained in:
MARTIN Julien 2026-02-23 23:23:13 +01:00
parent fac3ae9baa
commit 42bb872c41
3 changed files with 314 additions and 11 deletions

View File

@ -1,17 +1,252 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/relais_model.dart';
import 'package:p_tits_pas/services/relais_service.dart';
import 'package:p_tits_pas/services/user_service.dart';
class GestionnairesCreate extends StatelessWidget {
const GestionnairesCreate({super.key});
class GestionnaireCreateDialog extends StatefulWidget {
const GestionnaireCreateDialog({super.key});
@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;
@override
void initState() {
super.initState();
_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;
setState(() {
_relais = list.where((r) => r.actif).toList();
_isLoadingRelais = false;
});
} catch (_) {
if (!mounted) return;
setState(() {
_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) {
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 {
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(
const SnackBar(content: Text('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;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Créer un gestionnaire'),
),
body: const Center(
child: Text('Formulaire de création de gestionnaire'),
return AlertDialog(
title: const Text('Créer un gestionnaire'),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640),
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _nomController,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Nom',
border: OutlineInputBorder(),
),
validator: (v) => _required(v, 'Nom'),
),
const SizedBox(height: 12),
TextFormField(
controller: _prenomController,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Prénom',
border: OutlineInputBorder(),
),
validator: (v) => _required(v, 'Prénom'),
),
const SizedBox(height: 12),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
validator: _validateEmail,
),
const SizedBox(height: 12),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Mot de passe',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
icon: Icon(
_obscurePassword
? Icons.visibility_off
: Icons.visibility,
),
),
),
validator: _validatePassword,
),
const SizedBox(height: 12),
TextFormField(
controller: _telephoneController,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: 'Téléphone',
border: OutlineInputBorder(),
),
validator: (v) => _required(v, 'Téléphone'),
),
const SizedBox(height: 12),
DropdownButtonFormField<String?>(
value: _selectedRelaisId,
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),
],
],
),
),
),
),
actions: [
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'),
),
],
);
}
}

View File

@ -37,6 +37,44 @@ class UserService {
return data.map((e) => AppUser.fromJson(e)).toList();
}
static Future<AppUser> createGestionnaire({
required String nom,
required String prenom,
required String email,
required String password,
required String telephone,
String? relaisId,
}) async {
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.gestionnaires}'),
headers: await _headers(),
body: jsonEncode(<String, dynamic>{
'nom': nom,
'prenom': prenom,
'email': email,
'password': password,
'telephone': telephone,
'cguAccepted': true,
'relaisId': relaisId,
}),
);
if (response.statusCode != 200 && response.statusCode != 201) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur création gestionnaire');
}
throw Exception('Erreur création gestionnaire');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return AppUser.fromJson(data);
}
// Récupérer la liste des parents
static Future<List<ParentModel>> getParents() async {
final response = await http.get(

View File

@ -1,4 +1,5 @@
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';
@ -15,6 +16,7 @@ class AdminUserManagementPanel extends StatefulWidget {
class _AdminUserManagementPanelState extends State<AdminUserManagementPanel> {
int _subIndex = 0;
int _gestionnaireRefreshTick = 0;
final TextEditingController _searchController = TextEditingController();
final TextEditingController _amCapacityController = TextEditingController();
String? _parentStatus;
@ -133,6 +135,7 @@ class _AdminUserManagementPanelState extends State<AdminUserManagementPanel> {
switch (_subIndex) {
case 0:
return GestionnaireManagementWidget(
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
searchQuery: _searchController.text,
);
case 1:
@ -164,13 +167,40 @@ class _AdminUserManagementPanelState extends State<AdminUserManagementPanel> {
searchController: _searchController,
searchHint: _searchHintForTab(),
filterControl: _subBarFilterControl(),
onAddPressed: () {
// TODO: brancher création selon onglet actif
},
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++;
});
}
}
}