From 99a6c17c23d0c55a1cf27d666f57c91d1bec7de7 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 19:43:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(#157):=20enfants=20sans=20responsable=20?= =?UTF-8?q?=E2=80=94=20alerte=20liste=20et=20d=C3=A9tach=20dernier=20paren?= =?UTF-8?q?t.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squash depuis develop : détachement dernier parent autorisé, flag sans_responsable, vigilance liste Enfants, rattachement foyer depuis la fiche, polish détach et compteur foyer interim (#158 à suivre). Co-authored-by: Cursor --- backend/src/routes/enfants/enfants.service.ts | 27 +++++-- backend/src/routes/parents/parents.service.ts | 9 +-- frontend/lib/models/enfant_admin_model.dart | 19 +++++ frontend/lib/models/parent_model.dart | 52 +++++++++++++ frontend/lib/utils/enfant_vigilance.dart | 10 +++ .../admin/common/admin_am_edit_modal.dart | 3 +- .../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 ++- .../admin/parent_managmant_widget.dart | 2 +- 12 files changed, 211 insertions(+), 27 deletions(-) create mode 100644 frontend/lib/utils/enfant_vigilance.dart diff --git a/backend/src/routes/enfants/enfants.service.ts b/backend/src/routes/enfants/enfants.service.ts index bd77d68..4e0d393 100644 --- a/backend/src/routes/enfants/enfants.service.ts +++ b/backend/src/routes/enfants/enfants.service.ts @@ -146,16 +146,27 @@ export class EnfantsService { throw new ForbiddenException('Accès interdit'); } - // Liste des enfants (admin/gestionnaire) - async findAll(): Promise { - return this.childrenRepository.find({ - relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'], - order: { last_name: 'ASC', first_name: 'ASC' }, + /** Flag API #157 — true si aucun lien enfants_parents. */ + private withSansResponsable(child: Children): Children & { sans_responsable: boolean } { + return Object.assign(child, { + sans_responsable: !child.parentLinks || child.parentLinks.length === 0, }); } + // Liste des enfants (admin/gestionnaire) — inclut les orphelins (parentLinks: []) + async findAll(): Promise> { + const children = await this.childrenRepository.find({ + relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'], + order: { last_name: 'ASC', first_name: 'ASC' }, + }); + return children.map((c) => this.withSansResponsable(c)); + } + // Récupérer un enfant par id - async findOne(id: string, currentUser: Users): Promise { + async findOne( + id: string, + currentUser: Users, + ): Promise { const child = await this.childrenRepository.findOne({ where: { id }, relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'], @@ -172,14 +183,14 @@ export class EnfantsService { case RoleType.ADMINISTRATEUR: case RoleType.SUPER_ADMIN: case RoleType.GESTIONNAIRE: - // accès complet + // accès complet (y compris orphelins) break; default: throw new ForbiddenException('Accès interdit'); } - return child; + return this.withSansResponsable(child); } diff --git a/backend/src/routes/parents/parents.service.ts b/backend/src/routes/parents/parents.service.ts index 2696593..ac00f0a 100644 --- a/backend/src/routes/parents/parents.service.ts +++ b/backend/src/routes/parents/parents.service.ts @@ -138,7 +138,9 @@ export class ParentsService { } /** - * Détacher un enfant d'un parent sans supprimer l'enfant. Ticket #115 / doc 28 §6.2. + * Détacher un enfant d'un parent sans supprimer l'enfant. + * Autorise le détachement du dernier responsable (#157) — l'enfant reste listé + * via GET /enfants avec parentLinks vides (alerte front). */ async detachEnfant(parentUserId: string, enfantId: string): Promise { await this.findOne(parentUserId); @@ -150,11 +152,6 @@ export class ParentsService { throw new NotFoundException('Lien parent-enfant introuvable'); } - const totalLinks = await this.parentsChildrenRepository.count({ where: { enfantId } }); - if (totalLinks <= 1) { - throw new BadRequestException('Un enfant doit rester rattaché à au moins un responsable'); - } - await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId }); return this.findOne(parentUserId); } diff --git a/frontend/lib/models/enfant_admin_model.dart b/frontend/lib/models/enfant_admin_model.dart index e07e5b0..e835d26 100644 --- a/frontend/lib/models/enfant_admin_model.dart +++ b/frontend/lib/models/enfant_admin_model.dart @@ -14,6 +14,8 @@ class EnfantAdminModel { final bool consentPhoto; final bool isMultiple; final List parentLinks; + /// Flag API #157 (sinon déduit de [parentLinks]). + final bool? sansResponsable; EnfantAdminModel({ required this.id, @@ -27,6 +29,7 @@ class EnfantAdminModel { this.consentPhoto = false, this.isMultiple = false, this.parentLinks = const [], + this.sansResponsable, }); String get fullName { @@ -37,6 +40,12 @@ class EnfantAdminModel { return '$fn $ln'; } + /// Aucun lien parent valide — ticket #157. + bool get hasNoResponsable { + if (sansResponsable != null) return sansResponsable!; + return !parentLinks.any((l) => l.parentId.trim().isNotEmpty); + } + EnfantAdminModel copyWith({ String? id, String? firstName, @@ -49,6 +58,7 @@ class EnfantAdminModel { bool? consentPhoto, bool? isMultiple, List? parentLinks, + bool? sansResponsable, }) { return EnfantAdminModel( id: id ?? this.id, @@ -62,6 +72,7 @@ class EnfantAdminModel { consentPhoto: consentPhoto ?? this.consentPhoto, isMultiple: isMultiple ?? this.isMultiple, parentLinks: parentLinks ?? this.parentLinks, + sansResponsable: sansResponsable ?? this.sansResponsable, ); } @@ -81,6 +92,13 @@ class EnfantAdminModel { _parseBool(json['consentement_photo']) || _parseBool(json['consentPhoto']); + bool? sansResponsable; + if (json.containsKey('sans_responsable') || + json.containsKey('sansResponsable')) { + sansResponsable = _parseBool(json['sans_responsable']) || + _parseBool(json['sansResponsable']); + } + return EnfantAdminModel( id: (json['id'] ?? '').toString(), firstName: json['first_name'] as String? ?? json['prenom'] as String?, @@ -96,6 +114,7 @@ class EnfantAdminModel { isMultiple: _parseBool(json['is_multiple']) || _parseBool(json['est_multiple']), parentLinks: links, + sansResponsable: sansResponsable, ); } diff --git a/frontend/lib/models/parent_model.dart b/frontend/lib/models/parent_model.dart index 3a0cd5e..040aeda 100644 --- a/frontend/lib/models/parent_model.dart +++ b/frontend/lib/models/parent_model.dart @@ -62,4 +62,56 @@ class ParentModel { return children; } + + /// Nombre d’enfants distincts du foyer (ce parent + co-parent / même dossier). + /// Évite Claire=7 / Thomas=6 quand un lien n’est que sur un des deux (#157). + static int foyerChildrenCount( + ParentModel parent, + List allParents, + ) { + final memberIds = {parent.user.id}; + final coId = parent.coParent?.id.trim(); + if (coId != null && coId.isNotEmpty) memberIds.add(coId); + + final dossier = (parent.user.numeroDossier ?? '').trim(); + + for (final other in allParents) { + if (memberIds.contains(other.user.id)) continue; + final otherCo = other.coParent?.id.trim(); + if (otherCo != null && memberIds.contains(otherCo)) { + memberIds.add(other.user.id); + continue; + } + if (dossier.isNotEmpty && + (other.user.numeroDossier ?? '').trim() == dossier) { + memberIds.add(other.user.id); + final oc = other.coParent?.id.trim(); + if (oc != null && oc.isNotEmpty) memberIds.add(oc); + } + } + + final childIds = {}; + for (final p in allParents) { + if (!memberIds.contains(p.user.id)) continue; + for (final c in p.children) { + final id = c.id.trim(); + if (id.isNotEmpty) childIds.add(id); + } + // Repli si la liste enfants n’est pas hydratée. + if (p.children.isEmpty && p.childrenCount > 0) { + // Impossible de dédupliquer sans IDs : on prend au moins ce compte. + // (évite d’afficher 0 si l’API n’envoie que childrenCount) + } + } + + if (childIds.isNotEmpty) return childIds.length; + + var maxCount = 0; + for (final p in allParents) { + if (!memberIds.contains(p.user.id)) continue; + final n = p.children.isNotEmpty ? p.children.length : p.childrenCount; + if (n > maxCount) maxCount = n; + } + return maxCount; + } } diff --git a/frontend/lib/utils/enfant_vigilance.dart b/frontend/lib/utils/enfant_vigilance.dart new file mode 100644 index 0000000..5488416 --- /dev/null +++ b/frontend/lib/utils/enfant_vigilance.dart @@ -0,0 +1,10 @@ +import 'package:p_tits_pas/models/enfant_admin_model.dart'; + +/// Enfant sans lien parent (orphelins d’affiliation) — ticket #157. +bool enfantHasNoResponsable(EnfantAdminModel enfant) => enfant.hasNoResponsable; + +/// 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_am_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart index 6663be2..7059c2f 100644 --- a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart @@ -446,8 +446,7 @@ class _AdminAmEditModalState extends State 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é.)', + 'Retirer ${child.fullName} de la fiche de cette assistante ?', ), actions: [ TextButton( 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..6365159 100644 --- a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart @@ -283,8 +283,7 @@ class _AdminParentEditModalState extends State { builder: (ctx) => AlertDialog( 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é.)', + 'Retirer ${child.fullName} de la fiche de ce parent ?', ), 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, diff --git a/frontend/lib/widgets/admin/parent_managmant_widget.dart b/frontend/lib/widgets/admin/parent_managmant_widget.dart index dcc2a66..24e0ef5 100644 --- a/frontend/lib/widgets/admin/parent_managmant_widget.dart +++ b/frontend/lib/widgets/admin/parent_managmant_widget.dart @@ -80,7 +80,7 @@ class _ParentManagementWidgetState extends State { onCardTap: () => _openParentDetails(parent), subtitleLines: [ parent.user.email, - 'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.children.isNotEmpty ? parent.children.length : parent.childrenCount}', + 'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${ParentModel.foyerChildrenCount(parent, _parents)}', ], actions: [ IconButton(