From 2ececa711b9784b3a8d5f15e9444757d041d2143 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 19:02:21 +0200 Subject: [PATCH] feat(#157): alerte enfants sans responsable + rattachement foyer. Co-authored-by: Cursor --- frontend/lib/models/enfant_admin_model.dart | 4 + frontend/lib/utils/enfant_vigilance.dart | 12 +++ .../common/admin_child_detail_modal.dart | 75 +++++++++++++++++-- .../admin/common/admin_enfant_user_card.dart | 15 ++++ .../admin/common/admin_parent_edit_modal.dart | 3 +- .../common/admin_select_famille_modal.dart | 14 +++- .../admin/enfant_management_widget.dart | 9 ++- 7 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 frontend/lib/utils/enfant_vigilance.dart diff --git a/frontend/lib/models/enfant_admin_model.dart b/frontend/lib/models/enfant_admin_model.dart index e07e5b0..9d591eb 100644 --- a/frontend/lib/models/enfant_admin_model.dart +++ b/frontend/lib/models/enfant_admin_model.dart @@ -37,6 +37,10 @@ class EnfantAdminModel { return '$fn $ln'; } + /// Aucun lien parent valide (`enfants_parents` vide) — ticket #157. + bool get hasNoResponsable => + !parentLinks.any((l) => l.parentId.trim().isNotEmpty); + EnfantAdminModel copyWith({ String? id, String? firstName, diff --git a/frontend/lib/utils/enfant_vigilance.dart b/frontend/lib/utils/enfant_vigilance.dart new file mode 100644 index 0000000..354efae --- /dev/null +++ b/frontend/lib/utils/enfant_vigilance.dart @@ -0,0 +1,12 @@ +import 'package:p_tits_pas/models/enfant_admin_model.dart'; + +/// Enfant sans lien parent (orphelins d’affiliation) — ticket #157. +bool enfantHasNoResponsable(EnfantAdminModel enfant) { + return !enfant.parentLinks.any((l) => l.parentId.trim().isNotEmpty); +} + +/// Message vigilance liste Enfants (même usage que [amPlacesVigilanceMessage]). +String? enfantSansResponsableVigilanceMessage(EnfantAdminModel enfant) { + if (!enfantHasNoResponsable(enfant)) return null; + return 'Aucun responsable rattaché — à rattacher à un foyer'; +} 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 1a8a6c7..e160b25 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -65,6 +65,9 @@ class _AdminChildDetailModalState extends State { /// Famille choisie en mode création (#132). AdminFamilleFoyer? _selectedFamily; + /// Liens parents locaux (édition) — mis à jour après rattachement foyer (#157). + List? _localParentLinks; + /// Photo locale (création) — upload multipart `photo`. Uint8List? _photoBytes; String? _photoFilename; @@ -262,10 +265,14 @@ class _AdminChildDetailModalState extends State { } List get _parentLinks => - (widget.enfant?.parentLinks ?? const []) + (_localParentLinks ?? + widget.enfant?.parentLinks ?? + const []) .where((l) => l.parentId.trim().isNotEmpty) .toList(); + bool get _isOrphan => !widget.isCreating && _parentLinks.isEmpty; + Future _openParent(EnfantParentLink link) async { if (_busy) return; final id = link.parentId.trim(); @@ -580,6 +587,7 @@ class _AdminChildDetailModalState extends State { if (!enfantStatusValues.contains(_status)) { _status = 'sans_garde'; } + _localParentLinks = enfant.parentLinks; _coerceGenderForStatus(); }); } catch (_) { @@ -960,7 +968,9 @@ class _AdminChildDetailModalState extends State { padding: const EdgeInsets.only(top: 16), child: Align( alignment: Alignment.bottomCenter, - child: _placementBlock(), + child: SingleChildScrollView( + child: _placementBlock(), + ), ), ), ), @@ -1219,6 +1229,24 @@ class _AdminChildDetailModalState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + if (_isOrphan) ...[ + Text( + 'Sélection du dossier de la famille', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.red.shade800, + ), + ), + const SizedBox(height: 4), + Text( + 'Aucun responsable rattaché — choisissez un foyer', + style: TextStyle(fontSize: 12, color: Colors.red.shade700), + ), + const SizedBox(height: 8), + _familyPlacementSection(), + const SizedBox(height: 16), + ], Text( _placementTitle, style: TextStyle( @@ -1240,10 +1268,45 @@ class _AdminChildDetailModalState extends State { title: 'Choisir une famille', ); if (selected == null || !mounted) return; - setState(() { - _selectedFamily = selected; - _dirty = true; - }); + + if (widget.isCreating) { + setState(() { + _selectedFamily = selected; + _dirty = true; + }); + return; + } + + // Édition orphelin (#157) : rattache immédiatement au foyer. + final enfantId = widget.enfant?.id; + if (enfantId == null || enfantId.isEmpty) return; + + setState(() => _saving = true); + try { + for (final parentId in selected.parentUserIds) { + await UserService.attachEnfantToParent( + parentUserId: parentId, + enfantId: enfantId, + ); + } + final refreshed = await UserService.getEnfant(enfantId); + if (!mounted) return; + setState(() { + _localParentLinks = refreshed.parentLinks; + _selectedFamily = selected; + _saving = false; + }); + widget.onSaved?.call(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Enfant rattaché au foyer')), + ); + } catch (e) { + if (!mounted) return; + setState(() => _saving = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } } Widget _familyPlacementSection() { 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 a9b0f7f..04b22e5 100644 --- a/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart +++ b/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart @@ -3,6 +3,7 @@ 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/date_display_utils.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart'; +import 'package:p_tits_pas/utils/enfant_vigilance.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; List enfantAdminSubtitleLines({ @@ -36,6 +37,8 @@ class AdminEnfantUserCard extends StatelessWidget { final VoidCallback? onCardTap; final EdgeInsetsGeometry? margin; final EdgeInsetsGeometry? contentPadding; + final Color? borderColor; + final String? vigilanceTooltip; const AdminEnfantUserCard({ super.key, @@ -46,6 +49,8 @@ class AdminEnfantUserCard extends StatelessWidget { this.onCardTap, this.margin, this.contentPadding, + this.borderColor, + this.vigilanceTooltip, }); factory AdminEnfantUserCard.fromEnfant( @@ -55,10 +60,13 @@ class AdminEnfantUserCard extends StatelessWidget { VoidCallback? onCardTap, EdgeInsetsGeometry? margin, EdgeInsetsGeometry? contentPadding, + Color? borderColor, + String? vigilanceTooltip, }) { final parents = enfant.parentLinks .map((l) => l.parentName ?? 'Parent') .join(', '); + final orphan = enfantHasNoResponsable(enfant); return AdminEnfantUserCard( title: enfant.fullName, photoUrl: enfant.photoUrl, @@ -69,6 +77,7 @@ class AdminEnfantUserCard extends StatelessWidget { gender: enfant.gender, extra: [ if (parents.isNotEmpty) 'Responsables : $parents', + if (orphan) 'Aucun responsable rattaché', ...extraSubtitleLines, ], ), @@ -76,6 +85,10 @@ class AdminEnfantUserCard extends StatelessWidget { onCardTap: onCardTap, margin: margin, contentPadding: contentPadding, + borderColor: borderColor ?? + (orphan ? Colors.red.shade300 : null), + vigilanceTooltip: vigilanceTooltip ?? + enfantSansResponsableVigilanceMessage(enfant), ); } @@ -114,6 +127,8 @@ class AdminEnfantUserCard extends StatelessWidget { onCardTap: onCardTap, margin: margin, contentPadding: contentPadding, + borderColor: borderColor, + vigilanceTooltip: vigilanceTooltip, ); } } 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 176f1ce..e9bdab7 100644 --- a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart @@ -284,7 +284,8 @@ class _AdminParentEditModalState extends State { title: const Text('Détacher l\'enfant'), content: Text( 'Retirer ${child.fullName} de la fiche de ce parent ?\n' - '(L\'enfant ne sera pas supprimé.)', + '(L\'enfant ne sera pas supprimé. S\'il n\'a plus d\'autre ' + 'responsable, il restera visible dans l\'onglet Enfants avec une alerte.)', ), actions: [ TextButton( diff --git a/frontend/lib/widgets/admin/common/admin_select_famille_modal.dart b/frontend/lib/widgets/admin/common/admin_select_famille_modal.dart index ebfc947..4fbd3a3 100644 --- a/frontend/lib/widgets/admin/common/admin_select_famille_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_select_famille_modal.dart @@ -4,10 +4,12 @@ import 'package:p_tits_pas/services/user_service.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'; -/// Foyer / famille sélectionnable pour rattacher un nouvel enfant (#132). +/// Foyer / famille sélectionnable pour rattacher un nouvel enfant (#132 / #157). class AdminFamilleFoyer { /// Parent pivot pour `POST /enfants` (`parent_user_id`). final String pivotParentUserId; + /// Co-parent éventuel (rattachement foyer #157). + final String? coParentUserId; final String? numeroDossier; final String displayTitle; final List parentNames; @@ -16,9 +18,18 @@ class AdminFamilleFoyer { required this.pivotParentUserId, required this.displayTitle, required this.parentNames, + this.coParentUserId, this.numeroDossier, }); + /// Parents du foyer à lier à l’enfant (pivot puis co-parent). + List get parentUserIds { + final ids = [pivotParentUserId]; + final co = (coParentUserId ?? '').trim(); + if (co.isNotEmpty && co != pivotParentUserId) ids.add(co); + return ids; + } + String get subtitle { final parts = []; final dossier = (numeroDossier ?? '').trim(); @@ -61,6 +72,7 @@ List buildFamilleFoyers(List parents) { foyers.add( AdminFamilleFoyer( pivotParentUserId: p.user.id, + coParentUserId: co?.id, numeroDossier: dossier.isNotEmpty ? dossier : null, displayTitle: title, parentNames: names, diff --git a/frontend/lib/widgets/admin/enfant_management_widget.dart b/frontend/lib/widgets/admin/enfant_management_widget.dart index 9aaf59b..5ef03f1 100644 --- a/frontend/lib/widgets/admin/enfant_management_widget.dart +++ b/frontend/lib/widgets/admin/enfant_management_widget.dart @@ -72,7 +72,14 @@ class _EnfantManagementWidgetState extends State { normalizeEnfantStatus(e.status) == normalizeEnfantStatus(widget.statusFilter); return matchesName && matchesStatus; - }).toList(); + }).toList() + ..sort((a, b) { + // Orphelins (#157) en tête, puis ordre alphabétique. + final ao = a.hasNoResponsable ? 0 : 1; + final bo = b.hasNoResponsable ? 0 : 1; + if (ao != bo) return ao.compareTo(bo); + return a.fullName.toLowerCase().compareTo(b.fullName.toLowerCase()); + }); return UserList( isLoading: _isLoading,