From 8494341b56eb8eb1db07da45656dfea3013e0fa1 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Sun, 21 Jun 2026 17:42:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(#140):=20fiches=20admin=20famille=20?= =?UTF-8?q?=E2=80=94=20IdentityBlock,=20parsing=20enfants=20et=20avatars?= =?UTF-8?q?=20web?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extrait IdentityBlock réutilisable, aligne les modales parent/enfant sur le style validation, corrige le parsing parentChildren et les URLs /uploads en Flutter web. Co-authored-by: Cursor --- frontend/lib/models/enfant_admin_model.dart | 6 +- frontend/lib/models/parent_child_summary.dart | 35 +- frontend/lib/models/parent_model.dart | 46 +- frontend/lib/services/user_service.dart | 22 +- .../common/admin_child_detail_modal.dart | 421 ++++++++++------ .../admin/common/admin_parent_edit_modal.dart | 457 ++++++++++-------- .../widgets/admin/common/admin_user_card.dart | 49 +- .../common/validation_detail_section.dart | 153 +++++- .../widgets/admin/validation_am_wizard.dart | 31 +- .../admin/validation_family_wizard.dart | 35 +- .../lib/widgets/common/identity_block.dart | 278 +++++++++++ 11 files changed, 1075 insertions(+), 458 deletions(-) create mode 100644 frontend/lib/widgets/common/identity_block.dart diff --git a/frontend/lib/models/enfant_admin_model.dart b/frontend/lib/models/enfant_admin_model.dart index 2dace4b..396e263 100644 --- a/frontend/lib/models/enfant_admin_model.dart +++ b/frontend/lib/models/enfant_admin_model.dart @@ -89,8 +89,10 @@ class EnfantParentLink { EnfantParentLink({required this.parentId, this.parentName}); factory EnfantParentLink.fromJson(Map json) { - final parent = json['parent']; + final parentId = + (json['parentId'] ?? json['id_parent'] ?? '').toString(); String? name; + final parent = json['parent']; if (parent is Map) { final user = parent['user']; if (user is Map) { @@ -99,7 +101,7 @@ class EnfantParentLink { } } return EnfantParentLink( - parentId: (json['parentId'] ?? json['id_parent'] ?? '').toString(), + parentId: parentId, parentName: name, ); } diff --git a/frontend/lib/models/parent_child_summary.dart b/frontend/lib/models/parent_child_summary.dart index d235694..e40ccd7 100644 --- a/frontend/lib/models/parent_child_summary.dart +++ b/frontend/lib/models/parent_child_summary.dart @@ -23,9 +23,38 @@ class ParentChildSummary { factory ParentChildSummary.fromJson(Map json) { return ParentChildSummary( id: (json['id'] ?? '').toString(), - firstName: json['first_name'] as String?, - lastName: json['last_name'] as String?, - status: (json['status'] ?? '').toString(), + firstName: (json['first_name'] ?? json['prenom'])?.toString(), + lastName: (json['last_name'] ?? json['nom'])?.toString(), + status: (json['status'] ?? json['statut'] ?? '').toString(), ); } + + /// Parse un lien `parentChildren` (objet enfant imbriqué ou id seul). + static ParentChildSummary? fromParentChildLink(Map link) { + final childRaw = link['child'] ?? link['enfant']; + if (childRaw is Map) { + return ParentChildSummary.fromJson(Map.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; + } } diff --git a/frontend/lib/models/parent_model.dart b/frontend/lib/models/parent_model.dart index 54b4976..1e52b8d 100644 --- a/frontend/lib/models/parent_model.dart +++ b/frontend/lib/models/parent_model.dart @@ -13,28 +13,44 @@ class ParentModel { }); factory ParentModel.fromJson(Map json) { - final userJson = Map.from(json['user'] ?? json); - if (json['numero_dossier'] != null && userJson['numero_dossier'] == null) { - userJson['numero_dossier'] = json['numero_dossier']; + final root = Map.from(json); + final userJson = Map.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 = []; - final links = json['parentChildren'] as List?; - if (links != null) { - for (final link in links) { - if (link is Map && link['child'] is Map) { - children.add( - ParentChildSummary.fromJson(link['child'] as Map), - ); - } - } - } + 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.isNotEmpty ? children.length : (links?.length ?? 0), + childrenCount: children.isNotEmpty ? children.length : linkCount, children: children, ); } + + static List _parseChildren(Map json) { + final children = []; + final seen = {}; + + 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.from(link), + )); + } + } + + return children; + } } diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index a98e24e..1d1c87f 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -367,7 +367,9 @@ class UserService { } final List data = jsonDecode(response.body); - return data.map((e) => ParentModel.fromJson(e)).toList(); + return data + .map((e) => ParentModel.fromJson(Map.from(e as Map))) + .toList(); } static Future getParent(String userId) async { @@ -379,7 +381,10 @@ class UserService { final err = jsonDecode(response.body) as Map?; throw Exception(_toStr(err?['message']) ?? 'Erreur chargement parent'); } - return ParentModel.fromJson(jsonDecode(response.body) as Map); + final decoded = jsonDecode(response.body); + return ParentModel.fromJson( + Map.from(decoded is Map ? decoded : {}), + ); } static Future updateParentFiche({ @@ -394,7 +399,7 @@ class UserService { if (response.statusCode != 200) { throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour parent')); } - return ParentModel.fromJson(jsonDecode(response.body) as Map); + return _parentModelFromBody(response.body); } static Future attachEnfantToParent({ @@ -410,7 +415,7 @@ class UserService { if (response.statusCode != 200 && response.statusCode != 201) { throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant')); } - return ParentModel.fromJson(jsonDecode(response.body) as Map); + return _parentModelFromBody(response.body); } static Future detachEnfantFromParent({ @@ -429,7 +434,7 @@ class UserService { if (response.body.isEmpty) { return getParent(parentUserId); } - return ParentModel.fromJson(jsonDecode(response.body) as Map); + return _parentModelFromBody(response.body); } static Future> getEnfants() async { @@ -473,6 +478,13 @@ class UserService { return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map); } + static ParentModel _parentModelFromBody(String body) { + final decoded = jsonDecode(body); + return ParentModel.fromJson( + Map.from(decoded is Map ? decoded : {}), + ); + } + static String _extractErrorMessage(String body, String fallback) { try { final decoded = jsonDecode(body); diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart index 46e9584..8e0cc97 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -32,6 +32,12 @@ class _AdminChildDetailModalState extends State { static const _statuses = ['a_naitre', 'actif', 'scolarise']; static const _genders = ['H', 'F', 'Autre']; + static const _labelStyle = TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.black87, + ); + @override void initState() { super.initState(); @@ -41,7 +47,7 @@ class _AdminChildDetailModalState extends State { _birthCtrl = TextEditingController(text: e.birthDate ?? ''); _dueCtrl = TextEditingController(text: e.dueDate ?? ''); _status = _statuses.contains(e.status) ? e.status : 'actif'; - _gender = _genders.contains(e.gender) ? e.gender! : 'H'; + _gender = _normalizeGender(e.gender); _consentPhoto = e.consentPhoto; _isMultiple = e.isMultiple; for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) { @@ -57,6 +63,13 @@ class _AdminChildDetailModalState extends State { super.dispose(); } + static String _normalizeGender(String? raw) { + final g = (raw ?? '').trim(); + if (g == 'M') return 'H'; + if (_genders.contains(g)) return g; + return 'H'; + } + void _markDirty() { if (!_dirty) setState(() => _dirty = true); } @@ -72,7 +85,8 @@ class _AdminChildDetailModalState extends State { 'last_name': _nomCtrl.text.trim(), 'status': _status, 'gender': _gender, - if (_birthCtrl.text.trim().isNotEmpty) 'birth_date': _birthCtrl.text.trim(), + if (_birthCtrl.text.trim().isNotEmpty) + 'birth_date': _birthCtrl.text.trim(), if (_dueCtrl.text.trim().isNotEmpty) 'due_date': _dueCtrl.text.trim(), 'consent_photo': _consentPhoto, 'is_multiple': _isMultiple, @@ -96,124 +110,215 @@ class _AdminChildDetailModalState extends State { } } + String _parentsLine() { + final names = widget.enfant.parentLinks + .map((l) { + final n = (l.parentName ?? '').trim(); + return n.isNotEmpty ? n : 'Parent rattaché'; + }) + .where((s) => s.isNotEmpty) + .toList(); + if (names.isEmpty) return ''; + return names.join(', '); + } + @override Widget build(BuildContext context) { - final parents = widget.enfant.parentLinks - .map((l) => l.parentName ?? l.parentId) - .where((s) => s.isNotEmpty) - .join(', '); + final parents = _parentsLine(); + final showDueDate = _status == 'a_naitre'; return Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560, maxHeight: 640), - child: Padding( - padding: const EdgeInsets.all(18), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Text( - widget.enfant.fullName, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700), + child: SizedBox( + width: 480, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 640), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.enfant.fullName, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + if (parents.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + 'Responsables : $parents', + style: const TextStyle( + fontSize: 13, + color: Colors.black54, + ), + ), + ], + ], + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + tooltip: 'Fermer', + ), + ], + ), + const SizedBox(height: 12), + const Divider(height: 1), + const SizedBox(height: 12), + Flexible( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _labeledField( + 'Prénom', + TextField( + controller: _prenomCtrl, + decoration: _decoration(), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _labeledField( + 'Nom', + TextField( + controller: _nomCtrl, + decoration: _decoration(), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _labeledDropdown( + 'Statut', + _status, + _statuses + .map((s) => MapEntry(s, _statusLabel(s))) + .toList(), + (v) => setState(() { + _status = v; + _dirty = true; + }), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _labeledDropdown( + 'Genre', + _gender, + _genders + .map((g) => MapEntry(g, _genderLabel(g))) + .toList(), + (v) => setState(() { + _gender = v; + _dirty = true; + }), + ), + ), + ], + ), + const SizedBox(height: 12), + if (showDueDate) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _labeledField( + 'Date de naissance', + TextField( + controller: _birthCtrl, + decoration: + _decoration(hint: 'AAAA-MM-JJ'), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _labeledField( + 'Date prévue', + TextField( + controller: _dueCtrl, + decoration: + _decoration(hint: 'AAAA-MM-JJ'), + ), + ), + ), + ], + ) + else + _labeledField( + 'Date de naissance', + TextField( + controller: _birthCtrl, + decoration: _decoration(hint: 'AAAA-MM-JJ'), + ), + ), + const SizedBox(height: 16), + _switchRow( + 'Consentement photo', + _consentPhoto, + (v) => setState(() { + _consentPhoto = v; + _dirty = true; + }), + ), + _switchRow( + 'Naissance multiple', + _isMultiple, + (v) => setState(() { + _isMultiple = v; + _dirty = true; + }), + ), + ], ), ), - IconButton( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.close), - ), - ], - ), - if (parents.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text('Responsables : $parents', style: const TextStyle(color: Colors.black54)), ), - const Divider(), - Expanded( - child: SingleChildScrollView( - child: Column( - children: [ - _rowField('Prénom', TextField(controller: _prenomCtrl, decoration: _decoration())), - _rowField('Nom', TextField(controller: _nomCtrl, decoration: _decoration())), - _rowDropdown( - 'Statut', - _status, - _statuses.map((s) => MapEntry(s, _statusLabel(s))).toList(), - (v) => setState(() { - _status = v; - _dirty = true; - }), - ), - _rowDropdown( - 'Genre', - _gender, - _genders.map((g) => MapEntry(g, g)).toList(), - (v) => setState(() { - _gender = v; - _dirty = true; - }), - ), - _rowField( - 'Date naissance', - TextField( - controller: _birthCtrl, - decoration: _decoration(hint: 'AAAA-MM-JJ'), - ), - ), - _rowField( - 'Date prévue', - TextField( - controller: _dueCtrl, - decoration: _decoration(hint: 'AAAA-MM-JJ'), - ), - ), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Consentement photo'), - value: _consentPhoto, - onChanged: (v) => setState(() { - _consentPhoto = v; - _dirty = true; - }), - ), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Naissance multiple'), - value: _isMultiple, - onChanged: (v) => setState(() { - _isMultiple = v; - _dirty = true; - }), - ), - ], - ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Fermer'), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: !_dirty || _saving ? null : _save, + icon: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save), + label: const Text('Sauvegarder'), + ), + ], ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Fermer'), - ), - const SizedBox(width: 8), - ElevatedButton.icon( - onPressed: !_dirty || _saving ? null : _save, - icon: _saving - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.save), - label: const Text('Sauvegarder'), - ), - ], - ), - ], + ], + ), ), ), ), @@ -221,53 +326,62 @@ class _AdminChildDetailModalState extends State { } InputDecoration _decoration({String? hint}) { - return InputDecoration(isDense: true, border: const OutlineInputBorder(), hintText: hint); - } - - Widget _rowField(String label, Widget field) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 120, - child: Padding( - padding: const EdgeInsets.only(top: 12), - child: Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), - ), - ), - Expanded(child: field), - ], - ), + return InputDecoration( + isDense: true, + border: const OutlineInputBorder(), + hintText: hint, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), ); } - Widget _rowDropdown( + Widget _labeledField(String label, Widget field) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(label, style: _labelStyle), + const SizedBox(height: 4), + field, + ], + ); + } + + Widget _labeledDropdown( String label, String value, List> items, ValueChanged onChanged, ) { + final safeValue = + items.any((e) => e.key == value) ? value : items.first.key; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(label, style: _labelStyle), + const SizedBox(height: 4), + DropdownButtonFormField( + value: safeValue, + isExpanded: true, + decoration: _decoration(), + items: items + .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) + .toList(), + onChanged: (v) { + if (v != null) onChanged(v); + }, + ), + ], + ); + } + + Widget _switchRow(String label, bool value, ValueChanged onChanged) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), + padding: const EdgeInsets.symmetric(vertical: 4), child: Row( children: [ - SizedBox( - width: 120, - child: Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), - ), - Expanded( - child: DropdownButtonFormField( - value: items.any((e) => e.key == value) ? value : items.first.key, - decoration: _decoration(), - items: items - .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) - .toList(), - onChanged: (v) { - if (v != null) onChanged(v); - }, - ), + Expanded(child: Text(label, style: _labelStyle)), + Switch( + value: value, + onChanged: onChanged, ), ], ), @@ -286,4 +400,17 @@ class _AdminChildDetailModalState extends State { return status; } } + + String _genderLabel(String gender) { + switch (gender) { + case 'H': + return 'Garçon'; + case 'F': + return 'Fille'; + case 'Autre': + return 'Autre'; + default: + return gender; + } + } } diff --git a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart index babe468..44c1585 100644 --- a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart @@ -1,12 +1,16 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/models/parent_model.dart'; import 'package:p_tits_pas/services/user_service.dart'; -import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/common/identity_block.dart'; +import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; /// Fiche parent éditable (doc 28 §6.1, tickets #131 / #138). +/// Shell et typo alignés sur [ValidationDossierModal] / wizards validation. class AdminParentEditModal extends StatefulWidget { final ParentModel parent; final VoidCallback? onSaved; @@ -37,6 +41,10 @@ class _AdminParentEditModalState extends State { static const _statuts = ['actif', 'en_attente', 'suspendu', 'refuse']; + static const double _modalWidth = 930; + /// Corps plus haut que les modales validation pour agrandir la liste enfants. + static const double _bodyHeight = 580; + @override void initState() { super.initState(); @@ -44,20 +52,39 @@ class _AdminParentEditModalState extends State { _nomCtrl = TextEditingController(text: u.nom ?? ''); _prenomCtrl = TextEditingController(text: u.prenom ?? ''); _emailCtrl = TextEditingController(text: u.email); - _telCtrl = TextEditingController(text: u.telephone ?? ''); + _telCtrl = TextEditingController( + text: formatPhoneForDisplay(u.telephone ?? ''), + ); _adresseCtrl = TextEditingController(text: u.adresse ?? ''); _villeCtrl = TextEditingController(text: u.ville ?? ''); _cpCtrl = TextEditingController(text: u.codePostal ?? ''); _statut = u.statut ?? 'en_attente'; _children = List.of(widget.parent.children); - for (final c in [_nomCtrl, _prenomCtrl, _emailCtrl, _telCtrl, _adresseCtrl, _villeCtrl, _cpCtrl]) { + for (final c in [ + _nomCtrl, + _prenomCtrl, + _emailCtrl, + _telCtrl, + _adresseCtrl, + _villeCtrl, + _cpCtrl, + ]) { c.addListener(_markDirty); } + _reloadChildren(); } @override void dispose() { - for (final c in [_nomCtrl, _prenomCtrl, _emailCtrl, _telCtrl, _adresseCtrl, _villeCtrl, _cpCtrl]) { + for (final c in [ + _nomCtrl, + _prenomCtrl, + _emailCtrl, + _telCtrl, + _adresseCtrl, + _villeCtrl, + _cpCtrl, + ]) { c.dispose(); } super.dispose(); @@ -88,10 +115,18 @@ class _AdminParentEditModalState extends State { context: context, builder: (ctx) => AlertDialog( title: const Text('Confirmer'), - content: const Text('Enregistrer les modifications de la fiche parent ?'), + content: const Text( + 'Enregistrer les modifications de la fiche parent ?', + ), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Annuler')), - ElevatedButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Sauvegarder')), + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Sauvegarder'), + ), ], ), ); @@ -105,7 +140,7 @@ class _AdminParentEditModalState extends State { 'nom': _nomCtrl.text.trim(), 'prenom': _prenomCtrl.text.trim(), 'email': _emailCtrl.text.trim(), - 'telephone': _telCtrl.text.trim(), + 'telephone': normalizePhone(_telCtrl.text), 'adresse': _adresseCtrl.text.trim(), 'ville': _villeCtrl.text.trim(), 'code_postal': _cpCtrl.text.trim(), @@ -138,9 +173,7 @@ class _AdminParentEditModalState extends State { context: context, builder: (ctx) => AdminChildDetailModal( enfant: enfant, - onSaved: () async { - await _reloadChildren(); - }, + onSaved: _reloadChildren, ), ); } catch (e) { @@ -154,8 +187,26 @@ class _AdminParentEditModalState extends State { Future _reloadChildren() async { try { final refreshed = await UserService.getParent(widget.parent.user.id); + var kids = List.of(refreshed.children); + if (kids.any((c) => c.fullName == 'Enfant')) { + final all = await UserService.getEnfants(); + final byId = {for (final e in all) e.id: e}; + kids = kids + .map((c) { + if (c.fullName != 'Enfant') return c; + final e = byId[c.id]; + if (e == null) return c; + return ParentChildSummary( + id: c.id, + firstName: e.firstName, + lastName: e.lastName, + status: c.status.isNotEmpty ? c.status : e.status, + ); + }) + .toList(); + } if (!mounted) return; - setState(() => _children = List.of(refreshed.children)); + setState(() => _children = kids); } catch (_) {} } @@ -165,13 +216,19 @@ class _AdminParentEditModalState extends State { builder: (ctx) => AlertDialog( title: const Text('Détacher l\'enfant'), content: Text( - 'Retirer ${child.fullName} de la fiche de ce parent ?\n(L\'enfant ne sera pas supprimé.)', + 'Retirer ${child.fullName} de la fiche de ce parent ?\n' + '(L\'enfant ne sera pas supprimé.)', ), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Annuler')), + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler'), + ), ElevatedButton( onPressed: () => Navigator.pop(ctx, true), - style: ElevatedButton.styleFrom(backgroundColor: Colors.red.shade700), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade700, + ), child: const Text('Détacher'), ), ], @@ -254,209 +311,197 @@ class _AdminParentEditModalState extends State { } } - Widget _field(String label, TextEditingController ctrl, {TextInputType? keyboard}) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 130, - child: Padding( - padding: const EdgeInsets.only(top: 12), - child: Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), - ), - ), - Expanded( - child: TextFormField( - controller: ctrl, - keyboardType: keyboard, - decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()), - ), - ), - ], + Widget _statusDropdown() { + return Container( + height: 34, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: Colors.black26), ), + padding: const EdgeInsets.symmetric(horizontal: 12), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _statuts.contains(_statut) ? _statut : _statuts.first, + isExpanded: true, + isDense: true, + style: const TextStyle(fontSize: 13, color: Colors.black87), + icon: const Icon(Icons.arrow_drop_down, size: 20), + items: _statuts + .map( + (s) => DropdownMenuItem( + value: s, + child: Text(_displayStatus(s)), + ), + ) + .toList(), + onChanged: (v) { + if (v == null) return; + setState(() { + _statut = v; + _dirty = true; + }); + }, + ), + ), + ); + } + + Widget _identityFields() { + return IdentityBlock.editable( + nomController: _nomCtrl, + prenomController: _prenomCtrl, + telephoneController: _telCtrl, + emailController: _emailCtrl, + adresseController: _adresseCtrl, + codePostalController: _cpCtrl, + villeController: _villeCtrl, + ); + } + + Widget _childrenPanel() { + return Container( + width: double.infinity, + decoration: ValidationFieldDecoration.container(), + child: _children.isEmpty + ? const Center( + child: Padding( + padding: EdgeInsets.all(20), + child: Text( + 'Aucun enfant rattaché', + style: TextStyle(fontSize: 14, color: Colors.black54), + ), + ), + ) + : ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: _children.length, + separatorBuilder: (_, __) => Divider( + height: 1, + color: Colors.grey.shade300, + ), + itemBuilder: (_, i) { + final c = _children[i]; + return ListTile( + dense: true, + title: InkWell( + onTap: () => _openChild(c), + child: Text( + c.fullName, + style: const TextStyle( + fontSize: 14, + color: Colors.black87, + decoration: TextDecoration.underline, + ), + ), + ), + subtitle: Text( + _childStatusLabel(c.status), + style: const TextStyle(fontSize: 12, color: Colors.black54), + ), + trailing: IconButton( + tooltip: 'Détacher', + icon: Icon(Icons.link_off, color: Colors.orange.shade800), + onPressed: () => _detachChild(c), + ), + ); + }, + ), + ); + } + + Widget _buildFooter() { + return Row( + children: [ + TextButton( + onPressed: _saving ? null : () => Navigator.of(context).pop(), + child: const Text('Fermer'), + ), + const Spacer(), + TextButton.icon( + onPressed: _attachChild, + icon: const Icon(Icons.link, size: 18), + label: const Text('Rattacher un enfant'), + ), + const SizedBox(width: 12), + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: !_dirty || _saving ? null : _save, + child: _saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text(_dirty ? 'Sauvegarder' : 'Aucune modification'), + ), + ], ); } @override Widget build(BuildContext context) { - final numero = widget.parent.user.numeroDossier; + final maxH = MediaQuery.of(context).size.height * 0.85; + return Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 680, maxHeight: 720), - child: Padding( - padding: const EdgeInsets.all(18), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.parent.user.fullName.isEmpty - ? 'Parent' - : widget.parent.user.fullName, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700), - ), - Text(widget.parent.user.email, style: const TextStyle(color: Colors.black54)), - ], - ), - ), - IconButton( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.close), - ), - ], - ), - const Divider(), - Expanded( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (numero != null && numero.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - children: [ - const SizedBox( - width: 130, - child: Text('N° dossier', style: TextStyle(fontWeight: FontWeight.w600)), - ), - Expanded(child: Text(numero)), - ], - ), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - children: [ - const SizedBox( - width: 130, - child: Text('Statut', style: TextStyle(fontWeight: FontWeight.w600)), - ), - Expanded( - child: DropdownButtonFormField( - value: _statuts.contains(_statut) ? _statut : _statuts.first, - decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()), - items: _statuts - .map( - (s) => DropdownMenuItem( - value: s, - child: Text(_displayStatus(s)), - ), - ) - .toList(), - onChanged: (v) { - if (v == null) return; - setState(() { - _statut = v; - _dirty = true; - }); - }, - ), - ), - ], - ), - ), - _field('Nom', _nomCtrl), - _field('Prénom', _prenomCtrl), - _field('Email', _emailCtrl, keyboard: TextInputType.emailAddress), - _field('Téléphone', _telCtrl, keyboard: TextInputType.phone), - if (_telCtrl.text.isNotEmpty) - Padding( - padding: const EdgeInsets.only(left: 130, bottom: 4), - child: Text( - formatPhoneForDisplay(_telCtrl.text), - style: const TextStyle(fontSize: 12, color: Colors.black54), - ), - ), - _field('Adresse', _adresseCtrl), - _field('Ville', _villeCtrl), - _field('Code postal', _cpCtrl), - const SizedBox(height: 12), - Text( - 'Nombre d\'enfants : ${_children.length}', - style: const TextStyle(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.all(8), - child: _children.isEmpty - ? const Text('Aucun enfant rattaché', style: TextStyle(color: Colors.black54)) - : Column( - children: _children - .map( - (c) => ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - title: InkWell( - onTap: () => _openChild(c), - child: Text( - c.fullName, - style: const TextStyle( - decoration: TextDecoration.underline, - color: Colors.blue, - ), - ), - ), - subtitle: Text(_childStatusLabel(c.status)), - trailing: IconButton( - tooltip: 'Détacher', - icon: Icon(Icons.link_off, color: Colors.orange.shade800), - onPressed: () => _detachChild(c), - ), - ), - ) - .toList(), - ), - ), - const SizedBox(height: 8), - Align( - alignment: Alignment.centerLeft, - child: TextButton.icon( - onPressed: _attachChild, - icon: const Icon(Icons.link), - label: const Text('Rattacher un enfant'), - ), - ), - ], + constraints: BoxConstraints(maxWidth: _modalWidth, maxHeight: maxH), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(18, 18, 0, 12), + child: Text( + 'Fiche parent', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700), ), ), + const Spacer(), + SizedBox(width: 160, child: _statusDropdown()), + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + tooltip: 'Fermer', + ), + ], + ), + const Divider(height: 1), + SizedBox( + height: _bodyHeight, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _identityFields(), + Row( + children: [ + Text( + 'Nombre d\'enfants : ${_children.length}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + ], + ), + const SizedBox(height: 8), + Expanded(child: _childrenPanel()), + const SizedBox(height: 24), + _buildFooter(), + ], + ), ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: _saving ? null : () => Navigator.of(context).pop(), - child: const Text('Fermer'), - ), - const SizedBox(width: 8), - ElevatedButton.icon( - onPressed: !_dirty || _saving ? null : _save, - icon: _saving - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.save), - label: Text(_dirty ? 'Sauvegarder' : 'Aucune modification'), - ), - ], - ), - ], - ), + ), + ], ), ), ); diff --git a/frontend/lib/widgets/admin/common/admin_user_card.dart b/frontend/lib/widgets/admin/common/admin_user_card.dart index 92d4237..0e6c5ab 100644 --- a/frontend/lib/widgets/admin/common/admin_user_card.dart +++ b/frontend/lib/widgets/admin/common/admin_user_card.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; class AdminUserCard extends StatefulWidget { final String title; @@ -37,6 +39,7 @@ class _AdminUserCardState extends State { widget.subtitleLines.where((e) => e.trim().isNotEmpty).join(' '); final actionsWidth = widget.actions.isNotEmpty ? widget.actions.length * 30.0 : 0.0; + final avatarUrl = ApiConfig.absoluteMediaUrl(widget.avatarUrl); return MouseRegion( onEnter: (_) => setState(() => _isHovered = true), @@ -60,20 +63,7 @@ class _AdminUserCardState extends State { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), child: Row( children: [ - CircleAvatar( - radius: 14, - backgroundColor: const Color(0xFFEDE5FA), - backgroundImage: widget.avatarUrl != null - ? NetworkImage(widget.avatarUrl!) - : null, - child: widget.avatarUrl == null - ? Icon( - widget.fallbackIcon, - size: 16, - color: const Color(0xFF6B3FA0), - ) - : null, - ), + _buildAvatar(avatarUrl), const SizedBox(width: 10), Expanded( child: Row( @@ -140,4 +130,35 @@ class _AdminUserCardState extends State { ), ); } + + Widget _buildAvatar(String url) { + const size = 28.0; + const bg = Color(0xFFEDE5FA); + const iconColor = Color(0xFF6B3FA0); + + if (url.isEmpty) { + return CircleAvatar( + radius: 14, + backgroundColor: bg, + child: Icon(widget.fallbackIcon, size: 16, color: iconColor), + ); + } + + return ClipOval( + child: SizedBox( + width: size, + height: size, + child: AuthNetworkImage( + url: url, + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => ColoredBox( + color: bg, + child: Icon(widget.fallbackIcon, size: 16, color: iconColor), + ), + ), + ), + ); + } } diff --git a/frontend/lib/widgets/admin/common/validation_detail_section.dart b/frontend/lib/widgets/admin/common/validation_detail_section.dart index a0f36e2..ce1a3ba 100644 --- a/frontend/lib/widgets/admin/common/validation_detail_section.dart +++ b/frontend/lib/widgets/admin/common/validation_detail_section.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'admin_detail_modal.dart'; /// Bloc type formulaire (titre de section + champs read-only) pour les modales de validation. @@ -23,6 +24,39 @@ class ValidationDetailSection extends StatelessWidget { this.rowFlex, }); + @override + Widget build(BuildContext context) { + return ValidationFormGrid( + title: title, + rowLayout: rowLayout, + rowFlex: rowFlex, + fields: fields + .map( + (f) => ValidationLabeledField( + label: f.label, + field: ValidationReadOnlyField(value: f.value), + ), + ) + .toList(), + ); + } +} + +/// Grille label/champ réutilisable (validation, fiches admin). +class ValidationFormGrid extends StatelessWidget { + final String? title; + final List fields; + final List? rowLayout; + final Map>? rowFlex; + + const ValidationFormGrid({ + super.key, + this.title, + required this.fields, + this.rowLayout, + this.rowFlex, + }); + @override Widget build(BuildContext context) { final layout = rowLayout ?? List.filled(fields.length, 1); @@ -39,7 +73,7 @@ class ValidationDetailSection extends StatelessWidget { if (count == 1) { rows.add(Padding( padding: const EdgeInsets.only(bottom: 12), - child: _buildFieldCell(rowFields.first), + child: rowFields.first, )); } else { rows.add(Padding( @@ -53,7 +87,7 @@ class ValidationDetailSection extends StatelessWidget { flex: (flexForRow != null && i < flexForRow.length) ? flexForRow[i] : 1, - child: _buildFieldCell(rowFields[i]), + child: rowFields[i], ), ], ], @@ -81,14 +115,62 @@ class ValidationDetailSection extends StatelessWidget { ], ); } +} - Widget _buildFieldCell(AdminDetailField field) { +/// Décoration commune lecture seule / édition (modales validation, fiches admin). +class ValidationFieldDecoration { + ValidationFieldDecoration._(); + + static InputDecoration input({String? hint}) { + return InputDecoration( + isDense: true, + filled: true, + fillColor: Colors.grey.shade50, + hintText: hint, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: Colors.grey.shade500), + ), + ); + } + + static BoxDecoration container() { + return BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.grey.shade300), + ); + } +} + +/// Libellé au-dessus d’un champ (même typo que [ValidationDetailSection]). +class ValidationLabeledField extends StatelessWidget { + final String label; + final Widget field; + + const ValidationLabeledField({ + super.key, + required this.label, + required this.field, + }); + + @override + Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( - field.label, + label, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, @@ -96,13 +178,66 @@ class ValidationDetailSection extends StatelessWidget { ), ), const SizedBox(height: 4), - ValidationReadOnlyField(value: field.value), + field, ], ); } } -/// Champ texte en lecture seule, style formulaire (fond gris léger, bordure). Réutilisable en éditable plus tard. +/// Champ texte éditable, même rendu que [ValidationReadOnlyField]. +class ValidationEditableField extends StatelessWidget { + final TextEditingController controller; + final TextInputType keyboardType; + final List? inputFormatters; + final String? hintText; + final int maxLines; + + const ValidationEditableField({ + super.key, + required this.controller, + this.keyboardType = TextInputType.text, + this.inputFormatters, + this.hintText, + this.maxLines = 1, + }); + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + maxLines: maxLines, + style: const TextStyle(color: Colors.black87, fontSize: 14), + decoration: ValidationFieldDecoration.input(hint: hintText), + ); + } +} + +/// Grille label/champ éditable (délègue à [ValidationFormGrid]). +class ValidationEditableSection extends StatelessWidget { + final List fields; + final List? rowLayout; + final Map>? rowFlex; + + const ValidationEditableSection({ + super.key, + required this.fields, + this.rowLayout, + this.rowFlex, + }); + + @override + Widget build(BuildContext context) { + return ValidationFormGrid( + rowLayout: rowLayout, + rowFlex: rowFlex, + fields: fields, + ); + } +} + +/// Champ texte en lecture seule, style formulaire (fond gris léger, bordure). class ValidationReadOnlyField extends StatelessWidget { final String value; final int? maxLines; @@ -118,11 +253,7 @@ class ValidationReadOnlyField extends StatelessWidget { return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: Colors.grey.shade50, - borderRadius: BorderRadius.circular(6), - border: Border.all(color: Colors.grey.shade300), - ), + decoration: ValidationFieldDecoration.container(), child: Text( value, style: const TextStyle(color: Colors.black87, fontSize: 14), diff --git a/frontend/lib/widgets/admin/validation_am_wizard.dart b/frontend/lib/widgets/admin/validation_am_wizard.dart index 21356b9..5ea035a 100644 --- a/frontend/lib/widgets/admin/validation_am_wizard.dart +++ b/frontend/lib/widgets/admin/validation_am_wizard.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart'; -import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/nir_utils.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/common/identity_block.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'validation_modal_theme.dart'; import 'validation_refus_form.dart'; @@ -60,21 +60,6 @@ class _ValidationAmWizardState extends State { void _emitStep() => widget.onStepChanged?.call(_step, _stepCount); - /// Même ordre et disposition que le formulaire de création de compte (Nom/Prénom, Tél/Email, Adresse, CP/Ville). - List _personalFields(AppUser u) => [ - AdminDetailField(label: 'Nom', value: _v(u.nom)), - AdminDetailField(label: 'Prénom', value: _v(u.prenom)), - AdminDetailField( - label: 'Téléphone', - value: _v(u.telephone) != '–' - ? formatPhoneForDisplay(_v(u.telephone)) - : '–'), - AdminDetailField(label: 'Email', value: _v(u.email)), - AdminDetailField(label: 'Adresse (N° et Rue)', value: _v(u.adresse)), - AdminDetailField(label: 'Code postal', value: _v(u.codePostal)), - AdminDetailField(label: 'Ville', value: _v(u.ville)), - ]; - /// Panneau photo + grille droite : NIR|naissance, ville|pays, agrément|date, capa|places. List _photoProFields(DossierAM d) { final u = d.user; @@ -110,10 +95,7 @@ class _ValidationAmWizardState extends State { ]; } - static const List _personalRowLayout = [2, 2, 1, 2]; - static const Map> _personalRowFlex = { - 3: [2, 5] - }; // Code postal étroit, Ville large + static const List _photoProRowLayout = [2, 2, 2, 2]; /// Proportion photo d’identité (35×45 mm). static const double _idPhotoAspectRatio = 35 / 45; @@ -266,11 +248,10 @@ class _ValidationAmWizardState extends State { return SingleChildScrollView( child: ConstrainedBox( constraints: BoxConstraints(minWidth: constraints.maxWidth), - child: ValidationDetailSection( + child: IdentityBlock.readOnlyFromUser( + u, title: 'Identité et coordonnées', - fields: _personalFields(u), - rowLayout: _personalRowLayout, - rowFlex: _personalRowFlex, + emptyLabel: '–', ), ), ); @@ -311,7 +292,7 @@ class _ValidationAmWizardState extends State { child: ValidationDetailSection( title: 'Dossier professionnel', fields: _photoProFields(d), - rowLayout: const [2, 2, 2, 2], + rowLayout: _photoProRowLayout, ), ), ); diff --git a/frontend/lib/widgets/admin/validation_family_wizard.dart b/frontend/lib/widgets/admin/validation_family_wizard.dart index 96297bc..533f25d 100644 --- a/frontend/lib/widgets/admin/validation_family_wizard.dart +++ b/frontend/lib/widgets/admin/validation_family_wizard.dart @@ -3,11 +3,10 @@ import 'package:flutter/gestures.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart'; -import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/api/api_config.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/common/identity_block.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'validation_modal_theme.dart'; import 'validation_refus_form.dart'; @@ -108,26 +107,6 @@ class _ValidationFamilyWizardState extends State { static String _formatBirthDate(String? s) => formatIsoDateFr(s, ifEmpty: 'Non défini'); - /// Même ordre et disposition que le formulaire de création (Nom/Prénom, Tél/Email, Adresse, CP/Ville). - List _parentFields(ParentDossier p) => [ - AdminDetailField(label: 'Nom', value: _v(p.nom)), - AdminDetailField(label: 'Prénom', value: _v(p.prenom)), - AdminDetailField( - label: 'Téléphone', - value: _v(p.telephone) != 'Non défini' - ? formatPhoneForDisplay(_v(p.telephone)) - : 'Non défini'), - AdminDetailField(label: 'Email', value: _v(p.email)), - AdminDetailField(label: 'Adresse (N° et Rue)', value: _v(p.adresse)), - AdminDetailField(label: 'Code postal', value: _v(p.codePostal)), - AdminDetailField(label: 'Ville', value: _v(p.ville)), - ]; - - static const List _parentRowLayout = [2, 2, 1, 2]; - static const Map> _parentRowFlex = { - 3: [2, 5] - }; // Code postal étroit, Ville large - static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url); @override @@ -153,11 +132,9 @@ class _ValidationFamilyWizardState extends State { final d = widget.dossier; switch (_step) { case 0: - return ValidationDetailSection( + return IdentityBlock.readOnlyFromParentDossier( + d.parents.first, title: 'Parent principal', - fields: _parentFields(d.parents.first), - rowLayout: _parentRowLayout, - rowFlex: _parentRowFlex, ); case 1: return _buildParent2Step(); @@ -178,11 +155,9 @@ class _ValidationFamilyWizardState extends State { style: TextStyle(color: Colors.black87)), ); } - return ValidationDetailSection( + return IdentityBlock.readOnlyFromParentDossier( + widget.dossier.parents[1], title: 'Deuxième parent', - fields: _parentFields(widget.dossier.parents[1]), - rowLayout: _parentRowLayout, - rowFlex: _parentRowFlex, ); } diff --git a/frontend/lib/widgets/common/identity_block.dart b/frontend/lib/widgets/common/identity_block.dart new file mode 100644 index 0000000..91a1d30 --- /dev/null +++ b/frontend/lib/widgets/common/identity_block.dart @@ -0,0 +1,278 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/dossier_unifie.dart'; +import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; + +/// Valeurs affichées dans un [IdentityBlock] en lecture seule. +class IdentityValues { + final String nom; + final String prenom; + final String telephone; + final String email; + final String adresse; + final String codePostal; + final String ville; + + const IdentityValues({ + required this.nom, + required this.prenom, + required this.telephone, + required this.email, + required this.adresse, + required this.codePostal, + required this.ville, + }); + + static String _fmt(String? s, String empty) { + final t = (s ?? '').trim(); + return t.isEmpty ? empty : t; + } + + static String _fmtPhone(String? s, String empty) { + final tel = _fmt(s, empty); + return tel != empty ? formatPhoneForDisplay(tel) : tel; + } + + factory IdentityValues.fromUser( + AppUser user, { + String emptyLabel = 'Non défini', + }) { + return IdentityValues( + nom: _fmt(user.nom, emptyLabel), + prenom: _fmt(user.prenom, emptyLabel), + telephone: _fmtPhone(user.telephone, emptyLabel), + email: _fmt(user.email, emptyLabel), + adresse: _fmt(user.adresse, emptyLabel), + codePostal: _fmt(user.codePostal, emptyLabel), + ville: _fmt(user.ville, emptyLabel), + ); + } + + factory IdentityValues.fromParentDossier( + ParentDossier parent, { + String emptyLabel = 'Non défini', + }) { + return IdentityValues( + nom: _fmt(parent.nom, emptyLabel), + prenom: _fmt(parent.prenom, emptyLabel), + telephone: _fmtPhone(parent.telephone, emptyLabel), + email: _fmt(parent.email, emptyLabel), + adresse: _fmt(parent.adresse, emptyLabel), + codePostal: _fmt(parent.codePostal, emptyLabel), + ville: _fmt(parent.ville, emptyLabel), + ); + } +} + +/// Bloc identité : Nom/Prénom, Tél/Email, Adresse, CP/Ville. +/// Grille partagée (création de compte, validation AM/famille, fiches admin, etc.). +class IdentityBlock extends StatelessWidget { final String? title; + + final String? _nom; + final String? _prenom; + final String? _telephone; + final String? _email; + final String? _adresse; + final String? _codePostal; + final String? _ville; + + final TextEditingController? _nomCtrl; + final TextEditingController? _prenomCtrl; + final TextEditingController? _telCtrl; + final TextEditingController? _emailCtrl; + final TextEditingController? _adresseCtrl; + final TextEditingController? _cpCtrl; + final TextEditingController? _villeCtrl; + + static const List rowLayout = [2, 2, 1, 2]; + static const Map> rowFlex = {3: [2, 5]}; + + const IdentityBlock.readOnly({ + super.key, + this.title, + required String nom, + required String prenom, + required String telephone, + required String email, + required String adresse, + required String codePostal, + required String ville, + }) : _nom = nom, + _prenom = prenom, + _telephone = telephone, + _email = email, + _adresse = adresse, + _codePostal = codePostal, + _ville = ville, + _nomCtrl = null, + _prenomCtrl = null, + _telCtrl = null, + _emailCtrl = null, + _adresseCtrl = null, + _cpCtrl = null, + _villeCtrl = null; + + const IdentityBlock.editable({ + super.key, + this.title, + required TextEditingController nomController, + required TextEditingController prenomController, + required TextEditingController telephoneController, + required TextEditingController emailController, + required TextEditingController adresseController, + required TextEditingController codePostalController, + required TextEditingController villeController, + }) : _nom = null, + _prenom = null, + _telephone = null, + _email = null, + _adresse = null, + _codePostal = null, + _ville = null, + _nomCtrl = nomController, + _prenomCtrl = prenomController, + _telCtrl = telephoneController, + _emailCtrl = emailController, + _adresseCtrl = adresseController, + _cpCtrl = codePostalController, + _villeCtrl = villeController; + + /// Lecture seule à partir de [IdentityValues]. + factory IdentityBlock.readOnlyValues({ + Key? key, + String? title, + required IdentityValues values, + }) { + return IdentityBlock.readOnly( + key: key, + title: title, + nom: values.nom, + prenom: values.prenom, + telephone: values.telephone, + email: values.email, + adresse: values.adresse, + codePostal: values.codePostal, + ville: values.ville, + ); + } + + /// Lecture seule depuis un [AppUser] (validation AM, fiche AM, etc.). + factory IdentityBlock.readOnlyFromUser( + AppUser user, { + Key? key, + String? title, + String emptyLabel = 'Non défini', + }) { + return IdentityBlock.readOnlyValues( + key: key, + title: title, + values: IdentityValues.fromUser(user, emptyLabel: emptyLabel), + ); + } + + /// Lecture seule depuis un [ParentDossier] (validation famille). + factory IdentityBlock.readOnlyFromParentDossier( + ParentDossier parent, { + Key? key, + String? title, + String emptyLabel = 'Non défini', + }) { + return IdentityBlock.readOnlyValues( + key: key, + title: title, + values: IdentityValues.fromParentDossier( + parent, + emptyLabel: emptyLabel, + ), + ); + } + + bool get _isEditable => _nomCtrl != null; + @override + Widget build(BuildContext context) { + if (_isEditable) { + return ValidationFormGrid( + title: title, + rowLayout: rowLayout, + rowFlex: rowFlex, + fields: [ + ValidationLabeledField( + label: 'Nom', + field: ValidationEditableField(controller: _nomCtrl!), + ), + ValidationLabeledField( + label: 'Prénom', + field: ValidationEditableField(controller: _prenomCtrl!), + ), + ValidationLabeledField( + label: 'Téléphone', + field: ValidationEditableField( + controller: _telCtrl!, + keyboardType: TextInputType.phone, + inputFormatters: frenchPhoneInputFormatters, + ), + ), + ValidationLabeledField( + label: 'Email', + field: ValidationEditableField( + controller: _emailCtrl!, + keyboardType: TextInputType.emailAddress, + ), + ), + ValidationLabeledField( + label: 'Adresse (N° et Rue)', + field: ValidationEditableField(controller: _adresseCtrl!), + ), + ValidationLabeledField( + label: 'Code postal', + field: ValidationEditableField( + controller: _cpCtrl!, + keyboardType: TextInputType.number, + ), + ), + ValidationLabeledField( + label: 'Ville', + field: ValidationEditableField(controller: _villeCtrl!), + ), + ], + ); + } + + return ValidationFormGrid( + title: title, + rowLayout: rowLayout, + rowFlex: rowFlex, + fields: [ + ValidationLabeledField( + label: 'Nom', + field: ValidationReadOnlyField(value: _nom!), + ), + ValidationLabeledField( + label: 'Prénom', + field: ValidationReadOnlyField(value: _prenom!), + ), + ValidationLabeledField( + label: 'Téléphone', + field: ValidationReadOnlyField(value: _telephone!), + ), + ValidationLabeledField( + label: 'Email', + field: ValidationReadOnlyField(value: _email!), + ), + ValidationLabeledField( + label: 'Adresse (N° et Rue)', + field: ValidationReadOnlyField(value: _adresse!), + ), + ValidationLabeledField( + label: 'Code postal', + field: ValidationReadOnlyField(value: _codePostal!), + ), + ValidationLabeledField( + label: 'Ville', + field: ValidationReadOnlyField(value: _ville!), + ), + ], + ); + } +}