feat(#157): alerte enfants sans responsable + rattachement foyer.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
MARTIN Julien 2026-07-17 19:02:21 +02:00
parent 7a3d997ff9
commit 2ececa711b
7 changed files with 123 additions and 9 deletions

View File

@ -37,6 +37,10 @@ class EnfantAdminModel {
return '$fn $ln'; return '$fn $ln';
} }
/// Aucun lien parent valide (`enfants_parents` vide) ticket #157.
bool get hasNoResponsable =>
!parentLinks.any((l) => l.parentId.trim().isNotEmpty);
EnfantAdminModel copyWith({ EnfantAdminModel copyWith({
String? id, String? id,
String? firstName, String? firstName,

View File

@ -0,0 +1,12 @@
import 'package:p_tits_pas/models/enfant_admin_model.dart';
/// Enfant sans lien parent (orphelins daffiliation) 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';
}

View File

@ -65,6 +65,9 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
/// Famille choisie en mode création (#132). /// Famille choisie en mode création (#132).
AdminFamilleFoyer? _selectedFamily; AdminFamilleFoyer? _selectedFamily;
/// Liens parents locaux (édition) mis à jour après rattachement foyer (#157).
List<EnfantParentLink>? _localParentLinks;
/// Photo locale (création) upload multipart `photo`. /// Photo locale (création) upload multipart `photo`.
Uint8List? _photoBytes; Uint8List? _photoBytes;
String? _photoFilename; String? _photoFilename;
@ -262,10 +265,14 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
} }
List<EnfantParentLink> get _parentLinks => List<EnfantParentLink> get _parentLinks =>
(widget.enfant?.parentLinks ?? const <EnfantParentLink>[]) (_localParentLinks ??
widget.enfant?.parentLinks ??
const <EnfantParentLink>[])
.where((l) => l.parentId.trim().isNotEmpty) .where((l) => l.parentId.trim().isNotEmpty)
.toList(); .toList();
bool get _isOrphan => !widget.isCreating && _parentLinks.isEmpty;
Future<void> _openParent(EnfantParentLink link) async { Future<void> _openParent(EnfantParentLink link) async {
if (_busy) return; if (_busy) return;
final id = link.parentId.trim(); final id = link.parentId.trim();
@ -580,6 +587,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
if (!enfantStatusValues.contains(_status)) { if (!enfantStatusValues.contains(_status)) {
_status = 'sans_garde'; _status = 'sans_garde';
} }
_localParentLinks = enfant.parentLinks;
_coerceGenderForStatus(); _coerceGenderForStatus();
}); });
} catch (_) { } catch (_) {
@ -960,7 +968,9 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
padding: const EdgeInsets.only(top: 16), padding: const EdgeInsets.only(top: 16),
child: Align( child: Align(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
child: _placementBlock(), child: SingleChildScrollView(
child: _placementBlock(),
),
), ),
), ),
), ),
@ -1219,6 +1229,24 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ 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( Text(
_placementTitle, _placementTitle,
style: TextStyle( style: TextStyle(
@ -1240,10 +1268,45 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
title: 'Choisir une famille', title: 'Choisir une famille',
); );
if (selected == null || !mounted) return; if (selected == null || !mounted) return;
setState(() {
_selectedFamily = selected; if (widget.isCreating) {
_dirty = true; 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() { Widget _familyPlacementSection() {

View File

@ -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/models/parent_child_summary.dart';
import 'package:p_tits_pas/utils/date_display_utils.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_status_utils.dart';
import 'package:p_tits_pas/utils/enfant_vigilance.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
List<String> enfantAdminSubtitleLines({ List<String> enfantAdminSubtitleLines({
@ -36,6 +37,8 @@ class AdminEnfantUserCard extends StatelessWidget {
final VoidCallback? onCardTap; final VoidCallback? onCardTap;
final EdgeInsetsGeometry? margin; final EdgeInsetsGeometry? margin;
final EdgeInsetsGeometry? contentPadding; final EdgeInsetsGeometry? contentPadding;
final Color? borderColor;
final String? vigilanceTooltip;
const AdminEnfantUserCard({ const AdminEnfantUserCard({
super.key, super.key,
@ -46,6 +49,8 @@ class AdminEnfantUserCard extends StatelessWidget {
this.onCardTap, this.onCardTap,
this.margin, this.margin,
this.contentPadding, this.contentPadding,
this.borderColor,
this.vigilanceTooltip,
}); });
factory AdminEnfantUserCard.fromEnfant( factory AdminEnfantUserCard.fromEnfant(
@ -55,10 +60,13 @@ class AdminEnfantUserCard extends StatelessWidget {
VoidCallback? onCardTap, VoidCallback? onCardTap,
EdgeInsetsGeometry? margin, EdgeInsetsGeometry? margin,
EdgeInsetsGeometry? contentPadding, EdgeInsetsGeometry? contentPadding,
Color? borderColor,
String? vigilanceTooltip,
}) { }) {
final parents = enfant.parentLinks final parents = enfant.parentLinks
.map((l) => l.parentName ?? 'Parent') .map((l) => l.parentName ?? 'Parent')
.join(', '); .join(', ');
final orphan = enfantHasNoResponsable(enfant);
return AdminEnfantUserCard( return AdminEnfantUserCard(
title: enfant.fullName, title: enfant.fullName,
photoUrl: enfant.photoUrl, photoUrl: enfant.photoUrl,
@ -69,6 +77,7 @@ class AdminEnfantUserCard extends StatelessWidget {
gender: enfant.gender, gender: enfant.gender,
extra: [ extra: [
if (parents.isNotEmpty) 'Responsables : $parents', if (parents.isNotEmpty) 'Responsables : $parents',
if (orphan) 'Aucun responsable rattaché',
...extraSubtitleLines, ...extraSubtitleLines,
], ],
), ),
@ -76,6 +85,10 @@ class AdminEnfantUserCard extends StatelessWidget {
onCardTap: onCardTap, onCardTap: onCardTap,
margin: margin, margin: margin,
contentPadding: contentPadding, contentPadding: contentPadding,
borderColor: borderColor ??
(orphan ? Colors.red.shade300 : null),
vigilanceTooltip: vigilanceTooltip ??
enfantSansResponsableVigilanceMessage(enfant),
); );
} }
@ -114,6 +127,8 @@ class AdminEnfantUserCard extends StatelessWidget {
onCardTap: onCardTap, onCardTap: onCardTap,
margin: margin, margin: margin,
contentPadding: contentPadding, contentPadding: contentPadding,
borderColor: borderColor,
vigilanceTooltip: vigilanceTooltip,
); );
} }
} }

View File

@ -284,7 +284,8 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
title: const Text('Détacher l\'enfant'), title: const Text('Détacher l\'enfant'),
content: Text( content: Text(
'Retirer ${child.fullName} de la fiche de ce parent ?\n' '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: [ actions: [
TextButton( TextButton(

View File

@ -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_select_list_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.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 { class AdminFamilleFoyer {
/// Parent pivot pour `POST /enfants` (`parent_user_id`). /// Parent pivot pour `POST /enfants` (`parent_user_id`).
final String pivotParentUserId; final String pivotParentUserId;
/// Co-parent éventuel (rattachement foyer #157).
final String? coParentUserId;
final String? numeroDossier; final String? numeroDossier;
final String displayTitle; final String displayTitle;
final List<String> parentNames; final List<String> parentNames;
@ -16,9 +18,18 @@ class AdminFamilleFoyer {
required this.pivotParentUserId, required this.pivotParentUserId,
required this.displayTitle, required this.displayTitle,
required this.parentNames, required this.parentNames,
this.coParentUserId,
this.numeroDossier, this.numeroDossier,
}); });
/// Parents du foyer à lier à lenfant (pivot puis co-parent).
List<String> get parentUserIds {
final ids = <String>[pivotParentUserId];
final co = (coParentUserId ?? '').trim();
if (co.isNotEmpty && co != pivotParentUserId) ids.add(co);
return ids;
}
String get subtitle { String get subtitle {
final parts = <String>[]; final parts = <String>[];
final dossier = (numeroDossier ?? '').trim(); final dossier = (numeroDossier ?? '').trim();
@ -61,6 +72,7 @@ List<AdminFamilleFoyer> buildFamilleFoyers(List<ParentModel> parents) {
foyers.add( foyers.add(
AdminFamilleFoyer( AdminFamilleFoyer(
pivotParentUserId: p.user.id, pivotParentUserId: p.user.id,
coParentUserId: co?.id,
numeroDossier: dossier.isNotEmpty ? dossier : null, numeroDossier: dossier.isNotEmpty ? dossier : null,
displayTitle: title, displayTitle: title,
parentNames: names, parentNames: names,

View File

@ -72,7 +72,14 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
normalizeEnfantStatus(e.status) == normalizeEnfantStatus(e.status) ==
normalizeEnfantStatus(widget.statusFilter); normalizeEnfantStatus(widget.statusFilter);
return matchesName && matchesStatus; 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( return UserList(
isLoading: _isLoading, isLoading: _isLoading,