import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/utils/am_vigilance.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/utils/phone_utils.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_children_capacity_grid.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.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/admin/validation_modal_theme.dart'; /// Fiche AM éditable (ticket #131) — identité | fiche pro (photo) | enfants. class AdminAmEditModal extends StatefulWidget { final AssistanteMaternelleModel assistante; final VoidCallback? onSaved; const AdminAmEditModal({ super.key, required this.assistante, this.onSaved, }); @override State createState() => _AdminAmEditModalState(); } class _AdminAmEditModalState extends State with SingleTickerProviderStateMixin { late final TabController _tabCtrl; late final TextEditingController _nomCtrl; late final TextEditingController _prenomCtrl; late final TextEditingController _emailCtrl; late final TextEditingController _telCtrl; late final TextEditingController _adresseCtrl; late final TextEditingController _villeCtrl; late final TextEditingController _cpCtrl; late final TextEditingController _agrementCtrl; late final TextEditingController _nirCtrl; late final TextEditingController _dateNaissanceCtrl; late final TextEditingController _lieuNaissanceVilleCtrl; late final TextEditingController _lieuNaissancePaysCtrl; late final TextEditingController _dateAgrementCtrl; late final TextEditingController _capaciteCtrl; late String _statut; late bool _disponible; late int? _placesAvailable; late List _children; late Set _baselineChildIds; /// Enfants ajoutés localement qui étaient déjà chez une autre AM /// (enfantId → amUserId d'origine). Au save : détacher puis rattacher. final Map _transferFromAmIds = {}; bool _saving = false; bool _dirty = false; static const double _modalWidth = 930; static const double _photoProGap = 24; static const double _proColumnMinWidth = 260; static const double _photoColumnMinWidth = 160; static const double _proTabHeight = 300; static const List _photoProRowLayout = [2, 2, 2]; /// Hauteur onglet enfants : champs + titre + grille 2×2 (+ alerte places si besoin). double _childrenTabHeight() { const capacityFields = 72.0; const titleSection = 40.0; const inconsistencyExtra = 46.0; var h = capacityFields + titleSection + AdminAmChildrenCapacityGrid.fixedHeight; if (_placesInconsistent()) h += inconsistencyExtra; return h + 4; } double _tabViewHeight(int index) { switch (index) { case 1: return _proTabHeight; case 2: return _childrenTabHeight(); default: return 292; } } @override void initState() { super.initState(); _tabCtrl = TabController(length: 3, vsync: this); final u = widget.assistante.user; final am = widget.assistante; _nomCtrl = TextEditingController(text: u.nom ?? ''); _prenomCtrl = TextEditingController(text: u.prenom ?? ''); _emailCtrl = TextEditingController(text: u.email); _telCtrl = TextEditingController( text: formatPhoneForDisplay(u.telephone ?? ''), ); _adresseCtrl = TextEditingController(text: u.adresse ?? ''); _villeCtrl = TextEditingController(text: u.ville ?? ''); _cpCtrl = TextEditingController(text: u.codePostal ?? ''); _agrementCtrl = TextEditingController(text: am.approvalNumber ?? ''); _nirCtrl = TextEditingController(text: _formatNirDisplay()); _dateNaissanceCtrl = TextEditingController( text: formatIsoDateFr(u.dateNaissance, ifEmpty: ''), ); _lieuNaissanceVilleCtrl = TextEditingController( text: u.lieuNaissanceVille ?? '', ); _lieuNaissancePaysCtrl = TextEditingController( text: u.lieuNaissancePays ?? '', ); _dateAgrementCtrl = TextEditingController( text: formatIsoDateFr(am.agreementDate, ifEmpty: ''), ); _capaciteCtrl = TextEditingController( text: am.maxChildren?.toString() ?? '', ); _statut = u.statut ?? 'en_attente'; _disponible = am.available ?? true; _placesAvailable = am.placesAvailable; _children = List.of(am.children); _baselineChildIds = _children.map((c) => c.id).toSet(); for (final c in [ _nomCtrl, _prenomCtrl, _emailCtrl, _telCtrl, _adresseCtrl, _villeCtrl, _cpCtrl, _agrementCtrl, _nirCtrl, _dateNaissanceCtrl, _lieuNaissanceVilleCtrl, _lieuNaissancePaysCtrl, _dateAgrementCtrl, _capaciteCtrl, ]) { c.addListener(_markDirty); } _capaciteCtrl.addListener(_onCapacityChanged); _nomCtrl.addListener(_onNameFieldChanged); _prenomCtrl.addListener(_onNameFieldChanged); _tabCtrl.addListener(_onTabChanged); _fetchChildrenFromServer(); } bool _childrenChanged() { final current = _children.map((c) => c.id).toSet(); return current.length != _baselineChildIds.length || !current.containsAll(_baselineChildIds); } void _syncPlacesAfterChildrenChange() { final expected = _computedPlacesAvailable(); if (expected != null) _placesAvailable = expected; } void _onTabChanged() { if (!_tabCtrl.indexIsChanging) setState(() {}); } void _onNameFieldChanged() => setState(() {}); void _onCapacityChanged() => setState(() {}); @override void dispose() { _tabCtrl.dispose(); for (final c in [ _nomCtrl, _prenomCtrl, _emailCtrl, _telCtrl, _adresseCtrl, _villeCtrl, _cpCtrl, _agrementCtrl, _nirCtrl, _dateNaissanceCtrl, _lieuNaissanceVilleCtrl, _lieuNaissancePaysCtrl, _dateAgrementCtrl, _capaciteCtrl, ]) { c.dispose(); } super.dispose(); } void _markDirty() { if (!_dirty) setState(() => _dirty = true); } String _headerTitle() { final fn = _prenomCtrl.text.trim(); final ln = _nomCtrl.text.trim(); if (fn.isEmpty && ln.isEmpty) { final fallback = widget.assistante.user.fullName.trim(); return fallback.isNotEmpty ? fallback : 'Assistante maternelle'; } return '$fn $ln'.trim(); } String? _headerSubtitle() { final parts = []; final zone = (widget.assistante.residenceCity ?? '').trim(); if (zone.isNotEmpty) parts.add('Zone : $zone'); final agrement = _agrementCtrl.text.trim(); if (agrement.isNotEmpty) parts.add('Agrément : $agrement'); final dossier = widget.assistante.user.numeroDossier?.trim(); if (dossier != null && dossier.isNotEmpty) { parts.add('Dossier $dossier'); } if (parts.isEmpty) return null; return parts.join(' · '); } String _formatNirDisplay() { final raw = widget.assistante.nir?.trim() ?? ''; if (raw.isEmpty) return ''; final digits = nirToRaw(raw).toUpperCase(); return digits.length == 15 ? formatNir(digits) : raw; } String? _frDateToIso(String text) => parseFrDateToIso(text); int? _capaciteMax() => _parseIntField(_capaciteCtrl); /// True si plus aucune place d'accueil (enfants ≥ capacité max). bool get _capacityFull { final max = _capaciteMax(); if (max == null) return false; return _children.length >= max; } int? _computedPlacesAvailable() => amExpectedPlacesAvailable( maxChildren: _capaciteMax(), childrenCount: _children.length, ); bool _placesInconsistent() => amHasPlacesInconsistency( maxChildren: _capaciteMax(), placesAvailable: _placesAvailable, childrenCount: _children.length, ); String _placesDisplayValue() { if (_placesAvailable != null) return _placesAvailable.toString(); return '–'; } void _applyPlacesCorrection() { final expected = _computedPlacesAvailable(); if (expected == null) return; setState(() { _placesAvailable = expected; _dirty = true; }); } String? _placesInconsistencyMessage() { if (!_placesInconsistent()) return null; final stored = _placesAvailable; final expected = _computedPlacesAvailable(); final storedLabel = stored?.toString() ?? 'non renseigné'; final expectedLabel = expected?.toString() ?? '–'; return 'Incohérence : l\'AM déclare $storedLabel place(s) disponible(s), ' 'le calcul (capacité ${_capaciteMax() ?? '–'} − ${_children.length} ' 'enfant(s) rattaché(s)) donne $expectedLabel.'; } int? _parseIntField(TextEditingController c) { final t = c.text.trim(); if (t.isEmpty) return null; return int.tryParse(t); } Future _save() async { if (!_dirty) return; final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Confirmer'), content: const Text( 'Enregistrer les modifications de la fiche assistante maternelle ?', ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('Annuler'), ), ElevatedButton( onPressed: () => Navigator.pop(ctx, true), child: const Text('Sauvegarder'), ), ], ), ); if (confirmed != true || !mounted) return; setState(() => _saving = true); try { if (_childrenChanged()) _syncPlacesAfterChildrenChange(); final currentIds = _children.map((c) => c.id).toSet(); for (final id in _baselineChildIds.difference(currentIds)) { await UserService.detachEnfantFromAm( amUserId: widget.assistante.user.id, enfantId: id, ); } for (final id in currentIds.difference(_baselineChildIds)) { // Transfert : détacher l'ancienne AM sans recalculer ses places // (le ! de vigilance apparaît côté liste AM). final previousAmId = _transferFromAmIds[id] ?? (await UserService.findAmForEnfant(id))?.user.id; if (previousAmId != null && previousAmId != widget.assistante.user.id) { await UserService.detachEnfantFromAm( amUserId: previousAmId, enfantId: id, ); } await UserService.attachEnfantToAm( amUserId: widget.assistante.user.id, enfantId: id, ); } await UserService.updateAmFiche( amUserId: widget.assistante.user.id, body: { 'nom': _nomCtrl.text.trim(), 'prenom': _prenomCtrl.text.trim(), 'email': _emailCtrl.text.trim(), 'telephone': normalizePhone(_telCtrl.text), 'adresse': _adresseCtrl.text.trim(), 'ville': _villeCtrl.text.trim(), 'code_postal': _cpCtrl.text.trim(), 'statut': _statut, 'approval_number': _agrementCtrl.text.trim(), 'nir': nirToRaw(_nirCtrl.text), if (_frDateToIso(_dateNaissanceCtrl.text) != null) 'date_naissance': _frDateToIso(_dateNaissanceCtrl.text), 'lieu_naissance_ville': _lieuNaissanceVilleCtrl.text.trim(), 'lieu_naissance_pays': _lieuNaissancePaysCtrl.text.trim(), if (_frDateToIso(_dateAgrementCtrl.text) != null) 'agreement_date': _frDateToIso(_dateAgrementCtrl.text), if (_capaciteMax() != null) 'max_children': _capaciteMax(), if (_placesAvailable != null) 'places_available': _placesAvailable, 'available': _disponible, }, ); if (!mounted) return; setState(() { _dirty = false; _saving = false; _baselineChildIds = currentIds; _transferFromAmIds.clear(); }); widget.onSaved?.call(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Fiche assistante maternelle enregistrée')), ); } catch (e) { if (!mounted) return; setState(() => _saving = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), ); } } Future _fetchChildrenFromServer() async { try { final refreshed = await UserService.getAssistanteMaternelle(widget.assistante.user.id); final all = await UserService.getEnfants(); final byId = {for (final e in all) e.id: e}; final kids = refreshed.children.map((c) { final full = byId[c.id]; if (full != null) return ParentChildSummary.fromEnfant(full); return c; }).toList(); if (!mounted) return; setState(() { _children = kids; _baselineChildIds = kids.map((c) => c.id).toSet(); _transferFromAmIds.clear(); }); } catch (_) {} } Future _refreshChildrenDetails() async { try { final all = await UserService.getEnfants(); final byId = {for (final e in all) e.id: e}; if (!mounted) return; setState(() { _children = _children.map((c) { final full = byId[c.id]; if (full != null) return ParentChildSummary.fromEnfant(full); return c; }).toList(); }); } catch (_) {} } Future _openChild(ParentChildSummary child) async { try { final enfant = await UserService.getEnfant(child.id); if (!mounted) return; await showDialog( context: context, builder: (ctx) => AdminChildDetailModal( enfant: enfant, onSaved: _refreshChildrenDetails, ), ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), ); } } Future _detachChild(ParentChildSummary child) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Détacher l\'enfant'), content: Text( 'Retirer ${child.fullName} de la fiche de cette assistante ?\n' '(L\'enfant ne sera pas supprimé.)', ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('Annuler'), ), ElevatedButton( onPressed: () => Navigator.pop(ctx, true), style: ValidationModalTheme.primaryElevatedStyle, child: const Text('Détacher'), ), ], ), ); if (confirmed != true || !mounted) return; setState(() { _children = _children.where((c) => c.id != child.id).toList(); _transferFromAmIds.remove(child.id); _syncPlacesAfterChildrenChange(); _dirty = true; }); } Future _attachChild() async { if (_capacityFull || !mounted) return; final selected = await AdminSelectEnfantModal.show( context, excludeIds: _children.map((c) => c.id).toSet(), title: 'Rattacher un enfant', showSansGardeFilter: true, ); if (selected == null || !mounted) return; AssistanteMaternelleModel? previousAm; try { previousAm = await UserService.findAmForEnfant(selected.id); } catch (_) { previousAm = null; } if (!mounted) return; final previousAmId = previousAm?.user.id; final isTransfer = previousAmId != null && previousAmId != widget.assistante.user.id; if (isTransfer) { final amName = previousAm!.user.fullName.trim().isNotEmpty ? previousAm.user.fullName.trim() : 'une autre assistante maternelle'; final gardeLabel = (selected.gender ?? '').trim().toUpperCase() == 'F' ? 'déjà gardée' : 'déjà gardé'; final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Changer d\'affectation'), content: Text( '${selected.fullName} est $gardeLabel par $amName.\n\n' 'Confirmer le transfert vers cette assistante ? ' 'L\'enfant sera détaché de $amName.', ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('Annuler'), ), ElevatedButton( onPressed: () => Navigator.pop(ctx, true), style: ValidationModalTheme.primaryElevatedStyle, child: const Text('Confirmer'), ), ], ), ); if (confirmed != true || !mounted) return; } setState(() { _children = [ ..._children, ParentChildSummary.fromEnfant(selected), ]; if (isTransfer) { _transferFromAmIds[selected.id] = previousAmId!; } _syncPlacesAfterChildrenChange(); _dirty = true; }); } Widget _identityTab() { return SingleChildScrollView( child: IdentityBlock.editable( title: 'Identité et coordonnées', nomController: _nomCtrl, prenomController: _prenomCtrl, telephoneController: _telCtrl, emailController: _emailCtrl, adresseController: _adresseCtrl, codePostalController: _cpCtrl, villeController: _villeCtrl, ), ); } Widget _proFieldsGrid() { return ValidationFormGrid( title: 'Dossier professionnel', rowLayout: _photoProRowLayout, fields: [ ValidationLabeledField( label: 'NIR', field: ValidationEditableField( controller: _nirCtrl, hintText: '15 chiffres', inputFormatters: [ FilteringTextInputFormatter.allow(RegExp(r'[\d\s]')), ], ), ), ValidationLabeledField( label: 'Date de naissance', field: ValidationEditableField( controller: _dateNaissanceCtrl, hintText: 'jj/mm/aaaa', ), ), 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', ), ), ], ); } Widget _proTab() { return LayoutBuilder( builder: (context, c) { final maxRowW = c.maxWidth; final maxRowH = c.maxHeight; final bodyH = maxRowH; final idealPhotoW = bodyH * AdminAmPhotoFrame.idPhotoAspectRatio + 16; final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth) .clamp(0.0, double.infinity); var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 220.0); if (photoW > maxPhotoW) photoW = maxPhotoW; return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( width: photoW, child: AdminAmPhotoFrame( photoUrl: widget.assistante.user.photoUrl, ), ), const SizedBox(width: _photoProGap), Expanded( child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _proFieldsGrid(), SwitchListTile( contentPadding: EdgeInsets.zero, dense: true, visualDensity: VisualDensity.compact, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, title: const Text( 'Disponible pour accueillir', style: TextStyle(fontSize: 13), ), value: _disponible, onChanged: (v) => setState(() { _disponible = v; _dirty = true; }), ), ], ), ), ), ], ); }, ); } Widget _childrenCapacityFields() { final inconsistent = _placesInconsistent(); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ValidationEditableSection( rowLayout: const [2], fields: [ ValidationLabeledField( label: 'Capacité max (enfants)', field: ValidationEditableField( controller: _capaciteCtrl, keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], ), ), ValidationLabeledField( label: 'Places disponibles', field: ValidationReadOnlyField( value: _placesDisplayValue(), error: inconsistent, ), ), ], ), if (inconsistent) ...[ const SizedBox(height: 6), Wrap( crossAxisAlignment: WrapCrossAlignment.center, spacing: 6, runSpacing: 4, children: [ Text( _placesInconsistencyMessage()!, style: TextStyle( fontSize: 12, color: Colors.red.shade700, height: 1.3, ), ), OutlinedButton( onPressed: _saving ? null : _applyPlacesCorrection, style: OutlinedButton.styleFrom( foregroundColor: Colors.red.shade800, disabledForegroundColor: Colors.red.shade300, side: BorderSide(color: Colors.red.shade400), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), minimumSize: const Size(0, 28), tapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: VisualDensity.compact, textStyle: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, ), ), child: const Text('Mettre à jour'), ), ], ), ], ], ); } Widget _childrenTab() { final capacity = (_capaciteMax() ?? 4).clamp(1, 4); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _childrenCapacityFields(), const SizedBox(height: 10), Text( 'Enfants accueillis : ${_children.length}', style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: Colors.black87, ), ), const SizedBox(height: 8), AdminAmChildrenCapacityGrid( children: _children, capacity: capacity, onOpen: _openChild, onDetach: _detachChild, onAttachEmpty: _capacityFull ? null : _attachChild, ), ], ); } Widget _buildFooter() { final isChildrenTab = _tabCtrl.index == 2; final canAttachChild = !_saving && !_capacityFull; return Row( children: [ TextButton( onPressed: _saving ? null : () => Navigator.of(context).pop(), child: const Text('Fermer'), ), const Spacer(), if (isChildrenTab) Tooltip( message: _capacityFull ? 'Capacité maximale atteinte' : 'Rattacher un enfant', child: TextButton.icon( onPressed: canAttachChild ? _attachChild : null, icon: const Icon(Icons.link, size: 18), label: const Text('Rattacher un enfant'), ), ), if (isChildrenTab) const SizedBox(width: 12), ElevatedButton( style: ValidationModalTheme.primaryElevatedStyle, onPressed: !_dirty || _saving ? null : _save, child: _saving ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : Text(_dirty ? 'Sauvegarder' : 'Aucune modification'), ), ], ); } @override Widget build(BuildContext context) { return Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: _modalWidth), child: Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.fromLTRB(18, 16, 4, 0), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _headerTitle(), style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w700, ), ), if (_headerSubtitle() != null) ...[ const SizedBox(height: 4), Text( _headerSubtitle()!, style: const TextStyle( fontSize: 13, color: Colors.black54, ), ), ], ], ), ), const SizedBox(width: 12), SizedBox( width: 160, child: AdminStatusCapsule( statut: _statut, onChanged: (v) => setState(() { _statut = v; _dirty = true; }), ), ), IconButton( padding: EdgeInsets.zero, constraints: const BoxConstraints( minWidth: 40, minHeight: 40, ), icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop(), tooltip: 'Fermer', ), ], ), ), TabBar( controller: _tabCtrl, onTap: (_) => setState(() {}), labelColor: ValidationModalTheme.primaryActionBackground, unselectedLabelColor: Colors.black54, indicatorColor: ValidationModalTheme.primaryActionBackground, tabs: const [ Tab(text: 'Identité'), Tab(text: 'Fiche professionnelle'), Tab(text: 'Enfants accueillis'), ], ), const Divider(height: 1), Padding( padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), child: SizedBox( height: _tabViewHeight(_tabCtrl.index), child: TabBarView( controller: _tabCtrl, children: [ _identityTab(), _proTab(), _childrenTab(), ], ), ), ), Padding( padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), child: _buildFooter(), ), ], ), ), ); } }