feat(#135): mode édition dossier (PATCH fiche, co-parent, enfants).

Wizard edit famille/AM depuis la liste Dossiers ; save parents +
co-parent ; enfants existants en PATCH (photo multipart) sans POST doublon.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 16:07:23 +02:00
co-authored by Cursor
parent 530e896b66
commit c8c9cfbc4d
8 changed files with 788 additions and 69 deletions
@@ -141,12 +141,20 @@ export class EnfantsController {
RoleType.GESTIONNAIRE, RoleType.GESTIONNAIRE,
) )
@Patch(':id') @Patch(':id')
@ApiOperation({
summary: 'Mettre à jour un enfant',
description:
'JSON sans photo OK ; avec nouvelle photo → multipart (champ fichier `photo`, max 5 Mo).',
})
@ApiConsumes('application/json', 'multipart/form-data')
@UseInterceptors(OptionalEnfantPhotoInterceptor)
update( update(
@Param('id', new ParseUUIDPipe()) id: string, @Param('id', new ParseUUIDPipe()) id: string,
@Body() dto: UpdateEnfantsDto, @Body() dto: UpdateEnfantsDto,
@UploadedFile() photo: Express.Multer.File,
@User() currentUser: Users, @User() currentUser: Users,
) { ) {
return this.enfantsService.update(id, dto, currentUser); return this.enfantsService.update(id, dto, currentUser, photo);
} }
@Roles(RoleType.SUPER_ADMIN) @Roles(RoleType.SUPER_ADMIN)
+13 -1
View File
@@ -195,7 +195,12 @@ export class EnfantsService {
// Mise à jour // Mise à jour
async update(id: string, dto: Partial<CreateEnfantsDto>, currentUser: Users): Promise<Children> { async update(
id: string,
dto: Partial<CreateEnfantsDto>,
currentUser: Users,
photoFile?: Express.Multer.File,
): Promise<Children> {
const child = await this.childrenRepository.findOne({ where: { id } }); const child = await this.childrenRepository.findOne({ where: { id } });
if (!child) throw new NotFoundException('Enfant introuvable'); if (!child) throw new NotFoundException('Enfant introuvable');
@@ -205,6 +210,13 @@ export class EnfantsService {
patch.consent_photo = dto.consent_photo; patch.consent_photo = dto.consent_photo;
patch.consent_photo_at = dto.consent_photo ? new Date() : null!; patch.consent_photo_at = dto.consent_photo ? new Date() : null!;
} }
if (photoFile) {
patch.photo_url = `/uploads/photos/${photoFile.filename}`;
if (dto.consent_photo !== false) {
patch.consent_photo = true;
patch.consent_photo_at = new Date();
}
}
await this.childrenRepository.update(id, patch); await this.childrenRepository.update(id, patch);
return this.findOne(id, currentUser); return this.findOne(id, currentUser);
+6 -1
View File
@@ -215,8 +215,13 @@ class EnfantDossier {
factory EnfantDossier.fromJson(Map<String, dynamic> json) { factory EnfantDossier.fromJson(Map<String, dynamic> json) {
final rawPhoto = json['photo_url'] ?? json['photoUrl']; final rawPhoto = json['photo_url'] ?? json['photoUrl'];
final resolvedPhoto = _optionalPhotoUrl(rawPhoto); final resolvedPhoto = _optionalPhotoUrl(rawPhoto);
final rawId = json['id'] ??
json['enfant_id'] ??
json['enfantId'] ??
json['child_id'] ??
json['childId'];
return EnfantDossier( return EnfantDossier(
id: json['id']?.toString() ?? '', id: rawId?.toString().trim() ?? '',
firstName: (json['first_name'] ?? json['prenom'])?.toString(), firstName: (json['first_name'] ?? json['prenom'])?.toString(),
lastName: (json['last_name'] ?? json['nom'])?.toString(), lastName: (json['last_name'] ?? json['nom'])?.toString(),
birthDate: json['birth_date']?.toString(), birthDate: json['birth_date']?.toString(),
+98 -6
View File
@@ -525,17 +525,58 @@ class UserService {
return enfant.copyWith(parentLinks: enriched); return enfant.copyWith(parentLinks: enriched);
} }
/// Mise à jour enfant. Avec [photoBytes] : multipart (champ `photo`), sinon JSON.
static Future<EnfantAdminModel> updateEnfant({ static Future<EnfantAdminModel> updateEnfant({
required String enfantId, required String enfantId,
required Map<String, dynamic> body, required Map<String, dynamic> body,
List<int>? photoBytes,
String? photoFilename,
}) async { }) async {
final response = await http.patch( final id = enfantId.trim();
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'), if (id.isEmpty) {
headers: await _headers(), throw Exception('Identifiant enfant manquant.');
body: jsonEncode(body), }
); final hasPhoto = photoBytes != null && photoBytes.isNotEmpty;
final http.Response response;
if (hasPhoto) {
final token = await TokenService.getToken();
final req = http.MultipartRequest(
'PATCH',
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$id'),
);
req.headers['Accept'] = 'application/json';
if (token != null) {
req.headers['Authorization'] = 'Bearer $token';
}
body.forEach((key, value) {
if (value == null) return;
req.fields[key] = value is bool
? (value ? 'true' : 'false')
: value.toString();
});
final name = (photoFilename ?? '').trim();
final filename = name.isNotEmpty ? name : 'photo.jpg';
req.files.add(
http.MultipartFile.fromBytes(
'photo',
photoBytes,
filename: filename,
contentType: _imageMediaType(filename, photoBytes),
),
);
final streamed = await req.send();
response = await http.Response.fromStream(streamed);
} else {
response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$id'),
headers: await _headers(),
body: jsonEncode(body),
);
}
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant')); throw Exception(
_extractErrorMessage(response.body, 'Erreur mise à jour enfant'),
);
} }
final enfant = EnfantAdminModel.fromJson( final enfant = EnfantAdminModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>, jsonDecode(response.body) as Map<String, dynamic>,
@@ -645,6 +686,57 @@ class UserService {
return fallback; return fallback;
} }
/// Ajout dun co-parent sur foyer mono-parent (staff, #135).
/// `POST /parents/:pivotUserId/co-parent` — succès 201.
static Future<Map<String, dynamic>> addCoParent(
String pivotUserId, {
required Map<String, dynamic> body,
}) async {
final id = pivotUserId.trim();
if (id.isEmpty) {
throw Exception('Identifiant du parent pivot manquant.');
}
final http.Response response;
try {
response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$id/co-parent'),
headers: await _headers(),
body: jsonEncode(body),
);
} on http.ClientException {
throw Exception(
'Connexion au serveur impossible. Vérifiez votre réseau puis réessayez.',
);
}
if (response.statusCode == 200 || response.statusCode == 201) {
if (response.body.trim().isEmpty) return <String, dynamic>{};
try {
final decoded = jsonDecode(response.body);
if (decoded is Map) {
return Map<String, dynamic>.from(decoded);
}
} catch (_) {}
return <String, dynamic>{};
}
final message = _extractErrorMessage(
response.body,
'Erreur ajout co-parent',
);
if (response.statusCode == 409) {
throw Exception(
message.isNotEmpty ? message : 'Conflit : e-mail déjà utilisé.',
);
}
if (response.statusCode == 400) {
throw Exception(
message.isNotEmpty ? message : 'Données invalides (400).',
);
}
throw Exception(message);
}
/// Création dossier famille actif côté staff (#129). /// Création dossier famille actif côté staff (#129).
/// `POST /parents/dossier` — body aligné sur register parent complet. /// `POST /parents/dossier` — body aligné sur register parent complet.
/// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur, par parent créé). /// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur, par parent créé).
+165 -22
View File
@@ -23,9 +23,9 @@ import 'package:p_tits_pas/widgets/admin/validation_valider_confirm_dialog.dart'
import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart';
enum AmDossierWizardMode { review, create } enum AmDossierWizardMode { review, create, edit }
/// Wizard dossier AM — modes [review] (validation pending) et [create] (staff #156). /// Wizard dossier AM — [review] (#107), [create] (#156), [edit] (#135).
class AmDossierWizard extends StatefulWidget { class AmDossierWizard extends StatefulWidget {
final AmDossierWizardMode mode; final AmDossierWizardMode mode;
final DossierAM? dossier; final DossierAM? dossier;
@@ -74,7 +74,25 @@ class AmDossierWizard extends StatefulWidget {
); );
} }
factory AmDossierWizard.edit({
Key? key,
required DossierAM dossier,
required VoidCallback onClose,
required VoidCallback onSuccess,
void Function(int step, int total)? onStepChanged,
}) {
return AmDossierWizard._(
key: key,
mode: AmDossierWizardMode.edit,
dossier: dossier,
onClose: onClose,
onSuccess: onSuccess,
onStepChanged: onStepChanged,
);
}
bool get isCreate => mode == AmDossierWizardMode.create; bool get isCreate => mode == AmDossierWizardMode.create;
bool get isEdit => mode == AmDossierWizardMode.edit;
/// Hauteur corps modale AM — dérivée de [ValidationFormMetrics] (4 lignes). /// Hauteur corps modale AM — dérivée de [ValidationFormMetrics] (4 lignes).
static double get shellBodyHeight => static double get shellBodyHeight =>
@@ -118,9 +136,11 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
String? _photoFilename; String? _photoFilename;
bool get _isCreate => widget.isCreate; bool get _isCreate => widget.isCreate;
bool get _isEdit => widget.isEdit;
bool get _isEditable => _isCreate || _isEdit;
DossierAM get _dossier => widget.dossier!; DossierAM get _dossier => widget.dossier!;
bool get _isEnAttente => bool get _isEnAttente =>
!_isCreate && _dossier.user.statut == 'en_attente'; !_isCreate && !_isEdit && _dossier.user.statut == 'en_attente';
static String _v(String? s) => static String _v(String? s) =>
(s != null && s.trim().isNotEmpty) ? s.trim() : ''; (s != null && s.trim().isNotEmpty) ? s.trim() : '';
@@ -151,9 +171,33 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
_capaciteCtrl = TextEditingController(text: '1'); _capaciteCtrl = TextEditingController(text: '1');
_placesCtrl = TextEditingController(text: '1'); _placesCtrl = TextEditingController(text: '1');
_presentationCtrl = TextEditingController(); _presentationCtrl = TextEditingController();
if (_isEdit) {
_prefillFromDossier();
}
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep()); WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
} }
void _prefillFromDossier() {
final u = _dossier.user;
_nomCtrl.text = (u.nom ?? '').trim();
_prenomCtrl.text = (u.prenom ?? '').trim();
_telCtrl.text = (u.telephone ?? '').trim();
_emailCtrl.text = u.email.trim();
_adresseCtrl.text = (u.adresse ?? '').trim();
_cpCtrl.text = (u.codePostal ?? '').trim();
_villeCtrl.text = (u.ville ?? '').trim();
_nirCtrl.text = (_dossier.nir ?? '').trim();
_dateNaissanceCtrl.text = formatIsoDateFrInput(u.dateNaissance);
_lieuNaissanceVilleCtrl.text = (u.lieuNaissanceVille ?? '').trim();
final pays = (u.lieuNaissancePays ?? '').trim();
_lieuNaissancePaysCtrl.text = pays.isEmpty ? 'France' : pays;
_agrementCtrl.text = (_dossier.numeroAgrement ?? '').trim();
_dateAgrementCtrl.text = formatIsoDateFrInput(_dossier.dateAgrement);
_capaciteCtrl.text = '${_dossier.nbMaxEnfants ?? 1}';
_placesCtrl.text = '${_dossier.placesDisponibles ?? 0}';
_presentationCtrl.text = (_dossier.presentation ?? '').trim();
}
@override @override
void dispose() { void dispose() {
_nomCtrl.dispose(); _nomCtrl.dispose();
@@ -373,8 +417,8 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
return null; return null;
} }
String? _validateStep1() { String? _validateStep1({required bool requirePhoto}) {
if (_photoBytes == null || _photoBytes!.isEmpty) { if (requirePhoto && (_photoBytes == null || _photoBytes!.isEmpty)) {
return 'Une photo de profil est requise.'; return 'Une photo de profil est requise.';
} }
final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text); final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text);
@@ -407,12 +451,12 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
} }
String? _validateCurrentStep() { String? _validateCurrentStep() {
if (!_isCreate) return null; if (!_isEditable) return null;
switch (_step) { switch (_step) {
case 0: case 0:
return _validateStep0(); return _validateStep0();
case 1: case 1:
return _validateStep1(); return _validateStep1(requirePhoto: _isCreate);
default: default:
return null; return null;
} }
@@ -511,7 +555,7 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
); );
return; return;
} }
final err1 = _validateStep1(); final err1 = _validateStep1(requirePhoto: true);
if (err1 != null) { if (err1 != null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(err1), backgroundColor: Colors.red.shade700), SnackBar(content: Text(err1), backgroundColor: Colors.red.shade700),
@@ -554,6 +598,94 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
} }
} }
Future<void> _saveEdit() async {
if (_submitting || !_isEdit) return;
final err0 = _validateStep0();
if (err0 != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(err0), backgroundColor: Colors.red.shade700),
);
return;
}
final err1 = _validateStep1(requirePhoto: false);
if (err1 != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(err1), backgroundColor: Colors.red.shade700),
);
return;
}
final amId = _dossier.user.id.trim();
if (amId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Identifiant AM manquant.'),
backgroundColor: Colors.red.shade700,
),
);
return;
}
final ok = await showValidationValiderConfirmDialog(
context,
body: 'Enregistrer les modifications de la fiche assistante maternelle ?',
);
if (!mounted || !ok) return;
final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text);
final agrementIso = parseFrDateToIso(_dateAgrementCtrl.text);
final capa = int.tryParse(_capaciteCtrl.text.trim());
final places = int.tryParse(_placesCtrl.text.trim());
final biography = _presentationCtrl.text.trim();
setState(() => _submitting = true);
try {
await UserService.updateAmFiche(
amUserId: amId,
body: {
'nom': formatPersonNameCase(_nomCtrl.text),
'prenom': formatPersonNameCase(_prenomCtrl.text),
'email': normalizeEmailText(_emailCtrl.text),
'telephone': normalizePhone(_telCtrl.text),
'adresse': _adresseCtrl.text.trim(),
'ville': formatPersonNameCase(_villeCtrl.text),
'code_postal': _cpCtrl.text.trim(),
'approval_number': _agrementCtrl.text.trim(),
'nir': nirToRaw(_nirCtrl.text),
if (birthIso != null) 'date_naissance': birthIso,
'lieu_naissance_ville':
formatPersonNameCase(_lieuNaissanceVilleCtrl.text),
'lieu_naissance_pays':
formatPersonNameCase(_lieuNaissancePaysCtrl.text),
if (agrementIso != null) 'agreement_date': agrementIso,
if (capa != null) 'max_children': capa,
if (places != null) 'places_available': places,
'biography': biography,
},
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Fiche AM enregistrée.'),
duration: Duration(seconds: 4),
),
);
widget.onSuccess();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur'),
backgroundColor: Colors.red.shade700,
),
);
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_showRefusForm) { if (_showRefusForm) {
@@ -587,7 +719,7 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
} }
Widget _buildStep0() { Widget _buildStep0() {
if (_isCreate) { if (_isEditable) {
return IdentityBlock.editable( return IdentityBlock.editable(
title: 'Identité et coordonnées', title: 'Identité et coordonnées',
nomController: _nomCtrl, nomController: _nomCtrl,
@@ -620,7 +752,7 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
.clamp(_photoColumnMinWidth, 360.0); .clamp(_photoColumnMinWidth, 360.0);
if (photoW > maxPhotoW) photoW = maxPhotoW; if (photoW > maxPhotoW) photoW = maxPhotoW;
final form = _isCreate final form = _isEditable
? _buildCreateProFields() ? _buildCreateProFields()
: ValidationDetailSection( : ValidationDetailSection(
title: 'Dossier professionnel', title: 'Dossier professionnel',
@@ -628,20 +760,25 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
rowLayout: _photoProRowLayout, rowLayout: _photoProRowLayout,
); );
final Widget photo;
if (_isCreate) {
photo = AdminAmPhotoFrame(
imageBytes: _photoBytes,
onTap: _pickPhoto,
onClear: _photoBytes != null ? _clearPhoto : null,
emptyLabel: 'Ajouter une photo',
);
} else if (_isEdit) {
// Édition : photo existante en lecture ; remplacement hors scope PATCH fiche.
photo = _buildPhotoSectionReview(_dossier.user);
} else {
photo = _buildPhotoSectionReview(_dossier.user);
}
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
SizedBox( SizedBox(width: photoW, child: photo),
width: photoW,
child: _isCreate
? AdminAmPhotoFrame(
imageBytes: _photoBytes,
onTap: _pickPhoto,
onClear: _photoBytes != null ? _clearPhoto : null,
emptyLabel: 'Ajouter une photo',
)
: _buildPhotoSectionReview(_dossier.user),
),
const SizedBox(width: _photoProGap), const SizedBox(width: _photoProGap),
Expanded( Expanded(
child: Align( child: Align(
@@ -721,7 +858,7 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
} }
Widget _buildStep2() { Widget _buildStep2() {
if (_isCreate) { if (_isEditable) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@@ -832,6 +969,12 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
onPressed: _submitting ? null : _createAndValidate, onPressed: _submitting ? null : _createAndValidate,
child: Text(_submitting ? 'Envoi...' : 'Créer et valider'), child: Text(_submitting ? 'Envoi...' : 'Créer et valider'),
), ),
] else if (_isEdit) ...[
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: _submitting ? null : _saveEdit,
child: Text(_submitting ? 'Envoi...' : 'Enregistrer'),
),
] else if (_isEnAttente) ...[ ] else if (_isEnAttente) ...[
OutlinedButton( OutlinedButton(
onPressed: _submitting ? null : _refuser, onPressed: _submitting ? null : _refuser,
@@ -85,6 +85,7 @@ class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
context: context, context: context,
builder: (context) => ValidationDossierModal( builder: (context) => ValidationDossierModal(
numeroDossier: num, numeroDossier: num,
openAsEdit: true,
onClose: () => Navigator.of(context).pop(), onClose: () => Navigator.of(context).pop(),
onSuccess: () { onSuccess: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
@@ -22,10 +22,15 @@ import 'package:p_tits_pas/widgets/admin/validation_valider_confirm_dialog.dart'
import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart';
enum ParentDossierWizardMode { review, create } enum ParentDossierWizardMode { review, create, edit }
/// Enfant en cours de saisie (mode création). Contrôleurs propres, à disposer. /// Enfant en cours de saisie (création / édition). Contrôleurs propres, à disposer.
class _CreateChild { class _CreateChild {
/// Id existant en base (mode edit) — null = nouvel enfant → POST.
String? existingChildId;
String? existingPhotoUrl;
/// Statut dorigine (hors à naître) pour ne pas écraser garde/scolarise.
String? existingStatus;
final TextEditingController prenomCtrl = TextEditingController(); final TextEditingController prenomCtrl = TextEditingController();
final TextEditingController nomCtrl = TextEditingController(); final TextEditingController nomCtrl = TextEditingController();
final TextEditingController dateCtrl = TextEditingController(); final TextEditingController dateCtrl = TextEditingController();
@@ -34,6 +39,8 @@ class _CreateChild {
Uint8List? photoBytes; Uint8List? photoBytes;
String? photoFilename; String? photoFilename;
bool get hasExistingId => (existingChildId ?? '').trim().isNotEmpty;
void dispose() { void dispose() {
prenomCtrl.dispose(); prenomCtrl.dispose();
nomCtrl.dispose(); nomCtrl.dispose();
@@ -41,8 +48,7 @@ class _CreateChild {
} }
} }
/// Wizard dossier famille — modes [review] (validation dossier en attente, ticket #107) /// Wizard dossier famille — [review] (#107), [create] (#129), [edit] (#135).
/// et [create] (création dossier famille actif par le staff, ticket #129).
class ParentDossierWizard extends StatefulWidget { class ParentDossierWizard extends StatefulWidget {
final ParentDossierWizardMode mode; final ParentDossierWizardMode mode;
final DossierFamille? dossier; final DossierFamille? dossier;
@@ -91,7 +97,25 @@ class ParentDossierWizard extends StatefulWidget {
); );
} }
factory ParentDossierWizard.edit({
Key? key,
required DossierFamille dossier,
required VoidCallback onClose,
required VoidCallback onSuccess,
void Function(int step, int total)? onStepChanged,
}) {
return ParentDossierWizard._(
key: key,
mode: ParentDossierWizardMode.edit,
dossier: dossier,
onClose: onClose,
onSuccess: onSuccess,
onStepChanged: onStepChanged,
);
}
bool get isCreate => mode == ParentDossierWizardMode.create; bool get isCreate => mode == ParentDossierWizardMode.create;
bool get isEdit => mode == ParentDossierWizardMode.edit;
/// Hauteur corps modale famille — 4 lignes TF + marge pour le bandeau switch. /// Hauteur corps modale famille — 4 lignes TF + marge pour le bandeau switch.
static double get shellBodyHeight => static double get shellBodyHeight =>
@@ -141,16 +165,27 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
final List<_CreateChild> _children = []; final List<_CreateChild> _children = [];
late final TextEditingController _presentationCtrl; late final TextEditingController _presentationCtrl;
/// Nb de parents déjà en base au moment de louverture (mode edit).
int _initialParentCount = 0;
/// Enfants existants retirés en edit → DELETE au save.
final List<String> _removedEnfantIds = [];
bool get _isCreate => widget.isCreate; bool get _isCreate => widget.isCreate;
bool get _isEdit => widget.isEdit;
bool get _isEditable => _isCreate || _isEdit;
DossierFamille get _dossier => widget.dossier!; DossierFamille get _dossier => widget.dossier!;
bool get _isEnAttente => !_isCreate && _dossier.isEnAttente; bool get _isEnAttente => !_isCreate && !_isEdit && _dossier.isEnAttente;
String? get _firstParentId { String? get _firstParentId {
if (_isCreate) return null; if (_isCreate) return null;
return _dossier.parents.isNotEmpty ? _dossier.parents.first.id : null; return _dossier.parents.isNotEmpty ? _dossier.parents.first.id : null;
} }
/// Co-parent déjà présent au chargement (edit) — pas un ajout via POST.
bool get _hadExistingCoParent => _isEdit && _initialParentCount >= 2;
static String _v(String? s) => static String _v(String? s) =>
(s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini'; (s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini';
@@ -184,11 +219,81 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
_presentationCtrl = TextEditingController(); _presentationCtrl = TextEditingController();
if (_isCreate) { if (_isCreate) {
_children.add(_CreateChild()); _children.add(_CreateChild());
} else if (_isEdit) {
_prefillFromDossier();
} }
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep()); WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
} }
void _prefillFromDossier() {
final parents = _dossier.parents;
_initialParentCount = parents.length;
if (parents.isNotEmpty) {
final p1 = parents.first;
_p1NomCtrl.text = (p1.nom ?? '').trim();
_p1PrenomCtrl.text = (p1.prenom ?? '').trim();
_p1TelCtrl.text = (p1.telephone ?? '').trim();
_p1EmailCtrl.text = (p1.email).trim();
_p1AdresseCtrl.text = (p1.adresse ?? '').trim();
_p1CpCtrl.text = (p1.codePostal ?? '').trim();
_p1VilleCtrl.text = (p1.ville ?? '').trim();
if (parents.length >= 2) {
_hasCoParent = true;
final p2 = parents[1];
_p2NomCtrl.text = (p2.nom ?? '').trim();
_p2PrenomCtrl.text = (p2.prenom ?? '').trim();
_p2TelCtrl.text = (p2.telephone ?? '').trim();
_p2EmailCtrl.text = (p2.email).trim();
_p2AdresseCtrl.text = (p2.adresse ?? '').trim();
_p2CpCtrl.text = (p2.codePostal ?? '').trim();
_p2VilleCtrl.text = (p2.ville ?? '').trim();
} else {
_hasCoParent = false;
}
}
_prefillChildrenFromDossier();
}
void _prefillChildrenFromDossier() {
for (final c in _children) {
c.dispose();
}
_children.clear();
_removedEnfantIds.clear();
final enfants = _dossier.enfants;
if (enfants.isEmpty) {
_children.add(_CreateChild());
return;
}
for (final e in enfants) {
final status = (e.status ?? '').trim().toLowerCase();
final id = e.id.trim();
final child = _CreateChild()
..existingChildId = id.isEmpty ? null : id
..existingPhotoUrl = e.photoUrl
..existingStatus = status.isEmpty ? null : status
..isUnborn = status == 'a_naitre';
child.prenomCtrl.text = (e.firstName ?? '').trim();
child.nomCtrl.text = (e.lastName ?? '').trim();
final g = (e.gender ?? '').trim();
final gUp = g.toUpperCase();
if (gUp == 'H' || gUp == 'F') {
child.genre = gUp;
} else if (g == 'Autre' || child.isUnborn) {
child.genre = child.isUnborn ? 'Autre' : g;
}
final dateSrc = child.isUnborn ? e.dueDate : e.birthDate;
child.dateCtrl.text = formatIsoDateFrInput(dateSrc);
_children.add(child);
}
}
@override @override
void dispose() { void dispose() {
_enfantsScrollController.removeListener(_syncEnfantsScrollFades); _enfantsScrollController.removeListener(_syncEnfantsScrollFades);
@@ -291,7 +396,7 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
} }
Widget _buildStep0() { Widget _buildStep0() {
if (_isCreate) { if (_isEditable) {
return IdentityBlock.editable( return IdentityBlock.editable(
title: 'Parent principal', title: 'Parent principal',
nomController: _p1NomCtrl, nomController: _p1NomCtrl,
@@ -310,13 +415,15 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
} }
Widget _buildStep1() { Widget _buildStep1() {
if (_isCreate) { if (_isEditable) {
return _buildCoParentStepCreate(); return _buildCoParentStepEditable(
allowToggle: !_hadExistingCoParent,
);
} }
return _buildParent2Step(); return _buildParent2Step();
} }
Widget _buildCoParentStepCreate() { Widget _buildCoParentStepEditable({required bool allowToggle}) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@@ -330,19 +437,21 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
color: Colors.black87, color: Colors.black87,
), ),
), ),
const Spacer(), if (allowToggle) ...[
const Text( const Spacer(),
'Ajouter un co-parent', const Text(
style: TextStyle(fontSize: 14, color: Colors.black87), 'Ajouter un co-parent',
), style: TextStyle(fontSize: 14, color: Colors.black87),
Transform.scale(
scale: 0.75,
child: Switch(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
value: _hasCoParent,
onChanged: (v) => setState(() => _hasCoParent = v),
), ),
), Transform.scale(
scale: 0.75,
child: Switch(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
value: _hasCoParent,
onChanged: (v) => setState(() => _hasCoParent = v),
),
),
],
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -539,7 +648,7 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
} }
Widget _buildEnfantsStep() { Widget _buildEnfantsStep() {
if (_isCreate) { if (_isCreate || _isEdit) {
return _buildEnfantsStepCreate(); return _buildEnfantsStepCreate();
} }
final enfants = _dossier.enfants; final enfants = _dossier.enfants;
@@ -618,6 +727,10 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
if (_children.length <= 1) return; if (_children.length <= 1) return;
setState(() { setState(() {
final removed = _children.removeAt(index); final removed = _children.removeAt(index);
final existingId = (removed.existingChildId ?? '').trim();
if (existingId.isNotEmpty) {
_removedEnfantIds.add(existingId);
}
removed.dispose(); removed.dispose();
}); });
} }
@@ -706,7 +819,8 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 14, 8), // Réserve langle haut-droit pour la croix de suppression.
padding: EdgeInsets.fromLTRB(12, 12, canRemove ? 36 : 14, 8),
child: ValidationLabeledField( child: ValidationLabeledField(
label: 'Prénom', label: 'Prénom',
field: ValidationEditableField( field: ValidationEditableField(
@@ -747,6 +861,9 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
width: pw, width: pw,
height: ph, height: ph,
child: AdminAmPhotoFrame( child: AdminAmPhotoFrame(
photoUrl: child.photoBytes == null
? child.existingPhotoUrl
: null,
imageBytes: child.photoBytes, imageBytes: child.photoBytes,
onTap: () => _pickChildPhoto(index), onTap: () => _pickChildPhoto(index),
onClear: child.photoBytes != null onClear: child.photoBytes != null
@@ -833,19 +950,28 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
), ),
if (canRemove) if (canRemove)
Positioned( Positioned(
top: 4, top: 6,
right: 4, right: 6,
child: Material( child: Material(
color: Colors.transparent, color: Colors.white,
child: IconButton( elevation: 1,
tooltip: 'Retirer cet enfant', shape: const CircleBorder(),
visualDensity: VisualDensity.compact, clipBehavior: Clip.antiAlias,
padding: EdgeInsets.zero, child: InkWell(
constraints: customBorder: const CircleBorder(),
const BoxConstraints(minWidth: 32, minHeight: 32), onTap: () => _removeChild(index),
icon: Icon(Icons.close, child: Tooltip(
size: 18, color: Colors.grey.shade700), message: 'Retirer cet enfant',
onPressed: () => _removeChild(index), child: SizedBox(
width: 22,
height: 22,
child: Icon(
Icons.close,
size: 14,
color: Colors.grey.shade800,
),
),
),
), ),
), ),
), ),
@@ -1264,6 +1390,18 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
} }
String? _validateCurrentStep() { String? _validateCurrentStep() {
if (_isEdit) {
switch (_step) {
case 0:
return _validateP1();
case 1:
return _validateP2();
case 2:
return _validateEnfants();
default:
return null;
}
}
if (!_isCreate) return null; if (!_isCreate) return null;
switch (_step) { switch (_step) {
case 0: case 0:
@@ -1440,6 +1578,299 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
} }
} }
Map<String, dynamic> _parentFicheBody({
required TextEditingController nom,
required TextEditingController prenom,
required TextEditingController email,
required TextEditingController tel,
required TextEditingController adresse,
required TextEditingController cp,
required TextEditingController ville,
}) {
return {
'nom': formatPersonNameCase(nom.text),
'prenom': formatPersonNameCase(prenom.text),
'email': normalizeEmailText(email.text),
'telephone': normalizePhone(tel.text),
'adresse': adresse.text.trim(),
'ville': formatPersonNameCase(ville.text),
'code_postal': cp.text.trim(),
};
}
Future<void> _saveEdit() async {
if (_submitting || !_isEdit) return;
final err0 = _validateP1();
if (err0 != null) {
_showError(err0);
return;
}
final err1 = _validateP2();
if (err1 != null) {
_showError(err1);
return;
}
final err2 = _validateEnfants();
if (err2 != null) {
_showError(err2);
return;
}
final pivotId = (_firstParentId ?? '').trim();
if (pivotId.isEmpty) {
_showError('Identifiant du parent principal manquant.');
return;
}
final addingCoParent = _hasCoParent && !_hadExistingCoParent;
final ok = await showValidationValiderConfirmDialog(
context,
body: addingCoParent
? 'Enregistrer le dossier et ajouter le co-parent ? Un e-mail de création de mot de passe lui sera envoyé.'
: 'Enregistrer les modifications du dossier famille ?',
);
if (!mounted || !ok) return;
setState(() => _submitting = true);
try {
await UserService.updateParentFiche(
parentUserId: pivotId,
body: _parentFicheBody(
nom: _p1NomCtrl,
prenom: _p1PrenomCtrl,
email: _p1EmailCtrl,
tel: _p1TelCtrl,
adresse: _p1AdresseCtrl,
cp: _p1CpCtrl,
ville: _p1VilleCtrl,
),
);
if (_hadExistingCoParent) {
final coId = _dossier.parents[1].id.trim();
if (coId.isEmpty) {
throw Exception('Identifiant du co-parent manquant.');
}
await UserService.updateParentFiche(
parentUserId: coId,
body: _parentFicheBody(
nom: _p2NomCtrl,
prenom: _p2PrenomCtrl,
email: _p2EmailCtrl,
tel: _p2TelCtrl,
adresse: _p2AdresseCtrl,
cp: _p2CpCtrl,
ville: _p2VilleCtrl,
),
);
} else if (addingCoParent) {
if (_sameAddress) _copyP1AddressToP2();
final body = <String, dynamic>{
'email': normalizeEmailText(_p2EmailCtrl.text),
'prenom': formatPersonNameCase(_p2PrenomCtrl.text),
'nom': formatPersonNameCase(_p2NomCtrl.text),
'telephone': normalizePhone(_p2TelCtrl.text),
'meme_adresse': _sameAddress,
};
if (!_sameAddress) {
body['adresse'] = _p2AdresseCtrl.text.trim();
body['code_postal'] = _p2CpCtrl.text.trim();
body['ville'] = formatPersonNameCase(_p2VilleCtrl.text);
}
await UserService.addCoParent(pivotId, body: body);
}
await _saveEnfantsEdit(pivotId);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
addingCoParent
? 'Dossier enregistré. Co-parent ajouté.'
: 'Dossier enregistré.',
),
duration: const Duration(seconds: 4),
),
);
widget.onSuccess();
} catch (e) {
if (!mounted) return;
_showError(
e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur',
);
} finally {
if (mounted) setState(() => _submitting = false);
}
}
Map<String, dynamic> _enfantStaffBody(_CreateChild c) {
final prenom = formatPersonNameCase(c.prenomCtrl.text);
final nomRaw = c.nomCtrl.text.trim();
final nom = nomRaw.isNotEmpty
? formatPersonNameCase(nomRaw)
: formatPersonNameCase(_p1NomCtrl.text);
final dateIso = parseFrDateToIso(c.dateCtrl.text);
final existingStatus = (c.existingStatus ?? '').trim().toLowerCase();
final status = c.isUnborn
? 'a_naitre'
: (existingStatus.isNotEmpty && existingStatus != 'a_naitre'
? existingStatus
: 'sans_garde');
final gender = c.genre ?? 'H';
final map = <String, dynamic>{
'status': status,
'gender': gender,
'consent_photo': true,
'is_multiple': false,
};
if (prenom.length >= 2) map['first_name'] = prenom;
if (nom.length >= 2) map['last_name'] = nom;
if (c.isUnborn) {
if (dateIso != null) map['due_date'] = dateIso;
} else if (dateIso != null) {
map['birth_date'] = dateIso;
}
return map;
}
/// Empreinte identité pour rattacher un brouillon sans id à un enfant du GET.
String _enfantIdentityKey({
required String prenom,
required String nom,
required String? dateIso,
required bool isUnborn,
}) {
final pn = formatPersonNameCase(prenom).toLowerCase().trim();
final nm = formatPersonNameCase(nom).toLowerCase().trim();
final d = _normalizeDateKey(dateIso);
final kind = isUnborn ? 'due' : 'birth';
return '$pn|$nm|$kind|$d';
}
String _normalizeDateKey(String? raw) {
final s = (raw ?? '').trim();
if (s.isEmpty) return '';
final fromFr = parseFrDateToIso(s);
if (fromFr != null) return fromFr;
try {
final dt = DateTime.parse(s);
final y = dt.year.toString().padLeft(4, '0');
final m = dt.month.toString().padLeft(2, '0');
final d = dt.day.toString().padLeft(2, '0');
return '$y-$m-$d';
} catch (_) {
return s;
}
}
String _enfantKeyFromCreate(_CreateChild c) {
final prenom = formatPersonNameCase(c.prenomCtrl.text);
final nomRaw = c.nomCtrl.text.trim();
final nom = nomRaw.isNotEmpty
? formatPersonNameCase(nomRaw)
: formatPersonNameCase(_p1NomCtrl.text);
return _enfantIdentityKey(
prenom: prenom,
nom: nom,
dateIso: parseFrDateToIso(c.dateCtrl.text),
isUnborn: c.isUnborn,
);
}
String _enfantKeyFromDossier(EnfantDossier e) {
final isUnborn = (e.status ?? '').trim().toLowerCase() == 'a_naitre';
final nomRaw = (e.lastName ?? '').trim();
final nom = nomRaw.isNotEmpty ? nomRaw : (_p1NomCtrl.text.trim());
return _enfantIdentityKey(
prenom: e.firstName ?? '',
nom: nom,
dateIso: isUnborn ? e.dueDate : e.birthDate,
isUnborn: isUnborn,
);
}
bool _isBlankChildDraft(_CreateChild c) {
if (c.hasExistingId) return false;
final prenom = c.prenomCtrl.text.trim();
final nom = c.nomCtrl.text.trim();
final date = c.dateCtrl.text.trim();
return prenom.isEmpty && nom.isEmpty && date.isEmpty && c.genre == null;
}
/// Résout lid enfant : tracker edit, sinon match sur le GET dossier.
String? _resolveExistingChildId(
_CreateChild c,
Map<String, String> dossierIdByIdentity,
Set<String> alreadyUsedIds,
) {
final tracked = (c.existingChildId ?? '').trim();
if (tracked.isNotEmpty && !alreadyUsedIds.contains(tracked)) {
return tracked;
}
final key = _enfantKeyFromCreate(c);
final matched = (dossierIdByIdentity[key] ?? '').trim();
if (matched.isNotEmpty && !alreadyUsedIds.contains(matched)) {
return matched;
}
return null;
}
Future<void> _saveEnfantsEdit(String pivotUserId) async {
// Index id connus du GET (ne jamais POST pour ceux-là).
final dossierIdByIdentity = <String, String>{};
for (final e in _dossier.enfants) {
final id = e.id.trim();
if (id.isEmpty) continue;
dossierIdByIdentity[_enfantKeyFromDossier(e)] = id;
}
for (final id in _removedEnfantIds) {
await UserService.deleteEnfant(id);
}
final usedIds = <String>{..._removedEnfantIds};
for (final c in _children) {
if (_isBlankChildDraft(c)) continue;
final existingId = _resolveExistingChildId(
c,
dossierIdByIdentity,
usedIds,
);
final body = _enfantStaffBody(c);
if (existingId != null && existingId.isNotEmpty) {
// Ré-attache lid au modèle (si match identité a récupéré un id perdu).
c.existingChildId = existingId;
final bytes = c.photoBytes;
await UserService.updateEnfant(
enfantId: existingId,
body: body,
photoBytes: (bytes != null && bytes.isNotEmpty) ? bytes : null,
photoFilename: c.photoFilename,
);
usedIds.add(existingId);
continue;
}
// Uniquement les vrais nouveaux enfants (pas did dossier).
final bytes = c.photoBytes;
final created = await UserService.createEnfant(
parentUserId: pivotUserId,
body: body,
photoBytes: (bytes != null && bytes.isNotEmpty) ? bytes : null,
photoFilename: c.photoFilename,
);
final newId = created.id.trim();
if (newId.isNotEmpty) {
c.existingChildId = newId;
usedIds.add(newId);
}
}
}
Widget _buildNavigation() { Widget _buildNavigation() {
if (_step == 3) { if (_step == 3) {
return Row( return Row(
@@ -1463,6 +1894,12 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
onPressed: _submitting ? null : _createAndValidate, onPressed: _submitting ? null : _createAndValidate,
child: Text(_submitting ? 'Envoi...' : 'Créer et valider'), child: Text(_submitting ? 'Envoi...' : 'Créer et valider'),
), ),
] else if (_isEdit) ...[
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: _submitting ? null : _saveEdit,
child: Text(_submitting ? 'Envoi...' : 'Enregistrer'),
),
] else if (_isEnAttente && _firstParentId != null) ...[ ] else if (_isEnAttente && _firstParentId != null) ...[
OutlinedButton( OutlinedButton(
onPressed: _submitting ? null : _refuser, onPressed: _submitting ? null : _refuser,
@@ -2,20 +2,25 @@ import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart'; import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart';
import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart';
import 'package:p_tits_pas/widgets/admin/validation_am_wizard.dart'; import 'package:p_tits_pas/widgets/admin/validation_am_wizard.dart';
import 'package:p_tits_pas/widgets/admin/validation_family_wizard.dart'; import 'package:p_tits_pas/widgets/admin/validation_family_wizard.dart';
/// Modale (dialog) : charge le dossier par numéro puis affiche le wizard AM ou Famille. Ticket #107, #119. /// Modale (dialog) : charge le dossier par numéro puis affiche le wizard AM ou Famille.
/// Ticket #107 / #119 (review), #135 (`openAsEdit`).
class ValidationDossierModal extends StatefulWidget { class ValidationDossierModal extends StatefulWidget {
final String numeroDossier; final String numeroDossier;
final VoidCallback onClose; final VoidCallback onClose;
final VoidCallback? onSuccess; final VoidCallback? onSuccess;
/// Liste Dossiers actifs → mode edit (#135). Pending reste en review (défaut).
final bool openAsEdit;
const ValidationDossierModal({ const ValidationDossierModal({
super.key, super.key,
required this.numeroDossier, required this.numeroDossier,
required this.onClose, required this.onClose,
this.onSuccess, this.onSuccess,
this.openAsEdit = false,
}); });
@override @override
@@ -77,12 +82,12 @@ class _ValidationDossierModalState extends State<ValidationDossierModal> {
/// Largeur modale = 1,5 × 620. /// Largeur modale = 1,5 × 620.
static const double _modalWidth = 930; // 620 * 1.5 static const double _modalWidth = 930; // 620 * 1.5
static const double _familyBodyHeight = 435;
double get _bodyHeight { double get _bodyHeight {
final d = _dossier; final d = _dossier;
if (d != null && d.isAm) return AmDossierWizard.shellBodyHeight; if (d != null && d.isAm) return AmDossierWizard.shellBodyHeight;
return _familyBodyHeight; // Aligné create (#129) / edit (#135) — évite overflow IdentityBlock (8px).
return ParentDossierWizard.shellBodyHeight;
} }
@override @override
@@ -162,6 +167,14 @@ class _ValidationDossierModalState extends State<ValidationDossierModal> {
} }
final d = _dossier!; final d = _dossier!;
if (d.isAm) { if (d.isAm) {
if (widget.openAsEdit) {
return AmDossierWizard.edit(
dossier: d.asAm,
onClose: widget.onClose,
onSuccess: _onSuccess,
onStepChanged: _onStepChanged,
);
}
return ValidationAmWizard( return ValidationAmWizard(
dossier: d.asAm, dossier: d.asAm,
onClose: widget.onClose, onClose: widget.onClose,
@@ -169,6 +182,14 @@ class _ValidationDossierModalState extends State<ValidationDossierModal> {
onStepChanged: _onStepChanged, onStepChanged: _onStepChanged,
); );
} }
if (widget.openAsEdit) {
return ParentDossierWizard.edit(
dossier: d.asFamily,
onClose: widget.onClose,
onSuccess: _onSuccess,
onStepChanged: _onStepChanged,
);
}
return ValidationFamilyWizard( return ValidationFamilyWizard(
dossier: d.asFamily, dossier: d.asFamily,
onClose: widget.onClose, onClose: widget.onClose,