diff --git a/backend/src/routes/enfants/enfants.controller.ts b/backend/src/routes/enfants/enfants.controller.ts index e112972..2579a45 100644 --- a/backend/src/routes/enfants/enfants.controller.ts +++ b/backend/src/routes/enfants/enfants.controller.ts @@ -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, diff --git a/backend/src/routes/enfants/enfants.service.ts b/backend/src/routes/enfants/enfants.service.ts index bfefedb..fb900a1 100644 --- a/backend/src/routes/enfants/enfants.service.ts +++ b/backend/src/routes/enfants/enfants.service.ts @@ -78,10 +78,10 @@ export class EnfantsService { return this.findOne(child.id, currentUser); } - // Liste des enfants + // Liste des enfants (admin/gestionnaire) async findAll(): Promise { return this.childrenRepository.find({ - relations: ['parentLinks'], + relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'], order: { last_name: 'ASC', first_name: 'ASC' }, }); } diff --git a/backend/src/routes/parents/dto/update-parent-fiche-admin.dto.ts b/backend/src/routes/parents/dto/update-parent-fiche-admin.dto.ts new file mode 100644 index 0000000..7d680bd --- /dev/null +++ b/backend/src/routes/parents/dto/update-parent-fiche-admin.dto.ts @@ -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; +} diff --git a/backend/src/routes/parents/parents.controller.ts b/backend/src/routes/parents/parents.controller.ts index 261091f..6f44bee 100644 --- a/backend/src/routes/parents/parents.controller.ts +++ b/backend/src/routes/parents/parents.controller.ts @@ -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 { + 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 { + 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 { + return this.parentsService.detachEnfant(id, enfantId); + } + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) @Patch(':id') @ApiBody({ type: UpdateParentsDto }) diff --git a/backend/src/routes/parents/parents.module.ts b/backend/src/routes/parents/parents.module.ts index 7506259..b746e87 100644 --- a/backend/src/routes/parents/parents.module.ts +++ b/backend/src/routes/parents/parents.module.ts @@ -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], diff --git a/backend/src/routes/parents/parents.service.ts b/backend/src/routes/parents/parents.service.ts index 2e06914..2696593 100644 --- a/backend/src/routes/parents/parents.service.ts +++ b/backend/src/routes/parents/parents.service.ts @@ -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, @InjectRepository(DossierFamille) private readonly dossierFamilleRepository: Repository, + @InjectRepository(ParentsChildren) + private readonly parentsChildrenRepository: Repository, ) {} // Création d’un parent @@ -62,7 +66,7 @@ export class ParentsService { // Liste des parents async findAll(): Promise { 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 { 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 { + 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 { + 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 { + 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). diff --git a/docs/00_INDEX.md b/docs/00_INDEX.md index 2f8a601..7e5b9f6 100644 --- a/docs/00_INDEX.md +++ b/docs/00_INDEX.md @@ -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 diff --git a/docs/24_DECISIONS-PROJET.md b/docs/24_DECISIONS-PROJET.md index 62bcbe5..cf38376 100644 --- a/docs/24_DECISIONS-PROJET.md +++ b/docs/24_DECISIONS-PROJET.md @@ -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é diff --git a/docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md b/docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md new file mode 100644 index 0000000..b8cbfbe --- /dev/null +++ b/docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md @@ -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).* diff --git a/docs/EVOLUTIONS_CDC.md b/docs/EVOLUTIONS_CDC.md index 6ee6d1e..98b57b6 100644 --- a/docs/EVOLUTIONS_CDC.md +++ b/docs/EVOLUTIONS_CDC.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" @@ -275,4 +277,29 @@ Pour chaque évolution identifiée, ce document suivra la structure suivante : - Évolution du modèle de données vers un RBAC intra-RPE. - 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. \ No newline at end of file +- 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). \ No newline at end of file diff --git a/frontend/lib/models/enfant_admin_model.dart b/frontend/lib/models/enfant_admin_model.dart new file mode 100644 index 0000000..2dace4b --- /dev/null +++ b/frontend/lib/models/enfant_admin_model.dart @@ -0,0 +1,106 @@ +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 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 json) { + final linksRaw = json['parentLinks'] as List?; + final links = []; + if (linksRaw != null) { + for (final item in linksRaw) { + if (item is Map) { + 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 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 json) { + final parent = json['parent']; + String? name; + if (parent is Map) { + final user = parent['user']; + if (user is Map) { + final u = AppUser.fromJson(user); + name = u.fullName.isNotEmpty ? u.fullName : u.email; + } + } + return EnfantParentLink( + parentId: (json['parentId'] ?? json['id_parent'] ?? '').toString(), + parentName: name, + ); + } +} diff --git a/frontend/lib/models/parent_child_summary.dart b/frontend/lib/models/parent_child_summary.dart new file mode 100644 index 0000000..d235694 --- /dev/null +++ b/frontend/lib/models/parent_child_summary.dart @@ -0,0 +1,31 @@ +/// Résumé enfant affiché dans la fiche parent (dashboard admin). +class ParentChildSummary { + final String id; + final String? firstName; + final String? lastName; + final String status; + + ParentChildSummary({ + required this.id, + this.firstName, + this.lastName, + required this.status, + }); + + 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 json) { + return ParentChildSummary( + id: (json['id'] ?? '').toString(), + firstName: json['first_name'] as String?, + lastName: json['last_name'] as String?, + status: (json['status'] ?? '').toString(), + ); + } +} diff --git a/frontend/lib/models/parent_model.dart b/frontend/lib/models/parent_model.dart index 5b893fd..54b4976 100644 --- a/frontend/lib/models/parent_model.dart +++ b/frontend/lib/models/parent_model.dart @@ -1,18 +1,40 @@ +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 children; - ParentModel({required this.user, this.childrenCount = 0}); + ParentModel({ + required this.user, + this.childrenCount = 0, + this.children = const [], + }); factory ParentModel.fromJson(Map json) { - final userJson = json['user'] ?? json; + final userJson = Map.from(json['user'] ?? json); + if (json['numero_dossier'] != null && userJson['numero_dossier'] == null) { + userJson['numero_dossier'] = json['numero_dossier']; + } final user = AppUser.fromJson(userJson); - final children = json['parentChildren'] as List?; + + final children = []; + final links = json['parentChildren'] as List?; + if (links != null) { + for (final link in links) { + if (link is Map && link['child'] is Map) { + children.add( + ParentChildSummary.fromJson(link['child'] as Map), + ); + } + } + } + return ParentModel( user: user, - childrenCount: children?.length ?? 0, + childrenCount: children.isNotEmpty ? children.length : (links?.length ?? 0), + children: children, ); } } diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index da671bc..30508a4 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -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'; diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index 533af83..a98e24e 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -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'; @@ -369,6 +370,123 @@ class UserService { return data.map((e) => ParentModel.fromJson(e)).toList(); } + static Future 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?; + throw Exception(_toStr(err?['message']) ?? 'Erreur chargement parent'); + } + return ParentModel.fromJson(jsonDecode(response.body) as Map); + } + + static Future updateParentFiche({ + required String parentUserId, + required Map 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 ParentModel.fromJson(jsonDecode(response.body) as Map); + } + + static Future 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 ParentModel.fromJson(jsonDecode(response.body) as Map); + } + + static Future 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 ParentModel.fromJson(jsonDecode(response.body) as Map); + } + + static Future> 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 data = jsonDecode(response.body); + return data + .whereType>() + .map(EnfantAdminModel.fromJson) + .toList(); + } + + static Future 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); + } + + static Future updateEnfant({ + required String enfantId, + required Map 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); + } + + static String _extractErrorMessage(String body, String fallback) { + try { + final decoded = jsonDecode(body); + if (decoded is Map) { + 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 static Future> getAssistantesMaternelles() async { diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart new file mode 100644 index 0000000..46e9584 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -0,0 +1,289 @@ +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 createState() => _AdminChildDetailModalState(); +} + +class _AdminChildDetailModalState extends State { + 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']; + + @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 = _genders.contains(e.gender) ? e.gender! : 'H'; + _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(); + } + + void _markDirty() { + if (!_dirty) setState(() => _dirty = true); + } + + Future _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: ', ''))), + ); + } + } + + @override + Widget build(BuildContext context) { + final parents = widget.enfant.parentLinks + .map((l) => l.parentName ?? l.parentId) + .where((s) => s.isNotEmpty) + .join(', '); + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560, maxHeight: 640), + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + widget.enfant.fullName, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700), + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + if (parents.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text('Responsables : $parents', style: const TextStyle(color: Colors.black54)), + ), + const Divider(), + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + _rowField('Prénom', TextField(controller: _prenomCtrl, decoration: _decoration())), + _rowField('Nom', TextField(controller: _nomCtrl, decoration: _decoration())), + _rowDropdown( + 'Statut', + _status, + _statuses.map((s) => MapEntry(s, _statusLabel(s))).toList(), + (v) => setState(() { + _status = v; + _dirty = true; + }), + ), + _rowDropdown( + 'Genre', + _gender, + _genders.map((g) => MapEntry(g, g)).toList(), + (v) => setState(() { + _gender = v; + _dirty = true; + }), + ), + _rowField( + 'Date naissance', + TextField( + controller: _birthCtrl, + decoration: _decoration(hint: 'AAAA-MM-JJ'), + ), + ), + _rowField( + 'Date prévue', + TextField( + controller: _dueCtrl, + decoration: _decoration(hint: 'AAAA-MM-JJ'), + ), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Consentement photo'), + value: _consentPhoto, + onChanged: (v) => setState(() { + _consentPhoto = v; + _dirty = true; + }), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Naissance multiple'), + value: _isMultiple, + onChanged: (v) => setState(() { + _isMultiple = v; + _dirty = true; + }), + ), + ], + ), + ), + ), + 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); + } + + Widget _rowField(String label, Widget field) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Padding( + padding: const EdgeInsets.only(top: 12), + child: Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), + ), + ), + Expanded(child: field), + ], + ), + ); + } + + Widget _rowDropdown( + String label, + String value, + List> items, + ValueChanged onChanged, + ) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + SizedBox( + width: 120, + child: Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), + ), + Expanded( + child: DropdownButtonFormField( + value: items.any((e) => e.key == value) ? value : items.first.key, + decoration: _decoration(), + items: items + .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) + .toList(), + onChanged: (v) { + if (v != null) onChanged(v); + }, + ), + ), + ], + ), + ); + } + + String _statusLabel(String status) { + switch (status) { + case 'a_naitre': + return 'À naître'; + case 'actif': + return 'Actif'; + case 'scolarise': + return 'Scolarisé'; + default: + return status; + } + } +} diff --git a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart new file mode 100644 index 0000000..babe468 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart @@ -0,0 +1,477 @@ +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/models/parent_model.dart'; +import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; + +/// Fiche parent éditable (doc 28 §6.1, tickets #131 / #138). +class AdminParentEditModal extends StatefulWidget { + final ParentModel parent; + final VoidCallback? onSaved; + + const AdminParentEditModal({ + super.key, + required this.parent, + this.onSaved, + }); + + @override + State createState() => _AdminParentEditModalState(); +} + +class _AdminParentEditModalState extends State { + 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 _children; + bool _saving = false; + bool _dirty = false; + + static const _statuts = ['actif', 'en_attente', 'suspendu', 'refuse']; + + @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: 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); + for (final c in [_nomCtrl, _prenomCtrl, _emailCtrl, _telCtrl, _adresseCtrl, _villeCtrl, _cpCtrl]) { + c.addListener(_markDirty); + } + } + + @override + void dispose() { + for (final c in [_nomCtrl, _prenomCtrl, _emailCtrl, _telCtrl, _adresseCtrl, _villeCtrl, _cpCtrl]) { + c.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 _save() async { + if (!_dirty) return; + final confirmed = await showDialog( + 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': _telCtrl.text.trim(), + '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 _openChild(ParentChildSummary child) async { + try { + final enfant = await UserService.getEnfant(child.id); + if (!mounted) return; + await showDialog( + context: context, + builder: (ctx) => AdminChildDetailModal( + enfant: enfant, + onSaved: () async { + await _reloadChildren(); + }, + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + + Future _reloadChildren() async { + try { + final refreshed = await UserService.getParent(widget.parent.user.id); + if (!mounted) return; + setState(() => _children = List.of(refreshed.children)); + } catch (_) {} + } + + Future _detachChild(ParentChildSummary child) async { + final confirmed = await showDialog( + 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 { + final updated = await UserService.detachEnfantFromParent( + parentUserId: widget.parent.user.id, + enfantId: child.id, + ); + if (!mounted) return; + setState(() => _children = List.of(updated.children)); + 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 _attachChild() async { + List 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( + 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 { + final updated = await UserService.attachEnfantToParent( + parentUserId: widget.parent.user.id, + enfantId: selected.id, + ); + if (!mounted) return; + setState(() => _children = List.of(updated.children)); + 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 _field(String label, TextEditingController ctrl, {TextInputType? keyboard}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 130, + child: Padding( + padding: const EdgeInsets.only(top: 12), + child: Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), + ), + ), + Expanded( + child: TextFormField( + controller: ctrl, + keyboardType: keyboard, + decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final numero = widget.parent.user.numeroDossier; + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 680, maxHeight: 720), + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.parent.user.fullName.isEmpty + ? 'Parent' + : widget.parent.user.fullName, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700), + ), + Text(widget.parent.user.email, style: const TextStyle(color: Colors.black54)), + ], + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + const Divider(), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (numero != null && numero.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + const SizedBox( + width: 130, + child: Text('N° dossier', style: TextStyle(fontWeight: FontWeight.w600)), + ), + Expanded(child: Text(numero)), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + const SizedBox( + width: 130, + child: Text('Statut', style: TextStyle(fontWeight: FontWeight.w600)), + ), + Expanded( + child: DropdownButtonFormField( + value: _statuts.contains(_statut) ? _statut : _statuts.first, + decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()), + items: _statuts + .map( + (s) => DropdownMenuItem( + value: s, + child: Text(_displayStatus(s)), + ), + ) + .toList(), + onChanged: (v) { + if (v == null) return; + setState(() { + _statut = v; + _dirty = true; + }); + }, + ), + ), + ], + ), + ), + _field('Nom', _nomCtrl), + _field('Prénom', _prenomCtrl), + _field('Email', _emailCtrl, keyboard: TextInputType.emailAddress), + _field('Téléphone', _telCtrl, keyboard: TextInputType.phone), + if (_telCtrl.text.isNotEmpty) + Padding( + padding: const EdgeInsets.only(left: 130, bottom: 4), + child: Text( + formatPhoneForDisplay(_telCtrl.text), + style: const TextStyle(fontSize: 12, color: Colors.black54), + ), + ), + _field('Adresse', _adresseCtrl), + _field('Ville', _villeCtrl), + _field('Code postal', _cpCtrl), + const SizedBox(height: 12), + Text( + 'Nombre d\'enfants : ${_children.length}', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.all(8), + child: _children.isEmpty + ? const Text('Aucun enfant rattaché', style: TextStyle(color: Colors.black54)) + : Column( + children: _children + .map( + (c) => ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: InkWell( + onTap: () => _openChild(c), + child: Text( + c.fullName, + style: const TextStyle( + decoration: TextDecoration.underline, + color: Colors.blue, + ), + ), + ), + subtitle: Text(_childStatusLabel(c.status)), + trailing: IconButton( + tooltip: 'Détacher', + icon: Icon(Icons.link_off, color: Colors.orange.shade800), + onPressed: () => _detachChild(c), + ), + ), + ) + .toList(), + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: _attachChild, + icon: const Icon(Icons.link), + label: const Text('Rattacher un enfant'), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: _saving ? null : () => 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: Text(_dirty ? 'Sauvegarder' : 'Aucune modification'), + ), + ], + ), + ], + ), + ), + ), + ); + } + + String _childStatusLabel(String status) { + switch (status) { + case 'a_naitre': + return 'À naître'; + case 'actif': + return 'Actif'; + case 'scolarise': + return 'Scolarisé'; + default: + return status; + } + } +} diff --git a/frontend/lib/widgets/admin/enfant_management_widget.dart b/frontend/lib/widgets/admin/enfant_management_widget.dart new file mode 100644 index 0000000..3353897 --- /dev/null +++ b/frontend/lib/widgets/admin/enfant_management_widget.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/enfant_admin_model.dart'; +import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/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 createState() => _EnfantManagementWidgetState(); +} + +class _EnfantManagementWidgetState extends State { + bool _isLoading = false; + String? _error; + List _enfants = []; + + @override + void initState() { + super.initState(); + _loadEnfants(); + } + + Future _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; + }); + } + } + + String _statusLabel(String status) { + switch (status) { + case 'a_naitre': + return 'À naître'; + case 'actif': + return 'Actif'; + case 'scolarise': + return 'Scolarisé'; + default: + return status; + } + } + + Future _openEnfant(EnfantAdminModel enfant) async { + await showDialog( + 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]; + final parents = enfant.parentLinks + .map((l) => l.parentName ?? 'Parent') + .join(', '); + return AdminUserCard( + title: enfant.fullName, + fallbackIcon: Icons.child_care_outlined, + avatarUrl: enfant.photoUrl, + subtitleLines: [ + 'Statut : ${_statusLabel(enfant.status)}', + if (enfant.birthDate != null && enfant.birthDate!.isNotEmpty) + 'Naissance : ${enfant.birthDate}', + if (parents.isNotEmpty) 'Responsables : $parents', + ], + actions: [ + IconButton( + icon: const Icon(Icons.visibility_outlined), + tooltip: 'Voir / modifier', + onPressed: () => _openEnfant(enfant), + ), + ], + ); + }, + ); + } +} diff --git a/frontend/lib/widgets/admin/parent_managmant_widget.dart b/frontend/lib/widgets/admin/parent_managmant_widget.dart index 6e7e32d..becbd6c 100644 --- a/frontend/lib/widgets/admin/parent_managmant_widget.dart +++ b/frontend/lib/widgets/admin/parent_managmant_widget.dart @@ -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 { 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 { void _openParentDetails(ParentModel parent) { showDialog( 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; } diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index 83c12de..51cc207 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -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 { 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 { } List 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 { _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 { 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 { } if (_subIndex == _contentIndexOffset + 1) { + return DropdownButtonHideUnderline( + child: DropdownButton( + value: _enfantStatus, + isExpanded: true, + hint: const Padding( + padding: EdgeInsets.only(left: 10), + child: Text('Statut', style: TextStyle(fontSize: 12)), + ), + items: const [ + DropdownMenuItem( + value: null, + child: Padding( + padding: EdgeInsets.only(left: 10), + child: Text('Tous', style: TextStyle(fontSize: 12)), + ), + ), + DropdownMenuItem( + value: 'a_naitre', + child: Padding( + padding: EdgeInsets.only(left: 10), + child: Text('À naître', style: TextStyle(fontSize: 12)), + ), + ), + DropdownMenuItem( + value: 'actif', + child: Padding( + padding: EdgeInsets.only(left: 10), + child: Text('Actif', style: TextStyle(fontSize: 12)), + ), + ), + DropdownMenuItem( + 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 { 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 { Future _handleAddPressed() async { final contentIndex = _subIndex - _contentIndexOffset; - if (contentIndex == 2) { + if (contentIndex == 3) { final created = await showDialog( context: context, barrierDismissible: false, @@ -264,7 +322,7 @@ class _UserManagementPanelState extends State { return; } - if (contentIndex == 3) { + if (contentIndex == 4) { final created = await showDialog( context: context, barrierDismissible: false, @@ -289,7 +347,7 @@ class _UserManagementPanelState extends State { 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.', ), ), );