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
@@ -1,30 +1,102 @@
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/models/user.dart';
class AssistanteMaternelleModel {
final AppUser user;
final String? approvalNumber;
final String? nir;
final String? residenceCity;
final int? maxChildren;
final int? placesAvailable;
final String? biography;
final bool? available;
final String? agreementDate;
final List<ParentChildSummary> children;
AssistanteMaternelleModel({
required this.user,
this.approvalNumber,
this.nir,
this.residenceCity,
this.maxChildren,
this.placesAvailable,
this.biography,
this.available,
this.agreementDate,
this.children = const [],
});
factory AssistanteMaternelleModel.fromJson(Map<String, dynamic> json) {
final userJson = json['user'] ?? json;
final root = Map<String, dynamic>.from(json);
final userJson = Map<String, dynamic>.from(root['user'] ?? root);
if (root['numero_dossier'] != null && userJson['numero_dossier'] == null) {
userJson['numero_dossier'] = root['numero_dossier'];
}
final user = AppUser.fromJson(userJson);
final children = _parseChildren(root);
return AssistanteMaternelleModel(
user: user,
approvalNumber: json['numero_agrement'] as String?,
residenceCity: json['ville_residence'] as String?,
maxChildren: json['nb_max_enfants'] as int?,
placesAvailable: json['place_disponible'] as int?,
approvalNumber: _str(
root['approval_number'] ?? root['numero_agrement'],
),
nir: _str(root['nir'] ?? root['nir_chiffre']),
residenceCity: _str(
root['residence_city'] ?? root['ville_residence'],
),
maxChildren: _int(root['max_children'] ?? root['nb_max_enfants']),
placesAvailable: _int(
root['places_available'] ?? root['place_disponible'],
),
biography: _str(root['biography'] ?? root['biographie']),
available: root['available'] as bool? ?? root['disponible'] as bool?,
agreementDate: _dateString(
root['agreement_date'] ?? root['date_agrement'],
),
children: children,
);
}
static String? _str(dynamic v) {
if (v == null) return null;
final s = v.toString().trim();
return s.isEmpty ? null : s;
}
static int? _int(dynamic v) {
if (v == null) return null;
if (v is int) return v;
return int.tryParse(v.toString());
}
static String? _dateString(dynamic v) {
if (v == null) return null;
return v.toString().split('T').first;
}
static List<ParentChildSummary> _parseChildren(Map<String, dynamic> json) {
final children = <ParentChildSummary>[];
final seen = <String>{};
void add(ParentChildSummary? child) {
if (child == null || child.id.isEmpty || seen.contains(child.id)) return;
seen.add(child.id);
children.add(child);
}
final links = json['amChildren'] ??
json['am_children'] ??
json['assistanteChildren'] ??
json['assistante_children'];
if (links is List) {
for (final link in links) {
if (link is! Map) continue;
add(ParentChildSummary.fromParentChildLink(
Map<String, dynamic>.from(link),
));
}
}
return children;
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
/// Réponse unifiée GET /dossiers/:numeroDossier. Ticket #119, #107.
class DossierUnifie {
@@ -220,7 +221,7 @@ class EnfantDossier {
lastName: (json['last_name'] ?? json['nom'])?.toString(),
birthDate: json['birth_date']?.toString(),
gender: (json['gender'] ?? json['genre'])?.toString(),
status: json['status']?.toString(),
status: normalizeEnfantStatus(json['status']?.toString()),
dueDate: json['due_date']?.toString(),
photoUrl: resolvedPhoto,
consentPhoto:
+109
View File
@@ -0,0 +1,109 @@
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
/// Enfant tel que renvoyé par `GET /enfants` (dashboard admin).
class EnfantAdminModel {
final String id;
final String? firstName;
final String? lastName;
final String? gender;
final String? birthDate;
final String? dueDate;
final String status;
final String? photoUrl;
final bool consentPhoto;
final bool isMultiple;
final List<EnfantParentLink> parentLinks;
EnfantAdminModel({
required this.id,
this.firstName,
this.lastName,
this.gender,
this.birthDate,
this.dueDate,
required this.status,
this.photoUrl,
this.consentPhoto = false,
this.isMultiple = false,
this.parentLinks = const [],
});
String get fullName {
final fn = (firstName ?? '').trim();
final ln = (lastName ?? '').trim();
if (fn.isEmpty && ln.isEmpty) return 'Enfant';
if (ln.isEmpty) return fn;
return '$fn $ln';
}
factory EnfantAdminModel.fromJson(Map<String, dynamic> json) {
final linksRaw = json['parentLinks'] as List?;
final links = <EnfantParentLink>[];
if (linksRaw != null) {
for (final item in linksRaw) {
if (item is Map<String, dynamic>) {
links.add(EnfantParentLink.fromJson(item));
}
}
}
return EnfantAdminModel(
id: (json['id'] ?? '').toString(),
firstName: json['first_name'] as String?,
lastName: json['last_name'] as String?,
gender: json['gender'] as String?,
birthDate: _dateString(json['birth_date']),
dueDate: _dateString(json['due_date']),
status: normalizeEnfantStatus(json['status']?.toString()),
photoUrl: json['photo_url'] as String?,
consentPhoto: json['consent_photo'] == true,
isMultiple: json['is_multiple'] == true,
parentLinks: links,
);
}
Map<String, dynamic> toUpdateJson() {
return {
if (firstName != null) 'first_name': firstName,
if (lastName != null) 'last_name': lastName,
if (gender != null && gender!.isNotEmpty) 'gender': gender,
'status': status,
if (birthDate != null && birthDate!.isNotEmpty) 'birth_date': birthDate,
if (dueDate != null && dueDate!.isNotEmpty) 'due_date': dueDate,
'consent_photo': consentPhoto,
'is_multiple': isMultiple,
};
}
static String? _dateString(dynamic v) {
if (v == null) return null;
if (v is String) return v.split('T').first;
return v.toString().split('T').first;
}
}
class EnfantParentLink {
final String parentId;
final String? parentName;
EnfantParentLink({required this.parentId, this.parentName});
factory EnfantParentLink.fromJson(Map<String, dynamic> json) {
final parentId =
(json['parentId'] ?? json['id_parent'] ?? '').toString();
String? name;
final parent = json['parent'];
if (parent is Map<String, dynamic>) {
final user = parent['user'];
if (user is Map<String, dynamic>) {
final u = AppUser.fromJson(user);
name = u.fullName.isNotEmpty ? u.fullName : u.email;
}
}
return EnfantParentLink(
parentId: parentId,
parentName: name,
);
}
}
@@ -0,0 +1,92 @@
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
/// Résumé enfant affiché dans la fiche parent (dashboard admin).
class ParentChildSummary {
final String id;
final String? firstName;
final String? lastName;
final String status;
final String? photoUrl;
final String? birthDate;
final String? dueDate;
ParentChildSummary({
required this.id,
this.firstName,
this.lastName,
required this.status,
this.photoUrl,
this.birthDate,
this.dueDate,
});
String get fullName {
final fn = (firstName ?? '').trim();
final ln = (lastName ?? '').trim();
if (fn.isEmpty && ln.isEmpty) return 'Enfant';
if (ln.isEmpty) return fn;
return '$fn $ln';
}
factory ParentChildSummary.fromJson(Map<String, dynamic> json) {
return ParentChildSummary(
id: (json['id'] ?? '').toString(),
firstName: (json['first_name'] ?? json['prenom'])?.toString(),
lastName: (json['last_name'] ?? json['nom'])?.toString(),
status: normalizeEnfantStatus(
(json['status'] ?? json['statut'])?.toString(),
),
photoUrl: json['photo_url']?.toString(),
birthDate: _dateString(json['birth_date']),
dueDate: _dateString(json['due_date']),
);
}
factory ParentChildSummary.fromEnfant(EnfantAdminModel enfant) {
return ParentChildSummary(
id: enfant.id,
firstName: enfant.firstName,
lastName: enfant.lastName,
status: enfant.status,
photoUrl: enfant.photoUrl,
birthDate: enfant.birthDate,
dueDate: enfant.dueDate,
);
}
static String? _dateString(dynamic v) {
if (v == null) return null;
if (v is String) return v.split('T').first;
return v.toString().split('T').first;
}
/// Parse un lien `parentChildren` (objet enfant imbriqué ou id seul).
static ParentChildSummary? fromParentChildLink(Map<String, dynamic> link) {
final childRaw = link['child'] ?? link['enfant'];
if (childRaw is Map) {
return ParentChildSummary.fromJson(Map<String, dynamic>.from(childRaw));
}
if (link.containsKey('first_name') ||
link.containsKey('prenom') ||
link.containsKey('id')) {
final id = (link['id'] ?? '').toString();
if (id.isNotEmpty &&
(link.containsKey('first_name') || link.containsKey('prenom'))) {
return ParentChildSummary.fromJson(link);
}
}
final enfantId =
link['enfantId'] ?? link['id_enfant'] ?? link['enfant_id'];
if (enfantId != null && enfantId.toString().isNotEmpty) {
return ParentChildSummary(
id: enfantId.toString(),
status: '',
);
}
return null;
}
}
+51 -4
View File
@@ -1,18 +1,65 @@
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/models/user.dart';
class ParentModel {
final AppUser user;
final AppUser? coParent;
final int childrenCount;
final List<ParentChildSummary> children;
ParentModel({required this.user, this.childrenCount = 0});
ParentModel({
required this.user,
this.coParent,
this.childrenCount = 0,
this.children = const [],
});
factory ParentModel.fromJson(Map<String, dynamic> json) {
final userJson = json['user'] ?? json;
final root = Map<String, dynamic>.from(json);
final userJson = Map<String, dynamic>.from(root['user'] ?? root);
if (root['numero_dossier'] != null && userJson['numero_dossier'] == null) {
userJson['numero_dossier'] = root['numero_dossier'];
}
final user = AppUser.fromJson(userJson);
final children = json['parentChildren'] as List?;
AppUser? coParent;
final coParentRaw = root['co_parent'];
if (coParentRaw is Map) {
coParent = AppUser.fromJson(Map<String, dynamic>.from(coParentRaw));
}
final children = _parseChildren(root);
final links = root['parentChildren'] ?? root['parent_children'];
final linkCount = links is List ? links.length : 0;
return ParentModel(
user: user,
childrenCount: children?.length ?? 0,
coParent: coParent,
childrenCount: children.isNotEmpty ? children.length : linkCount,
children: children,
);
}
static List<ParentChildSummary> _parseChildren(Map<String, dynamic> json) {
final children = <ParentChildSummary>[];
final seen = <String>{};
void add(ParentChildSummary? child) {
if (child == null || child.id.isEmpty || seen.contains(child.id)) return;
seen.add(child.id);
children.add(child);
}
final links = json['parentChildren'] ?? json['parent_children'];
if (links is List) {
for (final link in links) {
if (link is! Map) continue;
add(ParentChildSummary.fromParentChildLink(
Map<String, dynamic>.from(link),
));
}
}
return children;
}
}