Merge branch 'feature/140-dashboard-admin-ch6-famille' into develop

Livraison epic #140 — dashboard admin ch.6 famille (doc 28 §6.1–§6.2).

Inclus :
- Backend : PATCH fiche parent, rattachement/détachement enfant (#115)
- Frontend : fiche parent éditable (#131), onglet Enfants (#137), fiche/liste enfant (#138), affiliation UI (#116), UserService (#130)
- IdentityBlock partagé, parsing parentChildren, avatars web /uploads

Reste ouvert sur tickets dédiés : fiche enfant UI (#138), modale rattacher (#116), fiche AM (#131), création enfant (#129).
This commit is contained in:
MARTIN Julien 2026-06-24 23:22:39 +02:00
commit d55f240f56
28 changed files with 2822 additions and 149 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

@ -83,7 +83,12 @@ export class EnfantsController {
return this.enfantsService.findOne(id, currentUser);
}
@Roles(RoleType.ADMINISTRATEUR, RoleType.SUPER_ADMIN, RoleType.PARENT)
@Roles(
RoleType.PARENT,
RoleType.ADMINISTRATEUR,
RoleType.SUPER_ADMIN,
RoleType.GESTIONNAIRE,
)
@Patch(':id')
update(
@Param('id', new ParseUUIDPipe()) id: string,

View File

@ -78,10 +78,10 @@ export class EnfantsService {
return this.findOne(child.id, currentUser);
}
// Liste des enfants
// Liste des enfants (admin/gestionnaire)
async findAll(): Promise<Children[]> {
return this.childrenRepository.find({
relations: ['parentLinks'],
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
order: { last_name: 'ASC', first_name: 'ASC' },
});
}

View File

@ -0,0 +1,57 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsEmail,
IsEnum,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Mise à jour fiche parent par admin/gestionnaire (doc 28 §6.1, ticket #131). */
export class UpdateParentFicheAdminDto {
@ApiPropertyOptional({ example: 'Dupont' })
@IsOptional()
@IsString()
@MaxLength(100)
nom?: string;
@ApiPropertyOptional({ example: 'Marie' })
@IsOptional()
@IsString()
@MaxLength(100)
prenom?: string;
@ApiPropertyOptional({ example: 'marie.dupont@example.com' })
@IsOptional()
@IsEmail()
email?: string;
@ApiPropertyOptional({ example: '+33612345678' })
@IsOptional()
@IsString()
@MaxLength(20)
telephone?: string;
@ApiPropertyOptional({ example: '10 rue de la Paix' })
@IsOptional()
@IsString()
adresse?: string;
@ApiPropertyOptional({ example: 'Paris' })
@IsOptional()
@IsString()
@MaxLength(150)
ville?: string;
@ApiPropertyOptional({ example: '75001' })
@IsOptional()
@IsString()
@MaxLength(10)
code_postal?: string;
@ApiPropertyOptional({ enum: StatutUtilisateurType })
@IsOptional()
@IsEnum(StatutUtilisateurType)
statut?: StatutUtilisateurType;
}

View File

@ -1,6 +1,7 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
@ -16,6 +17,7 @@ import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CreateParentDto } from '../user/dto/create_parent.dto';
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { User } from 'src/common/decorators/user.decorator';
@ -87,7 +89,7 @@ export class ParentsController {
return this.parentsService.findAll();
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Get(':id')
@ApiResponse({ status: 200, type: Parents, description: 'Détails du parent par ID utilisateur' })
@ApiResponse({ status: 404, description: 'Parent non trouvé' })
@ -105,6 +107,45 @@ export class ParentsController {
return this.parentsService.create(dto);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Patch(':id/fiche')
@ApiOperation({ summary: 'Mettre à jour la fiche parent (admin/gestionnaire) — ticket #131' })
@ApiParam({ name: 'id', description: "UUID utilisateur du parent" })
@ApiBody({ type: UpdateParentFicheAdminDto })
@ApiResponse({ status: 200, type: Parents, description: 'Fiche parent mise à jour' })
updateFicheAdmin(
@Param('id') id: string,
@Body() dto: UpdateParentFicheAdminDto,
): Promise<Parents> {
return this.parentsService.updateFicheAdmin(id, dto);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Post(':id/enfants/:enfantId')
@ApiOperation({ summary: 'Rattacher un enfant à un parent — ticket #115' })
@ApiParam({ name: 'id', description: "UUID utilisateur du parent" })
@ApiParam({ name: 'enfantId', description: "UUID de l'enfant" })
@ApiResponse({ status: 200, type: Parents, description: 'Parent avec enfants mis à jour' })
attachEnfant(
@Param('id') id: string,
@Param('enfantId') enfantId: string,
): Promise<Parents> {
return this.parentsService.attachEnfant(id, enfantId);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Delete(':id/enfants/:enfantId')
@ApiOperation({ summary: "Détacher un enfant d'un parent — ticket #115" })
@ApiParam({ name: 'id', description: "UUID utilisateur du parent" })
@ApiParam({ name: 'enfantId', description: "UUID de l'enfant" })
@ApiResponse({ status: 200, type: Parents, description: 'Parent avec enfants mis à jour' })
detachEnfant(
@Param('id') id: string,
@Param('enfantId') enfantId: string,
): Promise<Parents> {
return this.parentsService.detachEnfant(id, enfantId);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
@Patch(':id')
@ApiBody({ type: UpdateParentsDto })

View File

@ -4,6 +4,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { Parents } from 'src/entities/parents.entity';
import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { ParentsController } from './parents.controller';
import { ParentsService } from './parents.service';
import { Users } from 'src/entities/users.entity';
@ -11,7 +12,7 @@ import { UserModule } from '../user/user.module';
@Module({
imports: [
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant]),
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant, ParentsChildren]),
forwardRef(() => UserModule),
JwtModule.registerAsync({
imports: [ConfigModule],

View File

@ -17,7 +17,9 @@ import {
DossierFamilleParentDto,
DossierFamilleEnfantDto,
} from './dto/dossier-famille-complet.dto';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { Children } from 'src/entities/children.entity';
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
@Injectable()
export class ParentsService {
@ -28,6 +30,8 @@ export class ParentsService {
private readonly usersRepository: Repository<Users>,
@InjectRepository(DossierFamille)
private readonly dossierFamilleRepository: Repository<DossierFamille>,
@InjectRepository(ParentsChildren)
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
) {}
// Création dun parent
@ -62,7 +66,7 @@ export class ParentsService {
// Liste des parents
async findAll(): Promise<Parents[]> {
return this.parentsRepository.find({
relations: ['user', 'co_parent', 'parentChildren', 'dossiers'],
relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers'],
});
}
@ -70,7 +74,7 @@ export class ParentsService {
async findOne(user_id: string): Promise<Parents> {
const parent = await this.parentsRepository.findOne({
where: { user_id },
relations: ['user', 'co_parent', 'parentChildren', 'dossiers'],
relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers'],
});
if (!parent) throw new NotFoundException('Parent introuvable');
return parent;
@ -82,6 +86,79 @@ export class ParentsService {
return this.findOne(id);
}
/**
* Mise à jour fiche parent (champs user + statut) par admin/gestionnaire. Ticket #131 / doc 28 §6.1.
*/
async updateFicheAdmin(parentUserId: string, dto: UpdateParentFicheAdminDto): Promise<Parents> {
const parent = await this.findOne(parentUserId);
const user = parent.user;
if (dto.email && dto.email !== user.email) {
const existing = await this.usersRepository.findOne({ where: { email: dto.email } });
if (existing && existing.id !== user.id) {
throw new ConflictException('Cet email est déjà utilisé');
}
user.email = dto.email;
}
if (dto.nom !== undefined) user.nom = dto.nom;
if (dto.prenom !== undefined) user.prenom = dto.prenom;
if (dto.telephone !== undefined) user.telephone = dto.telephone;
if (dto.adresse !== undefined) user.adresse = dto.adresse;
if (dto.ville !== undefined) user.ville = dto.ville;
if (dto.code_postal !== undefined) user.code_postal = dto.code_postal;
if (dto.statut !== undefined) user.statut = dto.statut;
await this.usersRepository.save(user);
return this.findOne(parentUserId);
}
/**
* Rattacher un enfant existant à un parent (enfants_parents). Ticket #115 / doc 28 §6.2.
*/
async attachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
await this.findOne(parentUserId);
const existing = await this.parentsChildrenRepository.findOne({
where: { parentId: parentUserId, enfantId },
});
if (existing) {
throw new ConflictException('Cet enfant est déjà rattaché à ce parent');
}
const child = await this.parentsRepository.manager.findOne(Children, { where: { id: enfantId } });
if (!child) {
throw new NotFoundException('Enfant introuvable');
}
await this.parentsChildrenRepository.save(
this.parentsChildrenRepository.create({ parentId: parentUserId, enfantId }),
);
return this.findOne(parentUserId);
}
/**
* Détacher un enfant d'un parent sans supprimer l'enfant. Ticket #115 / doc 28 §6.2.
*/
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
await this.findOne(parentUserId);
const link = await this.parentsChildrenRepository.findOne({
where: { parentId: parentUserId, enfantId },
});
if (!link) {
throw new NotFoundException('Lien parent-enfant introuvable');
}
const totalLinks = await this.parentsChildrenRepository.count({ where: { enfantId } });
if (totalLinks <= 1) {
throw new BadRequestException('Un enfant doit rester rattaché à au moins un responsable');
}
await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId });
return this.findOne(parentUserId);
}
/**
* Liste des familles en attente (une entrée par famille).
* Famille = lien co_parent ou partage d'enfants (même logique que backfill #103).

View File

@ -36,6 +36,7 @@ Ce fichier sert d'index pour naviguer dans toute la documentation du projet.
- [**23 - Liste des Tickets**](./23_LISTE-TICKETS.md) - 61 tickets Phase 1 détaillés
- [**24 - Décisions Projet**](./24_DECISIONS-PROJET.md) - Décisions architecturales et fonctionnelles
- [**25 - Backlog Phase 2**](./25_PHASE-2-BACKLOG.md) - Fonctionnalités techniques reportées
- [**28 - Évolution famille et responsables**](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) - Modèle dossier/famille, recompositions, v1.0.0 vs post-1.0.0
- [**26 - API Gitea**](./26_GITEA-API.md) - Procédure d'utilisation de l'API Gitea (issues, PR, branches, labels)
- [**27 - Briefing frontend**](./27_BRIEFING-FRONTEND.md) - Accès Git, priorités, scripts Gitea (token)
@ -49,7 +50,7 @@ Fichiers **sans préfixe numérique** encore à la racine par **héritage** ou
références outils (`.cursorrules`, etc.) — **à renommer** en `NN_` quand
possible :
- `CHARTE_GRAPHIQUE.md`
- `EVOLUTIONS_CDC.md`
- [`EVOLUTIONS_CDC.md`](./EVOLUTIONS_CDC.md) — écarts CDC / app ; voir aussi [**28 - Évolution famille**](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md)
- `SuperNounou_Cahier_Des_Charges_Complet_V1.1.md`
- `SuperNounou_SSS-001.md`
@ -89,5 +90,5 @@ PgAdmin: https://app.ptits-pas.fr/pgadmin
Cette documentation est maintenue par Julien Martin (julien.martin@ptits-pas.fr).
Dernière mise à jour : Novembre 2025
Dernière mise à jour : Juin 2026

View File

@ -1,7 +1,7 @@
# 📋 Décisions Projet - P'titsPas
**Version** : 1.1
**Date** : 9 Février 2026
**Version** : 1.2
**Date** : 16 Juin 2026
**Auteur** : Équipe PtitsPas
---
@ -122,6 +122,27 @@ ptitspas-app/
---
### 5bis. Familles recomposées — contournement v1.0.0
**Décision** : ✅ **Second compte / second email pour les cas multi-contextes en v1.0.0** ; évolution structurelle reportée post-1.0.0
**Contexte** :
- Le numéro de dossier et le graphe « famille » (co-parent + enfants partagés) conviennent aux cas simples (un couple, N enfants).
- Ils ne couvrent pas proprement une même personne responsable dans **plusieurs unités familiales** (recompositions, plusieurs co-responsables successifs, tuteur/GP sur plusieurs contextes).
**Décision v1.0.0** :
- **Contournement opérationnel** : créer un **second compte** avec **email distinct** et un **second numéro de dossier** (souvent via le gestionnaire).
- Le numéro de dossier reste la **norme** pour les cas simples, **sans obligation absolue** pour les cas complexes.
- Rôle applicatif unique **`parent`** pour tous les responsables (tuteur, GP inclus) ; qualification juridique = évolution ultérieure.
**Évolution post-1.0.0** :
- **Parcours gestionnaire « famille complexe »** (§ 7.5 doc 28) : N responsables, **M un seul compte** avec tous ses enfants ; **A / B** limités à leur enfant via `enfants_parents`.
- Visibilité et workflows **sans fusion graphe familial** ; numéro de dossier optionnel ; qualification du lien responsableenfant.
**Référence** : [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) — § 4.4, § 7.5
---
### 6. Genre enfant obligatoire (H/F)
**Décision** : ✅ **Genre obligatoire (H/F uniquement)**
@ -562,6 +583,7 @@ docs/
| 14 | Migration données | ❌ Rejeté | N/A |
| 15 | Doc utilisateur | ⏸️ Phase 2 | Formation |
| 31 | Logs Winston | ✅ Phase 1 | Monitoring |
| 5bis | Familles recomposées — 2ᵉ compte v1.0.0 | ✅ v1.0.0 | Métier / dossier |
---
@ -571,10 +593,12 @@ docs/
|------|---------|---------------|
| 25/11/2025 | 1.0 | Création du document - Toutes les décisions initiales |
| 09/02/2026 | 1.1 | Configuration initiale : un seul panneau Paramètres (3 sections) dans le dashboard, plus de Setup Wizard dédié ; navigation bloquée jusqu'à sauvegarde |
| 16/06/2026 | 1.2 | Décision 5bis — familles recomposées, contournement v1.0.0 ; lien doc [28](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) |
| 16/06/2026 | 1.3 | Précision 5bis — cible post-1.0.0 : parcours gestionnaire § 7.5 (#139), visibilité par enfant |
---
**Dernière mise à jour** : 9 Février 2026
**Version** : 1.1
**Dernière mise à jour** : 16 Juin 2026
**Version** : 1.2
**Statut** : ✅ Document validé

View File

@ -0,0 +1,331 @@
# Évolution — Modèle famille, responsables légaux et dossiers
**Version** : 1.1
**Date** : 16 juin 2026
**Statut** : Réflexions produit / architecture — complément au [CDC](./01_CAHIER-DES-CHARGES.md)
**Documents liés** : [EVOLUTIONS_CDC.md](./EVOLUTIONS_CDC.md), [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md), [23_LISTE-TICKETS.md](./23_LISTE-TICKETS.md)
---
## 1. Objet de ce document
Ce document **trace les réflexions** menées en 2026 sur :
- les **limites du modèle « famille / numéro de dossier »** en v1.0.0 ;
- les **contournements** acceptés pour la release **1.0.0** ;
- les **évolutions** envisagées post-1.0.0 (unités de dossier, affiliation parentenfant, terminologie).
Il ne remplace pas le CDC : il documente l**écart assumé** entre le modèle idéal long terme et ce qui est livré en **1.0.0**, ainsi que la **feuille de route** pour aller plus loin.
---
## 2. Modèle actuel (v1.0.0) — rappel
| Concept | Implémentation |
|--------|----------------|
| **Responsable inscrit** | Rôle applicatif `parent` + entité `parents` |
| **Second adulte** | Co-parent optionnel (`id_co_parent`) — **un seul** |
| **Enfant** | Entité `enfants` |
| **Affiliation** | Table `enfants_parents` (liens many-to-many) |
| **Dossier famille** | `dossier_famille` + `dossier_famille_enfants` (motivation, etc.) |
| **Numéro de dossier** | Format `AAAA-NNNNNN`, sur `utilisateurs` et `parents` |
| **« Famille » calculée** | Graphe : co-parent **ou** enfants partagés (`getFamilyUserIds`) |
| **Workflows** | Validation, refus, reprise : souvent **par `numero_dossier`** ou par ce graphe |
Le numéro de dossier est aujourdhui une **aide forte** pour les cas simples (un couple, N enfants, une motivation), mais il tend à devenir **lidentifiant métier** de la famille — ce qui pose problème dans les cas complexes.
---
## 3. Limitation connue v1.0.0 — familles recomposées et multi-contextes
### 3.1 Exemple type (illustration)
```
Année 1 : Responsable M + co-responsable A → enfant α
Année 2 : Responsable M + co-responsable B → enfant β
```
Même personne **M** au centre, **deux contextes de vie** distincts. **A** nest pas parent de β ; **B** nest pas parent de α.
### 3.2 Autres cas couverts par la même limitation
Les exemples « maman / papa » sont **illustratifs**. La limitation sapplique **à toute configuration** :
| Configuration | Même règle |
|---------------|------------|
| Couple **HH** ou **FF** | Oui |
| **Père** avec deux partenaires et deux enfants (même ville, écarts dâge courts) | Oui |
| **Grand-parent** en tutelle ou **tuteur légal** | Oui *(voir § 5)* |
| Recomposition, demi-fratrie, garde alternée complexe | Oui |
### 3.3 Pourquoi le modèle casse
1. **Un `numero_dossier` par user** — M ne peut pas appartenir proprement à deux unités.
2. **Un seul co-parent** par fiche `parents`.
3. **Graphe famille trop large** — M liée à A (via α) et à B (via β) → A, B et M peuvent être fusionnés en **une seule « famille »** pour validation/refus/reprise.
4. **`dossier_famille`** — une motivation / une ancre par numéro, pas deux contextes pour la même personne.
---
## 4. Décision v1.0.0 — contournement opérationnel
### 4.1 Principe
> **Le numéro de dossier reste la norme pour les cas simples, pas une obligation absolue.**
> Pour les cas trop complexes, le **gestionnaire** compose manuellement (création admin, rattachements) ou applique le contournement ci-dessous.
### 4.2 Contournement accepté pour la 1.0.0
**Créer un second compte** avec une **adresse e-mail distincte** et un **second dossier** (second `numero_dossier`).
| Dossier | Compte | Co-responsable | Enfant |
|---------|--------|----------------|--------|
| 1 | `m.personne@…` | A | α |
| 2 | `m.personne.famille2@…` *(ou alias)* | B | β |
**Conséquences assumées :**
- Une **même personne physique** peut avoir **deux identités** dans lapp.
- Pas de vue unifiée « une personne, deux contextes » en v1.0.0.
- Procédure interne gestionnaire recommandée (note « même personne physique »).
- Emails distincts **volontaires** (alias, +tag, boîte dédiée selon infra mail).
### 4.3 Nuance — inscription publique
Si le **dossier 1** existe déjà avec lemail de P (titulaire ou co-parent), une **2ᵉ inscription publique** où lon saisit **le même email** comme co-parent est **bloquée** (*email déjà utilisé*).
Le **2ᵉ dossier** passe donc surtout par :
- **création / composition par le gestionnaire** (tickets admin #129+), ou
- **2ᵉ email** dès le départ pour la même personne physique.
### 4.4 Piste cible — parcours gestionnaire « famille complexe » *(réflexion juin 2026)*
Le contournement § 4.2 reste valable en **v1.0.0**. La **solution produit visée** pour les cas complexes est différente :
> **Seul le gestionnaire** dispose dun **parcours de création dédié** permettant de constituer une configuration avec **plus de deux responsables** (parents, co-parents, tuteurs…) **sans** se limiter au seul champ `co_parent`, en sappuyant sur **`enfants_parents`** comme vérité de visibilité.
**Exemple M / A / B / α / β :**
| Compte | Enfants visibles / rattachés |
|--------|------------------------------|
| **M** (un seul compte, un email) | α **et** β |
| **A** | α uniquement |
| **B** | β uniquement |
```
α ─── M ─── β
│ │
A B
```
- **M** voit et gère **ses deux enfants** sur **le même compte** (plus besoin dun 2ᵉ email pour M).
- **A** et **B** ne voient **que** lenfant qui leur est rattaché via `enfants_parents` — pas de fusion « famille » qui mélange A et B.
- Le parcours nest **pas** proposé à linscription publique (trop error-prone) : **réservé au gestionnaire** (#129+ ou ticket dédié « famille complexe »).
**Conséquences techniques (post-1.0.0) :**
1. **Visibilité** : filtrer listes, fiches et actions parent par **liens `enfants_parents`**, pas par `getFamilyUserIds` / co-parent.
2. **Workflows** (validation, refus, reprise) : périmètre par **enfant** ou **soumission**, pas par graphe familial élargi.
3. **`co_parent`** : reste utile pour le cas simple (couple + N enfants communs) ; **insuffisant seul** pour les cas complexes — le gestionnaire compose les liens enfant par enfant.
4. **Numéro de dossier** : optionnel ou secondaire ; peut rester sur M ou sur une « unité » admin, sans imposer un numéro par co-responsable.
*Détail : § 7.5.*
---
## 5. Terminologie — « parent » aujourdhui, qualification demain
### 5.1 v1.0.0
- Rôle technique : **`parent`** pour tout responsable inscrit (y compris tuteur, grand-parent tutrice, etc.).
- UI : « Parent 1 », « co-parent », parfois exemples maman/papa — **pas de statut juridique distinct**.
### 5.2 Évolution post-1.0.0 (piste)
Séparer deux niveaux :
| Niveau | Évolution |
|--------|-----------|
| **Rôle applicatif** | Conserver `parent` = « responsable du dossier » (auth, API) |
| **Qualification métier** | Nouveau champ, ex. `qualite_responsable` / `lien_avec_enfant` |
**Valeurs possibles (exemples)** : parent biologique ou adoptif, co-parent, tuteur légal, grand-parent exerçant la garde, autre responsable légal.
**Emplacement recommandé** : sur le **lien** `enfants_parents` (par enfant), pas seulement sur le user global.
**UI** :
- Libellés neutres : « Responsable 1 / 2 », « 2ᵉ responsable » ;
- **Combobox** pour qualifier le lien (admin + éventuellement inscription).
---
## 6. Évolutions UI / fonctionnelles liées (backlog)
Réflexions dashboard admin / gestionnaire (complément CDC §4.5.24.5.3).
> **Statut implémentation (juin 2026)** : §6.1 et §6.2 **en cours de livraison** (tickets #130#131, #115#116, #137#138). §6.3 reporté post-1.0.0 (#129).
### 6.1 Fiche parent (#131)
- Modale **éditable** dès louverture (pas lecture seule + « Modifier » factice).
- **Retirer lID** UUID ; option : **n° de dossier** en lecture seule.
- **Statut** en combobox (règles métier à cadrer vs validation/refus #110).
- Bas de modale :
- `Nombre d'enfants : N` (lecture seule) ;
- **Liste des prénoms/noms** (cadre) — consultation, clic → fiche enfant (#138).
### 6.2 Affiliation parent ↔ enfant (#115, #116, #138)
- **Vérité métier** : `enfants_parents`.
- **Modifier laffiliation** ≠ modifier le téléphone : gestion des **liens** (détacher / rattacher), avec garde-fous :
- ne pas supprimer lenfant pour retirer un lien ;
- au moins un responsable par enfant ;
- prudence sur fusion « famille » et co-parents.
- **Création enfant** : prioritaire depuis **fiche parent** (contexte famille) ; onglet **Enfants** (#137) pour vue globale + rattachement.
### 6.3 Création admin sans numéro (tickets #129+)
Le gestionnaire doit pouvoir **tout créer** depuis linterface ; le numéro reste **généré si utile**, **optionnel** si le cas est trop complexe — **objectif post-1.0.0** (unité de dossier).
---
## 7. Évolution structurelle post-1.0.0 — « unité de dossier »
### 7.1 Principe cible
| Aujourdhui | Cible |
|-------------|--------|
| `numero_dossier` = clé de la famille | **Liens parent↔enfant** = vérité |
| Famille déduite du graphe | **Unité de dossier** = regroupement **optionnel** |
| Un numéro par inscription | Numéro **optionnel** ; plusieurs unités par personne possibles |
### 7.2 Exemple cible (cas M / A / B) — deux approches
**Approche A — unités de dossier séparées** *(piste initiale § 7)* :
```
Unité 1 : numero 2026-000021 — M, A, α
Unité 2 : sans numéro (ou 2026-000089) — M, B, β
```
M appartient à **deux unités** ; A et B ne partagent pas la même unité. Peut impliquer **deux contextes de connexion** ou une agrégation côté M.
**Approche B — parcours gestionnaire « famille complexe »** *(préférée, § 4.4)* :
```
Compte M → enfants α, β
Compte A → enfant α
Compte B → enfant β
(liens enfants_parents ; pas de fusion A↔B)
```
- **Un seul compte pour M** avec **les deux enfants**.
- **A** et **B** isolés sur **leur** enfant respectif.
- Création **uniquement** par le gestionnaire ; inscription publique inchangée (couple + co-parent classique).
Les deux approches supposent de **cesser de déduire une « famille » unique** pour les workflows lorsque les liens enfant par enfant divergent. Lapproche B maximise lUX du responsable central (M) sans dupliquer son identité.
### 7.5 Parcours gestionnaire — création « famille complexe »
#### 7.5.1 Objectif
Permettre au **gestionnaire** de monter un dossier où :
- **N responsables** (≥ 2, parents ou tuteurs) sont créés ou rattachés ;
- chaque enfant est lié **explicitement** à un ou plusieurs responsables via `enfants_parents` ;
- un responsable (ex. **M**) peut être lié à **plusieurs enfants** dont les **autres** responsables (A, B) ne partagent **pas** la garde.
#### 7.5.2 Règles produit
| Règle | Détail |
|-------|--------|
| **Accès** | Parcours **gestionnaire uniquement** (dashboard), pas inscription publique |
| **Responsables** | Création ou rattachement de comptes `parent` ; qualification future sur le lien (§ 5) |
| **Visibilité** | Chaque responsable ne voit que **ses** enfants (liens `enfants_parents`) |
| **Co-parent UI** | Ne pas forcer « Parent 1 + co-parent unique » ; composition libre côté staff |
| **Garde-fous** | Au moins un responsable par enfant ; pas de suppression enfant pour retirer un lien |
#### 7.5.3 Esquisse du parcours UI (gestionnaire)
1. **Créer ou identifier** le responsable principal (ex. M).
2. **Ajouter dautres responsables** (A, B, tuteur…) — comptes distincts.
3. **Créer les enfants** (α, β…) et, pour chaque enfant, **cocher les responsables** rattachés.
4. **Motivation / dossier** : texte global ou par enfant (à cadrer).
5. **Validation** : par soumission ou par enfant, sans valider « toute la famille » dun coup si A et B ne doivent pas être fusionnés.
#### 7.5.4 Impacts techniques majeurs
| Domaine | Changement |
|---------|------------|
| **Auth / API parent** | `GET` enfants, dashboard parent : filtre `enfants_parents` pour lutilisateur courant |
| **`getFamilyUserIds`** | Ne plus utiliser pour visibilité parent ; réservé au cas simple ou deprecated progressivement |
| **Validation / refus** | Périmètre enfant ou responsable, pas fusion A+B via graphe |
| **Reprise (#112)** | Token / périmètre = enfants liés au compte refusé, pas toute la composante connexe |
| **Admin (#115#138)** | Prérequis : rattachement/détachement déjà en place ; ce parcours **compose** ces briques |
#### 7.5.5 Lien avec v1.0.0
| Phase | Comportement |
|-------|--------------|
| **v1.0.0** | Contournement § 4.2 (2ᵉ email M) + composition manuelle gestionnaire (#115#138) |
| **Post-1.0.0** | Parcours § 7.5 + refonte visibilité / workflows |
### 7.3 Impacts workflows
| Flux | Adaptation future |
|------|-------------------|
| Validation / refus (#110) | Par **enfant / soumission**, pas par graphe global |
| Reprise (#112) | Périmètre = enfants liés au compte, pas composante connexe |
| Liste « à valider » | Par soumission ou par enfant |
| Recherche gestionnaire | Numéro **ou** nom **ou** enfant |
| Visibilité parent | **Uniquement** enfants via `enfants_parents` (approche B § 7.2) |
### 7.4 Migration
- **Court terme** : contournement § 4 + tickets admin.
- **Moyen terme** : table **unité de dossier**, `numero_dossier` nullable.
- **Long terme** : workflows branchés sur lunité.
- Dossiers **existants** (couple + enfants) : une unité = un numéro → **comportement actuel préservé**.
---
## 8. Tickets Gitea associés
| Ticket | Sujet |
|--------|--------|
| #110 | Refus dossier (token reprise) — livré |
| #112 | Reprise après refus — livré |
| #115 / #116 | Rattachement parent — backend / front |
| #129+ | Création dossier admin (parent / AM) |
| #131 | Fiche parent éditable (dashboard) |
| #137 | Onglet Enfants — liste globale |
| #138 | Fiche enfant + liste dans fiche parent |
| *(à créer)* | Epic « Unité de dossier / numéro optionnel » |
| #139 | **Parcours gestionnaire « famille complexe »** (§ 7.5) — N responsables, visibilité par enfant |
| *(à créer)* | Qualification responsable légal (combobox sur lien enfant) |
---
## 9. Formulation type — release notes / doc gestionnaire (v1.0.0)
> **Familles recomposées ou responsabilités multiples**
> Une même personne ne peut pas gérer proprement deux unités familiales distinctes (recompositions, plusieurs co-responsables successifs, tuteur/GP pour plusieurs contextes) avec **un seul compte**.
> **Contournement v1.0.0** : second compte avec **email distinct** et second numéro de dossier.
> Sapplique à **tous les responsables inscrits** (couples HH/FF, tuteurs, grands-parents, etc.).
> **Évolution post-1.0.0** : parcours **gestionnaire** « famille complexe » (un compte M, enfants α+β ; A et B limités à leur enfant) ; visibilité par `enfants_parents` ; workflows sans fusion graphe familial.
---
## 10. Historique des mises à jour
| Date | Auteur | Modification |
|------|--------|--------------|
| 2026-06-16 | Équipe / session produit | Création — synthèse réflexions famille, tutelle, v1.0.0 vs post-1.0.0 |
| 2026-06-16 | Implémentation ch.6 | Back : `PATCH /parents/:id/fiche`, attach/detach enfant. Front : modale parent éditable, onglet Enfants, fiche enfant |
| 2026-06-16 | Réflexion produit | § 4.4 / § 7.5 — parcours gestionnaire famille complexe : M un compte (α+β), A/B visibilité restreinte par enfant |
---
*Ce document sera enrichi au fil des décisions. Pour les décisions formelles archivées, voir aussi [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md).*

View File

@ -2,6 +2,8 @@
Ce document liste les modifications à apporter au cahier des charges original pour le rendre conforme à l'application développée.
> **Document complémentaire (juin 2026)** — réflexions sur le **modèle famille / numéro de dossier**, familles recomposées, tuteurs et responsables légaux : voir **[28 - Évolution famille et responsables](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md)**.
## 1. Gestion des Enfants
### Modifications à apporter dans la section "Création de compte parent"
@ -276,3 +278,28 @@ Pour chaque évolution identifiée, ce document suivra la structure suivante :
- Adaptation des écrans d'administration pour gérer les rôles locaux.
- Renforcement des contrôles d'accès backend et des règles métier.
- Clarification des workflows décisionnels dans l'application.
## 9. Modèle famille, numéro de dossier et responsables légaux
### 9.1 Situation actuelle (v1.0.0)
- Un **numéro de dossier** par inscription (`AAAA-NNNNNN`), fortement utilisé dans les workflows (validation, refus, reprise).
- **Un co-parent** par fiche `parents` ; affiliation réelle via `enfants_parents`.
- La « famille » est **déduite** du graphe (co-parent + enfants partagés) — fusion parfois trop large en cas de recompositions.
### 9.2 Limitation v1.0.0 — familles recomposées
Cas type : même responsable M avec co-responsable A (enfant α) puis co-responsable B (enfant β). Le modèle actuel ne permet pas de séparer proprement **deux unités familiales** pour une même personne.
**Contournement accepté pour la release 1.0.0** : second compte avec **email distinct** et second numéro de dossier (procédure gestionnaire). S'applique aussi aux couples HH/FF, tuteurs, grands-parents, etc.
### 9.3 Évolutions post-1.0.0 (pistes)
| Sujet | Piste |
|-------|--------|
| **Unité de dossier** | Numéro **optionnel** ; plusieurs unités par personne |
| **Affiliation** | Gestion admin des liens parent↔enfant (#115, #116, #138) |
| **Qualification** | Combobox « lien responsableenfant » (tuteur, GP, parent…) sur `enfants_parents` |
| **UI admin** | Fiche parent éditable (#131), liste enfants en bas de fiche |
**Détail complet, scénarios, tickets et formulation release notes** : [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md).

View File

@ -0,0 +1,108 @@
import 'package:p_tits_pas/models/user.dart';
/// Enfant tel que renvoyé par `GET /enfants` (dashboard admin).
class EnfantAdminModel {
final String id;
final String? firstName;
final String? lastName;
final String? gender;
final String? birthDate;
final String? dueDate;
final String status;
final String? photoUrl;
final bool consentPhoto;
final bool isMultiple;
final List<EnfantParentLink> parentLinks;
EnfantAdminModel({
required this.id,
this.firstName,
this.lastName,
this.gender,
this.birthDate,
this.dueDate,
required this.status,
this.photoUrl,
this.consentPhoto = false,
this.isMultiple = false,
this.parentLinks = const [],
});
String get fullName {
final fn = (firstName ?? '').trim();
final ln = (lastName ?? '').trim();
if (fn.isEmpty && ln.isEmpty) return 'Enfant';
if (ln.isEmpty) return fn;
return '$fn $ln';
}
factory EnfantAdminModel.fromJson(Map<String, dynamic> json) {
final linksRaw = json['parentLinks'] as List?;
final links = <EnfantParentLink>[];
if (linksRaw != null) {
for (final item in linksRaw) {
if (item is Map<String, dynamic>) {
links.add(EnfantParentLink.fromJson(item));
}
}
}
return EnfantAdminModel(
id: (json['id'] ?? '').toString(),
firstName: json['first_name'] as String?,
lastName: json['last_name'] as String?,
gender: json['gender'] as String?,
birthDate: _dateString(json['birth_date']),
dueDate: _dateString(json['due_date']),
status: (json['status'] ?? '').toString(),
photoUrl: json['photo_url'] as String?,
consentPhoto: json['consent_photo'] == true,
isMultiple: json['is_multiple'] == true,
parentLinks: links,
);
}
Map<String, dynamic> toUpdateJson() {
return {
if (firstName != null) 'first_name': firstName,
if (lastName != null) 'last_name': lastName,
if (gender != null && gender!.isNotEmpty) 'gender': gender,
'status': status,
if (birthDate != null && birthDate!.isNotEmpty) 'birth_date': birthDate,
if (dueDate != null && dueDate!.isNotEmpty) 'due_date': dueDate,
'consent_photo': consentPhoto,
'is_multiple': isMultiple,
};
}
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;
}
}
class EnfantParentLink {
final String parentId;
final String? parentName;
EnfantParentLink({required this.parentId, this.parentName});
factory EnfantParentLink.fromJson(Map<String, dynamic> json) {
final parentId =
(json['parentId'] ?? json['id_parent'] ?? '').toString();
String? name;
final parent = json['parent'];
if (parent is Map<String, dynamic>) {
final user = parent['user'];
if (user is Map<String, dynamic>) {
final u = AppUser.fromJson(user);
name = u.fullName.isNotEmpty ? u.fullName : u.email;
}
}
return EnfantParentLink(
parentId: parentId,
parentName: name,
);
}
}

View File

@ -0,0 +1,89 @@
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 {
final fn = (firstName ?? '').trim();
final ln = (lastName ?? '').trim();
if (fn.isEmpty && ln.isEmpty) return 'Enfant';
if (ln.isEmpty) return fn;
return '$fn $ln';
}
factory ParentChildSummary.fromJson(Map<String, dynamic> json) {
return ParentChildSummary(
id: (json['id'] ?? '').toString(),
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'];
if (childRaw is Map) {
return ParentChildSummary.fromJson(Map<String, dynamic>.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;
}
}

View File

@ -1,18 +1,56 @@
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/models/user.dart';
class ParentModel {
final AppUser user;
final int childrenCount;
final List<ParentChildSummary> children;
ParentModel({required this.user, this.childrenCount = 0});
ParentModel({
required this.user,
this.childrenCount = 0,
this.children = const [],
});
factory ParentModel.fromJson(Map<String, dynamic> json) {
final userJson = json['user'] ?? json;
final root = Map<String, dynamic>.from(json);
final userJson = Map<String, dynamic>.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 = json['parentChildren'] as List?;
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?.length ?? 0,
childrenCount: children.isNotEmpty ? children.length : linkCount,
children: children,
);
}
static List<ParentChildSummary> _parseChildren(Map<String, dynamic> json) {
final children = <ParentChildSummary>[];
final seen = <String>{};
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<String, dynamic>.from(link),
));
}
}
return children;
}
}

View File

@ -61,6 +61,7 @@ class ApiConfig {
static const String gestionnaires = '/gestionnaires';
static const String parents = '/parents';
static const String assistantesMaternelles = '/assistantes-maternelles';
static const String enfants = '/enfants';
static const String relais = '/relais';
static const String dossiers = '/dossiers';

View File

@ -1,6 +1,7 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/models/pending_family.dart';
@ -366,7 +367,136 @@ class UserService {
}
final List<dynamic> data = jsonDecode(response.body);
return data.map((e) => ParentModel.fromJson(e)).toList();
return data
.map((e) => ParentModel.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
}
static Future<ParentModel> getParent(String userId) async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$userId'),
headers: await _headers(),
);
if (response.statusCode != 200) {
final err = jsonDecode(response.body) as Map<String, dynamic>?;
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement parent');
}
final decoded = jsonDecode(response.body);
return ParentModel.fromJson(
Map<String, dynamic>.from(decoded is Map ? decoded : {}),
);
}
static Future<ParentModel> updateParentFiche({
required String parentUserId,
required Map<String, dynamic> body,
}) async {
final response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$parentUserId/fiche'),
headers: await _headers(),
body: jsonEncode(body),
);
if (response.statusCode != 200) {
throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour parent'));
}
return _parentModelFromBody(response.body);
}
static Future<ParentModel> attachEnfantToParent({
required String parentUserId,
required String enfantId,
}) async {
final response = await http.post(
Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.parents}/$parentUserId/enfants/$enfantId',
),
headers: await _headers(),
);
if (response.statusCode != 200 && response.statusCode != 201) {
throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant'));
}
return _parentModelFromBody(response.body);
}
static Future<ParentModel> detachEnfantFromParent({
required String parentUserId,
required String enfantId,
}) async {
final response = await http.delete(
Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.parents}/$parentUserId/enfants/$enfantId',
),
headers: await _headers(),
);
if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception(_extractErrorMessage(response.body, 'Erreur détachement enfant'));
}
if (response.body.isEmpty) {
return getParent(parentUserId);
}
return _parentModelFromBody(response.body);
}
static Future<List<EnfantAdminModel>> getEnfants() async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
headers: await _headers(),
);
if (response.statusCode != 200) {
throw Exception(_extractErrorMessage(response.body, 'Erreur chargement enfants'));
}
final List<dynamic> data = jsonDecode(response.body);
return data
.whereType<Map<String, dynamic>>()
.map(EnfantAdminModel.fromJson)
.toList();
}
static Future<EnfantAdminModel> getEnfant(String enfantId) async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
headers: await _headers(),
);
if (response.statusCode != 200) {
throw Exception(_extractErrorMessage(response.body, 'Erreur chargement enfant'));
}
return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
static Future<EnfantAdminModel> updateEnfant({
required String enfantId,
required Map<String, dynamic> body,
}) async {
final response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
headers: await _headers(),
body: jsonEncode(body),
);
if (response.statusCode != 200) {
throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant'));
}
return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
static ParentModel _parentModelFromBody(String body) {
final decoded = jsonDecode(body);
return ParentModel.fromJson(
Map<String, dynamic>.from(decoded is Map ? decoded : {}),
);
}
static String _extractErrorMessage(String body, String fallback) {
try {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
return message.join(' - ');
}
return _toStr(message) ?? fallback;
}
} catch (_) {}
return fallback;
}
// Récupérer la liste des assistantes maternelles

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,416 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/services/user_service.dart';
/// Fiche enfant consultation / édition (ticket #138).
class AdminChildDetailModal extends StatefulWidget {
final EnfantAdminModel enfant;
final VoidCallback? onSaved;
const AdminChildDetailModal({
super.key,
required this.enfant,
this.onSaved,
});
@override
State<AdminChildDetailModal> createState() => _AdminChildDetailModalState();
}
class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
late final TextEditingController _prenomCtrl;
late final TextEditingController _nomCtrl;
late final TextEditingController _birthCtrl;
late final TextEditingController _dueCtrl;
late String _status;
late String _gender;
late bool _consentPhoto;
late bool _isMultiple;
bool _dirty = false;
bool _saving = false;
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();
final e = widget.enfant;
_prenomCtrl = TextEditingController(text: e.firstName ?? '');
_nomCtrl = TextEditingController(text: e.lastName ?? '');
_birthCtrl = TextEditingController(text: e.birthDate ?? '');
_dueCtrl = TextEditingController(text: e.dueDate ?? '');
_status = _statuses.contains(e.status) ? e.status : 'actif';
_gender = _normalizeGender(e.gender);
_consentPhoto = e.consentPhoto;
_isMultiple = e.isMultiple;
for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) {
c.addListener(_markDirty);
}
}
@override
void dispose() {
for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) {
c.dispose();
}
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);
}
Future<void> _save() async {
if (!_dirty) return;
setState(() => _saving = true);
try {
await UserService.updateEnfant(
enfantId: widget.enfant.id,
body: {
'first_name': _prenomCtrl.text.trim(),
'last_name': _nomCtrl.text.trim(),
'status': _status,
'gender': _gender,
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,
},
);
if (!mounted) return;
setState(() {
_dirty = false;
_saving = false;
});
widget.onSaved?.call();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Fiche enfant enregistrée')),
);
} catch (e) {
if (!mounted) return;
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
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 = _parentsLine();
final showDueDate = _status == 'a_naitre';
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
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;
}),
),
],
),
),
),
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'),
),
],
),
],
),
),
),
),
);
}
InputDecoration _decoration({String? hint}) {
return InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
hintText: hint,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
);
}
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<MapEntry<String, String>> items,
ValueChanged<String> 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<String>(
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<bool> onChanged) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Expanded(child: Text(label, style: _labelStyle)),
Switch(
value: value,
onChanged: onChanged,
),
],
),
);
}
String _statusLabel(String status) {
switch (status) {
case 'a_naitre':
return 'À naître';
case 'actif':
return 'Actif';
case 'scolarise':
return 'Scolarisé';
default:
return status;
}
}
String _genderLabel(String gender) {
switch (gender) {
case 'H':
return 'Garçon';
case 'F':
return 'Fille';
case 'Autre':
return 'Autre';
default:
return gender;
}
}
}

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

@ -0,0 +1,495 @@
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/widgets/admin/common/admin_child_detail_modal.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';
/// 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;
const AdminParentEditModal({
super.key,
required this.parent,
this.onSaved,
});
@override
State<AdminParentEditModal> createState() => _AdminParentEditModalState();
}
class _AdminParentEditModalState extends State<AdminParentEditModal> {
late final TextEditingController _nomCtrl;
late final TextEditingController _prenomCtrl;
late final TextEditingController _emailCtrl;
late final TextEditingController _telCtrl;
late final TextEditingController _adresseCtrl;
late final TextEditingController _villeCtrl;
late final TextEditingController _cpCtrl;
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;
/// 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() {
super.initState();
final u = widget.parent.user;
_nomCtrl = TextEditingController(text: u.nom ?? '');
_prenomCtrl = TextEditingController(text: u.prenom ?? '');
_emailCtrl = TextEditingController(text: u.email);
_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);
_childrenScrollCtrl = ScrollController();
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,
]) {
c.dispose();
}
_childrenScrollCtrl.dispose();
super.dispose();
}
void _markDirty() {
if (!_dirty) setState(() => _dirty = true);
}
String _displayStatus(String status) {
switch (status) {
case 'actif':
return 'Actif';
case 'en_attente':
return 'En attente';
case 'suspendu':
return 'Suspendu';
case 'refuse':
return 'Refusé';
default:
return status;
}
}
Future<void> _save() async {
if (!_dirty) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Confirmer'),
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'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _saving = true);
try {
await UserService.updateParentFiche(
parentUserId: widget.parent.user.id,
body: {
'nom': _nomCtrl.text.trim(),
'prenom': _prenomCtrl.text.trim(),
'email': _emailCtrl.text.trim(),
'telephone': normalizePhone(_telCtrl.text),
'adresse': _adresseCtrl.text.trim(),
'ville': _villeCtrl.text.trim(),
'code_postal': _cpCtrl.text.trim(),
'statut': _statut,
},
);
if (!mounted) return;
setState(() {
_dirty = false;
_saving = false;
});
widget.onSaved?.call();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Fiche parent enregistrée')),
);
} catch (e) {
if (!mounted) return;
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
Future<void> _openChild(ParentChildSummary child) async {
try {
final enfant = await UserService.getEnfant(child.id);
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AdminChildDetailModal(
enfant: enfant,
onSaved: _reloadChildren,
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
Future<void> _reloadChildren() async {
try {
final refreshed = await UserService.getParent(widget.parent.user.id);
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 (_) {}
}
Future<void> _detachChild(ParentChildSummary child) async {
final confirmed = await showDialog<bool>(
context: context,
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é.)',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade700,
),
child: const Text('Détacher'),
),
],
),
);
if (confirmed != true || !mounted) return;
try {
await UserService.detachEnfantFromParent(
parentUserId: widget.parent.user.id,
enfantId: child.id,
);
if (!mounted) return;
await _reloadChildren();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enfant détaché')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
Future<void> _attachChild() async {
List<EnfantAdminModel> all;
try {
all = await UserService.getEnfants();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
return;
}
final linkedIds = _children.map((c) => c.id).toSet();
final candidates = all.where((e) => !linkedIds.contains(e.id)).toList();
if (candidates.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Aucun enfant disponible à rattacher')),
);
return;
}
if (!mounted) return;
final selected = await showDialog<EnfantAdminModel>(
context: context,
builder: (ctx) => SimpleDialog(
title: const Text('Rattacher un enfant'),
children: candidates
.map(
(e) => SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, e),
child: Text(e.fullName),
),
)
.toList(),
),
);
if (selected == null || !mounted) return;
try {
await UserService.attachEnfantToParent(
parentUserId: widget.parent.user.id,
enfantId: selected.id,
);
if (!mounted) return;
await _reloadChildren();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enfant rattaché')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
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<String>(
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(
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: Text(
'Aucun enfant rattaché',
style: TextStyle(fontSize: 14, color: Colors.black54),
),
)
: ListView.builder(
controller: _childrenScrollCtrl,
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
itemExtent: _childListItemHeight,
itemCount: _children.length,
itemBuilder: (_, i) {
final c = _children[i];
return AdminEnfantUserCard.fromSummary(
c,
actions: [
IconButton(
icon: const Icon(Icons.visibility_outlined),
tooltip: 'Voir / modifier',
onPressed: () => _openChild(c),
),
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) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
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, 16, 0, 10),
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),
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),
_childrenPanel(),
const SizedBox(height: 16),
_buildFooter(),
],
),
),
],
),
),
);
}
}

View File

@ -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<AdminUserCard> {
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<AdminUserCard> {
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<AdminUserCard> {
),
);
}
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),
),
),
),
);
}
}

View File

@ -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<ValidationLabeledField> fields;
final List<int>? rowLayout;
final Map<int, List<int>>? 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 dun 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<TextInputFormatter>? 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<ValidationLabeledField> fields;
final List<int>? rowLayout;
final Map<int, List<int>>? 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),

View File

@ -0,0 +1,96 @@
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_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).
class EnfantManagementWidget extends StatefulWidget {
final String searchQuery;
final String? statusFilter;
const EnfantManagementWidget({
super.key,
required this.searchQuery,
this.statusFilter,
});
@override
State<EnfantManagementWidget> createState() => _EnfantManagementWidgetState();
}
class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
bool _isLoading = false;
String? _error;
List<EnfantAdminModel> _enfants = [];
@override
void initState() {
super.initState();
_loadEnfants();
}
Future<void> _loadEnfants() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final list = await UserService.getEnfants();
if (!mounted) return;
setState(() {
_enfants = list;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
Future<void> _openEnfant(EnfantAdminModel enfant) async {
await showDialog<void>(
context: context,
builder: (ctx) => AdminChildDetailModal(
enfant: enfant,
onSaved: _loadEnfants,
),
);
}
@override
Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase();
final filtered = _enfants.where((e) {
final matchesName = e.fullName.toLowerCase().contains(query);
final matchesStatus =
widget.statusFilter == null || e.status == widget.statusFilter;
return matchesName && matchesStatus;
}).toList();
return UserList(
isLoading: _isLoading,
error: _error,
isEmpty: filtered.isEmpty,
emptyMessage: 'Aucun enfant trouvé.',
itemCount: filtered.length,
itemBuilder: (context, index) {
final enfant = filtered[index];
return AdminEnfantUserCard.fromEnfant(
enfant,
actions: [
IconButton(
icon: const Icon(Icons.visibility_outlined),
tooltip: 'Voir / modifier',
onPressed: () => _openEnfant(enfant),
),
],
);
},
);
}
}

View File

@ -1,8 +1,7 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
@ -80,7 +79,7 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
avatarUrl: parent.user.photoUrl,
subtitleLines: [
parent.user.email,
'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.childrenCount}',
'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.children.isNotEmpty ? parent.children.length : parent.childrenCount}',
],
actions: [
IconButton(
@ -114,45 +113,10 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
void _openParentDetails(ParentModel parent) {
showDialog<void>(
context: context,
builder: (context) => AdminDetailModal(
title: parent.user.fullName.isEmpty ? 'Parent' : parent.user.fullName,
subtitle: parent.user.email,
fields: [
AdminDetailField(label: 'ID', value: _v(parent.user.id)),
AdminDetailField(
label: 'Statut',
value: _displayStatus(parent.user.statut),
),
AdminDetailField(
label: 'Telephone',
value: _v(parent.user.telephone) != '' ? formatPhoneForDisplay(_v(parent.user.telephone)) : '',
),
AdminDetailField(label: 'Adresse', value: _v(parent.user.adresse)),
AdminDetailField(label: 'Ville', value: _v(parent.user.ville)),
AdminDetailField(
label: 'Code postal',
value: _v(parent.user.codePostal),
),
AdminDetailField(
label: 'Nombre d\'enfants',
value: parent.childrenCount.toString(),
),
],
onEdit: () {
Navigator.of(context).pop();
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Action Modifier a implementer')),
);
},
onDelete: () {
Navigator.of(context).pop();
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Action Supprimer a implementer')),
);
},
builder: (context) => AdminParentEditModal(
parent: parent,
onSaved: _loadParents,
),
);
}
String _v(String? value) => (value == null || value.isEmpty) ? '-' : value;
}

View File

@ -4,6 +4,7 @@ import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart';
import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart';
@ -28,6 +29,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
final TextEditingController _searchController = TextEditingController();
final TextEditingController _amCapacityController = TextEditingController();
String? _parentStatus;
String? _enfantStatus;
bool _hasPending = false;
bool _pendingLoading = true;
@ -80,7 +82,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
}
List<String> get _tabLabels {
const base = ['Parents', 'Assistantes maternelles', 'Gestionnaires'];
const base = ['Parents', 'Enfants', 'Assistantes maternelles', 'Gestionnaires'];
final withAdmin = [...base, 'Administrateurs'];
final list = widget.showAdministrateursTab ? withAdmin : base;
// Onglet « À valider » visible seulement s'il y a des dossiers en attente (ticket #107).
@ -94,11 +96,12 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
_subIndex = index.clamp(0, maxIndex);
_searchController.clear();
_parentStatus = null;
_enfantStatus = null;
_amCapacityController.clear();
});
}
/// Index du contenu : -1 = À valider (si visible), 0 = Parents, 1 = AM, 2 = Gestionnaires, 3 = Admin.
/// Index du contenu : -1 = À valider, 0 = Parents, 1 = Enfants, 2 = AM, 3 = Gestionnaires, 4 = Admin.
int get _contentIndexOffset => (_hasPending && !_pendingLoading) ? 1 : 0;
String _searchHintForTab() {
@ -109,10 +112,12 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
case 0:
return 'Rechercher un parent...';
case 1:
return 'Rechercher une assistante...';
return 'Rechercher un enfant...';
case 2:
return 'Rechercher un gestionnaire...';
return 'Rechercher une assistante...';
case 3:
return 'Rechercher un gestionnaire...';
case 4:
return 'Rechercher un administrateur...';
default:
return 'Rechercher...';
@ -176,6 +181,54 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
}
if (_subIndex == _contentIndexOffset + 1) {
return DropdownButtonHideUnderline(
child: DropdownButton<String?>(
value: _enfantStatus,
isExpanded: true,
hint: const Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Statut', style: TextStyle(fontSize: 12)),
),
items: const [
DropdownMenuItem<String?>(
value: null,
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Tous', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(
value: 'a_naitre',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('À naître', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(
value: 'actif',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Actif', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(
value: 'scolarise',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Scolarisé', style: TextStyle(fontSize: 12)),
),
),
],
onChanged: (value) {
setState(() {
_enfantStatus = value;
});
},
),
);
}
if (_subIndex == _contentIndexOffset + 2) {
return TextField(
controller: _amCapacityController,
decoration: const InputDecoration(
@ -203,16 +256,21 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
statusFilter: _parentStatus,
);
case 1:
return EnfantManagementWidget(
searchQuery: _searchController.text,
statusFilter: _enfantStatus,
);
case 2:
return AssistanteMaternelleManagementWidget(
searchQuery: _searchController.text,
capacityMin: int.tryParse(_amCapacityController.text),
);
case 2:
case 3:
return GestionnaireManagementWidget(
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
searchQuery: _searchController.text,
);
case 3:
case 4:
return AdminManagementWidget(
key: ValueKey('admins-$_adminRefreshTick'),
searchQuery: _searchController.text,
@ -246,7 +304,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
Future<void> _handleAddPressed() async {
final contentIndex = _subIndex - _contentIndexOffset;
if (contentIndex == 2) {
if (contentIndex == 3) {
final created = await showDialog<bool>(
context: context,
barrierDismissible: false,
@ -264,7 +322,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
return;
}
if (contentIndex == 3) {
if (contentIndex == 4) {
final created = await showDialog<bool>(
context: context,
barrierDismissible: false,
@ -289,7 +347,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'La création est disponible pour les gestionnaires et administrateurs.',
'La création parent / enfant / AM sera disponible avec le ticket #129.',
),
),
);

View File

@ -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<ValidationAmWizard> {
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<AdminDetailField> _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<AdminDetailField> _photoProFields(DossierAM d) {
final u = d.user;
@ -110,10 +95,7 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
];
}
static const List<int> _personalRowLayout = [2, 2, 1, 2];
static const Map<int, List<int>> _personalRowFlex = {
3: [2, 5]
}; // Code postal étroit, Ville large
static const List<int> _photoProRowLayout = [2, 2, 2, 2];
/// Proportion photo didentité (35×45 mm).
static const double _idPhotoAspectRatio = 35 / 45;
@ -266,11 +248,10 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
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<ValidationAmWizard> {
child: ValidationDetailSection(
title: 'Dossier professionnel',
fields: _photoProFields(d),
rowLayout: const [2, 2, 2, 2],
rowLayout: _photoProRowLayout,
),
),
);

View File

@ -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<ValidationFamilyWizard> {
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<AdminDetailField> _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<int> _parentRowLayout = [2, 2, 1, 2];
static const Map<int, List<int>> _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<ValidationFamilyWizard> {
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<ValidationFamilyWizard> {
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,
);
}

View File

@ -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<int> rowLayout = [2, 2, 1, 2];
static const Map<int, List<int>> 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!),
),
],
);
}
}