feat: alignement master sur develop (squash)
- Dossiers unifiés #119, pending-families enrichi, validation admin (wizards) - Front: modèles dossier_unifie / pending_family, NIR, auth - Migrations dossier_famille, scripts de test API - Résolution conflits: parents.*, docs tickets, auth_service, nir_utils Made-with: Cursor
This commit is contained in:
parent
060e610a75
commit
cde676c4f9
@ -19,7 +19,8 @@
|
|||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
"test:api-dossiers": "node scripts/test-api-dossiers.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.1.6",
|
"@nestjs/common": "^11.1.6",
|
||||||
|
|||||||
122
backend/scripts/test-api-dossiers.js
Normal file
122
backend/scripts/test-api-dossiers.js
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Test API GET /dossiers/:numeroDossier (dossier unifié AM ou famille).
|
||||||
|
*
|
||||||
|
* Prérequis : backend démarré (npm run start:dev dans backend/).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/test-api-dossiers.js
|
||||||
|
* NUMERO_DOSSIER=2026-000001 node scripts/test-api-dossiers.js
|
||||||
|
* BASE_URL=https://app.ptits-pas.fr/api/v1 TEST_EMAIL=xxx TEST_PASSWORD=yyy NUMERO_DOSSIER=2026-000001 node scripts/test-api-dossiers.js
|
||||||
|
*
|
||||||
|
* Sans TEST_EMAIL/TEST_PASSWORD : 401 sur les routes protégées.
|
||||||
|
* NUMERO_DOSSIER : optionnel ; si absent, utilise le premier numero_dossier de pending-families (avec token).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000/api/v1';
|
||||||
|
const TEST_EMAIL = process.env.TEST_EMAIL;
|
||||||
|
const TEST_PASSWORD = process.env.TEST_PASSWORD;
|
||||||
|
const NUMERO_DOSSIER = process.env.NUMERO_DOSSIER;
|
||||||
|
|
||||||
|
async function request(method, path, body = null, token = null) {
|
||||||
|
const url = path.startsWith('http') ? path : `${BASE_URL}${path}`;
|
||||||
|
const opts = {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
};
|
||||||
|
if (token) opts.headers.Authorization = `Bearer ${token}`;
|
||||||
|
if (body) opts.body = JSON.stringify(body);
|
||||||
|
const res = await fetch(url, opts);
|
||||||
|
const text = await res.text();
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : null;
|
||||||
|
} catch (_) {
|
||||||
|
data = text;
|
||||||
|
}
|
||||||
|
return { status: res.status, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Base URL:', BASE_URL);
|
||||||
|
console.log('Numéro dossier (env):', NUMERO_DOSSIER ?? '(sera déduit si token fourni)');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
let token = null;
|
||||||
|
if (TEST_EMAIL && TEST_PASSWORD) {
|
||||||
|
console.log('1. Login...');
|
||||||
|
const loginRes = await request('POST', '/auth/login', {
|
||||||
|
email: TEST_EMAIL,
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
});
|
||||||
|
if (loginRes.status !== 200 && loginRes.status !== 201) {
|
||||||
|
console.log(' Échec login:', loginRes.status, loginRes.data);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
token = loginRes.data?.access_token ?? loginRes.data?.accessToken ?? null;
|
||||||
|
if (!token) {
|
||||||
|
console.log(' Réponse login sans token:', JSON.stringify(loginRes.data, null, 2));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(' OK, token reçu.');
|
||||||
|
console.log('');
|
||||||
|
} else {
|
||||||
|
console.log('TEST_EMAIL / TEST_PASSWORD non définis : GET /dossiers/:numero nécessite un token (401 attendu).');
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
let numeroDossier = NUMERO_DOSSIER;
|
||||||
|
if (!numeroDossier && token) {
|
||||||
|
console.log('2. Récupération d\'un numéro de dossier (GET /parents/pending-families)...');
|
||||||
|
const pendingRes = await request('GET', '/parents/pending-families', null, token);
|
||||||
|
if (pendingRes.status === 200 && Array.isArray(pendingRes.data) && pendingRes.data.length > 0) {
|
||||||
|
numeroDossier = pendingRes.data[0].numero_dossier || null;
|
||||||
|
console.log(' Premier numero_dossier:', numeroDossier);
|
||||||
|
} else {
|
||||||
|
console.log(' Aucune famille en attente ou erreur. Utilisez NUMERO_DOSSIER=2026-000001');
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!numeroDossier) {
|
||||||
|
numeroDossier = '2026-000001';
|
||||||
|
console.log('2. Pas de numéro fourni, test avec numéro par défaut:', numeroDossier);
|
||||||
|
} else {
|
||||||
|
console.log('2. GET /dossiers/' + encodeURIComponent(numeroDossier));
|
||||||
|
}
|
||||||
|
|
||||||
|
const dossierRes = await request(
|
||||||
|
'GET',
|
||||||
|
'/dossiers/' + encodeURIComponent(numeroDossier),
|
||||||
|
null,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(' Status:', dossierRes.status);
|
||||||
|
if (dossierRes.status === 200 && dossierRes.data) {
|
||||||
|
const d = dossierRes.data;
|
||||||
|
console.log(' type:', d.type);
|
||||||
|
console.log(' dossier (clés):', d.dossier ? Object.keys(d.dossier) : '-');
|
||||||
|
if (d.dossier && Array.isArray(d.dossier.enfants)) {
|
||||||
|
console.log(' enfants:', d.dossier.enfants.length);
|
||||||
|
d.dossier.enfants.forEach((e, i) => {
|
||||||
|
console.log(
|
||||||
|
` [${i + 1}] id=${e.id} first_name=${e.first_name} last_name=${e.last_name} birth_date=${e.birth_date} gender=${e.gender} genre=${e.genre} status=${e.status}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
console.log('Réponse brute (dossier):');
|
||||||
|
console.log(JSON.stringify(d.dossier, null, 2));
|
||||||
|
} else {
|
||||||
|
console.log(' Réponse:', JSON.stringify(dossierRes.data, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('Fin du test.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Erreur:', err.message || err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
110
backend/scripts/test-pending-api.js
Normal file
110
backend/scripts/test-pending-api.js
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Test des endpoints "comptes en attente" (ticket #107).
|
||||||
|
*
|
||||||
|
* Prérequis : backend démarré (npm run start:dev dans backend/).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/test-pending-api.js
|
||||||
|
* TEST_EMAIL=xxx TEST_PASSWORD=yyy node scripts/test-pending-api.js
|
||||||
|
* BASE_URL=https://app.ptits-pas.fr/api/v1 TEST_EMAIL=xxx TEST_PASSWORD=yyy node scripts/test-pending-api.js
|
||||||
|
*
|
||||||
|
* Sans TEST_EMAIL/TEST_PASSWORD : les GET protégés renverront 401 (normal).
|
||||||
|
* Avec un compte gestionnaire ou admin : affiche les listes en attente.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000/api/v1';
|
||||||
|
const TEST_EMAIL = process.env.TEST_EMAIL;
|
||||||
|
const TEST_PASSWORD = process.env.TEST_PASSWORD;
|
||||||
|
|
||||||
|
async function request(method, path, body = null, token = null) {
|
||||||
|
const url = path.startsWith('http') ? path : `${BASE_URL}${path}`;
|
||||||
|
const opts = {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
};
|
||||||
|
if (token) opts.headers.Authorization = `Bearer ${token}`;
|
||||||
|
if (body) opts.body = JSON.stringify(body);
|
||||||
|
const res = await fetch(url, opts);
|
||||||
|
const text = await res.text();
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : null;
|
||||||
|
} catch (_) {
|
||||||
|
data = text;
|
||||||
|
}
|
||||||
|
return { status: res.status, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Base URL:', BASE_URL);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
let token = null;
|
||||||
|
if (TEST_EMAIL && TEST_PASSWORD) {
|
||||||
|
console.log('1. Login...');
|
||||||
|
const loginRes = await request('POST', '/auth/login', {
|
||||||
|
email: TEST_EMAIL,
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
});
|
||||||
|
if (loginRes.status !== 200 && loginRes.status !== 201) {
|
||||||
|
console.log(' Échec login:', loginRes.status, loginRes.data);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
token = loginRes.data?.access_token ?? loginRes.data?.accessToken ?? null;
|
||||||
|
if (!token) {
|
||||||
|
console.log(' Réponse login sans token:', JSON.stringify(loginRes.data, null, 2));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(' OK, token reçu.');
|
||||||
|
console.log('');
|
||||||
|
} else {
|
||||||
|
console.log('TEST_EMAIL / TEST_PASSWORD non définis : les appels protégés vont renvoyer 401.');
|
||||||
|
console.log('Exemple: TEST_EMAIL=admin@example.com TEST_PASSWORD=xxx node scripts/test-pending-api.js');
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('2. GET /users/pending?role=assistante_maternelle');
|
||||||
|
const pendingUsersRes = await request(
|
||||||
|
'GET',
|
||||||
|
'/users/pending?role=assistante_maternelle',
|
||||||
|
null,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
console.log(' Status:', pendingUsersRes.status);
|
||||||
|
if (pendingUsersRes.status === 200) {
|
||||||
|
const list = Array.isArray(pendingUsersRes.data) ? pendingUsersRes.data : [];
|
||||||
|
console.log(' Nombre d\'utilisateurs en attente (AM):', list.length);
|
||||||
|
list.forEach((u, i) => {
|
||||||
|
console.log(
|
||||||
|
` [${i + 1}] id=${u.id} email=${u.email} role=${u.role} statut=${u.statut} numero_dossier=${u.numero_dossier ?? '-'}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(' Réponse:', JSON.stringify(pendingUsersRes.data, null, 2));
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
console.log('3. GET /parents/pending-families');
|
||||||
|
const pendingFamiliesRes = await request('GET', '/parents/pending-families', null, token);
|
||||||
|
console.log(' Status:', pendingFamiliesRes.status);
|
||||||
|
if (pendingFamiliesRes.status === 200) {
|
||||||
|
const list = Array.isArray(pendingFamiliesRes.data) ? pendingFamiliesRes.data : [];
|
||||||
|
console.log(' Nombre de familles en attente:', list.length);
|
||||||
|
list.forEach((f, i) => {
|
||||||
|
console.log(
|
||||||
|
` [${i + 1}] libelle=${f.libelle} parentIds=${JSON.stringify(f.parentIds)} numero_dossier=${f.numero_dossier ?? '-'}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(' Réponse:', JSON.stringify(pendingFamiliesRes.data, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('Fin du test.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Erreur:', err.message || err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
97
backend/scripts/update-gitea-issue-119-dossiers.js
Normal file
97
backend/scripts/update-gitea-issue-119-dossiers.js
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Met à jour l'issue Gitea #119 : endpoint unifié GET /dossiers/:numeroDossier (option A)
|
||||||
|
* Usage: node backend/scripts/update-gitea-issue-119-dossiers.js
|
||||||
|
* Token : .gitea-token (racine), GITEA_TOKEN, ou docs/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/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é');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = `## Besoin
|
||||||
|
|
||||||
|
Un **seul** endpoint **GET par numéro de dossier** qui renvoie le dossier complet, **AM ou famille** selon le numéro. Clé unique = numéro de dossier (usage : modale de validation, consultation gestionnaire, reprise, etc.).
|
||||||
|
|
||||||
|
**Option A – Endpoint unifié**
|
||||||
|
|
||||||
|
- **Route** : \`GET /api/v1/dossiers/:numeroDossier\` (ou \`GET /dossiers/:numeroDossier\` selon préfixe API).
|
||||||
|
- Le backend détermine si le numéro appartient à une **AM** ou à une **famille** (ex. lookup \`users\` / \`parents\` / \`assistantes_maternelles\`).
|
||||||
|
- **Réponse** avec discriminent :
|
||||||
|
- \`{ type: 'family', dossier: { numero_dossier, parents, enfants, presentation } }\`
|
||||||
|
- \`{ type: 'am', dossier: { numero_dossier, user, ... } }\` (fiche AM complète, champs utiles sans secrets)
|
||||||
|
- **Rôles** : SUPER_ADMIN, ADMINISTRATEUR, GESTIONNAIRE.
|
||||||
|
- **Réponses** : 200 (dossier), 403, 404 (numéro inconnu).
|
||||||
|
|
||||||
|
Aucun filtre par statut : on renvoie le dossier s'il existe ; le front affiche Valider/Refuser selon le statut.
|
||||||
|
|
||||||
|
**Labels suggérés** : backend, api, dossiers, gestionnaire
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implémentation
|
||||||
|
|
||||||
|
- **Nouveau module ou route** : \`GET /dossiers/:numeroDossier\`.
|
||||||
|
- **Service** : trouver qui possède ce \`numero_dossier\` (famille → \`parents\`, AM → \`users\` + \`assistantes_maternelles\`). Appeler la logique existante dossier-famille ou construire le payload AM, puis retourner \`{ type, dossier }\`.
|
||||||
|
- **Réutiliser** : la logique actuelle \`GET /parents/dossier-famille/:numeroDossier\` peut être appelée en interne pour \`type: 'family'\` ; ajouter une branche \`type: 'am'\` avec un DTO « dossier AM complet ».
|
||||||
|
- DTO(s) : garder \`DossierFamilleCompletDto\` pour la famille ; ajouter un DTO pour le dossier AM (user sans secrets + infos AM). Réponse unifiée : \`{ type: 'am' | 'family', dossier: ... }\`.`;
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
title: 'Endpoint unifié GET /dossiers/:numeroDossier (AM ou famille)',
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
hostname: 'git.ptits-pas.fr',
|
||||||
|
path: '/api/v1/repos/jmartin/petitspas/issues/119',
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'token ' + token,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(payload),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(opts, (res) => {
|
||||||
|
let d = '';
|
||||||
|
res.on('data', (c) => (d += c));
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const o = JSON.parse(d);
|
||||||
|
if (o.number || o.id) {
|
||||||
|
console.log('Issue #119 mise à jour.');
|
||||||
|
console.log('URL:', o.html_url || 'https://git.ptits-pas.fr/jmartin/petitspas/issues/119');
|
||||||
|
} else {
|
||||||
|
console.error('Erreur API:', o.message || d);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Réponse:', d);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', (e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
req.write(payload);
|
||||||
|
req.end();
|
||||||
@ -17,6 +17,7 @@ import { EnfantsModule } from './routes/enfants/enfants.module';
|
|||||||
import { AppConfigModule } from './modules/config/config.module';
|
import { AppConfigModule } from './modules/config/config.module';
|
||||||
import { DocumentsLegauxModule } from './modules/documents-legaux';
|
import { DocumentsLegauxModule } from './modules/documents-legaux';
|
||||||
import { RelaisModule } from './routes/relais/relais.module';
|
import { RelaisModule } from './routes/relais/relais.module';
|
||||||
|
import { DossiersModule } from './routes/dossiers/dossiers.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -55,6 +56,7 @@ import { RelaisModule } from './routes/relais/relais.module';
|
|||||||
AppConfigModule,
|
AppConfigModule,
|
||||||
DocumentsLegauxModule,
|
DocumentsLegauxModule,
|
||||||
RelaisModule,
|
RelaisModule,
|
||||||
|
DossiersModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
65
backend/src/entities/dossier_famille.entity.ts
Normal file
65
backend/src/entities/dossier_famille.entity.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Parents } from './parents.entity';
|
||||||
|
import { Children } from './children.entity';
|
||||||
|
import { StatutDossierType } from './dossiers.entity';
|
||||||
|
|
||||||
|
/** Un dossier = une famille, N enfants (texte de motivation unique, liste d'enfants). */
|
||||||
|
@Entity('dossier_famille')
|
||||||
|
export class DossierFamille {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'numero_dossier', length: 20 })
|
||||||
|
numero_dossier: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Parents, { onDelete: 'CASCADE', nullable: false })
|
||||||
|
@JoinColumn({ name: 'id_parent', referencedColumnName: 'user_id' })
|
||||||
|
parent: Parents;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
presentation?: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: StatutDossierType,
|
||||||
|
enumName: 'statut_dossier_type',
|
||||||
|
default: StatutDossierType.ENVOYE,
|
||||||
|
name: 'statut',
|
||||||
|
})
|
||||||
|
statut: StatutDossierType;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
|
||||||
|
cree_le: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
|
||||||
|
modifie_le: Date;
|
||||||
|
|
||||||
|
@OneToMany(() => DossierFamilleEnfant, (dfe) => dfe.dossier_famille)
|
||||||
|
enfants: DossierFamilleEnfant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('dossier_famille_enfants')
|
||||||
|
export class DossierFamilleEnfant {
|
||||||
|
@Column({ name: 'id_dossier_famille', primary: true })
|
||||||
|
id_dossier_famille: string;
|
||||||
|
|
||||||
|
@Column({ name: 'id_enfant', primary: true })
|
||||||
|
id_enfant: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => DossierFamille, (df) => df.enfants, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'id_dossier_famille' })
|
||||||
|
dossier_famille: DossierFamille;
|
||||||
|
|
||||||
|
@ManyToOne(() => Children, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'id_enfant' })
|
||||||
|
enfant: Children;
|
||||||
|
}
|
||||||
@ -43,6 +43,8 @@ export class AuthService {
|
|||||||
private readonly usersRepo: Repository<Users>,
|
private readonly usersRepo: Repository<Users>,
|
||||||
@InjectRepository(Children)
|
@InjectRepository(Children)
|
||||||
private readonly childrenRepo: Repository<Children>,
|
private readonly childrenRepo: Repository<Children>,
|
||||||
|
@InjectRepository(AssistanteMaternelle)
|
||||||
|
private readonly assistantesMaternellesRepo: Repository<AssistanteMaternelle>,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -189,6 +191,11 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dto.co_parent_email) {
|
if (dto.co_parent_email) {
|
||||||
|
if (dto.email.trim().toLowerCase() === dto.co_parent_email.trim().toLowerCase()) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'L\'email du parent et du co-parent doivent être différents.',
|
||||||
|
);
|
||||||
|
}
|
||||||
const coParentExiste = await this.usersService.findByEmailOrNull(dto.co_parent_email);
|
const coParentExiste = await this.usersService.findByEmailOrNull(dto.co_parent_email);
|
||||||
if (coParentExiste) {
|
if (coParentExiste) {
|
||||||
throw new ConflictException('L\'email du co-parent est déjà utilisé');
|
throw new ConflictException('L\'email du co-parent est déjà utilisé');
|
||||||
@ -360,6 +367,27 @@ export class AuthService {
|
|||||||
throw new ConflictException('Un compte avec cet email existe déjà');
|
throw new ConflictException('Un compte avec cet email existe déjà');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nirDejaUtilise = await this.assistantesMaternellesRepo.findOne({
|
||||||
|
where: { nir: nirNormalized },
|
||||||
|
});
|
||||||
|
if (nirDejaUtilise) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Un compte assistante maternelle avec ce numéro NIR existe déjà.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeroAgrement = (dto.numero_agrement || '').trim();
|
||||||
|
if (numeroAgrement) {
|
||||||
|
const agrementDejaUtilise = await this.assistantesMaternellesRepo.findOne({
|
||||||
|
where: { approval_number: numeroAgrement },
|
||||||
|
});
|
||||||
|
if (agrementDejaUtilise) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Un compte assistante maternelle avec ce numéro d\'agrément existe déjà.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const joursExpirationToken = await this.appConfigService.get<number>(
|
const joursExpirationToken = await this.appConfigService.get<number>(
|
||||||
'password_reset_token_expiry_days',
|
'password_reset_token_expiry_days',
|
||||||
7,
|
7,
|
||||||
|
|||||||
26
backend/src/routes/dossiers/dossiers.controller.ts
Normal file
26
backend/src/routes/dossiers/dossiers.controller.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||||
|
import { RoleType } from 'src/entities/users.entity';
|
||||||
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
|
import { DossiersService } from './dossiers.service';
|
||||||
|
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
||||||
|
|
||||||
|
@ApiTags('Dossiers')
|
||||||
|
@Controller('dossiers')
|
||||||
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
|
export class DossiersController {
|
||||||
|
constructor(private readonly dossiersService: DossiersService) {}
|
||||||
|
|
||||||
|
@Get(':numeroDossier')
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
|
@ApiOperation({ summary: 'Dossier complet par numéro (AM ou famille) – Ticket #119' })
|
||||||
|
@ApiParam({ name: 'numeroDossier', description: 'Numéro de dossier (ex: 2026-000001)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Dossier famille ou AM', type: DossierUnifieDto })
|
||||||
|
@ApiResponse({ status: 404, description: 'Aucun dossier pour ce numéro' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||||
|
getDossier(@Param('numeroDossier') numeroDossier: string): Promise<DossierUnifieDto> {
|
||||||
|
return this.dossiersService.getDossierByNumero(numeroDossier);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,28 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
|
import { ParentsModule } from '../parents/parents.module';
|
||||||
|
import { DossiersController } from './dossiers.controller';
|
||||||
|
import { DossiersService } from './dossiers.service';
|
||||||
|
|
||||||
@Module({})
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Parents, AssistanteMaternelle]),
|
||||||
|
ParentsModule,
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
secret: config.get('jwt.accessSecret'),
|
||||||
|
signOptions: { expiresIn: config.get('jwt.accessExpiresIn') },
|
||||||
|
}),
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [DossiersController],
|
||||||
|
providers: [DossiersService],
|
||||||
|
exports: [DossiersService],
|
||||||
|
})
|
||||||
export class DossiersModule {}
|
export class DossiersModule {}
|
||||||
|
|||||||
81
backend/src/routes/dossiers/dossiers.service.ts
Normal file
81
backend/src/routes/dossiers/dossiers.service.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
|
import { ParentsService } from '../parents/parents.service';
|
||||||
|
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
||||||
|
import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoint unifié GET /dossiers/:numeroDossier – AM ou famille. Ticket #119.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DossiersService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Parents)
|
||||||
|
private readonly parentsRepository: Repository<Parents>,
|
||||||
|
@InjectRepository(AssistanteMaternelle)
|
||||||
|
private readonly amRepository: Repository<AssistanteMaternelle>,
|
||||||
|
private readonly parentsService: ParentsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getDossierByNumero(numeroDossier: string): Promise<DossierUnifieDto> {
|
||||||
|
const num = numeroDossier?.trim();
|
||||||
|
if (!num) {
|
||||||
|
throw new NotFoundException('Numéro de dossier requis.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Famille : un parent a ce numéro ?
|
||||||
|
const parentWithNum = await this.parentsRepository.findOne({
|
||||||
|
where: { numero_dossier: num },
|
||||||
|
select: ['user_id'],
|
||||||
|
});
|
||||||
|
if (parentWithNum) {
|
||||||
|
const dossier = await this.parentsService.getDossierFamilleByNumero(num);
|
||||||
|
return { type: 'family', dossier };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) AM : une assistante maternelle a ce numéro ?
|
||||||
|
const am = await this.amRepository.findOne({
|
||||||
|
where: { numero_dossier: num },
|
||||||
|
relations: ['user'],
|
||||||
|
});
|
||||||
|
if (am?.user) {
|
||||||
|
const dossier: DossierAmCompletDto = {
|
||||||
|
numero_dossier: num,
|
||||||
|
user: this.toDossierAmUserDto(am.user),
|
||||||
|
numero_agrement: am.approval_number,
|
||||||
|
nir: am.nir,
|
||||||
|
biographie: am.biography,
|
||||||
|
disponible: am.available,
|
||||||
|
ville_residence: am.residence_city,
|
||||||
|
date_agrement: am.agreement_date,
|
||||||
|
annees_experience: am.years_experience,
|
||||||
|
specialite: am.specialty,
|
||||||
|
nb_max_enfants: am.max_children,
|
||||||
|
place_disponible: am.places_available,
|
||||||
|
};
|
||||||
|
return { type: 'am', dossier };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new NotFoundException('Aucun dossier trouvé pour ce numéro.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDossierAmUserDto(user: { id: string; email: string; prenom?: string; nom?: string; telephone?: string; adresse?: string; ville?: string; code_postal?: string; profession?: string; date_naissance?: Date; photo_url?: string; statut: any }): DossierAmUserDto {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
prenom: user.prenom,
|
||||||
|
nom: user.nom,
|
||||||
|
telephone: user.telephone,
|
||||||
|
adresse: user.adresse,
|
||||||
|
ville: user.ville,
|
||||||
|
code_postal: user.code_postal,
|
||||||
|
profession: user.profession,
|
||||||
|
date_naissance: user.date_naissance,
|
||||||
|
photo_url: user.photo_url,
|
||||||
|
statut: user.statut,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
58
backend/src/routes/dossiers/dto/dossier-am-complet.dto.ts
Normal file
58
backend/src/routes/dossiers/dto/dossier-am-complet.dto.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
|
/** Utilisateur AM sans données sensibles (pour dossier AM complet). Ticket #119 */
|
||||||
|
export class DossierAmUserDto {
|
||||||
|
@ApiProperty()
|
||||||
|
id: string;
|
||||||
|
@ApiProperty()
|
||||||
|
email: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
prenom?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
nom?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
telephone?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
adresse?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
ville?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
code_postal?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
profession?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
date_naissance?: Date;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
photo_url?: string;
|
||||||
|
@ApiProperty({ enum: StatutUtilisateurType })
|
||||||
|
statut: StatutUtilisateurType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dossier AM complet (fiche AM sans secrets). Ticket #119 */
|
||||||
|
export class DossierAmCompletDto {
|
||||||
|
@ApiProperty({ example: '2026-000003', description: 'Numéro de dossier AM' })
|
||||||
|
numero_dossier: string;
|
||||||
|
@ApiProperty({ type: DossierAmUserDto, description: 'Utilisateur (sans mot de passe ni tokens)' })
|
||||||
|
user: DossierAmUserDto;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
numero_agrement?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
nir?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
biographie?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
disponible?: boolean;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
ville_residence?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
date_agrement?: Date;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
annees_experience?: number;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
specialite?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
nb_max_enfants?: number;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
place_disponible?: number;
|
||||||
|
}
|
||||||
14
backend/src/routes/dossiers/dto/dossier-unifie.dto.ts
Normal file
14
backend/src/routes/dossiers/dto/dossier-unifie.dto.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { DossierFamilleCompletDto } from '../../parents/dto/dossier-famille-complet.dto';
|
||||||
|
import { DossierAmCompletDto } from './dossier-am-complet.dto';
|
||||||
|
|
||||||
|
/** Réponse unifiée GET /dossiers/:numeroDossier – AM ou famille. Ticket #119 */
|
||||||
|
export class DossierUnifieDto {
|
||||||
|
@ApiProperty({ enum: ['family', 'am'], description: 'Type de dossier' })
|
||||||
|
type: 'family' | 'am';
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Dossier famille (si type=family) ou dossier AM (si type=am)',
|
||||||
|
})
|
||||||
|
dossier: DossierFamilleCompletDto | DossierAmCompletDto;
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||||
|
import { StatutEnfantType, GenreType } from 'src/entities/children.entity';
|
||||||
|
|
||||||
|
/** Parent dans le dossier famille (infos utilisateur + parent) */
|
||||||
|
export class DossierFamilleParentDto {
|
||||||
|
@ApiProperty()
|
||||||
|
user_id: string;
|
||||||
|
@ApiProperty()
|
||||||
|
email: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
prenom?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
nom?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
telephone?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
adresse?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
ville?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
code_postal?: string;
|
||||||
|
@ApiProperty({ enum: StatutUtilisateurType })
|
||||||
|
statut: StatutUtilisateurType;
|
||||||
|
@ApiProperty({ required: false, description: 'Id du co-parent si couple' })
|
||||||
|
co_parent_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enfant dans le dossier famille */
|
||||||
|
export class DossierFamilleEnfantDto {
|
||||||
|
@ApiProperty()
|
||||||
|
id: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
first_name?: string;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
last_name?: string;
|
||||||
|
@ApiProperty({ required: false, enum: GenreType })
|
||||||
|
genre?: GenreType;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
birth_date?: Date;
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
due_date?: Date;
|
||||||
|
@ApiProperty({ enum: StatutEnfantType })
|
||||||
|
status: StatutEnfantType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Réponse GET /parents/dossier-famille/:numeroDossier – dossier famille complet. Ticket #119 */
|
||||||
|
export class DossierFamilleCompletDto {
|
||||||
|
@ApiProperty({ example: '2026-000001', description: 'Numéro de dossier famille' })
|
||||||
|
numero_dossier: string;
|
||||||
|
@ApiProperty({ type: [DossierFamilleParentDto] })
|
||||||
|
parents: DossierFamilleParentDto[];
|
||||||
|
@ApiProperty({ type: [DossierFamilleEnfantDto], description: 'Enfants de la famille' })
|
||||||
|
enfants: DossierFamilleEnfantDto[];
|
||||||
|
@ApiProperty({ required: false, description: 'Texte de présentation / motivation (un seul par famille)' })
|
||||||
|
texte_motivation?: string;
|
||||||
|
}
|
||||||
@ -1,4 +1,21 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ParentPendingSummaryDto {
|
||||||
|
@ApiProperty({ description: 'UUID utilisateur' })
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true })
|
||||||
|
telephone?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true })
|
||||||
|
code_postal?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true })
|
||||||
|
ville?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export class PendingFamilyDto {
|
export class PendingFamilyDto {
|
||||||
@ApiProperty({ example: 'Famille Dupont', description: 'Libellé affiché pour la famille' })
|
@ApiProperty({ example: 'Famille Dupont', description: 'Libellé affiché pour la famille' })
|
||||||
@ -17,4 +34,30 @@ export class PendingFamilyDto {
|
|||||||
description: 'Numéro de dossier famille (format AAAA-NNNNNN)',
|
description: 'Numéro de dossier famille (format AAAA-NNNNNN)',
|
||||||
})
|
})
|
||||||
numero_dossier: string | null;
|
numero_dossier: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
nullable: true,
|
||||||
|
example: '2026-01-12T10:00:00.000Z',
|
||||||
|
description: 'Date de référence dossier soumis / en attente : MIN(cree_le) des parents en_attente du groupe (ISO 8601)',
|
||||||
|
})
|
||||||
|
date_soumission: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 3,
|
||||||
|
description: 'Nombre d’enfants distincts liés aux parents de la famille (enfants_parents)',
|
||||||
|
})
|
||||||
|
nombre_enfants: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: [String],
|
||||||
|
example: ['parent1@example.com', 'parent2@example.com'],
|
||||||
|
description: 'Emails des parents du groupe (ordre stable : nom, prénom)',
|
||||||
|
})
|
||||||
|
emails?: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: [ParentPendingSummaryDto],
|
||||||
|
description: 'Résumé des parents (ordre stable, aligné sur parentIds/emails)',
|
||||||
|
})
|
||||||
|
parents?: ParentPendingSummaryDto[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ 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';
|
||||||
|
|
||||||
@ApiTags('Parents')
|
@ApiTags('Parents')
|
||||||
@Controller('parents')
|
@Controller('parents')
|
||||||
@ -33,12 +34,28 @@ export class ParentsController {
|
|||||||
@Get('pending-families')
|
@Get('pending-families')
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
@ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' })
|
@ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' })
|
||||||
@ApiResponse({ status: 200, description: 'Liste des familles (libellé, parentIds, numero_dossier)', type: [PendingFamilyDto] })
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description:
|
||||||
|
'Liste des familles (libellé, parentIds, numero_dossier, date_soumission, nombre_enfants, emails, parents)',
|
||||||
|
type: [PendingFamilyDto],
|
||||||
|
})
|
||||||
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||||
getPendingFamilies(): Promise<PendingFamilyDto[]> {
|
getPendingFamilies(): Promise<PendingFamilyDto[]> {
|
||||||
return this.parentsService.getPendingFamilies();
|
return this.parentsService.getPendingFamilies();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('dossier-famille/:numeroDossier')
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
|
@ApiOperation({ summary: 'Dossier famille complet par numéro de dossier (Ticket #119)' })
|
||||||
|
@ApiParam({ name: 'numeroDossier', description: 'Numéro de dossier (ex: 2026-000001)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Dossier famille (numero_dossier, parents, enfants, presentation)', type: DossierFamilleCompletDto })
|
||||||
|
@ApiResponse({ status: 404, description: 'Aucun dossier pour ce numéro' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||||
|
getDossierFamille(@Param('numeroDossier') numeroDossier: string): Promise<DossierFamilleCompletDto> {
|
||||||
|
return this.parentsService.getDossierFamilleByNumero(numeroDossier);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':parentId/valider-dossier')
|
@Post(':parentId/valider-dossier')
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
@ApiOperation({ summary: 'Valider tout le dossier famille (les 2 parents en une fois)' })
|
@ApiOperation({ summary: 'Valider tout le dossier famille (les 2 parents en une fois)' })
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import { Module, forwardRef } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
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 { 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';
|
||||||
@ -8,8 +11,16 @@ import { UserModule } from '../user/user.module';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Parents, Users]),
|
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant]),
|
||||||
forwardRef(() => UserModule),
|
forwardRef(() => UserModule),
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
secret: config.get('jwt.accessSecret'),
|
||||||
|
signOptions: { expiresIn: config.get('jwt.accessExpiresIn') },
|
||||||
|
}),
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
controllers: [ParentsController],
|
controllers: [ParentsController],
|
||||||
providers: [ParentsService],
|
providers: [ParentsService],
|
||||||
|
|||||||
@ -5,12 +5,18 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { In, Repository } from 'typeorm';
|
||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
|
import { DossierFamille } from 'src/entities/dossier_famille.entity';
|
||||||
import { RoleType, Users } from 'src/entities/users.entity';
|
import { RoleType, Users } from 'src/entities/users.entity';
|
||||||
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 { PendingFamilyDto } from './dto/pending-family.dto';
|
import { PendingFamilyDto } from './dto/pending-family.dto';
|
||||||
|
import {
|
||||||
|
DossierFamilleCompletDto,
|
||||||
|
DossierFamilleParentDto,
|
||||||
|
DossierFamilleEnfantDto,
|
||||||
|
} from './dto/dossier-famille-complet.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ParentsService {
|
export class ParentsService {
|
||||||
@ -19,6 +25,8 @@ export class ParentsService {
|
|||||||
private readonly parentsRepository: Repository<Parents>,
|
private readonly parentsRepository: Repository<Parents>,
|
||||||
@InjectRepository(Users)
|
@InjectRepository(Users)
|
||||||
private readonly usersRepository: Repository<Users>,
|
private readonly usersRepository: Repository<Users>,
|
||||||
|
@InjectRepository(DossierFamille)
|
||||||
|
private readonly dossierFamilleRepository: Repository<DossierFamille>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// Création d’un parent
|
// Création d’un parent
|
||||||
@ -79,47 +87,214 @@ export class ParentsService {
|
|||||||
* Uniquement les parents dont l'utilisateur a statut = en_attente.
|
* Uniquement les parents dont l'utilisateur a statut = en_attente.
|
||||||
*/
|
*/
|
||||||
async getPendingFamilies(): Promise<PendingFamilyDto[]> {
|
async getPendingFamilies(): Promise<PendingFamilyDto[]> {
|
||||||
const raw = await this.parentsRepository.query(`
|
let raw: {
|
||||||
WITH RECURSIVE
|
libelle: string;
|
||||||
links AS (
|
parentIds: unknown;
|
||||||
SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
|
numero_dossier: string | null;
|
||||||
UNION ALL
|
date_soumission: Date | string | null;
|
||||||
SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
|
nombre_enfants: string | number | null;
|
||||||
UNION ALL
|
emails: unknown;
|
||||||
SELECT ep1.id_parent AS p1, ep2.id_parent AS p2
|
parents: unknown;
|
||||||
FROM enfants_parents ep1
|
}[];
|
||||||
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
try {
|
||||||
UNION ALL
|
raw = await this.parentsRepository.query(`
|
||||||
SELECT ep2.id_parent AS p1, ep1.id_parent AS p2
|
WITH RECURSIVE
|
||||||
FROM enfants_parents ep1
|
links AS (
|
||||||
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
|
||||||
),
|
UNION ALL
|
||||||
rec AS (
|
SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
|
||||||
SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents
|
UNION ALL
|
||||||
UNION
|
SELECT ep1.id_parent AS p1, ep2.id_parent AS p2
|
||||||
SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1
|
FROM enfants_parents ep1
|
||||||
),
|
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
||||||
family_rep AS (
|
UNION ALL
|
||||||
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
|
SELECT ep2.id_parent AS p1, ep1.id_parent AS p2
|
||||||
)
|
FROM enfants_parents ep1
|
||||||
SELECT
|
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
||||||
'Famille ' || string_agg(u.nom, ' - ' ORDER BY u.nom, u.prenom) AS libelle,
|
),
|
||||||
array_agg(DISTINCT p.id_utilisateur ORDER BY p.id_utilisateur) AS "parentIds",
|
rec AS (
|
||||||
(array_agg(p.numero_dossier))[1] AS numero_dossier
|
SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents
|
||||||
FROM family_rep fr
|
UNION
|
||||||
JOIN parents p ON p.id_utilisateur = fr.id
|
SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1
|
||||||
JOIN utilisateurs u ON u.id = p.id_utilisateur
|
),
|
||||||
WHERE u.role = 'parent' AND u.statut = 'en_attente'
|
family_rep AS (
|
||||||
GROUP BY fr.rep
|
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
|
||||||
ORDER BY libelle
|
)
|
||||||
`);
|
SELECT
|
||||||
return raw.map((r: { libelle: string; parentIds: unknown; numero_dossier: string | null }) => ({
|
'Famille ' || string_agg(u.nom, ' - ' ORDER BY u.nom, u.prenom) AS libelle,
|
||||||
libelle: r.libelle,
|
array_agg(p.id_utilisateur ORDER BY u.nom, u.prenom, u.id) AS "parentIds",
|
||||||
parentIds: Array.isArray(r.parentIds) ? r.parentIds.map(String) : [],
|
(array_agg(p.numero_dossier))[1] AS numero_dossier,
|
||||||
|
MIN(u.cree_le) AS date_soumission,
|
||||||
|
COALESCE((
|
||||||
|
SELECT COUNT(DISTINCT ep.id_enfant)::int
|
||||||
|
FROM enfants_parents ep
|
||||||
|
WHERE ep.id_parent IN (
|
||||||
|
SELECT frx.id FROM family_rep frx WHERE frx.rep = fr.rep
|
||||||
|
)
|
||||||
|
), 0) AS nombre_enfants,
|
||||||
|
array_agg(u.email ORDER BY u.nom, u.prenom, u.id) AS emails,
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'id', u.id::text,
|
||||||
|
'email', u.email,
|
||||||
|
'telephone', u.telephone,
|
||||||
|
'code_postal', u.code_postal,
|
||||||
|
'ville', u.ville
|
||||||
|
)
|
||||||
|
ORDER BY u.nom, u.prenom, u.id
|
||||||
|
) AS parents
|
||||||
|
FROM family_rep fr
|
||||||
|
JOIN parents p ON p.id_utilisateur = fr.id
|
||||||
|
JOIN utilisateurs u ON u.id = p.id_utilisateur
|
||||||
|
WHERE u.role = 'parent' AND u.statut = 'en_attente'
|
||||||
|
GROUP BY fr.rep
|
||||||
|
ORDER BY libelle
|
||||||
|
`);
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw.map((r) => ({
|
||||||
|
libelle: r.libelle ?? '',
|
||||||
|
parentIds: this.normalizeParentIds(r.parentIds),
|
||||||
numero_dossier: r.numero_dossier ?? null,
|
numero_dossier: r.numero_dossier ?? null,
|
||||||
|
date_soumission: this.toIsoDateTimeOrNull(r.date_soumission),
|
||||||
|
nombre_enfants: this.normalizeNombreEnfants(r.nombre_enfants),
|
||||||
|
emails: this.normalizeEmails(r.emails),
|
||||||
|
parents: this.normalizeParents(r.parents),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private toIsoDateTimeOrNull(value: Date | string | null | undefined): string | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value instanceof Date) return value.toISOString();
|
||||||
|
const d = new Date(value);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeNombreEnfants(v: string | number | null | undefined): number {
|
||||||
|
if (v == null) return 0;
|
||||||
|
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
|
||||||
|
return Number.isFinite(n) && n >= 0 ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeEmails(emails: unknown): string[] {
|
||||||
|
if (Array.isArray(emails)) return emails.map(String);
|
||||||
|
if (typeof emails === 'string') {
|
||||||
|
const s = emails.replace(/^\{|\}$/g, '').trim();
|
||||||
|
return s ? s.split(',').map((x) => x.trim()) : [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeParents(parents: unknown): { id: string; email: string; telephone: string | null; code_postal: string | null; ville: string | null }[] {
|
||||||
|
if (Array.isArray(parents)) {
|
||||||
|
return parents.map((p: any) => ({
|
||||||
|
id: String(p?.id ?? ''),
|
||||||
|
email: String(p?.email ?? ''),
|
||||||
|
telephone: p?.telephone != null ? String(p.telephone) : null,
|
||||||
|
code_postal: p?.code_postal != null ? String(p.code_postal) : null,
|
||||||
|
ville: p?.ville != null ? String(p.ville) : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (typeof parents === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(parents);
|
||||||
|
return this.normalizeParents(parsed);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convertit parentIds (array ou chaîne PG) en string[] pour éviter 500 si le driver renvoie une chaîne. */
|
||||||
|
private normalizeParentIds(parentIds: unknown): string[] {
|
||||||
|
if (Array.isArray(parentIds)) return parentIds.map(String);
|
||||||
|
if (typeof parentIds === 'string') {
|
||||||
|
const s = parentIds.replace(/^\{|\}$/g, '').trim();
|
||||||
|
return s ? s.split(',').map((x) => x.trim()) : [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dossier famille complet par numéro de dossier. Ticket #119.
|
||||||
|
* Rôles : admin, gestionnaire.
|
||||||
|
* @throws NotFoundException si aucun parent avec ce numéro de dossier
|
||||||
|
*/
|
||||||
|
async getDossierFamilleByNumero(numeroDossier: string): Promise<DossierFamilleCompletDto> {
|
||||||
|
const num = numeroDossier?.trim();
|
||||||
|
if (!num) {
|
||||||
|
throw new NotFoundException('Numéro de dossier requis.');
|
||||||
|
}
|
||||||
|
const firstParent = await this.parentsRepository.findOne({
|
||||||
|
where: { numero_dossier: num },
|
||||||
|
relations: ['user'],
|
||||||
|
});
|
||||||
|
if (!firstParent || !firstParent.user) {
|
||||||
|
throw new NotFoundException('Aucun dossier famille trouvé pour ce numéro.');
|
||||||
|
}
|
||||||
|
const familyUserIds = await this.getFamilyUserIds(firstParent.user_id);
|
||||||
|
const parents = await this.parentsRepository.find({
|
||||||
|
where: { user_id: In(familyUserIds) },
|
||||||
|
relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers', 'dossiers.child'],
|
||||||
|
});
|
||||||
|
const enfantsMap = new Map<string, DossierFamilleEnfantDto>();
|
||||||
|
let texte_motivation: string | undefined;
|
||||||
|
|
||||||
|
// Un dossier = une famille, un seul texte de motivation
|
||||||
|
const dossierFamille = await this.dossierFamilleRepository.findOne({
|
||||||
|
where: { numero_dossier: num },
|
||||||
|
relations: ['parent', 'enfants', 'enfants.enfant'],
|
||||||
|
});
|
||||||
|
if (dossierFamille?.presentation) {
|
||||||
|
texte_motivation = dossierFamille.presentation;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of parents) {
|
||||||
|
// Enfants via parentChildren
|
||||||
|
if (p.parentChildren) {
|
||||||
|
for (const pc of p.parentChildren) {
|
||||||
|
if (pc.child && !enfantsMap.has(pc.child.id)) {
|
||||||
|
enfantsMap.set(pc.child.id, {
|
||||||
|
id: pc.child.id,
|
||||||
|
first_name: pc.child.first_name,
|
||||||
|
last_name: pc.child.last_name,
|
||||||
|
genre: pc.child.gender,
|
||||||
|
birth_date: pc.child.birth_date,
|
||||||
|
due_date: pc.child.due_date,
|
||||||
|
status: pc.child.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback : anciens dossiers (un texte, on prend le premier)
|
||||||
|
if (texte_motivation == null && p.dossiers?.length) {
|
||||||
|
texte_motivation = p.dossiers[0].presentation ?? undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentsDto: DossierFamilleParentDto[] = parents.map((p) => ({
|
||||||
|
user_id: p.user_id,
|
||||||
|
email: p.user.email,
|
||||||
|
prenom: p.user.prenom,
|
||||||
|
nom: p.user.nom,
|
||||||
|
telephone: p.user.telephone,
|
||||||
|
adresse: p.user.adresse,
|
||||||
|
ville: p.user.ville,
|
||||||
|
code_postal: p.user.code_postal,
|
||||||
|
statut: p.user.statut,
|
||||||
|
co_parent_id: p.co_parent?.id,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
numero_dossier: num,
|
||||||
|
parents: parentsDto,
|
||||||
|
enfants: Array.from(enfantsMap.values()),
|
||||||
|
texte_motivation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retourne les user_id de tous les parents de la même famille (co_parent ou enfants partagés).
|
* Retourne les user_id de tous les parents de la même famille (co_parent ou enfants partagés).
|
||||||
* @throws NotFoundException si parentId n'est pas un parent
|
* @throws NotFoundException si parentId n'est pas un parent
|
||||||
|
|||||||
27
database/migrations/2026_dossier_famille.sql
Normal file
27
database/migrations/2026_dossier_famille.sql
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
-- Un dossier = une famille, N enfants. Ticket #119 évolution.
|
||||||
|
-- Table: un enregistrement par famille (lien via numero_dossier / id_parent).
|
||||||
|
CREATE TABLE IF NOT EXISTS dossier_famille (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
numero_dossier VARCHAR(20) NOT NULL,
|
||||||
|
id_parent UUID NOT NULL REFERENCES parents(id_utilisateur) ON DELETE CASCADE,
|
||||||
|
presentation TEXT,
|
||||||
|
type_contrat VARCHAR(50),
|
||||||
|
repas BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
budget NUMERIC(10,2),
|
||||||
|
planning_souhaite JSONB,
|
||||||
|
statut statut_dossier_type NOT NULL DEFAULT 'envoye',
|
||||||
|
cree_le TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
modifie_le TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dossier_famille_numero ON dossier_famille(numero_dossier);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dossier_famille_id_parent ON dossier_famille(id_parent);
|
||||||
|
|
||||||
|
-- Enfants concernés par ce dossier famille (N par dossier).
|
||||||
|
CREATE TABLE IF NOT EXISTS dossier_famille_enfants (
|
||||||
|
id_dossier_famille UUID NOT NULL REFERENCES dossier_famille(id) ON DELETE CASCADE,
|
||||||
|
id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (id_dossier_famille, id_enfant)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dossier_famille_enfants_enfant ON dossier_famille_enfants(id_enfant);
|
||||||
5
database/migrations/2026_dossier_famille_simplifier.sql
Normal file
5
database/migrations/2026_dossier_famille_simplifier.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
-- Dossier famille = inscription uniquement, pas les données de dossier de garde (repas, type_contrat, budget, etc.)
|
||||||
|
ALTER TABLE dossier_famille DROP COLUMN IF EXISTS repas;
|
||||||
|
ALTER TABLE dossier_famille DROP COLUMN IF EXISTS type_contrat;
|
||||||
|
ALTER TABLE dossier_famille DROP COLUMN IF EXISTS budget;
|
||||||
|
ALTER TABLE dossier_famille DROP COLUMN IF EXISTS planning_souhaite;
|
||||||
@ -33,13 +33,13 @@
|
|||||||
| 20 | [Backend] API Inscription Parent (étape 3 - Enfants) | ✅ Terminé |
|
| 20 | [Backend] API Inscription Parent (étape 3 - Enfants) | ✅ Terminé |
|
||||||
| 21 | [Backend] API Inscription Parent (étape 4-6 - Finalisation) | ✅ Terminé |
|
| 21 | [Backend] API Inscription Parent (étape 4-6 - Finalisation) | ✅ Terminé |
|
||||||
| 24 | [Backend] API Création mot de passe | Ouvert |
|
| 24 | [Backend] API Création mot de passe | Ouvert |
|
||||||
| 25 | [Backend] API Liste comptes en attente | Ouvert |
|
| 25 | [Backend] API Liste comptes en attente | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||||
| 26 | [Backend] API Validation/Refus comptes | Ouvert |
|
| 26 | [Backend] API Validation/Refus comptes | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||||
| 27 | [Backend] Service Email - Installation Nodemailer | Ouvert |
|
| 27 | [Backend] Service Email - Installation Nodemailer | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||||
| 28 | [Backend] Templates Email - Validation | Ouvert |
|
| 28 | [Backend] Templates Email - Validation | Ouvert |
|
||||||
| 29 | [Backend] Templates Email - Refus | Ouvert |
|
| 29 | [Backend] Templates Email - Refus | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||||
| 30 | [Backend] Connexion - Vérification statut | Ouvert |
|
| 30 | [Backend] Connexion - Vérification statut | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||||
| 31 | [Backend] Changement MDP obligatoire première connexion | Ouvert |
|
| 31 | [Backend] Changement MDP obligatoire première connexion | ✅ Terminé |
|
||||||
| 32 | [Backend] Service Documents Légaux | Ouvert |
|
| 32 | [Backend] Service Documents Légaux | Ouvert |
|
||||||
| 33 | [Backend] API Documents Légaux | Ouvert |
|
| 33 | [Backend] API Documents Légaux | Ouvert |
|
||||||
| 34 | [Backend] Traçabilité acceptations documents | Ouvert |
|
| 34 | [Backend] Traçabilité acceptations documents | Ouvert |
|
||||||
@ -53,8 +53,8 @@
|
|||||||
| 42 | [Frontend] Inscription AM - Finalisation | ✅ Terminé |
|
| 42 | [Frontend] Inscription AM - Finalisation | ✅ Terminé |
|
||||||
| 43 | [Frontend] Écran Création Mot de Passe | Ouvert |
|
| 43 | [Frontend] Écran Création Mot de Passe | Ouvert |
|
||||||
| 44 | [Frontend] Dashboard Gestionnaire - Structure | ✅ Terminé |
|
| 44 | [Frontend] Dashboard Gestionnaire - Structure | ✅ Terminé |
|
||||||
| 45 | [Frontend] Dashboard Gestionnaire - Liste Parents | Ouvert |
|
| 45 | [Frontend] Dashboard Gestionnaire - Liste Parents | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||||
| 46 | [Frontend] Dashboard Gestionnaire - Liste AM | Ouvert |
|
| 46 | [Frontend] Dashboard Gestionnaire - Liste AM | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||||
| 47 | [Frontend] Écran Changement MDP Obligatoire | Ouvert |
|
| 47 | [Frontend] Écran Changement MDP Obligatoire | Ouvert |
|
||||||
| 48 | [Frontend] Gestion Erreurs & Messages | Ouvert |
|
| 48 | [Frontend] Gestion Erreurs & Messages | Ouvert |
|
||||||
| 49 | [Frontend] Écran Gestion Documents Légaux (Admin) | Ouvert |
|
| 49 | [Frontend] Écran Gestion Documents Légaux (Admin) | Ouvert |
|
||||||
@ -103,7 +103,7 @@
|
|||||||
|
|
||||||
*Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues*
|
*Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues*
|
||||||
|
|
||||||
*Tickets #103 à #117 = périmètre « Validation des nouveaux comptes par le gestionnaire » (plan + sync via `backend/scripts/sync-gitea-validation-tickets.js`).*
|
*Tickets #103 à #117 = périmètre « Validation des nouveaux comptes par le gestionnaire » (voir plan de spec).*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -641,17 +641,18 @@ Modifier l'endpoint de connexion pour bloquer les comptes en attente ou suspendu
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Ticket #31 : [Backend] Changement MDP obligatoire première connexion
|
### Ticket #31 : [Backend] Changement MDP obligatoire première connexion ✅
|
||||||
**Estimation** : 2h
|
**Estimation** : 2h
|
||||||
**Labels** : `backend`, `p2`, `auth`, `security`
|
**Labels** : `backend`, `p2`, `auth`, `security`
|
||||||
|
**Statut** : ✅ TERMINÉ
|
||||||
|
|
||||||
**Description** :
|
**Description** :
|
||||||
Implémenter le changement de mot de passe obligatoire pour les gestionnaires/admins à la première connexion.
|
Implémenter le changement de mot de passe obligatoire pour les gestionnaires/admins à la première connexion.
|
||||||
|
|
||||||
**Tâches** :
|
**Tâches** :
|
||||||
- [ ] Endpoint `POST /api/v1/auth/change-password-required`
|
- [x] Endpoint `POST /api/v1/auth/change-password-required`
|
||||||
- [ ] Vérification flag `changement_mdp_obligatoire`
|
- [x] Vérification flag `changement_mdp_obligatoire`
|
||||||
- [ ] Mise à jour flag après changement
|
- [x] Mise à jour flag après changement
|
||||||
- [ ] Tests unitaires
|
- [ ] Tests unitaires
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
210
frontend/lib/models/dossier_unifie.dart
Normal file
210
frontend/lib/models/dossier_unifie.dart
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
|
||||||
|
/// Réponse unifiée GET /dossiers/:numeroDossier. Ticket #119, #107.
|
||||||
|
class DossierUnifie {
|
||||||
|
final String type; // 'am' | 'family'
|
||||||
|
final dynamic dossier; // DossierAM | DossierFamille
|
||||||
|
|
||||||
|
DossierUnifie({required this.type, required this.dossier});
|
||||||
|
|
||||||
|
bool get isAm => type == 'am';
|
||||||
|
bool get isFamily => type == 'family';
|
||||||
|
|
||||||
|
DossierAM get asAm => dossier as DossierAM;
|
||||||
|
DossierFamille get asFamily => dossier as DossierFamille;
|
||||||
|
|
||||||
|
factory DossierUnifie.fromJson(Map<String, dynamic> json) {
|
||||||
|
final t = json['type'];
|
||||||
|
final raw = t is String ? t : 'family';
|
||||||
|
final typeStr = raw.toLowerCase();
|
||||||
|
final d = json['dossier'];
|
||||||
|
if (d == null || d is! Map<String, dynamic>) {
|
||||||
|
throw FormatException('dossier manquant ou invalide');
|
||||||
|
}
|
||||||
|
final dossierMap = Map<String, dynamic>.from(d as Map);
|
||||||
|
// Seul `am` (casse tolérée) charge le dossier AM ; le reste = famille (API : type "family").
|
||||||
|
final isAm = typeStr == 'am';
|
||||||
|
final dossier = isAm ? DossierAM.fromJson(dossierMap) : DossierFamille.fromJson(dossierMap);
|
||||||
|
return DossierUnifie(type: isAm ? 'am' : 'family', dossier: dossier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dossier AM (type: 'am'). Champs alignés API.
|
||||||
|
class DossierAM {
|
||||||
|
final String? numeroDossier;
|
||||||
|
final AppUser user;
|
||||||
|
final String? numeroAgrement;
|
||||||
|
final String? nir;
|
||||||
|
final String? presentation;
|
||||||
|
final String? dateAgrement;
|
||||||
|
final int? nbMaxEnfants;
|
||||||
|
final int? placesDisponibles;
|
||||||
|
final String? villeResidence;
|
||||||
|
|
||||||
|
DossierAM({
|
||||||
|
this.numeroDossier,
|
||||||
|
required this.user,
|
||||||
|
this.numeroAgrement,
|
||||||
|
this.nir,
|
||||||
|
this.presentation,
|
||||||
|
this.dateAgrement,
|
||||||
|
this.nbMaxEnfants,
|
||||||
|
this.placesDisponibles,
|
||||||
|
this.villeResidence,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory DossierAM.fromJson(Map<String, dynamic> json) {
|
||||||
|
final userJson = json['user'];
|
||||||
|
final userMap = userJson is Map<String, dynamic>
|
||||||
|
? userJson
|
||||||
|
: <String, dynamic>{};
|
||||||
|
final nbMax = json['nb_max_enfants'];
|
||||||
|
final places = json['place_disponible'];
|
||||||
|
return DossierAM(
|
||||||
|
numeroDossier: json['numero_dossier']?.toString(),
|
||||||
|
user: AppUser.fromJson(Map<String, dynamic>.from(userMap)),
|
||||||
|
numeroAgrement: json['numero_agrement']?.toString(),
|
||||||
|
nir: json['nir']?.toString(),
|
||||||
|
presentation: (json['biographie'] ?? json['presentation'])?.toString(),
|
||||||
|
dateAgrement: json['date_agrement']?.toString(),
|
||||||
|
nbMaxEnfants: nbMax is int ? nbMax : (nbMax is num ? nbMax.toInt() : null),
|
||||||
|
placesDisponibles: places is int ? places : (places is num ? places.toInt() : null),
|
||||||
|
villeResidence: json['ville_residence']?.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dossier famille (type: 'family'). Champs alignés API.
|
||||||
|
class DossierFamille {
|
||||||
|
final String? numeroDossier;
|
||||||
|
final List<ParentDossier> parents;
|
||||||
|
final List<EnfantDossier> enfants;
|
||||||
|
final String? presentation;
|
||||||
|
|
||||||
|
DossierFamille({
|
||||||
|
this.numeroDossier,
|
||||||
|
required this.parents,
|
||||||
|
required this.enfants,
|
||||||
|
this.presentation,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory DossierFamille.fromJson(Map<String, dynamic> json) {
|
||||||
|
final parentsRaw = json['parents'];
|
||||||
|
final parentsList = parentsRaw is List
|
||||||
|
? (parentsRaw)
|
||||||
|
.where((e) => e is Map)
|
||||||
|
.map((e) => ParentDossier.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||||
|
.toList()
|
||||||
|
: <ParentDossier>[];
|
||||||
|
final enfantsRaw = json['enfants'];
|
||||||
|
final enfantsList = enfantsRaw is List
|
||||||
|
? (enfantsRaw)
|
||||||
|
.where((e) => e is Map)
|
||||||
|
.map((e) => EnfantDossier.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||||
|
.toList()
|
||||||
|
: <EnfantDossier>[];
|
||||||
|
return DossierFamille(
|
||||||
|
numeroDossier: json['numero_dossier']?.toString(),
|
||||||
|
parents: parentsList,
|
||||||
|
enfants: enfantsList,
|
||||||
|
presentation: (json['texte_motivation'] ?? json['presentation'])?.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isEnAttente =>
|
||||||
|
parents.any((p) => p.statut == 'en_attente');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parent dans un dossier famille (champs user exposés).
|
||||||
|
class ParentDossier {
|
||||||
|
final String id;
|
||||||
|
final String email;
|
||||||
|
final String? prenom;
|
||||||
|
final String? nom;
|
||||||
|
final String? telephone;
|
||||||
|
final String? adresse;
|
||||||
|
final String? ville;
|
||||||
|
final String? codePostal;
|
||||||
|
final String? dateNaissance;
|
||||||
|
final String? genre;
|
||||||
|
final String? situationFamiliale;
|
||||||
|
final String? creeLe;
|
||||||
|
final String? statut;
|
||||||
|
|
||||||
|
ParentDossier({
|
||||||
|
required this.id,
|
||||||
|
required this.email,
|
||||||
|
this.prenom,
|
||||||
|
this.nom,
|
||||||
|
this.telephone,
|
||||||
|
this.adresse,
|
||||||
|
this.ville,
|
||||||
|
this.codePostal,
|
||||||
|
this.dateNaissance,
|
||||||
|
this.genre,
|
||||||
|
this.situationFamiliale,
|
||||||
|
this.creeLe,
|
||||||
|
this.statut,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get fullName => '${prenom ?? ''} ${nom ?? ''}'.trim();
|
||||||
|
|
||||||
|
factory ParentDossier.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ParentDossier(
|
||||||
|
id: json['id']?.toString() ?? '',
|
||||||
|
email: json['email']?.toString() ?? '',
|
||||||
|
prenom: json['prenom']?.toString(),
|
||||||
|
nom: json['nom']?.toString(),
|
||||||
|
telephone: json['telephone']?.toString(),
|
||||||
|
adresse: json['adresse']?.toString(),
|
||||||
|
ville: json['ville']?.toString(),
|
||||||
|
codePostal: json['code_postal']?.toString(),
|
||||||
|
dateNaissance: json['date_naissance']?.toString(),
|
||||||
|
genre: json['genre']?.toString(),
|
||||||
|
situationFamiliale: json['situation_familiale']?.toString(),
|
||||||
|
creeLe: json['cree_le']?.toString(),
|
||||||
|
statut: json['statut']?.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enfant dans un dossier famille.
|
||||||
|
class EnfantDossier {
|
||||||
|
final String id;
|
||||||
|
final String? firstName;
|
||||||
|
final String? lastName;
|
||||||
|
final String? birthDate;
|
||||||
|
final String? gender;
|
||||||
|
final String? status;
|
||||||
|
final String? dueDate;
|
||||||
|
final String? photoUrl;
|
||||||
|
final bool consentPhoto;
|
||||||
|
|
||||||
|
EnfantDossier({
|
||||||
|
required this.id,
|
||||||
|
this.firstName,
|
||||||
|
this.lastName,
|
||||||
|
this.birthDate,
|
||||||
|
this.gender,
|
||||||
|
this.status,
|
||||||
|
this.dueDate,
|
||||||
|
this.photoUrl,
|
||||||
|
this.consentPhoto = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get fullName => '${firstName ?? ''} ${lastName ?? ''}'.trim();
|
||||||
|
|
||||||
|
factory EnfantDossier.fromJson(Map<String, dynamic> json) {
|
||||||
|
return EnfantDossier(
|
||||||
|
id: json['id']?.toString() ?? '',
|
||||||
|
firstName: (json['first_name'] ?? json['prenom'])?.toString(),
|
||||||
|
lastName: (json['last_name'] ?? json['nom'])?.toString(),
|
||||||
|
birthDate: json['birth_date']?.toString(),
|
||||||
|
gender: (json['gender'] ?? json['genre'])?.toString(),
|
||||||
|
status: json['status']?.toString(),
|
||||||
|
dueDate: json['due_date']?.toString(),
|
||||||
|
photoUrl: json['photo_url']?.toString(),
|
||||||
|
consentPhoto: json['consent_photo'] == true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
232
frontend/lib/models/pending_family.dart
Normal file
232
frontend/lib/models/pending_family.dart
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
/// Résumé affichable pour un parent (liste pending-families).
|
||||||
|
class PendingParentLine {
|
||||||
|
final String? email;
|
||||||
|
final String? telephone;
|
||||||
|
final String? codePostal;
|
||||||
|
final String? ville;
|
||||||
|
|
||||||
|
const PendingParentLine({
|
||||||
|
this.email,
|
||||||
|
this.telephone,
|
||||||
|
this.codePostal,
|
||||||
|
this.ville,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isEmpty {
|
||||||
|
final e = email?.trim();
|
||||||
|
final t = telephone?.trim();
|
||||||
|
final loc = _locationTrimmed;
|
||||||
|
return (e == null || e.isEmpty) &&
|
||||||
|
(t == null || t.isEmpty) &&
|
||||||
|
(loc == null || loc.isEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? get _locationTrimmed {
|
||||||
|
final cp = codePostal?.trim();
|
||||||
|
final v = ville?.trim();
|
||||||
|
final loc = [if (cp != null && cp.isNotEmpty) cp, if (v != null && v.isNotEmpty) v]
|
||||||
|
.join(' ')
|
||||||
|
.trim();
|
||||||
|
return loc.isEmpty ? null : loc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Famille en attente de validation (GET /parents/pending-families). Ticket #107.
|
||||||
|
///
|
||||||
|
/// Contrat API : `libelle`, `parentIds`, `numero_dossier`, `date_soumission`,
|
||||||
|
/// `nombre_enfants`, `emails`, éventuellement `parents` / tableaux parallèles.
|
||||||
|
class PendingFamily {
|
||||||
|
final String libelle;
|
||||||
|
final List<String> parentIds;
|
||||||
|
final String? numeroDossier;
|
||||||
|
|
||||||
|
/// Date affichée : `date_soumission` (ISO), sinon alias `cree_le` / etc.
|
||||||
|
final DateTime? dateSoumission;
|
||||||
|
|
||||||
|
/// Emails seuls (API) — le sous-titre utilise de préférence [parentLines].
|
||||||
|
final List<String> emails;
|
||||||
|
|
||||||
|
/// Une entrée par parent : email, tél., CP ville (si fournis par l’API).
|
||||||
|
final List<PendingParentLine> parentLines;
|
||||||
|
|
||||||
|
final int nombreEnfants;
|
||||||
|
|
||||||
|
/// Compat : premier email.
|
||||||
|
final String? email;
|
||||||
|
|
||||||
|
PendingFamily({
|
||||||
|
required this.libelle,
|
||||||
|
required this.parentIds,
|
||||||
|
this.numeroDossier,
|
||||||
|
this.dateSoumission,
|
||||||
|
this.emails = const [],
|
||||||
|
this.parentLines = const [],
|
||||||
|
this.nombreEnfants = 0,
|
||||||
|
this.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
static DateTime? _parseDate(dynamic v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v is DateTime) return v;
|
||||||
|
if (v is String) {
|
||||||
|
return DateTime.tryParse(v);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> _parseStringList(dynamic raw) {
|
||||||
|
if (raw is! List) return [];
|
||||||
|
return raw
|
||||||
|
.map((e) => e?.toString().trim() ?? '')
|
||||||
|
.where((s) => s.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<PendingParentLine> _parseParentLinesFromMaps(dynamic raw) {
|
||||||
|
if (raw is! List) return [];
|
||||||
|
final out = <PendingParentLine>[];
|
||||||
|
for (final e in raw) {
|
||||||
|
if (e is! Map) continue;
|
||||||
|
final m = Map<String, dynamic>.from(e);
|
||||||
|
final em = m['email']?.toString().trim();
|
||||||
|
final tel = m['telephone']?.toString().trim();
|
||||||
|
final cp = (m['code_postal'] ?? m['codePostal'])?.toString().trim();
|
||||||
|
final ville = m['ville']?.toString().trim();
|
||||||
|
out.add(PendingParentLine(
|
||||||
|
email: em != null && em.isNotEmpty ? em : null,
|
||||||
|
telephone: tel != null && tel.isNotEmpty ? tel : null,
|
||||||
|
codePostal: cp != null && cp.isNotEmpty ? cp : null,
|
||||||
|
ville: ville != null && ville.isNotEmpty ? ville : null,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construit [parentLines] : objets `parents`, tableaux parallèles, ou emails + champs racine.
|
||||||
|
static List<PendingParentLine> _buildParentLines(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
List<String> emails,
|
||||||
|
) {
|
||||||
|
final fromMaps = _parseParentLinesFromMaps(
|
||||||
|
json['parents'] ?? json['resume_parents'] ?? json['parent_summaries'] ?? json['parent_lines'],
|
||||||
|
);
|
||||||
|
if (fromMaps.isNotEmpty) {
|
||||||
|
return fromMaps;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String>? parallel(dynamic keySingular, dynamic keyPlural) {
|
||||||
|
final pl = json[keyPlural];
|
||||||
|
if (pl is List) return _parseStringList(pl);
|
||||||
|
final s = json[keySingular];
|
||||||
|
if (s is String && s.trim().isNotEmpty) return [s.trim()];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final tels = parallel('telephone', 'telephones');
|
||||||
|
final cps = parallel('code_postal', 'code_postaux') ?? parallel('codePostal', 'codes_postaux');
|
||||||
|
final villes = parallel('ville', 'villes');
|
||||||
|
|
||||||
|
if (emails.isNotEmpty &&
|
||||||
|
((tels?.isNotEmpty ?? false) ||
|
||||||
|
(cps?.isNotEmpty ?? false) ||
|
||||||
|
(villes?.isNotEmpty ?? false))) {
|
||||||
|
return List.generate(emails.length, (i) {
|
||||||
|
return PendingParentLine(
|
||||||
|
email: emails[i],
|
||||||
|
telephone: tels != null && i < tels.length ? tels[i] : null,
|
||||||
|
codePostal: cps != null && i < cps.length ? cps[i] : null,
|
||||||
|
ville: villes != null && i < villes.length ? villes[i] : null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
final rootTel = json['telephone']?.toString().trim();
|
||||||
|
final rootTelOk = rootTel != null && rootTel.isNotEmpty ? rootTel : null;
|
||||||
|
final rootCp = (json['code_postal'] ?? json['codePostal'])?.toString().trim();
|
||||||
|
final rootCpOk = rootCp != null && rootCp.isNotEmpty ? rootCp : null;
|
||||||
|
final rootVille = json['ville']?.toString().trim();
|
||||||
|
final rootVilleOk = rootVille != null && rootVille.isNotEmpty ? rootVille : null;
|
||||||
|
|
||||||
|
if (emails.isNotEmpty) {
|
||||||
|
return List.generate(emails.length, (i) {
|
||||||
|
return PendingParentLine(
|
||||||
|
email: emails[i],
|
||||||
|
telephone: i == 0 ? rootTelOk : null,
|
||||||
|
codePostal: i == 0 ? rootCpOk : null,
|
||||||
|
ville: i == 0 ? rootVilleOk : null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rootTelOk != null || rootCpOk != null || rootVilleOk != null) {
|
||||||
|
final em = json['email']?.toString().trim();
|
||||||
|
return [
|
||||||
|
PendingParentLine(
|
||||||
|
email: em != null && em.isNotEmpty ? em : null,
|
||||||
|
telephone: rootTelOk,
|
||||||
|
codePostal: rootCpOk,
|
||||||
|
ville: rootVilleOk,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
factory PendingFamily.fromJson(Map<String, dynamic> json) {
|
||||||
|
final parentIdsRaw = json['parentIds'] ?? json['parent_ids'];
|
||||||
|
final List<String> ids = parentIdsRaw is List
|
||||||
|
? (parentIdsRaw).map((e) => e?.toString() ?? '').where((s) => s.isNotEmpty).toList()
|
||||||
|
: [];
|
||||||
|
final libelle = json['libelle'];
|
||||||
|
final libelleStr = libelle is String ? libelle : (libelle?.toString() ?? 'Famille');
|
||||||
|
final nd = json['numero_dossier'] ?? json['numeroDossier'];
|
||||||
|
final numeroDossier = (nd is String && nd.isNotEmpty) ? nd : null;
|
||||||
|
|
||||||
|
DateTime? dateSoumission = _parseDate(
|
||||||
|
json['date_soumission'] ?? json['dateSoumission'],
|
||||||
|
);
|
||||||
|
dateSoumission ??= _parseDate(
|
||||||
|
json['cree_le'] ?? json['creeLe'] ?? json['date_inscription'],
|
||||||
|
);
|
||||||
|
|
||||||
|
List<String> emails = _parseStringList(json['emails']);
|
||||||
|
if (emails.isEmpty) {
|
||||||
|
final emailRaw = json['email'];
|
||||||
|
if (emailRaw is String && emailRaw.trim().isNotEmpty) {
|
||||||
|
emails = [emailRaw.trim()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final String? emailCompat = emails.isNotEmpty
|
||||||
|
? emails.first
|
||||||
|
: (json['email'] is String && (json['email'] as String).trim().isNotEmpty
|
||||||
|
? (json['email'] as String).trim()
|
||||||
|
: null);
|
||||||
|
|
||||||
|
final nbRaw = json['nombre_enfants'] ?? json['nombreEnfants'];
|
||||||
|
int nombreEnfants = 0;
|
||||||
|
if (nbRaw is int) {
|
||||||
|
nombreEnfants = nbRaw;
|
||||||
|
} else if (nbRaw is num) {
|
||||||
|
nombreEnfants = nbRaw.toInt();
|
||||||
|
} else if (nbRaw != null) {
|
||||||
|
nombreEnfants = int.tryParse(nbRaw.toString()) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parentLines = _buildParentLines(json, emails);
|
||||||
|
if (parentLines.isEmpty && emails.isNotEmpty) {
|
||||||
|
parentLines = emails.map((e) => PendingParentLine(email: e)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return PendingFamily(
|
||||||
|
libelle: libelleStr.isEmpty ? 'Famille' : libelleStr,
|
||||||
|
parentIds: ids,
|
||||||
|
numeroDossier: numeroDossier,
|
||||||
|
dateSoumission: dateSoumission,
|
||||||
|
emails: emails,
|
||||||
|
parentLines: parentLines,
|
||||||
|
nombreEnfants: nombreEnfants,
|
||||||
|
email: emailCompat,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,7 @@ class AppUser {
|
|||||||
final String? codePostal;
|
final String? codePostal;
|
||||||
final String? relaisId;
|
final String? relaisId;
|
||||||
final String? relaisNom;
|
final String? relaisNom;
|
||||||
|
final String? numeroDossier;
|
||||||
|
|
||||||
AppUser({
|
AppUser({
|
||||||
required this.id,
|
required this.id,
|
||||||
@ -33,40 +34,50 @@ class AppUser {
|
|||||||
this.codePostal,
|
this.codePostal,
|
||||||
this.relaisId,
|
this.relaisId,
|
||||||
this.relaisNom,
|
this.relaisNom,
|
||||||
|
this.numeroDossier,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
static String _str(dynamic v) {
|
||||||
|
if (v == null) return '';
|
||||||
|
if (v is String) return v;
|
||||||
|
return v.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTime _date(dynamic v) {
|
||||||
|
if (v == null) return DateTime.now();
|
||||||
|
if (v is DateTime) return v;
|
||||||
|
try {
|
||||||
|
return DateTime.parse(v.toString());
|
||||||
|
} catch (_) {
|
||||||
|
return DateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
factory AppUser.fromJson(Map<String, dynamic> json) {
|
factory AppUser.fromJson(Map<String, dynamic> json) {
|
||||||
final relaisJson = json['relais'];
|
final relaisJson = json['relais'];
|
||||||
final relaisMap =
|
final relaisMap =
|
||||||
relaisJson is Map<String, dynamic> ? relaisJson : <String, dynamic>{};
|
relaisJson is Map<String, dynamic> ? relaisJson : <String, dynamic>{};
|
||||||
|
|
||||||
return AppUser(
|
return AppUser(
|
||||||
id: (json['id'] as String?) ?? '',
|
id: _str(json['id']),
|
||||||
email: (json['email'] as String?) ?? '',
|
email: _str(json['email']),
|
||||||
role: (json['role'] as String?) ?? '',
|
role: _str(json['role']),
|
||||||
createdAt: json['cree_le'] != null
|
createdAt: _date(json['cree_le'] ?? json['createdAt']),
|
||||||
? DateTime.parse(json['cree_le'] as String)
|
updatedAt: _date(json['modifie_le'] ?? json['updatedAt']),
|
||||||
: (json['createdAt'] != null
|
|
||||||
? DateTime.parse(json['createdAt'] as String)
|
|
||||||
: DateTime.now()),
|
|
||||||
updatedAt: json['modifie_le'] != null
|
|
||||||
? DateTime.parse(json['modifie_le'] as String)
|
|
||||||
: (json['updatedAt'] != null
|
|
||||||
? DateTime.parse(json['updatedAt'] as String)
|
|
||||||
: DateTime.now()),
|
|
||||||
changementMdpObligatoire:
|
changementMdpObligatoire:
|
||||||
json['changement_mdp_obligatoire'] as bool? ?? false,
|
json['changement_mdp_obligatoire'] == true,
|
||||||
nom: json['nom'] as String?,
|
nom: json['nom'] is String ? json['nom'] as String : null,
|
||||||
prenom: json['prenom'] as String?,
|
prenom: json['prenom'] is String ? json['prenom'] as String : null,
|
||||||
statut: json['statut'] as String?,
|
statut: json['statut'] is String ? json['statut'] as String : null,
|
||||||
telephone: json['telephone'] as String?,
|
telephone: json['telephone'] is String ? json['telephone'] as String : null,
|
||||||
photoUrl: json['photo_url'] as String?,
|
photoUrl: json['photo_url'] is String ? json['photo_url'] as String : null,
|
||||||
adresse: json['adresse'] as String?,
|
adresse: json['adresse'] is String ? json['adresse'] as String : null,
|
||||||
ville: json['ville'] as String?,
|
ville: json['ville'] is String ? json['ville'] as String : null,
|
||||||
codePostal: json['code_postal'] as String?,
|
codePostal: json['code_postal'] is String ? json['code_postal'] as String : null,
|
||||||
relaisId: (json['relaisId'] ?? json['relais_id'] ?? relaisMap['id'])
|
relaisId: (json['relaisId'] ?? json['relais_id'] ?? relaisMap['id'])
|
||||||
?.toString(),
|
?.toString(),
|
||||||
relaisNom: relaisMap['nom']?.toString(),
|
relaisNom: relaisMap['nom']?.toString(),
|
||||||
|
numeroDossier: json['numero_dossier'] is String ? json['numero_dossier'] as String : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,6 +99,7 @@ class AppUser {
|
|||||||
'code_postal': codePostal,
|
'code_postal': codePostal,
|
||||||
'relais_id': relaisId,
|
'relais_id': relaisId,
|
||||||
'relais_nom': relaisNom,
|
'relais_nom': relaisNom,
|
||||||
|
'numero_dossier': numeroDossier,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:p_tits_pas/utils/phone_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';
|
||||||
|
|
||||||
@ -34,7 +36,7 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
|
|||||||
_nomController.text = user.nom ?? '';
|
_nomController.text = user.nom ?? '';
|
||||||
_prenomController.text = user.prenom ?? '';
|
_prenomController.text = user.prenom ?? '';
|
||||||
_emailController.text = user.email;
|
_emailController.text = user.email;
|
||||||
_telephoneController.text = user.telephone ?? '';
|
_telephoneController.text = formatPhoneForDisplay(user.telephone ?? '');
|
||||||
// En édition, on ne préremplit jamais le mot de passe.
|
// En édition, on ne préremplit jamais le mot de passe.
|
||||||
_passwordController.clear();
|
_passwordController.clear();
|
||||||
}
|
}
|
||||||
@ -91,7 +93,7 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
|
|||||||
nom: _nomController.text.trim(),
|
nom: _nomController.text.trim(),
|
||||||
prenom: _prenomController.text.trim(),
|
prenom: _prenomController.text.trim(),
|
||||||
email: _emailController.text.trim(),
|
email: _emailController.text.trim(),
|
||||||
telephone: _telephoneController.text.trim(),
|
telephone: normalizePhone(_telephoneController.text),
|
||||||
password: _passwordController.text.trim().isEmpty
|
password: _passwordController.text.trim().isEmpty
|
||||||
? null
|
? null
|
||||||
: _passwordController.text,
|
: _passwordController.text,
|
||||||
@ -102,7 +104,7 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
|
|||||||
prenom: _prenomController.text.trim(),
|
prenom: _prenomController.text.trim(),
|
||||||
email: _emailController.text.trim(),
|
email: _emailController.text.trim(),
|
||||||
password: _passwordController.text,
|
password: _passwordController.text,
|
||||||
telephone: _telephoneController.text.trim(),
|
telephone: normalizePhone(_telephoneController.text),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@ -347,8 +349,13 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
|
|||||||
return TextFormField(
|
return TextFormField(
|
||||||
controller: _telephoneController,
|
controller: _telephoneController,
|
||||||
keyboardType: TextInputType.phone,
|
keyboardType: TextInputType.phone,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(10),
|
||||||
|
FrenchPhoneNumberFormatter(),
|
||||||
|
],
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Téléphone',
|
labelText: 'Téléphone (ex: 06 12 34 56 78)',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
validator: (v) => _required(v, 'Téléphone'),
|
validator: (v) => _required(v, 'Téléphone'),
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:p_tits_pas/models/relais_model.dart';
|
import 'package:p_tits_pas/models/relais_model.dart';
|
||||||
|
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
import 'package:p_tits_pas/services/relais_service.dart';
|
import 'package:p_tits_pas/services/relais_service.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
@ -40,12 +41,12 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
String? _selectedRelaisId;
|
String? _selectedRelaisId;
|
||||||
bool get _isEditMode => widget.initialUser != null;
|
bool get _isEditMode => widget.initialUser != null;
|
||||||
bool get _isSuperAdminTarget =>
|
bool get _isSuperAdminTarget =>
|
||||||
widget.initialUser?.role.toLowerCase() == 'super_admin';
|
(widget.initialUser?.role ?? '').toLowerCase() == 'super_admin';
|
||||||
bool get _isLockedAdminIdentity =>
|
bool get _isLockedAdminIdentity =>
|
||||||
_isEditMode && widget.adminMode && _isSuperAdminTarget;
|
_isEditMode && widget.adminMode && _isSuperAdminTarget;
|
||||||
String get _targetRoleKey {
|
String get _targetRoleKey {
|
||||||
if (widget.initialUser != null) {
|
if (widget.initialUser != null) {
|
||||||
return widget.initialUser!.role.toLowerCase();
|
return (widget.initialUser!.role).toLowerCase();
|
||||||
}
|
}
|
||||||
return widget.adminMode ? 'administrateur' : 'gestionnaire';
|
return widget.adminMode ? 'administrateur' : 'gestionnaire';
|
||||||
}
|
}
|
||||||
@ -92,7 +93,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
_nomController.text = user.nom ?? '';
|
_nomController.text = user.nom ?? '';
|
||||||
_prenomController.text = user.prenom ?? '';
|
_prenomController.text = user.prenom ?? '';
|
||||||
_emailController.text = user.email;
|
_emailController.text = user.email;
|
||||||
_telephoneController.text = _formatPhoneForDisplay(user.telephone ?? '');
|
_telephoneController.text = formatPhoneForDisplay(user.telephone ?? '');
|
||||||
// En édition, on ne préremplit jamais le mot de passe.
|
// En édition, on ne préremplit jamais le mot de passe.
|
||||||
_passwordController.clear();
|
_passwordController.clear();
|
||||||
final initialRelaisId = user.relaisId?.trim();
|
final initialRelaisId = user.relaisId?.trim();
|
||||||
@ -185,7 +186,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
}
|
}
|
||||||
final base = _required(value, 'Téléphone');
|
final base = _required(value, 'Téléphone');
|
||||||
if (base != null) return base;
|
if (base != null) return base;
|
||||||
final digits = _normalizePhone(value!);
|
final digits = normalizePhone(value!);
|
||||||
if (digits.length != 10) {
|
if (digits.length != 10) {
|
||||||
return 'Le téléphone doit contenir 10 chiffres';
|
return 'Le téléphone doit contenir 10 chiffres';
|
||||||
}
|
}
|
||||||
@ -195,22 +196,6 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
String _normalizePhone(String raw) {
|
|
||||||
return raw.replaceAll(RegExp(r'\D'), '');
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatPhoneForDisplay(String raw) {
|
|
||||||
final normalized = _normalizePhone(raw);
|
|
||||||
final digits =
|
|
||||||
normalized.length > 10 ? normalized.substring(0, 10) : normalized;
|
|
||||||
final buffer = StringBuffer();
|
|
||||||
for (var i = 0; i < digits.length; i++) {
|
|
||||||
if (i > 0 && i.isEven) buffer.write(' ');
|
|
||||||
buffer.write(digits[i]);
|
|
||||||
}
|
|
||||||
return buffer.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
String _toTitleCase(String raw) {
|
String _toTitleCase(String raw) {
|
||||||
final trimmed = raw.trim();
|
final trimmed = raw.trim();
|
||||||
if (trimmed.isEmpty) return trimmed;
|
if (trimmed.isEmpty) return trimmed;
|
||||||
@ -251,7 +236,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
try {
|
try {
|
||||||
final normalizedNom = _toTitleCase(_nomController.text);
|
final normalizedNom = _toTitleCase(_nomController.text);
|
||||||
final normalizedPrenom = _toTitleCase(_prenomController.text);
|
final normalizedPrenom = _toTitleCase(_prenomController.text);
|
||||||
final normalizedPhone = _normalizePhone(_telephoneController.text);
|
final normalizedPhone = normalizePhone(_telephoneController.text);
|
||||||
final passwordProvided = _passwordController.text.trim().isNotEmpty;
|
final passwordProvided = _passwordController.text.trim().isNotEmpty;
|
||||||
|
|
||||||
if (_isEditMode) {
|
if (_isEditMode) {
|
||||||
@ -264,7 +249,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
prenom: _isLockedAdminIdentity ? lockedPrenom : normalizedPrenom,
|
prenom: _isLockedAdminIdentity ? lockedPrenom : normalizedPrenom,
|
||||||
email: _emailController.text.trim(),
|
email: _emailController.text.trim(),
|
||||||
telephone: normalizedPhone.isEmpty
|
telephone: normalizedPhone.isEmpty
|
||||||
? _normalizePhone(widget.initialUser!.telephone ?? '')
|
? normalizePhone(widget.initialUser!.telephone ?? '')
|
||||||
: normalizedPhone,
|
: normalizedPhone,
|
||||||
password: passwordProvided ? _passwordController.text : null,
|
password: passwordProvided ? _passwordController.text : null,
|
||||||
);
|
);
|
||||||
@ -273,7 +258,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
final initialNom = _toTitleCase(currentUser.nom ?? '');
|
final initialNom = _toTitleCase(currentUser.nom ?? '');
|
||||||
final initialPrenom = _toTitleCase(currentUser.prenom ?? '');
|
final initialPrenom = _toTitleCase(currentUser.prenom ?? '');
|
||||||
final initialEmail = currentUser.email.trim();
|
final initialEmail = currentUser.email.trim();
|
||||||
final initialPhone = _normalizePhone(currentUser.telephone ?? '');
|
final initialPhone = normalizePhone(currentUser.telephone ?? '');
|
||||||
|
|
||||||
final onlyRelaisChanged =
|
final onlyRelaisChanged =
|
||||||
normalizedNom == initialNom &&
|
normalizedNom == initialNom &&
|
||||||
@ -306,7 +291,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
prenom: normalizedPrenom,
|
prenom: normalizedPrenom,
|
||||||
email: _emailController.text.trim(),
|
email: _emailController.text.trim(),
|
||||||
password: _passwordController.text,
|
password: _passwordController.text,
|
||||||
telephone: _normalizePhone(_telephoneController.text),
|
telephone: normalizePhone(_telephoneController.text),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await UserService.createGestionnaire(
|
await UserService.createGestionnaire(
|
||||||
@ -314,7 +299,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
prenom: normalizedPrenom,
|
prenom: normalizedPrenom,
|
||||||
email: _emailController.text.trim(),
|
email: _emailController.text.trim(),
|
||||||
password: _passwordController.text,
|
password: _passwordController.text,
|
||||||
telephone: _normalizePhone(_telephoneController.text),
|
telephone: normalizePhone(_telephoneController.text),
|
||||||
relaisId: _selectedRelaisId,
|
relaisId: _selectedRelaisId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -608,7 +593,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
: [
|
: [
|
||||||
FilteringTextInputFormatter.digitsOnly,
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
LengthLimitingTextInputFormatter(10),
|
LengthLimitingTextInputFormatter(10),
|
||||||
_FrenchPhoneNumberFormatter(),
|
FrenchPhoneNumberFormatter(),
|
||||||
],
|
],
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Téléphone (ex: 06 12 34 56 78)',
|
labelText: 'Téléphone (ex: 06 12 34 56 78)',
|
||||||
@ -662,27 +647,3 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FrenchPhoneNumberFormatter extends TextInputFormatter {
|
|
||||||
const _FrenchPhoneNumberFormatter();
|
|
||||||
|
|
||||||
@override
|
|
||||||
TextEditingValue formatEditUpdate(
|
|
||||||
TextEditingValue oldValue,
|
|
||||||
TextEditingValue newValue,
|
|
||||||
) {
|
|
||||||
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
|
||||||
final normalized = digits.length > 10 ? digits.substring(0, 10) : digits;
|
|
||||||
final buffer = StringBuffer();
|
|
||||||
for (var i = 0; i < normalized.length; i++) {
|
|
||||||
if (i > 0 && i.isEven) buffer.write(' ');
|
|
||||||
buffer.write(normalized[i]);
|
|
||||||
}
|
|
||||||
final formatted = buffer.toString();
|
|
||||||
|
|
||||||
return TextEditingValue(
|
|
||||||
text: formatted,
|
|
||||||
selection: TextSelection.collapsed(offset: formatted.length),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -17,7 +17,7 @@ class LoginScreen extends StatefulWidget {
|
|||||||
State<LoginScreen> createState() => _LoginPageState();
|
State<LoginScreen> createState() => _LoginPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
class _LoginPageState extends State<LoginScreen> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _emailController = TextEditingController();
|
final _emailController = TextEditingController();
|
||||||
final _passwordController = TextEditingController();
|
final _passwordController = TextEditingController();
|
||||||
@ -27,31 +27,30 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
static const double _mobileBreakpoint = 900.0;
|
static const double _mobileBreakpoint = 900.0;
|
||||||
|
|
||||||
|
/// Une seule fois : évite de relancer `_getImageDimensions()` à chaque rebuild (sinon sur web,
|
||||||
|
/// tout événement de layout / métriques recréait un Future et pouvait provoquer des erreurs DWDS
|
||||||
|
/// ou des comportements bizarres pendant la saisie dans les champs).
|
||||||
|
late final Future<ImageDimensions> _desktopRiverLogoDimensionsFuture;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
_desktopRiverLogoDimensionsFuture = _getImageDimensions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
|
||||||
_emailController.dispose();
|
_emailController.dispose();
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void didChangeMetrics() {
|
|
||||||
super.didChangeMetrics();
|
|
||||||
if (mounted) setState(() {});
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _validateEmail(String? value) {
|
String? _validateEmail(String? value) {
|
||||||
if (value == null || value.isEmpty) {
|
final v = value ?? '';
|
||||||
|
if (v.isEmpty) {
|
||||||
return 'Veuillez entrer votre email';
|
return 'Veuillez entrer votre email';
|
||||||
}
|
}
|
||||||
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
|
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(v)) {
|
||||||
return 'Veuillez entrer un email valide';
|
return 'Veuillez entrer un email valide';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@ -116,7 +115,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
TextInput.finishAutofillContext(shouldSave: true);
|
TextInput.finishAutofillContext(shouldSave: true);
|
||||||
|
|
||||||
// Rediriger selon le rôle de l'utilisateur
|
// Rediriger selon le rôle de l'utilisateur
|
||||||
_redirectUserByRole(user.role);
|
_redirectUserByRole(user.role.isEmpty ? null : user.role);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
@ -126,9 +125,10 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Redirige l'utilisateur selon son rôle (GoRouter : context.go).
|
/// Redirige l'utilisateur selon son rôle (GoRouter : context.go).
|
||||||
void _redirectUserByRole(String role) {
|
void _redirectUserByRole(String? role) {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
switch (role.toLowerCase()) {
|
final r = (role ?? '').toLowerCase();
|
||||||
|
switch (r) {
|
||||||
case 'super_admin':
|
case 'super_admin':
|
||||||
case 'administrateur':
|
case 'administrateur':
|
||||||
context.go('/admin-dashboard');
|
context.go('/admin-dashboard');
|
||||||
@ -162,8 +162,8 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final w = constraints.maxWidth;
|
final w = constraints.maxWidth;
|
||||||
final h = constraints.maxHeight;
|
final h = constraints.maxHeight;
|
||||||
return FutureBuilder(
|
return FutureBuilder<ImageDimensions>(
|
||||||
future: _getImageDimensions(),
|
future: _desktopRiverLogoDimensionsFuture,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (!snapshot.hasData) {
|
if (!snapshot.hasData) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
@ -203,7 +203,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
bottom: 0,
|
bottom: 0,
|
||||||
width: w * 0.6, // 60% de la largeur de l'écran
|
width: w * 0.6, // 60% de la largeur de l'écran
|
||||||
height: h * 0.5, // 50% de la hauteur de l'écran
|
height: h * 0.5, // 50% de la hauteur de l'écran
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(w * 0.02), // 2% de padding
|
padding: EdgeInsets.all(w * 0.02), // 2% de padding
|
||||||
child: AutofillGroup(
|
child: AutofillGroup(
|
||||||
child: Form(
|
child: Form(
|
||||||
@ -240,7 +240,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
hintText: 'Votre mot de passe',
|
hintText: 'Votre mot de passe',
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
autofillHints: const [
|
autofillHints: const [
|
||||||
AutofillHints.password
|
AutofillHints.password,
|
||||||
],
|
],
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
onFieldSubmitted:
|
onFieldSubmitted:
|
||||||
|
|||||||
@ -19,6 +19,7 @@ class ApiConfig {
|
|||||||
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 relais = '/relais';
|
static const String relais = '/relais';
|
||||||
|
static const String dossiers = '/dossiers';
|
||||||
|
|
||||||
// Configuration (admin)
|
// Configuration (admin)
|
||||||
static const String configuration = '/configuration';
|
static const String configuration = '/configuration';
|
||||||
|
|||||||
@ -193,7 +193,10 @@ class AuthService {
|
|||||||
const fallback = 'Erreur lors de l\'inscription';
|
const fallback = 'Erreur lors de l\'inscription';
|
||||||
if (decoded == null || decoded is! Map) return '$fallback ($statusCode)';
|
if (decoded == null || decoded is! Map) return '$fallback ($statusCode)';
|
||||||
final msg = decoded['message'];
|
final msg = decoded['message'];
|
||||||
if (msg == null) return decoded['error'] as String? ?? '$fallback ($statusCode)';
|
if (msg == null) {
|
||||||
|
final err = decoded['error'];
|
||||||
|
return (err is String ? err : err?.toString()) ?? '$fallback ($statusCode)';
|
||||||
|
}
|
||||||
if (msg is String) return msg;
|
if (msg is String) return msg;
|
||||||
if (msg is List) return msg.map((e) => e.toString()).join('. ').trim();
|
if (msg is List) return msg.map((e) => e.toString()).join('. ').trim();
|
||||||
if (msg is Map && msg['message'] != null) return msg['message'].toString();
|
if (msg is Map && msg['message'] != null) return msg['message'].toString();
|
||||||
|
|||||||
@ -3,6 +3,8 @@ 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/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/dossier_unifie.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/services/api/tokenService.dart';
|
import 'package:p_tits_pas/services/api/tokenService.dart';
|
||||||
|
|
||||||
@ -20,6 +22,145 @@ class UserService {
|
|||||||
return v.toString();
|
return v.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String _errMessage(dynamic err) {
|
||||||
|
if (err == null) return 'Erreur inconnue';
|
||||||
|
if (err is String) return err;
|
||||||
|
if (err is Map) {
|
||||||
|
final m = err['message'];
|
||||||
|
if (m is String) return m;
|
||||||
|
if (m is Map && m['message'] is String) return m['message'] as String;
|
||||||
|
if (m != null) return _toStr(m) ?? 'Erreur inconnue';
|
||||||
|
}
|
||||||
|
return _toStr(err) ?? 'Erreur inconnue';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Utilisateurs en attente de validation (GET /users/pending). Ticket #107.
|
||||||
|
static Future<List<AppUser>> getPendingUsers({String? role}) async {
|
||||||
|
final query = role != null ? '?role=$role' : '';
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/pending$query'),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
try {
|
||||||
|
final err = jsonDecode(response.body);
|
||||||
|
throw Exception(_errMessage(err is Map ? err['message'] : err));
|
||||||
|
} catch (e) {
|
||||||
|
if (e is Exception) rethrow;
|
||||||
|
throw Exception('Erreur chargement comptes en attente (${response.statusCode})');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
final data = decoded is List ? decoded as List<dynamic> : <dynamic>[];
|
||||||
|
return data
|
||||||
|
.where((e) => e is Map<String, dynamic>)
|
||||||
|
.map((e) => AppUser.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Réponse invalide (comptes en attente): ${e is Exception ? e.toString() : "format inattendu"}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Familles en attente (une entrée par famille). GET /parents/pending-families. Ticket #107.
|
||||||
|
static Future<List<PendingFamily>> getPendingFamilies() async {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/pending-families'),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
try {
|
||||||
|
final err = jsonDecode(response.body);
|
||||||
|
throw Exception(_errMessage(err is Map ? err['message'] : err));
|
||||||
|
} catch (e) {
|
||||||
|
if (e is Exception) rethrow;
|
||||||
|
throw Exception('Erreur chargement familles en attente (${response.statusCode})');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
final data = decoded is List ? decoded as List<dynamic> : <dynamic>[];
|
||||||
|
return data
|
||||||
|
.where((e) => e is Map)
|
||||||
|
.map((e) => PendingFamily.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Réponse invalide (familles en attente): ${e is Exception ? e.toString() : "format inattendu"}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dossier unifié par numéro (AM ou famille). GET /dossiers/:numeroDossier. Ticket #119, #107.
|
||||||
|
static Future<DossierUnifie> getDossier(String numeroDossier) async {
|
||||||
|
final encoded = Uri.encodeComponent(numeroDossier);
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
if (response.statusCode == 404) {
|
||||||
|
throw Exception('Aucun dossier avec ce numéro.');
|
||||||
|
}
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
try {
|
||||||
|
final err = jsonDecode(response.body);
|
||||||
|
throw Exception(_errMessage(err is Map ? err['message'] : err));
|
||||||
|
} catch (e) {
|
||||||
|
if (e is Exception) rethrow;
|
||||||
|
throw Exception('Erreur chargement dossier (${response.statusCode})');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
throw FormatException('Réponse invalide');
|
||||||
|
}
|
||||||
|
return DossierUnifie.fromJson(Map<String, dynamic>.from(decoded));
|
||||||
|
} catch (e) {
|
||||||
|
if (e is FormatException) rethrow;
|
||||||
|
throw Exception('Réponse invalide (dossier): ${e is Exception ? e.toString() : "format inattendu"}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Valider un utilisateur (AM). PATCH /users/:id/valider. Ticket #108.
|
||||||
|
static Future<AppUser> validateUser(String userId, {String? comment}) async {
|
||||||
|
final response = await http.patch(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId/valider'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(comment != null ? {'comment': comment} : {}),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
try {
|
||||||
|
final err = jsonDecode(response.body);
|
||||||
|
throw Exception(_errMessage(err is Map ? err['message'] : err));
|
||||||
|
} catch (e) {
|
||||||
|
if (e is Exception) rethrow;
|
||||||
|
throw Exception('Erreur validation (${response.statusCode})');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
return AppUser.fromJson(Map<String, dynamic>.from(data is Map ? data : {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Valider tout le dossier famille. POST /parents/:parentId/valider-dossier. Ticket #108.
|
||||||
|
static Future<List<AppUser>> validerDossierFamille(String parentId, {String? comment}) async {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$parentId/valider-dossier'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(comment != null ? {'comment': comment} : {}),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
try {
|
||||||
|
final err = jsonDecode(response.body);
|
||||||
|
throw Exception(_errMessage(err is Map ? err['message'] : err));
|
||||||
|
} catch (e) {
|
||||||
|
if (e is Exception) rethrow;
|
||||||
|
throw Exception('Erreur validation dossier famille (${response.statusCode})');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
final list = data is List ? data : [];
|
||||||
|
return list.map((e) => AppUser.fromJson(Map<String, dynamic>.from(e as Map))).toList();
|
||||||
|
}
|
||||||
|
|
||||||
// Récupérer la liste des gestionnaires (endpoint dédié)
|
// Récupérer la liste des gestionnaires (endpoint dédié)
|
||||||
static Future<List<AppUser>> getGestionnaires() async {
|
static Future<List<AppUser>> getGestionnaires() async {
|
||||||
final response = await http.get(
|
final response = await http.get(
|
||||||
|
|||||||
@ -49,12 +49,12 @@ String nirToRaw(String normalized) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Formate pour affichage : 1 12 34 56 789 012-34 ou 1 12 34 2A 789 012-34 (Corse).
|
/// Formate pour affichage : 1 12 34 56 789 012 - 34 ou 1 12 34 2A 789 012 - 34 (Corse).
|
||||||
String formatNir(String raw) {
|
String formatNir(String raw) {
|
||||||
final r = nirToRaw(raw);
|
final r = nirToRaw(raw);
|
||||||
if (r.length < 15) return r;
|
if (r.length < 15) return r;
|
||||||
// Même structure pour tous : sexe + année + mois + département + commune + ordre-clé.
|
// Même structure pour tous : sexe + année + mois + département + commune + ordre-clé.
|
||||||
return '${r.substring(0, 1)} ${r.substring(1, 3)} ${r.substring(3, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)}-${r.substring(13, 15)}';
|
return '${r.substring(0, 1)} ${r.substring(1, 3)} ${r.substring(3, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)} - ${r.substring(13, 15)}';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Vérifie le format : 15 caractères, structure 1+2+2+2+3+3+2, département 2A/2B autorisé.
|
/// Vérifie le format : 15 caractères, structure 1+2+2+2+3+3+2, département 2A/2B autorisé.
|
||||||
@ -82,7 +82,7 @@ String? validateNir(String? value) {
|
|||||||
if (value == null || value.isEmpty) return 'NIR requis';
|
if (value == null || value.isEmpty) return 'NIR requis';
|
||||||
final raw = nirToRaw(value).toUpperCase();
|
final raw = nirToRaw(value).toUpperCase();
|
||||||
if (raw.length != 15) return 'Le NIR doit contenir 15 caractères (chiffres, ou 2A/2B pour la Corse)';
|
if (raw.length != 15) return 'Le NIR doit contenir 15 caractères (chiffres, ou 2A/2B pour la Corse)';
|
||||||
if (!_isFormatValid(raw)) return 'Format NIR invalide (ex. 1 12 34 56 789 012-34 ou 2A pour la Corse)';
|
if (!_isFormatValid(raw)) return 'Format NIR invalide (ex. 1 12 34 56 789 012 - 34 ou 2A pour la Corse)';
|
||||||
final key = _controlKey(raw.substring(0, 13));
|
final key = _controlKey(raw.substring(0, 13));
|
||||||
final keyStr = key >= 0 && key <= 99 ? key.toString().padLeft(2, '0') : '';
|
final keyStr = key >= 0 && key <= 99 ? key.toString().padLeft(2, '0') : '';
|
||||||
final expectedKey = raw.substring(13, 15);
|
final expectedKey = raw.substring(13, 15);
|
||||||
@ -90,7 +90,7 @@ String? validateNir(String? value) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Formateur de saisie : affiche le NIR formaté (1 12 34 56 789 012-34) et limite à 15 caractères utiles.
|
/// Formateur de saisie : affiche le NIR formaté (1 12 34 56 789 012 - 34) et limite à 15 caractères utiles.
|
||||||
class NirInputFormatter extends TextInputFormatter {
|
class NirInputFormatter extends TextInputFormatter {
|
||||||
@override
|
@override
|
||||||
TextEditingValue formatEditUpdate(
|
TextEditingValue formatEditUpdate(
|
||||||
|
|||||||
57
frontend/lib/utils/phone_utils.dart
Normal file
57
frontend/lib/utils/phone_utils.dart
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// Format français : 10 chiffres affichés par paires (ex. 06 12 34 56 78).
|
||||||
|
|
||||||
|
/// Ne garde que les chiffres (max 10).
|
||||||
|
String normalizePhone(String raw) {
|
||||||
|
final digits = raw.replaceAll(RegExp(r'\D'), '');
|
||||||
|
return digits.length > 10 ? digits.substring(0, 10) : digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne le numéro formaté pour l'affichage (ex. "06 12 34 56 78").
|
||||||
|
/// Si [raw] est vide après normalisation, retourne [raw] tel quel (pour afficher "–" etc.).
|
||||||
|
String formatPhoneForDisplay(String raw) {
|
||||||
|
if (raw.trim().isEmpty) return raw;
|
||||||
|
final normalized = normalizePhone(raw);
|
||||||
|
if (normalized.isEmpty) return raw;
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
for (var i = 0; i < normalized.length; i++) {
|
||||||
|
if (i > 0 && i.isEven) buffer.write(' ');
|
||||||
|
buffer.write(normalized[i]);
|
||||||
|
}
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formatter de saisie : uniquement chiffres, espaces automatiques toutes les 2 chiffres, max 10 chiffres.
|
||||||
|
class FrenchPhoneNumberFormatter extends TextInputFormatter {
|
||||||
|
const FrenchPhoneNumberFormatter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextEditingValue formatEditUpdate(
|
||||||
|
TextEditingValue oldValue,
|
||||||
|
TextEditingValue newValue,
|
||||||
|
) {
|
||||||
|
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
||||||
|
final normalized = digits.length > 10 ? digits.substring(0, 10) : digits;
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
for (var i = 0; i < normalized.length; i++) {
|
||||||
|
if (i > 0 && i.isEven) buffer.write(' ');
|
||||||
|
buffer.write(normalized[i]);
|
||||||
|
}
|
||||||
|
final formatted = buffer.toString();
|
||||||
|
|
||||||
|
// Conserver la position du curseur : compter les chiffres avant la sélection
|
||||||
|
final sel = newValue.selection;
|
||||||
|
final digitsBeforeCursor = newValue.text
|
||||||
|
.substring(0, sel.start.clamp(0, newValue.text.length))
|
||||||
|
.replaceAll(RegExp(r'\D'), '')
|
||||||
|
.length;
|
||||||
|
final newOffset = digitsBeforeCursor + (digitsBeforeCursor > 0 ? digitsBeforeCursor ~/ 2 : 0);
|
||||||
|
final clampedOffset = newOffset.clamp(0, formatted.length);
|
||||||
|
|
||||||
|
return TextEditingValue(
|
||||||
|
text: formatted,
|
||||||
|
selection: TextSelection.collapsed(offset: clampedOffset),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||||
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
|
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
|
||||||
import 'package:p_tits_pas/services/auth_service.dart';
|
import 'package:p_tits_pas/services/auth_service.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
@ -60,18 +61,18 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentUserRole = cached.role.toLowerCase();
|
_currentUserRole = (cached.role).toLowerCase();
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final refreshed = await AuthService.refreshCurrentUser();
|
final refreshed = await AuthService.refreshCurrentUser();
|
||||||
if (!mounted || refreshed == null) return;
|
if (!mounted || refreshed == null) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentUserRole = refreshed.role.toLowerCase();
|
_currentUserRole = (refreshed.role).toLowerCase();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isSuperAdmin(AppUser user) => user.role.toLowerCase() == 'super_admin';
|
bool _isSuperAdmin(AppUser user) => (user.role).toLowerCase() == 'super_admin';
|
||||||
|
|
||||||
bool _canEditAdmin(AppUser target) {
|
bool _canEditAdmin(AppUser target) {
|
||||||
if (!_isSuperAdmin(target)) return true;
|
if (!_isSuperAdmin(target)) return true;
|
||||||
@ -123,7 +124,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
: Icons.manage_accounts_outlined,
|
: Icons.manage_accounts_outlined,
|
||||||
subtitleLines: [
|
subtitleLines: [
|
||||||
user.email,
|
user.email,
|
||||||
'Téléphone : ${user.telephone?.trim().isNotEmpty == true ? user.telephone : 'Non renseigné'}',
|
'Téléphone : ${user.telephone?.trim().isNotEmpty == true ? formatPhoneForDisplay(user.telephone!) : 'Non renseigné'}',
|
||||||
],
|
],
|
||||||
avatarUrl: user.photoUrl,
|
avatarUrl: user.photoUrl,
|
||||||
borderColor: isSuperAdmin
|
borderColor: isSuperAdmin
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
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/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_detail_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';
|
||||||
@ -126,7 +127,7 @@ class _AssistanteMaternelleManagementWidgetState
|
|||||||
),
|
),
|
||||||
AdminDetailField(
|
AdminDetailField(
|
||||||
label: 'Telephone',
|
label: 'Telephone',
|
||||||
value: _v(assistante.user.telephone),
|
value: _v(assistante.user.telephone) != '–' ? formatPhoneForDisplay(_v(assistante.user.telephone)) : '–',
|
||||||
),
|
),
|
||||||
AdminDetailField(label: 'Adresse', value: _v(assistante.user.adresse)),
|
AdminDetailField(label: 'Adresse', value: _v(assistante.user.adresse)),
|
||||||
AdminDetailField(label: 'Ville', value: _v(assistante.user.ville)),
|
AdminDetailField(label: 'Ville', value: _v(assistante.user.ville)),
|
||||||
|
|||||||
130
frontend/lib/widgets/admin/common/validation_detail_section.dart
Normal file
130
frontend/lib/widgets/admin/common/validation_detail_section.dart
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'admin_detail_modal.dart';
|
||||||
|
|
||||||
|
/// Bloc type formulaire (titre de section + champs read-only) pour les modales de validation.
|
||||||
|
/// [rowLayout] : même disposition que la création de compte, ex. [2, 2, 1, 2] = ligne de 2, ligne de 2, plein largeur, ligne de 2.
|
||||||
|
/// [rowFlex] : flex par index de ligne (optionnel). Ex. {3: [2, 5]} = 4e ligne : code postal étroit (2), ville large (5).
|
||||||
|
class ValidationDetailSection extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final List<AdminDetailField> fields;
|
||||||
|
|
||||||
|
/// Nombre de champs par ligne (1 = plein largeur, 2 = deux côte à côte). Ex. [2, 2, 1, 2] pour identité.
|
||||||
|
final List<int>? rowLayout;
|
||||||
|
|
||||||
|
/// Flex par ligne (index de ligne -> [flex1, flex2, ...]). Ex. {3: [2, 5]} pour Code postal | Ville.
|
||||||
|
final Map<int, List<int>>? rowFlex;
|
||||||
|
|
||||||
|
const ValidationDetailSection({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.fields,
|
||||||
|
this.rowLayout,
|
||||||
|
this.rowFlex,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final layout = rowLayout ?? List.filled(fields.length, 1);
|
||||||
|
int index = 0;
|
||||||
|
int rowIndex = 0;
|
||||||
|
final rows = <Widget>[];
|
||||||
|
for (final count in layout) {
|
||||||
|
if (index >= fields.length) break;
|
||||||
|
final rowFields = fields.skip(index).take(count).toList();
|
||||||
|
index += count;
|
||||||
|
if (rowFields.isEmpty) continue;
|
||||||
|
final flexForRow = rowFlex?[rowIndex];
|
||||||
|
rowIndex++;
|
||||||
|
if (count == 1) {
|
||||||
|
rows.add(Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: _buildFieldCell(rowFields.first),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
rows.add(Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < rowFields.length; i++) ...[
|
||||||
|
if (i > 0) const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
flex: (flexForRow != null && i < flexForRow.length)
|
||||||
|
? flexForRow[i]
|
||||||
|
: 1,
|
||||||
|
child: _buildFieldCell(rowFields[i]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
...rows,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFieldCell(AdminDetailField field) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
field.label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
ValidationReadOnlyField(value: field.value),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Champ texte en lecture seule, style formulaire (fond gris léger, bordure). Réutilisable en éditable plus tard.
|
||||||
|
class ValidationReadOnlyField extends StatelessWidget {
|
||||||
|
final String value;
|
||||||
|
final int? maxLines;
|
||||||
|
|
||||||
|
const ValidationReadOnlyField({
|
||||||
|
super.key,
|
||||||
|
required this.value,
|
||||||
|
this.maxLines = 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(color: Colors.black87, fontSize: 14),
|
||||||
|
maxLines: maxLines,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Sous-barre : Gestionnaires | Parents | Assistantes maternelles | [Administrateurs].
|
/// Sous-barre : [À valider] | Gestionnaires | Parents | Assistantes maternelles | [Administrateurs].
|
||||||
/// [subTabCount] = 3 pour masquer l'onglet Administrateurs (dashboard gestionnaire).
|
/// [tabLabels] : liste des libellés d'onglets (ex. avec « À valider » en premier si dossiers en attente).
|
||||||
|
/// [subTabCount] = 3 pour masquer Administrateurs (dashboard gestionnaire).
|
||||||
class DashboardUserManagementSubBar extends StatelessWidget {
|
class DashboardUserManagementSubBar extends StatelessWidget {
|
||||||
final int selectedSubIndex;
|
final int selectedSubIndex;
|
||||||
final ValueChanged<int> onSubTabChange;
|
final ValueChanged<int> onSubTabChange;
|
||||||
@ -11,8 +12,10 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
|||||||
final VoidCallback? onAddPressed;
|
final VoidCallback? onAddPressed;
|
||||||
final String addLabel;
|
final String addLabel;
|
||||||
final int subTabCount;
|
final int subTabCount;
|
||||||
|
/// Si non null, utilisé à la place des labels par défaut (ex. ['À valider', 'Parents', ...]).
|
||||||
|
final List<String>? tabLabels;
|
||||||
|
|
||||||
static const List<String> _tabLabels = [
|
static const List<String> _defaultTabLabels = [
|
||||||
'Gestionnaires',
|
'Gestionnaires',
|
||||||
'Parents',
|
'Parents',
|
||||||
'Assistantes maternelles',
|
'Assistantes maternelles',
|
||||||
@ -29,10 +32,14 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
|||||||
this.onAddPressed,
|
this.onAddPressed,
|
||||||
this.addLabel = '+ Ajouter',
|
this.addLabel = '+ Ajouter',
|
||||||
this.subTabCount = 4,
|
this.subTabCount = 4,
|
||||||
|
this.tabLabels,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
|
List<String> get _labels => tabLabels ?? _defaultTabLabels.sublist(0, subTabCount.clamp(1, _defaultTabLabels.length));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final labels = _labels;
|
||||||
return Container(
|
return Container(
|
||||||
height: 56,
|
height: 56,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@ -42,9 +49,9 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
for (int i = 0; i < subTabCount; i++) ...[
|
for (int i = 0; i < labels.length; i++) ...[
|
||||||
if (i > 0) const SizedBox(width: 12),
|
if (i > 0) const SizedBox(width: 12),
|
||||||
_buildSubNavItem(context, _tabLabels[i], i),
|
_buildSubNavItem(context, labels[i], i),
|
||||||
],
|
],
|
||||||
const SizedBox(width: 36),
|
const SizedBox(width: 36),
|
||||||
_pillField(
|
_pillField(
|
||||||
@ -68,7 +75,7 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
|||||||
_pillField(width: 150, child: filterControl!),
|
_pillField(width: 150, child: filterControl!),
|
||||||
],
|
],
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
_buildAddButton(),
|
if (onAddPressed != null) _buildAddButton(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
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_detail_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';
|
||||||
@ -122,7 +123,7 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
|||||||
),
|
),
|
||||||
AdminDetailField(
|
AdminDetailField(
|
||||||
label: 'Telephone',
|
label: 'Telephone',
|
||||||
value: _v(parent.user.telephone),
|
value: _v(parent.user.telephone) != '–' ? formatPhoneForDisplay(_v(parent.user.telephone)) : '–',
|
||||||
),
|
),
|
||||||
AdminDetailField(label: 'Adresse', value: _v(parent.user.adresse)),
|
AdminDetailField(label: 'Adresse', value: _v(parent.user.adresse)),
|
||||||
AdminDetailField(label: 'Ville', value: _v(parent.user.ville)),
|
AdminDetailField(label: 'Ville', value: _v(parent.user.ville)),
|
||||||
|
|||||||
354
frontend/lib/widgets/admin/pending_validation_widget.dart
Normal file
354
frontend/lib/widgets/admin/pending_validation_widget.dart
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
import 'package:p_tits_pas/models/pending_family.dart';
|
||||||
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart';
|
||||||
|
|
||||||
|
/// Onglet « À valider » : deux listes (AM en attente, familles en attente). Ticket #107.
|
||||||
|
class PendingValidationWidget extends StatefulWidget {
|
||||||
|
final VoidCallback? onRefresh;
|
||||||
|
|
||||||
|
const PendingValidationWidget({super.key, this.onRefresh});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PendingValidationWidget> createState() => _PendingValidationWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
||||||
|
bool _isLoading = true;
|
||||||
|
String? _error;
|
||||||
|
List<AppUser> _pendingAM = [];
|
||||||
|
List<PendingFamily> _pendingFamilies = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final am = await UserService.getPendingUsers(role: 'assistante_maternelle');
|
||||||
|
final families = await UserService.getPendingFamilies();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_pendingAM = am;
|
||||||
|
_pendingFamilies = families;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur inconnue';
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onOpenValidation({String? type, String? id, String? numeroDossier}) {
|
||||||
|
final num = numeroDossier?.trim();
|
||||||
|
if (num == null || num.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Numéro de dossier manquant.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => ValidationDossierModal(
|
||||||
|
numeroDossier: num,
|
||||||
|
onClose: () => Navigator.of(context).pop(),
|
||||||
|
onSuccess: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_load();
|
||||||
|
widget.onRefresh?.call();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (_error != null && _error!.isNotEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(_error!, style: const TextStyle(color: Colors.red)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _load,
|
||||||
|
child: const Text('Réessayer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final hasAM = _pendingAM.isNotEmpty;
|
||||||
|
final hasFamilies = _pendingFamilies.isNotEmpty;
|
||||||
|
if (!hasAM && !hasFamilies) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_circle_outline, size: 64, color: Colors.grey.shade400),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Aucun dossier en attente de validation',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async {
|
||||||
|
await _load();
|
||||||
|
widget.onRefresh?.call();
|
||||||
|
},
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (hasAM) ...[
|
||||||
|
_sectionTitle('Assistantes maternelles en attente'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
..._pendingAM.map((u) => _buildAMCard(u)),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
if (hasFamilies) ...[
|
||||||
|
_sectionTitle('Familles en attente'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
..._pendingFamilies.map((f) => _buildFamilyCard(f)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _sectionTitle(String title) {
|
||||||
|
return Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ligne commune : icône | titre (+ sous-titre) | bouton Ouvrir.
|
||||||
|
/// [titleWidget] remplace [title] si les deux sont fournis : priorité à [titleWidget].
|
||||||
|
Widget _buildPendingRow({
|
||||||
|
required IconData icon,
|
||||||
|
String? title,
|
||||||
|
Widget? titleWidget,
|
||||||
|
String? subtitle,
|
||||||
|
TextStyle? subtitleStyle,
|
||||||
|
required VoidCallback onOpen,
|
||||||
|
}) {
|
||||||
|
assert(title != null || titleWidget != null);
|
||||||
|
final titleChild = titleWidget ??
|
||||||
|
Text(
|
||||||
|
title!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final subStyle = subtitleStyle ??
|
||||||
|
TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
);
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
side: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: Colors.grey.shade600, size: 28),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
titleChild,
|
||||||
|
if (subtitle != null && subtitle.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: subStyle,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: onOpen,
|
||||||
|
icon: const Icon(Icons.open_in_new, size: 18),
|
||||||
|
label: const Text('Ouvrir'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sous-titre AM : `email - date • tél. • CP ville` (plan affichage lignes À valider).
|
||||||
|
String _amSubtitleLine(AppUser user) {
|
||||||
|
final email = user.email.trim();
|
||||||
|
final bits = <String>[];
|
||||||
|
bits.add(DateFormat('dd/MM/yyyy').format(user.createdAt.toLocal()));
|
||||||
|
final tel = user.telephone?.trim();
|
||||||
|
if (tel != null && tel.isNotEmpty) {
|
||||||
|
bits.add(formatPhoneForDisplay(tel));
|
||||||
|
}
|
||||||
|
final cp = user.codePostal?.trim();
|
||||||
|
final ville = user.ville?.trim();
|
||||||
|
final loc = [if (cp != null && cp.isNotEmpty) cp, if (ville != null && ville.isNotEmpty) ville]
|
||||||
|
.join(' ')
|
||||||
|
.trim();
|
||||||
|
if (loc.isNotEmpty) bits.add(loc);
|
||||||
|
final infos = bits.join(' • ');
|
||||||
|
if (email.isEmpty) return infos;
|
||||||
|
return '$email - $infos';
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAMCard(AppUser user) {
|
||||||
|
final numDossier = user.numeroDossier ?? '–';
|
||||||
|
final nameBold =
|
||||||
|
user.fullName.isNotEmpty ? user.fullName : (user.email.isNotEmpty ? user.email : '–');
|
||||||
|
return _buildPendingRow(
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
titleWidget: Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
style: const TextStyle(fontSize: 14, color: Colors.black87),
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: nameBold,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: ' - $numDossier',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w400),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: _amSubtitleLine(user),
|
||||||
|
subtitleStyle: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
onOpen: () => _onOpenValidation(
|
||||||
|
type: 'AM',
|
||||||
|
id: user.id,
|
||||||
|
numeroDossier: user.numeroDossier,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `email, tél., localisation` par parent, puis `date soumission`, puis `nb enfants`.
|
||||||
|
String _familyParentSegment(PendingParentLine p) {
|
||||||
|
final parts = <String>[];
|
||||||
|
final e = p.email?.trim();
|
||||||
|
if (e != null && e.isNotEmpty) parts.add(e);
|
||||||
|
final t = p.telephone?.trim();
|
||||||
|
if (t != null && t.isNotEmpty) parts.add(formatPhoneForDisplay(t));
|
||||||
|
final cp = p.codePostal?.trim();
|
||||||
|
final v = p.ville?.trim();
|
||||||
|
final loc = [if (cp != null && cp.isNotEmpty) cp, if (v != null && v.isNotEmpty) v]
|
||||||
|
.join(' ')
|
||||||
|
.trim();
|
||||||
|
if (loc.isNotEmpty) parts.add(loc);
|
||||||
|
return parts.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _familySubtitleLine(PendingFamily family) {
|
||||||
|
final blocks = family.parentLines
|
||||||
|
.map(_familyParentSegment)
|
||||||
|
.where((s) => s.isNotEmpty)
|
||||||
|
.join(' - ');
|
||||||
|
|
||||||
|
final tail = <String>[];
|
||||||
|
final date = family.dateSoumission;
|
||||||
|
if (date != null) {
|
||||||
|
tail.add(DateFormat('dd/MM/yyyy').format(date.toLocal()));
|
||||||
|
}
|
||||||
|
if (family.nombreEnfants > 0) {
|
||||||
|
tail.add(
|
||||||
|
family.nombreEnfants > 1
|
||||||
|
? '${family.nombreEnfants} enfants'
|
||||||
|
: '1 enfant',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final right = tail.join(' - ');
|
||||||
|
|
||||||
|
if (blocks.isEmpty && right.isEmpty) return '';
|
||||||
|
if (blocks.isEmpty) return right;
|
||||||
|
if (right.isEmpty) return blocks;
|
||||||
|
return '$blocks - $right';
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFamilyCard(PendingFamily family) {
|
||||||
|
final numDossier = family.numeroDossier ?? '–';
|
||||||
|
final nameBold = family.libelle.isNotEmpty ? family.libelle : 'Famille';
|
||||||
|
return _buildPendingRow(
|
||||||
|
icon: Icons.family_restroom_outlined,
|
||||||
|
titleWidget: Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
style: const TextStyle(fontSize: 14, color: Colors.black87),
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: nameBold,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: ' - $numDossier',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w400),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: _familySubtitleLine(family),
|
||||||
|
subtitleStyle: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
onOpen: () => _onOpenValidation(
|
||||||
|
type: 'famille',
|
||||||
|
id: family.parentIds.isNotEmpty ? family.parentIds.first : null,
|
||||||
|
numeroDossier: family.numeroDossier,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:p_tits_pas/models/relais_model.dart';
|
import 'package:p_tits_pas/models/relais_model.dart';
|
||||||
|
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||||
import 'package:p_tits_pas/services/relais_service.dart';
|
import 'package:p_tits_pas/services/relais_service.dart';
|
||||||
|
|
||||||
class RelaisManagementPanel extends StatefulWidget {
|
class RelaisManagementPanel extends StatefulWidget {
|
||||||
@ -723,7 +724,7 @@ class _RelaisFormDialogState extends State<_RelaisFormDialog> {
|
|||||||
inputFormatters: [
|
inputFormatters: [
|
||||||
FilteringTextInputFormatter.digitsOnly,
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
LengthLimitingTextInputFormatter(10),
|
LengthLimitingTextInputFormatter(10),
|
||||||
_FrenchPhoneNumberFormatter(),
|
FrenchPhoneNumberFormatter(),
|
||||||
],
|
],
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Ligne fixe',
|
labelText: 'Ligne fixe',
|
||||||
@ -991,30 +992,6 @@ class _RelaisFormDialogState extends State<_RelaisFormDialog> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FrenchPhoneNumberFormatter extends TextInputFormatter {
|
|
||||||
@override
|
|
||||||
TextEditingValue formatEditUpdate(
|
|
||||||
TextEditingValue oldValue,
|
|
||||||
TextEditingValue newValue,
|
|
||||||
) {
|
|
||||||
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
|
||||||
final buffer = StringBuffer();
|
|
||||||
|
|
||||||
for (var i = 0; i < digits.length; i++) {
|
|
||||||
if (i > 0 && i.isEven) {
|
|
||||||
buffer.write(' ');
|
|
||||||
}
|
|
||||||
buffer.write(digits[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
final formatted = buffer.toString();
|
|
||||||
return TextEditingValue(
|
|
||||||
text: formatted,
|
|
||||||
selection: TextSelection.collapsed(offset: formatted.length),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RelaisAddressFields extends StatelessWidget {
|
class _RelaisAddressFields extends StatelessWidget {
|
||||||
final TextEditingController streetController;
|
final TextEditingController streetController;
|
||||||
final TextEditingController postalCodeController;
|
final TextEditingController postalCodeController;
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
|
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
|
||||||
|
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/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';
|
||||||
|
|
||||||
class UserManagementPanel extends StatefulWidget {
|
class UserManagementPanel extends StatefulWidget {
|
||||||
/// Afficher l'onglet Administrateurs (sinon 3 onglets : Gestionnaires, Parents, AM).
|
/// Afficher l'onglet Administrateurs (sinon 3 onglets : Gestionnaires, Parents, AM).
|
||||||
@ -26,12 +28,41 @@ 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;
|
||||||
|
bool _hasPending = false;
|
||||||
|
bool _pendingLoading = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_searchController.addListener(_onFilterChanged);
|
_searchController.addListener(_onFilterChanged);
|
||||||
_amCapacityController.addListener(_onFilterChanged);
|
_amCapacityController.addListener(_onFilterChanged);
|
||||||
|
_loadPending();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPending() async {
|
||||||
|
try {
|
||||||
|
final am = await UserService.getPendingUsers(role: 'assistante_maternelle');
|
||||||
|
final families = await UserService.getPendingFamilies();
|
||||||
|
if (!mounted) return;
|
||||||
|
final hasPending = am.isNotEmpty || families.isNotEmpty;
|
||||||
|
setState(() {
|
||||||
|
final hadPending = _hasPending;
|
||||||
|
_hasPending = hasPending;
|
||||||
|
_pendingLoading = false;
|
||||||
|
// Si on passe à "plus de dossiers", recaler l'index (onglet À valider disparaît).
|
||||||
|
if (hadPending && !hasPending) {
|
||||||
|
_subIndex = (_subIndex > 0 ? _subIndex - 1 : 0).clamp(0, _tabLabels.length - 1);
|
||||||
|
} else if (!hadPending && hasPending) {
|
||||||
|
_subIndex = 0; // Afficher l'onglet À valider
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_hasPending = false;
|
||||||
|
_pendingLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -48,8 +79,17 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<String> get _tabLabels {
|
||||||
|
const base = ['Parents', 'Assistantes maternelles', 'Gestionnaires'];
|
||||||
|
final withAdmin = [...base, 'Administrateurs'];
|
||||||
|
final list = widget.showAdministrateursTab ? withAdmin : base;
|
||||||
|
// Onglet « À valider » visible seulement s'il y a des dossiers en attente (ticket #107).
|
||||||
|
if (!_pendingLoading && _hasPending) return ['À valider', ...list];
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
void _onSubTabChange(int index) {
|
void _onSubTabChange(int index) {
|
||||||
final maxIndex = widget.showAdministrateursTab ? 3 : 2;
|
final maxIndex = _tabLabels.length - 1;
|
||||||
setState(() {
|
setState(() {
|
||||||
_subIndex = index.clamp(0, maxIndex);
|
_subIndex = index.clamp(0, maxIndex);
|
||||||
_searchController.clear();
|
_searchController.clear();
|
||||||
@ -58,14 +98,20 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Index du contenu : -1 = À valider (si visible), 0 = Parents, 1 = AM, 2 = Gestionnaires, 3 = Admin.
|
||||||
|
int get _contentIndexOffset => (_hasPending && !_pendingLoading) ? 1 : 0;
|
||||||
|
|
||||||
String _searchHintForTab() {
|
String _searchHintForTab() {
|
||||||
switch (_subIndex) {
|
final contentIndex = _subIndex - _contentIndexOffset;
|
||||||
|
switch (contentIndex) {
|
||||||
|
case -1:
|
||||||
|
return 'À valider (pas de recherche)';
|
||||||
case 0:
|
case 0:
|
||||||
return 'Rechercher un gestionnaire...';
|
|
||||||
case 1:
|
|
||||||
return 'Rechercher un parent...';
|
return 'Rechercher un parent...';
|
||||||
case 2:
|
case 1:
|
||||||
return 'Rechercher une assistante...';
|
return 'Rechercher une assistante...';
|
||||||
|
case 2:
|
||||||
|
return 'Rechercher un gestionnaire...';
|
||||||
case 3:
|
case 3:
|
||||||
return 'Rechercher un administrateur...';
|
return 'Rechercher un administrateur...';
|
||||||
default:
|
default:
|
||||||
@ -74,7 +120,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget? _subBarFilterControl() {
|
Widget? _subBarFilterControl() {
|
||||||
if (_subIndex == 1) {
|
if (_subIndex == _contentIndexOffset + 0) {
|
||||||
return DropdownButtonHideUnderline(
|
return DropdownButtonHideUnderline(
|
||||||
child: DropdownButton<String?>(
|
child: DropdownButton<String?>(
|
||||||
value: _parentStatus,
|
value: _parentStatus,
|
||||||
@ -122,7 +168,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_subIndex == 2) {
|
if (_subIndex == _contentIndexOffset + 1) {
|
||||||
return TextField(
|
return TextField(
|
||||||
controller: _amCapacityController,
|
controller: _amCapacityController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
@ -139,22 +185,26 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody() {
|
Widget _buildBody() {
|
||||||
switch (_subIndex) {
|
final contentIndex = _subIndex - _contentIndexOffset;
|
||||||
|
if (_hasPending && !_pendingLoading && contentIndex == -1) {
|
||||||
|
return PendingValidationWidget(onRefresh: _loadPending);
|
||||||
|
}
|
||||||
|
switch (contentIndex) {
|
||||||
case 0:
|
case 0:
|
||||||
return GestionnaireManagementWidget(
|
|
||||||
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
|
|
||||||
searchQuery: _searchController.text,
|
|
||||||
);
|
|
||||||
case 1:
|
|
||||||
return ParentManagementWidget(
|
return ParentManagementWidget(
|
||||||
searchQuery: _searchController.text,
|
searchQuery: _searchController.text,
|
||||||
statusFilter: _parentStatus,
|
statusFilter: _parentStatus,
|
||||||
);
|
);
|
||||||
case 2:
|
case 1:
|
||||||
return AssistanteMaternelleManagementWidget(
|
return AssistanteMaternelleManagementWidget(
|
||||||
searchQuery: _searchController.text,
|
searchQuery: _searchController.text,
|
||||||
capacityMin: int.tryParse(_amCapacityController.text),
|
capacityMin: int.tryParse(_amCapacityController.text),
|
||||||
);
|
);
|
||||||
|
case 2:
|
||||||
|
return GestionnaireManagementWidget(
|
||||||
|
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
|
||||||
|
searchQuery: _searchController.text,
|
||||||
|
);
|
||||||
case 3:
|
case 3:
|
||||||
return AdminManagementWidget(
|
return AdminManagementWidget(
|
||||||
key: ValueKey('admins-$_adminRefreshTick'),
|
key: ValueKey('admins-$_adminRefreshTick'),
|
||||||
@ -167,7 +217,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final subTabCount = widget.showAdministrateursTab ? 4 : 3;
|
final labels = _tabLabels;
|
||||||
|
final isAValiderTab = _hasPending && !_pendingLoading && _subIndex == 0;
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
DashboardUserManagementSubBar(
|
DashboardUserManagementSubBar(
|
||||||
@ -176,9 +227,10 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
searchController: _searchController,
|
searchController: _searchController,
|
||||||
searchHint: _searchHintForTab(),
|
searchHint: _searchHintForTab(),
|
||||||
filterControl: _subBarFilterControl(),
|
filterControl: _subBarFilterControl(),
|
||||||
onAddPressed: _handleAddPressed,
|
onAddPressed: isAValiderTab ? null : _handleAddPressed,
|
||||||
addLabel: 'Ajouter',
|
addLabel: 'Ajouter',
|
||||||
subTabCount: subTabCount,
|
subTabCount: labels.length,
|
||||||
|
tabLabels: labels,
|
||||||
),
|
),
|
||||||
Expanded(child: _buildBody()),
|
Expanded(child: _buildBody()),
|
||||||
],
|
],
|
||||||
@ -186,7 +238,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleAddPressed() async {
|
Future<void> _handleAddPressed() async {
|
||||||
if (_subIndex == 0) {
|
final contentIndex = _subIndex - _contentIndexOffset;
|
||||||
|
if (contentIndex == 2) {
|
||||||
final created = await showDialog<bool>(
|
final created = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
@ -204,7 +257,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_subIndex == 3) {
|
if (contentIndex == 3) {
|
||||||
final created = await showDialog<bool>(
|
final created = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
|
|||||||
507
frontend/lib/widgets/admin/validation_am_wizard.dart
Normal file
507
frontend/lib/widgets/admin/validation_am_wizard.dart
Normal file
@ -0,0 +1,507 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||||||
|
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||||
|
import 'package:p_tits_pas/utils/nir_utils.dart';
|
||||||
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
||||||
|
import 'validation_modal_theme.dart';
|
||||||
|
import 'validation_refus_form.dart';
|
||||||
|
import 'validation_valider_confirm_dialog.dart';
|
||||||
|
|
||||||
|
/// Wizard de validation dossier AM : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107.
|
||||||
|
class ValidationAmWizard extends StatefulWidget {
|
||||||
|
final DossierAM dossier;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
final VoidCallback onSuccess;
|
||||||
|
final void Function(int step, int total)? onStepChanged;
|
||||||
|
|
||||||
|
const ValidationAmWizard({
|
||||||
|
super.key,
|
||||||
|
required this.dossier,
|
||||||
|
required this.onClose,
|
||||||
|
required this.onSuccess,
|
||||||
|
this.onStepChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ValidationAmWizard> createState() => _ValidationAmWizardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ValidationAmWizardState extends State<ValidationAmWizard> {
|
||||||
|
int _step = 0;
|
||||||
|
bool _showRefusForm = false;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
static const int _stepCount = 3;
|
||||||
|
|
||||||
|
bool get _isEnAttente => widget.dossier.user.statut == 'en_attente';
|
||||||
|
|
||||||
|
static String _v(String? s) =>
|
||||||
|
(s != null && s.trim().isNotEmpty) ? s.trim() : '–';
|
||||||
|
|
||||||
|
/// Présentation lisible : `1 12 34 56 789 012 - 34` (15 caractères utiles requis).
|
||||||
|
static String _formatNirForDisplay(String? nir) {
|
||||||
|
final v = _v(nir);
|
||||||
|
if (v == '–') return v;
|
||||||
|
final raw = nirToRaw(v).toUpperCase();
|
||||||
|
return raw.length == 15 ? formatNir(raw) : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
|
||||||
|
}
|
||||||
|
|
||||||
|
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)),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Informations professionnelles : N° Agrément|Date agrément, NIR, Capacité|Places, Ville.
|
||||||
|
List<AdminDetailField> _proFields(DossierAM d) => [
|
||||||
|
AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Date d’agrément',
|
||||||
|
value: d.dateAgrement != null && d.dateAgrement!.trim().isNotEmpty
|
||||||
|
? d.dateAgrement!.trim()
|
||||||
|
: '–',
|
||||||
|
),
|
||||||
|
AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Capacité max (enfants)',
|
||||||
|
value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '–',
|
||||||
|
),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Places disponibles',
|
||||||
|
value: d.placesDisponibles != null
|
||||||
|
? d.placesDisponibles.toString()
|
||||||
|
: '–',
|
||||||
|
),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Ville de résidence', value: _v(d.villeResidence)),
|
||||||
|
];
|
||||||
|
|
||||||
|
static const List<int> _personalRowLayout = [2, 2, 1, 2];
|
||||||
|
static const Map<int, List<int>> _personalRowFlex = {
|
||||||
|
3: [2, 5]
|
||||||
|
}; // Code postal étroit, Ville large
|
||||||
|
|
||||||
|
/// Proportion photo d’identité (35×45 mm).
|
||||||
|
static const double _idPhotoAspectRatio = 35 / 45;
|
||||||
|
|
||||||
|
static const double _photoProGap = 24;
|
||||||
|
/// Largeur mini réservée aux champs (évite une colonne photo trop gourmande).
|
||||||
|
static const double _proColumnMinWidth = 260;
|
||||||
|
static const double _photoColumnMinWidth = 160;
|
||||||
|
|
||||||
|
/// URL complète pour la photo : si relatif, on préfixe par l’origine de l’API.
|
||||||
|
static String _fullPhotoUrl(String? url) {
|
||||||
|
if (url == null || url.trim().isEmpty) return '';
|
||||||
|
final u = url.trim();
|
||||||
|
if (u.startsWith('http://') || u.startsWith('https://')) return u;
|
||||||
|
final base = ApiConfig.baseUrl;
|
||||||
|
final origin = base.replaceAll(RegExp(r'/api/v1.*'), '');
|
||||||
|
return u.startsWith('/') ? '$origin$u' : '$origin/$u';
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPhotoSection(AppUser u) {
|
||||||
|
final photoUrl = _fullPhotoUrl(u.photoUrl);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Photo de profil',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, c) {
|
||||||
|
// Cadre clair : une seule épaisseur partout (photo + padding identique haut/bas/gauche/droite).
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
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: photoUrl.isEmpty
|
||||||
|
? ColoredBox(
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.person_off_outlined,
|
||||||
|
size: 40,
|
||||||
|
color: Colors.grey.shade400),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Aucune photo fournie',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Image.network(
|
||||||
|
photoUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
width: pw,
|
||||||
|
height: ph,
|
||||||
|
loadingBuilder: (_, child, progress) {
|
||||||
|
if (progress == null) return child;
|
||||||
|
return ColoredBox(
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
child: Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
value: progress.expectedTotalBytes !=
|
||||||
|
null
|
||||||
|
? progress.cumulativeBytesLoaded /
|
||||||
|
(progress.expectedTotalBytes!)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
errorBuilder: (_, __, ___) => ColoredBox(
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment:
|
||||||
|
MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.broken_image_outlined,
|
||||||
|
size: 40,
|
||||||
|
color: Colors.grey.shade400),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Impossible de charger la photo',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_showRefusForm) {
|
||||||
|
return _buildRefusPage();
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Expanded(child: _buildStepContent()),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildNavigation(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStepContent() {
|
||||||
|
final d = widget.dossier;
|
||||||
|
final u = d.user;
|
||||||
|
switch (_step) {
|
||||||
|
case 0:
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
||||||
|
child: ValidationDetailSection(
|
||||||
|
title: 'Informations personnelles',
|
||||||
|
fields: _personalFields(u),
|
||||||
|
rowLayout: _personalRowLayout,
|
||||||
|
rowFlex: _personalRowFlex,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
case 1:
|
||||||
|
// Pas de SingleChildScrollView sur la Row (hauteur non bornée). Défilement à droite.
|
||||||
|
// Largeur photo ≈ ratio × hauteur utile, plafonnée pour laisser au moins [_proColumnMinWidth] aux champs.
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, c) {
|
||||||
|
final maxRowW = c.maxWidth;
|
||||||
|
final maxRowH = c.maxHeight;
|
||||||
|
// Titre « Photo de profil » + espacement (~52 px) : hauteur dispo pour le cadre photo.
|
||||||
|
const photoHeaderH = 52.0;
|
||||||
|
final bodyH = (maxRowH - photoHeaderH).clamp(0.0, double.infinity);
|
||||||
|
final idealPhotoW =
|
||||||
|
bodyH * _idPhotoAspectRatio + 16; // marge approx. cadre clair
|
||||||
|
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
|
||||||
|
.clamp(0.0, double.infinity);
|
||||||
|
var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 360.0);
|
||||||
|
if (photoW > maxPhotoW) photoW = maxPhotoW;
|
||||||
|
photoW = photoW.clamp(0.0, maxRowW - _photoProGap);
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: photoW,
|
||||||
|
child: _buildPhotoSection(u),
|
||||||
|
),
|
||||||
|
const SizedBox(width: _photoProGap),
|
||||||
|
Expanded(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
minWidth: constraints.maxWidth),
|
||||||
|
child: ValidationDetailSection(
|
||||||
|
title: 'Informations professionnelles',
|
||||||
|
fields: _proFields(d),
|
||||||
|
rowLayout: const [
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
1
|
||||||
|
], // N° Agrément|Date agrément, NIR, Capacité|Places, Ville
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
case 2:
|
||||||
|
final presentation =
|
||||||
|
(d.presentation != null && d.presentation!.trim().isNotEmpty)
|
||||||
|
? d.presentation!
|
||||||
|
: '–';
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Présentation',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.black87),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Expanded(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints:
|
||||||
|
BoxConstraints(minHeight: constraints.maxHeight),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
child: SelectableText(
|
||||||
|
presentation,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.black87, fontSize: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNavigation() {
|
||||||
|
if (_step == 2) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
|
||||||
|
const Spacer(),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => _step = 1);
|
||||||
|
_emitStep();
|
||||||
|
},
|
||||||
|
child: const Text('Précédent'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (_isEnAttente) ...[
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: _submitting ? null : _refuser,
|
||||||
|
child: const Text('Refuser')),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ValidationModalTheme.primaryElevatedStyle,
|
||||||
|
onPressed: _submitting ? null : _onValiderPressed,
|
||||||
|
child: Text(_submitting ? 'Envoi...' : 'Valider'),
|
||||||
|
),
|
||||||
|
] else
|
||||||
|
ElevatedButton(
|
||||||
|
style: ValidationModalTheme.primaryElevatedStyle,
|
||||||
|
onPressed: widget.onClose,
|
||||||
|
child: const Text('Fermer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
|
||||||
|
const Spacer(),
|
||||||
|
if (_step > 0) ...[
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => _step--);
|
||||||
|
_emitStep();
|
||||||
|
},
|
||||||
|
child: const Text('Précédent'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
ElevatedButton(
|
||||||
|
style: ValidationModalTheme.primaryElevatedStyle,
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => _step++);
|
||||||
|
_emitStep();
|
||||||
|
},
|
||||||
|
child: const Text('Suivant'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onValiderPressed() async {
|
||||||
|
if (_submitting) return;
|
||||||
|
final ok = await showValidationValiderConfirmDialog(
|
||||||
|
context,
|
||||||
|
body:
|
||||||
|
'Voulez-vous valider le dossier de cette assistante maternelle ? Cette action confirme le compte.',
|
||||||
|
);
|
||||||
|
if (!mounted || !ok) return;
|
||||||
|
await _valider();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _valider() async {
|
||||||
|
if (_submitting) return;
|
||||||
|
setState(() => _submitting = true);
|
||||||
|
try {
|
||||||
|
await UserService.validateUser(widget.dossier.user.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
widget.onSuccess();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(e is Exception
|
||||||
|
? e.toString().replaceFirst('Exception: ', '')
|
||||||
|
: 'Erreur'),
|
||||||
|
backgroundColor: Colors.red.shade700,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _submitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _refuser() => setState(() => _showRefusForm = true);
|
||||||
|
|
||||||
|
Widget _buildRefusPage() {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ValidationRefusForm(
|
||||||
|
onCancel: widget.onClose,
|
||||||
|
onPrevious: () => setState(() => _showRefusForm = false),
|
||||||
|
onSubmit: (comment) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Refus (à brancher sur l’API refus)')),
|
||||||
|
);
|
||||||
|
widget.onClose();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
173
frontend/lib/widgets/admin/validation_dossier_modal.dart
Normal file
173
frontend/lib/widgets/admin/validation_dossier_modal.dart
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||||||
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/validation_am_wizard.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/validation_family_wizard.dart';
|
||||||
|
|
||||||
|
/// Modale (dialog) : charge le dossier par numéro puis affiche le wizard AM ou Famille. Ticket #107, #119.
|
||||||
|
class ValidationDossierModal extends StatefulWidget {
|
||||||
|
final String numeroDossier;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
final VoidCallback? onSuccess;
|
||||||
|
|
||||||
|
const ValidationDossierModal({
|
||||||
|
super.key,
|
||||||
|
required this.numeroDossier,
|
||||||
|
required this.onClose,
|
||||||
|
this.onSuccess,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ValidationDossierModal> createState() => _ValidationDossierModalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ValidationDossierModalState extends State<ValidationDossierModal> {
|
||||||
|
bool _loading = true;
|
||||||
|
String? _error;
|
||||||
|
DossierUnifie? _dossier;
|
||||||
|
int? _stepIndex;
|
||||||
|
int? _stepTotal;
|
||||||
|
|
||||||
|
void _onStepChanged(int step, int total) {
|
||||||
|
// step = 0-based dans les wizards, affichage 1-based dans l'en-tête.
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_stepIndex = step;
|
||||||
|
_stepTotal = total;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
_error = null;
|
||||||
|
_dossier = null;
|
||||||
|
_stepIndex = null;
|
||||||
|
_stepTotal = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final d = await UserService.getDossier(widget.numeroDossier);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_dossier = d;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = e is Exception
|
||||||
|
? e.toString().replaceFirst('Exception: ', '')
|
||||||
|
: 'Erreur inconnue';
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSuccess() {
|
||||||
|
widget.onSuccess?.call();
|
||||||
|
// La modale est fermée par l’appelant dans onSuccess (Navigator.pop).
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Largeur modale = 1,5 × 620.
|
||||||
|
static const double _modalWidth = 930; // 620 * 1.5
|
||||||
|
// Hauteur uniforme (ajustée +5px pour éviter l'overflow des étapes parents sans scroll).
|
||||||
|
static const double _bodyHeight = 435;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final maxH = MediaQuery.of(context).size.height * 0.85;
|
||||||
|
final showStep =
|
||||||
|
_stepIndex != null && _stepTotal != null && (_stepTotal ?? 0) > 0;
|
||||||
|
return Dialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxWidth: _modalWidth, maxHeight: maxH),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 18, 0, 12),
|
||||||
|
child: Text(
|
||||||
|
'Dossier ${widget.numeroDossier}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (showStep) ...[
|
||||||
|
Text(
|
||||||
|
'Étape ${(_stepIndex ?? 0) + 1}/${_stepTotal ?? 1}',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
color: Colors.black54,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: widget.onClose,
|
||||||
|
tooltip: 'Fermer',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
SizedBox(
|
||||||
|
height: _bodyHeight,
|
||||||
|
child: _buildBody(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody() {
|
||||||
|
if (_loading) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.all(48),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_error != null && _error!.isNotEmpty) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(_error!,
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
textAlign: TextAlign.center),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(onPressed: _load, child: const Text('Réessayer')),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton(onPressed: widget.onClose, child: const Text('Fermer')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final d = _dossier!;
|
||||||
|
if (d.isAm) {
|
||||||
|
return ValidationAmWizard(
|
||||||
|
dossier: d.asAm,
|
||||||
|
onClose: widget.onClose,
|
||||||
|
onSuccess: _onSuccess,
|
||||||
|
onStepChanged: _onStepChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ValidationFamilyWizard(
|
||||||
|
dossier: d.asFamily,
|
||||||
|
onClose: widget.onClose,
|
||||||
|
onSuccess: _onSuccess,
|
||||||
|
onStepChanged: _onStepChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
730
frontend/lib/widgets/admin/validation_family_wizard.dart
Normal file
730
frontend/lib/widgets/admin/validation_family_wizard.dart
Normal file
@ -0,0 +1,730 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||||||
|
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||||
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
||||||
|
import 'validation_modal_theme.dart';
|
||||||
|
import 'validation_refus_form.dart';
|
||||||
|
import 'validation_valider_confirm_dialog.dart';
|
||||||
|
|
||||||
|
/// Wizard de validation dossier famille : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107.
|
||||||
|
class ValidationFamilyWizard extends StatefulWidget {
|
||||||
|
final DossierFamille dossier;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
final VoidCallback onSuccess;
|
||||||
|
final void Function(int step, int total)? onStepChanged;
|
||||||
|
|
||||||
|
const ValidationFamilyWizard({
|
||||||
|
super.key,
|
||||||
|
required this.dossier,
|
||||||
|
required this.onClose,
|
||||||
|
required this.onSuccess,
|
||||||
|
this.onStepChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ValidationFamilyWizard> createState() => _ValidationFamilyWizardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
|
||||||
|
int _step = 0;
|
||||||
|
bool _showRefusForm = false;
|
||||||
|
bool _submitting = false;
|
||||||
|
final ScrollController _enfantsScrollController = ScrollController();
|
||||||
|
|
||||||
|
/// Même logique que [ParentRegisterStep3Screen] : masque alpha sur les bords (ShaderMask dstIn).
|
||||||
|
bool _enfantsIsScrollable = false;
|
||||||
|
bool _enfantsFadeLeft = false;
|
||||||
|
bool _enfantsFadeRight = false;
|
||||||
|
|
||||||
|
/// Fraction de la largeur du viewport pour le fondu (identique inscription étape 3).
|
||||||
|
static const double _enfantsFadeExtent = 0.05;
|
||||||
|
|
||||||
|
int get _stepCount => 4;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_enfantsScrollController.addListener(_syncEnfantsScrollFades);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_enfantsScrollController.removeListener(_syncEnfantsScrollFades);
|
||||||
|
_enfantsScrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
|
||||||
|
|
||||||
|
void _syncEnfantsScrollFades() {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (!_enfantsScrollController.hasClients) {
|
||||||
|
if (_enfantsFadeLeft || _enfantsFadeRight || _enfantsIsScrollable) {
|
||||||
|
setState(() {
|
||||||
|
_enfantsIsScrollable = false;
|
||||||
|
_enfantsFadeLeft = false;
|
||||||
|
_enfantsFadeRight = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final p = _enfantsScrollController.position;
|
||||||
|
final scrollable = p.maxScrollExtent > 0;
|
||||||
|
final left = scrollable &&
|
||||||
|
p.pixels > (p.viewportDimension * _enfantsFadeExtent / 2);
|
||||||
|
final right = scrollable &&
|
||||||
|
p.pixels <
|
||||||
|
(p.maxScrollExtent -
|
||||||
|
(p.viewportDimension * _enfantsFadeExtent / 2));
|
||||||
|
if (scrollable != _enfantsIsScrollable ||
|
||||||
|
left != _enfantsFadeLeft ||
|
||||||
|
right != _enfantsFadeRight) {
|
||||||
|
setState(() {
|
||||||
|
_enfantsIsScrollable = scrollable;
|
||||||
|
_enfantsFadeLeft = left;
|
||||||
|
_enfantsFadeRight = right;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _isEnAttente => widget.dossier.isEnAttente;
|
||||||
|
|
||||||
|
String? get _firstParentId => widget.dossier.parents.isNotEmpty
|
||||||
|
? widget.dossier.parents.first.id
|
||||||
|
: null;
|
||||||
|
|
||||||
|
static String _v(String? s) =>
|
||||||
|
(s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini';
|
||||||
|
|
||||||
|
/// Date de naissance en jour/mois/année (dd/MM/yyyy).
|
||||||
|
static String _formatBirthDate(String? s) {
|
||||||
|
if (s == null || s.trim().isEmpty) return 'Non défini';
|
||||||
|
try {
|
||||||
|
final d = DateTime.parse(s.trim());
|
||||||
|
return DateFormat('dd/MM/yyyy').format(d);
|
||||||
|
} catch (_) {
|
||||||
|
return s.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
||||||
|
if (url == null || url.trim().isEmpty) return '';
|
||||||
|
final u = url.trim();
|
||||||
|
if (u.startsWith('http://') || u.startsWith('https://')) return u;
|
||||||
|
final base = ApiConfig.baseUrl;
|
||||||
|
final origin = base.replaceAll(RegExp(r'/api/v1.*'), '');
|
||||||
|
return u.startsWith('/') ? '$origin$u' : '$origin/$u';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_showRefusForm) {
|
||||||
|
return _buildRefusPage();
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Expanded(child: _buildStepContent()),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildNavigation(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStepContent() {
|
||||||
|
final d = widget.dossier;
|
||||||
|
switch (_step) {
|
||||||
|
case 0:
|
||||||
|
return ValidationDetailSection(
|
||||||
|
title: 'Parent principal',
|
||||||
|
fields: _parentFields(d.parents.first),
|
||||||
|
rowLayout: _parentRowLayout,
|
||||||
|
rowFlex: _parentRowFlex,
|
||||||
|
);
|
||||||
|
case 1:
|
||||||
|
return _buildParent2Step();
|
||||||
|
case 2:
|
||||||
|
return _buildEnfantsStep();
|
||||||
|
case 3:
|
||||||
|
return _buildPresentationStep();
|
||||||
|
default:
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildParent2Step() {
|
||||||
|
if (widget.dossier.parents.length < 2) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Text('Un seul parent pour ce dossier.',
|
||||||
|
style: TextStyle(color: Colors.black87)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ValidationDetailSection(
|
||||||
|
title: 'Deuxième parent',
|
||||||
|
fields: _parentFields(widget.dossier.parents[1]),
|
||||||
|
rowLayout: _parentRowLayout,
|
||||||
|
rowFlex: _parentRowFlex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static const double _idPhotoAspectRatio = 35 / 45;
|
||||||
|
|
||||||
|
Widget _buildEnfantsStep() {
|
||||||
|
final enfants = widget.dossier.enfants;
|
||||||
|
if (enfants.isEmpty) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Text('Aucun enfant renseigné.',
|
||||||
|
style: TextStyle(color: Colors.black87)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Enfants',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final cardHeight = constraints.maxHeight;
|
||||||
|
// Carte large : 1/3 photo + 2/3 champs (scroll horizontal si plusieurs enfants).
|
||||||
|
final cardWidth = (cardHeight * 1.72).clamp(500.0, 700.0);
|
||||||
|
return NotificationListener<ScrollMetricsNotification>(
|
||||||
|
onNotification: (_) {
|
||||||
|
_syncEnfantsScrollFades();
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: ShaderMask(
|
||||||
|
blendMode: BlendMode.dstIn,
|
||||||
|
shaderCallback: (Rect bounds) {
|
||||||
|
final stops = <double>[
|
||||||
|
0.0,
|
||||||
|
_enfantsFadeExtent,
|
||||||
|
1.0 - _enfantsFadeExtent,
|
||||||
|
1.0,
|
||||||
|
];
|
||||||
|
if (!_enfantsIsScrollable) {
|
||||||
|
return LinearGradient(
|
||||||
|
begin: Alignment.centerLeft,
|
||||||
|
end: Alignment.centerRight,
|
||||||
|
colors: const <Color>[
|
||||||
|
Colors.black,
|
||||||
|
Colors.black,
|
||||||
|
Colors.black,
|
||||||
|
Colors.black,
|
||||||
|
],
|
||||||
|
stops: stops,
|
||||||
|
).createShader(bounds);
|
||||||
|
}
|
||||||
|
final leftMask =
|
||||||
|
_enfantsFadeLeft ? Colors.transparent : Colors.black;
|
||||||
|
final rightMask =
|
||||||
|
_enfantsFadeRight ? Colors.transparent : Colors.black;
|
||||||
|
return LinearGradient(
|
||||||
|
begin: Alignment.centerLeft,
|
||||||
|
end: Alignment.centerRight,
|
||||||
|
colors: <Color>[
|
||||||
|
leftMask,
|
||||||
|
Colors.black,
|
||||||
|
Colors.black,
|
||||||
|
rightMask,
|
||||||
|
],
|
||||||
|
stops: stops,
|
||||||
|
).createShader(bounds);
|
||||||
|
},
|
||||||
|
child: Listener(
|
||||||
|
onPointerSignal: (event) {
|
||||||
|
if (event is PointerScrollEvent &&
|
||||||
|
_enfantsScrollController.hasClients) {
|
||||||
|
final offset = _enfantsScrollController.offset +
|
||||||
|
event.scrollDelta.dy;
|
||||||
|
_enfantsScrollController.jumpTo(offset.clamp(
|
||||||
|
_enfantsScrollController.position.minScrollExtent,
|
||||||
|
_enfantsScrollController.position.maxScrollExtent,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: ListView.builder(
|
||||||
|
controller: _enfantsScrollController,
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: enfants.length,
|
||||||
|
itemBuilder: (_, i) => Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
right: i < enfants.length - 1 ? 16 : 0),
|
||||||
|
child: SizedBox(
|
||||||
|
width: cardWidth,
|
||||||
|
height: cardHeight,
|
||||||
|
child: _buildEnfantCard(enfants[i]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fond carte enfant : teintes très pastel ; bordure discrète ; accent léger (barre).
|
||||||
|
static const Color _enfantCardBoyBg = Color(0xFFF0F7FB);
|
||||||
|
static const Color _enfantCardBoyBorder = Color(0xFFE3EDF4);
|
||||||
|
static const Color _enfantCardGirlBg = Color(0xFFFCF5F8);
|
||||||
|
static const Color _enfantCardGirlBorder = Color(0xFFEAE3E7);
|
||||||
|
|
||||||
|
static const double _enfantCardRadius = 12;
|
||||||
|
|
||||||
|
static List<BoxShadow> _enfantCardShadows() => [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.06),
|
||||||
|
blurRadius: 14,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
static BoxDecoration _enfantCardDecoration(String? gender) {
|
||||||
|
final g = (gender ?? '').trim().toUpperCase();
|
||||||
|
if (g == 'H') {
|
||||||
|
return BoxDecoration(
|
||||||
|
color: _enfantCardBoyBg,
|
||||||
|
borderRadius: BorderRadius.circular(_enfantCardRadius),
|
||||||
|
border: Border.all(color: _enfantCardBoyBorder, width: 1),
|
||||||
|
boxShadow: _enfantCardShadows(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (g == 'F') {
|
||||||
|
return BoxDecoration(
|
||||||
|
color: _enfantCardGirlBg,
|
||||||
|
borderRadius: BorderRadius.circular(_enfantCardRadius),
|
||||||
|
border: Border.all(color: _enfantCardGirlBorder, width: 1),
|
||||||
|
boxShadow: _enfantCardShadows(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return BoxDecoration(
|
||||||
|
color: Colors.grey.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(_enfantCardRadius),
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
boxShadow: _enfantCardShadows(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Carte enfant : prénom pleine largeur, puis ligne photo 1/3 + colonne 2/3 (champs + statut hors TF si besoin).
|
||||||
|
Widget _buildEnfantCard(EnfantDossier e) {
|
||||||
|
final photoUrl = _fullPhotoUrl(e.photoUrl);
|
||||||
|
final columnStatusLabel = _enfantColumnStatusLabel(e);
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(_enfantCardRadius),
|
||||||
|
child: Container(
|
||||||
|
decoration: _enfantCardDecoration(e.gender),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||||
|
child: _enfantLabeledField('Prénom', _v(e.firstName)),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 1,
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, c) {
|
||||||
|
// Même marge gauche que le bloc « Prénom » (12) ; droite / haut / bas 8.
|
||||||
|
const padL = 12.0;
|
||||||
|
const padR = 8.0;
|
||||||
|
const padV = 8.0;
|
||||||
|
final maxW =
|
||||||
|
(c.maxWidth - padL - padR).clamp(0.0, double.infinity);
|
||||||
|
final maxH =
|
||||||
|
(c.maxHeight - 2 * padV).clamp(0.0, double.infinity);
|
||||||
|
const ar = _idPhotoAspectRatio;
|
||||||
|
double ph = maxH;
|
||||||
|
double pw = ph * ar;
|
||||||
|
if (pw > maxW) {
|
||||||
|
pw = maxW;
|
||||||
|
ph = pw / ar;
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(padL, padV, padR, padV),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: _buildEnfantPhotoSlot(photoUrl, pw, ph),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(4, 4, 14, 12),
|
||||||
|
child: columnStatusLabel == null
|
||||||
|
? SingleChildScrollView(
|
||||||
|
child: _buildEnfantInfoFields(e),
|
||||||
|
)
|
||||||
|
: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: _buildEnfantInfoFields(e),
|
||||||
|
),
|
||||||
|
SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
columnStatusLabel,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: GoogleFonts.merienda(
|
||||||
|
fontSize: 14,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.grey.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// « Scolarisé » / « Scolarisée » selon le genre enfant (`F` / sinon masculin par défaut).
|
||||||
|
static String _scolariseAccordeAuGenre(String? gender) {
|
||||||
|
final g = (gender ?? '').trim().toUpperCase();
|
||||||
|
if (g == 'F') return 'Scolarisée';
|
||||||
|
return 'Scolarisé';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statut dans la colonne 2/3 uniquement (pas de [ValidationReadOnlyField]) : scolarisé·e ou « À naître ».
|
||||||
|
/// `actif` : pas de ligne statut.
|
||||||
|
String? _enfantColumnStatusLabel(EnfantDossier e) {
|
||||||
|
final s = (e.status ?? '').trim().toLowerCase();
|
||||||
|
if (s == 'a_naitre') return 'À naître';
|
||||||
|
if (s == 'scolarise') return _scolariseAccordeAuGenre(e.gender);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nom ; date de naissance et genre sur une ligne (prénom au-dessus, pleine largeur).
|
||||||
|
Widget _buildEnfantInfoFields(EnfantDossier e) {
|
||||||
|
final isANaitre = (e.status ?? '').trim().toLowerCase() == 'a_naitre';
|
||||||
|
final dueDateRenseignee = e.dueDate != null && e.dueDate!.trim().isNotEmpty;
|
||||||
|
final dateValue = isANaitre
|
||||||
|
? (dueDateRenseignee ? '${_formatBirthDate(e.dueDate)} (P)' : '– (P)')
|
||||||
|
: _formatBirthDate(e.birthDate);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: _enfantLabeledField('Nom', _formatNom(e.lastName)),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: _enfantLabeledField('Date de naissance', dateValue),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: _enfantLabeledField(
|
||||||
|
'Genre',
|
||||||
|
_genreEnfantLabel(e.gender, e.status),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _enfantLabeledField(String label, String value) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
ValidationReadOnlyField(value: value),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEnfantPhotoSlot(String photoUrl, double width, double height) {
|
||||||
|
return Container(
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.black.withValues(alpha: 0.08)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 6,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: photoUrl.isEmpty
|
||||||
|
? ColoredBox(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(Icons.person_outline, size: 32, color: Colors.grey.shade400),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Image.network(
|
||||||
|
photoUrl,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
errorBuilder: (_, __, ___) => ColoredBox(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(Icons.broken_image_outlined, size: 32, color: Colors.grey.shade400),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _formatNom(String? lastName) {
|
||||||
|
final n = (lastName ?? '').trim().toUpperCase();
|
||||||
|
return n.isEmpty ? 'Non défini' : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Genre enfant : Garçon, Fille, ou "Non connu" (uniquement si l'enfant est à naître).
|
||||||
|
static String _genreEnfantLabel(String? gender, String? status) {
|
||||||
|
final g = (gender ?? '').trim().toUpperCase();
|
||||||
|
final isANaitre = (status ?? '').trim().toLowerCase() == 'a_naitre';
|
||||||
|
if (g == 'H') return 'Garçon';
|
||||||
|
if (g == 'F') return 'Fille';
|
||||||
|
if (isANaitre) return 'Non connu';
|
||||||
|
if (g.isEmpty) return 'Non défini';
|
||||||
|
return (gender ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPresentationStep() {
|
||||||
|
final p = widget.dossier.presentation ?? '';
|
||||||
|
final text = p.trim().isEmpty ? 'Non défini' : p;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Présentation / Motivation',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Expanded(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
child: SelectableText(
|
||||||
|
text,
|
||||||
|
style:
|
||||||
|
const TextStyle(color: Colors.black87, fontSize: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNavigation() {
|
||||||
|
if (_step == 3) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
|
||||||
|
const Spacer(),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => _step = 2);
|
||||||
|
_emitStep();
|
||||||
|
},
|
||||||
|
child: const Text('Précédent'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (_isEnAttente && _firstParentId != null) ...[
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: _submitting ? null : _refuser,
|
||||||
|
child: const Text('Refuser')),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ValidationModalTheme.primaryElevatedStyle,
|
||||||
|
onPressed: _submitting ? null : _onValiderPressed,
|
||||||
|
child: Text(_submitting ? 'Envoi...' : 'Valider'),
|
||||||
|
),
|
||||||
|
] else if (!_isEnAttente)
|
||||||
|
ElevatedButton(
|
||||||
|
style: ValidationModalTheme.primaryElevatedStyle,
|
||||||
|
onPressed: widget.onClose,
|
||||||
|
child: const Text('Fermer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
|
||||||
|
const Spacer(),
|
||||||
|
if (_step > 0) ...[
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => _step--);
|
||||||
|
_emitStep();
|
||||||
|
},
|
||||||
|
child: const Text('Précédent'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
ElevatedButton(
|
||||||
|
style: ValidationModalTheme.primaryElevatedStyle,
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => _step++);
|
||||||
|
_emitStep();
|
||||||
|
},
|
||||||
|
child: const Text('Suivant'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onValiderPressed() async {
|
||||||
|
if (_submitting || _firstParentId == null) return;
|
||||||
|
final ok = await showValidationValiderConfirmDialog(
|
||||||
|
context,
|
||||||
|
body:
|
||||||
|
'Voulez-vous valider ce dossier famille ? Les comptes parents concernés seront confirmés.',
|
||||||
|
);
|
||||||
|
if (!mounted || !ok) return;
|
||||||
|
await _valider();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _valider() async {
|
||||||
|
if (_submitting || _firstParentId == null) return;
|
||||||
|
setState(() => _submitting = true);
|
||||||
|
try {
|
||||||
|
await UserService.validerDossierFamille(_firstParentId!);
|
||||||
|
if (!mounted) return;
|
||||||
|
widget.onSuccess();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(e is Exception
|
||||||
|
? e.toString().replaceFirst('Exception: ', '')
|
||||||
|
: 'Erreur'),
|
||||||
|
backgroundColor: Colors.red.shade700,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _submitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _refuser() => setState(() => _showRefusForm = true);
|
||||||
|
|
||||||
|
Widget _buildRefusPage() {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ValidationRefusForm(
|
||||||
|
onCancel: widget.onClose,
|
||||||
|
onPrevious: () => setState(() => _showRefusForm = false),
|
||||||
|
onSubmit: (comment) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Refus (à brancher sur l’API refus – ticket #110)')),
|
||||||
|
);
|
||||||
|
widget.onClose();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
frontend/lib/widgets/admin/validation_modal_theme.dart
Normal file
18
frontend/lib/widgets/admin/validation_modal_theme.dart
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Couleurs / styles communs aux modales de validation (cohérent avec le violet / lavande admin).
|
||||||
|
abstract final class ValidationModalTheme {
|
||||||
|
/// Violet pastel foncé (proche des cartes admin, ex. `0xFF6D4EA1`).
|
||||||
|
static const Color primaryActionBackground = Color(0xFF6D4EA1);
|
||||||
|
static const Color primaryActionForeground = Colors.white;
|
||||||
|
|
||||||
|
static ButtonStyle get primaryElevatedStyle {
|
||||||
|
return ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: primaryActionBackground,
|
||||||
|
foregroundColor: primaryActionForeground,
|
||||||
|
disabledBackgroundColor: primaryActionBackground.withOpacity(0.45),
|
||||||
|
disabledForegroundColor: primaryActionForeground.withOpacity(0.7),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
123
frontend/lib/widgets/admin/validation_refus_form.dart
Normal file
123
frontend/lib/widgets/admin/validation_refus_form.dart
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'validation_modal_theme.dart';
|
||||||
|
|
||||||
|
/// Page « Motifs du refus » : champ libre + Annuler (ferme la modale), Précédent (retour au choix Valider/Refuser), Envoyer. Ticket #107.
|
||||||
|
class ValidationRefusForm extends StatefulWidget {
|
||||||
|
/// Ferme la modale (abandon du flux).
|
||||||
|
final VoidCallback onCancel;
|
||||||
|
/// Retour à l’étape précédente du wizard (écran avec Valider / Refuser).
|
||||||
|
final VoidCallback onPrevious;
|
||||||
|
final ValueChanged<String?> onSubmit;
|
||||||
|
|
||||||
|
const ValidationRefusForm({
|
||||||
|
super.key,
|
||||||
|
required this.onCancel,
|
||||||
|
required this.onPrevious,
|
||||||
|
required this.onSubmit,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ValidationRefusForm> createState() => _ValidationRefusFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ValidationRefusFormState extends State<ValidationRefusForm> {
|
||||||
|
final _controller = TextEditingController();
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
static const int _minLength = 20;
|
||||||
|
|
||||||
|
String? _validateMotifs(String? value) {
|
||||||
|
final t = value?.trim() ?? '';
|
||||||
|
if (t.isEmpty) return 'Les motifs du refus sont obligatoires.';
|
||||||
|
if (t.length < _minLength) {
|
||||||
|
return 'Veuillez indiquer au moins $_minLength caractères (${t.length}/$_minLength).';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Indiquez les motifs du refus',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
return Container(
|
||||||
|
constraints: BoxConstraints.tight(Size(constraints.maxWidth, constraints.maxHeight)),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
border: Border.all(color: Colors.grey.shade400),
|
||||||
|
),
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _controller,
|
||||||
|
maxLines: null,
|
||||||
|
minLines: 1,
|
||||||
|
validator: _validateMotifs,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Saisissez les raisons du refus (minimum $_minLength caractères)',
|
||||||
|
border: InputBorder.none,
|
||||||
|
enabledBorder: InputBorder.none,
|
||||||
|
focusedBorder: InputBorder.none,
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
filled: false,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: widget.onCancel,
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: widget.onPrevious,
|
||||||
|
child: const Text('Précédent'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ValidationModalTheme.primaryElevatedStyle,
|
||||||
|
onPressed: () {
|
||||||
|
if (_formKey.currentState!.validate()) {
|
||||||
|
widget.onSubmit(_controller.text.trim());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Envoyer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'validation_modal_theme.dart';
|
||||||
|
|
||||||
|
/// Affiche une confirmation avant d’appeler l’API de validation du dossier.
|
||||||
|
/// Retourne `true` si l’utilisateur confirme.
|
||||||
|
Future<bool> showValidationValiderConfirmDialog(
|
||||||
|
BuildContext context, {
|
||||||
|
required String body,
|
||||||
|
}) async {
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Confirmer la validation'),
|
||||||
|
content: Text(body),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ValidationModalTheme.primaryElevatedStyle,
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
|
child: const Text('Confirmer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return result == true;
|
||||||
|
}
|
||||||
@ -15,8 +15,8 @@ class DashboardTabItem {
|
|||||||
|
|
||||||
/// Icône associée au rôle utilisateur (alignée sur le panneau admin).
|
/// Icône associée au rôle utilisateur (alignée sur le panneau admin).
|
||||||
IconData _iconForRole(String? role) {
|
IconData _iconForRole(String? role) {
|
||||||
if (role == null || role.isEmpty) return Icons.person_outline;
|
final r = (role ?? '').trim().toLowerCase();
|
||||||
final r = role.toLowerCase();
|
if (r.isEmpty) return Icons.person_outline;
|
||||||
if (r == 'super_admin') return Icons.verified_user_outlined;
|
if (r == 'super_admin') return Icons.verified_user_outlined;
|
||||||
if (r == 'admin' || r == 'administrateur') return Icons.manage_accounts_outlined;
|
if (r == 'admin' || r == 'administrateur') return Icons.manage_accounts_outlined;
|
||||||
if (r == 'gestionnaire') return Icons.assignment_ind_outlined;
|
if (r == 'gestionnaire') return Icons.assignment_ind_outlined;
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import '../utils/nir_utils.dart';
|
|||||||
import 'custom_app_text_field.dart';
|
import 'custom_app_text_field.dart';
|
||||||
|
|
||||||
/// Champ de saisie dédié au NIR (Numéro d'Inscription au Répertoire – 15 caractères).
|
/// Champ de saisie dédié au NIR (Numéro d'Inscription au Répertoire – 15 caractères).
|
||||||
/// Format affiché : 1 12 34 56 789 012-34 ou 1 12 34 2A 789 012-34 pour la Corse.
|
/// Format affiché : 1 12 34 56 789 012 - 34 ou 1 12 34 2A 789 012 - 34 pour la Corse.
|
||||||
/// La valeur envoyée au [controller] est formatée ; utiliser [normalizeNir](controller.text) à la soumission.
|
/// La valeur envoyée au [controller] est formatée ; utiliser [normalizeNir](controller.text) à la soumission.
|
||||||
class NirTextField extends StatelessWidget {
|
class NirTextField extends StatelessWidget {
|
||||||
final TextEditingController controller;
|
final TextEditingController controller;
|
||||||
@ -23,7 +23,7 @@ class NirTextField extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
this.labelText = 'N° Sécurité Sociale (NIR)',
|
this.labelText = 'N° Sécurité Sociale (NIR)',
|
||||||
this.hintText = '15 car. (ex. 1 12 34 56 789 012-34 ou 2A Corse)',
|
this.hintText = '15 car. (ex. 1 12 34 56 789 012 - 34 ou 2A Corse)',
|
||||||
this.validator,
|
this.validator,
|
||||||
this.fieldWidth = double.infinity,
|
this.fieldWidth = double.infinity,
|
||||||
this.fieldHeight = 53.0,
|
this.fieldHeight = 53.0,
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
@ -92,7 +94,7 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
_lastNameController = TextEditingController(text: widget.initialData.lastName);
|
_lastNameController = TextEditingController(text: widget.initialData.lastName);
|
||||||
_firstNameController = TextEditingController(text: widget.initialData.firstName);
|
_firstNameController = TextEditingController(text: widget.initialData.firstName);
|
||||||
_phoneController = TextEditingController(text: widget.initialData.phone);
|
_phoneController = TextEditingController(text: formatPhoneForDisplay(widget.initialData.phone));
|
||||||
_emailController = TextEditingController(text: widget.initialData.email);
|
_emailController = TextEditingController(text: widget.initialData.email);
|
||||||
_addressController = TextEditingController(text: widget.initialData.address);
|
_addressController = TextEditingController(text: widget.initialData.address);
|
||||||
_postalCodeController = TextEditingController(text: widget.initialData.postalCode);
|
_postalCodeController = TextEditingController(text: widget.initialData.postalCode);
|
||||||
@ -134,7 +136,7 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
|||||||
final data = PersonalInfoData(
|
final data = PersonalInfoData(
|
||||||
firstName: _firstNameController.text,
|
firstName: _firstNameController.text,
|
||||||
lastName: _lastNameController.text,
|
lastName: _lastNameController.text,
|
||||||
phone: _phoneController.text,
|
phone: normalizePhone(_phoneController.text),
|
||||||
email: _emailController.text,
|
email: _emailController.text,
|
||||||
address: _addressController.text,
|
address: _addressController.text,
|
||||||
postalCode: _postalCodeController.text,
|
postalCode: _postalCodeController.text,
|
||||||
@ -631,6 +633,7 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
|||||||
hint: 'Votre numéro de téléphone',
|
hint: 'Votre numéro de téléphone',
|
||||||
keyboardType: TextInputType.phone,
|
keyboardType: TextInputType.phone,
|
||||||
enabled: _fieldsEnabled,
|
enabled: _fieldsEnabled,
|
||||||
|
inputFormatters: _phoneInputFormatters,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 20),
|
const SizedBox(width: 20),
|
||||||
@ -732,7 +735,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
|||||||
child: _buildDisplayFieldValue(
|
child: _buildDisplayFieldValue(
|
||||||
context,
|
context,
|
||||||
'Téléphone :',
|
'Téléphone :',
|
||||||
_phoneController.text,
|
_phoneController.text.trim().isNotEmpty
|
||||||
|
? formatPhoneForDisplay(_phoneController.text)
|
||||||
|
: _phoneController.text,
|
||||||
labelFontSize: labelFontSize,
|
labelFontSize: labelFontSize,
|
||||||
valueFontSize: valueFontSize,
|
valueFontSize: valueFontSize,
|
||||||
),
|
),
|
||||||
@ -868,6 +873,7 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
|||||||
hint: 'Votre numéro de téléphone',
|
hint: 'Votre numéro de téléphone',
|
||||||
keyboardType: TextInputType.phone,
|
keyboardType: TextInputType.phone,
|
||||||
enabled: _fieldsEnabled,
|
enabled: _fieldsEnabled,
|
||||||
|
inputFormatters: _phoneInputFormatters,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
@ -923,13 +929,17 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
|||||||
String? hint,
|
String? hint,
|
||||||
TextInputType? keyboardType,
|
TextInputType? keyboardType,
|
||||||
bool enabled = true,
|
bool enabled = true,
|
||||||
|
List<TextInputFormatter>? inputFormatters,
|
||||||
}) {
|
}) {
|
||||||
if (config.isReadonly) {
|
if (config.isReadonly) {
|
||||||
// Mode readonly : utiliser FormFieldWrapper
|
// Mode readonly : utiliser FormFieldWrapper (téléphone formaté pour affichage)
|
||||||
|
final displayValue = label == 'Téléphone' && controller.text.trim().isNotEmpty
|
||||||
|
? formatPhoneForDisplay(controller.text)
|
||||||
|
: controller.text;
|
||||||
return FormFieldWrapper(
|
return FormFieldWrapper(
|
||||||
config: config,
|
config: config,
|
||||||
label: label,
|
label: label,
|
||||||
value: controller.text,
|
value: displayValue,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Mode éditable : style adapté mobile/desktop
|
// Mode éditable : style adapté mobile/desktop
|
||||||
@ -944,10 +954,17 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
|||||||
inputFontSize: config.isMobile ? 14.0 : 20.0,
|
inputFontSize: config.isMobile ? 14.0 : 20.0,
|
||||||
keyboardType: keyboardType ?? TextInputType.text,
|
keyboardType: keyboardType ?? TextInputType.text,
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
|
inputFormatters: inputFormatters,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static final _phoneInputFormatters = [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(10),
|
||||||
|
FrenchPhoneNumberFormatter(),
|
||||||
|
];
|
||||||
|
|
||||||
/// Retourne l'asset de carte vertical correspondant à la couleur
|
/// Retourne l'asset de carte vertical correspondant à la couleur
|
||||||
String _getVerticalCardAsset() {
|
String _getVerticalCardAsset() {
|
||||||
switch (widget.cardColor) {
|
switch (widget.cardColor) {
|
||||||
|
|||||||
@ -529,7 +529,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// NIR formaté pour affichage (1 12 34 56 789 012-34 ou 2A pour la Corse).
|
/// NIR formaté pour affichage (1 12 34 56 789 012 - 34 ou 2A pour la Corse).
|
||||||
String _formatNirForDisplay(String value) {
|
String _formatNirForDisplay(String value) {
|
||||||
final raw = nirToRaw(value);
|
final raw = nirToRaw(value);
|
||||||
return raw.length == 15 ? formatNir(raw) : value;
|
return raw.length == 15 ? formatNir(raw) : value;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user