feat(admin): onglet À valider, dossier unifié et modales de validation

- Onglet « À valider » (AM + familles), pending-families et détail dossier par numéro.
- Wizards validation AM et famille, modale commune, chargement via GET /dossiers/:num.
- UI : cartes enfants, photo AM (cadre uniforme, ratio), NIR affiché formaté (espaces autour du tiret).
- Backend : DTO / routes parents pending ; scripts de test et mise à jour issue Gitea.

Tickets #107, #119.

Made-with: Cursor
This commit is contained in:
2026-03-26 00:15:18 +01:00
parent d832559027
commit 2e02f60601
36 changed files with 3303 additions and 173 deletions
+210
View File
@@ -0,0 +1,210 @@
import 'package:p_tits_pas/models/user.dart';
/// Réponse unifiée GET /dossiers/:numeroDossier. Ticket #119, #107.
class DossierUnifie {
final String type; // 'am' | 'family'
final dynamic dossier; // DossierAM | DossierFamille
DossierUnifie({required this.type, required this.dossier});
bool get isAm => type == 'am';
bool get isFamily => type == 'family';
DossierAM get asAm => dossier as DossierAM;
DossierFamille get asFamily => dossier as DossierFamille;
factory DossierUnifie.fromJson(Map<String, dynamic> json) {
final t = json['type'];
final raw = t is String ? t : 'family';
final typeStr = raw.toLowerCase();
final d = json['dossier'];
if (d == null || d is! Map<String, dynamic>) {
throw FormatException('dossier manquant ou invalide');
}
final dossierMap = Map<String, dynamic>.from(d as Map);
// Seul `am` (casse tolérée) charge le dossier AM ; le reste = famille (API : type "family").
final isAm = typeStr == 'am';
final dossier = isAm ? DossierAM.fromJson(dossierMap) : DossierFamille.fromJson(dossierMap);
return DossierUnifie(type: isAm ? 'am' : 'family', dossier: dossier);
}
}
/// Dossier AM (type: 'am'). Champs alignés API.
class DossierAM {
final String? numeroDossier;
final AppUser user;
final String? numeroAgrement;
final String? nir;
final String? presentation;
final String? dateAgrement;
final int? nbMaxEnfants;
final int? placesDisponibles;
final String? villeResidence;
DossierAM({
this.numeroDossier,
required this.user,
this.numeroAgrement,
this.nir,
this.presentation,
this.dateAgrement,
this.nbMaxEnfants,
this.placesDisponibles,
this.villeResidence,
});
factory DossierAM.fromJson(Map<String, dynamic> json) {
final userJson = json['user'];
final userMap = userJson is Map<String, dynamic>
? userJson
: <String, dynamic>{};
final nbMax = json['nb_max_enfants'];
final places = json['place_disponible'];
return DossierAM(
numeroDossier: json['numero_dossier']?.toString(),
user: AppUser.fromJson(Map<String, dynamic>.from(userMap)),
numeroAgrement: json['numero_agrement']?.toString(),
nir: json['nir']?.toString(),
presentation: (json['biographie'] ?? json['presentation'])?.toString(),
dateAgrement: json['date_agrement']?.toString(),
nbMaxEnfants: nbMax is int ? nbMax : (nbMax is num ? nbMax.toInt() : null),
placesDisponibles: places is int ? places : (places is num ? places.toInt() : null),
villeResidence: json['ville_residence']?.toString(),
);
}
}
/// Dossier famille (type: 'family'). Champs alignés API.
class DossierFamille {
final String? numeroDossier;
final List<ParentDossier> parents;
final List<EnfantDossier> enfants;
final String? presentation;
DossierFamille({
this.numeroDossier,
required this.parents,
required this.enfants,
this.presentation,
});
factory DossierFamille.fromJson(Map<String, dynamic> json) {
final parentsRaw = json['parents'];
final parentsList = parentsRaw is List
? (parentsRaw)
.where((e) => e is Map)
.map((e) => ParentDossier.fromJson(Map<String, dynamic>.from(e as Map)))
.toList()
: <ParentDossier>[];
final enfantsRaw = json['enfants'];
final enfantsList = enfantsRaw is List
? (enfantsRaw)
.where((e) => e is Map)
.map((e) => EnfantDossier.fromJson(Map<String, dynamic>.from(e as Map)))
.toList()
: <EnfantDossier>[];
return DossierFamille(
numeroDossier: json['numero_dossier']?.toString(),
parents: parentsList,
enfants: enfantsList,
presentation: (json['texte_motivation'] ?? json['presentation'])?.toString(),
);
}
bool get isEnAttente =>
parents.any((p) => p.statut == 'en_attente');
}
/// Parent dans un dossier famille (champs user exposés).
class ParentDossier {
final String id;
final String email;
final String? prenom;
final String? nom;
final String? telephone;
final String? adresse;
final String? ville;
final String? codePostal;
final String? dateNaissance;
final String? genre;
final String? situationFamiliale;
final String? creeLe;
final String? statut;
ParentDossier({
required this.id,
required this.email,
this.prenom,
this.nom,
this.telephone,
this.adresse,
this.ville,
this.codePostal,
this.dateNaissance,
this.genre,
this.situationFamiliale,
this.creeLe,
this.statut,
});
String get fullName => '${prenom ?? ''} ${nom ?? ''}'.trim();
factory ParentDossier.fromJson(Map<String, dynamic> json) {
return ParentDossier(
id: json['id']?.toString() ?? '',
email: json['email']?.toString() ?? '',
prenom: json['prenom']?.toString(),
nom: json['nom']?.toString(),
telephone: json['telephone']?.toString(),
adresse: json['adresse']?.toString(),
ville: json['ville']?.toString(),
codePostal: json['code_postal']?.toString(),
dateNaissance: json['date_naissance']?.toString(),
genre: json['genre']?.toString(),
situationFamiliale: json['situation_familiale']?.toString(),
creeLe: json['cree_le']?.toString(),
statut: json['statut']?.toString(),
);
}
}
/// Enfant dans un dossier famille.
class EnfantDossier {
final String id;
final String? firstName;
final String? lastName;
final String? birthDate;
final String? gender;
final String? status;
final String? dueDate;
final String? photoUrl;
final bool consentPhoto;
EnfantDossier({
required this.id,
this.firstName,
this.lastName,
this.birthDate,
this.gender,
this.status,
this.dueDate,
this.photoUrl,
this.consentPhoto = false,
});
String get fullName => '${firstName ?? ''} ${lastName ?? ''}'.trim();
factory EnfantDossier.fromJson(Map<String, dynamic> json) {
return EnfantDossier(
id: json['id']?.toString() ?? '',
firstName: (json['first_name'] ?? json['prenom'])?.toString(),
lastName: (json['last_name'] ?? json['nom'])?.toString(),
birthDate: json['birth_date']?.toString(),
gender: (json['gender'] ?? json['genre'])?.toString(),
status: json['status']?.toString(),
dueDate: json['due_date']?.toString(),
photoUrl: json['photo_url']?.toString(),
consentPhoto: json['consent_photo'] == true,
);
}
}
+232
View File
@@ -0,0 +1,232 @@
/// Résumé affichable pour un parent (liste pending-families).
class PendingParentLine {
final String? email;
final String? telephone;
final String? codePostal;
final String? ville;
const PendingParentLine({
this.email,
this.telephone,
this.codePostal,
this.ville,
});
bool get isEmpty {
final e = email?.trim();
final t = telephone?.trim();
final loc = _locationTrimmed;
return (e == null || e.isEmpty) &&
(t == null || t.isEmpty) &&
(loc == null || loc.isEmpty);
}
String? get _locationTrimmed {
final cp = codePostal?.trim();
final v = ville?.trim();
final loc = [if (cp != null && cp.isNotEmpty) cp, if (v != null && v.isNotEmpty) v]
.join(' ')
.trim();
return loc.isEmpty ? null : loc;
}
}
/// Famille en attente de validation (GET /parents/pending-families). Ticket #107.
///
/// Contrat API : `libelle`, `parentIds`, `numero_dossier`, `date_soumission`,
/// `nombre_enfants`, `emails`, éventuellement `parents` / tableaux parallèles.
class PendingFamily {
final String libelle;
final List<String> parentIds;
final String? numeroDossier;
/// Date affichée : `date_soumission` (ISO), sinon alias `cree_le` / etc.
final DateTime? dateSoumission;
/// Emails seuls (API) — le sous-titre utilise de préférence [parentLines].
final List<String> emails;
/// Une entrée par parent : email, tél., CP ville (si fournis par lAPI).
final List<PendingParentLine> parentLines;
final int nombreEnfants;
/// Compat : premier email.
final String? email;
PendingFamily({
required this.libelle,
required this.parentIds,
this.numeroDossier,
this.dateSoumission,
this.emails = const [],
this.parentLines = const [],
this.nombreEnfants = 0,
this.email,
});
static DateTime? _parseDate(dynamic v) {
if (v == null) return null;
if (v is DateTime) return v;
if (v is String) {
return DateTime.tryParse(v);
}
return null;
}
static List<String> _parseStringList(dynamic raw) {
if (raw is! List) return [];
return raw
.map((e) => e?.toString().trim() ?? '')
.where((s) => s.isNotEmpty)
.toList();
}
static List<PendingParentLine> _parseParentLinesFromMaps(dynamic raw) {
if (raw is! List) return [];
final out = <PendingParentLine>[];
for (final e in raw) {
if (e is! Map) continue;
final m = Map<String, dynamic>.from(e);
final em = m['email']?.toString().trim();
final tel = m['telephone']?.toString().trim();
final cp = (m['code_postal'] ?? m['codePostal'])?.toString().trim();
final ville = m['ville']?.toString().trim();
out.add(PendingParentLine(
email: em != null && em.isNotEmpty ? em : null,
telephone: tel != null && tel.isNotEmpty ? tel : null,
codePostal: cp != null && cp.isNotEmpty ? cp : null,
ville: ville != null && ville.isNotEmpty ? ville : null,
));
}
return out;
}
/// Construit [parentLines] : objets `parents`, tableaux parallèles, ou emails + champs racine.
static List<PendingParentLine> _buildParentLines(
Map<String, dynamic> json,
List<String> emails,
) {
final fromMaps = _parseParentLinesFromMaps(
json['parents'] ?? json['resume_parents'] ?? json['parent_summaries'] ?? json['parent_lines'],
);
if (fromMaps.isNotEmpty) {
return fromMaps;
}
List<String>? parallel(dynamic keySingular, dynamic keyPlural) {
final pl = json[keyPlural];
if (pl is List) return _parseStringList(pl);
final s = json[keySingular];
if (s is String && s.trim().isNotEmpty) return [s.trim()];
return null;
}
final tels = parallel('telephone', 'telephones');
final cps = parallel('code_postal', 'code_postaux') ?? parallel('codePostal', 'codes_postaux');
final villes = parallel('ville', 'villes');
if (emails.isNotEmpty &&
((tels?.isNotEmpty ?? false) ||
(cps?.isNotEmpty ?? false) ||
(villes?.isNotEmpty ?? false))) {
return List.generate(emails.length, (i) {
return PendingParentLine(
email: emails[i],
telephone: tels != null && i < tels.length ? tels[i] : null,
codePostal: cps != null && i < cps.length ? cps[i] : null,
ville: villes != null && i < villes.length ? villes[i] : null,
);
});
}
final rootTel = json['telephone']?.toString().trim();
final rootTelOk = rootTel != null && rootTel.isNotEmpty ? rootTel : null;
final rootCp = (json['code_postal'] ?? json['codePostal'])?.toString().trim();
final rootCpOk = rootCp != null && rootCp.isNotEmpty ? rootCp : null;
final rootVille = json['ville']?.toString().trim();
final rootVilleOk = rootVille != null && rootVille.isNotEmpty ? rootVille : null;
if (emails.isNotEmpty) {
return List.generate(emails.length, (i) {
return PendingParentLine(
email: emails[i],
telephone: i == 0 ? rootTelOk : null,
codePostal: i == 0 ? rootCpOk : null,
ville: i == 0 ? rootVilleOk : null,
);
});
}
if (rootTelOk != null || rootCpOk != null || rootVilleOk != null) {
final em = json['email']?.toString().trim();
return [
PendingParentLine(
email: em != null && em.isNotEmpty ? em : null,
telephone: rootTelOk,
codePostal: rootCpOk,
ville: rootVilleOk,
),
];
}
return [];
}
factory PendingFamily.fromJson(Map<String, dynamic> json) {
final parentIdsRaw = json['parentIds'] ?? json['parent_ids'];
final List<String> ids = parentIdsRaw is List
? (parentIdsRaw).map((e) => e?.toString() ?? '').where((s) => s.isNotEmpty).toList()
: [];
final libelle = json['libelle'];
final libelleStr = libelle is String ? libelle : (libelle?.toString() ?? 'Famille');
final nd = json['numero_dossier'] ?? json['numeroDossier'];
final numeroDossier = (nd is String && nd.isNotEmpty) ? nd : null;
DateTime? dateSoumission = _parseDate(
json['date_soumission'] ?? json['dateSoumission'],
);
dateSoumission ??= _parseDate(
json['cree_le'] ?? json['creeLe'] ?? json['date_inscription'],
);
List<String> emails = _parseStringList(json['emails']);
if (emails.isEmpty) {
final emailRaw = json['email'];
if (emailRaw is String && emailRaw.trim().isNotEmpty) {
emails = [emailRaw.trim()];
}
}
final String? emailCompat = emails.isNotEmpty
? emails.first
: (json['email'] is String && (json['email'] as String).trim().isNotEmpty
? (json['email'] as String).trim()
: null);
final nbRaw = json['nombre_enfants'] ?? json['nombreEnfants'];
int nombreEnfants = 0;
if (nbRaw is int) {
nombreEnfants = nbRaw;
} else if (nbRaw is num) {
nombreEnfants = nbRaw.toInt();
} else if (nbRaw != null) {
nombreEnfants = int.tryParse(nbRaw.toString()) ?? 0;
}
var parentLines = _buildParentLines(json, emails);
if (parentLines.isEmpty && emails.isNotEmpty) {
parentLines = emails.map((e) => PendingParentLine(email: e)).toList();
}
return PendingFamily(
libelle: libelleStr.isEmpty ? 'Famille' : libelleStr,
parentIds: ids,
numeroDossier: numeroDossier,
dateSoumission: dateSoumission,
emails: emails,
parentLines: parentLines,
nombreEnfants: nombreEnfants,
email: emailCompat,
);
}
}
+34 -22
View File
@@ -15,6 +15,7 @@ class AppUser {
final String? codePostal;
final String? relaisId;
final String? relaisNom;
final String? numeroDossier;
AppUser({
required this.id,
@@ -33,40 +34,50 @@ class AppUser {
this.codePostal,
this.relaisId,
this.relaisNom,
this.numeroDossier,
});
static String _str(dynamic v) {
if (v == null) return '';
if (v is String) return v;
return v.toString();
}
static DateTime _date(dynamic v) {
if (v == null) return DateTime.now();
if (v is DateTime) return v;
try {
return DateTime.parse(v.toString());
} catch (_) {
return DateTime.now();
}
}
factory AppUser.fromJson(Map<String, dynamic> json) {
final relaisJson = json['relais'];
final relaisMap =
relaisJson is Map<String, dynamic> ? relaisJson : <String, dynamic>{};
return AppUser(
id: (json['id'] as String?) ?? '',
email: (json['email'] as String?) ?? '',
role: (json['role'] as String?) ?? '',
createdAt: json['cree_le'] != null
? DateTime.parse(json['cree_le'] as String)
: (json['createdAt'] != null
? DateTime.parse(json['createdAt'] as String)
: DateTime.now()),
updatedAt: json['modifie_le'] != null
? DateTime.parse(json['modifie_le'] as String)
: (json['updatedAt'] != null
? DateTime.parse(json['updatedAt'] as String)
: DateTime.now()),
id: _str(json['id']),
email: _str(json['email']),
role: _str(json['role']),
createdAt: _date(json['cree_le'] ?? json['createdAt']),
updatedAt: _date(json['modifie_le'] ?? json['updatedAt']),
changementMdpObligatoire:
json['changement_mdp_obligatoire'] as bool? ?? false,
nom: json['nom'] as String?,
prenom: json['prenom'] as String?,
statut: json['statut'] as String?,
telephone: json['telephone'] as String?,
photoUrl: json['photo_url'] as String?,
adresse: json['adresse'] as String?,
ville: json['ville'] as String?,
codePostal: json['code_postal'] as String?,
json['changement_mdp_obligatoire'] == true,
nom: json['nom'] is String ? json['nom'] as String : null,
prenom: json['prenom'] is String ? json['prenom'] as String : null,
statut: json['statut'] is String ? json['statut'] as String : null,
telephone: json['telephone'] is String ? json['telephone'] as String : null,
photoUrl: json['photo_url'] is String ? json['photo_url'] as String : null,
adresse: json['adresse'] is String ? json['adresse'] as String : null,
ville: json['ville'] is String ? json['ville'] as String : null,
codePostal: json['code_postal'] is String ? json['code_postal'] as String : null,
relaisId: (json['relaisId'] ?? json['relais_id'] ?? relaisMap['id'])
?.toString(),
relaisNom: relaisMap['nom']?.toString(),
numeroDossier: json['numero_dossier'] is String ? json['numero_dossier'] as String : null,
);
}
@@ -88,6 +99,7 @@ class AppUser {
'code_postal': codePostal,
'relais_id': relaisId,
'relais_nom': relaisNom,
'numero_dossier': numeroDossier,
};
}