feat(#131): fiches parent/AM éditable, placement AM↔enfant, statuts garde/sans_garde

Squash merge develop → master.

- Fiche parent éditable (co-parent, PATCH fiche, GET /parents)
- Fiche AM 3 onglets (PATCH fiche, rattacher/détacher enfants)
- Table enfants_assistantes_maternelles + enum garde/sans_garde
- Migration SQL + BDD.sql canonique
- Correctifs recette : @Get() parents, DTO fiche AM, fix NIR

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-11 22:49:57 +02:00
co-authored by Cursor
parent b99745e0fe
commit 003fe6b762
69 changed files with 6290 additions and 417 deletions
+45
View File
@@ -0,0 +1,45 @@
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
/// Places disponibles attendues : capacité max enfants rattachés.
int? amExpectedPlacesAvailable({
required int? maxChildren,
required int childrenCount,
}) {
if (maxChildren == null) return null;
return (maxChildren - childrenCount).clamp(0, maxChildren);
}
/// True si la valeur déclarée par l'AM ne correspond pas au calcul métier.
bool amHasPlacesInconsistency({
required int? maxChildren,
required int? placesAvailable,
required int childrenCount,
}) {
final expected = amExpectedPlacesAvailable(
maxChildren: maxChildren,
childrenCount: childrenCount,
);
if (expected == null) return false;
if (placesAvailable == null) return childrenCount > (maxChildren ?? 0);
return placesAvailable != expected;
}
String? amPlacesVigilanceMessage(AssistanteMaternelleModel am) {
if (!amHasPlacesInconsistency(
maxChildren: am.maxChildren,
placesAvailable: am.placesAvailable,
childrenCount: am.children.length,
)) {
return null;
}
final stored = am.placesAvailable;
final expected = amExpectedPlacesAvailable(
maxChildren: am.maxChildren,
childrenCount: am.children.length,
);
final storedLabel = stored?.toString() ?? 'non renseigné';
final expectedLabel = expected?.toString() ?? '';
return 'Point de vigilance : l\'AM déclare $storedLabel place(s) disponible(s), '
'alors que capacité ${am.maxChildren ?? ''} '
'${am.children.length} enfant(s) rattaché(s) = $expectedLabel.';
}
@@ -1,4 +1,5 @@
import 'package:intl/intl.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
/// Affiche une date ISO / parseable en `dd/MM/yyyy`, avec repli sur la chaîne ou [ifEmpty].
String formatIsoDateFr(String? s, {String ifEmpty = ''}) {
@@ -9,3 +10,89 @@ String formatIsoDateFr(String? s, {String ifEmpty = ''}) {
return s.trim();
}
}
/// 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;
try {
return DateFormat('dd/MM/yyyy')
.parseStrict(t)
.toIso8601String()
.split('T')
.first;
} catch (_) {
try {
return DateTime.parse(t).toIso8601String().split('T').first;
} catch (_) {
return null;
}
}
}
({int years, int months, int days}) computeChildAgeParts(
DateTime birth,
DateTime reference,
) {
final birthDay = DateTime(birth.year, birth.month, birth.day);
final refDay = DateTime(reference.year, reference.month, reference.day);
var years = refDay.year - birthDay.year;
var months = refDay.month - birthDay.month;
var days = refDay.day - birthDay.day;
if (days < 0) {
months--;
final prevMonth = DateTime(refDay.year, refDay.month, 0);
days += prevMonth.day;
}
if (months < 0) {
years--;
months += 12;
}
return (years: years, months: months, days: days);
}
String _yearLabel(int years) => years == 1 ? '1 an' : '$years ans';
String _monthLabel(int months) => months == 1 ? '1 mois' : '$months mois';
/// Libellé d'âge pour un enfant (liste admin, fiche parent, etc.).
String formatChildAgeLabel({
String? birthDate,
String? dueDate,
String? status,
}) {
if (status == 'a_naitre' || normalizeEnfantStatus(status) == 'a_naitre') {
final due = formatIsoDateFr(dueDate, ifEmpty: '');
if (due.isNotEmpty) return 'Naissance prévue : $due';
return 'À naître';
}
if (birthDate == null || birthDate.trim().isEmpty) return '';
try {
final birth = DateTime.parse(birthDate.trim().split('T').first);
final now = DateTime.now();
final parts = computeChildAgeParts(birth, now);
if (parts.years >= 1) {
if (parts.months > 0) {
return 'Âge : ${_yearLabel(parts.years)} ${parts.months} mois';
}
return 'Âge : ${_yearLabel(parts.years)}';
}
if (parts.months >= 1) {
return 'Âge : ${_monthLabel(parts.months)}';
}
final totalDays = DateTime(now.year, now.month, now.day)
.difference(DateTime(birth.year, birth.month, birth.day))
.inDays;
if (totalDays <= 0) return 'Âge : né récemment';
return 'Âge : $totalDays jour${totalDays > 1 ? 's' : ''}';
} catch (_) {
return 'Né le ${formatIsoDateFr(birthDate)}';
}
}
@@ -0,0 +1,51 @@
/// Statuts enfant alignés sur `statut_enfant_type` (BDD / back #131).
const enfantStatusValues = [
'a_naitre',
'sans_garde',
'garde',
'scolarise',
];
/// Normalise une valeur API (legacy `actif` → `sans_garde`).
String normalizeEnfantStatus(String? raw) {
final s = (raw ?? '').trim().toLowerCase();
if (s.isEmpty) return 'sans_garde';
if (s == 'actif') return 'sans_garde';
return s;
}
String _scolariseAccordeAuGenre(String? gender) {
final g = (gender ?? '').trim().toUpperCase();
if (g == 'F') return 'Scolarisée';
return 'Scolarisé';
}
/// Libellé affiché pour un statut enfant.
String enfantStatusLabel(String? status, {String? gender}) {
switch (normalizeEnfantStatus(status)) {
case 'a_naitre':
return 'À naître';
case 'sans_garde':
return 'Sans garde';
case 'garde':
return 'En garde';
case 'scolarise':
return _scolariseAccordeAuGenre(gender);
default:
return status?.trim().isNotEmpty == true ? status!.trim() : '';
}
}
/// Libellé court pour colonnes validation (null = pas de bandeau).
String? enfantColumnStatusLabel({String? status, String? gender}) {
final s = normalizeEnfantStatus(status);
switch (s) {
case 'a_naitre':
case 'sans_garde':
case 'garde':
case 'scolarise':
return enfantStatusLabel(s, gender: gender);
default:
return null;
}
}