Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2172eafdb | ||
|
|
d247867fa0 | ||
|
|
93912d1374 | ||
|
|
925b6d5cd4 | ||
|
|
b0dddd6695 | ||
|
|
ef7512dc1e | ||
|
|
2ececa711b |
@@ -146,16 +146,27 @@ export class EnfantsService {
|
|||||||
throw new ForbiddenException('Accès interdit');
|
throw new ForbiddenException('Accès interdit');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Liste des enfants (admin/gestionnaire)
|
/** Flag API #157 — true si aucun lien enfants_parents. */
|
||||||
async findAll(): Promise<Children[]> {
|
private withSansResponsable(child: Children): Children & { sans_responsable: boolean } {
|
||||||
return this.childrenRepository.find({
|
return Object.assign(child, {
|
||||||
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
sans_responsable: !child.parentLinks || child.parentLinks.length === 0,
|
||||||
order: { last_name: 'ASC', first_name: 'ASC' },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Liste des enfants (admin/gestionnaire) — inclut les orphelins (parentLinks: [])
|
||||||
|
async findAll(): Promise<Array<Children & { sans_responsable: boolean }>> {
|
||||||
|
const children = await this.childrenRepository.find({
|
||||||
|
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
||||||
|
order: { last_name: 'ASC', first_name: 'ASC' },
|
||||||
|
});
|
||||||
|
return children.map((c) => this.withSansResponsable(c));
|
||||||
|
}
|
||||||
|
|
||||||
// Récupérer un enfant par id
|
// Récupérer un enfant par id
|
||||||
async findOne(id: string, currentUser: Users): Promise<Children> {
|
async findOne(
|
||||||
|
id: string,
|
||||||
|
currentUser: Users,
|
||||||
|
): Promise<Children & { sans_responsable: boolean }> {
|
||||||
const child = await this.childrenRepository.findOne({
|
const child = await this.childrenRepository.findOne({
|
||||||
where: { id },
|
where: { id },
|
||||||
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
||||||
@@ -172,14 +183,14 @@ export class EnfantsService {
|
|||||||
case RoleType.ADMINISTRATEUR:
|
case RoleType.ADMINISTRATEUR:
|
||||||
case RoleType.SUPER_ADMIN:
|
case RoleType.SUPER_ADMIN:
|
||||||
case RoleType.GESTIONNAIRE:
|
case RoleType.GESTIONNAIRE:
|
||||||
// accès complet
|
// accès complet (y compris orphelins)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new ForbiddenException('Accès interdit');
|
throw new ForbiddenException('Accès interdit');
|
||||||
}
|
}
|
||||||
|
|
||||||
return child;
|
return this.withSansResponsable(child);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -114,34 +114,86 @@ export class ParentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rattacher un enfant existant à un parent (enfants_parents). Ticket #115 / doc 28 §6.2.
|
* Membres du foyer (user ids) pour affiliation enfant.
|
||||||
|
* Pivot + co-parent (A→B et B→A) + même numero_dossier. Ticket #158.
|
||||||
*/
|
*/
|
||||||
async attachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
private async resolveFoyerParentUserIds(parent: Parents): Promise<string[]> {
|
||||||
await this.findOne(parentUserId);
|
const ids = new Set<string>([parent.user_id]);
|
||||||
|
|
||||||
const existing = await this.parentsChildrenRepository.findOne({
|
if (parent.co_parent?.id) {
|
||||||
where: { parentId: parentUserId, enfantId },
|
ids.add(parent.co_parent.id);
|
||||||
});
|
|
||||||
if (existing) {
|
|
||||||
throw new ConflictException('Cet enfant est déjà rattaché à ce parent');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const child = await this.parentsRepository.manager.findOne(Children, { where: { id: enfantId } });
|
// Sens inverse : parents qui déclarent ce user comme co-parent
|
||||||
|
const reverseLinks = await this.parentsRepository.find({
|
||||||
|
where: { co_parent: { id: parent.user_id } },
|
||||||
|
relations: ['co_parent'],
|
||||||
|
});
|
||||||
|
for (const p of reverseLinks) {
|
||||||
|
ids.add(p.user_id);
|
||||||
|
if (p.co_parent?.id) ids.add(p.co_parent.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dossier = parent.numero_dossier?.trim();
|
||||||
|
if (dossier) {
|
||||||
|
const sameDossier = await this.parentsRepository.find({
|
||||||
|
where: { numero_dossier: dossier },
|
||||||
|
relations: ['co_parent'],
|
||||||
|
});
|
||||||
|
for (const p of sameDossier) {
|
||||||
|
ids.add(p.user_id);
|
||||||
|
if (p.co_parent?.id) ids.add(p.co_parent.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rattacher un enfant au foyer du parent (tous les responsables). Ticket #158.
|
||||||
|
* Un seul POST suffit : liens créés pour pivot + co-parent / même dossier.
|
||||||
|
*/
|
||||||
|
async attachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
||||||
|
const parent = await this.findOne(parentUserId);
|
||||||
|
|
||||||
|
const child = await this.parentsRepository.manager.findOne(Children, {
|
||||||
|
where: { id: enfantId },
|
||||||
|
});
|
||||||
if (!child) {
|
if (!child) {
|
||||||
throw new NotFoundException('Enfant introuvable');
|
throw new NotFoundException('Enfant introuvable');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const foyerIds = await this.resolveFoyerParentUserIds(parent);
|
||||||
|
let created = 0;
|
||||||
|
|
||||||
|
for (const memberId of foyerIds) {
|
||||||
|
const existing = await this.parentsChildrenRepository.findOne({
|
||||||
|
where: { parentId: memberId, enfantId },
|
||||||
|
});
|
||||||
|
if (existing) continue;
|
||||||
|
|
||||||
await this.parentsChildrenRepository.save(
|
await this.parentsChildrenRepository.save(
|
||||||
this.parentsChildrenRepository.create({ parentId: parentUserId, enfantId }),
|
this.parentsChildrenRepository.create({
|
||||||
|
parentId: memberId,
|
||||||
|
enfantId,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
created += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (created === 0) {
|
||||||
|
throw new ConflictException('Cet enfant est déjà rattaché à ce foyer');
|
||||||
|
}
|
||||||
|
|
||||||
return this.findOne(parentUserId);
|
return this.findOne(parentUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Détacher un enfant d'un parent sans supprimer l'enfant. Ticket #115 / doc 28 §6.2.
|
* Détacher un enfant du foyer du parent (tous les responsables). Ticket #158.
|
||||||
|
* Si plus aucun lien ensuite → enfant orphelin (#157).
|
||||||
*/
|
*/
|
||||||
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
||||||
await this.findOne(parentUserId);
|
const parent = await this.findOne(parentUserId);
|
||||||
|
|
||||||
const link = await this.parentsChildrenRepository.findOne({
|
const link = await this.parentsChildrenRepository.findOne({
|
||||||
where: { parentId: parentUserId, enfantId },
|
where: { parentId: parentUserId, enfantId },
|
||||||
@@ -150,12 +202,12 @@ export class ParentsService {
|
|||||||
throw new NotFoundException('Lien parent-enfant introuvable');
|
throw new NotFoundException('Lien parent-enfant introuvable');
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalLinks = await this.parentsChildrenRepository.count({ where: { enfantId } });
|
const foyerIds = await this.resolveFoyerParentUserIds(parent);
|
||||||
if (totalLinks <= 1) {
|
await this.parentsChildrenRepository.delete({
|
||||||
throw new BadRequestException('Un enfant doit rester rattaché à au moins un responsable');
|
parentId: In(foyerIds),
|
||||||
}
|
enfantId,
|
||||||
|
});
|
||||||
|
|
||||||
await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId });
|
|
||||||
return this.findOne(parentUserId);
|
return this.findOne(parentUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Analyse d’empreinte — P'titsPas
|
||||||
|
|
||||||
|
**Date :** 2026-07-22
|
||||||
|
**Contexte :** snapshot pour mémoire (après alerte disque plein + purge cache Gitea)
|
||||||
|
**Périmètre :** application `jmartin/petitspas` déployée sur le VPS (`/home/deploy/dev/ptitspas-app`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Lignes de code
|
||||||
|
|
||||||
|
Comptage brut (`wc -l`), hors `node_modules` / builds / `.dart_tool`.
|
||||||
|
|
||||||
|
| Zone | Lignes | Fichiers |
|
||||||
|
|------|--------|----------|
|
||||||
|
| **Frontend** (Dart `frontend/lib/`) | ~29 800 | 141 |
|
||||||
|
| **Backend** (TypeScript `backend/src/`) | ~10 000 | 141 |
|
||||||
|
| **BDD** (SQL sous `database/`) | ~1 250 | ~15 |
|
||||||
|
| **Total** | **~41 000** | |
|
||||||
|
|
||||||
|
### Frontend (détail)
|
||||||
|
|
||||||
|
| Dossier | Lignes |
|
||||||
|
|---------|--------|
|
||||||
|
| `widgets/` | ~18 400 |
|
||||||
|
| `screens/` | ~5 000 |
|
||||||
|
| `services/` | ~2 300 |
|
||||||
|
| `models/` | ~2 200 |
|
||||||
|
| `utils/` | ~1 400 |
|
||||||
|
| reste | ~500 |
|
||||||
|
|
||||||
|
### Backend (détail)
|
||||||
|
|
||||||
|
| Dossier | Lignes |
|
||||||
|
|---------|--------|
|
||||||
|
| `routes/` | ~6 500 |
|
||||||
|
| `modules/` | ~1 700 |
|
||||||
|
| `entities/` | ~1 000 |
|
||||||
|
| `common/` + `config/` | ~400 |
|
||||||
|
|
||||||
|
### BDD (détail)
|
||||||
|
|
||||||
|
| Élément | Lignes |
|
||||||
|
|---------|--------|
|
||||||
|
| `BDD.sql` (schéma) | ~470 |
|
||||||
|
| seeds | ~300 |
|
||||||
|
| migrations / patches | ~280 |
|
||||||
|
| tests SQL | ~205 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Empreinte disque — runtime (Docker)
|
||||||
|
|
||||||
|
| Composant | Taille | Notes |
|
||||||
|
|-----------|--------|--------|
|
||||||
|
| Image `ptitspas-app-backend` | ~300 Mo | NestJS |
|
||||||
|
| Image `ptitspas-app-frontend` | ~122 Mo | Flutter web + Nginx |
|
||||||
|
| Image `postgres:17` | ~454 Mo | |
|
||||||
|
| Image `dpage/pgadmin4` | ~534 Mo | optionnel |
|
||||||
|
| Volume `postgres_data` | ~49 Mo | données BDD live |
|
||||||
|
| Volume `backend_uploads` | ~20 Mo | photos |
|
||||||
|
| Volume `backend_documents_legaux` | ~0 | |
|
||||||
|
| **Stack complète (avec pgAdmin)** | **~1,5 Go** | |
|
||||||
|
| **Stack prod sans pgAdmin** | **~945 Mo** | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Empreinte disque — code source
|
||||||
|
|
||||||
|
| Élément | Taille |
|
||||||
|
|---------|--------|
|
||||||
|
| Clone `/home/deploy/dev/ptitspas-app` | ~701 Mo |
|
||||||
|
| dont `backend/node_modules` | ~354 Mo |
|
||||||
|
| dont `frontend` (+ `.dart_tool`) | ~114 Mo |
|
||||||
|
| **Code utile** (hors deps / `.git` / builds) | **~51 Mo** |
|
||||||
|
| Repo Gitea `petitspas.git` | ~109 Mo |
|
||||||
|
| Dump `database/BDD.sql` | ~19 Ko |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Pour (re)déployer
|
||||||
|
|
||||||
|
Minimum requis :
|
||||||
|
|
||||||
|
1. Images custom backend + frontend (~422 Mo), ou code + build Docker
|
||||||
|
2. Image `postgres:17` (~454 Mo)
|
||||||
|
3. Volumes persistants (~70 Mo au snapshot)
|
||||||
|
4. Optionnel : pgAdmin (~534 Mo)
|
||||||
|
|
||||||
|
**Ordre de grandeur :** ~1 Go pour faire tourner l’app en prod (sans pgAdmin).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Notes infra du jour (lié)
|
||||||
|
|
||||||
|
- Disque VPS passé de **100 %** à ~**44 %** après :
|
||||||
|
- purge cache Docker / journals
|
||||||
|
- purge officielle Gitea `delete_repo_archives` (~24 Go de zip/bundle `petitspas`)
|
||||||
|
- Crons Gitea activés :
|
||||||
|
- `archive_cleanup` @midnight (`OLDER_THAN = 24h`)
|
||||||
|
- `delete_repo_archives` @weekly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Fichier généré pour historique projet — ne pas considérer comme métrique CI automatisée.*
|
||||||
@@ -14,6 +14,8 @@ class EnfantAdminModel {
|
|||||||
final bool consentPhoto;
|
final bool consentPhoto;
|
||||||
final bool isMultiple;
|
final bool isMultiple;
|
||||||
final List<EnfantParentLink> parentLinks;
|
final List<EnfantParentLink> parentLinks;
|
||||||
|
/// Flag API #157 (sinon déduit de [parentLinks]).
|
||||||
|
final bool? sansResponsable;
|
||||||
|
|
||||||
EnfantAdminModel({
|
EnfantAdminModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -27,6 +29,7 @@ class EnfantAdminModel {
|
|||||||
this.consentPhoto = false,
|
this.consentPhoto = false,
|
||||||
this.isMultiple = false,
|
this.isMultiple = false,
|
||||||
this.parentLinks = const [],
|
this.parentLinks = const [],
|
||||||
|
this.sansResponsable,
|
||||||
});
|
});
|
||||||
|
|
||||||
String get fullName {
|
String get fullName {
|
||||||
@@ -37,6 +40,12 @@ class EnfantAdminModel {
|
|||||||
return '$fn $ln';
|
return '$fn $ln';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Aucun lien parent valide — ticket #157.
|
||||||
|
bool get hasNoResponsable {
|
||||||
|
if (sansResponsable != null) return sansResponsable!;
|
||||||
|
return !parentLinks.any((l) => l.parentId.trim().isNotEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
EnfantAdminModel copyWith({
|
EnfantAdminModel copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
String? firstName,
|
String? firstName,
|
||||||
@@ -49,6 +58,7 @@ class EnfantAdminModel {
|
|||||||
bool? consentPhoto,
|
bool? consentPhoto,
|
||||||
bool? isMultiple,
|
bool? isMultiple,
|
||||||
List<EnfantParentLink>? parentLinks,
|
List<EnfantParentLink>? parentLinks,
|
||||||
|
bool? sansResponsable,
|
||||||
}) {
|
}) {
|
||||||
return EnfantAdminModel(
|
return EnfantAdminModel(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -62,6 +72,7 @@ class EnfantAdminModel {
|
|||||||
consentPhoto: consentPhoto ?? this.consentPhoto,
|
consentPhoto: consentPhoto ?? this.consentPhoto,
|
||||||
isMultiple: isMultiple ?? this.isMultiple,
|
isMultiple: isMultiple ?? this.isMultiple,
|
||||||
parentLinks: parentLinks ?? this.parentLinks,
|
parentLinks: parentLinks ?? this.parentLinks,
|
||||||
|
sansResponsable: sansResponsable ?? this.sansResponsable,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +92,13 @@ class EnfantAdminModel {
|
|||||||
_parseBool(json['consentement_photo']) ||
|
_parseBool(json['consentement_photo']) ||
|
||||||
_parseBool(json['consentPhoto']);
|
_parseBool(json['consentPhoto']);
|
||||||
|
|
||||||
|
bool? sansResponsable;
|
||||||
|
if (json.containsKey('sans_responsable') ||
|
||||||
|
json.containsKey('sansResponsable')) {
|
||||||
|
sansResponsable = _parseBool(json['sans_responsable']) ||
|
||||||
|
_parseBool(json['sansResponsable']);
|
||||||
|
}
|
||||||
|
|
||||||
return EnfantAdminModel(
|
return EnfantAdminModel(
|
||||||
id: (json['id'] ?? '').toString(),
|
id: (json['id'] ?? '').toString(),
|
||||||
firstName: json['first_name'] as String? ?? json['prenom'] as String?,
|
firstName: json['first_name'] as String? ?? json['prenom'] as String?,
|
||||||
@@ -96,6 +114,7 @@ class EnfantAdminModel {
|
|||||||
isMultiple: _parseBool(json['is_multiple']) ||
|
isMultiple: _parseBool(json['is_multiple']) ||
|
||||||
_parseBool(json['est_multiple']),
|
_parseBool(json['est_multiple']),
|
||||||
parentLinks: links,
|
parentLinks: links,
|
||||||
|
sansResponsable: sansResponsable,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,4 +62,56 @@ class ParentModel {
|
|||||||
|
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Nombre d’enfants distincts du foyer (ce parent + co-parent / même dossier).
|
||||||
|
/// Évite Claire=7 / Thomas=6 quand un lien n’est que sur un des deux (#157).
|
||||||
|
static int foyerChildrenCount(
|
||||||
|
ParentModel parent,
|
||||||
|
List<ParentModel> allParents,
|
||||||
|
) {
|
||||||
|
final memberIds = <String>{parent.user.id};
|
||||||
|
final coId = parent.coParent?.id.trim();
|
||||||
|
if (coId != null && coId.isNotEmpty) memberIds.add(coId);
|
||||||
|
|
||||||
|
final dossier = (parent.user.numeroDossier ?? '').trim();
|
||||||
|
|
||||||
|
for (final other in allParents) {
|
||||||
|
if (memberIds.contains(other.user.id)) continue;
|
||||||
|
final otherCo = other.coParent?.id.trim();
|
||||||
|
if (otherCo != null && memberIds.contains(otherCo)) {
|
||||||
|
memberIds.add(other.user.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (dossier.isNotEmpty &&
|
||||||
|
(other.user.numeroDossier ?? '').trim() == dossier) {
|
||||||
|
memberIds.add(other.user.id);
|
||||||
|
final oc = other.coParent?.id.trim();
|
||||||
|
if (oc != null && oc.isNotEmpty) memberIds.add(oc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final childIds = <String>{};
|
||||||
|
for (final p in allParents) {
|
||||||
|
if (!memberIds.contains(p.user.id)) continue;
|
||||||
|
for (final c in p.children) {
|
||||||
|
final id = c.id.trim();
|
||||||
|
if (id.isNotEmpty) childIds.add(id);
|
||||||
|
}
|
||||||
|
// Repli si la liste enfants n’est pas hydratée.
|
||||||
|
if (p.children.isEmpty && p.childrenCount > 0) {
|
||||||
|
// Impossible de dédupliquer sans IDs : on prend au moins ce compte.
|
||||||
|
// (évite d’afficher 0 si l’API n’envoie que childrenCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (childIds.isNotEmpty) return childIds.length;
|
||||||
|
|
||||||
|
var maxCount = 0;
|
||||||
|
for (final p in allParents) {
|
||||||
|
if (!memberIds.contains(p.user.id)) continue;
|
||||||
|
final n = p.children.isNotEmpty ? p.children.length : p.childrenCount;
|
||||||
|
if (n > maxCount) maxCount = n;
|
||||||
|
}
|
||||||
|
return maxCount;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||||
|
|
||||||
|
/// Enfant sans lien parent (orphelins d’affiliation) — ticket #157.
|
||||||
|
bool enfantHasNoResponsable(EnfantAdminModel enfant) => enfant.hasNoResponsable;
|
||||||
|
|
||||||
|
/// Message vigilance liste Enfants (même usage que [amPlacesVigilanceMessage]).
|
||||||
|
String? enfantSansResponsableVigilanceMessage(EnfantAdminModel enfant) {
|
||||||
|
if (!enfantHasNoResponsable(enfant)) return null;
|
||||||
|
return 'Aucun responsable rattaché — à rattacher à un foyer';
|
||||||
|
}
|
||||||
@@ -446,8 +446,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('Détacher l\'enfant'),
|
title: const Text('Détacher l\'enfant'),
|
||||||
content: Text(
|
content: Text(
|
||||||
'Retirer ${child.fullName} de la fiche de cette assistante ?\n'
|
'Retirer ${child.fullName} de la fiche de cette assistante ?',
|
||||||
'(L\'enfant ne sera pas supprimé.)',
|
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
/// Famille choisie en mode création (#132).
|
/// Famille choisie en mode création (#132).
|
||||||
AdminFamilleFoyer? _selectedFamily;
|
AdminFamilleFoyer? _selectedFamily;
|
||||||
|
|
||||||
|
/// Liens parents locaux (édition) — mis à jour après rattachement foyer (#157).
|
||||||
|
List<EnfantParentLink>? _localParentLinks;
|
||||||
|
|
||||||
/// Photo locale (création) — upload multipart `photo`.
|
/// Photo locale (création) — upload multipart `photo`.
|
||||||
Uint8List? _photoBytes;
|
Uint8List? _photoBytes;
|
||||||
String? _photoFilename;
|
String? _photoFilename;
|
||||||
@@ -262,10 +265,14 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<EnfantParentLink> get _parentLinks =>
|
List<EnfantParentLink> get _parentLinks =>
|
||||||
(widget.enfant?.parentLinks ?? const <EnfantParentLink>[])
|
(_localParentLinks ??
|
||||||
|
widget.enfant?.parentLinks ??
|
||||||
|
const <EnfantParentLink>[])
|
||||||
.where((l) => l.parentId.trim().isNotEmpty)
|
.where((l) => l.parentId.trim().isNotEmpty)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
bool get _isOrphan => !widget.isCreating && _parentLinks.isEmpty;
|
||||||
|
|
||||||
Future<void> _openParent(EnfantParentLink link) async {
|
Future<void> _openParent(EnfantParentLink link) async {
|
||||||
if (_busy) return;
|
if (_busy) return;
|
||||||
final id = link.parentId.trim();
|
final id = link.parentId.trim();
|
||||||
@@ -580,6 +587,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
if (!enfantStatusValues.contains(_status)) {
|
if (!enfantStatusValues.contains(_status)) {
|
||||||
_status = 'sans_garde';
|
_status = 'sans_garde';
|
||||||
}
|
}
|
||||||
|
_localParentLinks = enfant.parentLinks;
|
||||||
_coerceGenderForStatus();
|
_coerceGenderForStatus();
|
||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -960,10 +968,12 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
padding: const EdgeInsets.only(top: 16),
|
padding: const EdgeInsets.only(top: 16),
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: SingleChildScrollView(
|
||||||
child: _placementBlock(),
|
child: _placementBlock(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1219,6 +1229,24 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
if (_isOrphan) ...[
|
||||||
|
Text(
|
||||||
|
'Sélection du dossier de la famille',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.red.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Aucun responsable rattaché — choisissez un foyer',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.red.shade700),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_familyPlacementSection(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
Text(
|
Text(
|
||||||
_placementTitle,
|
_placementTitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -1240,10 +1268,43 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
title: 'Choisir une famille',
|
title: 'Choisir une famille',
|
||||||
);
|
);
|
||||||
if (selected == null || !mounted) return;
|
if (selected == null || !mounted) return;
|
||||||
|
|
||||||
|
if (widget.isCreating) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedFamily = selected;
|
_selectedFamily = selected;
|
||||||
_dirty = true;
|
_dirty = true;
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Édition orphelin (#157/#158) : un seul attach — le back propage au foyer.
|
||||||
|
final enfantId = widget.enfant?.id;
|
||||||
|
if (enfantId == null || enfantId.isEmpty) return;
|
||||||
|
|
||||||
|
setState(() => _saving = true);
|
||||||
|
try {
|
||||||
|
await UserService.attachEnfantToParent(
|
||||||
|
parentUserId: selected.pivotParentUserId,
|
||||||
|
enfantId: enfantId,
|
||||||
|
);
|
||||||
|
final refreshed = await UserService.getEnfant(enfantId);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_localParentLinks = refreshed.parentLinks;
|
||||||
|
_selectedFamily = selected;
|
||||||
|
_saving = false;
|
||||||
|
});
|
||||||
|
widget.onSaved?.call();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Enfant rattaché au foyer')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _saving = false);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _familyPlacementSection() {
|
Widget _familyPlacementSection() {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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/models/parent_child_summary.dart';
|
||||||
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
||||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||||
|
import 'package:p_tits_pas/utils/enfant_vigilance.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||||
|
|
||||||
List<String> enfantAdminSubtitleLines({
|
List<String> enfantAdminSubtitleLines({
|
||||||
@@ -36,6 +37,8 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
final VoidCallback? onCardTap;
|
final VoidCallback? onCardTap;
|
||||||
final EdgeInsetsGeometry? margin;
|
final EdgeInsetsGeometry? margin;
|
||||||
final EdgeInsetsGeometry? contentPadding;
|
final EdgeInsetsGeometry? contentPadding;
|
||||||
|
final Color? borderColor;
|
||||||
|
final String? vigilanceTooltip;
|
||||||
|
|
||||||
const AdminEnfantUserCard({
|
const AdminEnfantUserCard({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -46,6 +49,8 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
this.onCardTap,
|
this.onCardTap,
|
||||||
this.margin,
|
this.margin,
|
||||||
this.contentPadding,
|
this.contentPadding,
|
||||||
|
this.borderColor,
|
||||||
|
this.vigilanceTooltip,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory AdminEnfantUserCard.fromEnfant(
|
factory AdminEnfantUserCard.fromEnfant(
|
||||||
@@ -55,10 +60,13 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
VoidCallback? onCardTap,
|
VoidCallback? onCardTap,
|
||||||
EdgeInsetsGeometry? margin,
|
EdgeInsetsGeometry? margin,
|
||||||
EdgeInsetsGeometry? contentPadding,
|
EdgeInsetsGeometry? contentPadding,
|
||||||
|
Color? borderColor,
|
||||||
|
String? vigilanceTooltip,
|
||||||
}) {
|
}) {
|
||||||
final parents = enfant.parentLinks
|
final parents = enfant.parentLinks
|
||||||
.map((l) => l.parentName ?? 'Parent')
|
.map((l) => l.parentName ?? 'Parent')
|
||||||
.join(', ');
|
.join(', ');
|
||||||
|
final orphan = enfantHasNoResponsable(enfant);
|
||||||
return AdminEnfantUserCard(
|
return AdminEnfantUserCard(
|
||||||
title: enfant.fullName,
|
title: enfant.fullName,
|
||||||
photoUrl: enfant.photoUrl,
|
photoUrl: enfant.photoUrl,
|
||||||
@@ -69,6 +77,7 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
gender: enfant.gender,
|
gender: enfant.gender,
|
||||||
extra: [
|
extra: [
|
||||||
if (parents.isNotEmpty) 'Responsables : $parents',
|
if (parents.isNotEmpty) 'Responsables : $parents',
|
||||||
|
if (orphan) 'Aucun responsable rattaché',
|
||||||
...extraSubtitleLines,
|
...extraSubtitleLines,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -76,6 +85,10 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
onCardTap: onCardTap,
|
onCardTap: onCardTap,
|
||||||
margin: margin,
|
margin: margin,
|
||||||
contentPadding: contentPadding,
|
contentPadding: contentPadding,
|
||||||
|
borderColor: borderColor ??
|
||||||
|
(orphan ? Colors.red.shade300 : null),
|
||||||
|
vigilanceTooltip: vigilanceTooltip ??
|
||||||
|
enfantSansResponsableVigilanceMessage(enfant),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +127,8 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
onCardTap: onCardTap,
|
onCardTap: onCardTap,
|
||||||
margin: margin,
|
margin: margin,
|
||||||
contentPadding: contentPadding,
|
contentPadding: contentPadding,
|
||||||
|
borderColor: borderColor,
|
||||||
|
vigilanceTooltip: vigilanceTooltip,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -283,8 +283,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('Détacher l\'enfant'),
|
title: const Text('Détacher l\'enfant'),
|
||||||
content: Text(
|
content: Text(
|
||||||
'Retirer ${child.fullName} de la fiche de ce parent ?\n'
|
'Retirer ${child.fullName} du foyer (tous les responsables) ?',
|
||||||
'(L\'enfant ne sera pas supprimé.)',
|
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -309,8 +308,9 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await _reloadChildren();
|
await _reloadChildren();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
widget.onSaved?.call();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Enfant détaché')),
|
const SnackBar(content: Text('Enfant détaché du foyer')),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -337,8 +337,9 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await _reloadChildren();
|
await _reloadChildren();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
widget.onSaved?.call();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Enfant rattaché')),
|
const SnackBar(content: Text('Enfant rattaché au foyer')),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import 'package:p_tits_pas/services/user_service.dart';
|
|||||||
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||||
|
|
||||||
/// Foyer / famille sélectionnable pour rattacher un nouvel enfant (#132).
|
/// Foyer / famille sélectionnable pour rattacher un nouvel enfant (#132 / #157).
|
||||||
class AdminFamilleFoyer {
|
class AdminFamilleFoyer {
|
||||||
/// Parent pivot pour `POST /enfants` (`parent_user_id`).
|
/// Parent pivot pour `POST /enfants` (`parent_user_id`).
|
||||||
final String pivotParentUserId;
|
final String pivotParentUserId;
|
||||||
|
/// Co-parent éventuel (rattachement foyer #157).
|
||||||
|
final String? coParentUserId;
|
||||||
final String? numeroDossier;
|
final String? numeroDossier;
|
||||||
final String displayTitle;
|
final String displayTitle;
|
||||||
final List<String> parentNames;
|
final List<String> parentNames;
|
||||||
@@ -16,9 +18,18 @@ class AdminFamilleFoyer {
|
|||||||
required this.pivotParentUserId,
|
required this.pivotParentUserId,
|
||||||
required this.displayTitle,
|
required this.displayTitle,
|
||||||
required this.parentNames,
|
required this.parentNames,
|
||||||
|
this.coParentUserId,
|
||||||
this.numeroDossier,
|
this.numeroDossier,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Parents du foyer à lier à l’enfant (pivot puis co-parent).
|
||||||
|
List<String> get parentUserIds {
|
||||||
|
final ids = <String>[pivotParentUserId];
|
||||||
|
final co = (coParentUserId ?? '').trim();
|
||||||
|
if (co.isNotEmpty && co != pivotParentUserId) ids.add(co);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
String get subtitle {
|
String get subtitle {
|
||||||
final parts = <String>[];
|
final parts = <String>[];
|
||||||
final dossier = (numeroDossier ?? '').trim();
|
final dossier = (numeroDossier ?? '').trim();
|
||||||
@@ -61,6 +72,7 @@ List<AdminFamilleFoyer> buildFamilleFoyers(List<ParentModel> parents) {
|
|||||||
foyers.add(
|
foyers.add(
|
||||||
AdminFamilleFoyer(
|
AdminFamilleFoyer(
|
||||||
pivotParentUserId: p.user.id,
|
pivotParentUserId: p.user.id,
|
||||||
|
coParentUserId: co?.id,
|
||||||
numeroDossier: dossier.isNotEmpty ? dossier : null,
|
numeroDossier: dossier.isNotEmpty ? dossier : null,
|
||||||
displayTitle: title,
|
displayTitle: title,
|
||||||
parentNames: names,
|
parentNames: names,
|
||||||
|
|||||||
@@ -72,7 +72,14 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
|
|||||||
normalizeEnfantStatus(e.status) ==
|
normalizeEnfantStatus(e.status) ==
|
||||||
normalizeEnfantStatus(widget.statusFilter);
|
normalizeEnfantStatus(widget.statusFilter);
|
||||||
return matchesName && matchesStatus;
|
return matchesName && matchesStatus;
|
||||||
}).toList();
|
}).toList()
|
||||||
|
..sort((a, b) {
|
||||||
|
// Orphelins (#157) en tête, puis ordre alphabétique.
|
||||||
|
final ao = a.hasNoResponsable ? 0 : 1;
|
||||||
|
final bo = b.hasNoResponsable ? 0 : 1;
|
||||||
|
if (ao != bo) return ao.compareTo(bo);
|
||||||
|
return a.fullName.toLowerCase().compareTo(b.fullName.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
return UserList(
|
return UserList(
|
||||||
isLoading: _isLoading,
|
isLoading: _isLoading,
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
|||||||
onCardTap: () => _openParentDetails(parent),
|
onCardTap: () => _openParentDetails(parent),
|
||||||
subtitleLines: [
|
subtitleLines: [
|
||||||
parent.user.email,
|
parent.user.email,
|
||||||
'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.children.isNotEmpty ? parent.children.length : parent.childrenCount}',
|
'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${ParentModel.foyerChildrenCount(parent, _parents)}',
|
||||||
],
|
],
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
Reference in New Issue
Block a user