Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86ceb558a4 | ||
|
|
cc3b52db16 | ||
|
|
3c7f4f6e16 | ||
|
|
dcd407a3da | ||
|
|
fde63f8e72 | ||
|
|
1f8f1b9507 |
@@ -146,27 +146,16 @@ export class EnfantsService {
|
||||
throw new ForbiddenException('Accès interdit');
|
||||
}
|
||||
|
||||
/** 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<Array<Children & { sans_responsable: boolean }>> {
|
||||
const children = await this.childrenRepository.find({
|
||||
// Liste des enfants (admin/gestionnaire)
|
||||
async findAll(): Promise<Children[]> {
|
||||
return 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<Children & { sans_responsable: boolean }> {
|
||||
async findOne(id: string, currentUser: Users): Promise<Children> {
|
||||
const child = await this.childrenRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
||||
@@ -183,14 +172,14 @@ export class EnfantsService {
|
||||
case RoleType.ADMINISTRATEUR:
|
||||
case RoleType.SUPER_ADMIN:
|
||||
case RoleType.GESTIONNAIRE:
|
||||
// accès complet (y compris orphelins)
|
||||
// accès complet
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ForbiddenException('Accès interdit');
|
||||
}
|
||||
|
||||
return this.withSansResponsable(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -138,9 +138,7 @@ export class ParentsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Détacher un enfant d'un parent sans supprimer l'enfant. Ticket #115 / doc 28 §6.2.
|
||||
*/
|
||||
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
||||
await this.findOne(parentUserId);
|
||||
@@ -152,6 +150,11 @@ 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);
|
||||
}
|
||||
|
||||
@@ -14,8 +14,6 @@ class EnfantAdminModel {
|
||||
final bool consentPhoto;
|
||||
final bool isMultiple;
|
||||
final List<EnfantParentLink> parentLinks;
|
||||
/// Flag API #157 (sinon déduit de [parentLinks]).
|
||||
final bool? sansResponsable;
|
||||
|
||||
EnfantAdminModel({
|
||||
required this.id,
|
||||
@@ -29,7 +27,6 @@ class EnfantAdminModel {
|
||||
this.consentPhoto = false,
|
||||
this.isMultiple = false,
|
||||
this.parentLinks = const [],
|
||||
this.sansResponsable,
|
||||
});
|
||||
|
||||
String get fullName {
|
||||
@@ -40,12 +37,6 @@ 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,
|
||||
@@ -58,7 +49,6 @@ class EnfantAdminModel {
|
||||
bool? consentPhoto,
|
||||
bool? isMultiple,
|
||||
List<EnfantParentLink>? parentLinks,
|
||||
bool? sansResponsable,
|
||||
}) {
|
||||
return EnfantAdminModel(
|
||||
id: id ?? this.id,
|
||||
@@ -72,7 +62,6 @@ class EnfantAdminModel {
|
||||
consentPhoto: consentPhoto ?? this.consentPhoto,
|
||||
isMultiple: isMultiple ?? this.isMultiple,
|
||||
parentLinks: parentLinks ?? this.parentLinks,
|
||||
sansResponsable: sansResponsable ?? this.sansResponsable,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,13 +81,6 @@ 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?,
|
||||
@@ -114,7 +96,6 @@ class EnfantAdminModel {
|
||||
isMultiple: _parseBool(json['is_multiple']) ||
|
||||
_parseBool(json['est_multiple']),
|
||||
parentLinks: links,
|
||||
sansResponsable: sansResponsable,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -62,56 +62,4 @@ 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<ParentModel> allParents,
|
||||
) {
|
||||
final memberIds = <String>{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 = <String>{};
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||
|
||||
@@ -12,50 +11,10 @@ String formatIsoDateFr(String? s, {String ifEmpty = '–'}) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Affiche une date ISO en saisie guidée `jj / mm / aaaa`.
|
||||
String formatIsoDateFrInput(String? s, {String ifEmpty = ''}) {
|
||||
if (s == null || s.trim().isEmpty) return ifEmpty;
|
||||
try {
|
||||
final dt = DateTime.parse(s.trim());
|
||||
return formatFrenchDateDigits(
|
||||
DateFormat('ddMMyyyy').format(dt),
|
||||
);
|
||||
} catch (_) {
|
||||
return formatFrenchDateDigits(s.replaceAll(RegExp(r'\D'), ''));
|
||||
}
|
||||
}
|
||||
|
||||
/// Formate jusqu’à 8 chiffres en `jj / mm / aaaa`.
|
||||
String formatFrenchDateDigits(String digits) {
|
||||
final d = digits.replaceAll(RegExp(r'\D'), '');
|
||||
final limited = d.length > 8 ? d.substring(0, 8) : d;
|
||||
final buf = StringBuffer();
|
||||
for (var i = 0; i < limited.length; i++) {
|
||||
if (i == 2 || i == 4) buf.write(' / ');
|
||||
buf.write(limited[i]);
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
/// Convertit `dd/MM/yyyy`, `dd / MM / yyyy`, 8 chiffres ou ISO en `yyyy-MM-dd`.
|
||||
/// Convertit `dd/MM/yyyy` (ou ISO) en `yyyy-MM-dd` pour l'API.
|
||||
String? parseFrDateToIso(String text) {
|
||||
final t = text.trim();
|
||||
if (t.isEmpty) return null;
|
||||
|
||||
final digits = t.replaceAll(RegExp(r'\D'), '');
|
||||
if (digits.length == 8) {
|
||||
final dd = digits.substring(0, 2);
|
||||
final mm = digits.substring(2, 4);
|
||||
final yyyy = digits.substring(4, 8);
|
||||
try {
|
||||
return DateFormat('dd/MM/yyyy')
|
||||
.parseStrict('$dd/$mm/$yyyy')
|
||||
.toIso8601String()
|
||||
.split('T')
|
||||
.first;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
try {
|
||||
return DateFormat('dd/MM/yyyy')
|
||||
.parseStrict(t)
|
||||
@@ -137,43 +96,3 @@ String formatChildAgeLabel({
|
||||
return 'Né le ${formatIsoDateFr(birthDate)}';
|
||||
}
|
||||
}
|
||||
|
||||
/// Saisie date FR : 8 chiffres → `jj / mm / aaaa`.
|
||||
class FrenchDateInputFormatter extends TextInputFormatter {
|
||||
const FrenchDateInputFormatter();
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
||||
final limited = digits.length > 8 ? digits.substring(0, 8) : digits;
|
||||
final formatted = formatFrenchDateDigits(limited);
|
||||
|
||||
final digitsBeforeCursor = newValue.text
|
||||
.substring(0, newValue.selection.start.clamp(0, newValue.text.length))
|
||||
.replaceAll(RegExp(r'\D'), '')
|
||||
.length
|
||||
.clamp(0, limited.length);
|
||||
|
||||
return TextEditingValue(
|
||||
text: formatted,
|
||||
selection: TextSelection.collapsed(
|
||||
offset: _cursorOffset(formatted, digitsBeforeCursor),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static int _cursorOffset(String formatted, int digitsBeforeCursor) {
|
||||
if (digitsBeforeCursor <= 0) return 0;
|
||||
var seen = 0;
|
||||
for (var i = 0; i < formatted.length; i++) {
|
||||
if (RegExp(r'\d').hasMatch(formatted[i])) {
|
||||
seen++;
|
||||
if (seen >= digitsBeforeCursor) return i + 1;
|
||||
}
|
||||
}
|
||||
return formatted.length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
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';
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Formatage affichage prénom / nom (capitalisation par mot et segments après `-` ou `'`).
|
||||
|
||||
String formatPersonNameCase(String raw) {
|
||||
@@ -11,22 +9,6 @@ String formatPersonNameCase(String raw) {
|
||||
return words.map(_capitalizeComposedWord).join(' ');
|
||||
}
|
||||
|
||||
/// Variante saisie live : pas de majuscule tant que le mot n’a qu’une lettre ;
|
||||
/// dès la 2ᵉ lettre, capitalisation comme [formatPersonNameCase].
|
||||
String formatPersonNameCaseTyping(String raw) {
|
||||
if (raw.isEmpty) return raw;
|
||||
final trailingMatch = RegExp(r'(\s*)$').firstMatch(raw);
|
||||
final trailing = trailingMatch?.group(1) ?? '';
|
||||
final core = raw.substring(0, raw.length - trailing.length);
|
||||
if (core.isEmpty) return raw;
|
||||
final words = core.split(RegExp(r'\s+'));
|
||||
final formatted = words.map((w) {
|
||||
if (w.length < 2) return w;
|
||||
return _capitalizeComposedWord(w);
|
||||
}).join(' ');
|
||||
return formatted + trailing;
|
||||
}
|
||||
|
||||
String _capitalizeComposedWord(String word) {
|
||||
if (word.isEmpty) {
|
||||
return word;
|
||||
@@ -48,26 +30,3 @@ String _capitalizeComposedWord(String word) {
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// Formateur de saisie prénom / nom (capitalisation progressive).
|
||||
class PersonNameInputFormatter extends TextInputFormatter {
|
||||
const PersonNameInputFormatter();
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
final formatted = formatPersonNameCaseTyping(newValue.text);
|
||||
if (formatted == newValue.text) return newValue;
|
||||
|
||||
final sel = newValue.selection;
|
||||
final offset = sel.isValid
|
||||
? sel.baseOffset.clamp(0, formatted.length)
|
||||
: formatted.length;
|
||||
return TextEditingValue(
|
||||
text: formatted,
|
||||
selection: TextSelection.collapsed(offset: offset),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,7 +446,8 @@ 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 ?',
|
||||
'Retirer ${child.fullName} de la fiche de cette assistante ?\n'
|
||||
'(L\'enfant ne sera pas supprimé.)',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||
@@ -8,7 +9,6 @@ import 'package:p_tits_pas/services/auth_service.dart';
|
||||
import 'package:p_tits_pas/services/user_service.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/name_format_utils.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart';
|
||||
@@ -50,7 +50,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
late final TextEditingController _birthCtrl;
|
||||
late final TextEditingController _dueCtrl;
|
||||
late String _status;
|
||||
late String? _gender;
|
||||
late String _gender;
|
||||
late bool _consentPhoto;
|
||||
late bool _isMultiple;
|
||||
bool _dirty = false;
|
||||
@@ -65,9 +65,6 @@ 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;
|
||||
@@ -102,22 +99,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
|
||||
bool get _busy => _saving || _deleting;
|
||||
|
||||
/// Création : tous les champs sauf photo / consentement ; édition : au moins une modif.
|
||||
bool get _canSubmit {
|
||||
if (_busy) return false;
|
||||
if (widget.isCreating) return _isCreateFormComplete;
|
||||
return _dirty;
|
||||
}
|
||||
|
||||
bool get _isCreateFormComplete {
|
||||
if (_selectedFamily == null) return false;
|
||||
if (formatPersonNameCase(_prenomCtrl.text).isEmpty) return false;
|
||||
if (formatPersonNameCase(_nomCtrl.text).isEmpty) return false;
|
||||
if (_dateToIso(_activeDateCtrl.text) == null) return false;
|
||||
if (_gender == null || !_availableGenders.contains(_gender)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -125,61 +106,34 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
_prenomCtrl = TextEditingController(text: e?.firstName ?? '');
|
||||
_nomCtrl = TextEditingController(text: e?.lastName ?? '');
|
||||
_birthCtrl = TextEditingController(
|
||||
text: formatIsoDateFrInput(e?.birthDate, ifEmpty: ''),
|
||||
text: formatIsoDateFr(e?.birthDate, ifEmpty: ''),
|
||||
);
|
||||
_dueCtrl = TextEditingController(
|
||||
text: formatIsoDateFrInput(e?.dueDate, ifEmpty: ''),
|
||||
text: formatIsoDateFr(e?.dueDate, ifEmpty: ''),
|
||||
);
|
||||
_status = normalizeEnfantStatus(e?.status);
|
||||
if (!enfantStatusValues.contains(_status)) {
|
||||
_status = 'sans_garde';
|
||||
}
|
||||
if (widget.isCreating) {
|
||||
_gender = null;
|
||||
} else {
|
||||
_gender = _normalizeGender(e?.gender, allowUnknown: _isUnborn);
|
||||
}
|
||||
_consentPhoto = e?.consentPhoto ?? false;
|
||||
_isMultiple = e?.isMultiple ?? false;
|
||||
for (final c in [_prenomCtrl, _nomCtrl]) {
|
||||
c.addListener(_onNameChanged);
|
||||
}
|
||||
for (final c in [_birthCtrl, _dueCtrl]) {
|
||||
c.addListener(_onDateChanged);
|
||||
for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) {
|
||||
c.addListener(_markDirty);
|
||||
}
|
||||
_prenomCtrl.addListener(_onNameChanged);
|
||||
_nomCtrl.addListener(_onNameChanged);
|
||||
_loadCurrentUserRole();
|
||||
if (widget.isCreating) {
|
||||
_loadingAm = false;
|
||||
_dirty = true;
|
||||
} else {
|
||||
_prenomCtrl.addListener(_markDirty);
|
||||
_nomCtrl.addListener(_markDirty);
|
||||
_loadLinkedAm();
|
||||
}
|
||||
}
|
||||
|
||||
void _onNameChanged() => setState(() {});
|
||||
|
||||
void _onDateChanged() {
|
||||
setState(() => _dirty = true);
|
||||
}
|
||||
|
||||
/// Date prévisionnelle strictement avant aujourd’hui (statut à naître).
|
||||
bool get _dueDateInPast {
|
||||
if (!_isUnborn) return false;
|
||||
final iso = _dateToIso(_dueCtrl.text);
|
||||
if (iso == null) return false;
|
||||
try {
|
||||
final due = DateTime.parse(iso);
|
||||
final now = DateTime.now();
|
||||
final dueDay = DateTime(due.year, due.month, due.day);
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
return dueDay.isBefore(today);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadLinkedAm() async {
|
||||
if (widget.isCreating) {
|
||||
setState(() => _loadingAm = false);
|
||||
@@ -237,8 +191,8 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
}
|
||||
|
||||
void _coerceGenderForStatus() {
|
||||
if (_gender != null && !_availableGenders.contains(_gender)) {
|
||||
_gender = null;
|
||||
if (!_availableGenders.contains(_gender)) {
|
||||
_gender = 'H';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,14 +219,10 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
}
|
||||
|
||||
List<EnfantParentLink> get _parentLinks =>
|
||||
(_localParentLinks ??
|
||||
widget.enfant?.parentLinks ??
|
||||
const <EnfantParentLink>[])
|
||||
(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();
|
||||
@@ -371,8 +321,8 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
await UserService.updateEnfant(
|
||||
enfantId: enfantId,
|
||||
body: {
|
||||
'first_name': formatPersonNameCase(_prenomCtrl.text),
|
||||
'last_name': formatPersonNameCase(_nomCtrl.text),
|
||||
'first_name': _prenomCtrl.text.trim(),
|
||||
'last_name': _nomCtrl.text.trim(),
|
||||
'status': _status,
|
||||
'gender': _gender,
|
||||
if (_isUnborn)
|
||||
@@ -413,8 +363,8 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
final prenom = formatPersonNameCase(_prenomCtrl.text);
|
||||
final nom = formatPersonNameCase(_nomCtrl.text);
|
||||
final prenom = _prenomCtrl.text.trim();
|
||||
final nom = _nomCtrl.text.trim();
|
||||
if (prenom.isEmpty || nom.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Prénom et nom sont obligatoires')),
|
||||
@@ -450,13 +400,11 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
'last_name': nom,
|
||||
'status': _status,
|
||||
'gender': _gender,
|
||||
if (_isUnborn) ...{
|
||||
if (_isUnborn)
|
||||
if (_dateToIso(_dueCtrl.text) != null)
|
||||
'due_date': _dateToIso(_dueCtrl.text),
|
||||
} else ...{
|
||||
if (_dateToIso(_birthCtrl.text) != null)
|
||||
'due_date': _dateToIso(_dueCtrl.text)
|
||||
else if (_dateToIso(_birthCtrl.text) != null)
|
||||
'birth_date': _dateToIso(_birthCtrl.text),
|
||||
},
|
||||
'consent_photo': _consentPhoto,
|
||||
'is_multiple': _isMultiple,
|
||||
},
|
||||
@@ -587,7 +535,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
if (!enfantStatusValues.contains(_status)) {
|
||||
_status = 'sans_garde';
|
||||
}
|
||||
_localParentLinks = enfant.parentLinks;
|
||||
_coerceGenderForStatus();
|
||||
});
|
||||
} catch (_) {
|
||||
@@ -673,121 +620,48 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Conserve la date saisie en basculant naissance ↔ prévisionnelle.
|
||||
void _preserveDateOnStatusChange(String from, String to) {
|
||||
final becomingUnborn = to == 'a_naitre' && from != 'a_naitre';
|
||||
final leavingUnborn = from == 'a_naitre' && to != 'a_naitre';
|
||||
if (becomingUnborn) {
|
||||
if (_dueCtrl.text.trim().isEmpty &&
|
||||
_birthCtrl.text.trim().isNotEmpty) {
|
||||
_dueCtrl.text = _birthCtrl.text;
|
||||
}
|
||||
} else if (leavingUnborn) {
|
||||
if (_birthCtrl.text.trim().isEmpty &&
|
||||
_dueCtrl.text.trim().isNotEmpty) {
|
||||
_birthCtrl.text = _dueCtrl.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
InputDecoration _inputDecoration({String? hint, bool error = false}) {
|
||||
final base = ValidationFieldDecoration.input(hint: hint).copyWith(
|
||||
InputDecoration _inputDecoration({String? hint}) {
|
||||
return ValidationFieldDecoration.input(hint: hint).copyWith(
|
||||
isDense: false,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
);
|
||||
if (!error) return base;
|
||||
final border = OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(color: Colors.red.shade400),
|
||||
);
|
||||
return base.copyWith(
|
||||
fillColor: Colors.red.shade50,
|
||||
enabledBorder: border,
|
||||
focusedBorder: border,
|
||||
border: border,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dropdownField({
|
||||
Key? key,
|
||||
required String? value,
|
||||
required String value,
|
||||
required List<MapEntry<String, String>> items,
|
||||
required ValueChanged<String> onChanged,
|
||||
String? hint,
|
||||
}) {
|
||||
final safeValue =
|
||||
value != null && items.any((e) => e.key == value) ? value : null;
|
||||
items.any((e) => e.key == value) ? value : items.first.key;
|
||||
return SizedBox(
|
||||
height: _fieldHeight,
|
||||
child: DropdownButtonFormField<String>(
|
||||
key: key,
|
||||
value: safeValue,
|
||||
hint: hint != null
|
||||
? Text(hint, style: TextStyle(color: Colors.grey.shade600))
|
||||
: null,
|
||||
isExpanded: true,
|
||||
decoration: _inputDecoration(),
|
||||
items: items
|
||||
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
|
||||
.toList(),
|
||||
onChanged: _busy
|
||||
? null
|
||||
: (v) {
|
||||
onChanged: _busy ? null : (v) {
|
||||
if (v != null) onChanged(v);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _textField(
|
||||
TextEditingController controller, {
|
||||
String? hint,
|
||||
Key? key,
|
||||
TextInputType? keyboardType,
|
||||
List<TextInputFormatter>? inputFormatters,
|
||||
bool error = false,
|
||||
Widget? suffixIcon,
|
||||
}) {
|
||||
Widget _textField(TextEditingController controller, {String? hint, Key? key}) {
|
||||
return SizedBox(
|
||||
height: _fieldHeight,
|
||||
child: TextField(
|
||||
key: key,
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
style: TextStyle(
|
||||
color: error ? Colors.red.shade800 : Colors.black87,
|
||||
fontSize: 14,
|
||||
style: const TextStyle(color: Colors.black87, fontSize: 14),
|
||||
decoration: _inputDecoration(hint: hint),
|
||||
),
|
||||
decoration: _inputDecoration(
|
||||
hint: hint,
|
||||
error: error,
|
||||
).copyWith(suffixIcon: suffixIcon),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dateField() {
|
||||
final past = _dueDateInPast;
|
||||
return _textField(
|
||||
_activeDateCtrl,
|
||||
hint: 'jj / mm / aaaa',
|
||||
key: ValueKey(_status),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: const [FrenchDateInputFormatter()],
|
||||
error: past,
|
||||
suffixIcon: past
|
||||
? Tooltip(
|
||||
message:
|
||||
'Date prévisionnelle antérieure à aujourd’hui',
|
||||
child: Icon(
|
||||
Icons.info_outline,
|
||||
size: 20,
|
||||
color: Colors.red.shade700,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -797,21 +671,19 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
fields: [
|
||||
ValidationLabeledField(
|
||||
label: 'Prénom',
|
||||
field: _textField(
|
||||
_prenomCtrl,
|
||||
inputFormatters: const [PersonNameInputFormatter()],
|
||||
),
|
||||
field: _textField(_prenomCtrl),
|
||||
),
|
||||
ValidationLabeledField(
|
||||
label: 'Nom',
|
||||
field: _textField(
|
||||
_nomCtrl,
|
||||
inputFormatters: const [PersonNameInputFormatter()],
|
||||
),
|
||||
field: _textField(_nomCtrl),
|
||||
),
|
||||
ValidationLabeledField(
|
||||
label: _dateFieldLabel,
|
||||
field: _dateField(),
|
||||
field: _textField(
|
||||
_activeDateCtrl,
|
||||
hint: 'jj/mm/aaaa',
|
||||
key: ValueKey(_status),
|
||||
),
|
||||
),
|
||||
ValidationLabeledField(
|
||||
label: 'Statut',
|
||||
@@ -827,7 +699,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
_preserveDateOnStatusChange(_status, v);
|
||||
_status = v;
|
||||
_coerceGenderForStatus();
|
||||
_dirty = true;
|
||||
@@ -843,7 +714,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
field: _dropdownField(
|
||||
key: ValueKey('genre-$_status'),
|
||||
value: _gender,
|
||||
hint: 'Veuillez sélectionner le genre',
|
||||
items: _availableGenders
|
||||
.map((g) => MapEntry(g, _genderLabel(g)))
|
||||
.toList(),
|
||||
@@ -968,12 +838,10 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: SingleChildScrollView(
|
||||
child: _placementBlock(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1220,7 +1088,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
}
|
||||
|
||||
String get _placementTitle {
|
||||
if (widget.isCreating) return 'Sélection du dossier de la famille';
|
||||
if (widget.isCreating) return 'Famille / dossier';
|
||||
return _isScolarise ? 'Scolarisation' : 'Assistante maternelle';
|
||||
}
|
||||
|
||||
@@ -1229,24 +1097,6 @@ 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(
|
||||
@@ -1268,45 +1118,10 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
title: 'Choisir une famille',
|
||||
);
|
||||
if (selected == null || !mounted) return;
|
||||
|
||||
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() {
|
||||
@@ -1497,7 +1312,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
style: ValidationModalTheme.primaryElevatedStyle,
|
||||
onPressed: !_canSubmit || _busy ? null : _save,
|
||||
onPressed: !_dirty || _busy ? null : _save,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
|
||||
@@ -3,7 +3,6 @@ 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({
|
||||
@@ -37,8 +36,6 @@ class AdminEnfantUserCard extends StatelessWidget {
|
||||
final VoidCallback? onCardTap;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final EdgeInsetsGeometry? contentPadding;
|
||||
final Color? borderColor;
|
||||
final String? vigilanceTooltip;
|
||||
|
||||
const AdminEnfantUserCard({
|
||||
super.key,
|
||||
@@ -49,8 +46,6 @@ class AdminEnfantUserCard extends StatelessWidget {
|
||||
this.onCardTap,
|
||||
this.margin,
|
||||
this.contentPadding,
|
||||
this.borderColor,
|
||||
this.vigilanceTooltip,
|
||||
});
|
||||
|
||||
factory AdminEnfantUserCard.fromEnfant(
|
||||
@@ -60,13 +55,10 @@ 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,
|
||||
@@ -77,7 +69,6 @@ class AdminEnfantUserCard extends StatelessWidget {
|
||||
gender: enfant.gender,
|
||||
extra: [
|
||||
if (parents.isNotEmpty) 'Responsables : $parents',
|
||||
if (orphan) 'Aucun responsable rattaché',
|
||||
...extraSubtitleLines,
|
||||
],
|
||||
),
|
||||
@@ -85,10 +76,6 @@ class AdminEnfantUserCard extends StatelessWidget {
|
||||
onCardTap: onCardTap,
|
||||
margin: margin,
|
||||
contentPadding: contentPadding,
|
||||
borderColor: borderColor ??
|
||||
(orphan ? Colors.red.shade300 : null),
|
||||
vigilanceTooltip: vigilanceTooltip ??
|
||||
enfantSansResponsableVigilanceMessage(enfant),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,8 +114,6 @@ class AdminEnfantUserCard extends StatelessWidget {
|
||||
onCardTap: onCardTap,
|
||||
margin: margin,
|
||||
contentPadding: contentPadding,
|
||||
borderColor: borderColor,
|
||||
vigilanceTooltip: vigilanceTooltip,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,7 +283,8 @@ 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 ?',
|
||||
'Retirer ${child.fullName} de la fiche de ce parent ?\n'
|
||||
'(L\'enfant ne sera pas supprimé.)',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
|
||||
@@ -4,12 +4,10 @@ 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 / #157).
|
||||
/// Foyer / famille sélectionnable pour rattacher un nouvel enfant (#132).
|
||||
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;
|
||||
@@ -18,18 +16,9 @@ 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<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();
|
||||
@@ -72,7 +61,6 @@ 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,14 +72,7 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
|
||||
normalizeEnfantStatus(e.status) ==
|
||||
normalizeEnfantStatus(widget.statusFilter);
|
||||
return matchesName && matchesStatus;
|
||||
}).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());
|
||||
});
|
||||
}).toList();
|
||||
|
||||
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 : ${ParentModel.foyerChildrenCount(parent, _parents)}',
|
||||
'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.children.isNotEmpty ? parent.children.length : parent.childrenCount}',
|
||||
],
|
||||
actions: [
|
||||
IconButton(
|
||||
|
||||
Reference in New Issue
Block a user