feat(#112): reprise après refus — dossier complet, email resoumission

- GET/PATCH reprise-dossier enrichis (parents, enfants, motivation, fiche AM)
- Front: lien mail, modale identify login, wizards préremplis, PATCH complet
- Email accusé resoumission aux parents avec n° de dossier
- Fixes préremplissage AM (dates, places, ValueKey étape 2)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-16 19:19:55 +02:00
co-authored by Cursor
parent c226c2fcdf
commit b99745e0fe
36 changed files with 2460 additions and 85 deletions
@@ -149,6 +149,20 @@ class ParentRegistrationPayload {
return body;
}
/// Enfant pour PATCH reprise (inclut `id` si connu).
static Map<String, dynamic> childToRepriseJson(
ChildData c,
int index,
String parentNom,
) {
final map = _childToJson(c, index, parentNom);
final id = c.repriseChildId?.trim();
if (id != null && id.isNotEmpty) {
map['id'] = id;
}
return map;
}
static Map<String, dynamic> _childToJson(ChildData c, int index, String parentNom) {
final map = <String, dynamic>{
'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre',
+223
View File
@@ -0,0 +1,223 @@
import 'package:p_tits_pas/models/am_registration_data.dart';
import 'package:p_tits_pas/models/card_assets.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/models/reprise_dossier.dart';
import 'package:p_tits_pas/models/user_registration_data.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
/// Mapping GET reprise-dossier → modèles wizard. Ticket #112.
class RepriseMapper {
RepriseMapper._();
static const List<CardColorVertical> _childCardColors = [
CardColorVertical.lavender,
CardColorVertical.pink,
CardColorVertical.peach,
CardColorVertical.lime,
CardColorVertical.red,
CardColorVertical.green,
CardColorVertical.blue,
];
static ParentData parentFromDossier(ParentDossier p) {
return ParentData(
firstName: p.prenom ?? '',
lastName: p.nom ?? '',
phone: p.telephone ?? '',
email: p.email,
address: p.adresse ?? '',
postalCode: p.codePostal ?? '',
city: p.ville ?? '',
password: '',
);
}
static ChildData childFromEnfant(EnfantDossier e, int index) {
final isUnborn = e.status == 'a_naitre';
final dob = isUnborn
? isoToDdMmYyyy(e.dueDate)
: isoToDdMmYyyy(e.birthDate);
final photo = e.photoUrl?.trim();
final hasPhoto = photo != null && photo.isNotEmpty;
return ChildData(
firstName: e.firstName ?? '',
lastName: e.lastName ?? '',
dob: dob,
genre: e.gender ?? '',
// Inscription initiale exigeait la coche pour envoyer la photo ; le back
// ne persistait pas toujours consent_photo — on pré-coche si photo en base.
photoConsent: e.consentPhoto || hasPhoto,
multipleBirth: e.estMultiple,
isUnbornChild: isUnborn,
cardColor: _childCardColors[index % _childCardColors.length],
repriseChildId: e.id,
existingPhotoUrl: photo != null && photo.isNotEmpty
? ApiConfig.absoluteMediaUrl(photo)
: null,
);
}
static void applyParentDossier(UserRegistrationData data, RepriseDossier dossier) {
ParentDossier? titulaire;
ParentDossier? coParent;
if (dossier.parents.isNotEmpty) {
for (final p in dossier.parents) {
if (p.id == dossier.id) {
titulaire = p;
} else {
coParent = p;
}
}
titulaire ??= dossier.parents.first;
if (coParent == null && dossier.parents.length > 1) {
coParent = dossier.parents.firstWhere(
(p) => p.id != titulaire!.id,
orElse: () => dossier.parents.last,
);
}
}
final p1 = titulaire != null
? parentFromDossier(titulaire)
: ParentData(
firstName: dossier.prenom ?? '',
lastName: dossier.nom ?? '',
phone: dossier.telephone ?? '',
email: dossier.email,
address: dossier.adresse ?? '',
postalCode: dossier.codePostal ?? '',
city: dossier.ville ?? '',
password: '',
);
final children = dossier.enfants
.asMap()
.entries
.map((e) => childFromEnfant(e.value, e.key))
.toList();
data.resetForReprise(
parent1Data: p1,
parent2Data: coParent != null ? parentFromDossier(coParent) : null,
childrenData: children,
motivation: dossier.texteMotivation ?? '',
);
}
static DateTime? parseIsoDate(String? raw) {
if (raw == null || raw.trim().isEmpty) return null;
final s = raw.trim();
final iso = RegExp(r'^(\d{4})-(\d{2})-(\d{2})');
final isoMatch = iso.firstMatch(s);
if (isoMatch != null) {
return DateTime(
int.parse(isoMatch.group(1)!),
int.parse(isoMatch.group(2)!),
int.parse(isoMatch.group(3)!),
);
}
try {
return DateTime.parse(s);
} catch (_) {
final parts = s.split('/');
if (parts.length == 3) {
final day = int.tryParse(parts[0]);
final month = int.tryParse(parts[1]);
final year = int.tryParse(parts[2]);
if (day != null && month != null && year != null) {
return DateTime(year, month, day);
}
}
return null;
}
}
static String? optionalDateString(dynamic value) {
if (value == null) return null;
if (value is String) {
final s = value.trim();
return s.isEmpty || s == 'null' ? null : s;
}
if (value is DateTime) {
return '${value.year.toString().padLeft(4, '0')}-'
'${value.month.toString().padLeft(2, '0')}-'
'${value.day.toString().padLeft(2, '0')}';
}
if (value is num) {
final ms = value.abs() > 9999999999
? value.toInt()
: value.toInt() * 1000;
final dt = DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true);
return '${dt.year.toString().padLeft(4, '0')}-'
'${dt.month.toString().padLeft(2, '0')}-'
'${dt.day.toString().padLeft(2, '0')}';
}
if (value is Map) {
final y = value['year'];
final m = value['month'] ?? value['monthValue'];
final d = value['day'] ?? value['dayOfMonth'];
if (y is num && m is num && d is num) {
return '${y.toInt().toString().padLeft(4, '0')}-'
'${m.toInt().toString().padLeft(2, '0')}-'
'${d.toInt().toString().padLeft(2, '0')}';
}
}
final s = value.toString().trim();
return s.isEmpty || s == 'null' ? null : s;
}
static int? optionalInt(dynamic value) {
if (value == null) return null;
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) {
final s = value.trim();
if (s.isEmpty) return null;
return int.tryParse(s);
}
return null;
}
static bool optionalBool(dynamic value) {
if (value == true) return true;
if (value is String && value.toLowerCase() == 'true') return true;
return false;
}
static void applyAmDossier(AmRegistrationData data, RepriseDossier dossier) {
final rawPhoto = dossier.photoUrl?.trim();
final hasPhoto = rawPhoto != null && rawPhoto.isNotEmpty;
final displayPhoto =
hasPhoto ? ApiConfig.absoluteMediaUrl(rawPhoto) : null;
data.resetForReprise(
firstName: dossier.prenom ?? '',
lastName: dossier.nom ?? '',
phone: dossier.telephone ?? '',
email: dossier.email,
streetAddress: dossier.adresse ?? '',
postalCode: dossier.codePostal ?? '',
city: dossier.ville ?? '',
existingPhotoUrl: displayPhoto,
consentementPhoto: dossier.consentementPhoto || hasPhoto,
dateOfBirth: parseIsoDate(dossier.dateNaissance),
birthCity: dossier.lieuNaissanceVille ?? '',
birthCountry: dossier.lieuNaissancePays ?? '',
nir: dossier.nir ?? '',
agrementNumber: dossier.numeroAgrement ?? '',
agreementDate: parseIsoDate(dossier.dateAgrement),
capacity: dossier.nbMaxEnfants,
placesAvailable: dossier.placeDisponible,
presentationText: dossier.biographie ?? '',
);
}
static String isoToDdMmYyyy(String? iso) {
final dt = parseIsoDate(iso);
if (dt == null) return '';
return '${dt.day.toString().padLeft(2, '0')}/'
'${dt.month.toString().padLeft(2, '0')}/'
'${dt.year}';
}
}
+150
View File
@@ -0,0 +1,150 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import '../models/am_registration_data.dart';
import '../models/user_registration_data.dart';
import 'nir_utils.dart';
import 'parent_registration_payload.dart';
/// Body PATCH /auth/reprise-resoumettre. Ticket #112.
class ReprisePayload {
ReprisePayload._();
static Map<String, dynamic> parentPatch(
UserRegistrationData data,
String token,
) {
final base = ParentRegistrationPayload.toJson(data);
base.remove('email');
base.remove('acceptation_cgu');
base.remove('acceptation_privacy');
base['token'] = token.trim();
final enfants = data.children.asMap().entries.map((e) {
final childMap = ParentRegistrationPayload.childToRepriseJson(
e.value,
e.key,
data.parent1.lastName.trim(),
);
return childMap;
}).toList();
base['enfants'] = enfants;
return base;
}
static Future<Map<String, dynamic>> amPatch(
AmRegistrationData data,
String token, {
String? existingPhotoUrl,
}) async {
final body = <String, dynamic>{'token': token.trim()};
_put(body, 'prenom', data.firstName.trim());
_put(body, 'nom', data.lastName.trim());
_put(body, 'telephone', data.phone.trim());
_put(body, 'adresse', data.streetAddress.trim());
_put(body, 'code_postal', data.postalCode.trim());
_put(body, 'ville', data.city.trim());
body['consentement_photo'] = data.photoConsent;
if (data.dateOfBirth != null) {
final d = data.dateOfBirth!;
body['date_naissance'] =
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
}
_put(body, 'lieu_naissance_ville', data.birthCity.trim());
_put(body, 'lieu_naissance_pays', data.birthCountry.trim());
_put(body, 'numero_agrement', data.agrementNumber.trim());
if (data.nir.trim().isNotEmpty) {
body['nir'] = normalizeNir(data.nir);
}
if (data.agreementDate != null) {
final d = data.agreementDate!;
body['date_agrement'] =
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
}
if (data.capacity != null) {
body['capacite_accueil'] = data.capacity;
}
if (data.placesAvailable != null) {
body['places_disponibles'] = data.placesAvailable;
}
if (data.presentationText.trim().isNotEmpty) {
body['biographie'] = data.presentationText.trim();
}
final photo = await _amPhotoPayload(data, existingPhotoUrl);
if (photo != null) {
body.addAll(photo);
}
return body;
}
static Future<Map<String, String>?> _amPhotoPayload(
AmRegistrationData data,
String? existingPhotoUrl,
) async {
if (data.photoBytes != null && data.photoBytes!.isNotEmpty) {
final mime = _imageMimeForBytes(data.photoBytes!);
final fn = (data.photoFilename ?? '').trim();
return {
'photo_base64':
'data:$mime;base64,${base64Encode(data.photoBytes!)}',
'photo_filename': fn.isNotEmpty ? fn : 'photo_am.jpg',
};
}
if (!kIsWeb &&
data.photoPath != null &&
data.photoPath!.isNotEmpty &&
!data.photoPath!.startsWith('assets/') &&
!data.photoPath!.startsWith('http')) {
try {
final file = File(data.photoPath!);
if (await file.exists()) {
final bytes = await file.readAsBytes();
final mime = _imageMimeForBytes(bytes);
return {
'photo_base64': 'data:$mime;base64,${base64Encode(bytes)}',
'photo_filename':
_basenameFromPath(data.photoPath!) ?? 'photo_am.jpg',
};
}
} catch (_) {}
}
final url = (existingPhotoUrl ?? data.repriseExistingPhotoUrl ?? '').trim();
if (url.isNotEmpty) {
return {'photo_url': url};
}
return null;
}
static String _imageMimeForBytes(Uint8List bytes) {
if (bytes.length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xD8) {
return 'image/jpeg';
}
if (bytes.length >= 8 &&
bytes[0] == 0x89 &&
bytes[1] == 0x50 &&
bytes[2] == 0x4E &&
bytes[3] == 0x47) {
return 'image/png';
}
return 'image/jpeg';
}
static String? _basenameFromPath(String path) {
final parts = path.replaceAll('\\', '/').split('/');
return parts.isEmpty ? null : parts.last;
}
static void _put(Map<String, dynamic> m, String key, String value) {
if (value.isNotEmpty) m[key] = value;
}
}