From dcd407a3da6b7e7561c010fd58b7e830b9eecdac Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 16:25:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(#146=E2=80=93#149):=20s=C3=A9lection=20enf?= =?UTF-8?q?ant/AM,=20capacit=C3=A9=20max=20et=20case=20libre.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modales de rattachement partagées, filtre Libre/Sans garde, recalcul des places à l'attach, désactivation si capacité pleine, clic sur case libre. Co-authored-by: Cursor --- frontend/lib/services/user_service.dart | 20 +- frontend/lib/utils/am_vigilance.dart | 12 + .../admin_am_children_capacity_grid.dart | 57 ++- .../admin/common/admin_am_edit_modal.dart | 131 +++++-- .../common/admin_child_detail_modal.dart | 43 +- .../admin/common/admin_enfant_user_card.dart | 14 + .../admin/common/admin_parent_edit_modal.dart | 40 +- .../admin/common/admin_select_am_modal.dart | 218 +++++++++++ .../common/admin_select_enfant_modal.dart | 72 ++++ .../admin/common/admin_select_list_modal.dart | 367 ++++++++++++++++++ .../widgets/admin/common/admin_user_card.dart | 8 +- 11 files changed, 862 insertions(+), 120 deletions(-) create mode 100644 frontend/lib/widgets/admin/common/admin_select_am_modal.dart create mode 100644 frontend/lib/widgets/admin/common/admin_select_enfant_modal.dart create mode 100644 frontend/lib/widgets/admin/common/admin_select_list_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_am_children_capacity_grid.dart b/frontend/lib/widgets/admin/common/admin_am_children_capacity_grid.dart index b77871c..5b28e3c 100644 --- a/frontend/lib/widgets/admin/common/admin_am_children_capacity_grid.dart +++ b/frontend/lib/widgets/admin/common/admin_am_children_capacity_grid.dart @@ -20,6 +20,8 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget { final int capacity; final void Function(ParentChildSummary child) onOpen; final void Function(ParentChildSummary child) onDetach; + /// Clic sur une case libre → même flux que « Rattacher un enfant » (#149). + final VoidCallback? onAttachEmpty; const AdminAmChildrenCapacityGrid({ super.key, @@ -27,6 +29,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget { required this.capacity, required this.onOpen, required this.onDetach, + this.onAttachEmpty, }); @override @@ -78,7 +81,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget { ); } if (index < maxSlots) { - return const _EmptySlot(); + return _EmptySlot(onTap: onAttachEmpty); } return const _UnavailableSlot(); } @@ -129,18 +132,52 @@ class _UnavailableSlot extends StatelessWidget { } } -class _EmptySlot extends StatelessWidget { - const _EmptySlot(); +class _EmptySlot extends StatefulWidget { + final VoidCallback? onTap; + + const _EmptySlot({this.onTap}); + + @override + State<_EmptySlot> createState() => _EmptySlotState(); +} + +class _EmptySlotState extends State<_EmptySlot> { + bool _hovered = false; @override Widget build(BuildContext context) { - return _SlotShell( - backgroundColor: Colors.grey.shade50, - borderColor: Colors.grey.shade300, - child: Center( - child: Text( - 'Place libre', - style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + final clickable = widget.onTap != null; + return MouseRegion( + onEnter: clickable ? (_) => setState(() => _hovered = true) : null, + onExit: clickable ? (_) => setState(() => _hovered = false) : null, + cursor: clickable ? SystemMouseCursors.click : MouseCursor.defer, + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: widget.onTap, + borderRadius: BorderRadius.circular(8), + hoverColor: const Color(0x149CC5C0), + child: _SlotShell( + backgroundColor: _hovered + ? const Color(0xFFF3F0FA) + : Colors.grey.shade50, + borderColor: _hovered + ? const Color(0xFFB8A4D4) + : Colors.grey.shade300, + child: Center( + child: Text( + 'Place libre', + style: TextStyle( + fontSize: 12, + color: _hovered + ? const Color(0xFF6B3FA0) + : Colors.grey.shade500, + fontWeight: _hovered ? FontWeight.w600 : FontWeight.w400, + ), + ), + ), + ), ), ), ); diff --git a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart index b3ca7f1..6663be2 100644 --- a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart @@ -1,7 +1,6 @@ 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/enfant_admin_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'; @@ -11,6 +10,7 @@ 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'; @@ -55,6 +55,10 @@ class _AdminAmEditModalState extends State 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; @@ -234,6 +238,13 @@ class _AdminAmEditModalState extends State 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, @@ -311,6 +322,17 @@ class _AdminAmEditModalState extends State ); } 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, @@ -346,6 +368,7 @@ class _AdminAmEditModalState extends State _dirty = false; _saving = false; _baselineChildIds = currentIds; + _transferFromAmIds.clear(); }); widget.onSaved?.call(); ScaffoldMessenger.of(context).showSnackBar( @@ -377,6 +400,7 @@ class _AdminAmEditModalState extends State setState(() { _children = kids; _baselineChildIds = kids.map((c) => c.id).toSet(); + _transferFromAmIds.clear(); }); } catch (_) {} } @@ -442,55 +466,75 @@ class _AdminAmEditModalState extends State setState(() { _children = _children.where((c) => c.id != child.id).toList(); + _transferFromAmIds.remove(child.id); _syncPlacesAfterChildrenChange(); _dirty = true; }); } Future _attachChild() async { - List all; - try { - all = await UserService.getEnfants(); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), - ); - return; - } - - final linkedIds = _children.map((c) => c.id).toSet(); - final candidates = all.where((e) => !linkedIds.contains(e.id)).toList(); - if (candidates.isEmpty) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Aucun enfant disponible à rattacher')), - ); - return; - } - - if (!mounted) return; - final selected = await showDialog( - context: context, - builder: (ctx) => SimpleDialog( - title: const Text('Rattacher un enfant'), - children: candidates - .map( - (e) => SimpleDialogOption( - onPressed: () => Navigator.pop(ctx, e), - child: Text(e.fullName), - ), - ) - .toList(), - ), + 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; }); @@ -701,6 +745,7 @@ class _AdminAmEditModalState extends State capacity: capacity, onOpen: _openChild, onDetach: _detachChild, + onAttachEmpty: _capacityFull ? null : _attachChild, ), ], ); @@ -708,6 +753,7 @@ class _AdminAmEditModalState extends State Widget _buildFooter() { final isChildrenTab = _tabCtrl.index == 2; + final canAttachChild = !_saving && !_capacityFull; return Row( children: [ TextButton( @@ -716,10 +762,15 @@ class _AdminAmEditModalState extends State ), const Spacer(), if (isChildrenTab) - TextButton.icon( - onPressed: _attachChild, - icon: const Icon(Icons.link, size: 18), - label: const Text('Rattacher un enfant'), + 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( 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_enfant_user_card.dart b/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart index 69dfa65..a9b0f7f 100644 --- a/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart +++ b/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart @@ -34,6 +34,8 @@ class AdminEnfantUserCard extends StatelessWidget { final List subtitleLines; final List actions; final VoidCallback? onCardTap; + final EdgeInsetsGeometry? margin; + final EdgeInsetsGeometry? contentPadding; const AdminEnfantUserCard({ super.key, @@ -42,6 +44,8 @@ class AdminEnfantUserCard extends StatelessWidget { required this.subtitleLines, this.actions = const [], this.onCardTap, + this.margin, + this.contentPadding, }); factory AdminEnfantUserCard.fromEnfant( @@ -49,6 +53,8 @@ class AdminEnfantUserCard extends StatelessWidget { List extraSubtitleLines = const [], List actions = const [], VoidCallback? onCardTap, + EdgeInsetsGeometry? margin, + EdgeInsetsGeometry? contentPadding, }) { final parents = enfant.parentLinks .map((l) => l.parentName ?? 'Parent') @@ -68,6 +74,8 @@ class AdminEnfantUserCard extends StatelessWidget { ), actions: actions, onCardTap: onCardTap, + margin: margin, + contentPadding: contentPadding, ); } @@ -76,6 +84,8 @@ class AdminEnfantUserCard extends StatelessWidget { List extraSubtitleLines = const [], List actions = const [], VoidCallback? onCardTap, + EdgeInsetsGeometry? margin, + EdgeInsetsGeometry? contentPadding, }) { return AdminEnfantUserCard( title: child.fullName, @@ -88,6 +98,8 @@ class AdminEnfantUserCard extends StatelessWidget { ), actions: actions, onCardTap: onCardTap, + margin: margin, + contentPadding: contentPadding, ); } @@ -100,6 +112,8 @@ class AdminEnfantUserCard extends StatelessWidget { subtitleLines: subtitleLines, actions: actions, onCardTap: onCardTap, + margin: margin, + contentPadding: contentPadding, ); } } diff --git a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart index 85c70a6..176f1ce 100644 --- a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/models/parent_model.dart'; @@ -7,6 +6,7 @@ import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.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/common/identity_block.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; @@ -321,41 +321,11 @@ class _AdminParentEditModalState extends State { } Future _attachChild() async { - List all; - try { - all = await UserService.getEnfants(); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), - ); - return; - } - - final linkedIds = _children.map((c) => c.id).toSet(); - final candidates = all.where((e) => !linkedIds.contains(e.id)).toList(); - if (candidates.isEmpty) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Aucun enfant disponible à rattacher')), - ); - return; - } - if (!mounted) return; - final selected = await showDialog( - context: context, - builder: (ctx) => SimpleDialog( - title: const Text('Rattacher un enfant'), - children: candidates - .map( - (e) => SimpleDialogOption( - onPressed: () => Navigator.pop(ctx, e), - child: Text(e.fullName), - ), - ) - .toList(), - ), + final selected = await AdminSelectEnfantModal.show( + context, + excludeIds: _children.map((c) => c.id).toSet(), + title: 'Rattacher un enfant', ); 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_enfant_modal.dart b/frontend/lib/widgets/admin/common/admin_select_enfant_modal.dart new file mode 100644 index 0000000..dafe5d4 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_select_enfant_modal.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/enfant_admin_model.dart'; +import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart'; + +/// Sélection d'un enfant à rattacher (fiche AM / fiche parent) — ticket #146. +/// S'appuie sur [AdminSelectListModal] (shell partagé avec #147). +class AdminSelectEnfantModal { + AdminSelectEnfantModal._(); + + static Future show( + BuildContext context, { + Set excludeIds = const {}, + String title = 'Rattacher un enfant', + /// Affiché uniquement depuis la fiche AM : filtre les enfants déjà en garde. + bool showSansGardeFilter = false, + }) { + return AdminSelectListModal.show( + context, + title: title, + searchHint: 'Rechercher par nom ou prénom…', + emptyMessage: 'Aucun enfant disponible à rattacher', + noResultsMessage: showSansGardeFilter + ? 'Aucun enfant sans garde pour cette recherche' + : 'Aucun résultat pour cette recherche', + toggleFilter: showSansGardeFilter + ? AdminSelectToggleFilter( + label: 'Sans garde', + initialValue: true, + whenEnabled: (e) => + normalizeEnfantStatus(e.status) == 'sans_garde', + ) + : null, + loadItems: () async { + final list = await UserService.getEnfants(); + return list + .where((e) => !excludeIds.contains(e.id)) + .toList() + ..sort( + (a, b) => + a.fullName.toLowerCase().compareTo(b.fullName.toLowerCase()), + ); + }, + matchesQuery: (e, q) { + final name = e.fullName.toLowerCase(); + final fn = (e.firstName ?? '').toLowerCase(); + final ln = (e.lastName ?? '').toLowerCase(); + return name.contains(q) || fn.contains(q) || ln.contains(q); + }, + itemBuilder: (context, e, onSelect) { + return AdminEnfantUserCard.fromEnfant( + e, + onCardTap: onSelect, + margin: const EdgeInsets.only(bottom: 4), + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + actions: [ + IconButton( + icon: const Icon(Icons.add_link), + tooltip: 'Rattacher', + onPressed: onSelect, + ), + ], + ); + }, + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_select_list_modal.dart b/frontend/lib/widgets/admin/common/admin_select_list_modal.dart new file mode 100644 index 0000000..4fdc70c --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_select_list_modal.dart @@ -0,0 +1,367 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; + +/// Filtre optionnel (switch) sur la même ligne que la barre de recherche. +class AdminSelectToggleFilter { + final String label; + final bool initialValue; + + /// Si le switch est activé, ne garde que les éléments pour lesquels + /// [whenEnabled] renvoie `true`. + final bool Function(T item) whenEnabled; + + const AdminSelectToggleFilter({ + required this.label, + required this.whenEnabled, + this.initialValue = true, + }); +} + +/// Shell générique « rechercher + liste + sélection » pour les modales admin. +/// 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; + final Future> Function() loadItems; + final bool Function(T item, String query) matchesQuery; + final Widget Function( + BuildContext context, + T item, + VoidCallback onSelect, + ) itemBuilder; + final String emptyMessage; + final String noResultsMessage; + final double modalWidth; + + /// Hauteur d'une carte (pour dimensionner la liste à ≥ [minVisibleCards]). + final double cardExtent; + + /// Nombre minimum de cartes visibles dans la zone scrollable. + final int minVisibleCards; + + /// 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, + required this.loadItems, + required this.matchesQuery, + required this.itemBuilder, + this.searchHint = 'Rechercher…', + this.emptyMessage = 'Aucun élément disponible', + this.noResultsMessage = 'Aucun résultat pour cette recherche', + this.modalWidth = 930, + this.cardExtent = 52, + this.minVisibleCards = 8, + this.toggleFilter, + this.resolveSelect, + }); + + static Future show( + BuildContext context, { + required String title, + required Future> Function() loadItems, + required bool Function(T item, String query) matchesQuery, + required Widget Function( + BuildContext context, + T item, + VoidCallback onSelect, + ) itemBuilder, + String searchHint = 'Rechercher…', + String emptyMessage = 'Aucun élément disponible', + String noResultsMessage = 'Aucun résultat pour cette recherche', + double modalWidth = 930, + double cardExtent = 52, + int minVisibleCards = 8, + AdminSelectToggleFilter? toggleFilter, + Future Function( + BuildContext context, + T item, + Future Function() reload, + )? resolveSelect, + }) { + return showDialog( + context: context, + builder: (ctx) => AdminSelectListModal( + title: title, + loadItems: loadItems, + matchesQuery: matchesQuery, + itemBuilder: itemBuilder, + searchHint: searchHint, + emptyMessage: emptyMessage, + noResultsMessage: noResultsMessage, + modalWidth: modalWidth, + cardExtent: cardExtent, + minVisibleCards: minVisibleCards, + toggleFilter: toggleFilter, + resolveSelect: resolveSelect, + ), + ); + } + + @override + State> createState() => + _AdminSelectListModalState(); +} + +class _AdminSelectListModalState extends State> { + final _searchCtrl = TextEditingController(); + List _all = []; + bool _loading = true; + String? _error; + late bool _toggleOn; + bool _resolving = false; + + double get _listHeight => + widget.cardExtent * widget.minVisibleCards; + + @override + void initState() { + super.initState(); + _toggleOn = widget.toggleFilter?.initialValue ?? false; + _searchCtrl.addListener(() => setState(() {})); + _load(); + } + + @override + void dispose() { + _searchCtrl.dispose(); + super.dispose(); + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final list = await widget.loadItems(); + if (!mounted) return; + setState(() { + _all = list; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _error = e.toString().replaceFirst('Exception: ', ''); + }); + } + } + + 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; + if (toggle != null && _toggleOn) { + list = list.where(toggle.whenEnabled).toList(); + } + final q = _searchCtrl.text.trim().toLowerCase(); + if (q.isEmpty) return list; + return list.where((item) => widget.matchesQuery(item, q)).toList(); + } + + @override + Widget build(BuildContext context) { + final toggle = widget.toggleFilter; + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: widget.modalWidth), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 4, 0), + child: Row( + children: [ + Expanded( + child: Text( + widget.title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + tooltip: 'Fermer', + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: TextField( + controller: _searchCtrl, + decoration: InputDecoration( + isDense: true, + hintText: widget.searchHint, + prefixIcon: const Icon(Icons.search, size: 20), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + ), + ), + ), + if (toggle != null) ...[ + const SizedBox(width: 12), + Text( + toggle.label, + style: const TextStyle(fontSize: 13), + ), + const SizedBox(width: 4), + Switch( + value: _toggleOn, + activeColor: + ValidationModalTheme.primaryActionBackground, + onChanged: (v) => setState(() => _toggleOn = v), + ), + ], + ], + ), + const SizedBox(height: 12), + SizedBox( + height: _listHeight, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: _buildBody(), + ), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + const Spacer(), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Annuler'), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildBody() { + if (_loading) { + return const Center( + child: CircularProgressIndicator( + color: ValidationModalTheme.primaryActionBackground, + ), + ); + } + + if (_error != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 40, color: Colors.red.shade400), + const SizedBox(height: 12), + Text( + _error!, + textAlign: TextAlign.center, + style: TextStyle(color: Colors.red.shade700), + ), + const SizedBox(height: 12), + TextButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh), + label: const Text('Réessayer'), + ), + ], + ), + ), + ); + } + + final items = _filtered; + if (_all.isEmpty) { + return Center( + child: Text( + widget.emptyMessage, + style: const TextStyle(fontSize: 14, color: Colors.black54), + ), + ); + } + + if (items.isEmpty) { + return Center( + child: Text( + widget.noResultsMessage, + style: const TextStyle(fontSize: 14, color: Colors.black54), + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), + itemExtent: widget.cardExtent, + itemCount: items.length, + itemBuilder: (context, i) { + final item = items[i]; + return widget.itemBuilder( + context, + item, + () => _handleSelect(item), + ); + }, + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_user_card.dart b/frontend/lib/widgets/admin/common/admin_user_card.dart index 2d02650..f7cbc1b 100644 --- a/frontend/lib/widgets/admin/common/admin_user_card.dart +++ b/frontend/lib/widgets/admin/common/admin_user_card.dart @@ -15,6 +15,7 @@ class AdminUserCard extends StatefulWidget { final String? vigilanceTooltip; final VoidCallback? onCardTap; final EdgeInsetsGeometry? margin; + final EdgeInsetsGeometry? contentPadding; const AdminUserCard({ super.key, @@ -30,6 +31,7 @@ class AdminUserCard extends StatefulWidget { this.vigilanceTooltip, this.onCardTap, this.margin, + this.contentPadding, }); @override @@ -69,7 +71,8 @@ class _AdminUserCardState extends State { side: BorderSide(color: widget.borderColor ?? Colors.grey.shade300), ), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + padding: widget.contentPadding ?? + const EdgeInsets.symmetric(horizontal: 12, vertical: 9), child: Row( children: [ _buildAvatar(avatarUrl), @@ -88,7 +91,10 @@ class _AdminUserCardState extends State { Expanded( child: Row( children: [ + // flex: 0 → largeur du nom ; le reste va aux infos + // (évite le partage 50/50 qui tronque « Responsables »). Flexible( + flex: 0, fit: FlexFit.loose, child: Text( widget.title.isNotEmpty ? widget.title : 'Sans nom',