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..c612436 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; @@ -311,6 +315,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 +361,7 @@ class _AdminAmEditModalState extends State _dirty = false; _saving = false; _baselineChildIds = currentIds; + _transferFromAmIds.clear(); }); widget.onSaved?.call(); ScaffoldMessenger.of(context).showSnackBar( @@ -377,6 +393,7 @@ class _AdminAmEditModalState extends State setState(() { _children = kids; _baselineChildIds = kids.map((c) => c.id).toSet(); + _transferFromAmIds.clear(); }); } catch (_) {} } @@ -442,55 +459,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(), - ), + 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; }); 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_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..61bcd39 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_select_list_modal.dart @@ -0,0 +1,334 @@ +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 prévu pour 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 »). + final AdminSelectToggleFilter? toggleFilter; + + 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, + }); + + 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, + }) { + 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, + ), + ); + } + + @override + State> createState() => + _AdminSelectListModalState(); +} + +class _AdminSelectListModalState extends State> { + final _searchCtrl = TextEditingController(); + List _all = []; + bool _loading = true; + String? _error; + late bool _toggleOn; + + 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: ', ''); + }); + } + } + + 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, + () => Navigator.of(context).pop(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',