From f194b5f9e8994cbe9c1273c723444e0449d0fac0 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 16:11:55 +0200 Subject: [PATCH] =?UTF-8?q?feat(#147):=20modale=20de=20s=C3=A9lection=20d'?= =?UTF-8?q?AM=20depuis=20la=20fiche=20enfant.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Réutilise AdminSelectListModal avec filtre Libre, cartes saturées en rouge, avertissement + lien fiche AM, et recalcul des places à chaque rattachement. Co-authored-by: Cursor --- frontend/lib/services/user_service.dart | 20 +- frontend/lib/utils/am_vigilance.dart | 12 + .../common/admin_child_detail_modal.dart | 43 +--- .../admin/common/admin_select_am_modal.dart | 218 ++++++++++++++++++ .../admin/common/admin_select_list_modal.dart | 39 +++- 5 files changed, 295 insertions(+), 37 deletions(-) create mode 100644 frontend/lib/widgets/admin/common/admin_select_am_modal.dart diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index f190463..b13d50b 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -8,6 +8,7 @@ import 'package:p_tits_pas/models/pending_family.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/api/tokenService.dart'; +import 'package:p_tits_pas/utils/am_vigilance.dart'; class DocumentActifInfo { final String id; @@ -688,7 +689,24 @@ class UserService { if (response.statusCode != 200 && response.statusCode != 201) { throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant')); } - return _amModelFromBody(response.body); + final am = _amModelFromBody(response.body); + // Recalcule toujours places_available (capacité − enfants) après attachement. + return syncAmPlacesAvailable(am); + } + + /// Aligne `places_available` sur capacité − enfants rattachés. + static Future syncAmPlacesAvailable( + AssistanteMaternelleModel am, + ) async { + final expected = amExpectedPlacesAvailable( + maxChildren: am.maxChildren, + childrenCount: am.children.length, + ); + if (expected == null || am.placesAvailable == expected) return am; + return updateAmFiche( + amUserId: am.user.id, + body: {'places_available': expected}, + ); } static Future detachEnfantFromAm({ diff --git a/frontend/lib/utils/am_vigilance.dart b/frontend/lib/utils/am_vigilance.dart index 944dc93..85a9bf6 100644 --- a/frontend/lib/utils/am_vigilance.dart +++ b/frontend/lib/utils/am_vigilance.dart @@ -9,6 +9,18 @@ int? amExpectedPlacesAvailable({ return (maxChildren - childrenCount).clamp(0, maxChildren); } +/// True s'il reste au moins une place d'accueil (capacité − enfants). +bool amHasFreePlace(AssistanteMaternelleModel am) { + final expected = amExpectedPlacesAvailable( + maxChildren: am.maxChildren, + childrenCount: am.children.length, + ); + if (expected != null) return expected > 0; + final places = am.placesAvailable; + if (places != null) return places > 0; + return true; +} + /// True si la valeur déclarée par l'AM ne correspond pas au calcul métier. bool amHasPlacesInconsistency({ required int? maxChildren, diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart index b1dac62..cae3ca8 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -9,6 +9,7 @@ import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_select_am_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/common/auth_network_image.dart'; @@ -399,49 +400,25 @@ class _AdminChildDetailModalState extends State { } Future _attachAm() async { - List all; - try { - all = await UserService.getAssistantesMaternelles(); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), - ); - return; - } - if (_linkedAm != null && !_isSansGarde) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Détachez l\'assistante actuelle avant d\'en choisir une autre'), + content: Text( + 'Détachez l\'assistante actuelle avant d\'en choisir une autre', + ), ), ); return; } - if (all.isEmpty) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Aucune assistante maternelle disponible')), - ); - return; - } - if (!mounted) return; - final selected = await showDialog( - context: context, - builder: (ctx) => SimpleDialog( - title: const Text('Choisir une assistante maternelle'), - children: all - .map( - (am) => SimpleDialogOption( - onPressed: () => Navigator.pop(ctx, am), - child: Text(am.user.fullName), - ), - ) - .toList(), - ), + final selected = await AdminSelectAmModal.show( + context, + excludeIds: { + if (_linkedAm != null) _linkedAm!.user.id, + }, + title: 'Choisir une assistante maternelle', ); if (selected == null || !mounted) return; diff --git a/frontend/lib/widgets/admin/common/admin_select_am_modal.dart b/frontend/lib/widgets/admin/common/admin_select_am_modal.dart new file mode 100644 index 0000000..ab46601 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_select_am_modal.dart @@ -0,0 +1,218 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; +import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/utils/am_vigilance.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; + +List _amSelectSubtitleLines(AssistanteMaternelleModel am) { + final lines = []; + final zone = (am.residenceCity ?? '').trim(); + if (zone.isNotEmpty) lines.add('Zone : $zone'); + final agrement = (am.approvalNumber ?? '').trim(); + if (agrement.isNotEmpty) lines.add('Agrément : $agrement'); + final max = am.maxChildren; + final free = amExpectedPlacesAvailable( + maxChildren: max, + childrenCount: am.children.length, + ) ?? + am.placesAvailable; + if (free != null || max != null) { + lines.add('Places libres : ${free ?? '–'} / capa. ${max ?? '–'}'); + } + lines.add('${am.children.length} enfant(s)'); + return lines; +} + +/// Sélection d'une AM à rattacher (fiche enfant) — ticket #147. +/// S'appuie sur [AdminSelectListModal] (shell partagé avec #146). +class AdminSelectAmModal { + AdminSelectAmModal._(); + + static Future show( + BuildContext context, { + Set excludeIds = const {}, + String title = 'Choisir une assistante maternelle', + }) { + return AdminSelectListModal.show( + context, + title: title, + searchHint: 'Rechercher par nom, prénom ou zone…', + emptyMessage: 'Aucune assistante maternelle disponible', + noResultsMessage: 'Aucune AM avec place libre pour cette recherche', + toggleFilter: const AdminSelectToggleFilter( + label: 'Libre', + initialValue: true, + whenEnabled: amHasFreePlace, + ), + loadItems: () async { + final list = await UserService.getAssistantesMaternelles(); + return list + .where((am) => !excludeIds.contains(am.user.id)) + .toList() + ..sort( + (a, b) => a.user.fullName + .toLowerCase() + .compareTo(b.user.fullName.toLowerCase()), + ); + }, + matchesQuery: (am, q) { + final u = am.user; + final name = u.fullName.toLowerCase(); + final fn = (u.prenom ?? '').toLowerCase(); + final ln = (u.nom ?? '').toLowerCase(); + final zone = (am.residenceCity ?? '').toLowerCase(); + final agrement = (am.approvalNumber ?? '').toLowerCase(); + return name.contains(q) || + fn.contains(q) || + ln.contains(q) || + zone.contains(q) || + agrement.contains(q); + }, + resolveSelect: (ctx, am, reload) => + _resolveAmSelection(ctx, am, reload), + itemBuilder: (context, am, onSelect) { + final full = !amHasFreePlace(am); + return AdminUserCard( + title: am.user.fullName, + avatarUrl: am.user.photoUrl, + fallbackIcon: Icons.face, + subtitleLines: _amSelectSubtitleLines(am), + vigilanceTooltip: amPlacesVigilanceMessage(am), + onCardTap: onSelect, + margin: const EdgeInsets.only(bottom: 4), + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + backgroundColor: full ? const Color(0xFFFFEBEE) : null, + borderColor: full ? Colors.red.shade200 : null, + actions: [ + IconButton( + icon: const Icon(Icons.add_link), + tooltip: 'Rattacher', + onPressed: onSelect, + ), + ], + ); + }, + ); + } + + static Future _resolveAmSelection( + BuildContext context, + AssistanteMaternelleModel am, + Future Function() reloadList, + ) async { + if (amHasFreePlace(am)) return am; + + final selected = await showDialog( + context: context, + builder: (ctx) => _AmNoPlaceWarningDialog(initialAm: am), + ); + await reloadList(); + return selected; + } +} + +/// Avertissement AM saturée + lien vers la fiche pour ajuster les places. +class _AmNoPlaceWarningDialog extends StatefulWidget { + final AssistanteMaternelleModel initialAm; + + const _AmNoPlaceWarningDialog({required this.initialAm}); + + @override + State<_AmNoPlaceWarningDialog> createState() => + _AmNoPlaceWarningDialogState(); +} + +class _AmNoPlaceWarningDialogState extends State<_AmNoPlaceWarningDialog> { + late AssistanteMaternelleModel _am; + TapGestureRecognizer? _linkRecognizer; + + @override + void initState() { + super.initState(); + _am = widget.initialAm; + _linkRecognizer = TapGestureRecognizer()..onTap = _openAmFiche; + } + + @override + void dispose() { + _linkRecognizer?.dispose(); + super.dispose(); + } + + Future _openAmFiche() async { + if (!mounted) return; + await showDialog( + context: context, + builder: (ctx) => AdminAmEditModal( + assistante: _am, + onSaved: () async { + try { + final fresh = + await UserService.getAssistanteMaternelle(_am.user.id); + if (mounted) setState(() => _am = fresh); + } catch (_) {} + }, + ), + ); + if (!mounted) return; + + try { + final fresh = await UserService.getAssistanteMaternelle(_am.user.id); + if (!mounted) return; + setState(() => _am = fresh); + if (amHasFreePlace(fresh)) { + Navigator.of(context).pop(fresh); + } + } catch (_) {} + } + + @override + Widget build(BuildContext context) { + final name = _am.user.fullName.trim().isNotEmpty + ? _am.user.fullName.trim() + : 'Cette assistante maternelle'; + const linkColor = ValidationModalTheme.primaryActionBackground; + + return AlertDialog( + title: const Text('Plus de place disponible'), + content: Text.rich( + TextSpan( + style: const TextStyle(fontSize: 14, color: Colors.black87, height: 1.4), + children: [ + TextSpan( + text: '$name n\'a plus de place libre pour accueillir ' + 'un enfant supplémentaire.\n\n', + ), + const TextSpan(text: 'Vous pouvez '), + TextSpan( + text: 'ouvrir sa fiche', + style: TextStyle( + color: linkColor, + decoration: TextDecoration.underline, + fontWeight: FontWeight.w600, + ), + recognizer: _linkRecognizer, + ), + const TextSpan( + text: ' pour modifier la capacité ou les places, ' + 'puis la sélectionner si une place se libère.', + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Fermer'), + ), + ], + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_select_list_modal.dart b/frontend/lib/widgets/admin/common/admin_select_list_modal.dart index 61bcd39..4fdc70c 100644 --- a/frontend/lib/widgets/admin/common/admin_select_list_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_select_list_modal.dart @@ -18,7 +18,7 @@ class AdminSelectToggleFilter { } /// Shell générique « rechercher + liste + sélection » pour les modales admin. -/// Utilisé par la sélection d'enfant (#146) et prévu pour la sélection d'AM (#147). +/// Utilisé par la sélection d'enfant (#146) et la sélection d'AM (#147). class AdminSelectListModal extends StatefulWidget { final String title; final String searchHint; @@ -39,9 +39,17 @@ class AdminSelectListModal extends StatefulWidget { /// Nombre minimum de cartes visibles dans la zone scrollable. final int minVisibleCards; - /// Switch optionnel à droite du champ de recherche (ex. « Sans garde »). + /// Switch optionnel à droite du champ de recherche (ex. « Sans garde », « Libre »). final AdminSelectToggleFilter? toggleFilter; + /// Si fourni, appelé avant de valider la sélection. + /// Retourne l'élément à pop (éventuellement rafraîchi), ou `null` pour annuler. + final Future Function( + BuildContext context, + T item, + Future Function() reload, + )? resolveSelect; + const AdminSelectListModal({ super.key, required this.title, @@ -55,6 +63,7 @@ class AdminSelectListModal extends StatefulWidget { this.cardExtent = 52, this.minVisibleCards = 8, this.toggleFilter, + this.resolveSelect, }); static Future show( @@ -74,6 +83,11 @@ class AdminSelectListModal extends StatefulWidget { double cardExtent = 52, int minVisibleCards = 8, AdminSelectToggleFilter? toggleFilter, + Future Function( + BuildContext context, + T item, + Future Function() reload, + )? resolveSelect, }) { return showDialog( context: context, @@ -89,6 +103,7 @@ class AdminSelectListModal extends StatefulWidget { cardExtent: cardExtent, minVisibleCards: minVisibleCards, toggleFilter: toggleFilter, + resolveSelect: resolveSelect, ), ); } @@ -104,6 +119,7 @@ class _AdminSelectListModalState extends State> { bool _loading = true; String? _error; late bool _toggleOn; + bool _resolving = false; double get _listHeight => widget.cardExtent * widget.minVisibleCards; @@ -143,6 +159,23 @@ class _AdminSelectListModalState extends State> { } } + Future _handleSelect(T item) async { + if (_resolving) return; + final resolve = widget.resolveSelect; + if (resolve == null) { + Navigator.of(context).pop(item); + return; + } + setState(() => _resolving = true); + try { + final chosen = await resolve(context, item, _load); + if (!mounted || chosen == null) return; + Navigator.of(context).pop(chosen); + } finally { + if (mounted) setState(() => _resolving = false); + } + } + List get _filtered { var list = _all; final toggle = widget.toggleFilter; @@ -326,7 +359,7 @@ class _AdminSelectListModalState extends State> { return widget.itemBuilder( context, item, - () => Navigator.of(context).pop(item), + () => _handleSelect(item), ); }, );