feat(#131): fiches parent/AM éditable, placement AM↔enfant, statuts garde/sans_garde
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 <cursoragent@cursor.com>
This commit is contained in:
parent
b99745e0fe
commit
003fe6b762
123
backend/scripts/close-gitea-issue-131.js
Normal file
123
backend/scripts/close-gitea-issue-131.js
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
153
backend/scripts/update-gitea-issue-140-ch6-famille.js
Normal file
153
backend/scripts/update-gitea-issue-140-ch6-famille.js
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* Met à jour l'issue Gitea #140 — epic dashboard admin ch.6 famille.
|
||||||
|
* Usage: node backend/scripts/update-gitea-issue-140-ch6-famille.js
|
||||||
|
* Token : .gitea-token (racine), GITEA_TOKEN, ou docs/27_BRIEFING-FRONTEND.md
|
||||||
|
*/
|
||||||
|
const https = require('https');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const repoRoot = path.join(__dirname, '../..');
|
||||||
|
let token = process.env.GITEA_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
try {
|
||||||
|
const tokenFile = path.join(repoRoot, '.gitea-token');
|
||||||
|
if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
try {
|
||||||
|
const briefing = fs.readFileSync(
|
||||||
|
path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/);
|
||||||
|
if (m) token = m[1].trim();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
console.error('Token non trouvé : .gitea-token ou GITEA_TOKEN (voir docs/26_GITEA-API.md)');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = `## Rôle de ce ticket
|
||||||
|
|
||||||
|
**#140 est un ticket epic / livraison** : il regroupe la mise en œuvre du **chapitre 6** du doc [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](../docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) (§6.1 et §6.2) sur la branche \`feature/140-dashboard-admin-ch6-famille\`.
|
||||||
|
|
||||||
|
Il **ne remplace pas** les tickets détaillés ci-dessous : il sert de **fil de livraison** (PR, recette, fermeture coordonnée). Chaque sous-ticket garde son périmètre propre ; #140 est clos quand l'ensemble est **fonctionnel et homogène en UI**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tickets couverts (périmètres embarqués)
|
||||||
|
|
||||||
|
| Ticket | Sujet | Rôle dans #140 |
|
||||||
|
|--------|--------|----------------|
|
||||||
|
| **#115** | Rattachement parent — **backend** | API \`POST/DELETE …/enfants/:enfantId\` |
|
||||||
|
| **#116** | Rattachement parent — **frontend** | UI rattacher / détacher depuis la fiche parent |
|
||||||
|
| **#130** | \`UserService\` — APIs admin | Appels \`getParents\`, \`getParent\`, \`getEnfants\`, \`updateParentFiche\`, etc. |
|
||||||
|
| **#131** | Fiche parent éditable | Modale parent (dashboard) — *hors fiche AM* |
|
||||||
|
| **#137** | Onglet **Enfants** | Liste globale admin + accès fiche enfant |
|
||||||
|
| **#138** | Fiche enfant + liste dans fiche parent | \`AdminChildDetailModal\` + liste enfants en bas de fiche parent |
|
||||||
|
|
||||||
|
> **Note :** fermer #140 peut entraîner la fermeture **partielle ou totale** de ces tickets selon ce qui est réellement livré et recetté dans la PR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hors scope #140
|
||||||
|
|
||||||
|
- **§6.3** — Création dossier admin sans numéro → **#129**
|
||||||
|
- Fiche **AM** éditable (dashboard) → reste **#131** (partie AM)
|
||||||
|
- Parcours gestionnaire « famille complexe » → **#139**
|
||||||
|
- Qualification responsable légal (combobox sur lien) → ticket à créer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend (attendu / livré)
|
||||||
|
|
||||||
|
- [x] \`PATCH /parents/:id/fiche\` — édition fiche parent (admin/gestionnaire)
|
||||||
|
- [x] \`POST /parents/:id/enfants/:enfantId\` — rattacher un enfant existant
|
||||||
|
- [x] \`DELETE /parents/:id/enfants/:enfantId\` — détacher (garde-fou : ≥1 responsable / enfant)
|
||||||
|
- [x] \`GET /enfants\` enrichi ; \`PATCH /enfants/:id\` pour gestionnaire
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend — fait
|
||||||
|
|
||||||
|
- [x] Modale **fiche parent** éditable (shell aligné validation, statut gélule, téléphone formaté, \`IdentityBlock\`)
|
||||||
|
- [x] Onglet **Enfants** — liste globale (\`EnfantManagementWidget\`)
|
||||||
|
- [x] Liste enfants dans fiche parent — cartes (photo, nom, âge ans/mois, statut), cadre blanc, scroll 2,5 lignes
|
||||||
|
- [x] Rattacher / détacher un enfant **existant** (API branchée)
|
||||||
|
- [x] Parsing robuste \`parentChildren\` + URLs médias \`/uploads\` en Flutter web
|
||||||
|
- [x] \`UserService\` — APIs parents / enfants / affiliation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend — reste à faire (bloquant clôture)
|
||||||
|
|
||||||
|
### Fiche enfant — #138 (UI)
|
||||||
|
- [ ] Reprendre \`AdminChildDetailModal\` : même **look & feel** que fiche parent / modales validation (largeur ~930 px, grille champs, photo enfant)
|
||||||
|
- [ ] Aligner dates, genre, statut sur les wizards validation famille
|
||||||
|
|
||||||
|
### Modale **rattacher** un enfant — #116 (UI)
|
||||||
|
- [ ] Remplacer le \`SimpleDialog\` actuel par une modale cohérente : liste type \`AdminEnfantUserCard\` (photo, nom, âge), recherche éventuelle
|
||||||
|
|
||||||
|
### Création d'un **nouvel** enfant depuis la fiche parent — doc §6.2
|
||||||
|
- [ ] **Non implémenté** aujourd'hui (seul le rattachement d'un enfant déjà en base existe)
|
||||||
|
- [ ] À trancher : inclus dans #140 si le back expose un \`POST\` admin depuis le contexte parent, sinon ticket dédié / extension #129
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recette avant merge
|
||||||
|
|
||||||
|
1. Parent avec 0 / 1 / 3+ enfants — liste, scroll, compteur
|
||||||
|
2. Rattacher puis détacher (message si dernier responsable)
|
||||||
|
3. Clic enfant → fiche enfant (après refonte UI)
|
||||||
|
4. Onglet Enfants — même rendu cartes
|
||||||
|
5. Avatars photos (URLs absolues web)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branche
|
||||||
|
|
||||||
|
\`feature/140-dashboard-admin-ch6-famille\`
|
||||||
|
|
||||||
|
## Références
|
||||||
|
|
||||||
|
- Doc produit : \`docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md\` §6.1, §6.2`;
|
||||||
|
|
||||||
|
const payload = JSON.stringify({ body });
|
||||||
|
|
||||||
|
const req = https.request(
|
||||||
|
{
|
||||||
|
hostname: 'git.ptits-pas.fr',
|
||||||
|
path: '/api/v1/repos/jmartin/petitspas/issues/140',
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(payload),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
const json = JSON.parse(data);
|
||||||
|
console.log('Issue #140 mise à jour :', json.html_url);
|
||||||
|
console.log('updated_at:', json.updated_at);
|
||||||
|
} else {
|
||||||
|
console.error('Erreur', res.statusCode, data);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on('error', (err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
req.write(payload);
|
||||||
|
req.end();
|
||||||
32
backend/src/common/utils/sanitize-user-for-api.spec.ts
Normal file
32
backend/src/common/utils/sanitize-user-for-api.spec.ts
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
23
backend/src/common/utils/sanitize-user-for-api.ts
Normal file
23
backend/src/common/utils/sanitize-user-for-api.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
47
backend/src/entities/am_children.entity.ts
Normal file
47
backend/src/entities/am_children.entity.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -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 { Users } from './users.entity';
|
||||||
|
import { AmChildren } from './am_children.entity';
|
||||||
|
|
||||||
@Entity('assistantes_maternelles')
|
@Entity('assistantes_maternelles')
|
||||||
export class AssistanteMaternelle {
|
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) */
|
/** Numéro de dossier (format AAAA-NNNNNN), même valeur que sur utilisateurs (ticket #103) */
|
||||||
@Column({ name: 'numero_dossier', length: 20, nullable: true })
|
@Column({ name: 'numero_dossier', length: 20, nullable: true })
|
||||||
numero_dossier?: string;
|
numero_dossier?: string;
|
||||||
|
|
||||||
|
@OneToMany(() => AmChildren, (ac) => ac.am)
|
||||||
|
amChildren: AmChildren[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,12 +4,14 @@ import {
|
|||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { Parents } from './parents.entity';
|
import { Parents } from './parents.entity';
|
||||||
import { ParentsChildren } from './parents_children.entity';
|
import { ParentsChildren } from './parents_children.entity';
|
||||||
|
import { AmChildren } from './am_children.entity';
|
||||||
import { Dossier } from './dossiers.entity';
|
import { Dossier } from './dossiers.entity';
|
||||||
|
|
||||||
export enum StatutEnfantType {
|
export enum StatutEnfantType {
|
||||||
A_NAITRE = 'a_naitre',
|
A_NAITRE = 'a_naitre',
|
||||||
ACTIF = 'actif',
|
|
||||||
SCOLARISE = 'scolarise',
|
SCOLARISE = 'scolarise',
|
||||||
|
GARDE = 'garde',
|
||||||
|
SANS_GARDE = 'sans_garde',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum GenreType {
|
export enum GenreType {
|
||||||
@ -68,6 +70,9 @@ export class Children {
|
|||||||
@OneToMany(() => ParentsChildren, pc => pc.child)
|
@OneToMany(() => ParentsChildren, pc => pc.child)
|
||||||
parentLinks: ParentsChildren[];
|
parentLinks: ParentsChildren[];
|
||||||
|
|
||||||
|
@OneToMany(() => AmChildren, (ac) => ac.child)
|
||||||
|
amLinks: AmChildren[];
|
||||||
|
|
||||||
// Relation avec Dossier
|
// Relation avec Dossier
|
||||||
@OneToMany(() => Dossier, d => d.child)
|
@OneToMany(() => Dossier, d => d.child)
|
||||||
dossiers: Dossier[];
|
dossiers: Dossier[];
|
||||||
|
|||||||
@ -12,11 +12,14 @@ import { AssistantesMaternellesService } from './assistantes_maternelles.service
|
|||||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
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 { CreateAssistanteDto } from '../user/dto/create_assistante.dto';
|
||||||
import { UpdateAssistanteDto } from '../user/dto/update_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 { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
import { AuthGuard } from 'src/common/guards/auth.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")
|
@ApiTags("Assistantes Maternelles")
|
||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
@ -31,28 +34,74 @@ export class AssistantesMaternellesController {
|
|||||||
@ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' })
|
@ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' })
|
||||||
@ApiBody({ type: CreateAssistanteDto })
|
@ApiBody({ type: CreateAssistanteDto })
|
||||||
@Post()
|
@Post()
|
||||||
create(@Body() dto: CreateAssistanteDto): Promise<AssistanteMaternelle> {
|
async create(@Body() dto: CreateAssistanteDto): Promise<AssistanteMaternelle> {
|
||||||
return this.assistantesMaternellesService.create(dto);
|
const am = await this.assistantesMaternellesService.create(dto);
|
||||||
|
return mapAmForApi(am);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
@Get()
|
@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: 200, description: 'Liste des nounous' })
|
||||||
@ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' })
|
@ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' })
|
||||||
getAll(): Promise<AssistanteMaternelle[]> {
|
async getAll(): Promise<AssistanteMaternelle[]> {
|
||||||
return this.assistantesMaternellesService.findAll();
|
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')
|
@Get(':id')
|
||||||
@ApiParam({ name: 'id', description: "UUID de la nounou" })
|
@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: 200, description: 'Détails de la nounou' })
|
||||||
@ApiResponse({ status: 404, description: 'Nounou non trouvée' })
|
@ApiResponse({ status: 404, description: 'Nounou non trouvée' })
|
||||||
@ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' })
|
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||||
getOne(@Param('id') user_id: string): Promise<AssistanteMaternelle> {
|
async getOne(@Param('id') user_id: string): Promise<AssistanteMaternelle> {
|
||||||
return this.assistantesMaternellesService.findOne(user_id);
|
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<AssistanteMaternelle> {
|
||||||
|
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<AssistanteMaternelle> {
|
||||||
|
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<AssistanteMaternelle> {
|
||||||
|
const am = await this.assistantesMaternellesService.detachEnfant(id, enfantId);
|
||||||
|
return mapAmForApi(am);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
||||||
@ -63,14 +112,15 @@ export class AssistantesMaternellesController {
|
|||||||
@ApiResponse({ status: 404, description: 'Nounou non trouvée' })
|
@ApiResponse({ status: 404, description: 'Nounou non trouvée' })
|
||||||
@ApiParam({ name: 'id', description: "UUID de la nounou" })
|
@ApiParam({ name: 'id', description: "UUID de la nounou" })
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateAssistanteDto): Promise<AssistanteMaternelle> {
|
async update(@Param('id') id: string, @Body() dto: UpdateAssistanteDto): Promise<AssistanteMaternelle> {
|
||||||
return this.assistantesMaternellesService.update(id, dto);
|
const am = await this.assistantesMaternellesService.update(id, dto);
|
||||||
|
return mapAmForApi(am);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({ summary: 'Supprimer une nounou' })
|
@ApiOperation({ summary: 'Supprimer une nounou' })
|
||||||
@ApiResponse({ status: 200, description: 'Nounou supprimée avec succès' })
|
@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' })
|
@ApiResponse({ status: 404, description: 'Nounou non trouvée' })
|
||||||
@ApiParam({ name: 'id', description: "UUID de la nounou" })
|
@ApiParam({ name: 'id', description: "UUID de la nounou" })
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
@ -2,12 +2,14 @@ import { Module } from '@nestjs/common';
|
|||||||
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
|
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
|
||||||
import { AssistantesMaternellesController } from './assistantes_maternelles.controller';
|
import { AssistantesMaternellesController } from './assistantes_maternelles.controller';
|
||||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
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 { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { Users } from 'src/entities/users.entity';
|
import { Users } from 'src/entities/users.entity';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([AssistanteMaternelle, Users]),
|
imports: [TypeOrmModule.forFeature([AssistanteMaternelle, AmChildren, Children, Users]),
|
||||||
AuthModule
|
AuthModule
|
||||||
],
|
],
|
||||||
controllers: [AssistantesMaternellesController],
|
controllers: [AssistantesMaternellesController],
|
||||||
|
|||||||
@ -5,11 +5,17 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { IsNull, Repository } from 'typeorm';
|
||||||
import { RoleType, Users } from 'src/entities/users.entity';
|
import { RoleType, Users } from 'src/entities/users.entity';
|
||||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.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 { CreateAssistanteDto } from '../user/dto/create_assistante.dto';
|
||||||
import { UpdateAssistanteDto } from '../user/dto/update_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()
|
@Injectable()
|
||||||
export class AssistantesMaternellesService {
|
export class AssistantesMaternellesService {
|
||||||
@ -17,10 +23,13 @@ export class AssistantesMaternellesService {
|
|||||||
@InjectRepository(AssistanteMaternelle)
|
@InjectRepository(AssistanteMaternelle)
|
||||||
private readonly assistantesMaternelleRepository: Repository<AssistanteMaternelle>,
|
private readonly assistantesMaternelleRepository: Repository<AssistanteMaternelle>,
|
||||||
@InjectRepository(Users)
|
@InjectRepository(Users)
|
||||||
private readonly usersRepository: Repository<Users>
|
private readonly usersRepository: Repository<Users>,
|
||||||
|
@InjectRepository(AmChildren)
|
||||||
|
private readonly amChildrenRepository: Repository<AmChildren>,
|
||||||
|
@InjectRepository(Children)
|
||||||
|
private readonly childrenRepository: Repository<Children>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// Création d’une assistante maternelle
|
|
||||||
async create(dto: CreateAssistanteDto): Promise<AssistanteMaternelle> {
|
async create(dto: CreateAssistanteDto): Promise<AssistanteMaternelle> {
|
||||||
const user = await this.usersRepository.findOneBy({ id: dto.user_id });
|
const user = await this.usersRepository.findOneBy({ id: dto.user_id });
|
||||||
if (!user) throw new NotFoundException('Utilisateur introuvable');
|
if (!user) throw new NotFoundException('Utilisateur introuvable');
|
||||||
@ -49,30 +58,208 @@ export class AssistantesMaternellesService {
|
|||||||
return this.assistantesMaternelleRepository.save(entity);
|
return this.assistantesMaternelleRepository.save(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Liste des assistantes maternelles
|
|
||||||
async findAll(): Promise<AssistanteMaternelle[]> {
|
async findAll(): Promise<AssistanteMaternelle[]> {
|
||||||
return this.assistantesMaternelleRepository.find({
|
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<AssistanteMaternelle> {
|
async findOne(user_id: string): Promise<AssistanteMaternelle> {
|
||||||
const assistante = await this.assistantesMaternelleRepository.findOne({
|
const assistante = await this.assistantesMaternelleRepository.findOne({
|
||||||
where: { user_id },
|
where: { user_id },
|
||||||
relations: ['user'],
|
relations: [...AM_CHILDREN_RELATIONS],
|
||||||
});
|
});
|
||||||
if (!assistante) throw new NotFoundException('Assistante maternelle introuvable');
|
if (!assistante) throw new NotFoundException('Assistante maternelle introuvable');
|
||||||
return assistante;
|
return assistante;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mise à jour
|
|
||||||
async update(id: string, dto: UpdateAssistanteDto): Promise<AssistanteMaternelle> {
|
async update(id: string, dto: UpdateAssistanteDto): Promise<AssistanteMaternelle> {
|
||||||
await this.assistantesMaternelleRepository.update(id, dto);
|
await this.assistantesMaternelleRepository.update(id, dto);
|
||||||
return this.findOne(id);
|
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<AssistanteMaternelle> {
|
||||||
|
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<AssistanteMaternelle> = {};
|
||||||
|
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<AssistanteMaternelle> {
|
||||||
|
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<AssistanteMaternelle> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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 }> {
|
async remove(id: string): Promise<{ message: string }> {
|
||||||
await this.assistantesMaternelleRepository.delete(id);
|
await this.assistantesMaternelleRepository.delete(id);
|
||||||
return { message: 'Assistante maternelle supprimée' };
|
return { message: 'Assistante maternelle supprimée' };
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -176,7 +176,7 @@ describe('AuthService (#118 create-password API)', () => {
|
|||||||
parentsServiceMock.getDossierFamilleByNumero.mockResolvedValue({
|
parentsServiceMock.getDossierFamilleByNumero.mockResolvedValue({
|
||||||
numero_dossier: '2026-000021',
|
numero_dossier: '2026-000021',
|
||||||
parents: [{ user_id: 'p1', email: 'claire@test.fr', statut: StatutUtilisateurType.REFUSE }],
|
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',
|
texte_motivation: 'Motivation test',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -551,7 +551,7 @@ export class AuthService {
|
|||||||
? new Date(enfantDto.date_previsionnelle_naissance)
|
? new Date(enfantDto.date_previsionnelle_naissance)
|
||||||
: undefined;
|
: undefined;
|
||||||
enfant.photo_url = urlPhoto || 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.consent_photo = false;
|
||||||
enfant.is_multiple = enfantDto.grossesse_multiple || 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.genre !== undefined) enfant.gender = enfantDto.genre;
|
||||||
if (enfantDto.date_naissance !== undefined) {
|
if (enfantDto.date_naissance !== undefined) {
|
||||||
enfant.birth_date = new Date(enfantDto.date_naissance);
|
enfant.birth_date = new Date(enfantDto.date_naissance);
|
||||||
enfant.status = StatutEnfantType.ACTIF;
|
enfant.status = StatutEnfantType.SANS_GARDE;
|
||||||
}
|
}
|
||||||
if (enfantDto.date_previsionnelle_naissance !== undefined) {
|
if (enfantDto.date_previsionnelle_naissance !== undefined) {
|
||||||
enfant.due_date = new Date(enfantDto.date_previsionnelle_naissance);
|
enfant.due_date = new Date(enfantDto.date_previsionnelle_naissance);
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import {
|
|||||||
import { GenreType, StatutEnfantType } from 'src/entities/children.entity';
|
import { GenreType, StatutEnfantType } from 'src/entities/children.entity';
|
||||||
|
|
||||||
export class CreateEnfantsDto {
|
export class CreateEnfantsDto {
|
||||||
@ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.ACTIF })
|
@ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE })
|
||||||
@IsEnum(StatutEnfantType)
|
@IsEnum(StatutEnfantType)
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
status: StatutEnfantType;
|
status: StatutEnfantType;
|
||||||
|
|||||||
@ -83,7 +83,12 @@ export class EnfantsController {
|
|||||||
return this.enfantsService.findOne(id, currentUser);
|
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')
|
@Patch(':id')
|
||||||
update(
|
update(
|
||||||
@Param('id', new ParseUUIDPipe()) id: string,
|
@Param('id', new ParseUUIDPipe()) id: string,
|
||||||
|
|||||||
@ -34,7 +34,7 @@ export class EnfantsService {
|
|||||||
|
|
||||||
// Vérif métier simple
|
// Vérif métier simple
|
||||||
if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) {
|
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)
|
// 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);
|
return this.findOne(child.id, currentUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Liste des enfants
|
// Liste des enfants (admin/gestionnaire)
|
||||||
async findAll(): Promise<Children[]> {
|
async findAll(): Promise<Children[]> {
|
||||||
return this.childrenRepository.find({
|
return this.childrenRepository.find({
|
||||||
relations: ['parentLinks'],
|
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
||||||
order: { last_name: 'ASC', first_name: 'ASC' },
|
order: { last_name: 'ASC', first_name: 'ASC' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,57 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsEmail,
|
||||||
|
IsEnum,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
|
/** Mise à jour fiche parent par admin/gestionnaire (doc 28 §6.1, ticket #131). */
|
||||||
|
export class UpdateParentFicheAdminDto {
|
||||||
|
@ApiPropertyOptional({ example: 'Dupont' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
nom?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Marie' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
prenom?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'marie.dupont@example.com' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '+33612345678' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
telephone?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '10 rue de la Paix' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
adresse?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'Paris' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(150)
|
||||||
|
ville?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: '75001' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10)
|
||||||
|
code_postal?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: StatutUtilisateurType })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(StatutUtilisateurType)
|
||||||
|
statut?: StatutUtilisateurType;
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
@ -16,11 +17,13 @@ import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
|
|||||||
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateParentDto } from '../user/dto/create_parent.dto';
|
import { CreateParentDto } from '../user/dto/create_parent.dto';
|
||||||
import { UpdateParentsDto } from '../user/dto/update_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 { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
import { User } from 'src/common/decorators/user.decorator';
|
import { User } from 'src/common/decorators/user.decorator';
|
||||||
import { PendingFamilyDto } from './dto/pending-family.dto';
|
import { PendingFamilyDto } from './dto/pending-family.dto';
|
||||||
import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto';
|
import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto';
|
||||||
|
import { mapParentForApi, mapParentsForApi } from './parents.mapper';
|
||||||
|
|
||||||
@ApiTags('Parents')
|
@ApiTags('Parents')
|
||||||
@Controller('parents')
|
@Controller('parents')
|
||||||
@ -79,21 +82,25 @@ export class ParentsController {
|
|||||||
return validated;
|
return validated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
|
||||||
@Get()
|
@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: 200, type: [Parents], description: 'Liste des parents' })
|
||||||
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
||||||
getAll(): Promise<Parents[]> {
|
async getAll(): Promise<Parents[]> {
|
||||||
return this.parentsService.findAll();
|
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')
|
@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: 200, type: Parents, description: 'Détails du parent par ID utilisateur' })
|
||||||
@ApiResponse({ status: 404, description: 'Parent non trouvé' })
|
@ApiResponse({ status: 404, description: 'Parent non trouvé' })
|
||||||
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
||||||
getOne(@Param('id') user_id: string): Promise<Parents> {
|
async getOne(@Param('id') user_id: string): Promise<Parents> {
|
||||||
return this.parentsService.findOne(user_id);
|
const parent = await this.parentsService.findOne(user_id);
|
||||||
|
return mapParentForApi(parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
||||||
@ -101,8 +108,51 @@ export class ParentsController {
|
|||||||
@ApiBody({ type: CreateParentDto })
|
@ApiBody({ type: CreateParentDto })
|
||||||
@ApiResponse({ status: 201, type: Parents, description: 'Parent créé avec succès' })
|
@ApiResponse({ status: 201, type: Parents, description: 'Parent créé avec succès' })
|
||||||
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
||||||
create(@Body() dto: CreateParentDto): Promise<Parents> {
|
async create(@Body() dto: CreateParentDto): Promise<Parents> {
|
||||||
return this.parentsService.create(dto);
|
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<Parents> {
|
||||||
|
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<Parents> {
|
||||||
|
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<Parents> {
|
||||||
|
const parent = await this.parentsService.detachEnfant(id, enfantId);
|
||||||
|
return mapParentForApi(parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
@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: 200, type: Parents, description: 'Parent mis à jour avec succès' })
|
||||||
@ApiResponse({ status: 404, description: 'Parent introuvable' })
|
@ApiResponse({ status: 404, description: 'Parent introuvable' })
|
||||||
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateParentsDto): Promise<Parents> {
|
async update(@Param('id') id: string, @Body() dto: UpdateParentsDto): Promise<Parents> {
|
||||||
return this.parentsService.update(id, dto);
|
const parent = await this.parentsService.update(id, dto);
|
||||||
|
return mapParentForApi(parent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
backend/src/routes/parents/parents.mapper.spec.ts
Normal file
38
backend/src/routes/parents/parents.mapper.spec.ts
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
18
backend/src/routes/parents/parents.mapper.ts
Normal file
18
backend/src/routes/parents/parents.mapper.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
|||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity';
|
import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity';
|
||||||
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
import { ParentsController } from './parents.controller';
|
import { ParentsController } from './parents.controller';
|
||||||
import { ParentsService } from './parents.service';
|
import { ParentsService } from './parents.service';
|
||||||
import { Users } from 'src/entities/users.entity';
|
import { Users } from 'src/entities/users.entity';
|
||||||
@ -11,7 +12,7 @@ import { UserModule } from '../user/user.module';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant]),
|
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant, ParentsChildren]),
|
||||||
forwardRef(() => UserModule),
|
forwardRef(() => UserModule),
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
|
|||||||
@ -17,7 +17,9 @@ import {
|
|||||||
DossierFamilleParentDto,
|
DossierFamilleParentDto,
|
||||||
DossierFamilleEnfantDto,
|
DossierFamilleEnfantDto,
|
||||||
} from './dto/dossier-famille-complet.dto';
|
} from './dto/dossier-famille-complet.dto';
|
||||||
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
import { Children } from 'src/entities/children.entity';
|
import { Children } from 'src/entities/children.entity';
|
||||||
|
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ParentsService {
|
export class ParentsService {
|
||||||
@ -28,6 +30,8 @@ export class ParentsService {
|
|||||||
private readonly usersRepository: Repository<Users>,
|
private readonly usersRepository: Repository<Users>,
|
||||||
@InjectRepository(DossierFamille)
|
@InjectRepository(DossierFamille)
|
||||||
private readonly dossierFamilleRepository: Repository<DossierFamille>,
|
private readonly dossierFamilleRepository: Repository<DossierFamille>,
|
||||||
|
@InjectRepository(ParentsChildren)
|
||||||
|
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// Création d’un parent
|
// Création d’un parent
|
||||||
@ -62,7 +66,7 @@ export class ParentsService {
|
|||||||
// Liste des parents
|
// Liste des parents
|
||||||
async findAll(): Promise<Parents[]> {
|
async findAll(): Promise<Parents[]> {
|
||||||
return this.parentsRepository.find({
|
return this.parentsRepository.find({
|
||||||
relations: ['user', 'co_parent', 'parentChildren', 'dossiers'],
|
relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,7 +74,7 @@ export class ParentsService {
|
|||||||
async findOne(user_id: string): Promise<Parents> {
|
async findOne(user_id: string): Promise<Parents> {
|
||||||
const parent = await this.parentsRepository.findOne({
|
const parent = await this.parentsRepository.findOne({
|
||||||
where: { user_id },
|
where: { user_id },
|
||||||
relations: ['user', 'co_parent', 'parentChildren', 'dossiers'],
|
relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers'],
|
||||||
});
|
});
|
||||||
if (!parent) throw new NotFoundException('Parent introuvable');
|
if (!parent) throw new NotFoundException('Parent introuvable');
|
||||||
return parent;
|
return parent;
|
||||||
@ -82,6 +86,79 @@ export class ParentsService {
|
|||||||
return this.findOne(id);
|
return this.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mise à jour fiche parent (champs user + statut) par admin/gestionnaire. Ticket #131 / doc 28 §6.1.
|
||||||
|
*/
|
||||||
|
async updateFicheAdmin(parentUserId: string, dto: UpdateParentFicheAdminDto): Promise<Parents> {
|
||||||
|
const parent = await this.findOne(parentUserId);
|
||||||
|
const user = parent.user;
|
||||||
|
|
||||||
|
if (dto.email && dto.email !== user.email) {
|
||||||
|
const existing = await this.usersRepository.findOne({ where: { email: dto.email } });
|
||||||
|
if (existing && existing.id !== user.id) {
|
||||||
|
throw new ConflictException('Cet email est déjà utilisé');
|
||||||
|
}
|
||||||
|
user.email = dto.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.nom !== undefined) user.nom = dto.nom;
|
||||||
|
if (dto.prenom !== undefined) user.prenom = dto.prenom;
|
||||||
|
if (dto.telephone !== undefined) user.telephone = dto.telephone;
|
||||||
|
if (dto.adresse !== undefined) user.adresse = dto.adresse;
|
||||||
|
if (dto.ville !== undefined) user.ville = dto.ville;
|
||||||
|
if (dto.code_postal !== undefined) user.code_postal = dto.code_postal;
|
||||||
|
if (dto.statut !== undefined) user.statut = dto.statut;
|
||||||
|
|
||||||
|
await this.usersRepository.save(user);
|
||||||
|
return this.findOne(parentUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rattacher un enfant existant à un parent (enfants_parents). Ticket #115 / doc 28 §6.2.
|
||||||
|
*/
|
||||||
|
async attachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
||||||
|
await this.findOne(parentUserId);
|
||||||
|
|
||||||
|
const existing = await this.parentsChildrenRepository.findOne({
|
||||||
|
where: { parentId: parentUserId, enfantId },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException('Cet enfant est déjà rattaché à ce parent');
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = await this.parentsRepository.manager.findOne(Children, { where: { id: enfantId } });
|
||||||
|
if (!child) {
|
||||||
|
throw new NotFoundException('Enfant introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.parentsChildrenRepository.save(
|
||||||
|
this.parentsChildrenRepository.create({ parentId: parentUserId, enfantId }),
|
||||||
|
);
|
||||||
|
return this.findOne(parentUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détacher un enfant d'un parent sans supprimer l'enfant. Ticket #115 / doc 28 §6.2.
|
||||||
|
*/
|
||||||
|
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
||||||
|
await this.findOne(parentUserId);
|
||||||
|
|
||||||
|
const link = await this.parentsChildrenRepository.findOne({
|
||||||
|
where: { parentId: parentUserId, enfantId },
|
||||||
|
});
|
||||||
|
if (!link) {
|
||||||
|
throw new NotFoundException('Lien parent-enfant introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalLinks = await this.parentsChildrenRepository.count({ where: { enfantId } });
|
||||||
|
if (totalLinks <= 1) {
|
||||||
|
throw new BadRequestException('Un enfant doit rester rattaché à au moins un responsable');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId });
|
||||||
|
return this.findOne(parentUserId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liste des familles en attente (une entrée par famille).
|
* Liste des familles en attente (une entrée par famille).
|
||||||
* Famille = lien co_parent ou partage d'enfants (même logique que backfill #103).
|
* Famille = lien co_parent ou partage d'enfants (même logique que backfill #103).
|
||||||
|
|||||||
218
database/BDD.sql
218
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";
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
@ -5,40 +11,63 @@ CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
|||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
DO $$ BEGIN
|
DO $$ BEGIN
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'role_type') THEN
|
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;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'genre_type') THEN
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'genre_type') THEN
|
||||||
CREATE TYPE genre_type AS ENUM ('H', 'F', 'Autre');
|
CREATE TYPE genre_type AS ENUM ('H', 'F', 'Autre');
|
||||||
END IF;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_utilisateur_type') THEN
|
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;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_enfant_type') THEN
|
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;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_dossier_type') THEN
|
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;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_contrat_type') THEN
|
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');
|
CREATE TYPE statut_contrat_type AS ENUM (
|
||||||
|
'brouillon', 'en_attente_signature', 'valide', 'resilie'
|
||||||
|
);
|
||||||
END IF;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_avenant_type') THEN
|
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;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_evenement_type') THEN
|
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;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_evenement_type') THEN
|
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;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_validation_type') THEN
|
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;
|
END IF;
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'situation_familiale_type') THEN
|
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 IF;
|
||||||
END $$;
|
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
|
-- Table : utilisateurs
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
@ -46,26 +75,33 @@ CREATE TABLE utilisateurs (
|
|||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
email VARCHAR(255) NOT NULL UNIQUE,
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'),
|
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),
|
prenom VARCHAR(100),
|
||||||
nom VARCHAR(100),
|
nom VARCHAR(100),
|
||||||
genre genre_type,
|
genre genre_type,
|
||||||
role role_type NOT NULL,
|
role role_type NOT NULL,
|
||||||
statut statut_utilisateur_type DEFAULT 'en_attente',
|
statut statut_utilisateur_type DEFAULT 'en_attente',
|
||||||
telephone VARCHAR(20), -- Unifié (mobile privilégié)
|
telephone VARCHAR(20),
|
||||||
adresse TEXT,
|
adresse TEXT,
|
||||||
date_naissance DATE,
|
date_naissance DATE,
|
||||||
lieu_naissance_ville VARCHAR(100),
|
lieu_naissance_ville VARCHAR(100),
|
||||||
lieu_naissance_pays 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,
|
consentement_photo BOOLEAN DEFAULT false,
|
||||||
date_consentement_photo TIMESTAMPTZ,
|
date_consentement_photo TIMESTAMPTZ,
|
||||||
token_creation_mdp VARCHAR(255), -- Token pour créer MDP après validation
|
token_creation_mdp VARCHAR(255),
|
||||||
token_creation_mdp_expire_le TIMESTAMPTZ, -- Expiration 7 jours
|
token_creation_mdp_expire_le TIMESTAMPTZ,
|
||||||
-- Ticket #127 : réinitialisation mot de passe oublié (distinct de token_creation_mdp)
|
password_reset_token VARCHAR(255),
|
||||||
password_reset_token VARCHAR(255) NULL,
|
password_reset_expires TIMESTAMPTZ,
|
||||||
password_reset_expires TIMESTAMPTZ NULL,
|
token_reprise VARCHAR(255),
|
||||||
|
token_reprise_expire_le TIMESTAMPTZ,
|
||||||
changement_mdp_obligatoire BOOLEAN DEFAULT false,
|
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(),
|
cree_le TIMESTAMPTZ DEFAULT now(),
|
||||||
modifie_le TIMESTAMPTZ DEFAULT now(),
|
modifie_le TIMESTAMPTZ DEFAULT now(),
|
||||||
ville VARCHAR(150),
|
ville VARCHAR(150),
|
||||||
@ -74,7 +110,6 @@ CREATE TABLE utilisateurs (
|
|||||||
situation_familiale situation_familiale_type
|
situation_familiale situation_familiale_type
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Index pour recherche par token
|
|
||||||
CREATE INDEX idx_utilisateurs_token_creation_mdp
|
CREATE INDEX idx_utilisateurs_token_creation_mdp
|
||||||
ON utilisateurs(token_creation_mdp)
|
ON utilisateurs(token_creation_mdp)
|
||||||
WHERE token_creation_mdp IS NOT NULL;
|
WHERE token_creation_mdp IS NOT NULL;
|
||||||
@ -83,6 +118,14 @@ CREATE INDEX idx_utilisateurs_password_reset_token
|
|||||||
ON utilisateurs(password_reset_token)
|
ON utilisateurs(password_reset_token)
|
||||||
WHERE password_reset_token IS NOT NULL;
|
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
|
-- Table : assistantes_maternelles
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
@ -97,17 +140,27 @@ CREATE TABLE assistantes_maternelles (
|
|||||||
date_agrement DATE,
|
date_agrement DATE,
|
||||||
annee_experience SMALLINT,
|
annee_experience SMALLINT,
|
||||||
specialite VARCHAR(100),
|
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
|
-- Table : parents
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
CREATE TABLE parents (
|
CREATE TABLE parents (
|
||||||
id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
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
|
-- Table : enfants
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
@ -116,7 +169,7 @@ CREATE TABLE enfants (
|
|||||||
statut statut_enfant_type,
|
statut statut_enfant_type,
|
||||||
prenom VARCHAR(100),
|
prenom VARCHAR(100),
|
||||||
nom VARCHAR(100),
|
nom VARCHAR(100),
|
||||||
genre genre_type NOT NULL, -- Obligatoire selon CDC
|
genre genre_type NOT NULL,
|
||||||
date_naissance DATE,
|
date_naissance DATE,
|
||||||
date_prevue_naissance DATE,
|
date_prevue_naissance DATE,
|
||||||
photo_url TEXT,
|
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 (
|
CREATE TABLE dossier_famille (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@ -276,10 +349,6 @@ CREATE TABLE notifications (
|
|||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
-- Table : validations
|
-- 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 (
|
CREATE TABLE validations (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE SET NULL,
|
id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE SET NULL,
|
||||||
@ -305,13 +374,10 @@ CREATE TABLE configuration (
|
|||||||
modifie_par UUID REFERENCES utilisateurs(id)
|
modifie_par UUID REFERENCES utilisateurs(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Index pour performance
|
|
||||||
CREATE INDEX idx_configuration_cle ON configuration(cle);
|
CREATE INDEX idx_configuration_cle ON configuration(cle);
|
||||||
CREATE INDEX idx_configuration_categorie ON configuration(categorie);
|
CREATE INDEX idx_configuration_categorie ON configuration(categorie);
|
||||||
|
|
||||||
-- Seed initial de configuration
|
|
||||||
INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES
|
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_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_port', '25', 'number', 'email', 'Port SMTP (25, 465, 587)'),
|
||||||
('smtp_secure', 'false', 'boolean', 'email', 'Utiliser SSL/TLS (true pour port 465)'),
|
('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)'),
|
('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_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'),
|
('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_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_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'),
|
('app_logo_url', '/assets/logo.png', 'string', 'app', 'URL du logo de l''application'),
|
||||||
('setup_completed', 'false', 'boolean', 'app', 'Configuration initiale terminée'),
|
('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)'),
|
('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)'),
|
('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)'),
|
('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 (
|
CREATE TABLE documents_legaux (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
type VARCHAR(50) NOT NULL, -- 'cgu' ou 'privacy'
|
type VARCHAR(50) NOT NULL,
|
||||||
version INTEGER NOT NULL, -- Numéro de version (auto-incrémenté)
|
version INTEGER NOT NULL,
|
||||||
fichier_nom VARCHAR(255) NOT NULL, -- Nom original du fichier
|
fichier_nom VARCHAR(255) NOT NULL,
|
||||||
fichier_path VARCHAR(500) NOT NULL, -- Chemin de stockage
|
fichier_path VARCHAR(500) NOT NULL,
|
||||||
fichier_hash VARCHAR(64) NOT NULL, -- Hash SHA-256 pour intégrité
|
fichier_hash VARCHAR(64) NOT NULL,
|
||||||
actif BOOLEAN DEFAULT false, -- Version actuellement active
|
actif BOOLEAN DEFAULT false,
|
||||||
televerse_par UUID REFERENCES utilisateurs(id), -- Qui a uploadé
|
televerse_par UUID REFERENCES utilisateurs(id),
|
||||||
televerse_le TIMESTAMPTZ DEFAULT now(), -- Date d'upload
|
televerse_le TIMESTAMPTZ DEFAULT now(),
|
||||||
active_le TIMESTAMPTZ, -- Date d'activation
|
active_le TIMESTAMPTZ,
|
||||||
UNIQUE(type, version) -- Pas de doublon version
|
UNIQUE(type, version)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Index pour performance
|
|
||||||
CREATE INDEX idx_documents_legaux_type_actif ON documents_legaux(type, actif);
|
CREATE INDEX idx_documents_legaux_type_actif ON documents_legaux(type, actif);
|
||||||
CREATE INDEX idx_documents_legaux_version ON documents_legaux(type, version DESC);
|
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 UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
||||||
id_document UUID REFERENCES documents_legaux(id),
|
id_document UUID REFERENCES documents_legaux(id),
|
||||||
type_document VARCHAR(50) NOT NULL, -- 'cgu' ou 'privacy'
|
type_document VARCHAR(50) NOT NULL,
|
||||||
version_document INTEGER NOT NULL, -- Version acceptée
|
version_document INTEGER NOT NULL,
|
||||||
accepte_le TIMESTAMPTZ DEFAULT now(), -- Date d'acceptation
|
accepte_le TIMESTAMPTZ DEFAULT now(),
|
||||||
ip_address INET, -- IP de l'utilisateur (RGPD)
|
ip_address INET,
|
||||||
user_agent TEXT -- Navigateur (preuve)
|
user_agent TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Index pour performance
|
|
||||||
CREATE INDEX idx_acceptations_utilisateur ON acceptations_documents(id_utilisateur);
|
CREATE INDEX idx_acceptations_utilisateur ON acceptations_documents(id_utilisateur);
|
||||||
CREATE INDEX idx_acceptations_document ON acceptations_documents(id_document);
|
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)
|
-- Ticket #103 : Numéro de dossier (AAAA-NNNNNN, séquence par année)
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
CREATE TABLE IF NOT EXISTS numero_dossier_sequence (
|
CREATE TABLE numero_dossier_sequence (
|
||||||
annee INT PRIMARY KEY,
|
annee INT PRIMARY KEY,
|
||||||
prochain INT NOT NULL DEFAULT 1
|
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
|
-- 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
|
-- Seed : Super Administrateur par défaut
|
||||||
-- ==========================================================
|
-- Email: admin@ptits-pas.fr | Mot de passe: 4dm1n1strateur
|
||||||
-- Email: admin@ptits-pas.fr
|
|
||||||
-- Mot de passe: 4dm1n1strateur (hashé bcrypt)
|
|
||||||
-- IMPORTANT: Changer ce mot de passe en production !
|
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
INSERT INTO utilisateurs (
|
INSERT INTO utilisateurs (
|
||||||
email,
|
email,
|
||||||
|
|||||||
@ -15,12 +15,13 @@ Ce projet contient la **base de données** pour l'application PtitsPas, avec scr
|
|||||||
|
|
||||||
## Structure du projet
|
## Structure du projet
|
||||||
|
|
||||||
- `migrations/` : scripts SQL pour la création et l'import de la base
|
- **`BDD.sql`** : schéma canonique (création from scratch — utilisé par `docker-compose.yml` racine)
|
||||||
- `bdd/data_test/` : fichiers CSV pour l'import de données de test
|
- `migrations/` : scripts SQL pour **mettre à jour** une BDD déjà en service
|
||||||
- `docs/` : documentation métier et technique
|
- `bdd/data_test/` : fichiers CSV (import legacy)
|
||||||
- `seed/` : scripts de seed
|
- `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
|
- `tests/` : tests SQL
|
||||||
- `docker-compose.dev.yml` : configuration Docker pour le développement
|
- `docker-compose.dev.yml` : Postgres standalone (BDD.sql + seed auto)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo","est_multiple"
|
"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
|
"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","sans_garde","Emma","Dupont","F","2020-06-01",,,False,,False
|
||||||
"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","actif",,,,"2020-01-01","2025-01-01",,False,,False
|
"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","sans_garde",,,,"2020-01-01","2025-01-01",,False,,False
|
||||||
"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","actif","Emma","Martin",,"2023-02-15",,,False,,False
|
"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","sans_garde","Emma","Martin",,"2023-02-15",,,False,,False
|
||||||
"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","actif","Noah","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","actif","Léa","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","actif","Chloé","Rousseau",,"2022-04-20",,,False,,False
|
"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","sans_garde","Chloé","Rousseau",,"2022-04-20",,,False,,False
|
||||||
"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","actif","Hugo","Rousseau",,"2024-03-10",,,False,,False
|
"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","sans_garde","Hugo","Rousseau",,"2024-03-10",,,False,,False
|
||||||
"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","actif","Maxime","Lecomte",,"2023-04-15",,,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
|
"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,,False
|
||||||
|
|||||||
|
@ -14,9 +14,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5433:5432"
|
- "5433:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- ./migrations/01_init.sql:/docker-entrypoint-initdb.d/01_init.sql
|
- ./BDD.sql:/docker-entrypoint-initdb.d/01_init.sql
|
||||||
- ./migrations/07_import.sql:/docker-entrypoint-initdb.d/07_import.sql
|
- ./seed/03_seed_test_data.sql:/docker-entrypoint-initdb.d/02_seed_test_data.sql
|
||||||
- ./bdd/data_test:/bdd/data_test
|
|
||||||
- postgres_standalone_data:/var/lib/postgresql/data
|
- postgres_standalone_data:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- ptitspas_dev
|
- ptitspas_dev
|
||||||
|
|||||||
@ -51,12 +51,17 @@ Ce document recense **toutes les valeurs énumérées** utilisées dans la base
|
|||||||
| Valeur | Description |
|
| Valeur | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `a_naitre` | Enfant à naître (date prévue renseignée) |
|
| `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 |
|
| `scolarise` | Enfant scolarisé, garde potentiellement périscolaire |
|
||||||
|
|
||||||
**Contraintes associées** :
|
**Contraintes associées** :
|
||||||
- `a_naitre` → **`date_prevue_naissance` obligatoire**
|
- `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`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -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 |
|
| **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_parent)** → `parents(id_utilisateur)` | **CASCADE** | Nettoyage liaisons N:N |
|
||||||
| **enfants_parents(id_enfant)** → `enfants(id)` | **CASCADE** | Idem |
|
| **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_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 |
|
| **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 |
|
| **messages(id_dossier)** → `dossiers(id)` | **CASCADE** | Messages détruits avec le dossier |
|
||||||
|
|||||||
36
database/migrations/2026_enfants_assistantes_maternelles.sql
Normal file
36
database/migrations/2026_enfants_assistantes_maternelles.sql
Normal file
@ -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;
|
||||||
@ -65,12 +65,12 @@ ON CONFLICT (id_utilisateur) DO NOTHING;
|
|||||||
|
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
-- Enfants
|
-- 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)
|
-- - child B : à naître (statut = 'a_naitre' et date_prevue_naissance requise)
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
|
|
||||||
INSERT INTO enfants (id, prenom, nom, statut, date_naissance, jumeau_multiple)
|
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;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance, jumeau_multiple)
|
INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance, jumeau_multiple)
|
||||||
|
|||||||
@ -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)
|
INSERT INTO utilisateurs (id, email, password, prenom, nom, role, statut, telephone, adresse, ville, code_postal, profession, situation_familiale, date_naissance, consentement_photo)
|
||||||
VALUES
|
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),
|
('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$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Lucas', 'MOREAU', 'gestionnaire', 'actif', '0687234567', '8 Rue Jean Jaurès', 'Bezons', '95870', 'Gestionnaire des placements', 'celibataire', '1985-09-22', 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$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Marie', 'DUBOIS', 'assistante_maternelle', 'actif', '0696345678', '25 Rue de la République', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1980-06-08', true),
|
('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$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Fatima', 'EL MANSOURI', 'assistante_maternelle', 'actif', '0675456789', '17 Boulevard Aristide Briand', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1975-11-12', 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$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Claire', 'MARTIN', 'parent', 'actif', '0689567890', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Infirmière', 'marie', '1990-04-03', false),
|
('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$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Thomas', 'MARTIN', 'parent', 'actif', '0678456789', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Ingénieur', 'marie', '1988-07-18', 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$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Amélie', 'DURAND', 'parent', 'actif', '0667788990', '23 Rue Victor Hugo', 'Bezons', '95870', 'Comptable', 'divorce', '1987-12-14', 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$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Julien', 'ROUSSEAU', 'parent', 'actif', '0656677889', '14 Rue Pasteur', 'Bezons', '95870', 'Commercial', 'divorce', '1985-08-29', 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$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'David', 'LECOMTE', 'parent', 'actif', '0645566778', '31 Rue Émile Zola', 'Bezons', '95870', 'Développeur web', 'parent_isole', '1992-10-07', 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;
|
ON CONFLICT (email) DO NOTHING;
|
||||||
|
|
||||||
-- ========== PARENTS (avec co-parent pour le couple Martin) ==========
|
-- ========== PARENTS (avec co-parent pour le couple Martin) ==========
|
||||||
@ -51,12 +51,12 @@ ON CONFLICT (id_utilisateur) DO NOTHING;
|
|||||||
-- ========== ENFANTS ==========
|
-- ========== ENFANTS ==========
|
||||||
INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut, est_multiple)
|
INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut, est_multiple)
|
||||||
VALUES
|
VALUES
|
||||||
('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'actif', true),
|
('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', 'actif', 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', 'actif', 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', 'actif', false),
|
('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', 'actif', 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', 'actif', false)
|
('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'sans_garde', false)
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
-- ========== ENFANTS_PARENTS (liaison N:N) ==========
|
-- ========== ENFANTS_PARENTS (liaison N:N) ==========
|
||||||
|
|||||||
@ -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
|
- [**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
|
- [**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
|
- [**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)
|
- [**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)
|
- [**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
|
références outils (`.cursorrules`, etc.) — **à renommer** en `NN_` quand
|
||||||
possible :
|
possible :
|
||||||
- `CHARTE_GRAPHIQUE.md`
|
- `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_Cahier_Des_Charges_Complet_V1.1.md`
|
||||||
- `SuperNounou_SSS-001.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).
|
Cette documentation est maintenue par Julien Martin (julien.martin@ptits-pas.fr).
|
||||||
|
|
||||||
Dernière mise à jour : Novembre 2025
|
Dernière mise à jour : Juin 2026
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# 📋 Décisions Projet - P'titsPas
|
# 📋 Décisions Projet - P'titsPas
|
||||||
|
|
||||||
**Version** : 1.1
|
**Version** : 1.2
|
||||||
**Date** : 9 Février 2026
|
**Date** : 16 Juin 2026
|
||||||
**Auteur** : Équipe PtitsPas
|
**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)
|
### 6. Genre enfant obligatoire (H/F)
|
||||||
|
|
||||||
**Décision** : ✅ **Genre obligatoire (H/F uniquement)**
|
**Décision** : ✅ **Genre obligatoire (H/F uniquement)**
|
||||||
@ -562,6 +583,7 @@ docs/
|
|||||||
| 14 | Migration données | ❌ Rejeté | N/A |
|
| 14 | Migration données | ❌ Rejeté | N/A |
|
||||||
| 15 | Doc utilisateur | ⏸️ Phase 2 | Formation |
|
| 15 | Doc utilisateur | ⏸️ Phase 2 | Formation |
|
||||||
| 31 | Logs Winston | ✅ Phase 1 | Monitoring |
|
| 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 |
|
| 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 |
|
| 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
|
**Dernière mise à jour** : 16 Juin 2026
|
||||||
**Version** : 1.1
|
**Version** : 1.2
|
||||||
**Statut** : ✅ Document validé
|
**Statut** : ✅ Document validé
|
||||||
|
|
||||||
|
|||||||
331
docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md
Normal file
331
docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
# Évolution — Modèle famille, responsables légaux et dossiers
|
||||||
|
|
||||||
|
**Version** : 1.1
|
||||||
|
**Date** : 16 juin 2026
|
||||||
|
**Statut** : Réflexions produit / architecture — complément au [CDC](./01_CAHIER-DES-CHARGES.md)
|
||||||
|
**Documents liés** : [EVOLUTIONS_CDC.md](./EVOLUTIONS_CDC.md), [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md), [23_LISTE-TICKETS.md](./23_LISTE-TICKETS.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Objet de ce document
|
||||||
|
|
||||||
|
Ce document **trace les réflexions** menées en 2026 sur :
|
||||||
|
|
||||||
|
- les **limites du modèle « famille / numéro de dossier »** en v1.0.0 ;
|
||||||
|
- les **contournements** acceptés pour la release **1.0.0** ;
|
||||||
|
- les **évolutions** envisagées post-1.0.0 (unités de dossier, affiliation parent–enfant, terminologie).
|
||||||
|
|
||||||
|
Il ne remplace pas le CDC : il documente l’**écart assumé** entre le modèle idéal long terme et ce qui est livré en **1.0.0**, ainsi que la **feuille de route** pour aller plus loin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Modèle actuel (v1.0.0) — rappel
|
||||||
|
|
||||||
|
| Concept | Implémentation |
|
||||||
|
|--------|----------------|
|
||||||
|
| **Responsable inscrit** | Rôle applicatif `parent` + entité `parents` |
|
||||||
|
| **Second adulte** | Co-parent optionnel (`id_co_parent`) — **un seul** |
|
||||||
|
| **Enfant** | Entité `enfants` |
|
||||||
|
| **Affiliation** | Table `enfants_parents` (liens many-to-many) |
|
||||||
|
| **Dossier famille** | `dossier_famille` + `dossier_famille_enfants` (motivation, etc.) |
|
||||||
|
| **Numéro de dossier** | Format `AAAA-NNNNNN`, sur `utilisateurs` et `parents` |
|
||||||
|
| **« Famille » calculée** | Graphe : co-parent **ou** enfants partagés (`getFamilyUserIds`) |
|
||||||
|
| **Workflows** | Validation, refus, reprise : souvent **par `numero_dossier`** ou par ce graphe |
|
||||||
|
|
||||||
|
Le numéro de dossier est aujourd’hui une **aide forte** pour les cas simples (un couple, N enfants, une motivation), mais il tend à devenir **l’identifiant métier** de la famille — ce qui pose problème dans les cas complexes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Limitation connue v1.0.0 — familles recomposées et multi-contextes
|
||||||
|
|
||||||
|
### 3.1 Exemple type (illustration)
|
||||||
|
|
||||||
|
```
|
||||||
|
Année 1 : Responsable M + co-responsable A → enfant α
|
||||||
|
Année 2 : Responsable M + co-responsable B → enfant β
|
||||||
|
```
|
||||||
|
|
||||||
|
Même personne **M** au centre, **deux contextes de vie** distincts. **A** n’est pas parent de β ; **B** n’est pas parent de α.
|
||||||
|
|
||||||
|
### 3.2 Autres cas couverts par la même limitation
|
||||||
|
|
||||||
|
Les exemples « maman / papa » sont **illustratifs**. La limitation s’applique **à toute configuration** :
|
||||||
|
|
||||||
|
| Configuration | Même règle |
|
||||||
|
|---------------|------------|
|
||||||
|
| Couple **HH** ou **FF** | Oui |
|
||||||
|
| **Père** avec deux partenaires et deux enfants (même ville, écarts d’âge courts) | Oui |
|
||||||
|
| **Grand-parent** en tutelle ou **tuteur légal** | Oui *(voir § 5)* |
|
||||||
|
| Recomposition, demi-fratrie, garde alternée complexe | Oui |
|
||||||
|
|
||||||
|
### 3.3 Pourquoi le modèle casse
|
||||||
|
|
||||||
|
1. **Un `numero_dossier` par user** — M ne peut pas appartenir proprement à deux unités.
|
||||||
|
2. **Un seul co-parent** par fiche `parents`.
|
||||||
|
3. **Graphe famille trop large** — M liée à A (via α) et à B (via β) → A, B et M peuvent être fusionnés en **une seule « famille »** pour validation/refus/reprise.
|
||||||
|
4. **`dossier_famille`** — une motivation / une ancre par numéro, pas deux contextes pour la même personne.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Décision v1.0.0 — contournement opérationnel
|
||||||
|
|
||||||
|
### 4.1 Principe
|
||||||
|
|
||||||
|
> **Le numéro de dossier reste la norme pour les cas simples, pas une obligation absolue.**
|
||||||
|
> Pour les cas trop complexes, le **gestionnaire** compose manuellement (création admin, rattachements) ou applique le contournement ci-dessous.
|
||||||
|
|
||||||
|
### 4.2 Contournement accepté pour la 1.0.0
|
||||||
|
|
||||||
|
**Créer un second compte** avec une **adresse e-mail distincte** et un **second dossier** (second `numero_dossier`).
|
||||||
|
|
||||||
|
| Dossier | Compte | Co-responsable | Enfant |
|
||||||
|
|---------|--------|----------------|--------|
|
||||||
|
| 1 | `m.personne@…` | A | α |
|
||||||
|
| 2 | `m.personne.famille2@…` *(ou alias)* | B | β |
|
||||||
|
|
||||||
|
**Conséquences assumées :**
|
||||||
|
|
||||||
|
- Une **même personne physique** peut avoir **deux identités** dans l’app.
|
||||||
|
- Pas de vue unifiée « une personne, deux contextes » en v1.0.0.
|
||||||
|
- Procédure interne gestionnaire recommandée (note « même personne physique »).
|
||||||
|
- Emails distincts **volontaires** (alias, +tag, boîte dédiée selon infra mail).
|
||||||
|
|
||||||
|
### 4.3 Nuance — inscription publique
|
||||||
|
|
||||||
|
Si le **dossier 1** existe déjà avec l’email de P (titulaire ou co-parent), une **2ᵉ inscription publique** où l’on saisit **le même email** comme co-parent est **bloquée** (*email déjà utilisé*).
|
||||||
|
|
||||||
|
Le **2ᵉ dossier** passe donc surtout par :
|
||||||
|
|
||||||
|
- **création / composition par le gestionnaire** (tickets admin #129+), ou
|
||||||
|
- **2ᵉ email** dès le départ pour la même personne physique.
|
||||||
|
|
||||||
|
### 4.4 Piste cible — parcours gestionnaire « famille complexe » *(réflexion juin 2026)*
|
||||||
|
|
||||||
|
Le contournement § 4.2 reste valable en **v1.0.0**. La **solution produit visée** pour les cas complexes est différente :
|
||||||
|
|
||||||
|
> **Seul le gestionnaire** dispose d’un **parcours de création dédié** permettant de constituer une configuration avec **plus de deux responsables** (parents, co-parents, tuteurs…) **sans** se limiter au seul champ `co_parent`, en s’appuyant sur **`enfants_parents`** comme vérité de visibilité.
|
||||||
|
|
||||||
|
**Exemple M / A / B / α / β :**
|
||||||
|
|
||||||
|
| Compte | Enfants visibles / rattachés |
|
||||||
|
|--------|------------------------------|
|
||||||
|
| **M** (un seul compte, un email) | α **et** β |
|
||||||
|
| **A** | α uniquement |
|
||||||
|
| **B** | β uniquement |
|
||||||
|
|
||||||
|
```
|
||||||
|
α ─── M ─── β
|
||||||
|
│ │
|
||||||
|
A B
|
||||||
|
```
|
||||||
|
|
||||||
|
- **M** voit et gère **ses deux enfants** sur **le même compte** (plus besoin d’un 2ᵉ email pour M).
|
||||||
|
- **A** et **B** ne voient **que** l’enfant qui leur est rattaché via `enfants_parents` — pas de fusion « famille » qui mélange A et B.
|
||||||
|
- Le parcours n’est **pas** proposé à l’inscription publique (trop error-prone) : **réservé au gestionnaire** (#129+ ou ticket dédié « famille complexe »).
|
||||||
|
|
||||||
|
**Conséquences techniques (post-1.0.0) :**
|
||||||
|
|
||||||
|
1. **Visibilité** : filtrer listes, fiches et actions parent par **liens `enfants_parents`**, pas par `getFamilyUserIds` / co-parent.
|
||||||
|
2. **Workflows** (validation, refus, reprise) : périmètre par **enfant** ou **soumission**, pas par graphe familial élargi.
|
||||||
|
3. **`co_parent`** : reste utile pour le cas simple (couple + N enfants communs) ; **insuffisant seul** pour les cas complexes — le gestionnaire compose les liens enfant par enfant.
|
||||||
|
4. **Numéro de dossier** : optionnel ou secondaire ; peut rester sur M ou sur une « unité » admin, sans imposer un numéro par co-responsable.
|
||||||
|
|
||||||
|
*Détail : § 7.5.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Terminologie — « parent » aujourd’hui, qualification demain
|
||||||
|
|
||||||
|
### 5.1 v1.0.0
|
||||||
|
|
||||||
|
- Rôle technique : **`parent`** pour tout responsable inscrit (y compris tuteur, grand-parent tutrice, etc.).
|
||||||
|
- UI : « Parent 1 », « co-parent », parfois exemples maman/papa — **pas de statut juridique distinct**.
|
||||||
|
|
||||||
|
### 5.2 Évolution post-1.0.0 (piste)
|
||||||
|
|
||||||
|
Séparer deux niveaux :
|
||||||
|
|
||||||
|
| Niveau | Évolution |
|
||||||
|
|--------|-----------|
|
||||||
|
| **Rôle applicatif** | Conserver `parent` = « responsable du dossier » (auth, API) |
|
||||||
|
| **Qualification métier** | Nouveau champ, ex. `qualite_responsable` / `lien_avec_enfant` |
|
||||||
|
|
||||||
|
**Valeurs possibles (exemples)** : parent biologique ou adoptif, co-parent, tuteur légal, grand-parent exerçant la garde, autre responsable légal.
|
||||||
|
|
||||||
|
**Emplacement recommandé** : sur le **lien** `enfants_parents` (par enfant), pas seulement sur le user global.
|
||||||
|
|
||||||
|
**UI** :
|
||||||
|
|
||||||
|
- Libellés neutres : « Responsable 1 / 2 », « 2ᵉ responsable » ;
|
||||||
|
- **Combobox** pour qualifier le lien (admin + éventuellement inscription).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Évolutions UI / fonctionnelles liées (backlog)
|
||||||
|
|
||||||
|
Réflexions dashboard admin / gestionnaire (complément CDC §4.5.2–4.5.3).
|
||||||
|
|
||||||
|
> **Statut implémentation (juin 2026)** : §6.1 et §6.2 **en cours de livraison** (tickets #130–#131, #115–#116, #137–#138). §6.3 reporté post-1.0.0 (#129).
|
||||||
|
|
||||||
|
### 6.1 Fiche parent (#131)
|
||||||
|
|
||||||
|
- Modale **éditable** dès l’ouverture (pas lecture seule + « Modifier » factice).
|
||||||
|
- **Retirer l’ID** UUID ; option : **n° de dossier** en lecture seule.
|
||||||
|
- **Statut** en combobox (règles métier à cadrer vs validation/refus #110).
|
||||||
|
- Bas de modale :
|
||||||
|
- `Nombre d'enfants : N` (lecture seule) ;
|
||||||
|
- **Liste des prénoms/noms** (cadre) — consultation, clic → fiche enfant (#138).
|
||||||
|
|
||||||
|
### 6.2 Affiliation parent ↔ enfant (#115, #116, #138)
|
||||||
|
|
||||||
|
- **Vérité métier** : `enfants_parents`.
|
||||||
|
- **Modifier l’affiliation** ≠ modifier le téléphone : gestion des **liens** (détacher / rattacher), avec garde-fous :
|
||||||
|
- ne pas supprimer l’enfant pour retirer un lien ;
|
||||||
|
- au moins un responsable par enfant ;
|
||||||
|
- prudence sur fusion « famille » et co-parents.
|
||||||
|
- **Création enfant** : prioritaire depuis **fiche parent** (contexte famille) ; onglet **Enfants** (#137) pour vue globale + rattachement.
|
||||||
|
|
||||||
|
### 6.3 Création admin sans numéro (tickets #129+)
|
||||||
|
|
||||||
|
Le gestionnaire doit pouvoir **tout créer** depuis l’interface ; le numéro reste **généré si utile**, **optionnel** si le cas est trop complexe — **objectif post-1.0.0** (unité de dossier).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Évolution structurelle post-1.0.0 — « unité de dossier »
|
||||||
|
|
||||||
|
### 7.1 Principe cible
|
||||||
|
|
||||||
|
| Aujourd’hui | Cible |
|
||||||
|
|-------------|--------|
|
||||||
|
| `numero_dossier` = clé de la famille | **Liens parent↔enfant** = vérité |
|
||||||
|
| Famille déduite du graphe | **Unité de dossier** = regroupement **optionnel** |
|
||||||
|
| Un numéro par inscription | Numéro **optionnel** ; plusieurs unités par personne possibles |
|
||||||
|
|
||||||
|
### 7.2 Exemple cible (cas M / A / B) — deux approches
|
||||||
|
|
||||||
|
**Approche A — unités de dossier séparées** *(piste initiale § 7)* :
|
||||||
|
|
||||||
|
```
|
||||||
|
Unité 1 : numero 2026-000021 — M, A, α
|
||||||
|
Unité 2 : sans numéro (ou 2026-000089) — M, B, β
|
||||||
|
```
|
||||||
|
|
||||||
|
M appartient à **deux unités** ; A et B ne partagent pas la même unité. Peut impliquer **deux contextes de connexion** ou une agrégation côté M.
|
||||||
|
|
||||||
|
**Approche B — parcours gestionnaire « famille complexe »** *(préférée, § 4.4)* :
|
||||||
|
|
||||||
|
```
|
||||||
|
Compte M → enfants α, β
|
||||||
|
Compte A → enfant α
|
||||||
|
Compte B → enfant β
|
||||||
|
(liens enfants_parents ; pas de fusion A↔B)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Un seul compte pour M** avec **les deux enfants**.
|
||||||
|
- **A** et **B** isolés sur **leur** enfant respectif.
|
||||||
|
- Création **uniquement** par le gestionnaire ; inscription publique inchangée (couple + co-parent classique).
|
||||||
|
|
||||||
|
Les deux approches supposent de **cesser de déduire une « famille » unique** pour les workflows lorsque les liens enfant par enfant divergent. L’approche B maximise l’UX du responsable central (M) sans dupliquer son identité.
|
||||||
|
|
||||||
|
### 7.5 Parcours gestionnaire — création « famille complexe »
|
||||||
|
|
||||||
|
#### 7.5.1 Objectif
|
||||||
|
|
||||||
|
Permettre au **gestionnaire** de monter un dossier où :
|
||||||
|
|
||||||
|
- **N responsables** (≥ 2, parents ou tuteurs) sont créés ou rattachés ;
|
||||||
|
- chaque enfant est lié **explicitement** à un ou plusieurs responsables via `enfants_parents` ;
|
||||||
|
- un responsable (ex. **M**) peut être lié à **plusieurs enfants** dont les **autres** responsables (A, B) ne partagent **pas** la garde.
|
||||||
|
|
||||||
|
#### 7.5.2 Règles produit
|
||||||
|
|
||||||
|
| Règle | Détail |
|
||||||
|
|-------|--------|
|
||||||
|
| **Accès** | Parcours **gestionnaire uniquement** (dashboard), pas inscription publique |
|
||||||
|
| **Responsables** | Création ou rattachement de comptes `parent` ; qualification future sur le lien (§ 5) |
|
||||||
|
| **Visibilité** | Chaque responsable ne voit que **ses** enfants (liens `enfants_parents`) |
|
||||||
|
| **Co-parent UI** | Ne pas forcer « Parent 1 + co-parent unique » ; composition libre côté staff |
|
||||||
|
| **Garde-fous** | Au moins un responsable par enfant ; pas de suppression enfant pour retirer un lien |
|
||||||
|
|
||||||
|
#### 7.5.3 Esquisse du parcours UI (gestionnaire)
|
||||||
|
|
||||||
|
1. **Créer ou identifier** le responsable principal (ex. M).
|
||||||
|
2. **Ajouter d’autres responsables** (A, B, tuteur…) — comptes distincts.
|
||||||
|
3. **Créer les enfants** (α, β…) et, pour chaque enfant, **cocher les responsables** rattachés.
|
||||||
|
4. **Motivation / dossier** : texte global ou par enfant (à cadrer).
|
||||||
|
5. **Validation** : par soumission ou par enfant, sans valider « toute la famille » d’un coup si A et B ne doivent pas être fusionnés.
|
||||||
|
|
||||||
|
#### 7.5.4 Impacts techniques majeurs
|
||||||
|
|
||||||
|
| Domaine | Changement |
|
||||||
|
|---------|------------|
|
||||||
|
| **Auth / API parent** | `GET` enfants, dashboard parent : filtre `enfants_parents` pour l’utilisateur courant |
|
||||||
|
| **`getFamilyUserIds`** | Ne plus utiliser pour visibilité parent ; réservé au cas simple ou deprecated progressivement |
|
||||||
|
| **Validation / refus** | Périmètre enfant ou responsable, pas fusion A+B via graphe |
|
||||||
|
| **Reprise (#112)** | Token / périmètre = enfants liés au compte refusé, pas toute la composante connexe |
|
||||||
|
| **Admin (#115–#138)** | Prérequis : rattachement/détachement déjà en place ; ce parcours **compose** ces briques |
|
||||||
|
|
||||||
|
#### 7.5.5 Lien avec v1.0.0
|
||||||
|
|
||||||
|
| Phase | Comportement |
|
||||||
|
|-------|--------------|
|
||||||
|
| **v1.0.0** | Contournement § 4.2 (2ᵉ email M) + composition manuelle gestionnaire (#115–#138) |
|
||||||
|
| **Post-1.0.0** | Parcours § 7.5 + refonte visibilité / workflows |
|
||||||
|
|
||||||
|
### 7.3 Impacts workflows
|
||||||
|
|
||||||
|
| Flux | Adaptation future |
|
||||||
|
|------|-------------------|
|
||||||
|
| Validation / refus (#110) | Par **enfant / soumission**, pas par graphe global |
|
||||||
|
| Reprise (#112) | Périmètre = enfants liés au compte, pas composante connexe |
|
||||||
|
| Liste « à valider » | Par soumission ou par enfant |
|
||||||
|
| Recherche gestionnaire | Numéro **ou** nom **ou** enfant |
|
||||||
|
| Visibilité parent | **Uniquement** enfants via `enfants_parents` (approche B § 7.2) |
|
||||||
|
|
||||||
|
### 7.4 Migration
|
||||||
|
|
||||||
|
- **Court terme** : contournement § 4 + tickets admin.
|
||||||
|
- **Moyen terme** : table **unité de dossier**, `numero_dossier` nullable.
|
||||||
|
- **Long terme** : workflows branchés sur l’unité.
|
||||||
|
- Dossiers **existants** (couple + enfants) : une unité = un numéro → **comportement actuel préservé**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Tickets Gitea associés
|
||||||
|
|
||||||
|
| Ticket | Sujet |
|
||||||
|
|--------|--------|
|
||||||
|
| #110 | Refus dossier (token reprise) — livré |
|
||||||
|
| #112 | Reprise après refus — livré |
|
||||||
|
| #115 / #116 | Rattachement parent — backend / front |
|
||||||
|
| #129+ | Création dossier admin (parent / AM) |
|
||||||
|
| #131 | Fiche parent éditable (dashboard) |
|
||||||
|
| #137 | Onglet Enfants — liste globale |
|
||||||
|
| #138 | Fiche enfant + liste dans fiche parent |
|
||||||
|
| *(à créer)* | Epic « Unité de dossier / numéro optionnel » |
|
||||||
|
| #139 | **Parcours gestionnaire « famille complexe »** (§ 7.5) — N responsables, visibilité par enfant |
|
||||||
|
| *(à créer)* | Qualification responsable légal (combobox sur lien enfant) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Formulation type — release notes / doc gestionnaire (v1.0.0)
|
||||||
|
|
||||||
|
> **Familles recomposées ou responsabilités multiples**
|
||||||
|
> Une même personne ne peut pas gérer proprement deux unités familiales distinctes (recompositions, plusieurs co-responsables successifs, tuteur/GP pour plusieurs contextes) avec **un seul compte**.
|
||||||
|
> **Contournement v1.0.0** : second compte avec **email distinct** et second numéro de dossier.
|
||||||
|
> S’applique à **tous les responsables inscrits** (couples HH/FF, tuteurs, grands-parents, etc.).
|
||||||
|
> **Évolution post-1.0.0** : parcours **gestionnaire** « famille complexe » (un compte M, enfants α+β ; A et B limités à leur enfant) ; visibilité par `enfants_parents` ; workflows sans fusion graphe familial.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Historique des mises à jour
|
||||||
|
|
||||||
|
| Date | Auteur | Modification |
|
||||||
|
|------|--------|--------------|
|
||||||
|
| 2026-06-16 | Équipe / session produit | Création — synthèse réflexions famille, tutelle, v1.0.0 vs post-1.0.0 |
|
||||||
|
| 2026-06-16 | Implémentation ch.6 | Back : `PATCH /parents/:id/fiche`, attach/detach enfant. Front : modale parent éditable, onglet Enfants, fiche enfant |
|
||||||
|
| 2026-06-16 | Réflexion produit | § 4.4 / § 7.5 — parcours gestionnaire famille complexe : M un compte (α+β), A/B visibilité restreinte par enfant |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Ce document sera enrichi au fil des décisions. Pour les décisions formelles archivées, voir aussi [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md).*
|
||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Ce document liste les modifications à apporter au cahier des charges original pour le rendre conforme à l'application développée.
|
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
|
## 1. Gestion des Enfants
|
||||||
|
|
||||||
### Modifications à apporter dans la section "Création de compte parent"
|
### Modifications à apporter dans la section "Création de compte parent"
|
||||||
@ -276,3 +278,28 @@ Pour chaque évolution identifiée, ce document suivra la structure suivante :
|
|||||||
- Adaptation des écrans d'administration pour gérer les rôles locaux.
|
- 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.
|
- Renforcement des contrôles d'accès backend et des règles métier.
|
||||||
- Clarification des workflows décisionnels dans l'application.
|
- 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).
|
||||||
127
docs/archive/temporaires/131-fiche-parent-co-parent-header.md
Normal file
127
docs/archive/temporaires/131-fiche-parent-co-parent-header.md
Normal file
@ -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**
|
||||||
@ -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
|
||||||
|
```
|
||||||
132
docs/archive/temporaires/TEMP_131-back-fiche-am-enfants.md
Normal file
132
docs/archive/temporaires/TEMP_131-back-fiche-am-enfants.md
Normal file
@ -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`
|
||||||
124
docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md
Normal file
124
docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md
Normal file
@ -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**
|
||||||
7
docs/serveur_ptitspas.code-workspace
Normal file
7
docs/serveur_ptitspas.code-workspace
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -1,30 +1,102 @@
|
|||||||
|
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
|
||||||
class AssistanteMaternelleModel {
|
class AssistanteMaternelleModel {
|
||||||
final AppUser user;
|
final AppUser user;
|
||||||
final String? approvalNumber;
|
final String? approvalNumber;
|
||||||
|
final String? nir;
|
||||||
final String? residenceCity;
|
final String? residenceCity;
|
||||||
final int? maxChildren;
|
final int? maxChildren;
|
||||||
final int? placesAvailable;
|
final int? placesAvailable;
|
||||||
|
final String? biography;
|
||||||
|
final bool? available;
|
||||||
|
final String? agreementDate;
|
||||||
|
final List<ParentChildSummary> children;
|
||||||
|
|
||||||
AssistanteMaternelleModel({
|
AssistanteMaternelleModel({
|
||||||
required this.user,
|
required this.user,
|
||||||
this.approvalNumber,
|
this.approvalNumber,
|
||||||
|
this.nir,
|
||||||
this.residenceCity,
|
this.residenceCity,
|
||||||
this.maxChildren,
|
this.maxChildren,
|
||||||
this.placesAvailable,
|
this.placesAvailable,
|
||||||
|
this.biography,
|
||||||
|
this.available,
|
||||||
|
this.agreementDate,
|
||||||
|
this.children = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
factory AssistanteMaternelleModel.fromJson(Map<String, dynamic> json) {
|
factory AssistanteMaternelleModel.fromJson(Map<String, dynamic> json) {
|
||||||
final userJson = json['user'] ?? json;
|
final root = Map<String, dynamic>.from(json);
|
||||||
|
final userJson = Map<String, dynamic>.from(root['user'] ?? root);
|
||||||
|
if (root['numero_dossier'] != null && userJson['numero_dossier'] == null) {
|
||||||
|
userJson['numero_dossier'] = root['numero_dossier'];
|
||||||
|
}
|
||||||
final user = AppUser.fromJson(userJson);
|
final user = AppUser.fromJson(userJson);
|
||||||
|
final children = _parseChildren(root);
|
||||||
|
|
||||||
return AssistanteMaternelleModel(
|
return AssistanteMaternelleModel(
|
||||||
user: user,
|
user: user,
|
||||||
approvalNumber: json['numero_agrement'] as String?,
|
approvalNumber: _str(
|
||||||
residenceCity: json['ville_residence'] as String?,
|
root['approval_number'] ?? root['numero_agrement'],
|
||||||
maxChildren: json['nb_max_enfants'] as int?,
|
),
|
||||||
placesAvailable: json['place_disponible'] as int?,
|
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<ParentChildSummary> _parseChildren(Map<String, dynamic> json) {
|
||||||
|
final children = <ParentChildSummary>[];
|
||||||
|
final seen = <String>{};
|
||||||
|
|
||||||
|
void add(ParentChildSummary? child) {
|
||||||
|
if (child == null || child.id.isEmpty || seen.contains(child.id)) return;
|
||||||
|
seen.add(child.id);
|
||||||
|
children.add(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
final links = json['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<String, dynamic>.from(link),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import 'package:p_tits_pas/models/user.dart';
|
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.
|
/// Réponse unifiée GET /dossiers/:numeroDossier. Ticket #119, #107.
|
||||||
class DossierUnifie {
|
class DossierUnifie {
|
||||||
@ -220,7 +221,7 @@ class EnfantDossier {
|
|||||||
lastName: (json['last_name'] ?? json['nom'])?.toString(),
|
lastName: (json['last_name'] ?? json['nom'])?.toString(),
|
||||||
birthDate: json['birth_date']?.toString(),
|
birthDate: json['birth_date']?.toString(),
|
||||||
gender: (json['gender'] ?? json['genre'])?.toString(),
|
gender: (json['gender'] ?? json['genre'])?.toString(),
|
||||||
status: json['status']?.toString(),
|
status: normalizeEnfantStatus(json['status']?.toString()),
|
||||||
dueDate: json['due_date']?.toString(),
|
dueDate: json['due_date']?.toString(),
|
||||||
photoUrl: resolvedPhoto,
|
photoUrl: resolvedPhoto,
|
||||||
consentPhoto:
|
consentPhoto:
|
||||||
|
|||||||
109
frontend/lib/models/enfant_admin_model.dart
Normal file
109
frontend/lib/models/enfant_admin_model.dart
Normal file
@ -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<EnfantParentLink> parentLinks;
|
||||||
|
|
||||||
|
EnfantAdminModel({
|
||||||
|
required this.id,
|
||||||
|
this.firstName,
|
||||||
|
this.lastName,
|
||||||
|
this.gender,
|
||||||
|
this.birthDate,
|
||||||
|
this.dueDate,
|
||||||
|
required this.status,
|
||||||
|
this.photoUrl,
|
||||||
|
this.consentPhoto = false,
|
||||||
|
this.isMultiple = false,
|
||||||
|
this.parentLinks = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
String get fullName {
|
||||||
|
final fn = (firstName ?? '').trim();
|
||||||
|
final ln = (lastName ?? '').trim();
|
||||||
|
if (fn.isEmpty && ln.isEmpty) return 'Enfant';
|
||||||
|
if (ln.isEmpty) return fn;
|
||||||
|
return '$fn $ln';
|
||||||
|
}
|
||||||
|
|
||||||
|
factory EnfantAdminModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
final linksRaw = json['parentLinks'] as List?;
|
||||||
|
final links = <EnfantParentLink>[];
|
||||||
|
if (linksRaw != null) {
|
||||||
|
for (final item in linksRaw) {
|
||||||
|
if (item is Map<String, dynamic>) {
|
||||||
|
links.add(EnfantParentLink.fromJson(item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return EnfantAdminModel(
|
||||||
|
id: (json['id'] ?? '').toString(),
|
||||||
|
firstName: json['first_name'] as String?,
|
||||||
|
lastName: json['last_name'] as String?,
|
||||||
|
gender: json['gender'] as String?,
|
||||||
|
birthDate: _dateString(json['birth_date']),
|
||||||
|
dueDate: _dateString(json['due_date']),
|
||||||
|
status: normalizeEnfantStatus(json['status']?.toString()),
|
||||||
|
photoUrl: json['photo_url'] as String?,
|
||||||
|
consentPhoto: json['consent_photo'] == true,
|
||||||
|
isMultiple: json['is_multiple'] == true,
|
||||||
|
parentLinks: links,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toUpdateJson() {
|
||||||
|
return {
|
||||||
|
if (firstName != null) 'first_name': firstName,
|
||||||
|
if (lastName != null) 'last_name': lastName,
|
||||||
|
if (gender != null && gender!.isNotEmpty) 'gender': gender,
|
||||||
|
'status': status,
|
||||||
|
if (birthDate != null && birthDate!.isNotEmpty) 'birth_date': birthDate,
|
||||||
|
if (dueDate != null && dueDate!.isNotEmpty) 'due_date': dueDate,
|
||||||
|
'consent_photo': consentPhoto,
|
||||||
|
'is_multiple': isMultiple,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _dateString(dynamic v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v is String) return v.split('T').first;
|
||||||
|
return v.toString().split('T').first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EnfantParentLink {
|
||||||
|
final String parentId;
|
||||||
|
final String? parentName;
|
||||||
|
|
||||||
|
EnfantParentLink({required this.parentId, this.parentName});
|
||||||
|
|
||||||
|
factory EnfantParentLink.fromJson(Map<String, dynamic> json) {
|
||||||
|
final parentId =
|
||||||
|
(json['parentId'] ?? json['id_parent'] ?? '').toString();
|
||||||
|
String? name;
|
||||||
|
final parent = json['parent'];
|
||||||
|
if (parent is Map<String, dynamic>) {
|
||||||
|
final user = parent['user'];
|
||||||
|
if (user is Map<String, dynamic>) {
|
||||||
|
final u = AppUser.fromJson(user);
|
||||||
|
name = u.fullName.isNotEmpty ? u.fullName : u.email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return EnfantParentLink(
|
||||||
|
parentId: parentId,
|
||||||
|
parentName: name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
92
frontend/lib/models/parent_child_summary.dart
Normal file
92
frontend/lib/models/parent_child_summary.dart
Normal file
@ -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<String, dynamic> 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<String, dynamic> link) {
|
||||||
|
final childRaw = link['child'] ?? link['enfant'];
|
||||||
|
if (childRaw is Map) {
|
||||||
|
return ParentChildSummary.fromJson(Map<String, dynamic>.from(childRaw));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (link.containsKey('first_name') ||
|
||||||
|
link.containsKey('prenom') ||
|
||||||
|
link.containsKey('id')) {
|
||||||
|
final id = (link['id'] ?? '').toString();
|
||||||
|
if (id.isNotEmpty &&
|
||||||
|
(link.containsKey('first_name') || link.containsKey('prenom'))) {
|
||||||
|
return ParentChildSummary.fromJson(link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final enfantId =
|
||||||
|
link['enfantId'] ?? link['id_enfant'] ?? link['enfant_id'];
|
||||||
|
if (enfantId != null && enfantId.toString().isNotEmpty) {
|
||||||
|
return ParentChildSummary(
|
||||||
|
id: enfantId.toString(),
|
||||||
|
status: '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,18 +1,65 @@
|
|||||||
|
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
|
||||||
class ParentModel {
|
class ParentModel {
|
||||||
final AppUser user;
|
final AppUser user;
|
||||||
|
final AppUser? coParent;
|
||||||
final int childrenCount;
|
final int childrenCount;
|
||||||
|
final List<ParentChildSummary> children;
|
||||||
|
|
||||||
ParentModel({required this.user, this.childrenCount = 0});
|
ParentModel({
|
||||||
|
required this.user,
|
||||||
|
this.coParent,
|
||||||
|
this.childrenCount = 0,
|
||||||
|
this.children = const [],
|
||||||
|
});
|
||||||
|
|
||||||
factory ParentModel.fromJson(Map<String, dynamic> json) {
|
factory ParentModel.fromJson(Map<String, dynamic> json) {
|
||||||
final userJson = json['user'] ?? json;
|
final root = Map<String, dynamic>.from(json);
|
||||||
|
final userJson = Map<String, dynamic>.from(root['user'] ?? root);
|
||||||
|
if (root['numero_dossier'] != null && userJson['numero_dossier'] == null) {
|
||||||
|
userJson['numero_dossier'] = root['numero_dossier'];
|
||||||
|
}
|
||||||
final user = AppUser.fromJson(userJson);
|
final 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<String, dynamic>.from(coParentRaw));
|
||||||
|
}
|
||||||
|
|
||||||
|
final children = _parseChildren(root);
|
||||||
|
final links = root['parentChildren'] ?? root['parent_children'];
|
||||||
|
final linkCount = links is List ? links.length : 0;
|
||||||
|
|
||||||
return ParentModel(
|
return ParentModel(
|
||||||
user: user,
|
user: user,
|
||||||
childrenCount: children?.length ?? 0,
|
coParent: coParent,
|
||||||
|
childrenCount: children.isNotEmpty ? children.length : linkCount,
|
||||||
|
children: children,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static List<ParentChildSummary> _parseChildren(Map<String, dynamic> json) {
|
||||||
|
final children = <ParentChildSummary>[];
|
||||||
|
final seen = <String>{};
|
||||||
|
|
||||||
|
void add(ParentChildSummary? child) {
|
||||||
|
if (child == null || child.id.isEmpty || seen.contains(child.id)) return;
|
||||||
|
seen.add(child.id);
|
||||||
|
children.add(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
final links = json['parentChildren'] ?? json['parent_children'];
|
||||||
|
if (links is List) {
|
||||||
|
for (final link in links) {
|
||||||
|
if (link is! Map) continue;
|
||||||
|
add(ParentChildSummary.fromParentChildLink(
|
||||||
|
Map<String, dynamic>.from(link),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,6 +61,7 @@ class ApiConfig {
|
|||||||
static const String gestionnaires = '/gestionnaires';
|
static const String gestionnaires = '/gestionnaires';
|
||||||
static const String parents = '/parents';
|
static const String parents = '/parents';
|
||||||
static const String assistantesMaternelles = '/assistantes-maternelles';
|
static const String assistantesMaternelles = '/assistantes-maternelles';
|
||||||
|
static const String enfants = '/enfants';
|
||||||
static const String relais = '/relais';
|
static const String relais = '/relais';
|
||||||
static const String dossiers = '/dossiers';
|
static const String dossiers = '/dossiers';
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:p_tits_pas/models/user.dart';
|
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/parent_model.dart';
|
||||||
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
||||||
import 'package:p_tits_pas/models/pending_family.dart';
|
import 'package:p_tits_pas/models/pending_family.dart';
|
||||||
@ -366,7 +367,136 @@ class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final List<dynamic> data = jsonDecode(response.body);
|
final List<dynamic> data = jsonDecode(response.body);
|
||||||
return data.map((e) => ParentModel.fromJson(e)).toList();
|
return data
|
||||||
|
.map((e) => ParentModel.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<ParentModel> getParent(String userId) async {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$userId'),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
final err = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||||
|
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement parent');
|
||||||
|
}
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
return ParentModel.fromJson(
|
||||||
|
Map<String, dynamic>.from(decoded is Map ? decoded : {}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<ParentModel> updateParentFiche({
|
||||||
|
required String parentUserId,
|
||||||
|
required Map<String, dynamic> body,
|
||||||
|
}) async {
|
||||||
|
final response = await http.patch(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$parentUserId/fiche'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour parent'));
|
||||||
|
}
|
||||||
|
return _parentModelFromBody(response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<ParentModel> attachEnfantToParent({
|
||||||
|
required String parentUserId,
|
||||||
|
required String enfantId,
|
||||||
|
}) async {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse(
|
||||||
|
'${ApiConfig.baseUrl}${ApiConfig.parents}/$parentUserId/enfants/$enfantId',
|
||||||
|
),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||||
|
throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant'));
|
||||||
|
}
|
||||||
|
return _parentModelFromBody(response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<ParentModel> detachEnfantFromParent({
|
||||||
|
required String parentUserId,
|
||||||
|
required String enfantId,
|
||||||
|
}) async {
|
||||||
|
final response = await http.delete(
|
||||||
|
Uri.parse(
|
||||||
|
'${ApiConfig.baseUrl}${ApiConfig.parents}/$parentUserId/enfants/$enfantId',
|
||||||
|
),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 204) {
|
||||||
|
throw Exception(_extractErrorMessage(response.body, 'Erreur détachement enfant'));
|
||||||
|
}
|
||||||
|
if (response.body.isEmpty) {
|
||||||
|
return getParent(parentUserId);
|
||||||
|
}
|
||||||
|
return _parentModelFromBody(response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<List<EnfantAdminModel>> getEnfants() async {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception(_extractErrorMessage(response.body, 'Erreur chargement enfants'));
|
||||||
|
}
|
||||||
|
final List<dynamic> data = jsonDecode(response.body);
|
||||||
|
return data
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(EnfantAdminModel.fromJson)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<EnfantAdminModel> getEnfant(String enfantId) async {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception(_extractErrorMessage(response.body, 'Erreur chargement enfant'));
|
||||||
|
}
|
||||||
|
return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<EnfantAdminModel> updateEnfant({
|
||||||
|
required String enfantId,
|
||||||
|
required Map<String, dynamic> body,
|
||||||
|
}) async {
|
||||||
|
final response = await http.patch(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant'));
|
||||||
|
}
|
||||||
|
return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ParentModel _parentModelFromBody(String body) {
|
||||||
|
final decoded = jsonDecode(body);
|
||||||
|
return ParentModel.fromJson(
|
||||||
|
Map<String, dynamic>.from(decoded is Map ? decoded : {}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _extractErrorMessage(String body, String fallback) {
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(body);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
final message = decoded['message'];
|
||||||
|
if (message is List && message.isNotEmpty) {
|
||||||
|
return message.join(' - ');
|
||||||
|
}
|
||||||
|
return _toStr(message) ?? fallback;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer la liste des assistantes maternelles
|
// Récupérer la liste des assistantes maternelles
|
||||||
@ -383,7 +513,153 @@ class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final List<dynamic> data = jsonDecode(response.body);
|
final List<dynamic> data = jsonDecode(response.body);
|
||||||
return data.map((e) => AssistanteMaternelleModel.fromJson(e)).toList();
|
return data
|
||||||
|
.map((e) => AssistanteMaternelleModel.fromJson(
|
||||||
|
Map<String, dynamic>.from(e as Map),
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<AssistanteMaternelleModel> 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<String, dynamic>.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<String, dynamic>?;
|
||||||
|
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement AM');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mise à jour fiche AM (identité + champs pro). Ticket #131.
|
||||||
|
static Future<AssistanteMaternelleModel> updateAmFiche({
|
||||||
|
required String amUserId,
|
||||||
|
required Map<String, dynamic> 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 = <String, dynamic>{};
|
||||||
|
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 = <String, dynamic>{};
|
||||||
|
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<AssistanteMaternelleModel> 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<AssistanteMaternelleModel> 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<String, dynamic>.from(decoded is Map ? decoded : {}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer la liste des administrateurs (via /users filtré ou autre)
|
// Récupérer la liste des administrateurs (via /users filtré ou autre)
|
||||||
|
|||||||
45
frontend/lib/utils/am_vigilance.dart
Normal file
45
frontend/lib/utils/am_vigilance.dart
Normal file
@ -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.';
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import 'package:intl/intl.dart';
|
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].
|
/// Affiche une date ISO / parseable en `dd/MM/yyyy`, avec repli sur la chaîne ou [ifEmpty].
|
||||||
String formatIsoDateFr(String? s, {String ifEmpty = '–'}) {
|
String formatIsoDateFr(String? s, {String ifEmpty = '–'}) {
|
||||||
@ -9,3 +10,89 @@ String formatIsoDateFr(String? s, {String ifEmpty = '–'}) {
|
|||||||
return s.trim();
|
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)}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
51
frontend/lib/utils/enfant_status_utils.dart
Normal file
51
frontend/lib/utils/enfant_status_utils.dart
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/models/assistante_maternelle_model.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/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/admin_user_card.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||||
|
|
||||||
@ -77,10 +77,12 @@ class _AssistanteMaternelleManagementWidgetState
|
|||||||
itemCount: filteredAssistantes.length,
|
itemCount: filteredAssistantes.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final assistante = filteredAssistantes[index];
|
final assistante = filteredAssistantes[index];
|
||||||
|
final vigilance = amPlacesVigilanceMessage(assistante);
|
||||||
return AdminUserCard(
|
return AdminUserCard(
|
||||||
title: assistante.user.fullName,
|
title: assistante.user.fullName,
|
||||||
avatarUrl: assistante.user.photoUrl,
|
avatarUrl: assistante.user.photoUrl,
|
||||||
fallbackIcon: Icons.face,
|
fallbackIcon: Icons.face,
|
||||||
|
vigilanceTooltip: vigilance,
|
||||||
subtitleLines: [
|
subtitleLines: [
|
||||||
assistante.user.email,
|
assistante.user.email,
|
||||||
'Zone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}',
|
'Zone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}',
|
||||||
@ -102,55 +104,10 @@ class _AssistanteMaternelleManagementWidgetState
|
|||||||
void _openAssistanteDetails(AssistanteMaternelleModel assistante) {
|
void _openAssistanteDetails(AssistanteMaternelleModel assistante) {
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AdminDetailModal(
|
builder: (context) => AdminAmEditModal(
|
||||||
title: assistante.user.fullName.isEmpty
|
assistante: assistante,
|
||||||
? 'Assistante maternelle'
|
onSaved: _loadAssistantes,
|
||||||
: 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')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _v(String? value) => (value == null || value.isEmpty) ? '-' : value;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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<ParentChildSummary> 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
841
frontend/lib/widgets/admin/common/admin_am_edit_modal.dart
Normal file
841
frontend/lib/widgets/admin/common/admin_am_edit_modal.dart
Normal file
@ -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<AdminAmEditModal> createState() => _AdminAmEditModalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminAmEditModalState extends State<AdminAmEditModal>
|
||||||
|
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<ParentChildSummary> _children;
|
||||||
|
late Set<String> _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<int> _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 = <String>[];
|
||||||
|
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<void> _save() async {
|
||||||
|
if (!_dirty) return;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Confirmer'),
|
||||||
|
content: const Text(
|
||||||
|
'Enregistrer les modifications de la fiche 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<void> _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<void> _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<void> _openChild(ParentChildSummary child) async {
|
||||||
|
try {
|
||||||
|
final enfant = await UserService.getEnfant(child.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AdminChildDetailModal(
|
||||||
|
enfant: enfant,
|
||||||
|
onSaved: _refreshChildrenDetails,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _detachChild(ParentChildSummary child) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Détacher l\'enfant'),
|
||||||
|
content: Text(
|
||||||
|
'Retirer ${child.fullName} de la fiche de 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<void> _attachChild() async {
|
||||||
|
List<EnfantAdminModel> all;
|
||||||
|
try {
|
||||||
|
all = await UserService.getEnfants();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final linkedIds = _children.map((c) => c.id).toSet();
|
||||||
|
final candidates = all.where((e) => !linkedIds.contains(e.id)).toList();
|
||||||
|
if (candidates.isEmpty) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Aucun enfant disponible à rattacher')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
final selected = await showDialog<EnfantAdminModel>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => SimpleDialog(
|
||||||
|
title: const Text('Rattacher un enfant'),
|
||||||
|
children: candidates
|
||||||
|
.map(
|
||||||
|
(e) => SimpleDialogOption(
|
||||||
|
onPressed: () => Navigator.pop(ctx, e),
|
||||||
|
child: Text(e.fullName),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (selected == null || !mounted) return;
|
||||||
|
|
||||||
|
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(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
frontend/lib/widgets/admin/common/admin_am_photo_frame.dart
Normal file
115
frontend/lib/widgets/admin/common/admin_am_photo_frame.dart
Normal file
@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
411
frontend/lib/widgets/admin/common/admin_child_detail_modal.dart
Normal file
411
frontend/lib/widgets/admin/common/admin_child_detail_modal.dart
Normal file
@ -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<AdminChildDetailModal> createState() => _AdminChildDetailModalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||||
|
late final TextEditingController _prenomCtrl;
|
||||||
|
late final TextEditingController _nomCtrl;
|
||||||
|
late final TextEditingController _birthCtrl;
|
||||||
|
late final TextEditingController _dueCtrl;
|
||||||
|
late String _status;
|
||||||
|
late String _gender;
|
||||||
|
late bool _consentPhoto;
|
||||||
|
late bool _isMultiple;
|
||||||
|
bool _dirty = false;
|
||||||
|
bool _saving = false;
|
||||||
|
|
||||||
|
static const _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<void> _save() async {
|
||||||
|
if (!_dirty) return;
|
||||||
|
setState(() => _saving = true);
|
||||||
|
try {
|
||||||
|
await UserService.updateEnfant(
|
||||||
|
enfantId: widget.enfant.id,
|
||||||
|
body: {
|
||||||
|
'first_name': _prenomCtrl.text.trim(),
|
||||||
|
'last_name': _nomCtrl.text.trim(),
|
||||||
|
'status': _status,
|
||||||
|
'gender': _gender,
|
||||||
|
if (_birthCtrl.text.trim().isNotEmpty)
|
||||||
|
'birth_date': _birthCtrl.text.trim(),
|
||||||
|
if (_dueCtrl.text.trim().isNotEmpty) 'due_date': _dueCtrl.text.trim(),
|
||||||
|
'consent_photo': _consentPhoto,
|
||||||
|
'is_multiple': _isMultiple,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_dirty = false;
|
||||||
|
_saving = false;
|
||||||
|
});
|
||||||
|
widget.onSaved?.call();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Fiche enfant enregistrée')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _saving = false);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _parentsLine() {
|
||||||
|
final names = widget.enfant.parentLinks
|
||||||
|
.map((l) {
|
||||||
|
final n = (l.parentName ?? '').trim();
|
||||||
|
return n.isNotEmpty ? n : 'Parent rattaché';
|
||||||
|
})
|
||||||
|
.where((s) => s.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
if (names.isEmpty) return '';
|
||||||
|
return names.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final parents = _parentsLine();
|
||||||
|
final showDueDate = _status == 'a_naitre';
|
||||||
|
|
||||||
|
return Dialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 480,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 640),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.enfant.fullName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (parents.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Responsables : $parents',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Colors.black54,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
tooltip: 'Fermer',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Divider(height: 1),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Flexible(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _labeledField(
|
||||||
|
'Prénom',
|
||||||
|
TextField(
|
||||||
|
controller: _prenomCtrl,
|
||||||
|
decoration: _decoration(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _labeledField(
|
||||||
|
'Nom',
|
||||||
|
TextField(
|
||||||
|
controller: _nomCtrl,
|
||||||
|
decoration: _decoration(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _labeledDropdown(
|
||||||
|
'Statut',
|
||||||
|
_status,
|
||||||
|
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<MapEntry<String, String>> items,
|
||||||
|
ValueChanged<String> onChanged,
|
||||||
|
) {
|
||||||
|
final safeValue =
|
||||||
|
items.any((e) => e.key == value) ? value : items.first.key;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(label, style: _labelStyle),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: safeValue,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: _decoration(),
|
||||||
|
items: items
|
||||||
|
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) {
|
||||||
|
if (v != null) onChanged(v);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _switchRow(String label, bool value, ValueChanged<bool> onChanged) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text(label, style: _labelStyle)),
|
||||||
|
Switch(
|
||||||
|
value: value,
|
||||||
|
onChanged: onChanged,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _genderLabel(String gender) {
|
||||||
|
switch (gender) {
|
||||||
|
case 'H':
|
||||||
|
return 'Garçon';
|
||||||
|
case 'F':
|
||||||
|
return 'Fille';
|
||||||
|
case 'Autre':
|
||||||
|
return 'Autre';
|
||||||
|
default:
|
||||||
|
return gender;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<ParentChildSummary> 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<String> enfantAdminSubtitleLines({
|
||||||
|
required String status,
|
||||||
|
String? birthDate,
|
||||||
|
String? dueDate,
|
||||||
|
String? gender,
|
||||||
|
List<String> extra = const [],
|
||||||
|
}) {
|
||||||
|
final lines = <String>[];
|
||||||
|
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<String> subtitleLines;
|
||||||
|
final List<Widget> actions;
|
||||||
|
|
||||||
|
const AdminEnfantUserCard({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
this.photoUrl,
|
||||||
|
required this.subtitleLines,
|
||||||
|
this.actions = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AdminEnfantUserCard.fromEnfant(
|
||||||
|
EnfantAdminModel enfant, {
|
||||||
|
List<String> extraSubtitleLines = const [],
|
||||||
|
List<Widget> actions = const [],
|
||||||
|
}) {
|
||||||
|
final parents = enfant.parentLinks
|
||||||
|
.map((l) => l.parentName ?? 'Parent')
|
||||||
|
.join(', ');
|
||||||
|
return AdminEnfantUserCard(
|
||||||
|
title: enfant.fullName,
|
||||||
|
photoUrl: enfant.photoUrl,
|
||||||
|
subtitleLines: enfantAdminSubtitleLines(
|
||||||
|
status: enfant.status,
|
||||||
|
birthDate: enfant.birthDate,
|
||||||
|
dueDate: enfant.dueDate,
|
||||||
|
gender: enfant.gender,
|
||||||
|
extra: [
|
||||||
|
if (parents.isNotEmpty) 'Responsables : $parents',
|
||||||
|
...extraSubtitleLines,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: actions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory AdminEnfantUserCard.fromSummary(
|
||||||
|
ParentChildSummary child, {
|
||||||
|
List<String> extraSubtitleLines = const [],
|
||||||
|
List<Widget> actions = const [],
|
||||||
|
}) {
|
||||||
|
return AdminEnfantUserCard(
|
||||||
|
title: child.fullName,
|
||||||
|
photoUrl: child.photoUrl,
|
||||||
|
subtitleLines: enfantAdminSubtitleLines(
|
||||||
|
status: child.status,
|
||||||
|
birthDate: child.birthDate,
|
||||||
|
dueDate: child.dueDate,
|
||||||
|
extra: extraSubtitleLines,
|
||||||
|
),
|
||||||
|
actions: actions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AdminUserCard(
|
||||||
|
title: title,
|
||||||
|
fallbackIcon: Icons.child_care_outlined,
|
||||||
|
avatarUrl: photoUrl,
|
||||||
|
subtitleLines: subtitleLines,
|
||||||
|
actions: actions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
462
frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart
Normal file
462
frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart
Normal file
@ -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<AdminParentEditModal> createState() => _AdminParentEditModalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
||||||
|
late final TextEditingController _nomCtrl;
|
||||||
|
late final TextEditingController _prenomCtrl;
|
||||||
|
late final TextEditingController _emailCtrl;
|
||||||
|
late final TextEditingController _telCtrl;
|
||||||
|
late final TextEditingController _adresseCtrl;
|
||||||
|
late final TextEditingController _villeCtrl;
|
||||||
|
late final TextEditingController _cpCtrl;
|
||||||
|
|
||||||
|
late String _statut;
|
||||||
|
late List<ParentChildSummary> _children;
|
||||||
|
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<void> _save() async {
|
||||||
|
if (!_dirty) return;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Confirmer'),
|
||||||
|
content: const Text(
|
||||||
|
'Enregistrer les modifications de la fiche parent ?',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: const Text('Sauvegarder'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
setState(() => _saving = true);
|
||||||
|
try {
|
||||||
|
await UserService.updateParentFiche(
|
||||||
|
parentUserId: widget.parent.user.id,
|
||||||
|
body: {
|
||||||
|
'nom': _nomCtrl.text.trim(),
|
||||||
|
'prenom': _prenomCtrl.text.trim(),
|
||||||
|
'email': _emailCtrl.text.trim(),
|
||||||
|
'telephone': normalizePhone(_telCtrl.text),
|
||||||
|
'adresse': _adresseCtrl.text.trim(),
|
||||||
|
'ville': _villeCtrl.text.trim(),
|
||||||
|
'code_postal': _cpCtrl.text.trim(),
|
||||||
|
'statut': _statut,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_dirty = false;
|
||||||
|
_saving = false;
|
||||||
|
});
|
||||||
|
widget.onSaved?.call();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Fiche parent enregistrée')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _saving = false);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openChild(ParentChildSummary child) async {
|
||||||
|
try {
|
||||||
|
final enfant = await UserService.getEnfant(child.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AdminChildDetailModal(
|
||||||
|
enfant: enfant,
|
||||||
|
onSaved: _reloadChildren,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _reloadChildren() async {
|
||||||
|
try {
|
||||||
|
final refreshed = await UserService.getParent(widget.parent.user.id);
|
||||||
|
final all = await UserService.getEnfants();
|
||||||
|
final byId = {for (final e in all) e.id: e};
|
||||||
|
|
||||||
|
final kids = refreshed.children.map((c) {
|
||||||
|
final full = byId[c.id];
|
||||||
|
if (full != null) return ParentChildSummary.fromEnfant(full);
|
||||||
|
return c;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_children = kids;
|
||||||
|
_coParent = refreshed.coParent;
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _detachChild(ParentChildSummary child) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Détacher l\'enfant'),
|
||||||
|
content: Text(
|
||||||
|
'Retirer ${child.fullName} de la fiche de ce parent ?\n'
|
||||||
|
'(L\'enfant ne sera pas supprimé.)',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
style: 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<void> _attachChild() async {
|
||||||
|
List<EnfantAdminModel> all;
|
||||||
|
try {
|
||||||
|
all = await UserService.getEnfants();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final linkedIds = _children.map((c) => c.id).toSet();
|
||||||
|
final candidates = all.where((e) => !linkedIds.contains(e.id)).toList();
|
||||||
|
if (candidates.isEmpty) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Aucun enfant disponible à rattacher')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
final selected = await showDialog<EnfantAdminModel>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => SimpleDialog(
|
||||||
|
title: const Text('Rattacher un enfant'),
|
||||||
|
children: candidates
|
||||||
|
.map(
|
||||||
|
(e) => SimpleDialogOption(
|
||||||
|
onPressed: () => Navigator.pop(ctx, e),
|
||||||
|
child: Text(e.fullName),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (selected == null || !mounted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await UserService.attachEnfantToParent(
|
||||||
|
parentUserId: widget.parent.user.id,
|
||||||
|
enfantId: selected.id,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
await _reloadChildren();
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Enfant rattaché')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _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(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
frontend/lib/widgets/admin/common/admin_status_capsule.dart
Normal file
66
frontend/lib/widgets/admin/common/admin_status_capsule.dart
Normal file
@ -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<String>? 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<String>(
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
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 {
|
class AdminUserCard extends StatefulWidget {
|
||||||
final String title;
|
final String title;
|
||||||
@ -10,6 +12,7 @@ class AdminUserCard extends StatefulWidget {
|
|||||||
final Color? backgroundColor;
|
final Color? backgroundColor;
|
||||||
final Color? titleColor;
|
final Color? titleColor;
|
||||||
final Color? infoColor;
|
final Color? infoColor;
|
||||||
|
final String? vigilanceTooltip;
|
||||||
|
|
||||||
const AdminUserCard({
|
const AdminUserCard({
|
||||||
super.key,
|
super.key,
|
||||||
@ -22,6 +25,7 @@ class AdminUserCard extends StatefulWidget {
|
|||||||
this.backgroundColor,
|
this.backgroundColor,
|
||||||
this.titleColor,
|
this.titleColor,
|
||||||
this.infoColor,
|
this.infoColor,
|
||||||
|
this.vigilanceTooltip,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -37,6 +41,7 @@ class _AdminUserCardState extends State<AdminUserCard> {
|
|||||||
widget.subtitleLines.where((e) => e.trim().isNotEmpty).join(' ');
|
widget.subtitleLines.where((e) => e.trim().isNotEmpty).join(' ');
|
||||||
final actionsWidth =
|
final actionsWidth =
|
||||||
widget.actions.isNotEmpty ? widget.actions.length * 30.0 : 0.0;
|
widget.actions.isNotEmpty ? widget.actions.length * 30.0 : 0.0;
|
||||||
|
final avatarUrl = ApiConfig.absoluteMediaUrl(widget.avatarUrl);
|
||||||
|
|
||||||
return MouseRegion(
|
return MouseRegion(
|
||||||
onEnter: (_) => setState(() => _isHovered = true),
|
onEnter: (_) => setState(() => _isHovered = true),
|
||||||
@ -60,21 +65,19 @@ class _AdminUserCardState extends State<AdminUserCard> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
_buildAvatar(avatarUrl),
|
||||||
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,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
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(
|
Expanded(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@ -140,4 +143,35 @@ class _AdminUserCardState extends State<AdminUserCard> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildAvatar(String url) {
|
||||||
|
const size = 28.0;
|
||||||
|
const bg = Color(0xFFEDE5FA);
|
||||||
|
const iconColor = Color(0xFF6B3FA0);
|
||||||
|
|
||||||
|
if (url.isEmpty) {
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: 14,
|
||||||
|
backgroundColor: bg,
|
||||||
|
child: Icon(widget.fallbackIcon, size: 16, color: iconColor),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ClipOval(
|
||||||
|
child: SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: AuthNetworkImage(
|
||||||
|
url: url,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => ColoredBox(
|
||||||
|
color: bg,
|
||||||
|
child: Icon(widget.fallbackIcon, size: 16, color: iconColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'admin_detail_modal.dart';
|
import 'admin_detail_modal.dart';
|
||||||
|
|
||||||
/// Bloc type formulaire (titre de section + champs read-only) pour les modales de validation.
|
/// Bloc type formulaire (titre de section + champs read-only) pour les modales de validation.
|
||||||
@ -23,6 +24,41 @@ class ValidationDetailSection extends StatelessWidget {
|
|||||||
this.rowFlex,
|
this.rowFlex,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ValidationFormGrid(
|
||||||
|
title: title,
|
||||||
|
rowLayout: rowLayout,
|
||||||
|
rowFlex: rowFlex,
|
||||||
|
fields: fields
|
||||||
|
.map(
|
||||||
|
(f) => ValidationLabeledField(
|
||||||
|
label: f.label,
|
||||||
|
field: ValidationReadOnlyField(value: f.value),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grille label/champ réutilisable (validation, fiches admin).
|
||||||
|
class ValidationFormGrid extends StatelessWidget {
|
||||||
|
final String? title;
|
||||||
|
final List<ValidationLabeledField> fields;
|
||||||
|
final List<int>? rowLayout;
|
||||||
|
final Map<int, List<int>>? rowFlex;
|
||||||
|
final bool compact;
|
||||||
|
|
||||||
|
const ValidationFormGrid({
|
||||||
|
super.key,
|
||||||
|
this.title,
|
||||||
|
required this.fields,
|
||||||
|
this.rowLayout,
|
||||||
|
this.rowFlex,
|
||||||
|
this.compact = false,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final layout = rowLayout ?? List.filled(fields.length, 1);
|
final layout = rowLayout ?? List.filled(fields.length, 1);
|
||||||
@ -38,22 +74,22 @@ class ValidationDetailSection extends StatelessWidget {
|
|||||||
rowIndex++;
|
rowIndex++;
|
||||||
if (count == 1) {
|
if (count == 1) {
|
||||||
rows.add(Padding(
|
rows.add(Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
|
||||||
child: _buildFieldCell(rowFields.first),
|
child: rowFields.first,
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
rows.add(Padding(
|
rows.add(Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
for (int i = 0; i < rowFields.length; i++) ...[
|
for (int i = 0; i < rowFields.length; i++) ...[
|
||||||
if (i > 0) const SizedBox(width: 16),
|
if (i > 0) SizedBox(width: compact ? 12 : 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: (flexForRow != null && i < flexForRow.length)
|
flex: (flexForRow != null && i < flexForRow.length)
|
||||||
? flexForRow[i]
|
? flexForRow[i]
|
||||||
: 1,
|
: 1,
|
||||||
child: _buildFieldCell(rowFields[i]),
|
child: rowFields[i],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@ -69,26 +105,96 @@ class ValidationDetailSection extends StatelessWidget {
|
|||||||
if (showTitle) ...[
|
if (showTitle) ...[
|
||||||
Text(
|
Text(
|
||||||
title!.trim(),
|
title!.trim(),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: compact ? 15 : 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Colors.black87,
|
color: Colors.black87,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
SizedBox(height: compact ? 8 : 12),
|
||||||
],
|
],
|
||||||
...rows,
|
...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(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
field.label,
|
label,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
@ -96,37 +202,203 @@ class ValidationDetailSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
ValidationReadOnlyField(value: field.value),
|
field,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Champ texte en lecture seule, style formulaire (fond gris léger, bordure). Réutilisable en éditable plus tard.
|
/// Champ texte éditable, même rendu que [ValidationReadOnlyField].
|
||||||
class ValidationReadOnlyField extends StatelessWidget {
|
class ValidationEditableField extends StatelessWidget {
|
||||||
|
final TextEditingController controller;
|
||||||
|
final TextInputType keyboardType;
|
||||||
|
final List<TextInputFormatter>? 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<ValidationLabeledField> fields;
|
||||||
|
final List<int>? rowLayout;
|
||||||
|
final Map<int, List<int>>? 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 String value;
|
||||||
final int? maxLines;
|
final int? maxLines;
|
||||||
|
final bool compact;
|
||||||
|
final bool error;
|
||||||
|
|
||||||
const ValidationReadOnlyField({
|
const ValidationReadOnlyField({
|
||||||
super.key,
|
super.key,
|
||||||
required this.value,
|
required this.value,
|
||||||
this.maxLines = 1,
|
this.maxLines = 1,
|
||||||
|
this.compact = false,
|
||||||
|
this.error = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ValidationReadOnlyField> createState() => _ValidationReadOnlyFieldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ValidationReadOnlyFieldState extends State<ValidationReadOnlyField> {
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
height: widget.compact && widget.maxLines == 1 ? _compactFieldHeight : null,
|
||||||
decoration: BoxDecoration(
|
alignment: widget.compact ? Alignment.centerLeft : null,
|
||||||
color: Colors.grey.shade50,
|
padding: EdgeInsets.symmetric(
|
||||||
borderRadius: BorderRadius.circular(6),
|
horizontal: widget.compact ? 10 : 12,
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
vertical: widget.compact ? 7 : 10,
|
||||||
),
|
),
|
||||||
|
decoration: ValidationFieldDecoration.container(error: widget.error),
|
||||||
child: Text(
|
child: Text(
|
||||||
value,
|
widget.value,
|
||||||
style: const TextStyle(color: Colors.black87, fontSize: 14),
|
style: TextStyle(
|
||||||
maxLines: maxLines,
|
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,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
98
frontend/lib/widgets/admin/enfant_management_widget.dart
Normal file
98
frontend/lib/widgets/admin/enfant_management_widget.dart
Normal file
@ -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<EnfantManagementWidget> createState() => _EnfantManagementWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _error;
|
||||||
|
List<EnfantAdminModel> _enfants = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadEnfants();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadEnfants() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final list = await UserService.getEnfants();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_enfants = list;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openEnfant(EnfantAdminModel enfant) async {
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AdminChildDetailModal(
|
||||||
|
enfant: enfant,
|
||||||
|
onSaved: _loadEnfants,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final query = widget.searchQuery.toLowerCase();
|
||||||
|
final filtered = _enfants.where((e) {
|
||||||
|
final matchesName = e.fullName.toLowerCase().contains(query);
|
||||||
|
final matchesStatus = widget.statusFilter == null ||
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/models/parent_model.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/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/admin_user_card.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||||
|
|
||||||
@ -80,7 +79,7 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
|||||||
avatarUrl: parent.user.photoUrl,
|
avatarUrl: parent.user.photoUrl,
|
||||||
subtitleLines: [
|
subtitleLines: [
|
||||||
parent.user.email,
|
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: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
@ -114,45 +113,10 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
|||||||
void _openParentDetails(ParentModel parent) {
|
void _openParentDetails(ParentModel parent) {
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AdminDetailModal(
|
builder: (context) => AdminParentEditModal(
|
||||||
title: parent.user.fullName.isEmpty ? 'Parent' : parent.user.fullName,
|
parent: parent,
|
||||||
subtitle: parent.user.email,
|
onSaved: _loadParents,
|
||||||
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')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _v(String? value) => (value == null || value.isEmpty) ? '-' : value;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import 'package:p_tits_pas/services/user_service.dart';
|
|||||||
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
|
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_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/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/gestionnaire_management_widget.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart';
|
import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart';
|
import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart';
|
||||||
@ -28,6 +29,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
final TextEditingController _amCapacityController = TextEditingController();
|
final TextEditingController _amCapacityController = TextEditingController();
|
||||||
String? _parentStatus;
|
String? _parentStatus;
|
||||||
|
String? _enfantStatus;
|
||||||
bool _hasPending = false;
|
bool _hasPending = false;
|
||||||
bool _pendingLoading = true;
|
bool _pendingLoading = true;
|
||||||
|
|
||||||
@ -80,7 +82,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<String> get _tabLabels {
|
List<String> get _tabLabels {
|
||||||
const base = ['Parents', 'Assistantes maternelles', 'Gestionnaires'];
|
const base = ['Parents', 'Enfants', 'Assistantes maternelles', 'Gestionnaires'];
|
||||||
final withAdmin = [...base, 'Administrateurs'];
|
final withAdmin = [...base, 'Administrateurs'];
|
||||||
final list = widget.showAdministrateursTab ? withAdmin : base;
|
final list = widget.showAdministrateursTab ? withAdmin : base;
|
||||||
// Onglet « À valider » visible seulement s'il y a des dossiers en attente (ticket #107).
|
// Onglet « À valider » visible seulement s'il y a des dossiers en attente (ticket #107).
|
||||||
@ -94,11 +96,12 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
_subIndex = index.clamp(0, maxIndex);
|
_subIndex = index.clamp(0, maxIndex);
|
||||||
_searchController.clear();
|
_searchController.clear();
|
||||||
_parentStatus = null;
|
_parentStatus = null;
|
||||||
|
_enfantStatus = null;
|
||||||
_amCapacityController.clear();
|
_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;
|
int get _contentIndexOffset => (_hasPending && !_pendingLoading) ? 1 : 0;
|
||||||
|
|
||||||
String _searchHintForTab() {
|
String _searchHintForTab() {
|
||||||
@ -109,10 +112,12 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
case 0:
|
case 0:
|
||||||
return 'Rechercher un parent...';
|
return 'Rechercher un parent...';
|
||||||
case 1:
|
case 1:
|
||||||
return 'Rechercher une assistante...';
|
return 'Rechercher un enfant...';
|
||||||
case 2:
|
case 2:
|
||||||
return 'Rechercher un gestionnaire...';
|
return 'Rechercher une assistante...';
|
||||||
case 3:
|
case 3:
|
||||||
|
return 'Rechercher un gestionnaire...';
|
||||||
|
case 4:
|
||||||
return 'Rechercher un administrateur...';
|
return 'Rechercher un administrateur...';
|
||||||
default:
|
default:
|
||||||
return 'Rechercher...';
|
return 'Rechercher...';
|
||||||
@ -176,6 +181,61 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_subIndex == _contentIndexOffset + 1) {
|
if (_subIndex == _contentIndexOffset + 1) {
|
||||||
|
return DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<String?>(
|
||||||
|
value: _enfantStatus,
|
||||||
|
isExpanded: true,
|
||||||
|
hint: const Padding(
|
||||||
|
padding: EdgeInsets.only(left: 10),
|
||||||
|
child: Text('Statut', style: TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem<String?>(
|
||||||
|
value: null,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(left: 10),
|
||||||
|
child: Text('Tous', style: TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DropdownMenuItem<String?>(
|
||||||
|
value: 'a_naitre',
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(left: 10),
|
||||||
|
child: Text('À naître', style: TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DropdownMenuItem<String?>(
|
||||||
|
value: 'sans_garde',
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(left: 10),
|
||||||
|
child: Text('Sans garde', style: TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DropdownMenuItem<String?>(
|
||||||
|
value: 'garde',
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(left: 10),
|
||||||
|
child: Text('En garde', style: TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DropdownMenuItem<String?>(
|
||||||
|
value: 'scolarise',
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(left: 10),
|
||||||
|
child: Text('Scolarisé', style: TextStyle(fontSize: 12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_enfantStatus = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_subIndex == _contentIndexOffset + 2) {
|
||||||
return TextField(
|
return TextField(
|
||||||
controller: _amCapacityController,
|
controller: _amCapacityController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
@ -203,16 +263,21 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
statusFilter: _parentStatus,
|
statusFilter: _parentStatus,
|
||||||
);
|
);
|
||||||
case 1:
|
case 1:
|
||||||
|
return EnfantManagementWidget(
|
||||||
|
searchQuery: _searchController.text,
|
||||||
|
statusFilter: _enfantStatus,
|
||||||
|
);
|
||||||
|
case 2:
|
||||||
return AssistanteMaternelleManagementWidget(
|
return AssistanteMaternelleManagementWidget(
|
||||||
searchQuery: _searchController.text,
|
searchQuery: _searchController.text,
|
||||||
capacityMin: int.tryParse(_amCapacityController.text),
|
capacityMin: int.tryParse(_amCapacityController.text),
|
||||||
);
|
);
|
||||||
case 2:
|
case 3:
|
||||||
return GestionnaireManagementWidget(
|
return GestionnaireManagementWidget(
|
||||||
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
|
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
|
||||||
searchQuery: _searchController.text,
|
searchQuery: _searchController.text,
|
||||||
);
|
);
|
||||||
case 3:
|
case 4:
|
||||||
return AdminManagementWidget(
|
return AdminManagementWidget(
|
||||||
key: ValueKey('admins-$_adminRefreshTick'),
|
key: ValueKey('admins-$_adminRefreshTick'),
|
||||||
searchQuery: _searchController.text,
|
searchQuery: _searchController.text,
|
||||||
@ -246,7 +311,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
|
|
||||||
Future<void> _handleAddPressed() async {
|
Future<void> _handleAddPressed() async {
|
||||||
final contentIndex = _subIndex - _contentIndexOffset;
|
final contentIndex = _subIndex - _contentIndexOffset;
|
||||||
if (contentIndex == 2) {
|
if (contentIndex == 3) {
|
||||||
final created = await showDialog<bool>(
|
final created = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
@ -264,7 +329,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentIndex == 3) {
|
if (contentIndex == 4) {
|
||||||
final created = await showDialog<bool>(
|
final created = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
@ -289,7 +354,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
'La création est disponible pour les gestionnaires et administrateurs.',
|
'La création parent / enfant / AM sera disponible avec le ticket #129.',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/models/dossier_unifie.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/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/utils/nir_utils.dart';
|
||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.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/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/admin_detail_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.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 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
||||||
import 'validation_modal_theme.dart';
|
import 'validation_modal_theme.dart';
|
||||||
import 'validation_refus_form.dart';
|
import 'validation_refus_form.dart';
|
||||||
@ -60,21 +60,6 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
|
|||||||
|
|
||||||
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
|
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
|
||||||
|
|
||||||
/// Même ordre et disposition que le formulaire de création de compte (Nom/Prénom, Tél/Email, Adresse, CP/Ville).
|
|
||||||
List<AdminDetailField> _personalFields(AppUser u) => [
|
|
||||||
AdminDetailField(label: 'Nom', value: _v(u.nom)),
|
|
||||||
AdminDetailField(label: 'Prénom', value: _v(u.prenom)),
|
|
||||||
AdminDetailField(
|
|
||||||
label: 'Téléphone',
|
|
||||||
value: _v(u.telephone) != '–'
|
|
||||||
? formatPhoneForDisplay(_v(u.telephone))
|
|
||||||
: '–'),
|
|
||||||
AdminDetailField(label: 'Email', value: _v(u.email)),
|
|
||||||
AdminDetailField(label: 'Adresse (N° et Rue)', value: _v(u.adresse)),
|
|
||||||
AdminDetailField(label: 'Code postal', value: _v(u.codePostal)),
|
|
||||||
AdminDetailField(label: 'Ville', value: _v(u.ville)),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Panneau photo + grille droite : NIR|naissance, ville|pays, agrément|date, capa|places.
|
/// Panneau photo + grille droite : NIR|naissance, ville|pays, agrément|date, capa|places.
|
||||||
List<AdminDetailField> _photoProFields(DossierAM d) {
|
List<AdminDetailField> _photoProFields(DossierAM d) {
|
||||||
final u = d.user;
|
final u = d.user;
|
||||||
@ -110,10 +95,7 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static const List<int> _personalRowLayout = [2, 2, 1, 2];
|
static const List<int> _photoProRowLayout = [2, 2, 2, 2];
|
||||||
static const Map<int, List<int>> _personalRowFlex = {
|
|
||||||
3: [2, 5]
|
|
||||||
}; // Code postal étroit, Ville large
|
|
||||||
|
|
||||||
/// Proportion photo d’identité (35×45 mm).
|
/// Proportion photo d’identité (35×45 mm).
|
||||||
static const double _idPhotoAspectRatio = 35 / 45;
|
static const double _idPhotoAspectRatio = 35 / 45;
|
||||||
@ -266,11 +248,10 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
|
|||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
||||||
child: ValidationDetailSection(
|
child: IdentityBlock.readOnlyFromUser(
|
||||||
|
u,
|
||||||
title: 'Identité et coordonnées',
|
title: 'Identité et coordonnées',
|
||||||
fields: _personalFields(u),
|
emptyLabel: '–',
|
||||||
rowLayout: _personalRowLayout,
|
|
||||||
rowFlex: _personalRowFlex,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -311,7 +292,7 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
|
|||||||
child: ValidationDetailSection(
|
child: ValidationDetailSection(
|
||||||
title: 'Dossier professionnel',
|
title: 'Dossier professionnel',
|
||||||
fields: _photoProFields(d),
|
fields: _photoProFields(d),
|
||||||
rowLayout: const [2, 2, 2, 2],
|
rowLayout: _photoProRowLayout,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,11 +3,11 @@ import 'package:flutter/gestures.dart';
|
|||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:p_tits_pas/models/dossier_unifie.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/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/user_service.dart';
|
||||||
import 'package:p_tits_pas/services/api/api_config.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/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 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
||||||
import 'validation_modal_theme.dart';
|
import 'validation_modal_theme.dart';
|
||||||
import 'validation_refus_form.dart';
|
import 'validation_refus_form.dart';
|
||||||
@ -108,26 +108,6 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
|
|||||||
static String _formatBirthDate(String? s) =>
|
static String _formatBirthDate(String? s) =>
|
||||||
formatIsoDateFr(s, ifEmpty: 'Non défini');
|
formatIsoDateFr(s, ifEmpty: 'Non défini');
|
||||||
|
|
||||||
/// Même ordre et disposition que le formulaire de création (Nom/Prénom, Tél/Email, Adresse, CP/Ville).
|
|
||||||
List<AdminDetailField> _parentFields(ParentDossier p) => [
|
|
||||||
AdminDetailField(label: 'Nom', value: _v(p.nom)),
|
|
||||||
AdminDetailField(label: 'Prénom', value: _v(p.prenom)),
|
|
||||||
AdminDetailField(
|
|
||||||
label: 'Téléphone',
|
|
||||||
value: _v(p.telephone) != 'Non défini'
|
|
||||||
? formatPhoneForDisplay(_v(p.telephone))
|
|
||||||
: 'Non défini'),
|
|
||||||
AdminDetailField(label: 'Email', value: _v(p.email)),
|
|
||||||
AdminDetailField(label: 'Adresse (N° et Rue)', value: _v(p.adresse)),
|
|
||||||
AdminDetailField(label: 'Code postal', value: _v(p.codePostal)),
|
|
||||||
AdminDetailField(label: 'Ville', value: _v(p.ville)),
|
|
||||||
];
|
|
||||||
|
|
||||||
static const List<int> _parentRowLayout = [2, 2, 1, 2];
|
|
||||||
static const Map<int, List<int>> _parentRowFlex = {
|
|
||||||
3: [2, 5]
|
|
||||||
}; // Code postal étroit, Ville large
|
|
||||||
|
|
||||||
static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url);
|
static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -153,11 +133,9 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
|
|||||||
final d = widget.dossier;
|
final d = widget.dossier;
|
||||||
switch (_step) {
|
switch (_step) {
|
||||||
case 0:
|
case 0:
|
||||||
return ValidationDetailSection(
|
return IdentityBlock.readOnlyFromParentDossier(
|
||||||
|
d.parents.first,
|
||||||
title: 'Parent principal',
|
title: 'Parent principal',
|
||||||
fields: _parentFields(d.parents.first),
|
|
||||||
rowLayout: _parentRowLayout,
|
|
||||||
rowFlex: _parentRowFlex,
|
|
||||||
);
|
);
|
||||||
case 1:
|
case 1:
|
||||||
return _buildParent2Step();
|
return _buildParent2Step();
|
||||||
@ -178,11 +156,9 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
|
|||||||
style: TextStyle(color: Colors.black87)),
|
style: TextStyle(color: Colors.black87)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return ValidationDetailSection(
|
return IdentityBlock.readOnlyFromParentDossier(
|
||||||
|
widget.dossier.parents[1],
|
||||||
title: 'Deuxième parent',
|
title: 'Deuxième parent',
|
||||||
fields: _parentFields(widget.dossier.parents[1]),
|
|
||||||
rowLayout: _parentRowLayout,
|
|
||||||
rowFlex: _parentRowFlex,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -422,20 +398,9 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// « Scolarisé » / « Scolarisée » selon le genre enfant (`F` / sinon masculin par défaut).
|
/// Statut dans la colonne 2/3 (scolarisé·e, à naître, sans garde, en garde).
|
||||||
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.
|
|
||||||
String? _enfantColumnStatusLabel(EnfantDossier e) {
|
String? _enfantColumnStatusLabel(EnfantDossier e) {
|
||||||
final s = (e.status ?? '').trim().toLowerCase();
|
return enfantColumnStatusLabel(status: e.status, gender: e.gender);
|
||||||
if (s == 'a_naitre') return 'À naître';
|
|
||||||
if (s == 'scolarise') return _scolariseAccordeAuGenre(e.gender);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Nom ; date de naissance et genre sur une ligne (prénom au-dessus, pleine largeur).
|
/// Nom ; date de naissance et genre sur une ligne (prénom au-dessus, pleine largeur).
|
||||||
|
|||||||
@ -13,6 +13,7 @@ class AuthNetworkImage extends StatefulWidget {
|
|||||||
this.width,
|
this.width,
|
||||||
this.height,
|
this.height,
|
||||||
this.fit = BoxFit.cover,
|
this.fit = BoxFit.cover,
|
||||||
|
this.alignment = Alignment.center,
|
||||||
this.loadingBuilder,
|
this.loadingBuilder,
|
||||||
this.errorBuilder,
|
this.errorBuilder,
|
||||||
});
|
});
|
||||||
@ -21,6 +22,7 @@ class AuthNetworkImage extends StatefulWidget {
|
|||||||
final double? width;
|
final double? width;
|
||||||
final double? height;
|
final double? height;
|
||||||
final BoxFit fit;
|
final BoxFit fit;
|
||||||
|
final AlignmentGeometry alignment;
|
||||||
final ImageLoadingBuilder? loadingBuilder;
|
final ImageLoadingBuilder? loadingBuilder;
|
||||||
final ImageErrorWidgetBuilder? errorBuilder;
|
final ImageErrorWidgetBuilder? errorBuilder;
|
||||||
|
|
||||||
@ -75,6 +77,7 @@ class _AuthNetworkImageState extends State<AuthNetworkImage> {
|
|||||||
width: widget.width,
|
width: widget.width,
|
||||||
height: widget.height,
|
height: widget.height,
|
||||||
fit: widget.fit,
|
fit: widget.fit,
|
||||||
|
alignment: widget.alignment,
|
||||||
loadingBuilder: widget.loadingBuilder,
|
loadingBuilder: widget.loadingBuilder,
|
||||||
errorBuilder: err,
|
errorBuilder: err,
|
||||||
);
|
);
|
||||||
@ -105,6 +108,7 @@ class _AuthNetworkImageState extends State<AuthNetworkImage> {
|
|||||||
width: widget.width,
|
width: widget.width,
|
||||||
height: widget.height,
|
height: widget.height,
|
||||||
fit: widget.fit,
|
fit: widget.fit,
|
||||||
|
alignment: widget.alignment,
|
||||||
headers: headers,
|
headers: headers,
|
||||||
loadingBuilder: widget.loadingBuilder,
|
loadingBuilder: widget.loadingBuilder,
|
||||||
errorBuilder: err,
|
errorBuilder: err,
|
||||||
|
|||||||
278
frontend/lib/widgets/common/identity_block.dart
Normal file
278
frontend/lib/widgets/common/identity_block.dart
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||||||
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
||||||
|
|
||||||
|
/// Valeurs affichées dans un [IdentityBlock] en lecture seule.
|
||||||
|
class IdentityValues {
|
||||||
|
final String nom;
|
||||||
|
final String prenom;
|
||||||
|
final String telephone;
|
||||||
|
final String email;
|
||||||
|
final String adresse;
|
||||||
|
final String codePostal;
|
||||||
|
final String ville;
|
||||||
|
|
||||||
|
const IdentityValues({
|
||||||
|
required this.nom,
|
||||||
|
required this.prenom,
|
||||||
|
required this.telephone,
|
||||||
|
required this.email,
|
||||||
|
required this.adresse,
|
||||||
|
required this.codePostal,
|
||||||
|
required this.ville,
|
||||||
|
});
|
||||||
|
|
||||||
|
static String _fmt(String? s, String empty) {
|
||||||
|
final t = (s ?? '').trim();
|
||||||
|
return t.isEmpty ? empty : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _fmtPhone(String? s, String empty) {
|
||||||
|
final tel = _fmt(s, empty);
|
||||||
|
return tel != empty ? formatPhoneForDisplay(tel) : tel;
|
||||||
|
}
|
||||||
|
|
||||||
|
factory IdentityValues.fromUser(
|
||||||
|
AppUser user, {
|
||||||
|
String emptyLabel = 'Non défini',
|
||||||
|
}) {
|
||||||
|
return IdentityValues(
|
||||||
|
nom: _fmt(user.nom, emptyLabel),
|
||||||
|
prenom: _fmt(user.prenom, emptyLabel),
|
||||||
|
telephone: _fmtPhone(user.telephone, emptyLabel),
|
||||||
|
email: _fmt(user.email, emptyLabel),
|
||||||
|
adresse: _fmt(user.adresse, emptyLabel),
|
||||||
|
codePostal: _fmt(user.codePostal, emptyLabel),
|
||||||
|
ville: _fmt(user.ville, emptyLabel),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory IdentityValues.fromParentDossier(
|
||||||
|
ParentDossier parent, {
|
||||||
|
String emptyLabel = 'Non défini',
|
||||||
|
}) {
|
||||||
|
return IdentityValues(
|
||||||
|
nom: _fmt(parent.nom, emptyLabel),
|
||||||
|
prenom: _fmt(parent.prenom, emptyLabel),
|
||||||
|
telephone: _fmtPhone(parent.telephone, emptyLabel),
|
||||||
|
email: _fmt(parent.email, emptyLabel),
|
||||||
|
adresse: _fmt(parent.adresse, emptyLabel),
|
||||||
|
codePostal: _fmt(parent.codePostal, emptyLabel),
|
||||||
|
ville: _fmt(parent.ville, emptyLabel),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bloc identité : Nom/Prénom, Tél/Email, Adresse, CP/Ville.
|
||||||
|
/// Grille partagée (création de compte, validation AM/famille, fiches admin, etc.).
|
||||||
|
class IdentityBlock extends StatelessWidget { final String? title;
|
||||||
|
|
||||||
|
final String? _nom;
|
||||||
|
final String? _prenom;
|
||||||
|
final String? _telephone;
|
||||||
|
final String? _email;
|
||||||
|
final String? _adresse;
|
||||||
|
final String? _codePostal;
|
||||||
|
final String? _ville;
|
||||||
|
|
||||||
|
final TextEditingController? _nomCtrl;
|
||||||
|
final TextEditingController? _prenomCtrl;
|
||||||
|
final TextEditingController? _telCtrl;
|
||||||
|
final TextEditingController? _emailCtrl;
|
||||||
|
final TextEditingController? _adresseCtrl;
|
||||||
|
final TextEditingController? _cpCtrl;
|
||||||
|
final TextEditingController? _villeCtrl;
|
||||||
|
|
||||||
|
static const List<int> rowLayout = [2, 2, 1, 2];
|
||||||
|
static const Map<int, List<int>> rowFlex = {3: [2, 5]};
|
||||||
|
|
||||||
|
const IdentityBlock.readOnly({
|
||||||
|
super.key,
|
||||||
|
this.title,
|
||||||
|
required String nom,
|
||||||
|
required String prenom,
|
||||||
|
required String telephone,
|
||||||
|
required String email,
|
||||||
|
required String adresse,
|
||||||
|
required String codePostal,
|
||||||
|
required String ville,
|
||||||
|
}) : _nom = nom,
|
||||||
|
_prenom = prenom,
|
||||||
|
_telephone = telephone,
|
||||||
|
_email = email,
|
||||||
|
_adresse = adresse,
|
||||||
|
_codePostal = codePostal,
|
||||||
|
_ville = ville,
|
||||||
|
_nomCtrl = null,
|
||||||
|
_prenomCtrl = null,
|
||||||
|
_telCtrl = null,
|
||||||
|
_emailCtrl = null,
|
||||||
|
_adresseCtrl = null,
|
||||||
|
_cpCtrl = null,
|
||||||
|
_villeCtrl = null;
|
||||||
|
|
||||||
|
const IdentityBlock.editable({
|
||||||
|
super.key,
|
||||||
|
this.title,
|
||||||
|
required TextEditingController nomController,
|
||||||
|
required TextEditingController prenomController,
|
||||||
|
required TextEditingController telephoneController,
|
||||||
|
required TextEditingController emailController,
|
||||||
|
required TextEditingController adresseController,
|
||||||
|
required TextEditingController codePostalController,
|
||||||
|
required TextEditingController villeController,
|
||||||
|
}) : _nom = null,
|
||||||
|
_prenom = null,
|
||||||
|
_telephone = null,
|
||||||
|
_email = null,
|
||||||
|
_adresse = null,
|
||||||
|
_codePostal = null,
|
||||||
|
_ville = null,
|
||||||
|
_nomCtrl = nomController,
|
||||||
|
_prenomCtrl = prenomController,
|
||||||
|
_telCtrl = telephoneController,
|
||||||
|
_emailCtrl = emailController,
|
||||||
|
_adresseCtrl = adresseController,
|
||||||
|
_cpCtrl = codePostalController,
|
||||||
|
_villeCtrl = villeController;
|
||||||
|
|
||||||
|
/// Lecture seule à partir de [IdentityValues].
|
||||||
|
factory IdentityBlock.readOnlyValues({
|
||||||
|
Key? key,
|
||||||
|
String? title,
|
||||||
|
required IdentityValues values,
|
||||||
|
}) {
|
||||||
|
return IdentityBlock.readOnly(
|
||||||
|
key: key,
|
||||||
|
title: title,
|
||||||
|
nom: values.nom,
|
||||||
|
prenom: values.prenom,
|
||||||
|
telephone: values.telephone,
|
||||||
|
email: values.email,
|
||||||
|
adresse: values.adresse,
|
||||||
|
codePostal: values.codePostal,
|
||||||
|
ville: values.ville,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lecture seule depuis un [AppUser] (validation AM, fiche AM, etc.).
|
||||||
|
factory IdentityBlock.readOnlyFromUser(
|
||||||
|
AppUser user, {
|
||||||
|
Key? key,
|
||||||
|
String? title,
|
||||||
|
String emptyLabel = 'Non défini',
|
||||||
|
}) {
|
||||||
|
return IdentityBlock.readOnlyValues(
|
||||||
|
key: key,
|
||||||
|
title: title,
|
||||||
|
values: IdentityValues.fromUser(user, emptyLabel: emptyLabel),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lecture seule depuis un [ParentDossier] (validation famille).
|
||||||
|
factory IdentityBlock.readOnlyFromParentDossier(
|
||||||
|
ParentDossier parent, {
|
||||||
|
Key? key,
|
||||||
|
String? title,
|
||||||
|
String emptyLabel = 'Non défini',
|
||||||
|
}) {
|
||||||
|
return IdentityBlock.readOnlyValues(
|
||||||
|
key: key,
|
||||||
|
title: title,
|
||||||
|
values: IdentityValues.fromParentDossier(
|
||||||
|
parent,
|
||||||
|
emptyLabel: emptyLabel,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _isEditable => _nomCtrl != null;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isEditable) {
|
||||||
|
return ValidationFormGrid(
|
||||||
|
title: title,
|
||||||
|
rowLayout: rowLayout,
|
||||||
|
rowFlex: rowFlex,
|
||||||
|
fields: [
|
||||||
|
ValidationLabeledField(
|
||||||
|
label: 'Nom',
|
||||||
|
field: ValidationEditableField(controller: _nomCtrl!),
|
||||||
|
),
|
||||||
|
ValidationLabeledField(
|
||||||
|
label: 'Prénom',
|
||||||
|
field: ValidationEditableField(controller: _prenomCtrl!),
|
||||||
|
),
|
||||||
|
ValidationLabeledField(
|
||||||
|
label: 'Téléphone',
|
||||||
|
field: ValidationEditableField(
|
||||||
|
controller: _telCtrl!,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
inputFormatters: frenchPhoneInputFormatters,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ValidationLabeledField(
|
||||||
|
label: 'Email',
|
||||||
|
field: ValidationEditableField(
|
||||||
|
controller: _emailCtrl!,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ValidationLabeledField(
|
||||||
|
label: 'Adresse (N° et Rue)',
|
||||||
|
field: ValidationEditableField(controller: _adresseCtrl!),
|
||||||
|
),
|
||||||
|
ValidationLabeledField(
|
||||||
|
label: 'Code postal',
|
||||||
|
field: ValidationEditableField(
|
||||||
|
controller: _cpCtrl!,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ValidationLabeledField(
|
||||||
|
label: 'Ville',
|
||||||
|
field: ValidationEditableField(controller: _villeCtrl!),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValidationFormGrid(
|
||||||
|
title: title,
|
||||||
|
rowLayout: rowLayout,
|
||||||
|
rowFlex: rowFlex,
|
||||||
|
fields: [
|
||||||
|
ValidationLabeledField(
|
||||||
|
label: 'Nom',
|
||||||
|
field: ValidationReadOnlyField(value: _nom!),
|
||||||
|
),
|
||||||
|
ValidationLabeledField(
|
||||||
|
label: 'Prénom',
|
||||||
|
field: ValidationReadOnlyField(value: _prenom!),
|
||||||
|
),
|
||||||
|
ValidationLabeledField(
|
||||||
|
label: 'Téléphone',
|
||||||
|
field: ValidationReadOnlyField(value: _telephone!),
|
||||||
|
),
|
||||||
|
ValidationLabeledField(
|
||||||
|
label: 'Email',
|
||||||
|
field: ValidationReadOnlyField(value: _email!),
|
||||||
|
),
|
||||||
|
ValidationLabeledField(
|
||||||
|
label: 'Adresse (N° et Rue)',
|
||||||
|
field: ValidationReadOnlyField(value: _adresse!),
|
||||||
|
),
|
||||||
|
ValidationLabeledField(
|
||||||
|
label: 'Code postal',
|
||||||
|
field: ValidationReadOnlyField(value: _codePostal!),
|
||||||
|
),
|
||||||
|
ValidationLabeledField(
|
||||||
|
label: 'Ville',
|
||||||
|
field: ValidationReadOnlyField(value: _ville!),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user