From 471a62ddb7450a1b978a12ccd3304fe4ad0392b5 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Wed, 22 Jul 2026 19:07:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(#156):=20wizard=20admin=20cr=C3=A9ation=20?= =?UTF-8?q?dossier=20AM=20(create/review).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Généralise ValidationAmWizard en AmDossierWizard ; le + Asmat ouvre le mode création vers POST /assistantes-maternelles/dossier. Co-authored-by: Cursor --- frontend/lib/services/api/api_config.dart | 3 + frontend/lib/services/user_service.dart | 50 + .../admin/am_dossier_create_modal.dart | 91 ++ .../lib/widgets/admin/am_dossier_wizard.dart | 981 ++++++++++++++++++ .../widgets/admin/user_management_panel.dart | 23 +- .../widgets/admin/validation_am_wizard.dart | 494 +-------- 6 files changed, 1155 insertions(+), 487 deletions(-) create mode 100644 frontend/lib/widgets/admin/am_dossier_create_modal.dart create mode 100644 frontend/lib/widgets/admin/am_dossier_wizard.dart diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index 30508a4..75a9610 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -61,6 +61,9 @@ class ApiConfig { static const String gestionnaires = '/gestionnaires'; static const String parents = '/parents'; static const String assistantesMaternelles = '/assistantes-maternelles'; + /// Création dossier AM actif par le staff (#156) — body type register AM. + static const String assistantesMaternellesDossier = + '/assistantes-maternelles/dossier'; static const String enfants = '/enfants'; static const String relais = '/relais'; static const String dossiers = '/dossiers'; diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index 86f5cad..ce8f662 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -645,6 +645,56 @@ class UserService { return fallback; } + /// Création dossier AM actif côté staff (#156). + /// `POST /assistantes-maternelles/dossier` — body aligné sur register AM. + /// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur). + /// Ne pas utiliser `POST /auth/register/am` (public / en_attente). + static Future> createAmDossier( + Map body, + ) async { + final http.Response response; + try { + response = await http.post( + Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.assistantesMaternellesDossier}', + ), + headers: await _headers(), + body: jsonEncode(body), + ); + } on http.ClientException { + throw Exception( + 'Connexion au serveur impossible. Vérifiez votre réseau puis réessayez.', + ); + } + + if (response.statusCode == 200 || response.statusCode == 201) { + if (response.body.trim().isEmpty) return {}; + try { + final decoded = jsonDecode(response.body); + if (decoded is Map) { + return Map.from(decoded); + } + } catch (_) {} + return {}; + } + + final message = _extractErrorMessage( + response.body, + 'Erreur création dossier AM', + ); + if (response.statusCode == 409) { + throw Exception(message.isNotEmpty + ? message + : 'Conflit : e-mail, NIR ou agrément déjà utilisé.'); + } + if (response.statusCode == 400) { + throw Exception( + message.isNotEmpty ? message : 'Données invalides (400).', + ); + } + throw Exception(message); + } + // Récupérer la liste des assistantes maternelles static Future> getAssistantesMaternelles() async { diff --git a/frontend/lib/widgets/admin/am_dossier_create_modal.dart b/frontend/lib/widgets/admin/am_dossier_create_modal.dart new file mode 100644 index 0000000..412ec82 --- /dev/null +++ b/frontend/lib/widgets/admin/am_dossier_create_modal.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart'; + +/// Modale de création dossier AM (#156) — même shell que [ValidationDossierModal]. +class AmDossierCreateModal extends StatefulWidget { + final VoidCallback onClose; + final VoidCallback? onSuccess; + + const AmDossierCreateModal({ + super.key, + required this.onClose, + this.onSuccess, + }); + + @override + State createState() => _AmDossierCreateModalState(); +} + +class _AmDossierCreateModalState extends State { + int? _stepIndex; + int? _stepTotal; + + void _onStepChanged(int step, int total) { + if (!mounted) return; + setState(() { + _stepIndex = step; + _stepTotal = total; + }); + } + + void _onSuccess() { + widget.onSuccess?.call(); + } + + static const double _modalWidth = 930; + static const double _bodyHeight = 435; + + @override + Widget build(BuildContext context) { + final maxH = MediaQuery.of(context).size.height * 0.85; + final showStep = + _stepIndex != null && _stepTotal != null && (_stepTotal ?? 0) > 0; + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: _modalWidth, maxHeight: maxH), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(18, 18, 0, 12), + child: Text( + 'Nouvelle assistante maternelle', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700), + ), + ), + const Spacer(), + if (showStep) ...[ + Text( + 'Étape ${(_stepIndex ?? 0) + 1}/${_stepTotal ?? 1}', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Colors.black54, + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(width: 8), + ], + IconButton( + icon: const Icon(Icons.close), + onPressed: widget.onClose, + tooltip: 'Fermer', + ), + ], + ), + const Divider(height: 1), + SizedBox( + height: _bodyHeight, + child: AmDossierWizard.create( + onClose: widget.onClose, + onSuccess: _onSuccess, + onStepChanged: _onStepChanged, + ), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/am_dossier_wizard.dart b/frontend/lib/widgets/admin/am_dossier_wizard.dart new file mode 100644 index 0000000..01fcc4c --- /dev/null +++ b/frontend/lib/widgets/admin/am_dossier_wizard.dart @@ -0,0 +1,981 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:p_tits_pas/models/am_registration_data.dart'; +import 'package:p_tits_pas/models/dossier_unifie.dart'; +import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/utils/date_display_utils.dart'; +import 'package:p_tits_pas/utils/email_utils.dart'; +import 'package:p_tits_pas/utils/nir_utils.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; +import 'package:p_tits_pas/widgets/admin/validation_refus_form.dart'; +import 'package:p_tits_pas/widgets/admin/validation_valider_confirm_dialog.dart'; +import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; +import 'package:p_tits_pas/widgets/common/identity_block.dart'; + +enum AmDossierWizardMode { review, create } + +/// Wizard dossier AM — modes [review] (validation pending) et [create] (staff #156). +class AmDossierWizard extends StatefulWidget { + final AmDossierWizardMode mode; + final DossierAM? dossier; + final VoidCallback onClose; + final VoidCallback onSuccess; + final void Function(int step, int total)? onStepChanged; + + const AmDossierWizard._({ + super.key, + required this.mode, + this.dossier, + required this.onClose, + required this.onSuccess, + this.onStepChanged, + }); + + factory AmDossierWizard.review({ + Key? key, + required DossierAM dossier, + required VoidCallback onClose, + required VoidCallback onSuccess, + void Function(int step, int total)? onStepChanged, + }) { + return AmDossierWizard._( + key: key, + mode: AmDossierWizardMode.review, + dossier: dossier, + onClose: onClose, + onSuccess: onSuccess, + onStepChanged: onStepChanged, + ); + } + + factory AmDossierWizard.create({ + Key? key, + required VoidCallback onClose, + required VoidCallback onSuccess, + void Function(int step, int total)? onStepChanged, + }) { + return AmDossierWizard._( + key: key, + mode: AmDossierWizardMode.create, + onClose: onClose, + onSuccess: onSuccess, + onStepChanged: onStepChanged, + ); + } + + bool get isCreate => mode == AmDossierWizardMode.create; + + @override + State createState() => _AmDossierWizardState(); +} + +class _AmDossierWizardState extends State { + int _step = 0; + bool _showRefusForm = false; + bool _submitting = false; + + static const int _stepCount = 3; + static const List _photoProRowLayout = [2, 2, 2, 2]; + static const double _idPhotoAspectRatio = 35 / 45; + static const double _photoProGap = 24; + static const double _proColumnMinWidth = 260; + static const double _photoColumnMinWidth = 160; + + // --- Create mode controllers / state --- + late final TextEditingController _nomCtrl; + late final TextEditingController _prenomCtrl; + late final TextEditingController _telCtrl; + late final TextEditingController _emailCtrl; + late final TextEditingController _adresseCtrl; + late final TextEditingController _cpCtrl; + late final TextEditingController _villeCtrl; + late final TextEditingController _nirCtrl; + late final TextEditingController _dateNaissanceCtrl; + late final TextEditingController _lieuNaissanceVilleCtrl; + late final TextEditingController _lieuNaissancePaysCtrl; + late final TextEditingController _agrementCtrl; + late final TextEditingController _dateAgrementCtrl; + late final TextEditingController _capaciteCtrl; + late final TextEditingController _placesCtrl; + late final TextEditingController _presentationCtrl; + + Uint8List? _photoBytes; + String? _photoFilename; + + bool get _isCreate => widget.isCreate; + DossierAM get _dossier => widget.dossier!; + bool get _isEnAttente => + !_isCreate && _dossier.user.statut == 'en_attente'; + + static String _v(String? s) => + (s != null && s.trim().isNotEmpty) ? s.trim() : '–'; + + static String _formatNirForDisplay(String? nir) { + final v = _v(nir); + if (v == '–') return v; + final raw = nirToRaw(v).toUpperCase(); + return raw.length == 15 ? formatNir(raw) : v; + } + + @override + void initState() { + super.initState(); + _nomCtrl = TextEditingController(); + _prenomCtrl = TextEditingController(); + _telCtrl = TextEditingController(); + _emailCtrl = TextEditingController(); + _adresseCtrl = TextEditingController(); + _cpCtrl = TextEditingController(); + _villeCtrl = TextEditingController(); + _nirCtrl = TextEditingController(); + _dateNaissanceCtrl = TextEditingController(); + _lieuNaissanceVilleCtrl = TextEditingController(); + _lieuNaissancePaysCtrl = TextEditingController(text: 'France'); + _agrementCtrl = TextEditingController(); + _dateAgrementCtrl = TextEditingController(); + _capaciteCtrl = TextEditingController(text: '1'); + _placesCtrl = TextEditingController(text: '1'); + _presentationCtrl = TextEditingController(); + WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep()); + } + + @override + void dispose() { + _nomCtrl.dispose(); + _prenomCtrl.dispose(); + _telCtrl.dispose(); + _emailCtrl.dispose(); + _adresseCtrl.dispose(); + _cpCtrl.dispose(); + _villeCtrl.dispose(); + _nirCtrl.dispose(); + _dateNaissanceCtrl.dispose(); + _lieuNaissanceVilleCtrl.dispose(); + _lieuNaissancePaysCtrl.dispose(); + _agrementCtrl.dispose(); + _dateAgrementCtrl.dispose(); + _capaciteCtrl.dispose(); + _placesCtrl.dispose(); + _presentationCtrl.dispose(); + super.dispose(); + } + + void _emitStep() => widget.onStepChanged?.call(_step, _stepCount); + + List _photoProFields(DossierAM d) { + final u = d.user; + return [ + AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)), + AdminDetailField( + label: 'Date de naissance', + value: formatIsoDateFr(u.dateNaissance), + ), + AdminDetailField( + label: 'Ville de naissance', + value: _v(u.lieuNaissanceVille), + ), + AdminDetailField( + label: 'Pays de naissance', + value: _v(u.lieuNaissancePays), + ), + AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)), + AdminDetailField( + label: 'Date d’agrément', + value: formatIsoDateFr(d.dateAgrement), + ), + AdminDetailField( + label: 'Capacité max (enfants)', + value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '–', + ), + AdminDetailField( + label: 'Places disponibles', + value: d.placesDisponibles != null + ? d.placesDisponibles.toString() + : '–', + ), + ]; + } + + static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url); + + Widget _buildPhotoSectionReview(AppUser u) { + final photoUrl = _fullPhotoUrl(u.photoUrl); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: LayoutBuilder( + builder: (context, c) { + const uniformFrame = 8.0; + final maxPhotoW = + (c.maxWidth - 2 * uniformFrame).clamp(0.0, double.infinity); + final maxPhotoH = + (c.maxHeight - 2 * uniformFrame).clamp(0.0, double.infinity); + const ar = _idPhotoAspectRatio; + double ph = maxPhotoH; + double pw = ph * ar; + if (pw > maxPhotoW) { + pw = maxPhotoW; + ph = pw / ar; + } + return Align( + alignment: Alignment.center, + child: Container( + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(uniformFrame), + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SizedBox( + width: pw, + height: ph, + child: photoUrl.isEmpty + ? ColoredBox( + color: Colors.grey.shade200, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.person_off_outlined, + size: 40, + color: Colors.grey.shade400), + const SizedBox(height: 8), + Text( + 'Aucune photo fournie', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 12), + ), + ], + ), + ) + : AuthNetworkImage( + url: photoUrl, + fit: BoxFit.cover, + width: pw, + height: ph, + loadingBuilder: (_, child, progress) { + if (progress == null) return child; + return ColoredBox( + color: Colors.grey.shade200, + child: Center( + child: CircularProgressIndicator( + value: progress.expectedTotalBytes != + null + ? progress.cumulativeBytesLoaded / + (progress.expectedTotalBytes!) + : null, + ), + ), + ); + }, + errorBuilder: (_, __, ___) => ColoredBox( + color: Colors.grey.shade200, + child: Column( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Icon(Icons.broken_image_outlined, + size: 40, + color: Colors.grey.shade400), + const SizedBox(height: 8), + Text( + 'Impossible de charger la photo', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 12), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + }, + ), + ), + ), + ], + ); + } + + Future _pickPhoto() async { + try { + final picked = await ImagePicker().pickImage( + source: ImageSource.gallery, + maxWidth: 1200, + imageQuality: 85, + ); + if (picked == null) return; + final bytes = await picked.readAsBytes(); + if (!mounted) return; + setState(() { + _photoBytes = bytes; + _photoFilename = picked.name; + }); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Impossible de charger la photo'), + backgroundColor: Colors.red.shade700, + ), + ); + } + } + + void _clearPhoto() => setState(() { + _photoBytes = null; + _photoFilename = null; + }); + + String? _validateStep0() { + final nom = _nomCtrl.text.trim(); + final prenom = _prenomCtrl.text.trim(); + if (nom.length < 2) return 'Le nom doit contenir au moins 2 caractères.'; + if (prenom.length < 2) { + return 'Le prénom doit contenir au moins 2 caractères.'; + } + final phoneErr = validateFrenchNationalPhone(_telCtrl.text); + if (phoneErr != null) return phoneErr; + final emailErr = validateEmail(_emailCtrl.text); + if (emailErr != null) return emailErr; + if (_adresseCtrl.text.trim().isEmpty) { + return 'L’adresse est requise.'; + } + if (_cpCtrl.text.trim().isEmpty) return 'Le code postal est requis.'; + if (_villeCtrl.text.trim().isEmpty) return 'La ville est requise.'; + return null; + } + + String? _validateStep1() { + if (_photoBytes == null || _photoBytes!.isEmpty) { + return 'Une photo de profil est requise.'; + } + final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text); + if (birthIso == null) { + return 'La date de naissance est invalide (jj/mm/aaaa).'; + } + final ville = _lieuNaissanceVilleCtrl.text.trim(); + final pays = _lieuNaissancePaysCtrl.text.trim(); + if (ville.length < 2 || pays.length < 2) { + return 'La ville et le pays de naissance sont requis (au moins 2 caractères).'; + } + final nirErr = validateNir(_nirCtrl.text); + if (nirErr != null) return nirErr; + if (_agrementCtrl.text.trim().isEmpty) { + return 'Le numéro d’agrément est requis.'; + } + final agrementIso = parseFrDateToIso(_dateAgrementCtrl.text); + if (agrementIso == null) { + return 'La date d’agrément est invalide (jj/mm/aaaa).'; + } + final capa = int.tryParse(_capaciteCtrl.text.trim()); + if (capa == null || capa < 1 || capa > kAmCapaciteAccueilMax) { + return 'La capacité doit être entre 1 et $kAmCapaciteAccueilMax.'; + } + final places = int.tryParse(_placesCtrl.text.trim()); + if (places == null || places < 0 || places > capa) { + return 'Les places disponibles doivent être entre 0 et la capacité.'; + } + return null; + } + + String? _validateCurrentStep() { + if (!_isCreate) return null; + switch (_step) { + case 0: + return _validateStep0(); + case 1: + return _validateStep1(); + default: + return null; + } + } + + void _goNext() { + final err = _validateCurrentStep(); + if (err != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(err), backgroundColor: Colors.red.shade700), + ); + return; + } + setState(() => _step++); + _emitStep(); + } + + static String _imageMimeForBytes(Uint8List bytes) { + if (bytes.length >= 8 && + bytes[0] == 0x89 && + bytes[1] == 0x50 && + bytes[2] == 0x4E && + bytes[3] == 0x47) { + return 'image/png'; + } + if (bytes.length >= 3 && + bytes[0] == 0xFF && + bytes[1] == 0xD8 && + bytes[2] == 0xFF) { + return 'image/jpeg'; + } + if (bytes.length >= 6 && + bytes[0] == 0x47 && + bytes[1] == 0x49 && + bytes[2] == 0x46) { + return 'image/gif'; + } + if (bytes.length >= 12 && + bytes[0] == 0x52 && + bytes[1] == 0x49 && + bytes[2] == 0x46 && + bytes[3] == 0x46) { + return 'image/webp'; + } + return 'image/jpeg'; + } + + Map _buildCreateBody() { + final bytes = _photoBytes!; + final mime = _imageMimeForBytes(bytes); + final photoBase64 = 'data:$mime;base64,${base64Encode(bytes)}'; + final fn = (_photoFilename ?? '').trim(); + final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text)!; + final agrementIso = parseFrDateToIso(_dateAgrementCtrl.text)!; + final capa = int.parse(_capaciteCtrl.text.trim()); + final places = int.parse(_placesCtrl.text.trim()); + final presentation = _presentationCtrl.text.trim(); + + return { + 'email': normalizeEmailText(_emailCtrl.text), + 'prenom': _prenomCtrl.text.trim(), + 'nom': _nomCtrl.text.trim(), + 'telephone': normalizePhone(_telCtrl.text), + 'adresse': + _adresseCtrl.text.trim().isNotEmpty ? _adresseCtrl.text.trim() : null, + 'code_postal': + _cpCtrl.text.trim().isNotEmpty ? _cpCtrl.text.trim() : null, + 'ville': + _villeCtrl.text.trim().isNotEmpty ? _villeCtrl.text.trim() : null, + 'photo_base64': photoBase64, + 'photo_filename': fn.isNotEmpty ? fn : 'photo_am.jpg', + 'consentement_photo': true, + 'date_naissance': birthIso, + 'lieu_naissance_ville': _lieuNaissanceVilleCtrl.text.trim(), + 'lieu_naissance_pays': _lieuNaissancePaysCtrl.text.trim(), + 'nir': normalizeNir(_nirCtrl.text), + 'numero_agrement': _agrementCtrl.text.trim(), + 'date_agrement': agrementIso, + 'capacite_accueil': capa, + 'places_disponibles': places, + 'biographie': presentation.isNotEmpty ? presentation : null, + 'acceptation_cgu': true, + 'acceptation_privacy': true, + }; + } + + Future _createAndValidate() async { + if (_submitting) return; + final err0 = _validateStep0(); + if (err0 != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(err0), backgroundColor: Colors.red.shade700), + ); + return; + } + final err1 = _validateStep1(); + if (err1 != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(err1), backgroundColor: Colors.red.shade700), + ); + return; + } + + final ok = await showValidationValiderConfirmDialog( + context, + body: + 'Créer et activer le dossier de cette assistante maternelle ? Un e-mail de création de mot de passe sera envoyé.', + ); + if (!mounted || !ok) return; + + setState(() => _submitting = true); + try { + await UserService.createAmDossier(_buildCreateBody()); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Dossier créé et validé. L’assistante maternelle est active.', + ), + duration: Duration(seconds: 4), + ), + ); + widget.onSuccess(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur'), + backgroundColor: Colors.red.shade700, + ), + ); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + @override + Widget build(BuildContext context) { + if (_showRefusForm) { + return _buildRefusPage(); + } + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 4), + Expanded(child: _buildStepContent()), + const SizedBox(height: 24), + _buildNavigation(), + ], + ), + ); + } + + Widget _buildStepContent() { + switch (_step) { + case 0: + return _buildStep0(); + case 1: + return _buildStep1(); + case 2: + return _buildStep2(); + default: + return const SizedBox(); + } + } + + Widget _buildStep0() { + if (_isCreate) { + return LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + child: IdentityBlock.editable( + title: 'Identité et coordonnées', + nomController: _nomCtrl, + prenomController: _prenomCtrl, + telephoneController: _telCtrl, + emailController: _emailCtrl, + adresseController: _adresseCtrl, + codePostalController: _cpCtrl, + villeController: _villeCtrl, + ), + ), + ); + }, + ); + } + final u = _dossier.user; + return LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + child: IdentityBlock.readOnlyFromUser( + u, + title: 'Identité et coordonnées', + emptyLabel: '–', + ), + ), + ); + }, + ); + } + + Widget _buildStep1() { + return LayoutBuilder( + builder: (context, c) { + final maxRowW = c.maxWidth; + final maxRowH = c.maxHeight; + const photoHeaderH = 0.0; + final bodyH = (maxRowH - photoHeaderH).clamp(0.0, double.infinity); + final idealPhotoW = bodyH * _idPhotoAspectRatio + 16; + final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth) + .clamp(0.0, double.infinity); + var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 360.0); + if (photoW > maxPhotoW) photoW = maxPhotoW; + photoW = photoW.clamp(0.0, maxRowW - _photoProGap); + + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: photoW, + child: _isCreate + ? AdminAmPhotoFrame( + imageBytes: _photoBytes, + onTap: _pickPhoto, + onClear: _photoBytes != null ? _clearPhoto : null, + emptyLabel: 'Ajouter une photo', + ) + : _buildPhotoSectionReview(_dossier.user), + ), + const SizedBox(width: _photoProGap), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: + BoxConstraints(minWidth: constraints.maxWidth), + child: _isCreate + ? _buildCreateProFields() + : ValidationDetailSection( + title: 'Dossier professionnel', + fields: _photoProFields(_dossier), + rowLayout: _photoProRowLayout, + ), + ), + ); + }, + ), + ), + ], + ); + }, + ); + } + + Widget _buildCreateProFields() { + return ValidationFormGrid( + title: 'Dossier professionnel', + rowLayout: _photoProRowLayout, + fields: [ + ValidationLabeledField( + label: 'NIR', + field: ValidationEditableField( + controller: _nirCtrl, + hintText: '15 caractères', + inputFormatters: [NirInputFormatter()], + ), + ), + ValidationLabeledField( + label: 'Date de naissance', + field: ValidationEditableField( + controller: _dateNaissanceCtrl, + hintText: 'jj / mm / aaaa', + keyboardType: TextInputType.number, + inputFormatters: const [FrenchDateInputFormatter()], + ), + ), + ValidationLabeledField( + label: 'Ville de naissance', + field: ValidationEditableField( + controller: _lieuNaissanceVilleCtrl, + ), + ), + ValidationLabeledField( + label: 'Pays de naissance', + field: ValidationEditableField( + controller: _lieuNaissancePaysCtrl, + ), + ), + ValidationLabeledField( + label: 'N° Agrément', + field: ValidationEditableField(controller: _agrementCtrl), + ), + ValidationLabeledField( + label: 'Date d’agrément', + field: ValidationEditableField( + controller: _dateAgrementCtrl, + hintText: 'jj / mm / aaaa', + keyboardType: TextInputType.number, + inputFormatters: const [FrenchDateInputFormatter()], + ), + ), + ValidationLabeledField( + label: 'Capacité max (enfants)', + field: ValidationEditableField( + controller: _capaciteCtrl, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + ), + ValidationLabeledField( + label: 'Places disponibles', + field: ValidationEditableField( + controller: _placesCtrl, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + ), + ], + ); + } + + Widget _buildStep2() { + if (_isCreate) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Présentation', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const SizedBox(height: 12), + Expanded( + child: TextField( + controller: _presentationCtrl, + maxLines: null, + expands: true, + maxLength: 2000, + textAlignVertical: TextAlignVertical.top, + decoration: InputDecoration( + hintText: 'Présentation (optionnel, max 2000 caractères)', + filled: true, + fillColor: Colors.grey.shade50, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + ), + ), + ], + ); + } + + final presentation = + (_dossier.presentation != null && _dossier.presentation!.trim().isNotEmpty) + ? _dossier.presentation! + : '–'; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Présentation', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const SizedBox(height: 12), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.grey.shade300), + ), + child: SelectableText( + presentation, + style: const TextStyle(color: Colors.black87, fontSize: 14), + ), + ), + ), + ); + }, + ), + ), + ], + ); + } + + Widget _buildNavigation() { + if (_step == 2) { + return Row( + children: [ + TextButton(onPressed: widget.onClose, child: const Text('Annuler')), + const Spacer(), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: () { + setState(() => _step = 1); + _emitStep(); + }, + child: const Text('Précédent'), + ), + const SizedBox(width: 8), + if (_isCreate) ...[ + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: _submitting ? null : _createAndValidate, + child: Text(_submitting ? 'Envoi...' : 'Créer et valider'), + ), + ] else if (_isEnAttente) ...[ + OutlinedButton( + onPressed: _submitting ? null : _refuser, + child: const Text('Refuser')), + const SizedBox(width: 12), + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: _submitting ? null : _onValiderPressed, + child: Text(_submitting ? 'Envoi...' : 'Valider'), + ), + ] else + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: widget.onClose, + child: const Text('Fermer'), + ), + ], + ), + ], + ); + } + return Row( + children: [ + TextButton(onPressed: widget.onClose, child: const Text('Annuler')), + const Spacer(), + if (_step > 0) ...[ + TextButton( + onPressed: () { + setState(() => _step--); + _emitStep(); + }, + child: const Text('Précédent'), + ), + const SizedBox(width: 8), + ], + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: _goNext, + child: const Text('Suivant'), + ), + ], + ); + } + + Future _onValiderPressed() async { + if (_submitting) return; + final ok = await showValidationValiderConfirmDialog( + context, + body: + 'Voulez-vous valider le dossier de cette assistante maternelle ? Cette action confirme le compte.', + ); + if (!mounted || !ok) return; + await _valider(); + } + + Future _valider() async { + if (_submitting) return; + setState(() => _submitting = true); + try { + await UserService.validateUser(_dossier.user.id); + if (!mounted) return; + widget.onSuccess(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur'), + backgroundColor: Colors.red.shade700, + ), + ); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + void _refuser() => setState(() => _showRefusForm = true); + + Widget _buildRefusPage() { + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: ValidationRefusForm( + isSubmitting: _submitting, + onCancel: widget.onClose, + onPrevious: () => setState(() => _showRefusForm = false), + onSubmit: (comment) { + if (comment == null || comment.trim().isEmpty) return; + _refuserEnvoyer(comment.trim()); + }, + ), + ), + ], + ), + ); + } + + Future _refuserEnvoyer(String comment) async { + if (_submitting) return; + setState(() => _submitting = true); + try { + await UserService.refuseUser(_dossier.user.id, comment: comment); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.', + ), + duration: Duration(seconds: 4), + ), + ); + widget.onSuccess(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur'), + backgroundColor: Colors.red.shade700, + ), + ); + } finally { + if (mounted) setState(() => _submitting = false); + } + } +} diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index 3abcb4c..d13eca7 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart'; +import 'package:p_tits_pas/widgets/admin/am_dossier_create_modal.dart'; import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; @@ -28,6 +29,7 @@ class _UserManagementPanelState extends State { int _gestionnaireRefreshTick = 0; int _adminRefreshTick = 0; int _enfantRefreshTick = 0; + int _amRefreshTick = 0; final TextEditingController _searchController = TextEditingController(); final TextEditingController _amCapacityController = TextEditingController(); String? _parentStatus; @@ -272,6 +274,7 @@ class _UserManagementPanelState extends State { ); case 2: return AssistanteMaternelleManagementWidget( + key: ValueKey('ams-$_amRefreshTick'), searchQuery: _searchController.text, capacityMin: int.tryParse(_amCapacityController.text), ); @@ -331,6 +334,24 @@ class _UserManagementPanelState extends State { return; } + if (contentIndex == 2) { + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return AmDossierCreateModal( + onClose: () => Navigator.of(dialogContext).pop(), + onSuccess: () { + Navigator.of(dialogContext).pop(); + if (!mounted) return; + setState(() => _amRefreshTick++); + }, + ); + }, + ); + return; + } + if (contentIndex == 3) { final created = await showDialog( context: context, @@ -374,7 +395,7 @@ class _UserManagementPanelState extends State { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( - 'La création parent / enfant / AM sera disponible avec le ticket #129.', + 'La création parent sera disponible avec le ticket #129.', ), ), ); diff --git a/frontend/lib/widgets/admin/validation_am_wizard.dart b/frontend/lib/widgets/admin/validation_am_wizard.dart index 5ea035a..0edc9ac 100644 --- a/frontend/lib/widgets/admin/validation_am_wizard.dart +++ b/frontend/lib/widgets/admin/validation_am_wizard.dart @@ -1,20 +1,9 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart'; -import 'package:p_tits_pas/utils/date_display_utils.dart'; -import 'package:p_tits_pas/utils/nir_utils.dart'; -import 'package:p_tits_pas/models/user.dart'; -import 'package:p_tits_pas/services/user_service.dart'; -import 'package:p_tits_pas/services/api/api_config.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; -import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; -import 'package:p_tits_pas/widgets/common/identity_block.dart'; -import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; -import 'validation_modal_theme.dart'; -import 'validation_refus_form.dart'; -import 'validation_valider_confirm_dialog.dart'; +import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart'; -/// Wizard de validation dossier AM : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107. -class ValidationAmWizard extends StatefulWidget { +/// Wrapper historique (#107) — délègue à [AmDossierWizard.review]. +class ValidationAmWizard extends StatelessWidget { final DossierAM dossier; final VoidCallback onClose; final VoidCallback onSuccess; @@ -28,480 +17,13 @@ class ValidationAmWizard extends StatefulWidget { this.onStepChanged, }); - @override - State createState() => _ValidationAmWizardState(); -} - -class _ValidationAmWizardState extends State { - int _step = 0; - bool _showRefusForm = false; - bool _submitting = false; - - static const int _stepCount = 3; - - bool get _isEnAttente => widget.dossier.user.statut == 'en_attente'; - - static String _v(String? s) => - (s != null && s.trim().isNotEmpty) ? s.trim() : '–'; - - /// Présentation lisible : `1 12 34 56 789 012 - 34` (15 caractères utiles requis). - static String _formatNirForDisplay(String? nir) { - final v = _v(nir); - if (v == '–') return v; - final raw = nirToRaw(v).toUpperCase(); - return raw.length == 15 ? formatNir(raw) : v; - } - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep()); - } - - void _emitStep() => widget.onStepChanged?.call(_step, _stepCount); - - /// Panneau photo + grille droite : NIR|naissance, ville|pays, agrément|date, capa|places. - List _photoProFields(DossierAM d) { - final u = d.user; - return [ - AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)), - AdminDetailField( - label: 'Date de naissance', - value: formatIsoDateFr(u.dateNaissance), - ), - AdminDetailField( - label: 'Ville de naissance', - value: _v(u.lieuNaissanceVille), - ), - AdminDetailField( - label: 'Pays de naissance', - value: _v(u.lieuNaissancePays), - ), - AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)), - AdminDetailField( - label: 'Date d’agrément', - value: formatIsoDateFr(d.dateAgrement), - ), - AdminDetailField( - label: 'Capacité max (enfants)', - value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '–', - ), - AdminDetailField( - label: 'Places disponibles', - value: d.placesDisponibles != null - ? d.placesDisponibles.toString() - : '–', - ), - ]; - } - - static const List _photoProRowLayout = [2, 2, 2, 2]; - - /// Proportion photo d’identité (35×45 mm). - static const double _idPhotoAspectRatio = 35 / 45; - - static const double _photoProGap = 24; - /// Largeur mini réservée aux champs (évite une colonne photo trop gourmande). - static const double _proColumnMinWidth = 260; - static const double _photoColumnMinWidth = 160; - - /// URL complète pour la photo : si relatif, préfixe [ApiConfig.baseUrl] (ex. `/api/v1/uploads/...`). - static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url); - - Widget _buildPhotoSection(AppUser u) { - final photoUrl = _fullPhotoUrl(u.photoUrl); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.only(right: 8), - child: LayoutBuilder( - builder: (context, c) { - // Cadre clair : une seule épaisseur partout (photo + padding identique haut/bas/gauche/droite). - const uniformFrame = 8.0; - final maxPhotoW = - (c.maxWidth - 2 * uniformFrame).clamp(0.0, double.infinity); - final maxPhotoH = - (c.maxHeight - 2 * uniformFrame).clamp(0.0, double.infinity); - const ar = _idPhotoAspectRatio; - double ph = maxPhotoH; - double pw = ph * ar; - if (pw > maxPhotoW) { - pw = maxPhotoW; - ph = pw / ar; - } - return Align( - alignment: Alignment.center, - child: Container( - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.shade300), - ), - clipBehavior: Clip.antiAlias, - child: Padding( - padding: const EdgeInsets.all(uniformFrame), - child: ClipRRect( - borderRadius: BorderRadius.circular(6), - child: SizedBox( - width: pw, - height: ph, - child: photoUrl.isEmpty - ? ColoredBox( - color: Colors.grey.shade200, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.person_off_outlined, - size: 40, - color: Colors.grey.shade400), - const SizedBox(height: 8), - Text( - 'Aucune photo fournie', - style: TextStyle( - color: Colors.grey.shade600, - fontSize: 12), - ), - ], - ), - ) - : AuthNetworkImage( - url: photoUrl, - fit: BoxFit.cover, - width: pw, - height: ph, - loadingBuilder: (_, child, progress) { - if (progress == null) return child; - return ColoredBox( - color: Colors.grey.shade200, - child: Center( - child: CircularProgressIndicator( - value: progress.expectedTotalBytes != - null - ? progress.cumulativeBytesLoaded / - (progress.expectedTotalBytes!) - : null, - ), - ), - ); - }, - errorBuilder: (_, __, ___) => ColoredBox( - color: Colors.grey.shade200, - child: Column( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Icon(Icons.broken_image_outlined, - size: 40, - color: Colors.grey.shade400), - const SizedBox(height: 8), - Text( - 'Impossible de charger la photo', - style: TextStyle( - color: Colors.grey.shade600, - fontSize: 12), - ), - ], - ), - ), - ), - ), - ), - ), - ), - ); - }, - ), - ), - ), - ], - ); - } - @override Widget build(BuildContext context) { - if (_showRefusForm) { - return _buildRefusPage(); - } - return Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 4), - Expanded(child: _buildStepContent()), - const SizedBox(height: 24), - _buildNavigation(), - ], - ), + return AmDossierWizard.review( + dossier: dossier, + onClose: onClose, + onSuccess: onSuccess, + onStepChanged: onStepChanged, ); } - - Widget _buildStepContent() { - final d = widget.dossier; - final u = d.user; - switch (_step) { - case 0: - return LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints(minWidth: constraints.maxWidth), - child: IdentityBlock.readOnlyFromUser( - u, - title: 'Identité et coordonnées', - emptyLabel: '–', - ), - ), - ); - }, - ); - case 1: - // Pas de SingleChildScrollView sur la Row (hauteur non bornée). Défilement à droite. - // Largeur photo ≈ ratio × hauteur utile, plafonnée pour laisser au moins [_proColumnMinWidth] aux champs. - return LayoutBuilder( - builder: (context, c) { - final maxRowW = c.maxWidth; - final maxRowH = c.maxHeight; - const photoHeaderH = 0.0; - final bodyH = (maxRowH - photoHeaderH).clamp(0.0, double.infinity); - final idealPhotoW = - bodyH * _idPhotoAspectRatio + 16; // marge approx. cadre clair - final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth) - .clamp(0.0, double.infinity); - var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 360.0); - if (photoW > maxPhotoW) photoW = maxPhotoW; - photoW = photoW.clamp(0.0, maxRowW - _photoProGap); - - return Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox( - width: photoW, - child: _buildPhotoSection(u), - ), - const SizedBox(width: _photoProGap), - Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minWidth: constraints.maxWidth), - child: ValidationDetailSection( - title: 'Dossier professionnel', - fields: _photoProFields(d), - rowLayout: _photoProRowLayout, - ), - ), - ); - }, - ), - ), - ], - ); - }, - ); - case 2: - final presentation = - (d.presentation != null && d.presentation!.trim().isNotEmpty) - ? d.presentation! - : '–'; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text( - 'Présentation', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.black87, - ), - ), - const SizedBox(height: 12), - Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - child: ConstrainedBox( - constraints: - BoxConstraints(minHeight: constraints.maxHeight), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: Colors.grey.shade50, - borderRadius: BorderRadius.circular(6), - border: Border.all(color: Colors.grey.shade300), - ), - child: SelectableText( - presentation, - style: const TextStyle( - color: Colors.black87, fontSize: 14), - ), - ), - ), - ); - }, - ), - ), - ], - ); - default: - return const SizedBox(); - } - } - - Widget _buildNavigation() { - if (_step == 2) { - return Row( - children: [ - TextButton(onPressed: widget.onClose, child: const Text('Annuler')), - const Spacer(), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - TextButton( - onPressed: () { - setState(() => _step = 1); - _emitStep(); - }, - child: const Text('Précédent'), - ), - const SizedBox(width: 8), - if (_isEnAttente) ...[ - OutlinedButton( - onPressed: _submitting ? null : _refuser, - child: const Text('Refuser')), - const SizedBox(width: 12), - ElevatedButton( - style: ValidationModalTheme.primaryElevatedStyle, - onPressed: _submitting ? null : _onValiderPressed, - child: Text(_submitting ? 'Envoi...' : 'Valider'), - ), - ] else - ElevatedButton( - style: ValidationModalTheme.primaryElevatedStyle, - onPressed: widget.onClose, - child: const Text('Fermer'), - ), - ], - ), - ], - ); - } - return Row( - children: [ - TextButton(onPressed: widget.onClose, child: const Text('Annuler')), - const Spacer(), - if (_step > 0) ...[ - TextButton( - onPressed: () { - setState(() => _step--); - _emitStep(); - }, - child: const Text('Précédent'), - ), - const SizedBox(width: 8), - ], - ElevatedButton( - style: ValidationModalTheme.primaryElevatedStyle, - onPressed: () { - setState(() => _step++); - _emitStep(); - }, - child: const Text('Suivant'), - ), - ], - ); - } - - Future _onValiderPressed() async { - if (_submitting) return; - final ok = await showValidationValiderConfirmDialog( - context, - body: - 'Voulez-vous valider le dossier de cette assistante maternelle ? Cette action confirme le compte.', - ); - if (!mounted || !ok) return; - await _valider(); - } - - Future _valider() async { - if (_submitting) return; - setState(() => _submitting = true); - try { - await UserService.validateUser(widget.dossier.user.id); - if (!mounted) return; - widget.onSuccess(); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(e is Exception - ? e.toString().replaceFirst('Exception: ', '') - : 'Erreur'), - backgroundColor: Colors.red.shade700, - ), - ); - } finally { - if (mounted) setState(() => _submitting = false); - } - } - - void _refuser() => setState(() => _showRefusForm = true); - - Widget _buildRefusPage() { - return Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: ValidationRefusForm( - isSubmitting: _submitting, - onCancel: widget.onClose, - onPrevious: () => setState(() => _showRefusForm = false), - onSubmit: (comment) { - if (comment == null || comment.trim().isEmpty) return; - _refuserEnvoyer(comment.trim()); - }, - ), - ), - ], - ), - ); - } - - Future _refuserEnvoyer(String comment) async { - if (_submitting) return; - setState(() => _submitting = true); - try { - await UserService.refuseUser(widget.dossier.user.id, comment: comment); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text( - 'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.', - ), - duration: const Duration(seconds: 4), - ), - ); - widget.onSuccess(); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(e is Exception - ? e.toString().replaceFirst('Exception: ', '') - : 'Erreur'), - backgroundColor: Colors.red.shade700, - ), - ); - } finally { - if (mounted) setState(() => _submitting = false); - } - } }