From 003fe6b7625ebeee2d2544af4fb1dcd2a8ec9aef Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Sat, 11 Jul 2026 22:49:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(#131):=20fiches=20parent/AM=20=C3=A9ditabl?= =?UTF-8?q?e,=20placement=20AM=E2=86=94enfant,=20statuts=20garde/sans=5Fga?= =?UTF-8?q?rde?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squash merge develop → master. - Fiche parent éditable (co-parent, PATCH fiche, GET /parents) - Fiche AM 3 onglets (PATCH fiche, rattacher/détacher enfants) - Table enfants_assistantes_maternelles + enum garde/sans_garde - Migration SQL + BDD.sql canonique - Correctifs recette : @Get() parents, DTO fiche AM, fix NIR Co-authored-by: Cursor --- backend/scripts/close-gitea-issue-131.js | 123 +++ .../update-gitea-issue-140-ch6-famille.js | 153 ++++ .../utils/sanitize-user-for-api.spec.ts | 32 + .../src/common/utils/sanitize-user-for-api.ts | 23 + backend/src/entities/am_children.entity.ts | 47 + .../assistantes_maternelles.entity.ts | 6 +- backend/src/entities/children.entity.ts | 7 +- .../assistantes_maternelles.controller.ts | 78 +- .../assistantes_maternelles.mapper.ts | 28 + .../assistantes_maternelles.module.ts | 4 +- .../assistantes_maternelles.service.ts | 205 ++++- .../dto/update-am-fiche-admin.dto.ts | 126 +++ backend/src/routes/auth/auth.service.spec.ts | 2 +- backend/src/routes/auth/auth.service.ts | 4 +- .../routes/enfants/dto/create_enfants.dto.ts | 2 +- .../src/routes/enfants/enfants.controller.ts | 7 +- backend/src/routes/enfants/enfants.service.ts | 6 +- .../dto/update-parent-fiche-admin.dto.ts | 57 ++ .../src/routes/parents/parents.controller.ts | 71 +- .../src/routes/parents/parents.mapper.spec.ts | 38 + backend/src/routes/parents/parents.mapper.ts | 18 + backend/src/routes/parents/parents.module.ts | 3 +- backend/src/routes/parents/parents.service.ts | 81 +- database/BDD.sql | 228 ++--- database/README.md | 11 +- database/bdd/data_test/enfants.csv | 20 +- database/docker-compose.dev.yml | 5 +- database/docs/ENUMS.md | 9 +- database/docs/FK_POLICIES.md | 3 + .../2026_enfants_assistantes_maternelles.sql | 36 + database/seed/02_seed.sql | 4 +- database/seed/03_seed_test_data.sql | 30 +- docs/00_INDEX.md | 5 +- docs/24_DECISIONS-PROJET.md | 32 +- docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md | 331 +++++++ docs/EVOLUTIONS_CDC.md | 29 +- .../131-fiche-parent-co-parent-header.md | 127 +++ .../TEMP_112-back-reprise-alignement-front.md | 244 +++++ .../TEMP_131-back-fiche-am-enfants.md | 132 +++ .../TEMP_131-back-fiche-parent-co-parent.md | 124 +++ docs/serveur_ptitspas.code-workspace | 7 + .../models/assistante_maternelle_model.dart | 84 +- frontend/lib/models/dossier_unifie.dart | 3 +- frontend/lib/models/enfant_admin_model.dart | 109 +++ frontend/lib/models/parent_child_summary.dart | 92 ++ frontend/lib/models/parent_model.dart | 55 +- frontend/lib/services/api/api_config.dart | 1 + frontend/lib/services/user_service.dart | 280 +++++- frontend/lib/utils/am_vigilance.dart | 45 + frontend/lib/utils/date_display_utils.dart | 87 ++ frontend/lib/utils/enfant_status_utils.dart | 51 ++ ...sistante_maternelle_management_widget.dart | 57 +- .../admin_am_children_capacity_grid.dart | 301 +++++++ .../admin/common/admin_am_edit_modal.dart | 841 ++++++++++++++++++ .../admin/common/admin_am_photo_frame.dart | 115 +++ .../common/admin_child_detail_modal.dart | 411 +++++++++ .../admin_children_affiliation_panel.dart | 84 ++ .../admin/common/admin_enfant_user_card.dart | 98 ++ .../admin/common/admin_parent_edit_modal.dart | 462 ++++++++++ .../admin/common/admin_status_capsule.dart | 66 ++ .../widgets/admin/common/admin_user_card.dart | 62 +- .../common/validation_detail_section.dart | 314 ++++++- .../admin/enfant_management_widget.dart | 98 ++ .../admin/parent_managmant_widget.dart | 46 +- .../widgets/admin/user_management_panel.dart | 83 +- .../widgets/admin/validation_am_wizard.dart | 31 +- .../admin/validation_family_wizard.dart | 51 +- .../widgets/common/auth_network_image.dart | 4 + .../lib/widgets/common/identity_block.dart | 278 ++++++ 69 files changed, 6290 insertions(+), 417 deletions(-) create mode 100644 backend/scripts/close-gitea-issue-131.js create mode 100644 backend/scripts/update-gitea-issue-140-ch6-famille.js create mode 100644 backend/src/common/utils/sanitize-user-for-api.spec.ts create mode 100644 backend/src/common/utils/sanitize-user-for-api.ts create mode 100644 backend/src/entities/am_children.entity.ts create mode 100644 backend/src/routes/assistantes_maternelles/assistantes_maternelles.mapper.ts create mode 100644 backend/src/routes/assistantes_maternelles/dto/update-am-fiche-admin.dto.ts create mode 100644 backend/src/routes/parents/dto/update-parent-fiche-admin.dto.ts create mode 100644 backend/src/routes/parents/parents.mapper.spec.ts create mode 100644 backend/src/routes/parents/parents.mapper.ts create mode 100644 database/migrations/2026_enfants_assistantes_maternelles.sql create mode 100644 docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md create mode 100644 docs/archive/temporaires/131-fiche-parent-co-parent-header.md create mode 100644 docs/archive/temporaires/TEMP_112-back-reprise-alignement-front.md create mode 100644 docs/archive/temporaires/TEMP_131-back-fiche-am-enfants.md create mode 100644 docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md create mode 100644 docs/serveur_ptitspas.code-workspace create mode 100644 frontend/lib/models/enfant_admin_model.dart create mode 100644 frontend/lib/models/parent_child_summary.dart create mode 100644 frontend/lib/utils/am_vigilance.dart create mode 100644 frontend/lib/utils/enfant_status_utils.dart create mode 100644 frontend/lib/widgets/admin/common/admin_am_children_capacity_grid.dart create mode 100644 frontend/lib/widgets/admin/common/admin_am_edit_modal.dart create mode 100644 frontend/lib/widgets/admin/common/admin_am_photo_frame.dart create mode 100644 frontend/lib/widgets/admin/common/admin_child_detail_modal.dart create mode 100644 frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart create mode 100644 frontend/lib/widgets/admin/common/admin_enfant_user_card.dart create mode 100644 frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart create mode 100644 frontend/lib/widgets/admin/common/admin_status_capsule.dart create mode 100644 frontend/lib/widgets/admin/enfant_management_widget.dart create mode 100644 frontend/lib/widgets/common/identity_block.dart diff --git a/backend/scripts/close-gitea-issue-131.js b/backend/scripts/close-gitea-issue-131.js new file mode 100644 index 0000000..756a8e8 --- /dev/null +++ b/backend/scripts/close-gitea-issue-131.js @@ -0,0 +1,123 @@ +/** + * Commentaire de clôture + fermeture issue Gitea #131. + * Usage: node backend/scripts/close-gitea-issue-131.js + */ +const https = require('https'); +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.join(__dirname, '../..'); +const ISSUE = 131; +const REPO = 'jmartin/petitspas'; + +const body = `## Fermeture ticket #131 — livré sur \`develop\` et \`master\` + +Branche **\`feature/131\`** mergée dans **\`develop\`**, puis squash merge **\`develop\` → \`master\`** (déploiement production). + +### Fiche parent (dashboard admin) +- Modale **\`AdminParentEditModal\`** éditable dès l'ouverture +- En-tête dynamique : **nom/prénom** + sous-titre **co-parent** (si connu) +- Gélule **statut** modifiable +- **\`PATCH /api/v1/parents/:id/fiche\`** — identité + statut +- **\`GET /api/v1/parents\`** — liste parents (fix décorateur \`@Get()\` manquant) +- Réponses API : \`co_parent\` peuplé, secrets user masqués (\`sanitizeUserForApi\`) +- Liste enfants en bas de fiche + rattachement/détachement + +### Fiche AM (dashboard admin) +- Modale **\`AdminAmEditModal\`** — 3 onglets : Identité | Fiche pro | Enfants accueillis +- **\`PATCH /api/v1/assistantes-maternelles/:id/fiche\`** — identité + champs pro (NIR, date/lieu naissance, date agrément, places, disponibilité) +- **\`POST/DELETE …/enfants/:enfantId\`** — rattacher / détacher un enfant +- Grille capacité **\`AdminAmChildrenCapacityGrid\`** (2×2) +- Rattachement enfants **différé jusqu'à Sauvegarder** (pas d'appel API immédiat) + +### Back — placement AM ↔ enfant +- Table **\`enfants_assistantes_maternelles\`** (placement temporel, 1 garde active/enfant) +- Enum enfant : \`a_naitre\`, \`garde\`, \`sans_garde\`, \`scolarise\` — **plus \`actif\`** +- Rattachement → statut enfant \`garde\` ; détachement → \`sans_garde\` +- Migration : \`database/migrations/2026_enfants_assistantes_maternelles.sql\` +- Schéma canonique : \`database/BDD.sql\` + +### Front — statuts & polish +- **\`enfant_status_utils.dart\`** — libellés/couleurs \`garde\`/\`sans_garde\` +- Mise à jour cartes enfants, modale détail, filtres dashboard + +### Correctifs recette +- \`GET /parents\` 404 → ajout \`@Get()\` sur \`getAll()\` +- Sauvegarde AM 400 → alignement DTO \`UpdateAmFicheAdminDto\` sur payload front +- Crash NIR → fix \`toISOString\` + pas d'effacement NIR (colonne NOT NULL) + +### Hors périmètre #131 (tickets voisins) +- **#137** onglet Enfants global · **#138** fiche enfant complète · **#115/#116** affiliation avancée + +--- +*Issue fermée après merge sur \`develop\` + \`master\` et déploiement production.*`; + +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'); + process.exit(1); +} + +function request(method, apiPath, payloadObj) { + const payload = payloadObj ? JSON.stringify(payloadObj) : null; + return new Promise((resolve, reject) => { + const opts = { + hostname: 'git.ptits-pas.fr', + path: `/api/v1/repos/${REPO}${apiPath}`, + method, + headers: { + Authorization: 'token ' + token, + 'Content-Type': 'application/json', + ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}), + }, + }; + const req = https.request(opts, (res) => { + let d = ''; + res.on('data', (c) => (d += c)); + res.on('end', () => { + if (res.statusCode !== 200 && res.statusCode !== 201) { + reject(new Error(`HTTP ${res.statusCode}: ${d}`)); + return; + } + try { + resolve(d ? JSON.parse(d) : {}); + } catch (_) { + resolve({}); + } + }); + }); + req.on('error', reject); + if (payload) req.write(payload); + req.end(); + }); +} + +(async () => { + try { + console.log(`POST commentaire issue #${ISSUE}...`); + await request('POST', `/issues/${ISSUE}/comments`, { body }); + console.log('Commentaire publié.'); + console.log(`PATCH fermeture issue #${ISSUE}...`); + await request('PATCH', `/issues/${ISSUE}`, { state: 'closed' }); + console.log(`Issue #${ISSUE} fermée.`); + } catch (e) { + console.error(e.message || e); + process.exit(1); + } +})(); diff --git a/backend/scripts/update-gitea-issue-140-ch6-famille.js b/backend/scripts/update-gitea-issue-140-ch6-famille.js new file mode 100644 index 0000000..d2f0992 --- /dev/null +++ b/backend/scripts/update-gitea-issue-140-ch6-famille.js @@ -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(); diff --git a/backend/src/common/utils/sanitize-user-for-api.spec.ts b/backend/src/common/utils/sanitize-user-for-api.spec.ts new file mode 100644 index 0000000..c4ad2b2 --- /dev/null +++ b/backend/src/common/utils/sanitize-user-for-api.spec.ts @@ -0,0 +1,32 @@ +import { sanitizeUserForApi } from './sanitize-user-for-api'; +import { RoleType, StatutUtilisateurType, Users } from '../../entities/users.entity'; + +describe('sanitizeUserForApi', () => { + const base: Users = { + id: 'u1', + email: 'a@b.fr', + prenom: 'Paul', + nom: 'Parent', + role: RoleType.PARENT, + statut: StatutUtilisateurType.ACTIF, + password: 'hash', + token_creation_mdp: 'tok', + token_creation_mdp_expire_le: new Date(), + password_reset_token: 'rst', + password_reset_expires: new Date(), + } as Users; + + it('retire password et tokens', () => { + const out = sanitizeUserForApi(base)!; + expect(out.prenom).toBe('Paul'); + expect(out.nom).toBe('Parent'); + expect(out.password).toBeUndefined(); + expect(out.token_creation_mdp).toBeUndefined(); + expect(out.password_reset_token).toBeUndefined(); + }); + + it('retourne undefined si user absent', () => { + expect(sanitizeUserForApi(null)).toBeUndefined(); + expect(sanitizeUserForApi(undefined)).toBeUndefined(); + }); +}); diff --git a/backend/src/common/utils/sanitize-user-for-api.ts b/backend/src/common/utils/sanitize-user-for-api.ts new file mode 100644 index 0000000..dcd7e89 --- /dev/null +++ b/backend/src/common/utils/sanitize-user-for-api.ts @@ -0,0 +1,23 @@ +import { Users } from 'src/entities/users.entity'; + +/** Champs sensibles exclus des réponses API (ticket #131 — user / co_parent). */ +const SENSITIVE_USER_KEYS: (keyof Users)[] = [ + 'password', + 'token_creation_mdp', + 'token_creation_mdp_expire_le', + 'password_reset_token', + 'password_reset_expires', +]; + +/** + * Retourne une copie utilisateur sans secrets (hash MDP, tokens). + * Utilisé pour `user` et `co_parent` dans les réponses Parents. + */ +export function sanitizeUserForApi(user?: Users | null): Users | undefined { + if (!user) return undefined; + const safe = { ...user } as Users; + for (const key of SENSITIVE_USER_KEYS) { + delete safe[key]; + } + return safe; +} diff --git a/backend/src/entities/am_children.entity.ts b/backend/src/entities/am_children.entity.ts new file mode 100644 index 0000000..06d209b --- /dev/null +++ b/backend/src/entities/am_children.entity.ts @@ -0,0 +1,47 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, +} from 'typeorm'; +import { AssistanteMaternelle } from './assistantes_maternelles.entity'; +import { Children } from './children.entity'; +import { Users } from './users.entity'; + +@Entity('enfants_assistantes_maternelles', { schema: 'public' }) +export class AmChildren { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'id_am', type: 'uuid' }) + amId: string; + + @Column({ name: 'id_enfant', type: 'uuid' }) + enfantId: string; + + @Column({ name: 'date_debut', type: 'date' }) + date_debut: Date; + + @Column({ name: 'date_fin', type: 'date', nullable: true }) + date_fin?: Date; + + @CreateDateColumn({ name: 'cree_le', type: 'timestamptz' }) + cree_le: Date; + + @Column({ name: 'cree_par', type: 'uuid', nullable: true }) + cree_par?: string; + + @ManyToOne(() => AssistanteMaternelle, (am) => am.amChildren, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'id_am', referencedColumnName: 'user_id' }) + am: AssistanteMaternelle; + + @ManyToOne(() => Children, (c) => c.amLinks, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'id_enfant', referencedColumnName: 'id' }) + child: Children; + + @ManyToOne(() => Users, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'cree_par', referencedColumnName: 'id' }) + createdBy?: Users; +} diff --git a/backend/src/entities/assistantes_maternelles.entity.ts b/backend/src/entities/assistantes_maternelles.entity.ts index 77db5f1..65d46b8 100644 --- a/backend/src/entities/assistantes_maternelles.entity.ts +++ b/backend/src/entities/assistantes_maternelles.entity.ts @@ -1,5 +1,6 @@ -import { Entity, PrimaryColumn, Column, OneToOne, JoinColumn } from 'typeorm'; +import { Entity, PrimaryColumn, Column, OneToOne, OneToMany, JoinColumn } from 'typeorm'; import { Users } from './users.entity'; +import { AmChildren } from './am_children.entity'; @Entity('assistantes_maternelles') export class AssistanteMaternelle { @@ -51,4 +52,7 @@ export class AssistanteMaternelle { /** Numéro de dossier (format AAAA-NNNNNN), même valeur que sur utilisateurs (ticket #103) */ @Column({ name: 'numero_dossier', length: 20, nullable: true }) numero_dossier?: string; + + @OneToMany(() => AmChildren, (ac) => ac.am) + amChildren: AmChildren[]; } diff --git a/backend/src/entities/children.entity.ts b/backend/src/entities/children.entity.ts index 3dd691f..3c1db67 100644 --- a/backend/src/entities/children.entity.ts +++ b/backend/src/entities/children.entity.ts @@ -4,12 +4,14 @@ import { } from 'typeorm'; import { Parents } from './parents.entity'; import { ParentsChildren } from './parents_children.entity'; +import { AmChildren } from './am_children.entity'; import { Dossier } from './dossiers.entity'; export enum StatutEnfantType { A_NAITRE = 'a_naitre', - ACTIF = 'actif', SCOLARISE = 'scolarise', + GARDE = 'garde', + SANS_GARDE = 'sans_garde', } export enum GenreType { @@ -68,6 +70,9 @@ export class Children { @OneToMany(() => ParentsChildren, pc => pc.child) parentLinks: ParentsChildren[]; + @OneToMany(() => AmChildren, (ac) => ac.child) + amLinks: AmChildren[]; + // Relation avec Dossier @OneToMany(() => Dossier, d => d.child) dossiers: Dossier[]; diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts index 47d51eb..540c73e 100644 --- a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts @@ -12,11 +12,14 @@ import { AssistantesMaternellesService } from './assistantes_maternelles.service import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { Roles } from 'src/common/decorators/roles.decorator'; -import { RoleType } from 'src/entities/users.entity'; +import { RoleType, Users } from 'src/entities/users.entity'; import { CreateAssistanteDto } from '../user/dto/create_assistante.dto'; import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto'; +import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto'; import { RolesGuard } from 'src/common/guards/roles.guard'; import { AuthGuard } from 'src/common/guards/auth.guard'; +import { User } from 'src/common/decorators/user.decorator'; +import { mapAmForApi, mapAmsForApi } from './assistantes_maternelles.mapper'; @ApiTags("Assistantes Maternelles") @ApiBearerAuth('access-token') @@ -31,28 +34,74 @@ export class AssistantesMaternellesController { @ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' }) @ApiBody({ type: CreateAssistanteDto }) @Post() - create(@Body() dto: CreateAssistanteDto): Promise { - return this.assistantesMaternellesService.create(dto); + async create(@Body() dto: CreateAssistanteDto): Promise { + const am = await this.assistantesMaternellesService.create(dto); + return mapAmForApi(am); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Get() - @ApiOperation({ summary: 'Récupérer la liste des nounous' }) + @ApiOperation({ summary: 'Récupérer la liste des nounous (inclut amChildren actifs) — ticket #131' }) @ApiResponse({ status: 200, description: 'Liste des nounous' }) @ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' }) - getAll(): Promise { - return this.assistantesMaternellesService.findAll(); + async getAll(): Promise { + const ams = await this.assistantesMaternellesService.findAll(); + return mapAmsForApi(ams); } - @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Get(':id') @ApiParam({ name: 'id', description: "UUID de la nounou" }) - @ApiOperation({ summary: 'Récupérer une nounou par id' }) + @ApiOperation({ summary: 'Récupérer une nounou par id (inclut amChildren) — ticket #131' }) @ApiResponse({ status: 200, description: 'Détails de la nounou' }) @ApiResponse({ status: 404, description: 'Nounou non trouvée' }) - @ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' }) - getOne(@Param('id') user_id: string): Promise { - return this.assistantesMaternellesService.findOne(user_id); + @ApiResponse({ status: 403, description: 'Accès refusé' }) + async getOne(@Param('id') user_id: string): Promise { + const am = await this.assistantesMaternellesService.findOne(user_id); + return mapAmForApi(am); + } + + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) + @Patch(':id/fiche') + @ApiBody({ type: UpdateAmFicheAdminDto }) + @ApiOperation({ summary: 'Mettre à jour la fiche AM (identité + pro) — ticket #131' }) + @ApiParam({ name: 'id', description: "UUID utilisateur de l'AM" }) + @ApiResponse({ status: 200, description: 'Fiche AM mise à jour' }) + async updateFicheAdmin( + @Param('id') id: string, + @Body() dto: UpdateAmFicheAdminDto, + ): Promise { + const am = await this.assistantesMaternellesService.updateFicheAdmin(id, dto); + return mapAmForApi(am); + } + + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) + @Post(':id/enfants/:enfantId') + @ApiOperation({ summary: 'Rattacher un enfant à une AM (statut enfant → garde) — ticket #131' }) + @ApiParam({ name: 'id', description: "UUID utilisateur de l'AM" }) + @ApiParam({ name: 'enfantId', description: "UUID de l'enfant" }) + @ApiResponse({ status: 200, description: 'AM avec enfants mis à jour' }) + async attachEnfant( + @Param('id') id: string, + @Param('enfantId') enfantId: string, + @User() currentUser: Users, + ): Promise { + const am = await this.assistantesMaternellesService.attachEnfant(id, enfantId, currentUser); + return mapAmForApi(am); + } + + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) + @Delete(':id/enfants/:enfantId') + @ApiOperation({ summary: "Clôturer le placement d'un enfant chez une AM — ticket #131" }) + @ApiParam({ name: 'id', description: "UUID utilisateur de l'AM" }) + @ApiParam({ name: 'enfantId', description: "UUID de l'enfant" }) + @ApiResponse({ status: 200, description: 'AM avec enfants mis à jour' }) + async detachEnfant( + @Param('id') id: string, + @Param('enfantId') enfantId: string, + ): Promise { + const am = await this.assistantesMaternellesService.detachEnfant(id, enfantId); + return mapAmForApi(am); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) @@ -63,14 +112,15 @@ export class AssistantesMaternellesController { @ApiResponse({ status: 404, description: 'Nounou non trouvée' }) @ApiParam({ name: 'id', description: "UUID de la nounou" }) @Patch(':id') - update(@Param('id') id: string, @Body() dto: UpdateAssistanteDto): Promise { - return this.assistantesMaternellesService.update(id, dto); + async update(@Param('id') id: string, @Body() dto: UpdateAssistanteDto): Promise { + const am = await this.assistantesMaternellesService.update(id, dto); + return mapAmForApi(am); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @ApiOperation({ summary: 'Supprimer une nounou' }) @ApiResponse({ status: 200, description: 'Nounou supprimée avec succès' }) - @ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins, gestionnaires et administrateurs' }) + @ApiResponse({ status: 403, description: 'Accès refusé' }) @ApiResponse({ status: 404, description: 'Nounou non trouvée' }) @ApiParam({ name: 'id', description: "UUID de la nounou" }) @Delete(':id') diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.mapper.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.mapper.ts new file mode 100644 index 0000000..cc33c4c --- /dev/null +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.mapper.ts @@ -0,0 +1,28 @@ +import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; +import { sanitizeUserForApi } from '../../common/utils/sanitize-user-for-api'; + +/** + * Sérialisation API fiche AM — ticket #131. + * Expose `amChildren` actifs (date_fin null) avec enfant imbriqué, sans secrets user. + */ +export function mapAmForApi(am: AssistanteMaternelle): AssistanteMaternelle { + const activeChildren = (am.amChildren ?? []).filter((link) => !link.date_fin); + + return { + ...am, + user: sanitizeUserForApi(am.user)!, + amChildren: activeChildren.map((link) => ({ + ...link, + child: link.child, + })), + }; +} + +export function mapAmsForApi(ams: AssistanteMaternelle[]): AssistanteMaternelle[] { + return ams.map(mapAmForApi); +} + +export function filterActiveAmChildren(links: AmChildren[] | undefined): AmChildren[] { + return (links ?? []).filter((link) => !link.date_fin); +} diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.module.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.module.ts index 9e2e231..54a10ae 100644 --- a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.module.ts +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.module.ts @@ -2,12 +2,14 @@ import { Module } from '@nestjs/common'; import { AssistantesMaternellesService } from './assistantes_maternelles.service'; import { AssistantesMaternellesController } from './assistantes_maternelles.controller'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; +import { Children } from 'src/entities/children.entity'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Users } from 'src/entities/users.entity'; import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [TypeOrmModule.forFeature([AssistanteMaternelle, Users]), + imports: [TypeOrmModule.forFeature([AssistanteMaternelle, AmChildren, Children, Users]), AuthModule ], controllers: [AssistantesMaternellesController], diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts index c9ba68f..ced0e0e 100644 --- a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts @@ -5,11 +5,17 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { IsNull, Repository } from 'typeorm'; import { RoleType, Users } from 'src/entities/users.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; +import { Children, StatutEnfantType } from 'src/entities/children.entity'; import { CreateAssistanteDto } from '../user/dto/create_assistante.dto'; import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto'; +import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto'; +import { validateNir } from 'src/common/utils/nir.util'; + +const AM_CHILDREN_RELATIONS = ['user', 'amChildren', 'amChildren.child'] as const; @Injectable() export class AssistantesMaternellesService { @@ -17,10 +23,13 @@ export class AssistantesMaternellesService { @InjectRepository(AssistanteMaternelle) private readonly assistantesMaternelleRepository: Repository, @InjectRepository(Users) - private readonly usersRepository: Repository + private readonly usersRepository: Repository, + @InjectRepository(AmChildren) + private readonly amChildrenRepository: Repository, + @InjectRepository(Children) + private readonly childrenRepository: Repository, ) {} - // Création d’une assistante maternelle async create(dto: CreateAssistanteDto): Promise { const user = await this.usersRepository.findOneBy({ id: dto.user_id }); if (!user) throw new NotFoundException('Utilisateur introuvable'); @@ -49,30 +58,208 @@ export class AssistantesMaternellesService { return this.assistantesMaternelleRepository.save(entity); } - // Liste des assistantes maternelles async findAll(): Promise { return this.assistantesMaternelleRepository.find({ - relations: ['user'], + relations: [...AM_CHILDREN_RELATIONS], }); } - // Récupérer une assistante maternelle par user_id async findOne(user_id: string): Promise { const assistante = await this.assistantesMaternelleRepository.findOne({ where: { user_id }, - relations: ['user'], + relations: [...AM_CHILDREN_RELATIONS], }); if (!assistante) throw new NotFoundException('Assistante maternelle introuvable'); return assistante; } - // Mise à jour async update(id: string, dto: UpdateAssistanteDto): Promise { await this.assistantesMaternelleRepository.update(id, dto); return this.findOne(id); } - // Suppression d’une assistante maternelle + /** + * Mise à jour fiche AM (identité + champs pro) par admin/gestionnaire. Ticket #131. + */ + async updateFicheAdmin(amUserId: string, dto: UpdateAmFicheAdminDto): Promise { + const am = await this.findOne(amUserId); + const user = am.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; + if (dto.date_naissance !== undefined) { + user.date_naissance = dto.date_naissance ? new Date(dto.date_naissance) : undefined; + } + if (dto.lieu_naissance_ville !== undefined) { + user.lieu_naissance_ville = dto.lieu_naissance_ville || undefined; + } + if (dto.lieu_naissance_pays !== undefined) { + user.lieu_naissance_pays = dto.lieu_naissance_pays || undefined; + } + + await this.usersRepository.save(user); + + const amPatch: Partial = {}; + if (dto.approval_number !== undefined) amPatch.approval_number = dto.approval_number; + if (dto.residence_city !== undefined) amPatch.residence_city = dto.residence_city; + if (dto.max_children !== undefined) amPatch.max_children = dto.max_children; + if (dto.places_available !== undefined) amPatch.places_available = dto.places_available; + if (dto.biography !== undefined) amPatch.biography = dto.biography; + if (dto.available !== undefined) amPatch.available = dto.available; + if (dto.agreement_date !== undefined) { + amPatch.agreement_date = dto.agreement_date ? new Date(dto.agreement_date) : undefined; + } + + if (dto.nir !== undefined) { + const nirNormalized = dto.nir.replace(/\s/g, '').toUpperCase(); + if (nirNormalized) { + const dateNaissanceForNir = + dto.date_naissance ?? + (user.date_naissance instanceof Date + ? user.date_naissance.toISOString().slice(0, 10) + : user.date_naissance + ? String(user.date_naissance).slice(0, 10) + : undefined); + const nirValidation = validateNir(nirNormalized, { + dateNaissance: dateNaissanceForNir, + }); + if (!nirValidation.valid) { + throw new BadRequestException(nirValidation.error || 'NIR invalide'); + } + const nirDejaUtilise = await this.assistantesMaternelleRepository.findOne({ + where: { nir: nirNormalized }, + }); + if (nirDejaUtilise && nirDejaUtilise.user_id !== amUserId) { + throw new ConflictException( + 'Un compte assistante maternelle avec ce numéro NIR existe déjà.', + ); + } + amPatch.nir = nirNormalized; + } + // NIR vide : ne pas effacer (colonne NOT NULL en BDD) — le front renvoie toujours la clé. + } + + if (Object.keys(amPatch).length > 0) { + await this.assistantesMaternelleRepository.update(amUserId, amPatch); + } + + return this.findOne(amUserId); + } + + /** + * Rattacher un enfant à une AM (placement actif). Ticket #131. + * Passe le statut enfant à `garde` (sauf a_naitre / scolarise). + */ + async attachEnfant(amUserId: string, enfantId: string, createdBy?: Users): Promise { + const am = await this.findOne(amUserId); + + const existingForAm = await this.amChildrenRepository.findOne({ + where: { amId: amUserId, enfantId, date_fin: IsNull() }, + }); + if (existingForAm) { + throw new ConflictException('Cet enfant est déjà rattaché à cette assistante maternelle'); + } + + const child = await this.childrenRepository.findOne({ where: { id: enfantId } }); + if (!child) { + throw new NotFoundException('Enfant introuvable'); + } + + const activeForChild = await this.amChildrenRepository.findOne({ + where: { enfantId, date_fin: IsNull() }, + }); + if (activeForChild && activeForChild.amId !== amUserId) { + throw new ConflictException( + 'Cet enfant est déjà en garde chez une autre assistante maternelle', + ); + } + + const activeCount = await this.amChildrenRepository.count({ + where: { amId: amUserId, date_fin: IsNull() }, + }); + if (am.max_children != null && activeCount >= am.max_children) { + throw new BadRequestException( + `Capacité maximale atteinte (${am.max_children} enfant(s))`, + ); + } + + await this.amChildrenRepository.save( + this.amChildrenRepository.create({ + amId: amUserId, + enfantId, + date_debut: new Date(), + cree_par: createdBy?.id, + }), + ); + + await this.applyGardeStatusOnAttach(child); + + return this.findOne(amUserId); + } + + /** + * Clôturer le placement AM ↔ enfant. Ticket #131. + * Repasse l'enfant en `sans_garde` s'il n'a plus de placement actif. + */ + async detachEnfant(amUserId: string, enfantId: string): Promise { + await this.findOne(amUserId); + + const link = await this.amChildrenRepository.findOne({ + where: { amId: amUserId, enfantId, date_fin: IsNull() }, + relations: ['child'], + }); + if (!link) { + throw new NotFoundException('Lien assistante maternelle-enfant introuvable'); + } + + link.date_fin = new Date(); + await this.amChildrenRepository.save(link); + + const remaining = await this.amChildrenRepository.count({ + where: { enfantId, date_fin: IsNull() }, + }); + if (remaining === 0 && link.child) { + await this.applySansGardeStatusOnDetach(link.child); + } + + return this.findOne(amUserId); + } + + private async applyGardeStatusOnAttach(child: Children): Promise { + if ( + child.status === StatutEnfantType.A_NAITRE || + child.status === StatutEnfantType.SCOLARISE + ) { + return; + } + child.status = StatutEnfantType.GARDE; + await this.childrenRepository.save(child); + } + + private async applySansGardeStatusOnDetach(child: Children): Promise { + if ( + child.status === StatutEnfantType.A_NAITRE || + child.status === StatutEnfantType.SCOLARISE + ) { + return; + } + child.status = StatutEnfantType.SANS_GARDE; + await this.childrenRepository.save(child); + } + async remove(id: string): Promise<{ message: string }> { await this.assistantesMaternelleRepository.delete(id); return { message: 'Assistante maternelle supprimée' }; diff --git a/backend/src/routes/assistantes_maternelles/dto/update-am-fiche-admin.dto.ts b/backend/src/routes/assistantes_maternelles/dto/update-am-fiche-admin.dto.ts new file mode 100644 index 0000000..cbac068 --- /dev/null +++ b/backend/src/routes/assistantes_maternelles/dto/update-am-fiche-admin.dto.ts @@ -0,0 +1,126 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsBoolean, + IsDateString, + IsEmail, + IsEnum, + IsInt, + IsOptional, + IsString, + Max, + MaxLength, + Min, +} from 'class-validator'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; + +/** Mise à jour fiche AM par admin/gestionnaire (doc 28 §6.1, ticket #131). */ +export class UpdateAmFicheAdminDto { + @ApiPropertyOptional({ example: 'MARTIN' }) + @IsOptional() + @IsString() + @MaxLength(100) + nom?: string; + + @ApiPropertyOptional({ example: 'Claire' }) + @IsOptional() + @IsString() + @MaxLength(100) + prenom?: string; + + @ApiPropertyOptional({ example: 'claire@example.com' }) + @IsOptional() + @IsEmail() + email?: string; + + @ApiPropertyOptional({ example: '0612345678' }) + @IsOptional() + @IsString() + @MaxLength(20) + telephone?: string; + + @ApiPropertyOptional({ example: '5 place Bellecour' }) + @IsOptional() + @IsString() + adresse?: string; + + @ApiPropertyOptional({ example: 'Lyon' }) + @IsOptional() + @IsString() + @MaxLength(150) + ville?: string; + + @ApiPropertyOptional({ example: '69002' }) + @IsOptional() + @IsString() + @MaxLength(10) + code_postal?: string; + + @ApiPropertyOptional({ enum: StatutUtilisateurType }) + @IsOptional() + @IsEnum(StatutUtilisateurType) + statut?: StatutUtilisateurType; + + @ApiPropertyOptional({ example: '123456789012345' }) + @IsOptional() + @IsString() + @MaxLength(15) + nir?: string; + + @ApiPropertyOptional({ example: '1985-03-12' }) + @IsOptional() + @IsDateString() + date_naissance?: string; + + @ApiPropertyOptional({ example: 'Lyon' }) + @IsOptional() + @IsString() + @MaxLength(100) + lieu_naissance_ville?: string; + + @ApiPropertyOptional({ example: 'France' }) + @IsOptional() + @IsString() + @MaxLength(100) + lieu_naissance_pays?: string; + + @ApiPropertyOptional({ example: 'AGR-2024-12345' }) + @IsOptional() + @IsString() + @MaxLength(50) + approval_number?: string; + + @ApiPropertyOptional({ example: '2020-01-15' }) + @IsOptional() + @IsDateString() + agreement_date?: string; + + @ApiPropertyOptional({ example: 'Lyon' }) + @IsOptional() + @IsString() + @MaxLength(100) + residence_city?: string; + + @ApiPropertyOptional({ example: 4 }) + @IsOptional() + @IsInt() + @Min(1) + @Max(10) + max_children?: number; + + @ApiPropertyOptional({ example: 2 }) + @IsOptional() + @IsInt() + @Min(0) + @Max(10) + places_available?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + biography?: string; + + @ApiPropertyOptional({ example: true }) + @IsOptional() + @IsBoolean() + available?: boolean; +} diff --git a/backend/src/routes/auth/auth.service.spec.ts b/backend/src/routes/auth/auth.service.spec.ts index e373abd..f24b719 100644 --- a/backend/src/routes/auth/auth.service.spec.ts +++ b/backend/src/routes/auth/auth.service.spec.ts @@ -176,7 +176,7 @@ describe('AuthService (#118 create-password API)', () => { parentsServiceMock.getDossierFamilleByNumero.mockResolvedValue({ numero_dossier: '2026-000021', parents: [{ user_id: 'p1', email: 'claire@test.fr', statut: StatutUtilisateurType.REFUSE }], - enfants: [{ id: 'e1', first_name: 'Emma', status: 'actif' }], + enfants: [{ id: 'e1', first_name: 'Emma', status: 'sans_garde' }], texte_motivation: 'Motivation test', }); diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index f78a06e..308bb81 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -551,7 +551,7 @@ export class AuthService { ? new Date(enfantDto.date_previsionnelle_naissance) : undefined; enfant.photo_url = urlPhoto || undefined; - enfant.status = enfantDto.date_naissance ? StatutEnfantType.ACTIF : StatutEnfantType.A_NAITRE; + enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE; enfant.consent_photo = false; enfant.is_multiple = enfantDto.grossesse_multiple || false; @@ -1063,7 +1063,7 @@ export class AuthService { if (enfantDto.genre !== undefined) enfant.gender = enfantDto.genre; if (enfantDto.date_naissance !== undefined) { enfant.birth_date = new Date(enfantDto.date_naissance); - enfant.status = StatutEnfantType.ACTIF; + enfant.status = StatutEnfantType.SANS_GARDE; } if (enfantDto.date_previsionnelle_naissance !== undefined) { enfant.due_date = new Date(enfantDto.date_previsionnelle_naissance); diff --git a/backend/src/routes/enfants/dto/create_enfants.dto.ts b/backend/src/routes/enfants/dto/create_enfants.dto.ts index 3ede09d..7d69067 100644 --- a/backend/src/routes/enfants/dto/create_enfants.dto.ts +++ b/backend/src/routes/enfants/dto/create_enfants.dto.ts @@ -12,7 +12,7 @@ import { import { GenreType, StatutEnfantType } from 'src/entities/children.entity'; export class CreateEnfantsDto { - @ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.ACTIF }) + @ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE }) @IsEnum(StatutEnfantType) @IsNotEmpty() status: StatutEnfantType; 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..e4b46c4 100644 --- a/backend/src/routes/enfants/enfants.service.ts +++ b/backend/src/routes/enfants/enfants.service.ts @@ -34,7 +34,7 @@ export class EnfantsService { // Vérif métier simple if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) { - throw new BadRequestException('Un enfant actif doit avoir une date de naissance'); + throw new BadRequestException('Un enfant né doit avoir une date de naissance'); } // Vérif doublon éventuel (ex: même prénom + date de naissance pour ce parent) @@ -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..396ac2f 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,11 +17,13 @@ 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'; import { PendingFamilyDto } from './dto/pending-family.dto'; import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto'; +import { mapParentForApi, mapParentsForApi } from './parents.mapper'; @ApiTags('Parents') @Controller('parents') @@ -79,21 +82,25 @@ export class ParentsController { return validated; } - @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Get() + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) + @ApiOperation({ summary: 'Liste des parents (user, co_parent, parentChildren) — ticket #131' }) @ApiResponse({ status: 200, type: [Parents], description: 'Liste des parents' }) @ApiResponse({ status: 403, description: 'Accès refusé !' }) - getAll(): Promise { - return this.parentsService.findAll(); + async getAll(): Promise { + const parents = await this.parentsService.findAll(); + return mapParentsForApi(parents); } - @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Get(':id') + @ApiOperation({ summary: 'Détail parent par user_id (inclut co_parent si id_co_parent renseigné) — ticket #131' }) @ApiResponse({ status: 200, type: Parents, description: 'Détails du parent par ID utilisateur' }) @ApiResponse({ status: 404, description: 'Parent non trouvé' }) @ApiResponse({ status: 403, description: 'Accès refusé !' }) - getOne(@Param('id') user_id: string): Promise { - return this.parentsService.findOne(user_id); + async getOne(@Param('id') user_id: string): Promise { + const parent = await this.parentsService.findOne(user_id); + return mapParentForApi(parent); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) @@ -101,8 +108,51 @@ export class ParentsController { @ApiBody({ type: CreateParentDto }) @ApiResponse({ status: 201, type: Parents, description: 'Parent créé avec succès' }) @ApiResponse({ status: 403, description: 'Accès refusé !' }) - create(@Body() dto: CreateParentDto): Promise { - return this.parentsService.create(dto); + async create(@Body() dto: CreateParentDto): Promise { + const parent = await this.parentsService.create(dto); + return mapParentForApi(parent); + } + + @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' }) + async updateFicheAdmin( + @Param('id') id: string, + @Body() dto: UpdateParentFicheAdminDto, + ): Promise { + const parent = await this.parentsService.updateFicheAdmin(id, dto); + return mapParentForApi(parent); + } + + @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' }) + async attachEnfant( + @Param('id') id: string, + @Param('enfantId') enfantId: string, + ): Promise { + const parent = await this.parentsService.attachEnfant(id, enfantId); + return mapParentForApi(parent); + } + + @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' }) + async detachEnfant( + @Param('id') id: string, + @Param('enfantId') enfantId: string, + ): Promise { + const parent = await this.parentsService.detachEnfant(id, enfantId); + return mapParentForApi(parent); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) @@ -111,7 +161,8 @@ export class ParentsController { @ApiResponse({ status: 200, type: Parents, description: 'Parent mis à jour avec succès' }) @ApiResponse({ status: 404, description: 'Parent introuvable' }) @ApiResponse({ status: 403, description: 'Accès refusé !' }) - update(@Param('id') id: string, @Body() dto: UpdateParentsDto): Promise { - return this.parentsService.update(id, dto); + async update(@Param('id') id: string, @Body() dto: UpdateParentsDto): Promise { + const parent = await this.parentsService.update(id, dto); + return mapParentForApi(parent); } } diff --git a/backend/src/routes/parents/parents.mapper.spec.ts b/backend/src/routes/parents/parents.mapper.spec.ts new file mode 100644 index 0000000..e34ecc0 --- /dev/null +++ b/backend/src/routes/parents/parents.mapper.spec.ts @@ -0,0 +1,38 @@ +import { mapParentForApi } from './parents.mapper'; +import { Parents } from '../../entities/parents.entity'; +import { RoleType, StatutUtilisateurType, Users } from '../../entities/users.entity'; + +describe('mapParentForApi', () => { + it('expose co_parent avec prenom/nom sans secrets', () => { + const coParent = { + id: 'cp1', + email: 'co@b.fr', + prenom: 'Clara', + nom: 'Co', + role: RoleType.PARENT, + statut: StatutUtilisateurType.ACTIF, + password: 'secret', + } as Users; + + const parent = { + user_id: 'u1', + numero_dossier: '2026-000042', + user: { + id: 'u1', + email: 'p@b.fr', + prenom: 'Paul', + nom: 'Parent', + role: RoleType.PARENT, + password: 'secret', + } as Users, + co_parent: coParent, + parentChildren: [], + } as Parents; + + const out = mapParentForApi(parent); + expect(out.co_parent?.prenom).toBe('Clara'); + expect(out.co_parent?.nom).toBe('Co'); + expect(out.co_parent?.password).toBeUndefined(); + expect(out.user.password).toBeUndefined(); + }); +}); diff --git a/backend/src/routes/parents/parents.mapper.ts b/backend/src/routes/parents/parents.mapper.ts new file mode 100644 index 0000000..5c9e517 --- /dev/null +++ b/backend/src/routes/parents/parents.mapper.ts @@ -0,0 +1,18 @@ +import { Parents } from 'src/entities/parents.entity'; +import { sanitizeUserForApi } from '../../common/utils/sanitize-user-for-api'; + +/** + * Sérialisation API fiche parent — ticket #131. + * Garantit `user`, `co_parent` (si présent) et relations sans champs sensibles. + */ +export function mapParentForApi(parent: Parents): Parents { + return { + ...parent, + user: sanitizeUserForApi(parent.user)!, + co_parent: sanitizeUserForApi(parent.co_parent), + }; +} + +export function mapParentsForApi(parents: Parents[]): Parents[] { + return parents.map(mapParentForApi); +} 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/database/BDD.sql b/database/BDD.sql index b7cd431..9cd9f5e 100644 --- a/database/BDD.sql +++ b/database/BDD.sql @@ -1,3 +1,9 @@ +-- ========================================================== +-- P'titsPas — Schéma PostgreSQL (création from scratch) +-- Fichier canonique : toute nouvelle BDD doit partir d'ici. +-- Migrations dans database/migrations/ : BDD existantes uniquement. +-- ========================================================== + CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- ========================================================== @@ -5,40 +11,63 @@ CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- ========================================================== DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'role_type') THEN - CREATE TYPE role_type AS ENUM ('parent', 'gestionnaire', 'super_admin', 'administrateur', 'assistante_maternelle'); + CREATE TYPE role_type AS ENUM ( + 'parent', 'gestionnaire', 'super_admin', 'administrateur', 'assistante_maternelle' + ); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'genre_type') THEN CREATE TYPE genre_type AS ENUM ('H', 'F', 'Autre'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_utilisateur_type') THEN - CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente','actif','suspendu','refuse'); + CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente', 'actif', 'suspendu', 'refuse'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_enfant_type') THEN - CREATE TYPE statut_enfant_type AS ENUM ('a_naitre','actif','scolarise'); + CREATE TYPE statut_enfant_type AS ENUM ('a_naitre', 'garde', 'sans_garde', 'scolarise'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_dossier_type') THEN - CREATE TYPE statut_dossier_type AS ENUM ('envoye','accepte','refuse'); + CREATE TYPE statut_dossier_type AS ENUM ('envoye', 'accepte', 'refuse'); END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_contrat_type') THEN - CREATE TYPE statut_contrat_type AS ENUM ('brouillon','en_attente_signature','valide','resilie'); + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_contrat_type') THEN + CREATE TYPE statut_contrat_type AS ENUM ( + 'brouillon', 'en_attente_signature', 'valide', 'resilie' + ); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_avenant_type') THEN - CREATE TYPE statut_avenant_type AS ENUM ('propose','accepte','refuse'); + CREATE TYPE statut_avenant_type AS ENUM ('propose', 'accepte', 'refuse'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_evenement_type') THEN - CREATE TYPE type_evenement_type AS ENUM ('absence_enfant','conge_am','conge_parent','arret_maladie_am','evenement_rpe'); + CREATE TYPE type_evenement_type AS ENUM ( + 'absence_enfant', 'conge_am', 'conge_parent', 'arret_maladie_am', 'evenement_rpe' + ); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_evenement_type') THEN - CREATE TYPE statut_evenement_type AS ENUM ('propose','valide','refuse'); + CREATE TYPE statut_evenement_type AS ENUM ('propose', 'valide', 'refuse'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_validation_type') THEN - CREATE TYPE statut_validation_type AS ENUM ('en_attente','valide','refuse'); + CREATE TYPE statut_validation_type AS ENUM ('en_attente', 'valide', 'refuse'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'situation_familiale_type') THEN - CREATE TYPE situation_familiale_type AS ENUM ('celibataire','marie','concubinage','pacse','separe','divorce','veuf','parent_isole'); + CREATE TYPE situation_familiale_type AS ENUM ( + 'celibataire', 'marie', 'concubinage', 'pacse', 'separe', 'divorce', 'veuf', 'parent_isole' + ); END IF; END $$; +-- ========================================================== +-- Table : relais (avant utilisateurs — FK relais_id) +-- ========================================================== +CREATE TABLE relais ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + nom VARCHAR(255) NOT NULL, + adresse TEXT NOT NULL, + horaires_ouverture JSONB, + ligne_fixe VARCHAR(20), + actif BOOLEAN DEFAULT true, + notes TEXT, + cree_le TIMESTAMPTZ DEFAULT now(), + modifie_le TIMESTAMPTZ DEFAULT now() +); + -- ========================================================== -- Table : utilisateurs -- ========================================================== @@ -46,26 +75,33 @@ CREATE TABLE utilisateurs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email VARCHAR(255) NOT NULL UNIQUE, CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'), - password TEXT, -- NULL avant création via token + password TEXT, prenom VARCHAR(100), nom VARCHAR(100), genre genre_type, role role_type NOT NULL, statut statut_utilisateur_type DEFAULT 'en_attente', - telephone VARCHAR(20), -- Unifié (mobile privilégié) + telephone VARCHAR(20), adresse TEXT, date_naissance DATE, lieu_naissance_ville VARCHAR(100), lieu_naissance_pays VARCHAR(100), - photo_url TEXT, -- Obligatoire pour AM, non utilisé pour parents + photo_url TEXT, consentement_photo BOOLEAN DEFAULT false, date_consentement_photo TIMESTAMPTZ, - token_creation_mdp VARCHAR(255), -- Token pour créer MDP après validation - token_creation_mdp_expire_le TIMESTAMPTZ, -- Expiration 7 jours - -- Ticket #127 : réinitialisation mot de passe oublié (distinct de token_creation_mdp) - password_reset_token VARCHAR(255) NULL, - password_reset_expires TIMESTAMPTZ NULL, + token_creation_mdp VARCHAR(255), + token_creation_mdp_expire_le TIMESTAMPTZ, + password_reset_token VARCHAR(255), + password_reset_expires TIMESTAMPTZ, + token_reprise VARCHAR(255), + token_reprise_expire_le TIMESTAMPTZ, changement_mdp_obligatoire BOOLEAN DEFAULT false, + numero_dossier VARCHAR(20), + cgu_version_acceptee INTEGER, + cgu_acceptee_le TIMESTAMPTZ, + privacy_version_acceptee INTEGER, + privacy_acceptee_le TIMESTAMPTZ, + relais_id UUID REFERENCES relais(id) ON DELETE SET NULL, cree_le TIMESTAMPTZ DEFAULT now(), modifie_le TIMESTAMPTZ DEFAULT now(), ville VARCHAR(150), @@ -74,15 +110,22 @@ CREATE TABLE utilisateurs ( situation_familiale situation_familiale_type ); --- Index pour recherche par token -CREATE INDEX idx_utilisateurs_token_creation_mdp - ON utilisateurs(token_creation_mdp) +CREATE INDEX idx_utilisateurs_token_creation_mdp + ON utilisateurs(token_creation_mdp) WHERE token_creation_mdp IS NOT NULL; CREATE INDEX idx_utilisateurs_password_reset_token ON utilisateurs(password_reset_token) WHERE password_reset_token IS NOT NULL; +CREATE INDEX idx_utilisateurs_token_reprise + ON utilisateurs(token_reprise) + WHERE token_reprise IS NOT NULL; + +CREATE INDEX idx_utilisateurs_numero_dossier + ON utilisateurs(numero_dossier) + WHERE numero_dossier IS NOT NULL; + -- ========================================================== -- Table : assistantes_maternelles -- ========================================================== @@ -97,17 +140,27 @@ CREATE TABLE assistantes_maternelles ( date_agrement DATE, annee_experience SMALLINT, specialite VARCHAR(100), - place_disponible INT + place_disponible INT, + numero_dossier VARCHAR(20) ); +CREATE INDEX idx_assistantes_maternelles_numero_dossier + ON assistantes_maternelles(numero_dossier) + WHERE numero_dossier IS NOT NULL; + -- ========================================================== -- Table : parents -- ========================================================== CREATE TABLE parents ( id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE, - id_co_parent UUID REFERENCES utilisateurs(id) + id_co_parent UUID REFERENCES utilisateurs(id), + numero_dossier VARCHAR(20) ); +CREATE INDEX idx_parents_numero_dossier + ON parents(numero_dossier) + WHERE numero_dossier IS NOT NULL; + -- ========================================================== -- Table : enfants -- ========================================================== @@ -116,7 +169,7 @@ CREATE TABLE enfants ( statut statut_enfant_type, prenom VARCHAR(100), nom VARCHAR(100), - genre genre_type NOT NULL, -- Obligatoire selon CDC + genre genre_type NOT NULL, date_naissance DATE, date_prevue_naissance DATE, photo_url TEXT, @@ -135,7 +188,27 @@ CREATE TABLE enfants_parents ( ); -- ========================================================== --- Table : dossier_famille (inscription parent, schéma simplifié — ticket #119) +-- Table : enfants_assistantes_maternelles (placement AM ↔ enfant — #131) +-- ========================================================== +CREATE TABLE enfants_assistantes_maternelles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + id_am UUID NOT NULL REFERENCES assistantes_maternelles(id_utilisateur) ON DELETE CASCADE, + id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE, + date_debut DATE NOT NULL DEFAULT CURRENT_DATE, + date_fin DATE NULL, + cree_le TIMESTAMPTZ NOT NULL DEFAULT now(), + cree_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL +); + +CREATE INDEX idx_eam_am ON enfants_assistantes_maternelles(id_am); +CREATE INDEX idx_eam_enfant ON enfants_assistantes_maternelles(id_enfant); + +CREATE UNIQUE INDEX uq_enfant_garde_active + ON enfants_assistantes_maternelles (id_enfant) + WHERE date_fin IS NULL; + +-- ========================================================== +-- Table : dossier_famille (inscription parent — ticket #119) -- ========================================================== CREATE TABLE dossier_famille ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -276,10 +349,6 @@ CREATE TABLE notifications ( -- ========================================================== -- Table : validations -- ========================================================== --- Historique des décisions (validation / refus / suspension de compte). --- Colonnes commentaire + valide_par : requises par l’API Nest (TypeORM). --- FK en ON DELETE SET NULL : conserver la ligne si l’utilisateur référencé --- est supprimé (voir database/docs/FK_POLICIES.md). CREATE TABLE validations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE SET NULL, @@ -305,13 +374,10 @@ CREATE TABLE configuration ( modifie_par UUID REFERENCES utilisateurs(id) ); --- Index pour performance CREATE INDEX idx_configuration_cle ON configuration(cle); CREATE INDEX idx_configuration_categorie ON configuration(categorie); --- Seed initial de configuration INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES --- === Configuration Email (SMTP) === ('smtp_host', 'localhost', 'string', 'email', 'Serveur SMTP (ex: mail.mairie-bezons.fr, smtp.gmail.com)'), ('smtp_port', '25', 'number', 'email', 'Port SMTP (25, 465, 587)'), ('smtp_secure', 'false', 'boolean', 'email', 'Utiliser SSL/TLS (true pour port 465)'), @@ -320,14 +386,10 @@ INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES ('smtp_password', '', 'encrypted', 'email', 'Mot de passe SMTP (chiffré en AES-256)'), ('email_from_name', 'P''titsPas', 'string', 'email', 'Nom de l''expéditeur affiché dans les emails'), ('email_from_address', 'no-reply@ptits-pas.fr', 'string', 'email', 'Adresse email de l''expéditeur'), - --- === Configuration Application === ('app_name', 'P''titsPas', 'string', 'app', 'Nom de l''application (affiché dans l''interface)'), ('app_url', 'https://app.ptits-pas.fr', 'string', 'app', 'URL publique de l''application (pour les liens dans emails)'), ('app_logo_url', '/assets/logo.png', 'string', 'app', 'URL du logo de l''application'), ('setup_completed', 'false', 'boolean', 'app', 'Configuration initiale terminée'), - --- === Configuration Sécurité === ('password_reset_token_expiry_days', '7', 'number', 'security', 'Durée de validité des tokens de création/réinitialisation de mot de passe (en jours)'), ('jwt_expiry_hours', '24', 'number', 'security', 'Durée de validité des sessions JWT (en heures)'), ('max_upload_size_mb', '5', 'number', 'security', 'Taille maximale des fichiers uploadés (en MB)'), @@ -338,19 +400,18 @@ INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES -- ========================================================== CREATE TABLE documents_legaux ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - type VARCHAR(50) NOT NULL, -- 'cgu' ou 'privacy' - version INTEGER NOT NULL, -- Numéro de version (auto-incrémenté) - fichier_nom VARCHAR(255) NOT NULL, -- Nom original du fichier - fichier_path VARCHAR(500) NOT NULL, -- Chemin de stockage - fichier_hash VARCHAR(64) NOT NULL, -- Hash SHA-256 pour intégrité - actif BOOLEAN DEFAULT false, -- Version actuellement active - televerse_par UUID REFERENCES utilisateurs(id), -- Qui a uploadé - televerse_le TIMESTAMPTZ DEFAULT now(), -- Date d'upload - active_le TIMESTAMPTZ, -- Date d'activation - UNIQUE(type, version) -- Pas de doublon version + type VARCHAR(50) NOT NULL, + version INTEGER NOT NULL, + fichier_nom VARCHAR(255) NOT NULL, + fichier_path VARCHAR(500) NOT NULL, + fichier_hash VARCHAR(64) NOT NULL, + actif BOOLEAN DEFAULT false, + televerse_par UUID REFERENCES utilisateurs(id), + televerse_le TIMESTAMPTZ DEFAULT now(), + active_le TIMESTAMPTZ, + UNIQUE(type, version) ); --- Index pour performance CREATE INDEX idx_documents_legaux_type_actif ON documents_legaux(type, actif); CREATE INDEX idx_documents_legaux_version ON documents_legaux(type, version DESC); @@ -361,73 +422,23 @@ CREATE TABLE acceptations_documents ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE CASCADE, id_document UUID REFERENCES documents_legaux(id), - type_document VARCHAR(50) NOT NULL, -- 'cgu' ou 'privacy' - version_document INTEGER NOT NULL, -- Version acceptée - accepte_le TIMESTAMPTZ DEFAULT now(), -- Date d'acceptation - ip_address INET, -- IP de l'utilisateur (RGPD) - user_agent TEXT -- Navigateur (preuve) + type_document VARCHAR(50) NOT NULL, + version_document INTEGER NOT NULL, + accepte_le TIMESTAMPTZ DEFAULT now(), + ip_address INET, + user_agent TEXT ); --- Index pour performance CREATE INDEX idx_acceptations_utilisateur ON acceptations_documents(id_utilisateur); CREATE INDEX idx_acceptations_document ON acceptations_documents(id_document); --- ========================================================== --- Table : relais --- ========================================================== -CREATE TABLE relais ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - nom VARCHAR(255) NOT NULL, - adresse TEXT NOT NULL, - horaires_ouverture JSONB, - ligne_fixe VARCHAR(20), - actif BOOLEAN DEFAULT true, - notes TEXT, - cree_le TIMESTAMPTZ DEFAULT now(), - modifie_le TIMESTAMPTZ DEFAULT now() -); - --- ========================================================== --- Modification Table : utilisateurs (ajout colonnes documents et relais) --- ========================================================== -ALTER TABLE utilisateurs - ADD COLUMN IF NOT EXISTS cgu_version_acceptee INTEGER, - ADD COLUMN IF NOT EXISTS cgu_acceptee_le TIMESTAMPTZ, - ADD COLUMN IF NOT EXISTS privacy_version_acceptee INTEGER, - ADD COLUMN IF NOT EXISTS privacy_acceptee_le TIMESTAMPTZ, - ADD COLUMN IF NOT EXISTS relais_id UUID REFERENCES relais(id) ON DELETE SET NULL; - -- ========================================================== -- Ticket #103 : Numéro de dossier (AAAA-NNNNNN, séquence par année) -- ========================================================== -CREATE TABLE IF NOT EXISTS numero_dossier_sequence ( - annee INT PRIMARY KEY, - prochain INT NOT NULL DEFAULT 1 +CREATE TABLE numero_dossier_sequence ( + annee INT PRIMARY KEY, + prochain INT NOT NULL DEFAULT 1 ); -ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL; -ALTER TABLE assistantes_maternelles ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL; -ALTER TABLE parents ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL; -CREATE INDEX IF NOT EXISTS idx_utilisateurs_numero_dossier ON utilisateurs(numero_dossier) WHERE numero_dossier IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_assistantes_maternelles_numero_dossier ON assistantes_maternelles(numero_dossier) WHERE numero_dossier IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_parents_numero_dossier ON parents(numero_dossier) WHERE numero_dossier IS NOT NULL; - --- ========================================================== --- Ticket #110 : Token reprise après refus (lien email) --- ========================================================== -ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise VARCHAR(255) NULL; -ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL; -CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise ON utilisateurs(token_reprise) WHERE token_reprise IS NOT NULL; - --- ========================================================== --- Ticket #127 : Mot de passe oublié (token dédié, ≠ inscription) --- ========================================================== -ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS password_reset_token VARCHAR(255) NULL; -ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS password_reset_expires TIMESTAMPTZ NULL; -CREATE INDEX IF NOT EXISTS idx_utilisateurs_password_reset_token ON utilisateurs(password_reset_token) WHERE password_reset_token IS NOT NULL; - --- Lieu de naissance (aligné CREATE TABLE utilisateurs — idempotent si colonnes déjà présentes) -ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_ville VARCHAR(100) NULL; -ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_pays VARCHAR(100) NULL; -- ========================================================== -- Seed : Documents légaux génériques v1 @@ -438,10 +449,7 @@ INSERT INTO documents_legaux (type, version, fichier_nom, fichier_path, fichier_ -- ========================================================== -- Seed : Super Administrateur par défaut --- ========================================================== --- Email: admin@ptits-pas.fr --- Mot de passe: 4dm1n1strateur (hashé bcrypt) --- IMPORTANT: Changer ce mot de passe en production ! +-- Email: admin@ptits-pas.fr | Mot de passe: 4dm1n1strateur -- ========================================================== INSERT INTO utilisateurs ( email, diff --git a/database/README.md b/database/README.md index 98200b5..16846eb 100644 --- a/database/README.md +++ b/database/README.md @@ -15,12 +15,13 @@ Ce projet contient la **base de données** pour l'application PtitsPas, avec scr ## Structure du projet -- `migrations/` : scripts SQL pour la création et l'import de la base -- `bdd/data_test/` : fichiers CSV pour l'import de données de test -- `docs/` : documentation métier et technique -- `seed/` : scripts de seed +- **`BDD.sql`** : schéma canonique (création from scratch — utilisé par `docker-compose.yml` racine) +- `migrations/` : scripts SQL pour **mettre à jour** une BDD déjà en service +- `bdd/data_test/` : fichiers CSV (import legacy) +- `docs/` : documentation métier et technique (`ENUMS.md`, `FK_POLICIES.md`) +- `seed/` : scripts de seed (`03_seed_test_data.sql` — jeu dashboard admin) - `tests/` : tests SQL -- `docker-compose.dev.yml` : configuration Docker pour le développement +- `docker-compose.dev.yml` : Postgres standalone (BDD.sql + seed auto) --- diff --git a/database/bdd/data_test/enfants.csv b/database/bdd/data_test/enfants.csv index 1cf2ab6..43a2275 100644 --- a/database/bdd/data_test/enfants.csv +++ b/database/bdd/data_test/enfants.csv @@ -1,10 +1,10 @@ -"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo","est_multiple" -"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","actif","Emma","Dupont","F","2020-06-01",,,False,,False -"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","actif",,,,"2020-01-01","2025-01-01",,False,,False -"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","actif","Emma","Martin",,"2023-02-15",,,False,,False -"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","actif","Noah","Martin",,"2023-02-15",,,False,,False -"e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e","actif","Léa","Martin",,"2023-02-15",,,False,,False -"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","actif","Chloé","Rousseau",,"2022-04-20",,,False,,False -"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","actif","Hugo","Rousseau",,"2024-03-10",,,False,,False -"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","actif","Maxime","Lecomte",,"2023-04-15",,,False,,False -"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,,False +"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo","est_multiple" +"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","sans_garde","Emma","Dupont","F","2020-06-01",,,False,,False +"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","sans_garde",,,,"2020-01-01","2025-01-01",,False,,False +"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","sans_garde","Emma","Martin",,"2023-02-15",,,False,,False +"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","sans_garde","Noah","Martin",,"2023-02-15",,,False,,False +"e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e","sans_garde","Léa","Martin",,"2023-02-15",,,False,,False +"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","sans_garde","Chloé","Rousseau",,"2022-04-20",,,False,,False +"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","sans_garde","Hugo","Rousseau",,"2024-03-10",,,False,,False +"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","sans_garde","Maxime","Lecomte",,"2023-04-15",,,False,,False +"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,,False diff --git a/database/docker-compose.dev.yml b/database/docker-compose.dev.yml index 6c608be..74d6db2 100644 --- a/database/docker-compose.dev.yml +++ b/database/docker-compose.dev.yml @@ -14,9 +14,8 @@ services: ports: - "5433:5432" volumes: - - ./migrations/01_init.sql:/docker-entrypoint-initdb.d/01_init.sql - - ./migrations/07_import.sql:/docker-entrypoint-initdb.d/07_import.sql - - ./bdd/data_test:/bdd/data_test + - ./BDD.sql:/docker-entrypoint-initdb.d/01_init.sql + - ./seed/03_seed_test_data.sql:/docker-entrypoint-initdb.d/02_seed_test_data.sql - postgres_standalone_data:/var/lib/postgresql/data networks: - ptitspas_dev diff --git a/database/docs/ENUMS.md b/database/docs/ENUMS.md index c24e516..bda4d68 100644 --- a/database/docs/ENUMS.md +++ b/database/docs/ENUMS.md @@ -51,12 +51,17 @@ Ce document recense **toutes les valeurs énumérées** utilisées dans la base | Valeur | Description | |---|---| | `a_naitre` | Enfant à naître (date prévue renseignée) | -| `actif` | Enfant pris en charge / en cours de garde | +| `sans_garde` | Enfant né, pas encore placé chez une AM (défaut à l'inscription) | +| `garde` | Enfant placé chez une AM (`enfants_assistantes_maternelles` actif) | | `scolarise` | Enfant scolarisé, garde potentiellement périscolaire | **Contraintes associées** : - `a_naitre` → **`date_prevue_naissance` obligatoire** -- `actif`/`scolarise` → **`date_naissance` obligatoire** +- `sans_garde` / `garde` / `scolarise` → **`date_naissance` obligatoire** + +**Transitions** (API admin #131) : +- Rattachement AM ↔ enfant → `garde` (sauf `a_naitre` / `scolarise`) +- Détachement sans autre placement actif → `sans_garde` --- diff --git a/database/docs/FK_POLICIES.md b/database/docs/FK_POLICIES.md index d4686b1..2d861fa 100644 --- a/database/docs/FK_POLICIES.md +++ b/database/docs/FK_POLICIES.md @@ -32,6 +32,9 @@ Documenter, de façon unique et partagée, les règles de suppression/mise à jo | **parents(id_co_parent)** → `utilisateurs(id)` | **SET NULL** | Conserver le parent principal si co-parent disparaît | | **enfants_parents(id_parent)** → `parents(id_utilisateur)` | **CASCADE** | Nettoyage liaisons N:N | | **enfants_parents(id_enfant)** → `enfants(id)` | **CASCADE** | Idem | +| **enfants_assistantes_maternelles(id_am)** → `assistantes_maternelles(id_utilisateur)` | **CASCADE** | Placement supprimé avec l’AM | +| **enfants_assistantes_maternelles(id_enfant)** → `enfants(id)` | **CASCADE** | Idem | +| **enfants_assistantes_maternelles(cree_par)** → `utilisateurs(id)` | **SET NULL** | Historique placement conservé | | **dossiers(id_parent)** → `parents(id_utilisateur)` | **CASCADE** | Dossier n’a pas de sens sans parent | | **dossiers(id_enfant)** → `enfants(id)` | **CASCADE** | Dossier n’a pas de sens sans enfant | | **messages(id_dossier)** → `dossiers(id)` | **CASCADE** | Messages détruits avec le dossier | diff --git a/database/migrations/2026_enfants_assistantes_maternelles.sql b/database/migrations/2026_enfants_assistantes_maternelles.sql new file mode 100644 index 0000000..9175051 --- /dev/null +++ b/database/migrations/2026_enfants_assistantes_maternelles.sql @@ -0,0 +1,36 @@ +-- Ticket #131 / #141 — Placement AM ↔ enfant + statuts garde/sans_garde +-- ⚠️ BDD EXISTANTES UNIQUEMENT — ne pas exécuter sur une base créée via BDD.sql à jour. +-- Idempotent : safe à rejouer sur recette / prod. + +-- 1) Nouveaux statuts enfant +DO $$ BEGIN + ALTER TYPE statut_enfant_type ADD VALUE IF NOT EXISTS 'garde'; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TYPE statut_enfant_type ADD VALUE IF NOT EXISTS 'sans_garde'; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- actif → sans_garde (enfants sans placement AM connu au déploiement) +UPDATE enfants SET statut = 'sans_garde' WHERE statut = 'actif'; + +-- 2) Table de placement AM ↔ enfant +CREATE TABLE IF NOT EXISTS enfants_assistantes_maternelles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + id_am UUID NOT NULL REFERENCES assistantes_maternelles(id_utilisateur) ON DELETE CASCADE, + id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE, + date_debut DATE NOT NULL DEFAULT CURRENT_DATE, + date_fin DATE NULL, + cree_le TIMESTAMPTZ NOT NULL DEFAULT now(), + cree_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_eam_am ON enfants_assistantes_maternelles(id_am); +CREATE INDEX IF NOT EXISTS idx_eam_enfant ON enfants_assistantes_maternelles(id_enfant); + +-- Au plus une garde active par enfant +CREATE UNIQUE INDEX IF NOT EXISTS uq_enfant_garde_active + ON enfants_assistantes_maternelles (id_enfant) + WHERE date_fin IS NULL; diff --git a/database/seed/02_seed.sql b/database/seed/02_seed.sql index a0a109c..bd95d05 100644 --- a/database/seed/02_seed.sql +++ b/database/seed/02_seed.sql @@ -65,12 +65,12 @@ ON CONFLICT (id_utilisateur) DO NOTHING; -- ------------------------------------------------------------ -- Enfants --- - child A : déjà né (statut = 'actif' et date_naissance requise) +-- - child A : déjà né (statut = 'sans_garde' et date_naissance requise) -- - child B : à naître (statut = 'a_naitre' et date_prevue_naissance requise) -- ------------------------------------------------------------ INSERT INTO enfants (id, prenom, nom, statut, date_naissance, jumeau_multiple) -VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'actif', '2022-04-12', false) +VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'sans_garde', '2022-04-12', false) ON CONFLICT (id) DO NOTHING; INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance, jumeau_multiple) diff --git a/database/seed/03_seed_test_data.sql b/database/seed/03_seed_test_data.sql index 6d54592..8a54c7f 100644 --- a/database/seed/03_seed_test_data.sql +++ b/database/seed/03_seed_test_data.sql @@ -18,15 +18,15 @@ BEGIN; INSERT INTO utilisateurs (id, email, password, prenom, nom, role, statut, telephone, adresse, ville, code_postal, profession, situation_familiale, date_naissance, consentement_photo) VALUES - ('a0000001-0001-0001-0001-000000000001', 'sophie.bernard@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Sophie', 'BERNARD', 'administrateur', 'actif', '0678123456', '12 Avenue Gabriel Péri', 'Bezons', '95870', 'Responsable administrative', 'marie', '1978-03-15', false), - ('a0000002-0002-0002-0002-000000000002', 'lucas.moreau@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Lucas', 'MOREAU', 'gestionnaire', 'actif', '0687234567', '8 Rue Jean Jaurès', 'Bezons', '95870', 'Gestionnaire des placements', 'celibataire', '1985-09-22', false), - ('a0000003-0003-0003-0003-000000000003', 'marie.dubois@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Marie', 'DUBOIS', 'assistante_maternelle', 'actif', '0696345678', '25 Rue de la République', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1980-06-08', true), - ('a0000004-0004-0004-0004-000000000004', 'fatima.elmansouri@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Fatima', 'EL MANSOURI', 'assistante_maternelle', 'actif', '0675456789', '17 Boulevard Aristide Briand', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1975-11-12', true), - ('a0000005-0005-0005-0005-000000000005', 'claire.martin@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Claire', 'MARTIN', 'parent', 'actif', '0689567890', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Infirmière', 'marie', '1990-04-03', false), - ('a0000006-0006-0006-0006-000000000006', 'thomas.martin@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Thomas', 'MARTIN', 'parent', 'actif', '0678456789', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Ingénieur', 'marie', '1988-07-18', false), - ('a0000007-0007-0007-0007-000000000007', 'amelie.durand@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Amélie', 'DURAND', 'parent', 'actif', '0667788990', '23 Rue Victor Hugo', 'Bezons', '95870', 'Comptable', 'divorce', '1987-12-14', false), - ('a0000008-0008-0008-0008-000000000008', 'julien.rousseau@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Julien', 'ROUSSEAU', 'parent', 'actif', '0656677889', '14 Rue Pasteur', 'Bezons', '95870', 'Commercial', 'divorce', '1985-08-29', false), - ('a0000009-0009-0009-0009-000000000009', 'david.lecomte@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'David', 'LECOMTE', 'parent', 'actif', '0645566778', '31 Rue Émile Zola', 'Bezons', '95870', 'Développeur web', 'parent_isole', '1992-10-07', false) + ('a0000001-0001-0001-0001-000000000001', 'sophie.bernard@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Sophie', 'BERNARD', 'administrateur', 'actif', '0678123456', '12 Avenue Gabriel Péri', 'Bezons', '95870', 'Responsable administrative', 'marie', '1978-03-15', false), + ('a0000002-0002-0002-0002-000000000002', 'lucas.moreau@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Lucas', 'MOREAU', 'gestionnaire', 'actif', '0687234567', '8 Rue Jean Jaurès', 'Bezons', '95870', 'Gestionnaire des placements', 'celibataire', '1985-09-22', false), + ('a0000003-0003-0003-0003-000000000003', 'marie.dubois@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Marie', 'DUBOIS', 'assistante_maternelle', 'actif', '0696345678', '25 Rue de la République', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1980-06-08', true), + ('a0000004-0004-0004-0004-000000000004', 'fatima.elmansouri@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Fatima', 'EL MANSOURI', 'assistante_maternelle', 'actif', '0675456789', '17 Boulevard Aristide Briand', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1975-11-12', true), + ('a0000005-0005-0005-0005-000000000005', 'claire.martin@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Claire', 'MARTIN', 'parent', 'actif', '0689567890', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Infirmière', 'marie', '1990-04-03', false), + ('a0000006-0006-0006-0006-000000000006', 'thomas.martin@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Thomas', 'MARTIN', 'parent', 'actif', '0678456789', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Ingénieur', 'marie', '1988-07-18', false), + ('a0000007-0007-0007-0007-000000000007', 'amelie.durand@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Amélie', 'DURAND', 'parent', 'actif', '0667788990', '23 Rue Victor Hugo', 'Bezons', '95870', 'Comptable', 'divorce', '1987-12-14', false), + ('a0000008-0008-0008-0008-000000000008', 'julien.rousseau@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Julien', 'ROUSSEAU', 'parent', 'actif', '0656677889', '14 Rue Pasteur', 'Bezons', '95870', 'Commercial', 'divorce', '1985-08-29', false), + ('a0000009-0009-0009-0009-000000000009', 'david.lecomte@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'David', 'LECOMTE', 'parent', 'actif', '0645566778', '31 Rue Émile Zola', 'Bezons', '95870', 'Développeur web', 'parent_isole', '1992-10-07', false) ON CONFLICT (email) DO NOTHING; -- ========== PARENTS (avec co-parent pour le couple Martin) ========== @@ -51,12 +51,12 @@ ON CONFLICT (id_utilisateur) DO NOTHING; -- ========== ENFANTS ========== INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut, est_multiple) VALUES - ('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'actif', true), - ('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'actif', true), - ('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'actif', true), - ('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'actif', false), - ('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'actif', false), - ('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'actif', false) + ('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'sans_garde', true), + ('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'sans_garde', true), + ('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'sans_garde', true), + ('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'sans_garde', false), + ('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'sans_garde', false), + ('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'sans_garde', false) ON CONFLICT (id) DO NOTHING; -- ========== ENFANTS_PARENTS (liaison N:N) ========== 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/docs/archive/temporaires/131-fiche-parent-co-parent-header.md b/docs/archive/temporaires/131-fiche-parent-co-parent-header.md new file mode 100644 index 0000000..6f8a3c8 --- /dev/null +++ b/docs/archive/temporaires/131-fiche-parent-co-parent-header.md @@ -0,0 +1,127 @@ +# #131 — En-tête fiche parent : co-parent (note front → back) + +**Ticket :** #131 (fiche parent dashboard, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1) +**Date :** 2026-06-01 +**Statut front :** livré (en-tête dynamique) +**Modif backend demandée :** **aucune fonctionnelle** — ce document fixe le contrat attendu ; le back valide `co_parent` et masque les champs sensibles. + +--- + +## 1. Comportement UI (front) + +Dans la modale **fiche parent** (`AdminParentEditModal`) : + +| Zone | Contenu | +|------|---------| +| **Titre** | `prenom` + `nom` du parent affiché (plus le libellé fixe « Fiche parent ») | +| **Sous-titre** | `Co-parent : {prenom} {nom}` — affiché **uniquement** si un co-parent est connu | + +Le titre se met à jour en direct pendant l’édition des champs nom/prénom. +Le sous-titre provient du co-parent **chargé depuis l’API** (pas saisi à la main dans la modale). + +--- + +## 2. Endpoints consommés + +| Méthode | Route | Usage front | +|---------|-------|-------------| +| `GET` | `/api/v1/parents` | Liste parents (onglet Parents) | +| `GET` | `/api/v1/parents/:userId` | Rechargement fiche après rattachement/détachement enfant | +| `PATCH` | `/api/v1/parents/:userId/fiche` | Sauvegarde identité + statut (inchangé) | + +Rôles : `super_admin`, `gestionnaire`, `administrateur` (selon route). + +--- + +## 3. Contrat JSON attendu pour `co_parent` + +Le front parse `ParentModel.fromJson` avec la clé **`co_parent`** (snake_case), objet utilisateur imbriqué. + +### Champs minimum utilisés pour le sous-titre + +| Clé JSON | Usage | +|----------|--------| +| `co_parent` | Objet ou absent/`null` | +| `co_parent.id` | Identifiant (futur lien cliquable éventuel) | +| `co_parent.prenom` | Affichage | +| `co_parent.nom` | Affichage | + +Affichage front : `'{prenom} {nom}'.trim()` → libellé `Co-parent : …`. + +### Exemple de fragment de réponse (`GET /parents/:id`) + +```json +{ + "user_id": "33333333-3333-3333-3333-333333333333", + "numero_dossier": "2026-000042", + "user": { + "id": "33333333-3333-3333-3333-333333333333", + "email": "parent1@example.com", + "prenom": "Paul", + "nom": "PARENT", + "statut": "actif", + "telephone": "0601020304" + }, + "co_parent": { + "id": "44444444-4444-4444-4444-444444444444", + "email": "coparent1@example.com", + "prenom": "Clara", + "nom": "COPARENT", + "role": "parent", + "statut": "actif" + }, + "parentChildren": [] +} +``` + +> **Note :** le front lit `user` (pas `utilisateur`). La doc `11_API.md` § Parents mentionne encore `utilisateur` / `id_co_parent` seul — le contrat **effectif** côté Nest/TypeORM est l’entité `Parents` sérialisée (`user`, `co_parent`, `parentChildren`, …). + +--- + +## 4. État backend + +### Relations (déjà en place) + +- `findAll()` et `findOne(user_id)` chargent **`co_parent`** ; +- FK : `parents.id_co_parent` → `utilisateurs.id` ; +- inscription couple : les deux sens renseignés en principe (`auth.service.ts`). + +### Livraison back (#131) + +- `mapParentForApi` / `sanitizeUserForApi` : réponses `GET/PATCH/POST/DELETE` parents **sans** `password`, `token_creation_mdp`, `password_reset_*` sur `user` et `co_parent`. + +**Checklist validation :** + +- [x] `GET /parents/:id` renvoie `co_parent` peuplé quand `id_co_parent` est non null +- [x] `GET /parents` (liste) inclut `co_parent` +- [x] `prenom` / `nom` du co-parent présents +- [x] Pas de fuite `password` / tokens sur `user` ni `co_parent` + +--- + +## 5. Points d’attention (hors périmètre immédiat) + +| Sujet | Détail | +|-------|--------| +| **Lien inverse** | Si B est co-parent de A (`A.id_co_parent = B`) mais `B.id_co_parent` est `null`, le sous-titre **ne s’affichera pas** sur la fiche de B. Pas de résolution inverse côté front. | +| **Familles > 2 adultes** | Sous-titre = co-parent direct (`id_co_parent`) uniquement. | +| **Trou AM ↔ enfants en garde** | Pas de lien AM–enfant aujourd’hui (à documenter / traiter plus tard). | + +--- + +## 6. Fichiers back concernés + +| Fichier | Rôle | +|---------|------| +| `backend/src/routes/parents/parents.service.ts` | `findOne`, `findAll` + relations | +| `backend/src/routes/parents/parents.controller.ts` | `mapParentForApi` sur les réponses | +| `backend/src/routes/parents/parents.mapper.ts` | Sérialisation API | +| `backend/src/common/utils/sanitize-user-for-api.ts` | Masquage secrets | +| `backend/src/entities/parents.entity.ts` | relation `co_parent` | + +--- + +## 7. Références + +- `docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1 +- Ticket Gitea **#131** diff --git a/docs/archive/temporaires/TEMP_112-back-reprise-alignement-front.md b/docs/archive/temporaires/TEMP_112-back-reprise-alignement-front.md new file mode 100644 index 0000000..65eff55 --- /dev/null +++ b/docs/archive/temporaires/TEMP_112-back-reprise-alignement-front.md @@ -0,0 +1,244 @@ +# #112 — Alignement front après évolution back (reprise dossier complet) + +**Branche déployée :** `feature/112-reprise-apres-refus-front` +**Commit back :** `d70577b1` — `feat(#112): reprise après refus — dossier complet GET/PATCH` +**Date :** 2026-06-16 + +Ce document décrit le **contrat API réel** après extension du back, et ce que le front doit encore brancher pour exploiter le dossier complet (au-delà de l’identité seule). + +--- + +## 1. Endpoints (inchangés côté URL) + +| Méthode | Route | Auth | +|---------|-------|------| +| `GET` | `/api/v1/auth/reprise-dossier?token={uuid}` | Public | +| `PATCH` | `/api/v1/auth/reprise-resoumettre` | Public | +| `POST` | `/api/v1/auth/reprise-identify` | Public (inchangé) | + +> **Note :** le ticket #111 parlait de `PUT` ; l’implémentation reste en **`PATCH`** (comme avant). + +--- + +## 2. `GET /auth/reprise-dossier` — réponse enrichie + +### Champs communs (toujours présents) + +Identiques à avant : `id`, `email`, `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal`, `numero_dossier`, `role`, `photo_url`, `genre`, `situation_familiale`. + +### Rôle `parent` (+ champs #119) + +Alignés sur `DossierFamilleCompletDto` : + +```json +{ + "parents": [ + { + "user_id": "uuid", + "email": "…", + "prenom": "…", + "nom": "…", + "telephone": "…", + "adresse": "…", + "ville": "…", + "code_postal": "…", + "statut": "refuse", + "co_parent_id": "uuid-parent-entity" + } + ], + "enfants": [ + { + "id": "uuid-enfant", + "first_name": "Emma", + "last_name": "MARTIN", + "genre": "F", + "status": "actif", + "birth_date": "2023-02-15T00:00:00.000Z", + "due_date": null, + "photo_url": "/uploads/photos/…", + "consent_photo": true, + "est_multiple": false + } + ], + "texte_motivation": "Nous recherchons…" +} +``` + +**Mapping front suggéré :** + +| JSON back | Modèle / wizard parent | +|-----------|-------------------------| +| `parents[]` | `UserRegistrationData.parent1` + `parent2` (matcher par `email` ou ordre : titulaire = `id` du GET racine) | +| `enfants[].first_name` / `last_name` | `ChildData.firstName` / `lastName` | +| `enfants[].birth_date` | `ChildData.birthDate` (ISO → `DateTime`) | +| `enfants[].due_date` | `ChildData.dueDate` (enfant `a_naitre`) | +| `enfants[].status` | `actif` = né, `a_naitre` = à naître | +| `enfants[].photo_url` | `ApiConfig.absoluteMediaUrl()` + conserver pour reprise sans re-upload | +| `enfants[].id` | **Obligatoire** pour le PATCH (update par id) | +| `enfants[].est_multiple` | `grossesse_multiple` si utilisé | +| `texte_motivation` | étape présentation / motivation | + +Si `numero_dossier` absent : pas de `parents[]` / `enfants[]` / `texte_motivation` (identité seule). + +### Rôle `assistante_maternelle` + +Champs racine + fiche pro (structure **aplatie**, pas de sous-objet `user`) : + +```json +{ + "consentement_photo": true, + "date_naissance": "1985-03-12T00:00:00.000Z", + "lieu_naissance_ville": "Paris", + "lieu_naissance_pays": "France", + "numero_agrement": "AGR-2024-12345", + "nir": "123456789012345", + "date_agrement": "2024-06-01T00:00:00.000Z", + "nb_max_enfants": 4, + "place_disponible": 2, + "biographie": "…" +} +``` + +**Mapping `AmRegistrationData` :** + +| JSON back | Champ front | +|-----------|-------------| +| `nb_max_enfants` | `capaciteAccueil` | +| `place_disponible` | `placesDisponibles` | +| `numero_agrement` | `numeroAgrement` | +| `biographie` | `biographie` / présentation | +| `photo_url` | déjà géré via `RepriseSession.photoUrl` | + +--- + +## 3. `PATCH /auth/reprise-resoumettre` — body étendu + +### Commun + +```json +{ "token": "uuid-reprise" } +``` + +### Parent — champs à envoyer depuis le wizard + +| Champ PATCH | Source wizard | Notes | +|-------------|---------------|-------| +| `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal` | Parent 1 (titulaire token) | Champs racine | +| `co_parent_prenom`, `co_parent_nom`, `co_parent_telephone` | Parent 2 | | +| `co_parent_meme_adresse`, `co_parent_adresse`, `co_parent_code_postal`, `co_parent_ville` | Parent 2 adresse | | +| `texte_motivation` **ou** `presentation_dossier` | Étape motivation | Les deux alias acceptés | +| `enfants[]` | Liste enfants | Voir ci-dessous | + +**Structure `enfants[]` (miroir inscription + `id` obligatoire) :** + +```json +{ + "id": "uuid-enfant-existant", + "prenom": "Emma", + "nom": "MARTIN", + "date_naissance": "2023-02-15", + "date_previsionnelle_naissance": null, + "genre": "F", + "photo_base64": "data:image/jpeg;base64,…", + "photo_filename": "emma.jpg", + "grossesse_multiple": false +} +``` + +- **v1 back :** update par `id` uniquement — pas de création/suppression d’enfant. +- Si `id` inconnu pour ce dossier → **400** `Enfant inconnu pour ce dossier : {id}`. +- Sans nouvelle photo : ne pas envoyer `photo_base64` (l’existant est conservé). + +### AM — champs à envoyer + +| Champ PATCH | Source | +|-------------|--------| +| Identité + `photo_url` ou `photo_base64` + `photo_filename` | Étapes 1–2 | +| `consentement_photo`, `date_naissance`, `lieu_naissance_ville`, `lieu_naissance_pays` | Identité | +| `numero_agrement`, `nir`, `date_agrement` | Pro | +| `capacite_accueil`, `places_disponibles` | Pro | +| `biographie` | Présentation | + +Validation NIR identique à l’inscription si `nir` fourni. + +### Réponse succès (nouveau format) + +```json +{ + "message": "Dossier resoumis avec succès. Il est de nouveau en attente de validation.", + "statut": "en_attente", + "user_id": "uuid", + "numero_dossier": "2026-000021" +} +``` + +Code HTTP : **200** (pas de corps `Users` brut comme l’ancien back). + +### Effet métier + +- **Parent :** tous les users `role=parent` avec le même `numero_dossier` passent en `en_attente` ; `token_reprise` invalidé sur **tous** (symétrique refus #110). +- **AM :** un seul user. + +### E-mail accusé resoumission (parent) + +Après `PATCH` réussi, un e-mail est envoyé à **chaque parent** du dossier (`sendResoumissionPendingEmail`) : +- confirmation de resoumission ; +- rappel du **numéro de dossier** ; +- mention « en attente de validation ». + +Échec SMTP : logué, **ne bloque pas** la resoumission (même règle que l'inscription initiale). + +--- + +## 4. Fichiers front à modifier (checklist) + +### Modèles + +- [ ] `lib/models/reprise_dossier.dart` — parser `parents[]`, `enfants[]`, `texte_motivation`, champs AM +- [ ] Réutiliser ou mapper vers `DossierFamilleEnfant` / structures existantes (#119 admin) si possible + +### Session / préremplissage + +- [ ] `lib/services/reprise_session.dart` + - `applyToParent` : remplir parent1/parent2 depuis `parents[]`, enfants, motivation + - `applyToAm` : remplir tous les champs AM + +### API + +- [ ] `lib/services/auth_service.dart` — `resoumettreReprise()` : accepter body complet (parent + AM), pas seulement identité +- [ ] Étendre `UserRegistrationData` / `AmRegistrationData` helpers `toReprisePatchBody()` si utile + +### Écrans fin de parcours + +- [ ] `parent_register_step5_screen.dart` — PATCH avec co-parent, enfants, motivation +- [ ] `am_register_step4_screen.dart` — PATCH avec fiche AM complète + +### Hors scope back (inchangé) + +RIB / IBAN / attestation CAF (étape 5 wizard parent) : **non persistés** — rien à envoyer en reprise. + +### Non implémenté front (ticket #112 initial) + +- [ ] Modale login « J’ai un numéro de dossier » → `POST /auth/reprise-identify` (back prêt, front absent) + +--- + +## 5. Tests manuels suggérés + +1. Refuser un dossier parent complet (≥1 enfant + co-parent + motivation). +2. Ouvrir le lien mail `/reprise?token=…`. +3. Vérifier dans DevTools que le GET contient `enfants[]` et `texte_motivation`. +4. Après branchement front : wizard prérempli sur toutes les étapes. +5. Resoumettre → statut `en_attente` pour les deux parents ; dossier visible file validation admin (#119). + +--- + +## 6. Références code back + +``` +backend/src/routes/auth/dto/reprise-dossier.dto.ts +backend/src/routes/auth/dto/resoumettre-reprise.dto.ts +backend/src/routes/auth/dto/enfant-reprise.dto.ts +backend/src/routes/auth/auth.service.ts → getRepriseDossier, resoumettreReprise +backend/src/routes/parents/dto/dossier-famille-complet.dto.ts +``` diff --git a/docs/archive/temporaires/TEMP_131-back-fiche-am-enfants.md b/docs/archive/temporaires/TEMP_131-back-fiche-am-enfants.md new file mode 100644 index 0000000..870ef51 --- /dev/null +++ b/docs/archive/temporaires/TEMP_131-back-fiche-am-enfants.md @@ -0,0 +1,132 @@ +# #131 — Fiche AM éditable + affiliation enfants (note front → back) + +**Ticket :** #131 (partie AM, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1) +**Date :** 2026-06-01 +**Statut front :** modale livrée (2 onglets) — **API affiliation AM↔enfant à implémenter** + +--- + +## 1. Comportement UI (front) + +Modale `AdminAmEditModal` — même shell que la fiche parent (~930 px) : + +| Onglet | Contenu | +|--------|---------| +| **Identité & professionnel** | `IdentityBlock` éditable + grille pro (agrément, ville résidence, capacité, places, NIR/agrément date en lecture seule, biographie, switch disponible) + gélule statut | +| **Enfants accueillis** | Liste cartes enfants (réutilise `AdminChildrenAffiliationPanel` / `AdminEnfantUserCard`) + rattacher / détacher | + +En-tête : prénom nom · sous-titre `Zone · Agrément · Dossier`. + +--- + +## 2. Endpoints consommés + +### Déjà existants (partiels) + +| Méthode | Route | Usage | +|---------|-------|-------| +| `GET` | `/api/v1/assistantes-maternelles` | Liste AM | +| `GET` | `/api/v1/assistantes-maternelles/:userId` | Détail (403 possible pour `administrateur` → fallback liste) | +| `PATCH` | `/api/v1/users/:userId` | Identité + statut (admin / super_admin uniquement) | +| `PATCH` | `/api/v1/assistantes-maternelles/:userId` | Champs pro (gestionnaire / super_admin) | + +### À créer (recommandé — miroir parent #131 / #115) + +| Méthode | Route | Rôle | +|---------|-------|------| +| `PATCH` | `/api/v1/assistantes-maternelles/:userId/fiche` | Mise à jour unifiée identité + pro + statut (`super_admin`, `gestionnaire`, `administrateur`) | +| `POST` | `/api/v1/assistantes-maternelles/:userId/enfants/:enfantId` | Rattacher un enfant | +| `DELETE` | `/api/v1/assistantes-maternelles/:userId/enfants/:enfantId` | Détacher un enfant | +| `GET` | `/api/v1/assistantes-maternelles/:userId` | Inclure `amChildren[]` (relation enfant) | + +Le front appelle déjà ces routes ; en l’absence de `PATCH …/fiche`, il tente un fallback `PATCH users` + `PATCH assistantes-maternelles` (échoue selon le rôle connecté). + +--- + +## 3. Modèle de données affiliation AM ↔ enfant + +**À définir côté BDD** (pas de table dédiée aujourd’hui, contrairement à `enfants_parents`) : + +Proposition alignée parent : + +```sql +-- Piste : enfants_assistantes_maternelles +CREATE TABLE enfants_assistantes_maternelles ( + id_am UUID NOT NULL REFERENCES utilisateurs(id) ON DELETE CASCADE, + id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE, + PRIMARY KEY (id_am, id_enfant) +); +``` + +Réponse API attendue sur `GET /assistantes-maternelles/:id` : + +```json +{ + "user_id": "uuid-am", + "user": { "id": "…", "prenom": "Claire", "nom": "MARTIN", "statut": "actif" }, + "approval_number": "AGR-2024-12345", + "residence_city": "Bezons", + "max_children": 4, + "places_available": 2, + "available": true, + "amChildren": [ + { + "child": { + "id": "uuid-enfant", + "first_name": "Emma", + "last_name": "MARTIN", + "status": "actif", + "birth_date": "2023-02-15" + } + } + ] +} +``` + +Le front parse `amChildren` / `am_children` / `assistanteChildren` (même logique que `parentChildren`). + +--- + +## 4. Body `PATCH …/fiche` suggéré + +```json +{ + "nom": "MARTIN", + "prenom": "Claire", + "email": "claire@example.com", + "telephone": "0612345678", + "adresse": "5 place Bellecour", + "ville": "Lyon", + "code_postal": "69002", + "statut": "actif", + "approval_number": "AGR-2024-12345", + "residence_city": "Lyon", + "max_children": 4, + "places_available": 2, + "biography": "…", + "available": true +} +``` + +NIR et date d’agrément : lecture seule dans la modale (modification hors périmètre admin v1). + +--- + +## 5. Fichiers front concernés + +| Fichier | Rôle | +|---------|------| +| `frontend/lib/widgets/admin/common/admin_am_edit_modal.dart` | Modale 2 onglets | +| `frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart` | Liste enfants partagée parent/AM | +| `frontend/lib/widgets/admin/common/admin_status_capsule.dart` | Gélule statut partagée | +| `frontend/lib/models/assistante_maternelle_model.dart` | Parse champs pro + `amChildren` | +| `frontend/lib/services/user_service.dart` | `getAssistanteMaternelle`, `updateAmFiche`, `attachEnfantToAm`, `detachEnfantFromAm` | +| `frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart` | Ouverture modale au clic Modifier | + +--- + +## 6. Références + +- Fiche parent : `PATCH /parents/:id/fiche`, `POST|DELETE /parents/:id/enfants/:enfantId` +- Ticket Gitea **#131**, **#115** +- `docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md` diff --git a/docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md b/docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md new file mode 100644 index 0000000..43ac761 --- /dev/null +++ b/docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md @@ -0,0 +1,124 @@ +# #131 — En-tête fiche parent : co-parent (note front → back) + +**Ticket :** #131 (fiche parent dashboard, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1) +**Date :** 2026-06-01 +**Statut front :** livré (en-tête dynamique) +**Modif backend demandée :** **aucune** — ce document fixe le contrat attendu et invite à valider que l’existant le couvre. + +--- + +## 1. Comportement UI (front) + +Dans la modale **fiche parent** (`AdminParentEditModal`) : + +| Zone | Contenu | +|------|---------| +| **Titre** | `prenom` + `nom` du parent affiché (plus le libellé fixe « Fiche parent ») | +| **Sous-titre** | `Co-parent : {prenom} {nom}` — affiché **uniquement** si un co-parent est connu | + +Le titre se met à jour en direct pendant l’édition des champs nom/prénom. +Le sous-titre provient du co-parent **chargé depuis l’API** (pas saisi à la main dans la modale). + +--- + +## 2. Endpoints consommés + +| Méthode | Route | Usage front | +|---------|-------|-------------| +| `GET` | `/api/v1/parents` | Liste parents (onglet Parents) | +| `GET` | `/api/v1/parents/:userId` | Rechargement fiche après rattachement/détachement enfant | +| `PATCH` | `/api/v1/parents/:userId/fiche` | Sauvegarde identité + statut (inchangé) | + +Rôles : `super_admin`, `gestionnaire`, `administrateur` (selon route). + +--- + +## 3. Contrat JSON attendu pour `co_parent` + +Le front parse `ParentModel.fromJson` avec la clé **`co_parent`** (snake_case), objet utilisateur imbriqué. + +### Champs minimum utilisés pour le sous-titre + +| Clé JSON | Usage | +|----------|--------| +| `co_parent` | Objet ou absent/`null` | +| `co_parent.id` | Identifiant (futur lien cliquable éventuel) | +| `co_parent.prenom` | Affichage | +| `co_parent.nom` | Affichage | + +Affichage front : `'{prenom} {nom}'.trim()` → libellé `Co-parent : …`. + +### Exemple de fragment de réponse (`GET /parents/:id`) + +```json +{ + "user_id": "33333333-3333-3333-3333-333333333333", + "numero_dossier": "2026-000042", + "user": { + "id": "33333333-3333-3333-3333-333333333333", + "email": "parent1@example.com", + "prenom": "Paul", + "nom": "PARENT", + "statut": "actif", + "telephone": "0601020304" + }, + "co_parent": { + "id": "44444444-4444-4444-4444-444444444444", + "email": "coparent1@example.com", + "prenom": "Clara", + "nom": "COPARENT", + "role": "parent", + "statut": "actif" + }, + "parentChildren": [] +} +``` + +> **Note :** le front lit `user` (pas `utilisateur`). La doc `11_API.md` § Parents mentionne encore `utilisateur` / `id_co_parent` seul — le contrat **effectif** côté Nest/TypeORM est l’entité `Parents` sérialisée (`user`, `co_parent`, `parentChildren`, …). + +--- + +## 4. État backend (à valider, pas à refaire) + +D’après le code actuel (`parents.service.ts`) : + +- `findAll()` et `findOne(user_id)` chargent déjà la relation **`co_parent`** ; +- la FK métier est `parents.id_co_parent` → `utilisateurs.id` ; +- à l’inscription couple, les deux sens sont en principe renseignés (`auth.service.ts`). + +**Checklist validation back :** + +- [ ] `GET /parents/:id` renvoie bien `co_parent` peuplé quand `id_co_parent` est non null +- [ ] `GET /parents` (liste) inclut aussi `co_parent` (sous-titre disponible dès l’ouverture sans re-fetch) +- [ ] Les champs `prenom` / `nom` du co-parent sont présents dans la réponse JSON + +Si ces trois points passent en recette, **aucun changement backend n’est nécessaire** pour cette fonctionnalité. + +--- + +## 5. Points d’attention (hors périmètre immédiat) + +| Sujet | Détail | +|-------|--------| +| **Lien inverse** | Si le parent B est le co-parent de A (`A.id_co_parent = B`) mais que `B.id_co_parent` est `null`, le sous-titre **ne s’affichera pas** sur la fiche de B. Le front ne fait pas de résolution inverse. À traiter côté back **seulement si** des données legacy ont un lien à sens unique. | +| **Familles > 2 adultes** | Le sous-titre n’affiche que le co-parent direct (`id_co_parent`). Les autres responsables liés uniquement via `enfants_parents` ne sont pas listés ici (cf. doc 28 §6). | +| **Données sensibles** | Vérifier que la sérialisation de `co_parent` n’expose pas `password` / tokens (même remarque que pour `user`). | + +--- + +## 6. Fichiers front concernés + +| Fichier | Rôle | +|---------|------| +| `frontend/lib/models/parent_model.dart` | Parse `co_parent` → `AppUser? coParent` | +| `frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart` | Titre + sous-titre | +| `frontend/lib/services/user_service.dart` | `getParents()` / `getParent()` | + +--- + +## 7. Références + +- `docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1 +- `backend/src/routes/parents/parents.service.ts` — `findOne`, `findAll` +- `backend/src/entities/parents.entity.ts` — relation `co_parent` +- Ticket Gitea **#131** diff --git a/docs/serveur_ptitspas.code-workspace b/docs/serveur_ptitspas.code-workspace new file mode 100644 index 0000000..ef9f5d2 --- /dev/null +++ b/docs/serveur_ptitspas.code-workspace @@ -0,0 +1,7 @@ +{ + "folders": [ + { + "path": "." + } + ] +} \ No newline at end of file diff --git a/frontend/lib/models/assistante_maternelle_model.dart b/frontend/lib/models/assistante_maternelle_model.dart index 2d53fb0..9a8cb96 100644 --- a/frontend/lib/models/assistante_maternelle_model.dart +++ b/frontend/lib/models/assistante_maternelle_model.dart @@ -1,30 +1,102 @@ +import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/models/user.dart'; class AssistanteMaternelleModel { final AppUser user; final String? approvalNumber; + final String? nir; final String? residenceCity; final int? maxChildren; final int? placesAvailable; + final String? biography; + final bool? available; + final String? agreementDate; + final List children; AssistanteMaternelleModel({ required this.user, this.approvalNumber, + this.nir, this.residenceCity, this.maxChildren, this.placesAvailable, + this.biography, + this.available, + this.agreementDate, + this.children = const [], }); factory AssistanteMaternelleModel.fromJson(Map json) { - final userJson = json['user'] ?? json; + final root = Map.from(json); + final userJson = Map.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 = _parseChildren(root); + return AssistanteMaternelleModel( user: user, - approvalNumber: json['numero_agrement'] as String?, - residenceCity: json['ville_residence'] as String?, - maxChildren: json['nb_max_enfants'] as int?, - placesAvailable: json['place_disponible'] as int?, + approvalNumber: _str( + root['approval_number'] ?? root['numero_agrement'], + ), + nir: _str(root['nir'] ?? root['nir_chiffre']), + residenceCity: _str( + root['residence_city'] ?? root['ville_residence'], + ), + maxChildren: _int(root['max_children'] ?? root['nb_max_enfants']), + placesAvailable: _int( + root['places_available'] ?? root['place_disponible'], + ), + biography: _str(root['biography'] ?? root['biographie']), + available: root['available'] as bool? ?? root['disponible'] as bool?, + agreementDate: _dateString( + root['agreement_date'] ?? root['date_agrement'], + ), + children: children, ); } + + static String? _str(dynamic v) { + if (v == null) return null; + final s = v.toString().trim(); + return s.isEmpty ? null : s; + } + + static int? _int(dynamic v) { + if (v == null) return null; + if (v is int) return v; + return int.tryParse(v.toString()); + } + + static String? _dateString(dynamic v) { + if (v == null) return null; + return v.toString().split('T').first; + } + + static List _parseChildren(Map json) { + final children = []; + final seen = {}; + + 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['amChildren'] ?? + json['am_children'] ?? + json['assistanteChildren'] ?? + json['assistante_children']; + if (links is List) { + for (final link in links) { + if (link is! Map) continue; + add(ParentChildSummary.fromParentChildLink( + Map.from(link), + )); + } + } + + return children; + } } diff --git a/frontend/lib/models/dossier_unifie.dart b/frontend/lib/models/dossier_unifie.dart index 07b2d56..78b2fb3 100644 --- a/frontend/lib/models/dossier_unifie.dart +++ b/frontend/lib/models/dossier_unifie.dart @@ -1,4 +1,5 @@ import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; /// Réponse unifiée GET /dossiers/:numeroDossier. Ticket #119, #107. class DossierUnifie { @@ -220,7 +221,7 @@ class EnfantDossier { lastName: (json['last_name'] ?? json['nom'])?.toString(), birthDate: json['birth_date']?.toString(), gender: (json['gender'] ?? json['genre'])?.toString(), - status: json['status']?.toString(), + status: normalizeEnfantStatus(json['status']?.toString()), dueDate: json['due_date']?.toString(), photoUrl: resolvedPhoto, consentPhoto: diff --git a/frontend/lib/models/enfant_admin_model.dart b/frontend/lib/models/enfant_admin_model.dart new file mode 100644 index 0000000..5c7c84d --- /dev/null +++ b/frontend/lib/models/enfant_admin_model.dart @@ -0,0 +1,109 @@ +import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.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: normalizeEnfantStatus(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 parentId = + (json['parentId'] ?? json['id_parent'] ?? '').toString(); + String? name; + final parent = json['parent']; + 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: parentId, + 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..3a30bcb --- /dev/null +++ b/frontend/lib/models/parent_child_summary.dart @@ -0,0 +1,92 @@ +import 'package:p_tits_pas/models/enfant_admin_model.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.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 json) { + return ParentChildSummary( + id: (json['id'] ?? '').toString(), + firstName: (json['first_name'] ?? json['prenom'])?.toString(), + lastName: (json['last_name'] ?? json['nom'])?.toString(), + status: normalizeEnfantStatus( + (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 link) { + final childRaw = link['child'] ?? link['enfant']; + if (childRaw is Map) { + return ParentChildSummary.fromJson(Map.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; + } +} diff --git a/frontend/lib/models/parent_model.dart b/frontend/lib/models/parent_model.dart index 5b893fd..3a0cd5e 100644 --- a/frontend/lib/models/parent_model.dart +++ b/frontend/lib/models/parent_model.dart @@ -1,18 +1,65 @@ +import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/models/user.dart'; class ParentModel { final AppUser user; + final AppUser? coParent; final int childrenCount; + final List children; - ParentModel({required this.user, this.childrenCount = 0}); + ParentModel({ + required this.user, + this.coParent, + this.childrenCount = 0, + this.children = const [], + }); factory ParentModel.fromJson(Map json) { - final userJson = json['user'] ?? json; + final root = Map.from(json); + final userJson = Map.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?; + + AppUser? coParent; + final coParentRaw = root['co_parent']; + if (coParentRaw is Map) { + coParent = AppUser.fromJson(Map.from(coParentRaw)); + } + + 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, + coParent: coParent, + childrenCount: children.isNotEmpty ? children.length : linkCount, + children: children, ); } + + static List _parseChildren(Map json) { + final children = []; + final seen = {}; + + 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.from(link), + )); + } + } + + return 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..31ea722 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'; @@ -366,7 +367,136 @@ class UserService { } final List data = jsonDecode(response.body); - return data.map((e) => ParentModel.fromJson(e)).toList(); + return data + .map((e) => ParentModel.fromJson(Map.from(e as Map))) + .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'); + } + final decoded = jsonDecode(response.body); + return ParentModel.fromJson( + Map.from(decoded is Map ? decoded : {}), + ); + } + + 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 _parentModelFromBody(response.body); + } + + 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 _parentModelFromBody(response.body); + } + + 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 _parentModelFromBody(response.body); + } + + 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 ParentModel _parentModelFromBody(String body) { + final decoded = jsonDecode(body); + return ParentModel.fromJson( + Map.from(decoded is Map ? decoded : {}), + ); + } + + 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 @@ -383,7 +513,153 @@ class UserService { } final List data = jsonDecode(response.body); - return data.map((e) => AssistanteMaternelleModel.fromJson(e)).toList(); + return data + .map((e) => AssistanteMaternelleModel.fromJson( + Map.from(e as Map), + )) + .toList(); + } + + static Future getAssistanteMaternelle( + String userId, + ) async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$userId'), + headers: await _headers(), + ); + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body); + return AssistanteMaternelleModel.fromJson( + Map.from(decoded is Map ? decoded : {}), + ); + } + if (response.statusCode == 403 || response.statusCode == 404) { + final all = await getAssistantesMaternelles(); + return all.firstWhere( + (a) => a.user.id == userId, + orElse: () => throw Exception('Assistante maternelle introuvable'), + ); + } + final err = jsonDecode(response.body) as Map?; + throw Exception(_toStr(err?['message']) ?? 'Erreur chargement AM'); + } + + /// Mise à jour fiche AM (identité + champs pro). Ticket #131. + static Future updateAmFiche({ + required String amUserId, + required Map body, + }) async { + final ficheResponse = await http.patch( + Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId/fiche', + ), + headers: await _headers(), + body: jsonEncode(body), + ); + if (ficheResponse.statusCode == 200) { + return _amModelFromBody(ficheResponse.body); + } + if (ficheResponse.statusCode != 404) { + throw Exception( + _extractErrorMessage(ficheResponse.body, 'Erreur mise à jour AM'), + ); + } + + final userFields = {}; + for (final k in [ + 'nom', + 'prenom', + 'email', + 'telephone', + 'adresse', + 'ville', + 'code_postal', + 'statut', + 'date_naissance', + 'lieu_naissance_ville', + 'lieu_naissance_pays', + ]) { + if (body.containsKey(k)) userFields[k] = body[k]; + } + + final proFields = {}; + for (final entry in body.entries) { + if (!userFields.containsKey(entry.key)) { + proFields[entry.key] = entry.value; + } + } + + if (userFields.isNotEmpty) { + final userResponse = await http.patch( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$amUserId'), + headers: await _headers(), + body: jsonEncode(userFields), + ); + if (userResponse.statusCode != 200) { + throw Exception( + _extractErrorMessage(userResponse.body, 'Erreur mise à jour identité'), + ); + } + } + + if (proFields.isNotEmpty) { + final proResponse = await http.patch( + Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId', + ), + headers: await _headers(), + body: jsonEncode(proFields), + ); + if (proResponse.statusCode != 200) { + throw Exception( + _extractErrorMessage(proResponse.body, 'Erreur mise à jour fiche pro'), + ); + } + } + + return getAssistanteMaternelle(amUserId); + } + + static Future attachEnfantToAm({ + required String amUserId, + required String enfantId, + }) async { + final response = await http.post( + Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId/enfants/$enfantId', + ), + headers: await _headers(), + ); + if (response.statusCode != 200 && response.statusCode != 201) { + throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant')); + } + return _amModelFromBody(response.body); + } + + static Future detachEnfantFromAm({ + required String amUserId, + required String enfantId, + }) async { + final response = await http.delete( + Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId/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 getAssistanteMaternelle(amUserId); + } + return _amModelFromBody(response.body); + } + + static AssistanteMaternelleModel _amModelFromBody(String body) { + final decoded = jsonDecode(body); + return AssistanteMaternelleModel.fromJson( + Map.from(decoded is Map ? decoded : {}), + ); } // Récupérer la liste des administrateurs (via /users filtré ou autre) diff --git a/frontend/lib/utils/am_vigilance.dart b/frontend/lib/utils/am_vigilance.dart new file mode 100644 index 0000000..944dc93 --- /dev/null +++ b/frontend/lib/utils/am_vigilance.dart @@ -0,0 +1,45 @@ +import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; + +/// Places disponibles attendues : capacité max − enfants rattachés. +int? amExpectedPlacesAvailable({ + required int? maxChildren, + required int childrenCount, +}) { + if (maxChildren == null) return null; + return (maxChildren - childrenCount).clamp(0, maxChildren); +} + +/// True si la valeur déclarée par l'AM ne correspond pas au calcul métier. +bool amHasPlacesInconsistency({ + required int? maxChildren, + required int? placesAvailable, + required int childrenCount, +}) { + final expected = amExpectedPlacesAvailable( + maxChildren: maxChildren, + childrenCount: childrenCount, + ); + if (expected == null) return false; + if (placesAvailable == null) return childrenCount > (maxChildren ?? 0); + return placesAvailable != expected; +} + +String? amPlacesVigilanceMessage(AssistanteMaternelleModel am) { + if (!amHasPlacesInconsistency( + maxChildren: am.maxChildren, + placesAvailable: am.placesAvailable, + childrenCount: am.children.length, + )) { + return null; + } + final stored = am.placesAvailable; + final expected = amExpectedPlacesAvailable( + maxChildren: am.maxChildren, + childrenCount: am.children.length, + ); + final storedLabel = stored?.toString() ?? 'non renseigné'; + final expectedLabel = expected?.toString() ?? '–'; + return 'Point de vigilance : l\'AM déclare $storedLabel place(s) disponible(s), ' + 'alors que capacité ${am.maxChildren ?? '–'} − ' + '${am.children.length} enfant(s) rattaché(s) = $expectedLabel.'; +} diff --git a/frontend/lib/utils/date_display_utils.dart b/frontend/lib/utils/date_display_utils.dart index 075f2d2..61ecb5e 100644 --- a/frontend/lib/utils/date_display_utils.dart +++ b/frontend/lib/utils/date_display_utils.dart @@ -1,4 +1,5 @@ import 'package:intl/intl.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; /// Affiche une date ISO / parseable en `dd/MM/yyyy`, avec repli sur la chaîne ou [ifEmpty]. String formatIsoDateFr(String? s, {String ifEmpty = '–'}) { @@ -9,3 +10,89 @@ String formatIsoDateFr(String? s, {String ifEmpty = '–'}) { return s.trim(); } } + +/// Convertit `dd/MM/yyyy` (ou ISO) en `yyyy-MM-dd` pour l'API. +String? parseFrDateToIso(String text) { + final t = text.trim(); + if (t.isEmpty) return null; + try { + return DateFormat('dd/MM/yyyy') + .parseStrict(t) + .toIso8601String() + .split('T') + .first; + } catch (_) { + try { + return DateTime.parse(t).toIso8601String().split('T').first; + } catch (_) { + return null; + } + } +} + +({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' || normalizeEnfantStatus(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)}'; + } +} diff --git a/frontend/lib/utils/enfant_status_utils.dart b/frontend/lib/utils/enfant_status_utils.dart new file mode 100644 index 0000000..9ea84b5 --- /dev/null +++ b/frontend/lib/utils/enfant_status_utils.dart @@ -0,0 +1,51 @@ +/// Statuts enfant alignés sur `statut_enfant_type` (BDD / back #131). +const enfantStatusValues = [ + 'a_naitre', + 'sans_garde', + 'garde', + 'scolarise', +]; + +/// Normalise une valeur API (legacy `actif` → `sans_garde`). +String normalizeEnfantStatus(String? raw) { + final s = (raw ?? '').trim().toLowerCase(); + if (s.isEmpty) return 'sans_garde'; + if (s == 'actif') return 'sans_garde'; + return s; +} + +String _scolariseAccordeAuGenre(String? gender) { + final g = (gender ?? '').trim().toUpperCase(); + if (g == 'F') return 'Scolarisée'; + return 'Scolarisé'; +} + +/// Libellé affiché pour un statut enfant. +String enfantStatusLabel(String? status, {String? gender}) { + switch (normalizeEnfantStatus(status)) { + case 'a_naitre': + return 'À naître'; + case 'sans_garde': + return 'Sans garde'; + case 'garde': + return 'En garde'; + case 'scolarise': + return _scolariseAccordeAuGenre(gender); + default: + return status?.trim().isNotEmpty == true ? status!.trim() : '–'; + } +} + +/// Libellé court pour colonnes validation (null = pas de bandeau). +String? enfantColumnStatusLabel({String? status, String? gender}) { + final s = normalizeEnfantStatus(status); + switch (s) { + case 'a_naitre': + case 'sans_garde': + case 'garde': + case 'scolarise': + return enfantStatusLabel(s, gender: gender); + default: + return null; + } +} diff --git a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart index 432a782..5ee5ed1 100644 --- a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart +++ b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; -import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/utils/am_vigilance.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_am_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'; @@ -77,10 +77,12 @@ class _AssistanteMaternelleManagementWidgetState itemCount: filteredAssistantes.length, itemBuilder: (context, index) { final assistante = filteredAssistantes[index]; + final vigilance = amPlacesVigilanceMessage(assistante); return AdminUserCard( title: assistante.user.fullName, avatarUrl: assistante.user.photoUrl, fallbackIcon: Icons.face, + vigilanceTooltip: vigilance, subtitleLines: [ assistante.user.email, 'Zone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}', @@ -102,55 +104,10 @@ class _AssistanteMaternelleManagementWidgetState void _openAssistanteDetails(AssistanteMaternelleModel assistante) { showDialog( context: context, - builder: (context) => AdminDetailModal( - title: assistante.user.fullName.isEmpty - ? 'Assistante maternelle' - : assistante.user.fullName, - subtitle: assistante.user.email, - fields: [ - AdminDetailField(label: 'ID', value: _v(assistante.user.id)), - AdminDetailField( - label: 'Numero agrement', - value: _v(assistante.approvalNumber), - ), - AdminDetailField( - label: 'Ville residence', - value: _v(assistante.residenceCity), - ), - AdminDetailField( - label: 'Capacite max', - value: assistante.maxChildren?.toString() ?? '-', - ), - AdminDetailField( - label: 'Places disponibles', - value: assistante.placesAvailable?.toString() ?? '-', - ), - AdminDetailField( - label: 'Telephone', - value: _v(assistante.user.telephone) != '–' ? formatPhoneForDisplay(_v(assistante.user.telephone)) : '–', - ), - AdminDetailField(label: 'Adresse', value: _v(assistante.user.adresse)), - AdminDetailField(label: 'Ville', value: _v(assistante.user.ville)), - AdminDetailField( - label: 'Code postal', - value: _v(assistante.user.codePostal), - ), - ], - 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) => AdminAmEditModal( + assistante: assistante, + onSaved: _loadAssistantes, ), ); } - - String _v(String? value) => (value == null || value.isEmpty) ? '-' : value; } diff --git a/frontend/lib/widgets/admin/common/admin_am_children_capacity_grid.dart b/frontend/lib/widgets/admin/common/admin_am_children_capacity_grid.dart new file mode 100644 index 0000000..b77871c --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_am_children_capacity_grid.dart @@ -0,0 +1,301 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/parent_child_summary.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/utils/date_display_utils.dart'; +import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; + +/// Grille 2×2 des places d'accueil AM (max 4, limitée à [capacity]). +class AdminAmChildrenCapacityGrid extends StatelessWidget { + static const int _gridSlots = 4; + static const double _slotHeight = 44; + static const double _gridPadding = 10; + static const double _gridGap = 8; + static const double _borderWidth = 1; + static const double fixedHeight = _borderWidth * 2 + + _gridPadding * 2 + + _slotHeight * 2 + + _gridGap; + + final List children; + final int capacity; + final void Function(ParentChildSummary child) onOpen; + final void Function(ParentChildSummary child) onDetach; + + const AdminAmChildrenCapacityGrid({ + super.key, + required this.children, + required this.capacity, + required this.onOpen, + required this.onDetach, + }); + + @override + Widget build(BuildContext context) { + final maxSlots = capacity.clamp(0, _gridSlots); + + return Container( + height: fixedHeight, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300, width: _borderWidth), + ), + padding: const EdgeInsets.all(_gridPadding), + child: Column( + children: [ + _buildRow(0, 1, maxSlots), + const SizedBox(height: _gridGap), + _buildRow(2, 3, maxSlots), + ], + ), + ); + } + + Widget _buildRow(int leftIndex, int rightIndex, int maxSlots) { + return Row( + children: [ + Expanded(child: _buildSlot(leftIndex, maxSlots)), + const SizedBox(width: _gridGap), + Expanded(child: _buildSlot(rightIndex, maxSlots)), + ], + ); + } + + Widget _buildSlot(int index, int maxSlots) { + return SizedBox( + height: _slotHeight, + child: _buildSlotContent(index, maxSlots), + ); + } + + Widget _buildSlotContent(int index, int maxSlots) { + if (index < children.length) { + return _OccupiedSlot( + child: children[index], + overCapacity: index >= maxSlots, + onOpen: () => onOpen(children[index]), + onDetach: () => onDetach(children[index]), + ); + } + if (index < maxSlots) { + return const _EmptySlot(); + } + return const _UnavailableSlot(); + } +} + +class _SlotShell extends StatelessWidget { + final Color backgroundColor; + final Color borderColor; + final Widget child; + + const _SlotShell({ + required this.backgroundColor, + required this.borderColor, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return SizedBox.expand( + child: DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: borderColor), + ), + child: child, + ), + ); + } +} + +class _UnavailableSlot extends StatelessWidget { + const _UnavailableSlot(); + + @override + Widget build(BuildContext context) { + return _SlotShell( + backgroundColor: Colors.grey.shade100, + borderColor: Colors.grey.shade200, + child: Center( + child: Icon( + Icons.block_outlined, + size: 20, + color: Colors.grey.shade400, + ), + ), + ); + } +} + +class _EmptySlot extends StatelessWidget { + const _EmptySlot(); + + @override + Widget build(BuildContext context) { + return _SlotShell( + backgroundColor: Colors.grey.shade50, + borderColor: Colors.grey.shade300, + child: Center( + child: Text( + 'Place libre', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + ), + ); + } +} + +class _OccupiedSlot extends StatefulWidget { + final ParentChildSummary child; + final bool overCapacity; + final VoidCallback onOpen; + final VoidCallback onDetach; + + const _OccupiedSlot({ + required this.child, + required this.overCapacity, + required this.onOpen, + required this.onDetach, + }); + + @override + State<_OccupiedSlot> createState() => _OccupiedSlotState(); +} + +class _OccupiedSlotState extends State<_OccupiedSlot> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final age = formatChildAgeLabel( + birthDate: widget.child.birthDate, + dueDate: widget.child.dueDate, + status: widget.child.status, + ); + final avatarUrl = ApiConfig.absoluteMediaUrl(widget.child.photoUrl ?? ''); + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: _SlotShell( + backgroundColor: widget.overCapacity + ? Colors.red.shade50 + : const Color(0xFFF8F5FC), + borderColor: widget.overCapacity + ? Colors.red.shade300 + : const Color(0xFFD8CCE8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildAvatar(avatarUrl), + const SizedBox(width: 8), + Expanded( + child: Row( + children: [ + Flexible( + fit: FlexFit.loose, + child: Text( + widget.child.fullName, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (age.isNotEmpty) ...[ + const SizedBox(width: 8), + Expanded( + child: Text( + age, + style: const TextStyle( + fontSize: 11, + color: Colors.black54, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ], + ), + ), + AnimatedOpacity( + duration: const Duration(milliseconds: 120), + opacity: _hovered ? 1 : 0, + child: IgnorePointer( + ignoring: !_hovered, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Voir / modifier', + icon: const Icon(Icons.visibility_outlined, size: 20), + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + onPressed: widget.onOpen, + ), + IconButton( + tooltip: 'Détacher', + icon: Icon( + Icons.link_off, + size: 20, + color: Colors.orange.shade800, + ), + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + onPressed: widget.onDetach, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + 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: const Icon(Icons.child_care_outlined, size: 15, 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: const Icon(Icons.child_care_outlined, size: 15, color: iconColor), + ), + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart new file mode 100644 index 0000000..b3ca7f1 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart @@ -0,0 +1,841 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:p_tits_pas/models/assistante_maternelle_model.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/am_vigilance.dart'; +import 'package:p_tits_pas/utils/date_display_utils.dart'; +import 'package:p_tits_pas/utils/nir_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/widgets/admin/common/admin_am_children_capacity_grid.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.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/admin/validation_modal_theme.dart'; + +/// Fiche AM éditable (ticket #131) — identité | fiche pro (photo) | enfants. +class AdminAmEditModal extends StatefulWidget { + final AssistanteMaternelleModel assistante; + final VoidCallback? onSaved; + + const AdminAmEditModal({ + super.key, + required this.assistante, + this.onSaved, + }); + + @override + State createState() => _AdminAmEditModalState(); +} + +class _AdminAmEditModalState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabCtrl; + 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 final TextEditingController _agrementCtrl; + late final TextEditingController _nirCtrl; + late final TextEditingController _dateNaissanceCtrl; + late final TextEditingController _lieuNaissanceVilleCtrl; + late final TextEditingController _lieuNaissancePaysCtrl; + late final TextEditingController _dateAgrementCtrl; + late final TextEditingController _capaciteCtrl; + + late String _statut; + late bool _disponible; + late int? _placesAvailable; + late List _children; + late Set _baselineChildIds; + + bool _saving = false; + bool _dirty = false; + + static const double _modalWidth = 930; + static const double _photoProGap = 24; + static const double _proColumnMinWidth = 260; + static const double _photoColumnMinWidth = 160; + static const double _proTabHeight = 300; + static const List _photoProRowLayout = [2, 2, 2]; + + /// Hauteur onglet enfants : champs + titre + grille 2×2 (+ alerte places si besoin). + double _childrenTabHeight() { + const capacityFields = 72.0; + const titleSection = 40.0; + const inconsistencyExtra = 46.0; + var h = capacityFields + + titleSection + + AdminAmChildrenCapacityGrid.fixedHeight; + if (_placesInconsistent()) h += inconsistencyExtra; + return h + 4; + } + + double _tabViewHeight(int index) { + switch (index) { + case 1: + return _proTabHeight; + case 2: + return _childrenTabHeight(); + default: + return 292; + } + } + + @override + void initState() { + super.initState(); + _tabCtrl = TabController(length: 3, vsync: this); + final u = widget.assistante.user; + final am = widget.assistante; + + _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 ?? ''); + _agrementCtrl = TextEditingController(text: am.approvalNumber ?? ''); + _nirCtrl = TextEditingController(text: _formatNirDisplay()); + _dateNaissanceCtrl = TextEditingController( + text: formatIsoDateFr(u.dateNaissance, ifEmpty: ''), + ); + _lieuNaissanceVilleCtrl = TextEditingController( + text: u.lieuNaissanceVille ?? '', + ); + _lieuNaissancePaysCtrl = TextEditingController( + text: u.lieuNaissancePays ?? '', + ); + _dateAgrementCtrl = TextEditingController( + text: formatIsoDateFr(am.agreementDate, ifEmpty: ''), + ); + _capaciteCtrl = TextEditingController( + text: am.maxChildren?.toString() ?? '', + ); + _statut = u.statut ?? 'en_attente'; + _disponible = am.available ?? true; + _placesAvailable = am.placesAvailable; + _children = List.of(am.children); + _baselineChildIds = _children.map((c) => c.id).toSet(); + + for (final c in [ + _nomCtrl, + _prenomCtrl, + _emailCtrl, + _telCtrl, + _adresseCtrl, + _villeCtrl, + _cpCtrl, + _agrementCtrl, + _nirCtrl, + _dateNaissanceCtrl, + _lieuNaissanceVilleCtrl, + _lieuNaissancePaysCtrl, + _dateAgrementCtrl, + _capaciteCtrl, + ]) { + c.addListener(_markDirty); + } + _capaciteCtrl.addListener(_onCapacityChanged); + _nomCtrl.addListener(_onNameFieldChanged); + _prenomCtrl.addListener(_onNameFieldChanged); + _tabCtrl.addListener(_onTabChanged); + _fetchChildrenFromServer(); + } + + bool _childrenChanged() { + final current = _children.map((c) => c.id).toSet(); + return current.length != _baselineChildIds.length || + !current.containsAll(_baselineChildIds); + } + + void _syncPlacesAfterChildrenChange() { + final expected = _computedPlacesAvailable(); + if (expected != null) _placesAvailable = expected; + } + + void _onTabChanged() { + if (!_tabCtrl.indexIsChanging) setState(() {}); + } + + void _onNameFieldChanged() => setState(() {}); + + void _onCapacityChanged() => setState(() {}); + + @override + void dispose() { + _tabCtrl.dispose(); + for (final c in [ + _nomCtrl, + _prenomCtrl, + _emailCtrl, + _telCtrl, + _adresseCtrl, + _villeCtrl, + _cpCtrl, + _agrementCtrl, + _nirCtrl, + _dateNaissanceCtrl, + _lieuNaissanceVilleCtrl, + _lieuNaissancePaysCtrl, + _dateAgrementCtrl, + _capaciteCtrl, + ]) { + c.dispose(); + } + super.dispose(); + } + + void _markDirty() { + if (!_dirty) setState(() => _dirty = true); + } + + String _headerTitle() { + final fn = _prenomCtrl.text.trim(); + final ln = _nomCtrl.text.trim(); + if (fn.isEmpty && ln.isEmpty) { + final fallback = widget.assistante.user.fullName.trim(); + return fallback.isNotEmpty ? fallback : 'Assistante maternelle'; + } + return '$fn $ln'.trim(); + } + + String? _headerSubtitle() { + final parts = []; + final zone = (widget.assistante.residenceCity ?? '').trim(); + if (zone.isNotEmpty) parts.add('Zone : $zone'); + final agrement = _agrementCtrl.text.trim(); + if (agrement.isNotEmpty) parts.add('Agrément : $agrement'); + final dossier = widget.assistante.user.numeroDossier?.trim(); + if (dossier != null && dossier.isNotEmpty) { + parts.add('Dossier $dossier'); + } + if (parts.isEmpty) return null; + return parts.join(' · '); + } + + String _formatNirDisplay() { + final raw = widget.assistante.nir?.trim() ?? ''; + if (raw.isEmpty) return ''; + final digits = nirToRaw(raw).toUpperCase(); + return digits.length == 15 ? formatNir(digits) : raw; + } + + String? _frDateToIso(String text) => parseFrDateToIso(text); + + int? _capaciteMax() => _parseIntField(_capaciteCtrl); + + int? _computedPlacesAvailable() => amExpectedPlacesAvailable( + maxChildren: _capaciteMax(), + childrenCount: _children.length, + ); + + bool _placesInconsistent() => amHasPlacesInconsistency( + maxChildren: _capaciteMax(), + placesAvailable: _placesAvailable, + childrenCount: _children.length, + ); + + String _placesDisplayValue() { + if (_placesAvailable != null) return _placesAvailable.toString(); + return '–'; + } + + void _applyPlacesCorrection() { + final expected = _computedPlacesAvailable(); + if (expected == null) return; + setState(() { + _placesAvailable = expected; + _dirty = true; + }); + } + + String? _placesInconsistencyMessage() { + if (!_placesInconsistent()) return null; + final stored = _placesAvailable; + final expected = _computedPlacesAvailable(); + final storedLabel = stored?.toString() ?? 'non renseigné'; + final expectedLabel = expected?.toString() ?? '–'; + return 'Incohérence : l\'AM déclare $storedLabel place(s) disponible(s), ' + 'le calcul (capacité ${_capaciteMax() ?? '–'} − ${_children.length} ' + 'enfant(s) rattaché(s)) donne $expectedLabel.'; + } + + int? _parseIntField(TextEditingController c) { + final t = c.text.trim(); + if (t.isEmpty) return null; + return int.tryParse(t); + } + + 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 assistante maternelle ?', + ), + 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 { + if (_childrenChanged()) _syncPlacesAfterChildrenChange(); + + final currentIds = _children.map((c) => c.id).toSet(); + for (final id in _baselineChildIds.difference(currentIds)) { + await UserService.detachEnfantFromAm( + amUserId: widget.assistante.user.id, + enfantId: id, + ); + } + for (final id in currentIds.difference(_baselineChildIds)) { + await UserService.attachEnfantToAm( + amUserId: widget.assistante.user.id, + enfantId: id, + ); + } + + await UserService.updateAmFiche( + amUserId: widget.assistante.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, + 'approval_number': _agrementCtrl.text.trim(), + 'nir': nirToRaw(_nirCtrl.text), + if (_frDateToIso(_dateNaissanceCtrl.text) != null) + 'date_naissance': _frDateToIso(_dateNaissanceCtrl.text), + 'lieu_naissance_ville': _lieuNaissanceVilleCtrl.text.trim(), + 'lieu_naissance_pays': _lieuNaissancePaysCtrl.text.trim(), + if (_frDateToIso(_dateAgrementCtrl.text) != null) + 'agreement_date': _frDateToIso(_dateAgrementCtrl.text), + if (_capaciteMax() != null) 'max_children': _capaciteMax(), + if (_placesAvailable != null) 'places_available': _placesAvailable, + 'available': _disponible, + }, + ); + if (!mounted) return; + setState(() { + _dirty = false; + _saving = false; + _baselineChildIds = currentIds; + }); + widget.onSaved?.call(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Fiche assistante maternelle enregistrée')), + ); + } catch (e) { + if (!mounted) return; + setState(() => _saving = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + + Future _fetchChildrenFromServer() async { + try { + final refreshed = + await UserService.getAssistanteMaternelle(widget.assistante.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; + _baselineChildIds = kids.map((c) => c.id).toSet(); + }); + } catch (_) {} + } + + Future _refreshChildrenDetails() async { + try { + final all = await UserService.getEnfants(); + final byId = {for (final e in all) e.id: e}; + + if (!mounted) return; + setState(() { + _children = _children.map((c) { + final full = byId[c.id]; + if (full != null) return ParentChildSummary.fromEnfant(full); + return c; + }).toList(); + }); + } catch (_) {} + } + + 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: _refreshChildrenDetails, + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + + 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 cette assistante ?\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: ValidationModalTheme.primaryElevatedStyle, + child: const Text('Détacher'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + setState(() { + _children = _children.where((c) => c.id != child.id).toList(); + _syncPlacesAfterChildrenChange(); + _dirty = true; + }); + } + + 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; + + setState(() { + _children = [ + ..._children, + ParentChildSummary.fromEnfant(selected), + ]; + _syncPlacesAfterChildrenChange(); + _dirty = true; + }); + } + + + Widget _identityTab() { + return SingleChildScrollView( + child: IdentityBlock.editable( + title: 'Identité et coordonnées', + nomController: _nomCtrl, + prenomController: _prenomCtrl, + telephoneController: _telCtrl, + emailController: _emailCtrl, + adresseController: _adresseCtrl, + codePostalController: _cpCtrl, + villeController: _villeCtrl, + ), + ); + } + + Widget _proFieldsGrid() { + return ValidationFormGrid( + title: 'Dossier professionnel', + rowLayout: _photoProRowLayout, + fields: [ + ValidationLabeledField( + label: 'NIR', + field: ValidationEditableField( + controller: _nirCtrl, + hintText: '15 chiffres', + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[\d\s]')), + ], + ), + ), + ValidationLabeledField( + label: 'Date de naissance', + field: ValidationEditableField( + controller: _dateNaissanceCtrl, + hintText: 'jj/mm/aaaa', + ), + ), + ValidationLabeledField( + label: 'Ville de naissance', + field: ValidationEditableField( + controller: _lieuNaissanceVilleCtrl, + ), + ), + ValidationLabeledField( + label: 'Pays de naissance', + field: ValidationEditableField( + controller: _lieuNaissancePaysCtrl, + ), + ), + ValidationLabeledField( + label: 'N° Agrément', + field: ValidationEditableField( + controller: _agrementCtrl, + ), + ), + ValidationLabeledField( + label: 'Date d\'agrément', + field: ValidationEditableField( + controller: _dateAgrementCtrl, + hintText: 'jj/mm/aaaa', + ), + ), + ], + ); + } + + Widget _proTab() { + return LayoutBuilder( + builder: (context, c) { + final maxRowW = c.maxWidth; + final maxRowH = c.maxHeight; + final bodyH = maxRowH; + final idealPhotoW = bodyH * AdminAmPhotoFrame.idPhotoAspectRatio + 16; + final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth) + .clamp(0.0, double.infinity); + var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 220.0); + if (photoW > maxPhotoW) photoW = maxPhotoW; + + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: photoW, + child: AdminAmPhotoFrame( + photoUrl: widget.assistante.user.photoUrl, + ), + ), + const SizedBox(width: _photoProGap), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _proFieldsGrid(), + SwitchListTile( + contentPadding: EdgeInsets.zero, + dense: true, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + title: const Text( + 'Disponible pour accueillir', + style: TextStyle(fontSize: 13), + ), + value: _disponible, + onChanged: (v) => setState(() { + _disponible = v; + _dirty = true; + }), + ), + ], + ), + ), + ), + ], + ); + }, + ); + } + + Widget _childrenCapacityFields() { + final inconsistent = _placesInconsistent(); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ValidationEditableSection( + rowLayout: const [2], + fields: [ + ValidationLabeledField( + label: 'Capacité max (enfants)', + field: ValidationEditableField( + controller: _capaciteCtrl, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + ), + ValidationLabeledField( + label: 'Places disponibles', + field: ValidationReadOnlyField( + value: _placesDisplayValue(), + error: inconsistent, + ), + ), + ], + ), + if (inconsistent) ...[ + const SizedBox(height: 6), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 6, + runSpacing: 4, + children: [ + Text( + _placesInconsistencyMessage()!, + style: TextStyle( + fontSize: 12, + color: Colors.red.shade700, + height: 1.3, + ), + ), + OutlinedButton( + onPressed: _saving ? null : _applyPlacesCorrection, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red.shade800, + disabledForegroundColor: Colors.red.shade300, + side: BorderSide(color: Colors.red.shade400), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + minimumSize: const Size(0, 28), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + child: const Text('Mettre à jour'), + ), + ], + ), + ], + ], + ); + } + + Widget _childrenTab() { + final capacity = (_capaciteMax() ?? 4).clamp(1, 4); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _childrenCapacityFields(), + const SizedBox(height: 10), + Text( + 'Enfants accueillis : ${_children.length}', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const SizedBox(height: 8), + AdminAmChildrenCapacityGrid( + children: _children, + capacity: capacity, + onOpen: _openChild, + onDetach: _detachChild, + ), + ], + ); + } + + Widget _buildFooter() { + final isChildrenTab = _tabCtrl.index == 2; + return Row( + children: [ + TextButton( + onPressed: _saving ? null : () => Navigator.of(context).pop(), + child: const Text('Fermer'), + ), + const Spacer(), + if (isChildrenTab) + TextButton.icon( + onPressed: _attachChild, + icon: const Icon(Icons.link, size: 18), + label: const Text('Rattacher un enfant'), + ), + if (isChildrenTab) 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: const BoxConstraints(maxWidth: _modalWidth), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 4, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _headerTitle(), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + if (_headerSubtitle() != null) ...[ + const SizedBox(height: 4), + Text( + _headerSubtitle()!, + style: const TextStyle( + fontSize: 13, + color: Colors.black54, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 160, + child: AdminStatusCapsule( + statut: _statut, + onChanged: (v) => setState(() { + _statut = v; + _dirty = true; + }), + ), + ), + IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 40, + minHeight: 40, + ), + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + tooltip: 'Fermer', + ), + ], + ), + ), + TabBar( + controller: _tabCtrl, + onTap: (_) => setState(() {}), + labelColor: ValidationModalTheme.primaryActionBackground, + unselectedLabelColor: Colors.black54, + indicatorColor: ValidationModalTheme.primaryActionBackground, + tabs: const [ + Tab(text: 'Identité'), + Tab(text: 'Fiche professionnelle'), + Tab(text: 'Enfants accueillis'), + ], + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), + child: SizedBox( + height: _tabViewHeight(_tabCtrl.index), + child: TabBarView( + controller: _tabCtrl, + children: [ + _identityTab(), + _proTab(), + _childrenTab(), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), + child: _buildFooter(), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart b/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart new file mode 100644 index 0000000..17ac62e --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart @@ -0,0 +1,115 @@ +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'; + +/// Cadre photo identité AM (35×45 mm) — même logique que [ValidationAmWizard]. +class AdminAmPhotoFrame extends StatelessWidget { + final String? photoUrl; + + static const double idPhotoAspectRatio = 35 / 45; + + const AdminAmPhotoFrame({super.key, this.photoUrl}); + + /// Largeur colonne photo pour remplir [height] (cadre inclus). + static double columnWidthForHeight(double height) { + const frame = 16.0; + final innerH = (height - frame).clamp(0.0, double.infinity); + return innerH * idPhotoAspectRatio + frame; + } + + @override + Widget build(BuildContext context) { + final fullUrl = ApiConfig.absoluteMediaUrl(photoUrl); + return LayoutBuilder( + builder: (context, c) { + const uniformFrame = 8.0; + final maxPhotoW = + (c.maxWidth - 2 * uniformFrame).clamp(0.0, double.infinity); + final maxPhotoH = + (c.maxHeight - 2 * uniformFrame).clamp(0.0, double.infinity); + const ar = idPhotoAspectRatio; + + double ph = maxPhotoH; + double pw = ph * ar; + if (pw > maxPhotoW) { + pw = maxPhotoW; + ph = pw / ar; + } + + // Cadre gris = taille photo + padding uniforme ; centré dans la colonne + // (évite le vide blanc en bas quand le conteneur parent est plus haut). + return Align( + alignment: Alignment.topCenter, + child: Container( + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(uniformFrame), + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SizedBox( + width: pw, + height: ph, + child: _photoContent(fullUrl, pw, ph), + ), + ), + ), + ), + ); + }, + ); + } + + Widget _photoContent(String fullUrl, double pw, double ph) { + if (fullUrl.isEmpty) { + return ColoredBox( + color: Colors.grey.shade200, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.person_off_outlined, size: 36, color: Colors.grey.shade400), + const SizedBox(height: 6), + Text( + 'Aucune photo', + style: TextStyle(color: Colors.grey.shade600, fontSize: 11), + ), + ], + ), + ); + } + return AuthNetworkImage( + url: fullUrl, + width: pw, + height: ph, + fit: BoxFit.cover, + alignment: Alignment.topCenter, + loadingBuilder: (_, child, progress) { + if (progress == null) return child; + return ColoredBox( + color: Colors.grey.shade200, + child: Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + value: progress.expectedTotalBytes != null + ? progress.cumulativeBytesLoaded / + (progress.expectedTotalBytes!) + : null, + ), + ), + ), + ); + }, + errorBuilder: (_, __, ___) => ColoredBox( + color: Colors.grey.shade200, + child: Icon(Icons.broken_image_outlined, size: 36, color: Colors.grey.shade400), + ), + ); + } +} 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..30941ed --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -0,0 +1,411 @@ +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/utils/enfant_status_utils.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 _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 = normalizeEnfantStatus(e.status); + if (!enfantStatusValues.contains(_status)) { + _status = 'sans_garde'; + } + _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 _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, + enfantStatusValues + .map( + (s) => MapEntry( + s, + enfantStatusLabel(s, gender: _gender), + ), + ) + .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> items, + ValueChanged 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( + 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 onChanged) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Expanded(child: Text(label, style: _labelStyle)), + Switch( + value: value, + onChanged: onChanged, + ), + ], + ), + ); + } + + String _genderLabel(String gender) { + switch (gender) { + case 'H': + return 'Garçon'; + case 'F': + return 'Fille'; + case 'Autre': + return 'Autre'; + default: + return gender; + } + } +} diff --git a/frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart b/frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart new file mode 100644 index 0000000..e984e66 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/parent_child_summary.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart'; + +/// Liste scrollable d'enfants rattachés (fiche parent / fiche AM). +class AdminChildrenAffiliationPanel extends StatelessWidget { + final List children; + final ScrollController scrollController; + final void Function(ParentChildSummary child) onOpen; + final void Function(ParentChildSummary child) onDetach; + final String emptyMessage; + final double? height; + + static const double _itemHeight = 58; + static const double defaultViewportHeight = _itemHeight * 2.5 + 8; + + const AdminChildrenAffiliationPanel({ + super.key, + required this.children, + required this.scrollController, + required this.onOpen, + required this.onDetach, + this.emptyMessage = 'Aucun enfant rattaché', + this.height, + }); + + @override + Widget build(BuildContext context) { + if (height != null) { + return _panel(height!); + } + return LayoutBuilder( + builder: (context, constraints) { + final h = constraints.maxHeight.isFinite && constraints.maxHeight > 0 + ? constraints.maxHeight + : defaultViewportHeight; + return _panel(h); + }, + ); + } + + Widget _panel(double panelHeight) { + return Container( + height: panelHeight, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + clipBehavior: Clip.antiAlias, + child: children.isEmpty + ? Center( + child: Text( + emptyMessage, + style: const TextStyle(fontSize: 14, color: Colors.black54), + ), + ) + : ListView.builder( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), + itemExtent: _itemHeight, + 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: () => onOpen(c), + ), + IconButton( + tooltip: 'Détacher', + icon: Icon(Icons.link_off, color: Colors.orange.shade800), + onPressed: () => onDetach(c), + ), + ], + ); + }, + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart b/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart new file mode 100644 index 0000000..a6349b8 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart @@ -0,0 +1,98 @@ +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/utils/enfant_status_utils.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; + +List enfantAdminSubtitleLines({ + required String status, + String? birthDate, + String? dueDate, + String? gender, + List extra = const [], +}) { + final lines = []; + final age = formatChildAgeLabel( + birthDate: birthDate, + dueDate: dueDate, + status: normalizeEnfantStatus(status), + ); + if (age.isNotEmpty) lines.add(age); + final normalized = normalizeEnfantStatus(status); + if (normalized.isNotEmpty) { + lines.add('Statut : ${enfantStatusLabel(normalized, gender: gender)}'); + } + 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 subtitleLines; + final List actions; + + const AdminEnfantUserCard({ + super.key, + required this.title, + this.photoUrl, + required this.subtitleLines, + this.actions = const [], + }); + + factory AdminEnfantUserCard.fromEnfant( + EnfantAdminModel enfant, { + List extraSubtitleLines = const [], + List 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, + gender: enfant.gender, + extra: [ + if (parents.isNotEmpty) 'Responsables : $parents', + ...extraSubtitleLines, + ], + ), + actions: actions, + ); + } + + factory AdminEnfantUserCard.fromSummary( + ParentChildSummary child, { + List extraSubtitleLines = const [], + List 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, + ); + } +} 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..b1fe436 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart @@ -0,0 +1,462 @@ +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/models/user.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_children_affiliation_panel.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.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 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; + AppUser? _coParent; + late final ScrollController _childrenScrollCtrl; + bool _saving = false; + bool _dirty = false; + + static const double _modalWidth = 930; + + @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'; + _coParent = widget.parent.coParent; + _children = List.of(widget.parent.children); + _childrenScrollCtrl = ScrollController(); + for (final c in [ + _nomCtrl, + _prenomCtrl, + _emailCtrl, + _telCtrl, + _adresseCtrl, + _villeCtrl, + _cpCtrl, + ]) { + c.addListener(_markDirty); + } + _nomCtrl.addListener(_onNameFieldChanged); + _prenomCtrl.addListener(_onNameFieldChanged); + _reloadChildren(); + } + + void _onNameFieldChanged() { + setState(() {}); + } + + @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 _headerTitle() { + final fn = _prenomCtrl.text.trim(); + final ln = _nomCtrl.text.trim(); + if (fn.isEmpty && ln.isEmpty) { + final fallback = widget.parent.user.fullName.trim(); + return fallback.isNotEmpty ? fallback : 'Parent'; + } + return '$fn $ln'.trim(); + } + + String? _coParentSubtitle() { + final name = _coParent?.fullName.trim() ?? ''; + if (name.isEmpty) return null; + return 'Co-parent : $name'; + } + + 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': 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 _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: _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); + 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; + _coParent = refreshed.coParent; + }); + } 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: ValidationModalTheme.primaryElevatedStyle, + 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 _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 { + 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 _childrenPanel() { + return AdminChildrenAffiliationPanel( + children: _children, + scrollController: _childrenScrollCtrl, + onOpen: _openChild, + onDetach: _detachChild, + ); + } + + Widget _identityFields() { + return IdentityBlock.editable( + nomController: _nomCtrl, + prenomController: _prenomCtrl, + telephoneController: _telCtrl, + emailController: _emailCtrl, + adresseController: _adresseCtrl, + codePostalController: _cpCtrl, + villeController: _villeCtrl, + ); + } + + 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: const BoxConstraints(maxWidth: _modalWidth), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 4, 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _headerTitle(), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + if (_coParentSubtitle() != null) ...[ + const SizedBox(height: 4), + Text( + _coParentSubtitle()!, + style: const TextStyle( + fontSize: 13, + color: Colors.black54, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 160, + child: AdminStatusCapsule( + statut: _statut, + onChanged: (v) => setState(() { + _statut = v; + _dirty = true; + }), + ), + ), + IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 40, + minHeight: 40, + ), + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + tooltip: 'Fermer', + ), + ], + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _identityFields(), + const SizedBox(height: 10), + Text( + 'Nombre d\'enfants : ${_children.length}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const SizedBox(height: 6), + _childrenPanel(), + const SizedBox(height: 12), + _buildFooter(), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_status_capsule.dart b/frontend/lib/widgets/admin/common/admin_status_capsule.dart new file mode 100644 index 0000000..a42b015 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_status_capsule.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +/// Gélule de sélection du statut utilisateur (fiches admin parent / AM). +class AdminStatusCapsule extends StatelessWidget { + final String statut; + final ValueChanged? onChanged; + + static const statuts = ['actif', 'en_attente', 'suspendu', 'refuse']; + + const AdminStatusCapsule({ + super.key, + required this.statut, + this.onChanged, + }); + + static 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; + } + } + + @override + Widget build(BuildContext context) { + final value = statuts.contains(statut) ? statut : statuts.first; + 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( + value: value, + 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: onChanged == null + ? null + : (v) { + if (v != null) onChanged!(v); + }, + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_user_card.dart b/frontend/lib/widgets/admin/common/admin_user_card.dart index 92d4237..7caafe1 100644 --- a/frontend/lib/widgets/admin/common/admin_user_card.dart +++ b/frontend/lib/widgets/admin/common/admin_user_card.dart @@ -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; @@ -10,6 +12,7 @@ class AdminUserCard extends StatefulWidget { final Color? backgroundColor; final Color? titleColor; final Color? infoColor; + final String? vigilanceTooltip; const AdminUserCard({ super.key, @@ -22,6 +25,7 @@ class AdminUserCard extends StatefulWidget { this.backgroundColor, this.titleColor, this.infoColor, + this.vigilanceTooltip, }); @override @@ -37,6 +41,7 @@ class _AdminUserCardState extends State { 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,21 +65,19 @@ class _AdminUserCardState extends State { 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), + if (widget.vigilanceTooltip != null) ...[ + Tooltip( + message: widget.vigilanceTooltip!, + child: Icon( + Icons.error_outline, + size: 20, + color: Colors.orange.shade800, + ), + ), + const SizedBox(width: 6), + ], Expanded( child: Row( children: [ @@ -140,4 +143,35 @@ class _AdminUserCardState extends State { ), ); } + + 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), + ), + ), + ), + ); + } } diff --git a/frontend/lib/widgets/admin/common/validation_detail_section.dart b/frontend/lib/widgets/admin/common/validation_detail_section.dart index a0f36e2..610dd98 100644 --- a/frontend/lib/widgets/admin/common/validation_detail_section.dart +++ b/frontend/lib/widgets/admin/common/validation_detail_section.dart @@ -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,41 @@ 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 fields; + final List? rowLayout; + final Map>? rowFlex; + final bool compact; + + const ValidationFormGrid({ + super.key, + this.title, + required this.fields, + this.rowLayout, + this.rowFlex, + this.compact = false, + }); + @override Widget build(BuildContext context) { final layout = rowLayout ?? List.filled(fields.length, 1); @@ -38,22 +74,22 @@ class ValidationDetailSection extends StatelessWidget { rowIndex++; if (count == 1) { rows.add(Padding( - padding: const EdgeInsets.only(bottom: 12), - child: _buildFieldCell(rowFields.first), + padding: EdgeInsets.only(bottom: compact ? 8 : 12), + child: rowFields.first, )); } else { rows.add(Padding( - padding: const EdgeInsets.only(bottom: 12), + padding: EdgeInsets.only(bottom: compact ? 8 : 12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (int i = 0; i < rowFields.length; i++) ...[ - if (i > 0) const SizedBox(width: 16), + if (i > 0) SizedBox(width: compact ? 12 : 16), Expanded( flex: (flexForRow != null && i < flexForRow.length) ? flexForRow[i] : 1, - child: _buildFieldCell(rowFields[i]), + child: rowFields[i], ), ], ], @@ -69,26 +105,96 @@ class ValidationDetailSection extends StatelessWidget { if (showTitle) ...[ Text( title!.trim(), - style: const TextStyle( - fontSize: 16, + style: TextStyle( + fontSize: compact ? 15 : 16, fontWeight: FontWeight.w600, color: Colors.black87, ), ), - const SizedBox(height: 12), + SizedBox(height: compact ? 8 : 12), ], ...rows, ], ); } +} - Widget _buildFieldCell(AdminDetailField field) { +/// Décoration commune lecture seule / édition (modales validation, fiches admin). +class ValidationFieldDecoration { + ValidationFieldDecoration._(); + + static InputDecoration input({String? hint, bool compact = false}) { + return InputDecoration( + isDense: true, + filled: true, + fillColor: Colors.grey.shade50, + hintText: hint, + contentPadding: EdgeInsets.symmetric( + horizontal: compact ? 10 : 12, + vertical: compact ? 7 : 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 InputDecoration readOnly({bool error = false, bool compact = false}) { + final borderColor = error ? Colors.red.shade400 : Colors.grey.shade300; + final fillColor = error ? Colors.red.shade50 : Colors.grey.shade50; + return input(compact: compact).copyWith( + filled: true, + fillColor: fillColor, + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: borderColor), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: borderColor), + ), + ); + } + + static BoxDecoration container({bool error = false}) { + return BoxDecoration( + color: error ? Colors.red.shade50 : Colors.grey.shade50, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: error ? Colors.red.shade400 : 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,37 +202,203 @@ 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. -class ValidationReadOnlyField extends StatelessWidget { +/// Champ texte éditable, même rendu que [ValidationReadOnlyField]. +class ValidationEditableField extends StatelessWidget { + final TextEditingController controller; + final TextInputType keyboardType; + final List? inputFormatters; + final String? hintText; + final int maxLines; + final bool compact; + + const ValidationEditableField({ + super.key, + required this.controller, + this.keyboardType = TextInputType.text, + this.inputFormatters, + this.hintText, + this.maxLines = 1, + this.compact = false, + }); + + static const double _compactFieldHeight = 34; + + static BoxDecoration _compactDecoration({bool error = false}) { + return BoxDecoration( + color: error ? Colors.red.shade50 : Colors.grey.shade50, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: error ? Colors.red.shade400 : Colors.grey.shade300, + ), + ); + } + + static InputDecoration _compactInputDecoration({String? hint}) { + return InputDecoration( + isDense: true, + filled: false, + hintText: hint, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + ); + } + + @override + Widget build(BuildContext context) { + if (maxLines > 1) { + return TextField( + controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + maxLines: maxLines, + style: const TextStyle(color: Colors.black87, fontSize: 14), + decoration: ValidationFieldDecoration.input(hint: hintText), + ); + } + if (!compact) { + return TextField( + controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + maxLines: 1, + style: const TextStyle(color: Colors.black87, fontSize: 14), + decoration: ValidationFieldDecoration.input(hint: hintText), + ); + } + return SizedBox( + height: _compactFieldHeight, + child: DecoratedBox( + decoration: _compactDecoration(), + child: TextField( + controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + maxLines: 1, + textAlignVertical: TextAlignVertical.center, + style: const TextStyle( + color: Colors.black87, + fontSize: 13, + height: 1.0, + ), + decoration: _compactInputDecoration(hint: hintText), + ), + ), + ); + } +} + +/// Grille label/champ éditable (délègue à [ValidationFormGrid]). +class ValidationEditableSection extends StatelessWidget { + final List fields; + final List? rowLayout; + final Map>? rowFlex; + final bool compact; + + const ValidationEditableSection({ + super.key, + required this.fields, + this.rowLayout, + this.rowFlex, + this.compact = false, + }); + + @override + Widget build(BuildContext context) { + return ValidationFormGrid( + rowLayout: rowLayout, + rowFlex: rowFlex, + compact: compact, + fields: fields, + ); + } +} + +/// Champ texte en lecture seule, même coque [TextField] que [ValidationEditableField]. +class ValidationReadOnlyField extends StatefulWidget { final String value; final int? maxLines; + final bool compact; + final bool error; const ValidationReadOnlyField({ super.key, required this.value, this.maxLines = 1, + this.compact = false, + this.error = false, }); + @override + State createState() => _ValidationReadOnlyFieldState(); +} + +class _ValidationReadOnlyFieldState extends State { + late final TextEditingController _controller; + + static const double _compactFieldHeight = 34; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.value); + } + + @override + void didUpdateWidget(ValidationReadOnlyField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.value != widget.value) { + _controller.text = widget.value; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + if (!widget.compact && widget.maxLines == 1) { + return TextField( + controller: _controller, + readOnly: true, + enableInteractiveSelection: false, + style: TextStyle( + color: widget.error ? Colors.red.shade800 : Colors.black87, + fontSize: 14, + fontWeight: widget.error ? FontWeight.w600 : null, + ), + decoration: ValidationFieldDecoration.readOnly(error: widget.error), + ); + } + 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), + height: widget.compact && widget.maxLines == 1 ? _compactFieldHeight : null, + alignment: widget.compact ? Alignment.centerLeft : null, + padding: EdgeInsets.symmetric( + horizontal: widget.compact ? 10 : 12, + vertical: widget.compact ? 7 : 10, ), + decoration: ValidationFieldDecoration.container(error: widget.error), child: Text( - value, - style: const TextStyle(color: Colors.black87, fontSize: 14), - maxLines: maxLines, + widget.value, + style: TextStyle( + color: widget.error ? Colors.red.shade800 : Colors.black87, + fontSize: widget.compact ? 13 : 14, + height: widget.compact ? 1.0 : null, + fontWeight: widget.error ? FontWeight.w600 : null, + ), + maxLines: widget.maxLines, overflow: TextOverflow.ellipsis, ), ); 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..bc90aaf --- /dev/null +++ b/frontend/lib/widgets/admin/enfant_management_widget.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.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 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; + }); + } + } + + 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 || + normalizeEnfantStatus(e.status) == + normalizeEnfantStatus(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), + ), + ], + ); + }, + ); + } +} 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..9de03bc 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,61 @@ 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: 'sans_garde', + child: Padding( + padding: EdgeInsets.only(left: 10), + child: Text('Sans garde', style: TextStyle(fontSize: 12)), + ), + ), + DropdownMenuItem( + value: 'garde', + child: Padding( + padding: EdgeInsets.only(left: 10), + child: Text('En garde', 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 +263,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 +311,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 +329,7 @@ class _UserManagementPanelState extends State { return; } - if (contentIndex == 3) { + if (contentIndex == 4) { final created = await showDialog( context: context, barrierDismissible: false, @@ -289,7 +354,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.', ), ), ); diff --git a/frontend/lib/widgets/admin/validation_am_wizard.dart b/frontend/lib/widgets/admin/validation_am_wizard.dart index 21356b9..5ea035a 100644 --- a/frontend/lib/widgets/admin/validation_am_wizard.dart +++ b/frontend/lib/widgets/admin/validation_am_wizard.dart @@ -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 { 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 _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 _photoProFields(DossierAM d) { final u = d.user; @@ -110,10 +95,7 @@ class _ValidationAmWizardState extends State { ]; } - static const List _personalRowLayout = [2, 2, 1, 2]; - static const Map> _personalRowFlex = { - 3: [2, 5] - }; // Code postal étroit, Ville large + static const List _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 { 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 { child: ValidationDetailSection( title: 'Dossier professionnel', fields: _photoProFields(d), - rowLayout: const [2, 2, 2, 2], + rowLayout: _photoProRowLayout, ), ), ); diff --git a/frontend/lib/widgets/admin/validation_family_wizard.dart b/frontend/lib/widgets/admin/validation_family_wizard.dart index 96297bc..76ac741 100644 --- a/frontend/lib/widgets/admin/validation_family_wizard.dart +++ b/frontend/lib/widgets/admin/validation_family_wizard.dart @@ -3,11 +3,11 @@ 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/utils/enfant_status_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 +108,6 @@ class _ValidationFamilyWizardState extends State { 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 _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 _parentRowLayout = [2, 2, 1, 2]; - static const Map> _parentRowFlex = { - 3: [2, 5] - }; // Code postal étroit, Ville large - static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url); @override @@ -153,11 +133,9 @@ class _ValidationFamilyWizardState extends State { 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 +156,9 @@ class _ValidationFamilyWizardState extends State { 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, ); } @@ -422,20 +398,9 @@ class _ValidationFamilyWizardState extends State { ); } - /// « Scolarisé » / « Scolarisée » selon le genre enfant (`F` / sinon masculin par défaut). - static String _scolariseAccordeAuGenre(String? gender) { - final g = (gender ?? '').trim().toUpperCase(); - if (g == 'F') return 'Scolarisée'; - return 'Scolarisé'; - } - - /// Statut dans la colonne 2/3 uniquement (pas de [ValidationReadOnlyField]) : scolarisé·e ou « À naître ». - /// `actif` : pas de ligne statut. + /// Statut dans la colonne 2/3 (scolarisé·e, à naître, sans garde, en garde). String? _enfantColumnStatusLabel(EnfantDossier e) { - final s = (e.status ?? '').trim().toLowerCase(); - if (s == 'a_naitre') return 'À naître'; - if (s == 'scolarise') return _scolariseAccordeAuGenre(e.gender); - return null; + return enfantColumnStatusLabel(status: e.status, gender: e.gender); } /// Nom ; date de naissance et genre sur une ligne (prénom au-dessus, pleine largeur). diff --git a/frontend/lib/widgets/common/auth_network_image.dart b/frontend/lib/widgets/common/auth_network_image.dart index 60ff0f2..10221ed 100644 --- a/frontend/lib/widgets/common/auth_network_image.dart +++ b/frontend/lib/widgets/common/auth_network_image.dart @@ -13,6 +13,7 @@ class AuthNetworkImage extends StatefulWidget { this.width, this.height, this.fit = BoxFit.cover, + this.alignment = Alignment.center, this.loadingBuilder, this.errorBuilder, }); @@ -21,6 +22,7 @@ class AuthNetworkImage extends StatefulWidget { final double? width; final double? height; final BoxFit fit; + final AlignmentGeometry alignment; final ImageLoadingBuilder? loadingBuilder; final ImageErrorWidgetBuilder? errorBuilder; @@ -75,6 +77,7 @@ class _AuthNetworkImageState extends State { width: widget.width, height: widget.height, fit: widget.fit, + alignment: widget.alignment, loadingBuilder: widget.loadingBuilder, errorBuilder: err, ); @@ -105,6 +108,7 @@ class _AuthNetworkImageState extends State { width: widget.width, height: widget.height, fit: widget.fit, + alignment: widget.alignment, headers: headers, loadingBuilder: widget.loadingBuilder, errorBuilder: err, diff --git a/frontend/lib/widgets/common/identity_block.dart b/frontend/lib/widgets/common/identity_block.dart new file mode 100644 index 0000000..91a1d30 --- /dev/null +++ b/frontend/lib/widgets/common/identity_block.dart @@ -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 rowLayout = [2, 2, 1, 2]; + static const Map> 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!), + ), + ], + ); + } +}