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:
parent
8494341b56
commit
966f4a9c9c
153
backend/scripts/update-gitea-issue-140-ch6-famille.js
Normal file
153
backend/scripts/update-gitea-issue-140-ch6-famille.js
Normal 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();
|
||||
@ -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'];
|
||||
|
||||
@ -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)}';
|
||||
}
|
||||
}
|
||||
|
||||
107
frontend/lib/widgets/admin/common/admin_enfant_user_card.dart
Normal file
107
frontend/lib/widgets/admin/common/admin_enfant_user_card.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user