diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index 75a9610..72e7667 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -60,6 +60,8 @@ class ApiConfig { static const String userChildren = '/users/children'; static const String gestionnaires = '/gestionnaires'; static const String parents = '/parents'; + /// Création dossier famille actif par le staff (#129) — body type register parent. + static const String parentsDossier = '/parents/dossier'; static const String assistantesMaternelles = '/assistantes-maternelles'; /// Création dossier AM actif par le staff (#156) — body type register AM. static const String assistantesMaternellesDossier = diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index ce8f662..be87d9e 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -645,6 +645,54 @@ class UserService { return fallback; } + /// Création dossier famille actif côté staff (#129). + /// `POST /parents/dossier` — body aligné sur register parent complet. + /// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur, par parent créé). + /// Ne pas utiliser `POST /auth/register/parent` (public / en_attente). + static Future> createParentDossier( + Map body, + ) async { + final http.Response response; + try { + response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parentsDossier}'), + 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 famille', + ); + if (response.statusCode == 409) { + throw Exception( + message.isNotEmpty ? message : 'Conflit : e-mail déjà utilisé.', + ); + } + if (response.statusCode == 400) { + throw Exception( + message.isNotEmpty ? message : 'Données invalides (400).', + ); + } + throw Exception(message); + } + /// 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). diff --git a/frontend/lib/widgets/admin/parent_dossier_create_modal.dart b/frontend/lib/widgets/admin/parent_dossier_create_modal.dart new file mode 100644 index 0000000..10f1126 --- /dev/null +++ b/frontend/lib/widgets/admin/parent_dossier_create_modal.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart'; + +/// Modale de création dossier famille (#129) — même shell que [AmDossierCreateModal]. +class ParentDossierCreateModal extends StatefulWidget { + final VoidCallback onClose; + final VoidCallback? onSuccess; + + const ParentDossierCreateModal({ + super.key, + required this.onClose, + this.onSuccess, + }); + + @override + State createState() => + _ParentDossierCreateModalState(); +} + +class _ParentDossierCreateModalState 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; + /// Hauteur calculée depuis 4 lignes de TF (voir [ParentDossierWizard.shellBodyHeight]). + static double get _bodyHeight => ParentDossierWizard.shellBodyHeight; + + @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( + 'Nouveau dossier famille', + 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: ParentDossierWizard.create( + onClose: widget.onClose, + onSuccess: _onSuccess, + onStepChanged: _onStepChanged, + ), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/parent_dossier_wizard.dart b/frontend/lib/widgets/admin/parent_dossier_wizard.dart new file mode 100644 index 0000000..051b183 --- /dev/null +++ b/frontend/lib/widgets/admin/parent_dossier_wizard.dart @@ -0,0 +1,1471 @@ +import 'dart:convert'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:p_tits_pas/models/dossier_unifie.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/enfant_status_utils.dart'; +import 'package:p_tits_pas/utils/name_format_utils.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/utils/postal_utils.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.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 ParentDossierWizardMode { review, create } + +/// Enfant en cours de saisie (mode création). Contrôleurs propres, à disposer. +class _CreateChild { + final TextEditingController prenomCtrl = TextEditingController(); + final TextEditingController nomCtrl = TextEditingController(); + final TextEditingController dateCtrl = TextEditingController(); + String? genre; + Uint8List? photoBytes; + String? photoFilename; + + void dispose() { + prenomCtrl.dispose(); + nomCtrl.dispose(); + dateCtrl.dispose(); + } +} + +/// Wizard dossier famille — modes [review] (validation dossier en attente, ticket #107) +/// et [create] (création dossier famille actif par le staff, ticket #129). +class ParentDossierWizard extends StatefulWidget { + final ParentDossierWizardMode mode; + final DossierFamille? dossier; + final VoidCallback onClose; + final VoidCallback onSuccess; + final void Function(int step, int total)? onStepChanged; + + const ParentDossierWizard._({ + super.key, + required this.mode, + this.dossier, + required this.onClose, + required this.onSuccess, + this.onStepChanged, + }); + + factory ParentDossierWizard.review({ + Key? key, + required DossierFamille dossier, + required VoidCallback onClose, + required VoidCallback onSuccess, + void Function(int step, int total)? onStepChanged, + }) { + return ParentDossierWizard._( + key: key, + mode: ParentDossierWizardMode.review, + dossier: dossier, + onClose: onClose, + onSuccess: onSuccess, + onStepChanged: onStepChanged, + ); + } + + factory ParentDossierWizard.create({ + Key? key, + required VoidCallback onClose, + required VoidCallback onSuccess, + void Function(int step, int total)? onStepChanged, + }) { + return ParentDossierWizard._( + key: key, + mode: ParentDossierWizardMode.create, + onClose: onClose, + onSuccess: onSuccess, + onStepChanged: onStepChanged, + ); + } + + bool get isCreate => mode == ParentDossierWizardMode.create; + + /// Hauteur corps modale famille — dérivée de [ValidationFormMetrics] (4 lignes). + static double get shellBodyHeight => + ValidationFormMetrics.shellBodyHeightForRows(4); + + @override + State createState() => _ParentDossierWizardState(); +} + +class _ParentDossierWizardState extends State { + int _step = 0; + bool _showRefusForm = false; + bool _submitting = false; + final ScrollController _enfantsScrollController = ScrollController(); + + /// Même logique que [ParentRegisterStep3Screen] : masque alpha sur les bords (ShaderMask dstIn). + bool _enfantsIsScrollable = false; + bool _enfantsFadeLeft = false; + bool _enfantsFadeRight = false; + + /// Fraction de la largeur du viewport pour le fondu (identique inscription étape 3). + static const double _enfantsFadeExtent = 0.05; + + static const int _stepCount = 4; + + // --- Mode création : parent principal --- + late final TextEditingController _p1NomCtrl; + late final TextEditingController _p1PrenomCtrl; + late final TextEditingController _p1TelCtrl; + late final TextEditingController _p1EmailCtrl; + late final TextEditingController _p1AdresseCtrl; + late final TextEditingController _p1CpCtrl; + late final TextEditingController _p1VilleCtrl; + + // --- Mode création : co-parent (optionnel) --- + bool _hasCoParent = false; + bool _sameAddress = false; + late final TextEditingController _p2NomCtrl; + late final TextEditingController _p2PrenomCtrl; + late final TextEditingController _p2TelCtrl; + late final TextEditingController _p2EmailCtrl; + late final TextEditingController _p2AdresseCtrl; + late final TextEditingController _p2CpCtrl; + late final TextEditingController _p2VilleCtrl; + + // --- Mode création : enfants + présentation --- + final List<_CreateChild> _children = []; + late final TextEditingController _presentationCtrl; + + bool get _isCreate => widget.isCreate; + DossierFamille get _dossier => widget.dossier!; + + bool get _isEnAttente => !_isCreate && _dossier.isEnAttente; + + String? get _firstParentId { + if (_isCreate) return null; + return _dossier.parents.isNotEmpty ? _dossier.parents.first.id : null; + } + + static String _v(String? s) => + (s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini'; + + /// Date de naissance en jour/mois/année (dd/MM/yyyy). + static String _formatBirthDate(String? s) => + formatIsoDateFr(s, ifEmpty: 'Non défini'); + + static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url); + + @override + void initState() { + super.initState(); + _enfantsScrollController.addListener(_syncEnfantsScrollFades); + + _p1NomCtrl = TextEditingController(); + _p1PrenomCtrl = TextEditingController(); + _p1TelCtrl = TextEditingController(); + _p1EmailCtrl = TextEditingController(); + _p1AdresseCtrl = TextEditingController(); + _p1CpCtrl = TextEditingController(); + _p1VilleCtrl = TextEditingController(); + + _p2NomCtrl = TextEditingController(); + _p2PrenomCtrl = TextEditingController(); + _p2TelCtrl = TextEditingController(); + _p2EmailCtrl = TextEditingController(); + _p2AdresseCtrl = TextEditingController(); + _p2CpCtrl = TextEditingController(); + _p2VilleCtrl = TextEditingController(); + + _presentationCtrl = TextEditingController(); + if (_isCreate) { + _children.add(_CreateChild()); + } + + WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep()); + } + + @override + void dispose() { + _enfantsScrollController.removeListener(_syncEnfantsScrollFades); + _enfantsScrollController.dispose(); + + _p1NomCtrl.dispose(); + _p1PrenomCtrl.dispose(); + _p1TelCtrl.dispose(); + _p1EmailCtrl.dispose(); + _p1AdresseCtrl.dispose(); + _p1CpCtrl.dispose(); + _p1VilleCtrl.dispose(); + + _p2NomCtrl.dispose(); + _p2PrenomCtrl.dispose(); + _p2TelCtrl.dispose(); + _p2EmailCtrl.dispose(); + _p2AdresseCtrl.dispose(); + _p2CpCtrl.dispose(); + _p2VilleCtrl.dispose(); + + for (final c in _children) { + c.dispose(); + } + _presentationCtrl.dispose(); + super.dispose(); + } + + void _emitStep() => widget.onStepChanged?.call(_step, _stepCount); + + void _showError(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), backgroundColor: Colors.red.shade700), + ); + } + + void _syncEnfantsScrollFades() { + if (!mounted) return; + if (!_enfantsScrollController.hasClients) { + if (_enfantsFadeLeft || _enfantsFadeRight || _enfantsIsScrollable) { + setState(() { + _enfantsIsScrollable = false; + _enfantsFadeLeft = false; + _enfantsFadeRight = false; + }); + } + return; + } + final p = _enfantsScrollController.position; + final scrollable = p.maxScrollExtent > 0; + final left = scrollable && + p.pixels > (p.viewportDimension * _enfantsFadeExtent / 2); + final right = scrollable && + p.pixels < + (p.maxScrollExtent - + (p.viewportDimension * _enfantsFadeExtent / 2)); + if (scrollable != _enfantsIsScrollable || + left != _enfantsFadeLeft || + right != _enfantsFadeRight) { + setState(() { + _enfantsIsScrollable = scrollable; + _enfantsFadeLeft = left; + _enfantsFadeRight = right; + }); + } + } + + @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 _buildEnfantsStep(); + case 3: + return _buildPresentationStep(); + default: + return const SizedBox(); + } + } + + Widget _buildStep0() { + if (_isCreate) { + return IdentityBlock.editable( + title: 'Parent principal', + nomController: _p1NomCtrl, + prenomController: _p1PrenomCtrl, + telephoneController: _p1TelCtrl, + emailController: _p1EmailCtrl, + adresseController: _p1AdresseCtrl, + codePostalController: _p1CpCtrl, + villeController: _p1VilleCtrl, + ); + } + return IdentityBlock.readOnlyFromParentDossier( + _dossier.parents.first, + title: 'Parent principal', + ); + } + + Widget _buildStep1() { + if (_isCreate) { + return _buildCoParentStepCreate(); + } + return _buildParent2Step(); + } + + Widget _buildCoParentStepCreate() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Ajouter un co-parent'), + value: _hasCoParent, + onChanged: (v) => setState(() => _hasCoParent = v), + ), + if (!_hasCoParent) + const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Text( + 'Un seul parent pour ce dossier.', + style: TextStyle(color: Colors.black87), + ), + ) + else ...[ + const SizedBox(height: 8), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + IdentityBlock.editable( + title: 'Deuxième parent', + nomController: _p2NomCtrl, + prenomController: _p2PrenomCtrl, + telephoneController: _p2TelCtrl, + emailController: _p2EmailCtrl, + adresseController: _p2AdresseCtrl, + codePostalController: _p2CpCtrl, + villeController: _p2VilleCtrl, + ), + const SizedBox(height: 12), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Même adresse que le parent principal'), + value: _sameAddress, + onChanged: (v) => setState(() { + _sameAddress = v; + if (v) { + _p2AdresseCtrl.text = _p1AdresseCtrl.text; + _p2CpCtrl.text = _p1CpCtrl.text; + _p2VilleCtrl.text = _p1VilleCtrl.text; + } + }), + ), + ], + ), + ), + ), + ], + ], + ); + } + + Widget _buildParent2Step() { + if (_dossier.parents.length < 2) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Text('Un seul parent pour ce dossier.', + style: TextStyle(color: Colors.black87)), + ); + } + return IdentityBlock.readOnlyFromParentDossier( + _dossier.parents[1], + title: 'Deuxième parent', + ); + } + + static const double _idPhotoAspectRatio = 35 / 45; + + /// Liste horizontale scrollable (fondu bords) partagée par les cartes enfant + /// en lecture seule (review) et éditables (create). + Widget _buildEnfantsHorizontalList({ + required int itemCount, + required Widget Function(int index) itemBuilder, + }) { + return LayoutBuilder( + builder: (context, constraints) { + final cardHeight = constraints.maxHeight; + // Carte large : 1/3 photo + 2/3 champs (scroll horizontal si plusieurs enfants). + final cardWidth = (cardHeight * 1.72).clamp(500.0, 700.0); + return NotificationListener( + onNotification: (_) { + _syncEnfantsScrollFades(); + return false; + }, + child: ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (Rect bounds) { + final stops = [ + 0.0, + _enfantsFadeExtent, + 1.0 - _enfantsFadeExtent, + 1.0, + ]; + if (!_enfantsIsScrollable) { + return LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: const [ + Colors.black, + Colors.black, + Colors.black, + Colors.black, + ], + stops: stops, + ).createShader(bounds); + } + final leftMask = + _enfantsFadeLeft ? Colors.transparent : Colors.black; + final rightMask = + _enfantsFadeRight ? Colors.transparent : Colors.black; + return LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + leftMask, + Colors.black, + Colors.black, + rightMask, + ], + stops: stops, + ).createShader(bounds); + }, + child: Listener( + onPointerSignal: (event) { + if (event is PointerScrollEvent && + _enfantsScrollController.hasClients) { + final offset = _enfantsScrollController.offset + + event.scrollDelta.dy; + _enfantsScrollController.jumpTo(offset.clamp( + _enfantsScrollController.position.minScrollExtent, + _enfantsScrollController.position.maxScrollExtent, + )); + } + }, + child: ListView.builder( + controller: _enfantsScrollController, + scrollDirection: Axis.horizontal, + itemCount: itemCount, + itemBuilder: (_, i) => Padding( + padding: EdgeInsets.only(right: i < itemCount - 1 ? 16 : 0), + child: SizedBox( + width: cardWidth, + height: cardHeight, + child: itemBuilder(i), + ), + ), + ), + ), + ), + ); + }, + ); + } + + Widget _buildEnfantsStep() { + if (_isCreate) { + return _buildEnfantsStepCreate(); + } + final enfants = _dossier.enfants; + if (enfants.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Text('Aucun enfant renseigné.', + style: TextStyle(color: Colors.black87)), + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Enfants', + style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87), + ), + const SizedBox(height: 16), + Expanded( + child: _buildEnfantsHorizontalList( + itemCount: enfants.length, + itemBuilder: (i) => _buildEnfantCard(enfants[i]), + ), + ), + ], + ); + } + + Widget _buildEnfantsStepCreate() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Text( + 'Enfants', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const Spacer(), + TextButton.icon( + onPressed: _addChild, + icon: const Icon(Icons.add), + label: const Text('Ajouter un enfant'), + ), + ], + ), + const SizedBox(height: 8), + Expanded( + child: _buildEnfantsHorizontalList( + itemCount: _children.length, + itemBuilder: (i) => _buildCreateEnfantCard(i), + ), + ), + ], + ); + } + + void _addChild() { + setState(() => _children.add(_CreateChild())); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_enfantsScrollController.hasClients) return; + _enfantsScrollController.animateTo( + _enfantsScrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + }); + } + + void _removeChild(int index) { + if (_children.length <= 1) return; + setState(() { + final removed = _children.removeAt(index); + removed.dispose(); + }); + } + + Future _pickChildPhoto(int index) 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 || index >= _children.length) return; + setState(() { + _children[index].photoBytes = bytes; + _children[index].photoFilename = picked.name; + }); + } catch (_) { + if (!mounted) return; + _showError('Impossible de charger la photo.'); + } + } + + void _clearChildPhoto(int index) { + if (index >= _children.length) return; + setState(() { + _children[index].photoBytes = null; + _children[index].photoFilename = null; + }); + } + + /// Fond carte enfant : teintes très pastel ; bordure discrète ; accent léger (barre). + static const Color _enfantCardBoyBg = Color(0xFFF0F7FB); + static const Color _enfantCardBoyBorder = Color(0xFFE3EDF4); + static const Color _enfantCardGirlBg = Color(0xFFFCF5F8); + static const Color _enfantCardGirlBorder = Color(0xFFEAE3E7); + + static const double _enfantCardRadius = 12; + + static List _enfantCardShadows() => [ + BoxShadow( + color: Colors.black.withOpacity(0.06), + blurRadius: 14, + offset: const Offset(0, 4), + ), + ]; + + static BoxDecoration _enfantCardDecoration(String? gender) { + final g = (gender ?? '').trim().toUpperCase(); + if (g == 'H') { + return BoxDecoration( + color: _enfantCardBoyBg, + borderRadius: BorderRadius.circular(_enfantCardRadius), + border: Border.all(color: _enfantCardBoyBorder, width: 1), + boxShadow: _enfantCardShadows(), + ); + } + if (g == 'F') { + return BoxDecoration( + color: _enfantCardGirlBg, + borderRadius: BorderRadius.circular(_enfantCardRadius), + border: Border.all(color: _enfantCardGirlBorder, width: 1), + boxShadow: _enfantCardShadows(), + ); + } + return BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(_enfantCardRadius), + border: Border.all(color: Colors.grey.shade300), + boxShadow: _enfantCardShadows(), + ); + } + + /// Carte enfant éditable (création) : prénom pleine largeur, puis photo 1/3 + champs 2/3. + Widget _buildCreateEnfantCard(int index) { + final child = _children[index]; + final canRemove = _children.length > 1; + return ClipRRect( + borderRadius: BorderRadius.circular(_enfantCardRadius), + child: Container( + decoration: _enfantCardDecoration(child.genre), + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 40, 8), + child: ValidationLabeledField( + label: 'Prénom', + field: ValidationEditableField( + controller: child.prenomCtrl, + inputFormatters: const [PersonNameInputFormatter()], + ), + ), + ), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 1, + child: LayoutBuilder( + builder: (context, c) { + const padL = 12.0; + const padR = 8.0; + const padV = 8.0; + final maxW = (c.maxWidth - padL - padR) + .clamp(0.0, double.infinity); + final maxH = (c.maxHeight - 2 * padV) + .clamp(0.0, double.infinity); + const ar = _idPhotoAspectRatio; + double ph = maxH; + double pw = ph * ar; + if (pw > maxW) { + pw = maxW; + ph = pw / ar; + } + return Padding( + padding: const EdgeInsets.fromLTRB( + padL, padV, padR, padV), + child: Align( + alignment: Alignment.centerLeft, + child: SizedBox( + width: pw, + height: ph, + child: AdminAmPhotoFrame( + imageBytes: child.photoBytes, + onTap: () => _pickChildPhoto(index), + onClear: child.photoBytes != null + ? () => _clearChildPhoto(index) + : null, + emptyLabel: 'Ajouter une photo', + ), + ), + ), + ); + }, + ), + ), + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsets.fromLTRB(4, 4, 14, 12), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ValidationLabeledField( + label: 'Nom', + field: ValidationEditableField( + controller: child.nomCtrl, + inputFormatters: const [ + PersonNameInputFormatter(), + ], + ), + ), + const SizedBox(height: 12), + ValidationLabeledField( + label: 'Date de naissance', + field: ValidationEditableField( + controller: child.dateCtrl, + hintText: 'jj / mm / aaaa', + keyboardType: TextInputType.number, + inputFormatters: const [ + FrenchDateInputFormatter(), + ], + ), + ), + const SizedBox(height: 12), + ValidationLabeledField( + label: 'Genre', + field: _genreDropdown(index), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ], + ), + if (canRemove) + Positioned( + top: 4, + right: 4, + child: Material( + color: Colors.transparent, + child: IconButton( + tooltip: 'Retirer cet enfant', + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: + const BoxConstraints(minWidth: 32, minHeight: 32), + icon: Icon(Icons.close, + size: 18, color: Colors.grey.shade700), + onPressed: () => _removeChild(index), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _genreDropdown(int index) { + final child = _children[index]; + return SizedBox( + height: ValidationFormMetrics.fieldHeight, + child: DropdownButtonFormField( + value: child.genre, + isExpanded: true, + hint: Text( + 'Sélectionner', + style: TextStyle(color: Colors.grey.shade600, fontSize: 14), + ), + decoration: ValidationFieldDecoration.input(), + items: const [ + DropdownMenuItem(value: 'H', child: Text('Garçon')), + DropdownMenuItem(value: 'F', child: Text('Fille')), + ], + onChanged: (v) => setState(() => child.genre = v), + ), + ); + } + + /// Carte enfant : prénom pleine largeur, puis ligne photo 1/3 + colonne 2/3 (champs + statut hors TF si besoin). + Widget _buildEnfantCard(EnfantDossier e) { + final photoUrl = _fullPhotoUrl(e.photoUrl); + final columnStatusLabel = _enfantColumnStatusLabel(e); + return ClipRRect( + borderRadius: BorderRadius.circular(_enfantCardRadius), + child: Container( + decoration: _enfantCardDecoration(e.gender), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), + child: _enfantLabeledField('Prénom', _v(e.firstName)), + ), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 1, + child: LayoutBuilder( + builder: (context, c) { + // Même marge gauche que le bloc « Prénom » (12) ; droite / haut / bas 8. + const padL = 12.0; + const padR = 8.0; + const padV = 8.0; + final maxW = + (c.maxWidth - padL - padR).clamp(0.0, double.infinity); + final maxH = + (c.maxHeight - 2 * padV).clamp(0.0, double.infinity); + const ar = _idPhotoAspectRatio; + double ph = maxH; + double pw = ph * ar; + if (pw > maxW) { + pw = maxW; + ph = pw / ar; + } + return Padding( + padding: const EdgeInsets.fromLTRB(padL, padV, padR, padV), + child: Align( + alignment: Alignment.centerLeft, + child: _buildEnfantPhotoSlot(photoUrl, pw, ph), + ), + ); + }, + ), + ), + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsets.fromLTRB(4, 4, 14, 12), + child: columnStatusLabel == null + ? SingleChildScrollView( + child: _buildEnfantInfoFields(e), + ) + : CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: _buildEnfantInfoFields(e), + ), + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Text( + columnStatusLabel, + textAlign: TextAlign.center, + style: GoogleFonts.merienda( + fontSize: 14, + fontStyle: FontStyle.italic, + fontWeight: FontWeight.w600, + color: Colors.grey.shade800, + ), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + /// Statut dans la colonne 2/3 (scolarisé·e, à naître, sans garde, en garde). + String? _enfantColumnStatusLabel(EnfantDossier e) { + return enfantColumnStatusLabel(status: e.status, gender: e.gender); + } + + /// Nom ; date de naissance et genre sur une ligne (prénom au-dessus, pleine largeur). + Widget _buildEnfantInfoFields(EnfantDossier e) { + final isANaitre = (e.status ?? '').trim().toLowerCase() == 'a_naitre'; + final dueDateRenseignee = e.dueDate != null && e.dueDate!.trim().isNotEmpty; + final dateValue = isANaitre + ? (dueDateRenseignee ? '${_formatBirthDate(e.dueDate)} (P)' : '– (P)') + : _formatBirthDate(e.birthDate); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _enfantLabeledField('Nom', _formatNom(e.lastName)), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: _enfantLabeledField('Date de naissance', dateValue), + ), + const SizedBox(width: 16), + Expanded( + flex: 2, + child: _enfantLabeledField( + 'Genre', + _genreEnfantLabel(e.gender, e.status), + ), + ), + ], + ), + ], + ); + } + + Widget _enfantLabeledField(String label, String value) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Colors.grey.shade700, + ), + ), + const SizedBox(height: 4), + ValidationReadOnlyField(value: value), + ], + ); + } + + Widget _buildEnfantPhotoSlot(String photoUrl, double width, double height) { + const photoRadius = 8.0; + return Container( + width: width, + height: height, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(photoRadius), + border: Border.all(color: Colors.black.withOpacity(0.08)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: photoUrl.isEmpty + ? ColoredBox( + color: Colors.grey.shade100, + child: Center( + child: Icon(Icons.person_outline, size: 32, color: Colors.grey.shade400), + ), + ) + : AuthNetworkImage( + url: photoUrl, + fit: BoxFit.cover, + width: width, + height: height, + errorBuilder: (_, __, ___) => ColoredBox( + color: Colors.grey.shade100, + child: Center( + child: Icon(Icons.broken_image_outlined, size: 32, color: Colors.grey.shade400), + ), + ), + ), + ); + } + + static String _formatNom(String? lastName) { + final n = (lastName ?? '').trim().toUpperCase(); + return n.isEmpty ? 'Non défini' : n; + } + + /// Genre enfant : Garçon, Fille, ou "Non connu" (uniquement si l'enfant est à naître). + static String _genreEnfantLabel(String? gender, String? status) { + final g = (gender ?? '').trim().toUpperCase(); + final isANaitre = (status ?? '').trim().toLowerCase() == 'a_naitre'; + if (g == 'H') return 'Garçon'; + if (g == 'F') return 'Fille'; + if (isANaitre) return 'Non connu'; + if (g.isEmpty) return 'Non défini'; + return (gender ?? '').trim(); + } + + Widget _buildPresentationStep() { + 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 p = _dossier.presentation ?? ''; + final text = p.trim().isEmpty ? 'Non défini' : p; + 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( + text, + style: + const TextStyle(color: Colors.black87, fontSize: 14), + ), + ), + ), + ); + }, + ), + ), + ], + ); + } + + // --- Validation (mode création) --- + + String? _validateP1() { + final nom = _p1NomCtrl.text.trim(); + final prenom = _p1PrenomCtrl.text.trim(); + if (nom.length < 2) { + return 'Le nom du parent principal doit contenir au moins 2 caractères.'; + } + if (prenom.length < 2) { + return 'Le prénom du parent principal doit contenir au moins 2 caractères.'; + } + final phoneErr = validateFrenchNationalPhone(_p1TelCtrl.text); + if (phoneErr != null) return phoneErr; + final emailErr = validateEmail(_p1EmailCtrl.text); + if (emailErr != null) return emailErr; + if (_p1AdresseCtrl.text.trim().isEmpty) { + return 'L’adresse du parent principal est requise.'; + } + final cpErr = validateFrenchPostalCode(_p1CpCtrl.text); + if (cpErr != null) return cpErr; + if (_p1VilleCtrl.text.trim().isEmpty) { + return 'La ville du parent principal est requise.'; + } + return null; + } + + String? _validateP2() { + if (!_hasCoParent) return null; + final nom = _p2NomCtrl.text.trim(); + final prenom = _p2PrenomCtrl.text.trim(); + if (nom.length < 2) { + return 'Le nom du co-parent doit contenir au moins 2 caractères.'; + } + if (prenom.length < 2) { + return 'Le prénom du co-parent doit contenir au moins 2 caractères.'; + } + final phoneErr = validateFrenchNationalPhone(_p2TelCtrl.text); + if (phoneErr != null) return 'Co-parent : $phoneErr'; + final emailErr = validateEmail(_p2EmailCtrl.text); + if (emailErr != null) return 'Co-parent : $emailErr'; + if (normalizeEmailText(_p2EmailCtrl.text) == + normalizeEmailText(_p1EmailCtrl.text)) { + return 'L’email du co-parent doit être différent de celui du parent principal.'; + } + if (_p2AdresseCtrl.text.trim().isEmpty) { + return 'L’adresse du co-parent est requise.'; + } + final cpErr = validateFrenchPostalCode(_p2CpCtrl.text); + if (cpErr != null) return 'Co-parent : $cpErr'; + if (_p2VilleCtrl.text.trim().isEmpty) { + return 'La ville du co-parent est requise.'; + } + return null; + } + + String? _validateEnfants() { + if (_children.isEmpty) return 'Au moins un enfant est requis.'; + for (var i = 0; i < _children.length; i++) { + final c = _children[i]; + final label = 'Enfant ${i + 1}'; + if (c.prenomCtrl.text.trim().length < 2) { + return '$label : le prénom doit contenir au moins 2 caractères.'; + } + if (parseFrDateToIso(c.dateCtrl.text) == null) { + return '$label : indiquez une date de naissance valide (jj/mm/aaaa).'; + } + if (c.genre != 'H' && c.genre != 'F') { + return '$label : indiquez le genre (Garçon ou Fille).'; + } + } + return null; + } + + String? _validateCurrentStep() { + if (!_isCreate) return null; + switch (_step) { + case 0: + return _validateP1(); + case 1: + return _validateP2(); + case 2: + return _validateEnfants(); + default: + return null; + } + } + + void _goNext() { + final err = _validateCurrentStep(); + if (err != null) { + _showError(err); + 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 _childCreateToJson(_CreateChild c) { + final prenom = formatPersonNameCase(c.prenomCtrl.text); + final nomRaw = c.nomCtrl.text.trim(); + final nom = nomRaw.isNotEmpty + ? formatPersonNameCase(nomRaw) + : formatPersonNameCase(_p1NomCtrl.text); + final birthIso = parseFrDateToIso(c.dateCtrl.text); + + final map = { + 'prenom': prenom, + 'nom': nom, + 'date_naissance': birthIso, + 'genre': c.genre, + 'consent_photo': true, + 'grossesse_multiple': false, + }; + + final bytes = c.photoBytes; + if (bytes != null && bytes.isNotEmpty) { + final mime = _imageMimeForBytes(bytes); + final fn = (c.photoFilename ?? '').trim(); + map['photo_base64'] = 'data:$mime;base64,${base64Encode(bytes)}'; + map['photo_filename'] = fn.isNotEmpty ? fn : 'enfant.jpg'; + } + + return map; + } + + Map _buildCreateBody() { + final body = { + 'email': normalizeEmailText(_p1EmailCtrl.text), + 'prenom': formatPersonNameCase(_p1PrenomCtrl.text), + 'nom': formatPersonNameCase(_p1NomCtrl.text), + 'telephone': normalizePhone(_p1TelCtrl.text), + 'adresse': _p1AdresseCtrl.text.trim(), + 'code_postal': _p1CpCtrl.text.trim(), + 'ville': formatPersonNameCase(_p1VilleCtrl.text), + 'acceptation_cgu': true, + 'acceptation_privacy': true, + }; + + if (_hasCoParent) { + final sameAddr = + _p2AdresseCtrl.text.trim() == _p1AdresseCtrl.text.trim() && + _p2CpCtrl.text.trim() == _p1CpCtrl.text.trim() && + _p2VilleCtrl.text.trim() == _p1VilleCtrl.text.trim(); + body['co_parent_email'] = normalizeEmailText(_p2EmailCtrl.text); + body['co_parent_prenom'] = formatPersonNameCase(_p2PrenomCtrl.text); + body['co_parent_nom'] = formatPersonNameCase(_p2NomCtrl.text); + body['co_parent_telephone'] = normalizePhone(_p2TelCtrl.text); + body['co_parent_meme_adresse'] = sameAddr; + if (!sameAddr) { + body['co_parent_adresse'] = _p2AdresseCtrl.text.trim(); + body['co_parent_code_postal'] = _p2CpCtrl.text.trim(); + body['co_parent_ville'] = formatPersonNameCase(_p2VilleCtrl.text); + } + } + + final presentation = _presentationCtrl.text.trim(); + if (presentation.isNotEmpty) { + body['presentation_dossier'] = presentation; + } + + body['enfants'] = _children.map(_childCreateToJson).toList(); + + return body; + } + + Future _createAndValidate() async { + if (_submitting) return; + final err0 = _validateP1(); + if (err0 != null) { + _showError(err0); + return; + } + final err1 = _validateP2(); + if (err1 != null) { + _showError(err1); + return; + } + final err2 = _validateEnfants(); + if (err2 != null) { + _showError(err2); + return; + } + + final ok = await showValidationValiderConfirmDialog( + context, + body: + 'Créer et activer ce dossier famille ? Un e-mail de création de mot de passe sera envoyé à chaque parent créé.', + ); + if (!mounted || !ok) return; + + setState(() => _submitting = true); + try { + await UserService.createParentDossier(_buildCreateBody()); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Dossier créé et validé. La famille est active.'), + duration: Duration(seconds: 4), + ), + ); + widget.onSuccess(); + } catch (e) { + if (!mounted) return; + _showError( + e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur', + ); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + Widget _buildNavigation() { + if (_step == 3) { + return Row( + children: [ + TextButton(onPressed: widget.onClose, child: const Text('Annuler')), + const Spacer(), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: () { + setState(() => _step = 2); + _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 && _firstParentId != null) ...[ + 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 if (!_isEnAttente) + 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 || _firstParentId == null) return; + final ok = await showValidationValiderConfirmDialog( + context, + body: + 'Voulez-vous valider ce dossier famille ? Les comptes parents concernés seront confirmés.', + ); + if (!mounted || !ok) return; + await _valider(); + } + + Future _valider() async { + if (_submitting || _firstParentId == null) return; + setState(() => _submitting = true); + try { + await UserService.validerDossierFamille(_firstParentId!); + if (!mounted) return; + widget.onSuccess(); + } catch (e) { + if (!mounted) return; + _showError( + e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur', + ); + } 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()); + }, + ), + ), + ], + ), + ); + } + + /// Un seul appel : le back refuse tout le dossier famille (co-parents + mails). Ticket #110. + Future _refuserEnvoyer(String comment) async { + if (_submitting) return; + final parentId = _firstParentId?.trim(); + if (parentId == null || parentId.isEmpty || !_isEnAttente) { + if (!mounted) return; + _showError('Aucun compte en attente à refuser pour ce dossier.'); + return; + } + setState(() => _submitting = true); + try { + await UserService.refuseUser(parentId, comment: comment); + if (!mounted) return; + final nbEnAttente = + _dossier.parents.where((p) => p.statut == 'en_attente').length; + final msg = nbEnAttente > 1 + ? 'Refus enregistré. Un e-mail de reprise a été envoyé à chaque parent du dossier.' + : 'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(msg), + duration: const Duration(seconds: 4), + ), + ); + widget.onSuccess(); + } catch (e) { + if (!mounted) return; + _showError( + e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur', + ); + } 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 d13eca7..7be38c7 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -8,6 +8,7 @@ import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart'; +import 'package:p_tits_pas/widgets/admin/parent_dossier_create_modal.dart'; import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart'; import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart'; @@ -27,6 +28,7 @@ class UserManagementPanel extends StatefulWidget { class _UserManagementPanelState extends State { int _subIndex = 0; int _gestionnaireRefreshTick = 0; + int _parentRefreshTick = 0; int _adminRefreshTick = 0; int _enfantRefreshTick = 0; int _amRefreshTick = 0; @@ -263,6 +265,7 @@ class _UserManagementPanelState extends State { switch (contentIndex) { case 0: return ParentManagementWidget( + key: ValueKey('parents-$_parentRefreshTick'), searchQuery: _searchController.text, statusFilter: _parentStatus, ); @@ -318,6 +321,24 @@ class _UserManagementPanelState extends State { Future _handleAddPressed() async { final contentIndex = _subIndex - _contentIndexOffset; + if (contentIndex == 0) { + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return ParentDossierCreateModal( + onClose: () => Navigator.of(dialogContext).pop(), + onSuccess: () { + Navigator.of(dialogContext).pop(); + if (!mounted) return; + setState(() => _parentRefreshTick++); + }, + ); + }, + ); + return; + } + if (contentIndex == 1) { await showDialog( context: context, diff --git a/frontend/lib/widgets/admin/validation_family_wizard.dart b/frontend/lib/widgets/admin/validation_family_wizard.dart index 76ac741..f72fc4b 100644 --- a/frontend/lib/widgets/admin/validation_family_wizard.dart +++ b/frontend/lib/widgets/admin/validation_family_wizard.dart @@ -1,20 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:flutter/gestures.dart'; -import 'package:google_fonts/google_fonts.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/enfant_status_utils.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/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/parent_dossier_wizard.dart'; -/// Wizard de validation dossier famille : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107. -class ValidationFamilyWizard extends StatefulWidget { +/// Wrapper historique (#107) — délègue à [ParentDossierWizard.review]. +class ValidationFamilyWizard extends StatelessWidget { final DossierFamille dossier; final VoidCallback onClose; final VoidCallback onSuccess; @@ -28,697 +17,13 @@ class ValidationFamilyWizard extends StatefulWidget { this.onStepChanged, }); - @override - State createState() => _ValidationFamilyWizardState(); -} - -class _ValidationFamilyWizardState extends State { - int _step = 0; - bool _showRefusForm = false; - bool _submitting = false; - final ScrollController _enfantsScrollController = ScrollController(); - - /// Même logique que [ParentRegisterStep3Screen] : masque alpha sur les bords (ShaderMask dstIn). - bool _enfantsIsScrollable = false; - bool _enfantsFadeLeft = false; - bool _enfantsFadeRight = false; - - /// Fraction de la largeur du viewport pour le fondu (identique inscription étape 3). - static const double _enfantsFadeExtent = 0.05; - - int get _stepCount => 4; - - @override - void initState() { - super.initState(); - _enfantsScrollController.addListener(_syncEnfantsScrollFades); - WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep()); - } - - @override - void dispose() { - _enfantsScrollController.removeListener(_syncEnfantsScrollFades); - _enfantsScrollController.dispose(); - super.dispose(); - } - - void _emitStep() => widget.onStepChanged?.call(_step, _stepCount); - - void _syncEnfantsScrollFades() { - if (!mounted) return; - if (!_enfantsScrollController.hasClients) { - if (_enfantsFadeLeft || _enfantsFadeRight || _enfantsIsScrollable) { - setState(() { - _enfantsIsScrollable = false; - _enfantsFadeLeft = false; - _enfantsFadeRight = false; - }); - } - return; - } - final p = _enfantsScrollController.position; - final scrollable = p.maxScrollExtent > 0; - final left = scrollable && - p.pixels > (p.viewportDimension * _enfantsFadeExtent / 2); - final right = scrollable && - p.pixels < - (p.maxScrollExtent - - (p.viewportDimension * _enfantsFadeExtent / 2)); - if (scrollable != _enfantsIsScrollable || - left != _enfantsFadeLeft || - right != _enfantsFadeRight) { - setState(() { - _enfantsIsScrollable = scrollable; - _enfantsFadeLeft = left; - _enfantsFadeRight = right; - }); - } - } - - bool get _isEnAttente => widget.dossier.isEnAttente; - - String? get _firstParentId => widget.dossier.parents.isNotEmpty - ? widget.dossier.parents.first.id - : null; - - static String _v(String? s) => - (s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini'; - - /// Date de naissance en jour/mois/année (dd/MM/yyyy). - static String _formatBirthDate(String? s) => - formatIsoDateFr(s, ifEmpty: 'Non défini'); - - static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url); - @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 ParentDossierWizard.review( + dossier: dossier, + onClose: onClose, + onSuccess: onSuccess, + onStepChanged: onStepChanged, ); } - - Widget _buildStepContent() { - final d = widget.dossier; - switch (_step) { - case 0: - return IdentityBlock.readOnlyFromParentDossier( - d.parents.first, - title: 'Parent principal', - ); - case 1: - return _buildParent2Step(); - case 2: - return _buildEnfantsStep(); - case 3: - return _buildPresentationStep(); - default: - return const SizedBox(); - } - } - - Widget _buildParent2Step() { - if (widget.dossier.parents.length < 2) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 12), - child: Text('Un seul parent pour ce dossier.', - style: TextStyle(color: Colors.black87)), - ); - } - return IdentityBlock.readOnlyFromParentDossier( - widget.dossier.parents[1], - title: 'Deuxième parent', - ); - } - - static const double _idPhotoAspectRatio = 35 / 45; - - Widget _buildEnfantsStep() { - final enfants = widget.dossier.enfants; - if (enfants.isEmpty) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 12), - child: Text('Aucun enfant renseigné.', - style: TextStyle(color: Colors.black87)), - ); - } - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text( - 'Enfants', - style: TextStyle( - fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87), - ), - const SizedBox(height: 16), - Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - final cardHeight = constraints.maxHeight; - // Carte large : 1/3 photo + 2/3 champs (scroll horizontal si plusieurs enfants). - final cardWidth = (cardHeight * 1.72).clamp(500.0, 700.0); - return NotificationListener( - onNotification: (_) { - _syncEnfantsScrollFades(); - return false; - }, - child: ShaderMask( - blendMode: BlendMode.dstIn, - shaderCallback: (Rect bounds) { - final stops = [ - 0.0, - _enfantsFadeExtent, - 1.0 - _enfantsFadeExtent, - 1.0, - ]; - if (!_enfantsIsScrollable) { - return LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: const [ - Colors.black, - Colors.black, - Colors.black, - Colors.black, - ], - stops: stops, - ).createShader(bounds); - } - final leftMask = - _enfantsFadeLeft ? Colors.transparent : Colors.black; - final rightMask = - _enfantsFadeRight ? Colors.transparent : Colors.black; - return LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - leftMask, - Colors.black, - Colors.black, - rightMask, - ], - stops: stops, - ).createShader(bounds); - }, - child: Listener( - onPointerSignal: (event) { - if (event is PointerScrollEvent && - _enfantsScrollController.hasClients) { - final offset = _enfantsScrollController.offset + - event.scrollDelta.dy; - _enfantsScrollController.jumpTo(offset.clamp( - _enfantsScrollController.position.minScrollExtent, - _enfantsScrollController.position.maxScrollExtent, - )); - } - }, - child: ListView.builder( - controller: _enfantsScrollController, - scrollDirection: Axis.horizontal, - itemCount: enfants.length, - itemBuilder: (_, i) => Padding( - padding: EdgeInsets.only( - right: i < enfants.length - 1 ? 16 : 0), - child: SizedBox( - width: cardWidth, - height: cardHeight, - child: _buildEnfantCard(enfants[i]), - ), - ), - ), - ), - ), - ); - }, - ), - ), - ], - ); - } - - /// Fond carte enfant : teintes très pastel ; bordure discrète ; accent léger (barre). - static const Color _enfantCardBoyBg = Color(0xFFF0F7FB); - static const Color _enfantCardBoyBorder = Color(0xFFE3EDF4); - static const Color _enfantCardGirlBg = Color(0xFFFCF5F8); - static const Color _enfantCardGirlBorder = Color(0xFFEAE3E7); - - static const double _enfantCardRadius = 12; - - static List _enfantCardShadows() => [ - BoxShadow( - color: Colors.black.withOpacity(0.06), - blurRadius: 14, - offset: const Offset(0, 4), - ), - ]; - - static BoxDecoration _enfantCardDecoration(String? gender) { - final g = (gender ?? '').trim().toUpperCase(); - if (g == 'H') { - return BoxDecoration( - color: _enfantCardBoyBg, - borderRadius: BorderRadius.circular(_enfantCardRadius), - border: Border.all(color: _enfantCardBoyBorder, width: 1), - boxShadow: _enfantCardShadows(), - ); - } - if (g == 'F') { - return BoxDecoration( - color: _enfantCardGirlBg, - borderRadius: BorderRadius.circular(_enfantCardRadius), - border: Border.all(color: _enfantCardGirlBorder, width: 1), - boxShadow: _enfantCardShadows(), - ); - } - return BoxDecoration( - color: Colors.grey.shade50, - borderRadius: BorderRadius.circular(_enfantCardRadius), - border: Border.all(color: Colors.grey.shade300), - boxShadow: _enfantCardShadows(), - ); - } - - /// Carte enfant : prénom pleine largeur, puis ligne photo 1/3 + colonne 2/3 (champs + statut hors TF si besoin). - Widget _buildEnfantCard(EnfantDossier e) { - final photoUrl = _fullPhotoUrl(e.photoUrl); - final columnStatusLabel = _enfantColumnStatusLabel(e); - return ClipRRect( - borderRadius: BorderRadius.circular(_enfantCardRadius), - child: Container( - decoration: _enfantCardDecoration(e.gender), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), - child: _enfantLabeledField('Prénom', _v(e.firstName)), - ), - Expanded( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - flex: 1, - child: LayoutBuilder( - builder: (context, c) { - // Même marge gauche que le bloc « Prénom » (12) ; droite / haut / bas 8. - const padL = 12.0; - const padR = 8.0; - const padV = 8.0; - final maxW = - (c.maxWidth - padL - padR).clamp(0.0, double.infinity); - final maxH = - (c.maxHeight - 2 * padV).clamp(0.0, double.infinity); - const ar = _idPhotoAspectRatio; - double ph = maxH; - double pw = ph * ar; - if (pw > maxW) { - pw = maxW; - ph = pw / ar; - } - return Padding( - padding: const EdgeInsets.fromLTRB(padL, padV, padR, padV), - child: Align( - alignment: Alignment.centerLeft, - child: _buildEnfantPhotoSlot(photoUrl, pw, ph), - ), - ); - }, - ), - ), - Expanded( - flex: 2, - child: Padding( - padding: const EdgeInsets.fromLTRB(4, 4, 14, 12), - child: columnStatusLabel == null - ? SingleChildScrollView( - child: _buildEnfantInfoFields(e), - ) - : CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: _buildEnfantInfoFields(e), - ), - SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Text( - columnStatusLabel, - textAlign: TextAlign.center, - style: GoogleFonts.merienda( - fontSize: 14, - fontStyle: FontStyle.italic, - fontWeight: FontWeight.w600, - color: Colors.grey.shade800, - ), - ), - ), - ), - ], - ), - ), - ), - ], - ), - ), - ], - ), - ), - ); - } - - /// Statut dans la colonne 2/3 (scolarisé·e, à naître, sans garde, en garde). - String? _enfantColumnStatusLabel(EnfantDossier e) { - return enfantColumnStatusLabel(status: e.status, gender: e.gender); - } - - /// Nom ; date de naissance et genre sur une ligne (prénom au-dessus, pleine largeur). - Widget _buildEnfantInfoFields(EnfantDossier e) { - final isANaitre = (e.status ?? '').trim().toLowerCase() == 'a_naitre'; - final dueDateRenseignee = e.dueDate != null && e.dueDate!.trim().isNotEmpty; - final dateValue = isANaitre - ? (dueDateRenseignee ? '${_formatBirthDate(e.dueDate)} (P)' : '– (P)') - : _formatBirthDate(e.birthDate); - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: _enfantLabeledField('Nom', _formatNom(e.lastName)), - ), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 3, - child: _enfantLabeledField('Date de naissance', dateValue), - ), - const SizedBox(width: 16), - Expanded( - flex: 2, - child: _enfantLabeledField( - 'Genre', - _genreEnfantLabel(e.gender, e.status), - ), - ), - ], - ), - ], - ); - } - - Widget _enfantLabeledField(String label, String value) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Colors.grey.shade700, - ), - ), - const SizedBox(height: 4), - ValidationReadOnlyField(value: value), - ], - ); - } - - Widget _buildEnfantPhotoSlot(String photoUrl, double width, double height) { - const photoRadius = 8.0; - return Container( - width: width, - height: height, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(photoRadius), - border: Border.all(color: Colors.black.withOpacity(0.08)), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 6, - offset: const Offset(0, 2), - ), - ], - ), - clipBehavior: Clip.antiAlias, - child: photoUrl.isEmpty - ? ColoredBox( - color: Colors.grey.shade100, - child: Center( - child: Icon(Icons.person_outline, size: 32, color: Colors.grey.shade400), - ), - ) - : AuthNetworkImage( - url: photoUrl, - fit: BoxFit.cover, - width: width, - height: height, - errorBuilder: (_, __, ___) => ColoredBox( - color: Colors.grey.shade100, - child: Center( - child: Icon(Icons.broken_image_outlined, size: 32, color: Colors.grey.shade400), - ), - ), - ), - ); - } - - static String _formatNom(String? lastName) { - final n = (lastName ?? '').trim().toUpperCase(); - return n.isEmpty ? 'Non défini' : n; - } - - /// Genre enfant : Garçon, Fille, ou "Non connu" (uniquement si l'enfant est à naître). - static String _genreEnfantLabel(String? gender, String? status) { - final g = (gender ?? '').trim().toUpperCase(); - final isANaitre = (status ?? '').trim().toLowerCase() == 'a_naitre'; - if (g == 'H') return 'Garçon'; - if (g == 'F') return 'Fille'; - if (isANaitre) return 'Non connu'; - if (g.isEmpty) return 'Non défini'; - return (gender ?? '').trim(); - } - - Widget _buildPresentationStep() { - final p = widget.dossier.presentation ?? ''; - final text = p.trim().isEmpty ? 'Non défini' : p; - 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( - text, - style: - const TextStyle(color: Colors.black87, fontSize: 14), - ), - ), - ), - ); - }, - ), - ), - ], - ); - } - - Widget _buildNavigation() { - if (_step == 3) { - return Row( - children: [ - TextButton(onPressed: widget.onClose, child: const Text('Annuler')), - const Spacer(), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - TextButton( - onPressed: () { - setState(() => _step = 2); - _emitStep(); - }, - child: const Text('Précédent'), - ), - const SizedBox(width: 8), - if (_isEnAttente && _firstParentId != null) ...[ - 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 if (!_isEnAttente) - 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 || _firstParentId == null) return; - final ok = await showValidationValiderConfirmDialog( - context, - body: - 'Voulez-vous valider ce dossier famille ? Les comptes parents concernés seront confirmés.', - ); - if (!mounted || !ok) return; - await _valider(); - } - - Future _valider() async { - if (_submitting || _firstParentId == null) return; - setState(() => _submitting = true); - try { - await UserService.validerDossierFamille(_firstParentId!); - 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()); - }, - ), - ), - ], - ), - ); - } - - /// Un seul appel : le back refuse tout le dossier famille (co-parents + mails). Ticket #110. - Future _refuserEnvoyer(String comment) async { - if (_submitting) return; - final parentId = _firstParentId?.trim(); - if (parentId == null || parentId.isEmpty || !_isEnAttente) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('Aucun compte en attente à refuser pour ce dossier.'), - backgroundColor: Colors.red.shade700, - ), - ); - return; - } - setState(() => _submitting = true); - try { - await UserService.refuseUser(parentId, comment: comment); - if (!mounted) return; - final nbEnAttente = widget.dossier.parents - .where((p) => p.statut == 'en_attente') - .length; - final msg = nbEnAttente > 1 - ? 'Refus enregistré. Un e-mail de reprise a été envoyé à chaque parent du dossier.' - : 'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.'; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(msg), - 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); - } - } }