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:
commit
d55f240f56
153
backend/scripts/update-gitea-issue-140-ch6-famille.js
Normal file
153
backend/scripts/update-gitea-issue-140-ch6-famille.js
Normal file
@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Met à jour l'issue Gitea #140 — epic dashboard admin ch.6 famille.
|
||||
* Usage: node backend/scripts/update-gitea-issue-140-ch6-famille.js
|
||||
* Token : .gitea-token (racine), GITEA_TOKEN, ou docs/27_BRIEFING-FRONTEND.md
|
||||
*/
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.join(__dirname, '../..');
|
||||
let token = process.env.GITEA_TOKEN;
|
||||
if (!token) {
|
||||
try {
|
||||
const tokenFile = path.join(repoRoot, '.gitea-token');
|
||||
if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!token) {
|
||||
try {
|
||||
const briefing = fs.readFileSync(
|
||||
path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'),
|
||||
'utf8',
|
||||
);
|
||||
const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/);
|
||||
if (m) token = m[1].trim();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!token) {
|
||||
console.error('Token non trouvé : .gitea-token ou GITEA_TOKEN (voir docs/26_GITEA-API.md)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const body = `## Rôle de ce ticket
|
||||
|
||||
**#140 est un ticket epic / livraison** : il regroupe la mise en œuvre du **chapitre 6** du doc [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](../docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) (§6.1 et §6.2) sur la branche \`feature/140-dashboard-admin-ch6-famille\`.
|
||||
|
||||
Il **ne remplace pas** les tickets détaillés ci-dessous : il sert de **fil de livraison** (PR, recette, fermeture coordonnée). Chaque sous-ticket garde son périmètre propre ; #140 est clos quand l'ensemble est **fonctionnel et homogène en UI**.
|
||||
|
||||
---
|
||||
|
||||
## Tickets couverts (périmètres embarqués)
|
||||
|
||||
| Ticket | Sujet | Rôle dans #140 |
|
||||
|--------|--------|----------------|
|
||||
| **#115** | Rattachement parent — **backend** | API \`POST/DELETE …/enfants/:enfantId\` |
|
||||
| **#116** | Rattachement parent — **frontend** | UI rattacher / détacher depuis la fiche parent |
|
||||
| **#130** | \`UserService\` — APIs admin | Appels \`getParents\`, \`getParent\`, \`getEnfants\`, \`updateParentFiche\`, etc. |
|
||||
| **#131** | Fiche parent éditable | Modale parent (dashboard) — *hors fiche AM* |
|
||||
| **#137** | Onglet **Enfants** | Liste globale admin + accès fiche enfant |
|
||||
| **#138** | Fiche enfant + liste dans fiche parent | \`AdminChildDetailModal\` + liste enfants en bas de fiche parent |
|
||||
|
||||
> **Note :** fermer #140 peut entraîner la fermeture **partielle ou totale** de ces tickets selon ce qui est réellement livré et recetté dans la PR.
|
||||
|
||||
---
|
||||
|
||||
## Hors scope #140
|
||||
|
||||
- **§6.3** — Création dossier admin sans numéro → **#129**
|
||||
- Fiche **AM** éditable (dashboard) → reste **#131** (partie AM)
|
||||
- Parcours gestionnaire « famille complexe » → **#139**
|
||||
- Qualification responsable légal (combobox sur lien) → ticket à créer
|
||||
|
||||
---
|
||||
|
||||
## Backend (attendu / livré)
|
||||
|
||||
- [x] \`PATCH /parents/:id/fiche\` — édition fiche parent (admin/gestionnaire)
|
||||
- [x] \`POST /parents/:id/enfants/:enfantId\` — rattacher un enfant existant
|
||||
- [x] \`DELETE /parents/:id/enfants/:enfantId\` — détacher (garde-fou : ≥1 responsable / enfant)
|
||||
- [x] \`GET /enfants\` enrichi ; \`PATCH /enfants/:id\` pour gestionnaire
|
||||
|
||||
---
|
||||
|
||||
## Frontend — fait
|
||||
|
||||
- [x] Modale **fiche parent** éditable (shell aligné validation, statut gélule, téléphone formaté, \`IdentityBlock\`)
|
||||
- [x] Onglet **Enfants** — liste globale (\`EnfantManagementWidget\`)
|
||||
- [x] Liste enfants dans fiche parent — cartes (photo, nom, âge ans/mois, statut), cadre blanc, scroll 2,5 lignes
|
||||
- [x] Rattacher / détacher un enfant **existant** (API branchée)
|
||||
- [x] Parsing robuste \`parentChildren\` + URLs médias \`/uploads\` en Flutter web
|
||||
- [x] \`UserService\` — APIs parents / enfants / affiliation
|
||||
|
||||
---
|
||||
|
||||
## Frontend — reste à faire (bloquant clôture)
|
||||
|
||||
### Fiche enfant — #138 (UI)
|
||||
- [ ] Reprendre \`AdminChildDetailModal\` : même **look & feel** que fiche parent / modales validation (largeur ~930 px, grille champs, photo enfant)
|
||||
- [ ] Aligner dates, genre, statut sur les wizards validation famille
|
||||
|
||||
### Modale **rattacher** un enfant — #116 (UI)
|
||||
- [ ] Remplacer le \`SimpleDialog\` actuel par une modale cohérente : liste type \`AdminEnfantUserCard\` (photo, nom, âge), recherche éventuelle
|
||||
|
||||
### Création d'un **nouvel** enfant depuis la fiche parent — doc §6.2
|
||||
- [ ] **Non implémenté** aujourd'hui (seul le rattachement d'un enfant déjà en base existe)
|
||||
- [ ] À trancher : inclus dans #140 si le back expose un \`POST\` admin depuis le contexte parent, sinon ticket dédié / extension #129
|
||||
|
||||
---
|
||||
|
||||
## Recette avant merge
|
||||
|
||||
1. Parent avec 0 / 1 / 3+ enfants — liste, scroll, compteur
|
||||
2. Rattacher puis détacher (message si dernier responsable)
|
||||
3. Clic enfant → fiche enfant (après refonte UI)
|
||||
4. Onglet Enfants — même rendu cartes
|
||||
5. Avatars photos (URLs absolues web)
|
||||
|
||||
---
|
||||
|
||||
## Branche
|
||||
|
||||
\`feature/140-dashboard-admin-ch6-famille\`
|
||||
|
||||
## Références
|
||||
|
||||
- Doc produit : \`docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md\` §6.1, §6.2`;
|
||||
|
||||
const payload = JSON.stringify({ body });
|
||||
|
||||
const req = https.request(
|
||||
{
|
||||
hostname: 'git.ptits-pas.fr',
|
||||
path: '/api/v1/repos/jmartin/petitspas/issues/140',
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
const json = JSON.parse(data);
|
||||
console.log('Issue #140 mise à jour :', json.html_url);
|
||||
console.log('updated_at:', json.updated_at);
|
||||
} else {
|
||||
console.error('Erreur', res.statusCode, data);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('error', (err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
req.write(payload);
|
||||
req.end();
|
||||
@ -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,
|
||||
|
||||
@ -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' },
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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 })
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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 d’un 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).
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 responsable–enfant.
|
||||
|
||||
**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é
|
||||
|
||||
|
||||
331
docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md
Normal file
331
docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md
Normal 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 parent–enfant, 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 aujourd’hui une **aide forte** pour les cas simples (un couple, N enfants, une motivation), mais il tend à devenir **l’identifiant 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** n’est pas parent de β ; **B** n’est pas parent de α.
|
||||
|
||||
### 3.2 Autres cas couverts par la même limitation
|
||||
|
||||
Les exemples « maman / papa » sont **illustratifs**. La limitation s’applique **à 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 l’app.
|
||||
- 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 l’email de P (titulaire ou co-parent), une **2ᵉ inscription publique** où l’on 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 d’un **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 s’appuyant 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 d’un 2ᵉ email pour M).
|
||||
- **A** et **B** ne voient **que** l’enfant qui leur est rattaché via `enfants_parents` — pas de fusion « famille » qui mélange A et B.
|
||||
- Le parcours n’est **pas** proposé à l’inscription 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 » aujourd’hui, 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.2–4.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 l’ouverture (pas lecture seule + « Modifier » factice).
|
||||
- **Retirer l’ID** 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 l’affiliation** ≠ modifier le téléphone : gestion des **liens** (détacher / rattacher), avec garde-fous :
|
||||
- ne pas supprimer l’enfant 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 l’interface ; 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
|
||||
|
||||
| Aujourd’hui | 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. L’approche B maximise l’UX 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 d’autres 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 » d’un 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 l’utilisateur 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 l’unité.
|
||||
- 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.
|
||||
> S’applique à **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).*
|
||||
@ -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 responsable–enfant » (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).
|
||||
108
frontend/lib/models/enfant_admin_model.dart
Normal file
108
frontend/lib/models/enfant_admin_model.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
89
frontend/lib/models/parent_child_summary.dart
Normal file
89
frontend/lib/models/parent_child_summary.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)}';
|
||||
}
|
||||
}
|
||||
|
||||
416
frontend/lib/widgets/admin/common/admin_child_detail_modal.dart
Normal file
416
frontend/lib/widgets/admin/common/admin_child_detail_modal.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
107
frontend/lib/widgets/admin/common/admin_enfant_user_card.dart
Normal file
107
frontend/lib/widgets/admin/common/admin_enfant_user_card.dart
Normal file
@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
||||
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||
|
||||
String enfantStatusLabel(String status) {
|
||||
switch (status) {
|
||||
case 'a_naitre':
|
||||
return 'À naître';
|
||||
case 'actif':
|
||||
return 'Actif';
|
||||
case 'scolarise':
|
||||
return 'Scolarisé';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
List<String> enfantAdminSubtitleLines({
|
||||
required String status,
|
||||
String? birthDate,
|
||||
String? dueDate,
|
||||
List<String> extra = const [],
|
||||
}) {
|
||||
final lines = <String>[];
|
||||
final age = formatChildAgeLabel(
|
||||
birthDate: birthDate,
|
||||
dueDate: dueDate,
|
||||
status: status,
|
||||
);
|
||||
if (age.isNotEmpty) lines.add(age);
|
||||
if (status.isNotEmpty) {
|
||||
lines.add('Statut : ${enfantStatusLabel(status)}');
|
||||
}
|
||||
lines.addAll(extra);
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// Carte enfant admin (photo, nom, âge) — même rendu onglet Enfants / fiche parent.
|
||||
class AdminEnfantUserCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String? photoUrl;
|
||||
final List<String> subtitleLines;
|
||||
final List<Widget> actions;
|
||||
|
||||
const AdminEnfantUserCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.photoUrl,
|
||||
required this.subtitleLines,
|
||||
this.actions = const [],
|
||||
});
|
||||
|
||||
factory AdminEnfantUserCard.fromEnfant(
|
||||
EnfantAdminModel enfant, {
|
||||
List<String> extraSubtitleLines = const [],
|
||||
List<Widget> actions = const [],
|
||||
}) {
|
||||
final parents = enfant.parentLinks
|
||||
.map((l) => l.parentName ?? 'Parent')
|
||||
.join(', ');
|
||||
return AdminEnfantUserCard(
|
||||
title: enfant.fullName,
|
||||
photoUrl: enfant.photoUrl,
|
||||
subtitleLines: enfantAdminSubtitleLines(
|
||||
status: enfant.status,
|
||||
birthDate: enfant.birthDate,
|
||||
dueDate: enfant.dueDate,
|
||||
extra: [
|
||||
if (parents.isNotEmpty) 'Responsables : $parents',
|
||||
...extraSubtitleLines,
|
||||
],
|
||||
),
|
||||
actions: actions,
|
||||
);
|
||||
}
|
||||
|
||||
factory AdminEnfantUserCard.fromSummary(
|
||||
ParentChildSummary child, {
|
||||
List<String> extraSubtitleLines = const [],
|
||||
List<Widget> actions = const [],
|
||||
}) {
|
||||
return AdminEnfantUserCard(
|
||||
title: child.fullName,
|
||||
photoUrl: child.photoUrl,
|
||||
subtitleLines: enfantAdminSubtitleLines(
|
||||
status: child.status,
|
||||
birthDate: child.birthDate,
|
||||
dueDate: child.dueDate,
|
||||
extra: extraSubtitleLines,
|
||||
),
|
||||
actions: actions,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AdminUserCard(
|
||||
title: title,
|
||||
fallbackIcon: Icons.child_care_outlined,
|
||||
avatarUrl: photoUrl,
|
||||
subtitleLines: subtitleLines,
|
||||
actions: actions,
|
||||
);
|
||||
}
|
||||
}
|
||||
495
frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart
Normal file
495
frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart
Normal 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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 d’un champ (même typo que [ValidationDetailSection]).
|
||||
class ValidationLabeledField extends StatelessWidget {
|
||||
final String label;
|
||||
final Widget field;
|
||||
|
||||
const ValidationLabeledField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.field,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
field.label,
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
@ -96,13 +178,66 @@ class ValidationDetailSection extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ValidationReadOnlyField(value: field.value),
|
||||
field,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Champ texte en lecture seule, style formulaire (fond gris léger, bordure). Réutilisable en éditable plus tard.
|
||||
/// Champ texte éditable, même rendu que [ValidationReadOnlyField].
|
||||
class ValidationEditableField extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final TextInputType keyboardType;
|
||||
final List<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),
|
||||
|
||||
96
frontend/lib/widgets/admin/enfant_management_widget.dart
Normal file
96
frontend/lib/widgets/admin/enfant_management_widget.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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.',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -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 d’identité (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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
278
frontend/lib/widgets/common/identity_block.dart
Normal file
278
frontend/lib/widgets/common/identity_block.dart
Normal 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!),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user