feat(#157): enfants sans responsable — alerte liste et détach dernier parent.

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 <cursoragent@cursor.com>
This commit is contained in:
2026-07-17 19:43:58 +02:00
co-authored by Cursor
parent 04f49cb62f
commit 99a6c17c23
12 changed files with 211 additions and 27 deletions
@@ -446,8 +446,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
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(
@@ -65,6 +65,9 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
/// Famille choisie en mode création (#132).
AdminFamilleFoyer? _selectedFamily;
/// Liens parents locaux (édition) — mis à jour après rattachement foyer (#157).
List<EnfantParentLink>? _localParentLinks;
/// Photo locale (création) — upload multipart `photo`.
Uint8List? _photoBytes;
String? _photoFilename;
@@ -262,10 +265,14 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
}
List<EnfantParentLink> get _parentLinks =>
(widget.enfant?.parentLinks ?? const <EnfantParentLink>[])
(_localParentLinks ??
widget.enfant?.parentLinks ??
const <EnfantParentLink>[])
.where((l) => l.parentId.trim().isNotEmpty)
.toList();
bool get _isOrphan => !widget.isCreating && _parentLinks.isEmpty;
Future<void> _openParent(EnfantParentLink link) async {
if (_busy) return;
final id = link.parentId.trim();
@@ -580,6 +587,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
if (!enfantStatusValues.contains(_status)) {
_status = 'sans_garde';
}
_localParentLinks = enfant.parentLinks;
_coerceGenderForStatus();
});
} catch (_) {
@@ -960,7 +968,9 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
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<AdminChildDetailModal> {
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<AdminChildDetailModal> {
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() {
@@ -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<String> 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,
);
}
}
@@ -283,8 +283,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
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(
@@ -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<String> parentNames;
@@ -16,9 +18,18 @@ class AdminFamilleFoyer {
required this.pivotParentUserId,
required this.displayTitle,
required this.parentNames,
this.coParentUserId,
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 {
final parts = <String>[];
final dossier = (numeroDossier ?? '').trim();
@@ -61,6 +72,7 @@ List<AdminFamilleFoyer> buildFamilleFoyers(List<ParentModel> parents) {
foyers.add(
AdminFamilleFoyer(
pivotParentUserId: p.user.id,
coParentUserId: co?.id,
numeroDossier: dossier.isNotEmpty ? dossier : null,
displayTitle: title,
parentNames: names,
@@ -72,7 +72,14 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
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,
@@ -80,7 +80,7 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
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(