feat(#140): liste enfants unifiée — cartes, âge précis et cadre scrollable

Partage AdminEnfantUserCard entre fiche parent et onglet Enfants, enrichit ParentChildSummary (photo, dates) et compacte la modale parent.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
MARTIN Julien 2026-06-24 23:06:38 +02:00
parent 8494341b56
commit 966f4a9c9c
6 changed files with 435 additions and 129 deletions

View File

@ -0,0 +1,153 @@
/**
* Met à jour l'issue Gitea #140 epic dashboard admin ch.6 famille.
* Usage: node backend/scripts/update-gitea-issue-140-ch6-famille.js
* Token : .gitea-token (racine), GITEA_TOKEN, ou docs/27_BRIEFING-FRONTEND.md
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
const repoRoot = path.join(__dirname, '../..');
let token = process.env.GITEA_TOKEN;
if (!token) {
try {
const tokenFile = path.join(repoRoot, '.gitea-token');
if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim();
} catch (_) {}
}
if (!token) {
try {
const briefing = fs.readFileSync(
path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'),
'utf8',
);
const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/);
if (m) token = m[1].trim();
} catch (_) {}
}
if (!token) {
console.error('Token non trouvé : .gitea-token ou GITEA_TOKEN (voir docs/26_GITEA-API.md)');
process.exit(1);
}
const body = `## Rôle de ce ticket
**#140 est un ticket epic / livraison** : il regroupe la mise en œuvre du **chapitre 6** du doc [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](../docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) (§6.1 et §6.2) sur la branche \`feature/140-dashboard-admin-ch6-famille\`.
Il **ne remplace pas** les tickets détaillés ci-dessous : il sert de **fil de livraison** (PR, recette, fermeture coordonnée). Chaque sous-ticket garde son périmètre propre ; #140 est clos quand l'ensemble est **fonctionnel et homogène en UI**.
---
## Tickets couverts (périmètres embarqués)
| Ticket | Sujet | Rôle dans #140 |
|--------|--------|----------------|
| **#115** | Rattachement parent **backend** | API \`POST/DELETE …/enfants/:enfantId\` |
| **#116** | Rattachement parent **frontend** | UI rattacher / détacher depuis la fiche parent |
| **#130** | \`UserService\` — APIs admin | Appels \`getParents\`, \`getParent\`, \`getEnfants\`, \`updateParentFiche\`, etc. |
| **#131** | Fiche parent éditable | Modale parent (dashboard) *hors fiche AM* |
| **#137** | Onglet **Enfants** | Liste globale admin + accès fiche enfant |
| **#138** | Fiche enfant + liste dans fiche parent | \`AdminChildDetailModal\` + liste enfants en bas de fiche parent |
> **Note :** fermer #140 peut entraîner la fermeture **partielle ou totale** de ces tickets selon ce qui est réellement livré et recetté dans la PR.
---
## Hors scope #140
- **§6.3** Création dossier admin sans numéro **#129**
- Fiche **AM** éditable (dashboard) reste **#131** (partie AM)
- Parcours gestionnaire « famille complexe » **#139**
- Qualification responsable légal (combobox sur lien) ticket à créer
---
## Backend (attendu / livré)
- [x] \`PATCH /parents/:id/fiche\` — édition fiche parent (admin/gestionnaire)
- [x] \`POST /parents/:id/enfants/:enfantId\` — rattacher un enfant existant
- [x] \`DELETE /parents/:id/enfants/:enfantId\` — détacher (garde-fou : ≥1 responsable / enfant)
- [x] \`GET /enfants\` enrichi ; \`PATCH /enfants/:id\` pour gestionnaire
---
## Frontend fait
- [x] Modale **fiche parent** éditable (shell aligné validation, statut gélule, téléphone formaté, \`IdentityBlock\`)
- [x] Onglet **Enfants** liste globale (\`EnfantManagementWidget\`)
- [x] Liste enfants dans fiche parent cartes (photo, nom, âge ans/mois, statut), cadre blanc, scroll 2,5 lignes
- [x] Rattacher / détacher un enfant **existant** (API branchée)
- [x] Parsing robuste \`parentChildren\` + URLs médias \`/uploads\` en Flutter web
- [x] \`UserService\` — APIs parents / enfants / affiliation
---
## Frontend reste à faire (bloquant clôture)
### Fiche enfant #138 (UI)
- [ ] Reprendre \`AdminChildDetailModal\` : même **look & feel** que fiche parent / modales validation (largeur ~930 px, grille champs, photo enfant)
- [ ] Aligner dates, genre, statut sur les wizards validation famille
### Modale **rattacher** un enfant #116 (UI)
- [ ] Remplacer le \`SimpleDialog\` actuel par une modale cohérente : liste type \`AdminEnfantUserCard\` (photo, nom, âge), recherche éventuelle
### Création d'un **nouvel** enfant depuis la fiche parent doc §6.2
- [ ] **Non implémenté** aujourd'hui (seul le rattachement d'un enfant déjà en base existe)
- [ ] À trancher : inclus dans #140 si le back expose un \`POST\` admin depuis le contexte parent, sinon ticket dédié / extension #129
---
## Recette avant merge
1. Parent avec 0 / 1 / 3+ enfants liste, scroll, compteur
2. Rattacher puis détacher (message si dernier responsable)
3. Clic enfant fiche enfant (après refonte UI)
4. Onglet Enfants même rendu cartes
5. Avatars photos (URLs absolues web)
---
## Branche
\`feature/140-dashboard-admin-ch6-famille\`
## Références
- Doc produit : \`docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md\` §6.1, §6.2`;
const payload = JSON.stringify({ body });
const req = https.request(
{
hostname: 'git.ptits-pas.fr',
path: '/api/v1/repos/jmartin/petitspas/issues/140',
method: 'PATCH',
headers: {
Authorization: `token ${token}`,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
},
},
(res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
const json = JSON.parse(data);
console.log('Issue #140 mise à jour :', json.html_url);
console.log('updated_at:', json.updated_at);
} else {
console.error('Erreur', res.statusCode, data);
process.exit(1);
}
});
},
);
req.on('error', (err) => {
console.error(err);
process.exit(1);
});
req.write(payload);
req.end();

View File

@ -1,15 +1,23 @@
import 'package:p_tits_pas/models/enfant_admin_model.dart';
/// Résumé enfant affiché dans la fiche parent (dashboard admin).
class ParentChildSummary {
final String id;
final String? firstName;
final String? lastName;
final String status;
final String? photoUrl;
final String? birthDate;
final String? dueDate;
ParentChildSummary({
required this.id,
this.firstName,
this.lastName,
required this.status,
this.photoUrl,
this.birthDate,
this.dueDate,
});
String get fullName {
@ -26,9 +34,30 @@ class ParentChildSummary {
firstName: (json['first_name'] ?? json['prenom'])?.toString(),
lastName: (json['last_name'] ?? json['nom'])?.toString(),
status: (json['status'] ?? json['statut'] ?? '').toString(),
photoUrl: json['photo_url']?.toString(),
birthDate: _dateString(json['birth_date']),
dueDate: _dateString(json['due_date']),
);
}
factory ParentChildSummary.fromEnfant(EnfantAdminModel enfant) {
return ParentChildSummary(
id: enfant.id,
firstName: enfant.firstName,
lastName: enfant.lastName,
status: enfant.status,
photoUrl: enfant.photoUrl,
birthDate: enfant.birthDate,
dueDate: enfant.dueDate,
);
}
static String? _dateString(dynamic v) {
if (v == null) return null;
if (v is String) return v.split('T').first;
return v.toString().split('T').first;
}
/// Parse un lien `parentChildren` (objet enfant imbriqué ou id seul).
static ParentChildSummary? fromParentChildLink(Map<String, dynamic> link) {
final childRaw = link['child'] ?? link['enfant'];

View File

@ -9,3 +9,71 @@ String formatIsoDateFr(String? s, {String ifEmpty = ''}) {
return s.trim();
}
}
/// Parties d'âge calendaire entre [birth] et [reference] (date-only).
({int years, int months, int days}) computeChildAgeParts(
DateTime birth,
DateTime reference,
) {
final birthDay = DateTime(birth.year, birth.month, birth.day);
final refDay = DateTime(reference.year, reference.month, reference.day);
var years = refDay.year - birthDay.year;
var months = refDay.month - birthDay.month;
var days = refDay.day - birthDay.day;
if (days < 0) {
months--;
final prevMonth = DateTime(refDay.year, refDay.month, 0);
days += prevMonth.day;
}
if (months < 0) {
years--;
months += 12;
}
return (years: years, months: months, days: days);
}
String _yearLabel(int years) => years == 1 ? '1 an' : '$years ans';
String _monthLabel(int months) => months == 1 ? '1 mois' : '$months mois';
/// Libellé d'âge pour un enfant (liste admin, fiche parent, etc.).
String formatChildAgeLabel({
String? birthDate,
String? dueDate,
String? status,
}) {
if (status == 'a_naitre') {
final due = formatIsoDateFr(dueDate, ifEmpty: '');
if (due.isNotEmpty) return 'Naissance prévue : $due';
return 'À naître';
}
if (birthDate == null || birthDate.trim().isEmpty) return '';
try {
final birth = DateTime.parse(birthDate.trim().split('T').first);
final now = DateTime.now();
final parts = computeChildAgeParts(birth, now);
if (parts.years >= 1) {
if (parts.months > 0) {
return 'Âge : ${_yearLabel(parts.years)} ${parts.months} mois';
}
return 'Âge : ${_yearLabel(parts.years)}';
}
if (parts.months >= 1) {
return 'Âge : ${_monthLabel(parts.months)}';
}
final totalDays = DateTime(now.year, now.month, now.day)
.difference(DateTime(birth.year, birth.month, birth.day))
.inDays;
if (totalDays <= 0) return 'Âge : né récemment';
return 'Âge : $totalDays jour${totalDays > 1 ? 's' : ''}';
} catch (_) {
return 'Né le ${formatIsoDateFr(birthDate)}';
}
}

View File

@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
String enfantStatusLabel(String status) {
switch (status) {
case 'a_naitre':
return 'À naître';
case 'actif':
return 'Actif';
case 'scolarise':
return 'Scolarisé';
default:
return status;
}
}
List<String> enfantAdminSubtitleLines({
required String status,
String? birthDate,
String? dueDate,
List<String> extra = const [],
}) {
final lines = <String>[];
final age = formatChildAgeLabel(
birthDate: birthDate,
dueDate: dueDate,
status: status,
);
if (age.isNotEmpty) lines.add(age);
if (status.isNotEmpty) {
lines.add('Statut : ${enfantStatusLabel(status)}');
}
lines.addAll(extra);
return lines;
}
/// Carte enfant admin (photo, nom, âge) même rendu onglet Enfants / fiche parent.
class AdminEnfantUserCard extends StatelessWidget {
final String title;
final String? photoUrl;
final List<String> subtitleLines;
final List<Widget> actions;
const AdminEnfantUserCard({
super.key,
required this.title,
this.photoUrl,
required this.subtitleLines,
this.actions = const [],
});
factory AdminEnfantUserCard.fromEnfant(
EnfantAdminModel enfant, {
List<String> extraSubtitleLines = const [],
List<Widget> actions = const [],
}) {
final parents = enfant.parentLinks
.map((l) => l.parentName ?? 'Parent')
.join(', ');
return AdminEnfantUserCard(
title: enfant.fullName,
photoUrl: enfant.photoUrl,
subtitleLines: enfantAdminSubtitleLines(
status: enfant.status,
birthDate: enfant.birthDate,
dueDate: enfant.dueDate,
extra: [
if (parents.isNotEmpty) 'Responsables : $parents',
...extraSubtitleLines,
],
),
actions: actions,
);
}
factory AdminEnfantUserCard.fromSummary(
ParentChildSummary child, {
List<String> extraSubtitleLines = const [],
List<Widget> actions = const [],
}) {
return AdminEnfantUserCard(
title: child.fullName,
photoUrl: child.photoUrl,
subtitleLines: enfantAdminSubtitleLines(
status: child.status,
birthDate: child.birthDate,
dueDate: child.dueDate,
extra: extraSubtitleLines,
),
actions: actions,
);
}
@override
Widget build(BuildContext context) {
return AdminUserCard(
title: title,
fallbackIcon: Icons.child_care_outlined,
avatarUrl: photoUrl,
subtitleLines: subtitleLines,
actions: actions,
);
}
}

View File

@ -5,7 +5,7 @@ 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/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/admin/common/admin_enfant_user_card.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
@ -36,14 +36,17 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
late String _statut;
late List<ParentChildSummary> _children;
late final ScrollController _childrenScrollCtrl;
bool _saving = false;
bool _dirty = false;
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;
/// Hauteur d'une ligne enfant (carte + marge) — viewport = 2,5 lignes visibles.
static const double _childListItemHeight = 58;
static const double _childrenListViewportHeight =
_childListItemHeight * 2.5 + 8;
@override
void initState() {
@ -60,6 +63,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
_cpCtrl = TextEditingController(text: u.codePostal ?? '');
_statut = u.statut ?? 'en_attente';
_children = List.of(widget.parent.children);
_childrenScrollCtrl = ScrollController();
for (final c in [
_nomCtrl,
_prenomCtrl,
@ -87,6 +91,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
]) {
c.dispose();
}
_childrenScrollCtrl.dispose();
super.dispose();
}
@ -187,24 +192,15 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
Future<void> _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();
}
final all = await UserService.getEnfants();
final byId = {for (final e in all) e.id: e};
final kids = refreshed.children.map((c) {
final full = byId[c.id];
if (full != null) return ParentChildSummary.fromEnfant(full);
return c;
}).toList();
if (!mounted) return;
setState(() => _children = kids);
} catch (_) {}
@ -237,12 +233,13 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
if (confirmed != true || !mounted) return;
try {
final updated = await UserService.detachEnfantFromParent(
await UserService.detachEnfantFromParent(
parentUserId: widget.parent.user.id,
enfantId: child.id,
);
if (!mounted) return;
setState(() => _children = List.of(updated.children));
await _reloadChildren();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enfant détaché')),
);
@ -294,12 +291,13 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
if (selected == null || !mounted) return;
try {
final updated = await UserService.attachEnfantToParent(
await UserService.attachEnfantToParent(
parentUserId: widget.parent.user.id,
enfantId: selected.id,
);
if (!mounted) return;
setState(() => _children = List.of(updated.children));
await _reloadChildren();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enfant rattaché')),
);
@ -361,49 +359,41 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
Widget _childrenPanel() {
return Container(
width: double.infinity,
decoration: ValidationFieldDecoration.container(),
height: _childrenListViewportHeight,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
clipBehavior: Clip.antiAlias,
child: _children.isEmpty
? const Center(
child: Padding(
padding: EdgeInsets.all(20),
child: Text(
'Aucun enfant rattaché',
style: TextStyle(fontSize: 14, color: Colors.black54),
),
child: Text(
'Aucun enfant rattaché',
style: TextStyle(fontSize: 14, color: Colors.black54),
),
)
: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 4),
: ListView.builder(
controller: _childrenScrollCtrl,
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
itemExtent: _childListItemHeight,
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,
),
return AdminEnfantUserCard.fromSummary(
c,
actions: [
IconButton(
icon: const Icon(Icons.visibility_outlined),
tooltip: 'Voir / modifier',
onPressed: () => _openChild(c),
),
),
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),
),
IconButton(
tooltip: 'Détacher',
icon: Icon(Icons.link_off, color: Colors.orange.shade800),
onPressed: () => _detachChild(c),
),
],
);
},
),
@ -444,19 +434,20 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
@override
Widget build(BuildContext context) {
final maxH = MediaQuery.of(context).size.height * 0.85;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: _modalWidth, maxHeight: maxH),
constraints: BoxConstraints(
maxWidth: _modalWidth,
maxHeight: MediaQuery.of(context).size.height * 0.85,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Padding(
padding: EdgeInsets.fromLTRB(18, 18, 0, 12),
padding: EdgeInsets.fromLTRB(18, 16, 0, 10),
child: Text(
'Fiche parent',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
@ -473,32 +464,27 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
],
),
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,
),
),
],
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_identityFields(),
const SizedBox(height: 12),
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),
_childrenPanel(),
const SizedBox(height: 16),
_buildFooter(),
],
),
),
],
@ -506,17 +492,4 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
),
);
}
String _childStatusLabel(String status) {
switch (status) {
case 'a_naitre':
return 'À naître';
case 'actif':
return 'Actif';
case 'scolarise':
return 'Scolarisé';
default:
return status;
}
}
}

View File

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
/// Onglet liste globale des enfants (doc 28 §6.2, ticket #137).
@ -52,19 +52,6 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
}
}
String _statusLabel(String status) {
switch (status) {
case 'a_naitre':
return 'À naître';
case 'actif':
return 'Actif';
case 'scolarise':
return 'Scolarisé';
default:
return status;
}
}
Future<void> _openEnfant(EnfantAdminModel enfant) async {
await showDialog<void>(
context: context,
@ -93,19 +80,8 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
itemCount: filtered.length,
itemBuilder: (context, index) {
final enfant = filtered[index];
final parents = enfant.parentLinks
.map((l) => l.parentName ?? 'Parent')
.join(', ');
return AdminUserCard(
title: enfant.fullName,
fallbackIcon: Icons.child_care_outlined,
avatarUrl: enfant.photoUrl,
subtitleLines: [
'Statut : ${_statusLabel(enfant.status)}',
if (enfant.birthDate != null && enfant.birthDate!.isNotEmpty)
'Naissance : ${enfant.birthDate}',
if (parents.isNotEmpty) 'Responsables : $parents',
],
return AdminEnfantUserCard.fromEnfant(
enfant,
actions: [
IconButton(
icon: const Icon(Icons.visibility_outlined),