feat(#157): alerte enfants sans responsable + rattachement foyer.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7a3d997ff9
commit
2ececa711b
@ -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,
|
||||||
|
|||||||
12
frontend/lib/utils/enfant_vigilance.dart
Normal file
12
frontend/lib/utils/enfant_vigilance.dart
Normal file
@ -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';
|
||||||
|
}
|
||||||
@ -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,10 +968,12 @@ 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: SingleChildScrollView(
|
||||||
child: _placementBlock(),
|
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;
|
||||||
|
|
||||||
|
if (widget.isCreating) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedFamily = selected;
|
_selectedFamily = selected;
|
||||||
_dirty = true;
|
_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() {
|
||||||
|
|||||||
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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 à l’enfant (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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user