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:cov": "jest --coverage",
|
||||
"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": {
|
||||
"@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 { DocumentsLegauxModule } from './modules/documents-legaux';
|
||||
import { RelaisModule } from './routes/relais/relais.module';
|
||||
import { DossiersModule } from './routes/dossiers/dossiers.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -55,6 +56,7 @@ import { RelaisModule } from './routes/relais/relais.module';
|
||||
AppConfigModule,
|
||||
DocumentsLegauxModule,
|
||||
RelaisModule,
|
||||
DossiersModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
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>,
|
||||
@InjectRepository(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.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);
|
||||
if (coParentExiste) {
|
||||
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à');
|
||||
}
|
||||
|
||||
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>(
|
||||
'password_reset_token_expiry_days',
|
||||
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 { 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 {}
|
||||
|
||||
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 {
|
||||
@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)',
|
||||
})
|
||||
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 { User } from 'src/common/decorators/user.decorator';
|
||||
import { PendingFamilyDto } from './dto/pending-family.dto';
|
||||
import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto';
|
||||
|
||||
@ApiTags('Parents')
|
||||
@Controller('parents')
|
||||
@ -33,12 +34,28 @@ export class ParentsController {
|
||||
@Get('pending-families')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@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é' })
|
||||
getPendingFamilies(): Promise<PendingFamilyDto[]> {
|
||||
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')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({ summary: 'Valider tout le dossier famille (les 2 parents en une fois)' })
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { Module, forwardRef } 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 { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity';
|
||||
import { ParentsController } from './parents.controller';
|
||||
import { ParentsService } from './parents.service';
|
||||
import { Users } from 'src/entities/users.entity';
|
||||
@ -8,8 +11,16 @@ import { UserModule } from '../user/user.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Parents, Users]),
|
||||
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant]),
|
||||
forwardRef(() => UserModule),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get('jwt.accessSecret'),
|
||||
signOptions: { expiresIn: config.get('jwt.accessExpiresIn') },
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
controllers: [ParentsController],
|
||||
providers: [ParentsService],
|
||||
|
||||
@ -5,12 +5,18 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { DossierFamille } from 'src/entities/dossier_famille.entity';
|
||||
import { RoleType, Users } from 'src/entities/users.entity';
|
||||
import { CreateParentDto } from '../user/dto/create_parent.dto';
|
||||
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
|
||||
import { PendingFamilyDto } from './dto/pending-family.dto';
|
||||
import {
|
||||
DossierFamilleCompletDto,
|
||||
DossierFamilleParentDto,
|
||||
DossierFamilleEnfantDto,
|
||||
} from './dto/dossier-famille-complet.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ParentsService {
|
||||
@ -19,6 +25,8 @@ export class ParentsService {
|
||||
private readonly parentsRepository: Repository<Parents>,
|
||||
@InjectRepository(Users)
|
||||
private readonly usersRepository: Repository<Users>,
|
||||
@InjectRepository(DossierFamille)
|
||||
private readonly dossierFamilleRepository: Repository<DossierFamille>,
|
||||
) {}
|
||||
|
||||
// Création d’un parent
|
||||
@ -79,47 +87,214 @@ export class ParentsService {
|
||||
* Uniquement les parents dont l'utilisateur a statut = en_attente.
|
||||
*/
|
||||
async getPendingFamilies(): Promise<PendingFamilyDto[]> {
|
||||
const raw = await this.parentsRepository.query(`
|
||||
WITH RECURSIVE
|
||||
links AS (
|
||||
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
|
||||
SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT ep1.id_parent AS p1, ep2.id_parent AS p2
|
||||
FROM enfants_parents ep1
|
||||
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
||||
UNION ALL
|
||||
SELECT ep2.id_parent AS p1, ep1.id_parent AS p2
|
||||
FROM enfants_parents ep1
|
||||
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
||||
),
|
||||
rec AS (
|
||||
SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents
|
||||
UNION
|
||||
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
|
||||
),
|
||||
family_rep AS (
|
||||
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
|
||||
)
|
||||
SELECT
|
||||
'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",
|
||||
(array_agg(p.numero_dossier))[1] AS numero_dossier
|
||||
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
|
||||
`);
|
||||
return raw.map((r: { libelle: string; parentIds: unknown; numero_dossier: string | null }) => ({
|
||||
libelle: r.libelle,
|
||||
parentIds: Array.isArray(r.parentIds) ? r.parentIds.map(String) : [],
|
||||
let raw: {
|
||||
libelle: string;
|
||||
parentIds: unknown;
|
||||
numero_dossier: string | null;
|
||||
date_soumission: Date | string | null;
|
||||
nombre_enfants: string | number | null;
|
||||
emails: unknown;
|
||||
parents: unknown;
|
||||
}[];
|
||||
try {
|
||||
raw = await this.parentsRepository.query(`
|
||||
WITH RECURSIVE
|
||||
links AS (
|
||||
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
|
||||
SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT ep1.id_parent AS p1, ep2.id_parent AS p2
|
||||
FROM enfants_parents ep1
|
||||
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
||||
UNION ALL
|
||||
SELECT ep2.id_parent AS p1, ep1.id_parent AS p2
|
||||
FROM enfants_parents ep1
|
||||
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
||||
),
|
||||
rec AS (
|
||||
SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents
|
||||
UNION
|
||||
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
|
||||
),
|
||||
family_rep AS (
|
||||
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
|
||||
)
|
||||
SELECT
|
||||
'Famille ' || string_agg(u.nom, ' - ' ORDER BY u.nom, u.prenom) AS libelle,
|
||||
array_agg(p.id_utilisateur ORDER BY u.nom, u.prenom, u.id) AS "parentIds",
|
||||
(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,
|
||||
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).
|
||||
* @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é |
|
||||
| 21 | [Backend] API Inscription Parent (étape 4-6 - Finalisation) | ✅ Terminé |
|
||||
| 24 | [Backend] API Création mot de passe | Ouvert |
|
||||
| 25 | [Backend] API Liste comptes en attente | Ouvert |
|
||||
| 26 | [Backend] API Validation/Refus comptes | Ouvert |
|
||||
| 27 | [Backend] Service Email - Installation Nodemailer | Ouvert |
|
||||
| 25 | [Backend] API Liste comptes en attente | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||
| 26 | [Backend] API Validation/Refus comptes | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||
| 27 | [Backend] Service Email - Installation Nodemailer | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||
| 28 | [Backend] Templates Email - Validation | Ouvert |
|
||||
| 29 | [Backend] Templates Email - Refus | Ouvert |
|
||||
| 30 | [Backend] Connexion - Vérification statut | Ouvert |
|
||||
| 31 | [Backend] Changement MDP obligatoire première connexion | Ouvert |
|
||||
| 29 | [Backend] Templates Email - Refus | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||
| 30 | [Backend] Connexion - Vérification statut | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||
| 31 | [Backend] Changement MDP obligatoire première connexion | ✅ Terminé |
|
||||
| 32 | [Backend] Service Documents Légaux | Ouvert |
|
||||
| 33 | [Backend] API Documents Légaux | Ouvert |
|
||||
| 34 | [Backend] Traçabilité acceptations documents | Ouvert |
|
||||
@ -53,8 +53,8 @@
|
||||
| 42 | [Frontend] Inscription AM - Finalisation | ✅ Terminé |
|
||||
| 43 | [Frontend] Écran Création Mot de Passe | Ouvert |
|
||||
| 44 | [Frontend] Dashboard Gestionnaire - Structure | ✅ Terminé |
|
||||
| 45 | [Frontend] Dashboard Gestionnaire - Liste Parents | Ouvert |
|
||||
| 46 | [Frontend] Dashboard Gestionnaire - Liste AM | Ouvert |
|
||||
| 45 | [Frontend] Dashboard Gestionnaire - Liste Parents | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||
| 46 | [Frontend] Dashboard Gestionnaire - Liste AM | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||
| 47 | [Frontend] Écran Changement MDP Obligatoire | Ouvert |
|
||||
| 48 | [Frontend] Gestion Erreurs & Messages | 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*
|
||||
|
||||
*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
|
||||
**Labels** : `backend`, `p2`, `auth`, `security`
|
||||
**Labels** : `backend`, `p2`, `auth`, `security`
|
||||
**Statut** : ✅ TERMINÉ
|
||||
|
||||
**Description** :
|
||||
Implémenter le changement de mot de passe obligatoire pour les gestionnaires/admins à la première connexion.
|
||||
|
||||
**Tâches** :
|
||||
- [ ] Endpoint `POST /api/v1/auth/change-password-required`
|
||||
- [ ] Vérification flag `changement_mdp_obligatoire`
|
||||
- [ ] Mise à jour flag après changement
|
||||
- [x] Endpoint `POST /api/v1/auth/change-password-required`
|
||||
- [x] Vérification flag `changement_mdp_obligatoire`
|
||||
- [x] Mise à jour flag après changement
|
||||
- [ ] 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? relaisId;
|
||||
final String? relaisNom;
|
||||
final String? numeroDossier;
|
||||
|
||||
AppUser({
|
||||
required this.id,
|
||||
@ -33,40 +34,50 @@ class AppUser {
|
||||
this.codePostal,
|
||||
this.relaisId,
|
||||
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) {
|
||||
final relaisJson = json['relais'];
|
||||
final relaisMap =
|
||||
relaisJson is Map<String, dynamic> ? relaisJson : <String, dynamic>{};
|
||||
|
||||
return AppUser(
|
||||
id: (json['id'] as String?) ?? '',
|
||||
email: (json['email'] as String?) ?? '',
|
||||
role: (json['role'] as String?) ?? '',
|
||||
createdAt: json['cree_le'] != null
|
||||
? DateTime.parse(json['cree_le'] as String)
|
||||
: (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()),
|
||||
id: _str(json['id']),
|
||||
email: _str(json['email']),
|
||||
role: _str(json['role']),
|
||||
createdAt: _date(json['cree_le'] ?? json['createdAt']),
|
||||
updatedAt: _date(json['modifie_le'] ?? json['updatedAt']),
|
||||
changementMdpObligatoire:
|
||||
json['changement_mdp_obligatoire'] as bool? ?? false,
|
||||
nom: json['nom'] as String?,
|
||||
prenom: json['prenom'] as String?,
|
||||
statut: json['statut'] as String?,
|
||||
telephone: json['telephone'] as String?,
|
||||
photoUrl: json['photo_url'] as String?,
|
||||
adresse: json['adresse'] as String?,
|
||||
ville: json['ville'] as String?,
|
||||
codePostal: json['code_postal'] as String?,
|
||||
json['changement_mdp_obligatoire'] == true,
|
||||
nom: json['nom'] is String ? json['nom'] as String : null,
|
||||
prenom: json['prenom'] is String ? json['prenom'] as String : null,
|
||||
statut: json['statut'] is String ? json['statut'] as String : null,
|
||||
telephone: json['telephone'] is String ? json['telephone'] as String : null,
|
||||
photoUrl: json['photo_url'] is String ? json['photo_url'] as String : null,
|
||||
adresse: json['adresse'] is String ? json['adresse'] as String : null,
|
||||
ville: json['ville'] is String ? json['ville'] as String : null,
|
||||
codePostal: json['code_postal'] is String ? json['code_postal'] as String : null,
|
||||
relaisId: (json['relaisId'] ?? json['relais_id'] ?? relaisMap['id'])
|
||||
?.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,
|
||||
'relais_id': relaisId,
|
||||
'relais_nom': relaisNom,
|
||||
'numero_dossier': numeroDossier,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
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/services/user_service.dart';
|
||||
|
||||
@ -34,7 +36,7 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
|
||||
_nomController.text = user.nom ?? '';
|
||||
_prenomController.text = user.prenom ?? '';
|
||||
_emailController.text = user.email;
|
||||
_telephoneController.text = user.telephone ?? '';
|
||||
_telephoneController.text = formatPhoneForDisplay(user.telephone ?? '');
|
||||
// En édition, on ne préremplit jamais le mot de passe.
|
||||
_passwordController.clear();
|
||||
}
|
||||
@ -91,7 +93,7 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
|
||||
nom: _nomController.text.trim(),
|
||||
prenom: _prenomController.text.trim(),
|
||||
email: _emailController.text.trim(),
|
||||
telephone: _telephoneController.text.trim(),
|
||||
telephone: normalizePhone(_telephoneController.text),
|
||||
password: _passwordController.text.trim().isEmpty
|
||||
? null
|
||||
: _passwordController.text,
|
||||
@ -102,7 +104,7 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
|
||||
prenom: _prenomController.text.trim(),
|
||||
email: _emailController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
telephone: _telephoneController.text.trim(),
|
||||
telephone: normalizePhone(_telephoneController.text),
|
||||
);
|
||||
}
|
||||
if (!mounted) return;
|
||||
@ -347,8 +349,13 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
|
||||
return TextFormField(
|
||||
controller: _telephoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
FrenchPhoneNumberFormatter(),
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Téléphone',
|
||||
labelText: 'Téléphone (ex: 06 12 34 56 78)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (v) => _required(v, 'Téléphone'),
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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/services/relais_service.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
@ -40,12 +41,12 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
String? _selectedRelaisId;
|
||||
bool get _isEditMode => widget.initialUser != null;
|
||||
bool get _isSuperAdminTarget =>
|
||||
widget.initialUser?.role.toLowerCase() == 'super_admin';
|
||||
(widget.initialUser?.role ?? '').toLowerCase() == 'super_admin';
|
||||
bool get _isLockedAdminIdentity =>
|
||||
_isEditMode && widget.adminMode && _isSuperAdminTarget;
|
||||
String get _targetRoleKey {
|
||||
if (widget.initialUser != null) {
|
||||
return widget.initialUser!.role.toLowerCase();
|
||||
return (widget.initialUser!.role).toLowerCase();
|
||||
}
|
||||
return widget.adminMode ? 'administrateur' : 'gestionnaire';
|
||||
}
|
||||
@ -92,7 +93,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
_nomController.text = user.nom ?? '';
|
||||
_prenomController.text = user.prenom ?? '';
|
||||
_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.
|
||||
_passwordController.clear();
|
||||
final initialRelaisId = user.relaisId?.trim();
|
||||
@ -185,7 +186,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
}
|
||||
final base = _required(value, 'Téléphone');
|
||||
if (base != null) return base;
|
||||
final digits = _normalizePhone(value!);
|
||||
final digits = normalizePhone(value!);
|
||||
if (digits.length != 10) {
|
||||
return 'Le téléphone doit contenir 10 chiffres';
|
||||
}
|
||||
@ -195,22 +196,6 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
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) {
|
||||
final trimmed = raw.trim();
|
||||
if (trimmed.isEmpty) return trimmed;
|
||||
@ -251,7 +236,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
try {
|
||||
final normalizedNom = _toTitleCase(_nomController.text);
|
||||
final normalizedPrenom = _toTitleCase(_prenomController.text);
|
||||
final normalizedPhone = _normalizePhone(_telephoneController.text);
|
||||
final normalizedPhone = normalizePhone(_telephoneController.text);
|
||||
final passwordProvided = _passwordController.text.trim().isNotEmpty;
|
||||
|
||||
if (_isEditMode) {
|
||||
@ -264,7 +249,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
prenom: _isLockedAdminIdentity ? lockedPrenom : normalizedPrenom,
|
||||
email: _emailController.text.trim(),
|
||||
telephone: normalizedPhone.isEmpty
|
||||
? _normalizePhone(widget.initialUser!.telephone ?? '')
|
||||
? normalizePhone(widget.initialUser!.telephone ?? '')
|
||||
: normalizedPhone,
|
||||
password: passwordProvided ? _passwordController.text : null,
|
||||
);
|
||||
@ -273,7 +258,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
final initialNom = _toTitleCase(currentUser.nom ?? '');
|
||||
final initialPrenom = _toTitleCase(currentUser.prenom ?? '');
|
||||
final initialEmail = currentUser.email.trim();
|
||||
final initialPhone = _normalizePhone(currentUser.telephone ?? '');
|
||||
final initialPhone = normalizePhone(currentUser.telephone ?? '');
|
||||
|
||||
final onlyRelaisChanged =
|
||||
normalizedNom == initialNom &&
|
||||
@ -306,7 +291,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
prenom: normalizedPrenom,
|
||||
email: _emailController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
telephone: _normalizePhone(_telephoneController.text),
|
||||
telephone: normalizePhone(_telephoneController.text),
|
||||
);
|
||||
} else {
|
||||
await UserService.createGestionnaire(
|
||||
@ -314,7 +299,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
prenom: normalizedPrenom,
|
||||
email: _emailController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
telephone: _normalizePhone(_telephoneController.text),
|
||||
telephone: normalizePhone(_telephoneController.text),
|
||||
relaisId: _selectedRelaisId,
|
||||
);
|
||||
}
|
||||
@ -608,7 +593,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
_FrenchPhoneNumberFormatter(),
|
||||
FrenchPhoneNumberFormatter(),
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
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();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
||||
class _LoginPageState extends State<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
@ -27,31 +27,30 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
||||
|
||||
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
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_desktopRiverLogoDimensionsFuture = _getImageDimensions();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
super.didChangeMetrics();
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
String? _validateEmail(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
final v = value ?? '';
|
||||
if (v.isEmpty) {
|
||||
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 null;
|
||||
@ -116,7 +115,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
||||
TextInput.finishAutofillContext(shouldSave: true);
|
||||
|
||||
// Rediriger selon le rôle de l'utilisateur
|
||||
_redirectUserByRole(user.role);
|
||||
_redirectUserByRole(user.role.isEmpty ? null : user.role);
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
@ -126,9 +125,10 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
/// Redirige l'utilisateur selon son rôle (GoRouter : context.go).
|
||||
void _redirectUserByRole(String role) {
|
||||
void _redirectUserByRole(String? role) {
|
||||
setState(() => _isLoading = false);
|
||||
switch (role.toLowerCase()) {
|
||||
final r = (role ?? '').toLowerCase();
|
||||
switch (r) {
|
||||
case 'super_admin':
|
||||
case 'administrateur':
|
||||
context.go('/admin-dashboard');
|
||||
@ -162,8 +162,8 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
||||
builder: (context, constraints) {
|
||||
final w = constraints.maxWidth;
|
||||
final h = constraints.maxHeight;
|
||||
return FutureBuilder(
|
||||
future: _getImageDimensions(),
|
||||
return FutureBuilder<ImageDimensions>(
|
||||
future: _desktopRiverLogoDimensionsFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
@ -203,7 +203,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
||||
bottom: 0,
|
||||
width: w * 0.6, // 60% de la largeur 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
|
||||
child: AutofillGroup(
|
||||
child: Form(
|
||||
@ -240,7 +240,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
||||
hintText: 'Votre mot de passe',
|
||||
obscureText: true,
|
||||
autofillHints: const [
|
||||
AutofillHints.password
|
||||
AutofillHints.password,
|
||||
],
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted:
|
||||
|
||||
@ -19,6 +19,7 @@ class ApiConfig {
|
||||
static const String parents = '/parents';
|
||||
static const String assistantesMaternelles = '/assistantes-maternelles';
|
||||
static const String relais = '/relais';
|
||||
static const String dossiers = '/dossiers';
|
||||
|
||||
// Configuration (admin)
|
||||
static const String configuration = '/configuration';
|
||||
|
||||
@ -193,7 +193,10 @@ class AuthService {
|
||||
const fallback = 'Erreur lors de l\'inscription';
|
||||
if (decoded == null || decoded is! Map) return '$fallback ($statusCode)';
|
||||
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 List) return msg.map((e) => e.toString()).join('. ').trim();
|
||||
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/parent_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/tokenService.dart';
|
||||
|
||||
@ -20,6 +22,145 @@ class UserService {
|
||||
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é)
|
||||
static Future<List<AppUser>> getGestionnaires() async {
|
||||
final response = await http.get(
|
||||
|
||||
@ -49,12 +49,12 @@ String nirToRaw(String normalized) {
|
||||
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) {
|
||||
final r = nirToRaw(raw);
|
||||
if (r.length < 15) return r;
|
||||
// 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é.
|
||||
@ -82,7 +82,7 @@ String? validateNir(String? value) {
|
||||
if (value == null || value.isEmpty) return 'NIR requis';
|
||||
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 (!_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 keyStr = key >= 0 && key <= 99 ? key.toString().padLeft(2, '0') : '';
|
||||
final expectedKey = raw.substring(13, 15);
|
||||
@ -90,7 +90,7 @@ String? validateNir(String? value) {
|
||||
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 {
|
||||
@override
|
||||
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: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/services/auth_service.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
@ -60,18 +61,18 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
||||
if (!mounted) return;
|
||||
if (cached != null) {
|
||||
setState(() {
|
||||
_currentUserRole = cached.role.toLowerCase();
|
||||
_currentUserRole = (cached.role).toLowerCase();
|
||||
});
|
||||
return;
|
||||
}
|
||||
final refreshed = await AuthService.refreshCurrentUser();
|
||||
if (!mounted || refreshed == null) return;
|
||||
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) {
|
||||
if (!_isSuperAdmin(target)) return true;
|
||||
@ -123,7 +124,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
||||
: Icons.manage_accounts_outlined,
|
||||
subtitleLines: [
|
||||
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,
|
||||
borderColor: isSuperAdmin
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||
import 'package:p_tits_pas/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_user_card.dart';
|
||||
@ -126,7 +127,7 @@ class _AssistanteMaternelleManagementWidgetState
|
||||
),
|
||||
AdminDetailField(
|
||||
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: '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';
|
||||
|
||||
/// Sous-barre : Gestionnaires | Parents | Assistantes maternelles | [Administrateurs].
|
||||
/// [subTabCount] = 3 pour masquer l'onglet Administrateurs (dashboard gestionnaire).
|
||||
/// Sous-barre : [À valider] | Gestionnaires | Parents | Assistantes maternelles | [Administrateurs].
|
||||
/// [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 {
|
||||
final int selectedSubIndex;
|
||||
final ValueChanged<int> onSubTabChange;
|
||||
@ -11,8 +12,10 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
||||
final VoidCallback? onAddPressed;
|
||||
final String addLabel;
|
||||
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',
|
||||
'Parents',
|
||||
'Assistantes maternelles',
|
||||
@ -29,10 +32,14 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
||||
this.onAddPressed,
|
||||
this.addLabel = '+ Ajouter',
|
||||
this.subTabCount = 4,
|
||||
this.tabLabels,
|
||||
}) : super(key: key);
|
||||
|
||||
List<String> get _labels => tabLabels ?? _defaultTabLabels.sublist(0, subTabCount.clamp(1, _defaultTabLabels.length));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final labels = _labels;
|
||||
return Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
@ -42,9 +49,9 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
for (int i = 0; i < subTabCount; i++) ...[
|
||||
for (int i = 0; i < labels.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 12),
|
||||
_buildSubNavItem(context, _tabLabels[i], i),
|
||||
_buildSubNavItem(context, labels[i], i),
|
||||
],
|
||||
const SizedBox(width: 36),
|
||||
_pillField(
|
||||
@ -68,7 +75,7 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
||||
_pillField(width: 150, child: filterControl!),
|
||||
],
|
||||
const Spacer(),
|
||||
_buildAddButton(),
|
||||
if (onAddPressed != null) _buildAddButton(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/parent_model.dart';
|
||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||
@ -122,7 +123,7 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
||||
),
|
||||
AdminDetailField(
|
||||
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: '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/services.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';
|
||||
|
||||
class RelaisManagementPanel extends StatefulWidget {
|
||||
@ -723,7 +724,7 @@ class _RelaisFormDialogState extends State<_RelaisFormDialog> {
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
_FrenchPhoneNumberFormatter(),
|
||||
FrenchPhoneNumberFormatter(),
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
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 {
|
||||
final TextEditingController streetController;
|
||||
final TextEditingController postalCodeController;
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import 'package:flutter/material.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/assistante_maternelle_management_widget.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/parent_managmant_widget.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart';
|
||||
|
||||
class UserManagementPanel extends StatefulWidget {
|
||||
/// 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 _amCapacityController = TextEditingController();
|
||||
String? _parentStatus;
|
||||
bool _hasPending = false;
|
||||
bool _pendingLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchController.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
|
||||
@ -48,8 +79,17 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
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) {
|
||||
final maxIndex = widget.showAdministrateursTab ? 3 : 2;
|
||||
final maxIndex = _tabLabels.length - 1;
|
||||
setState(() {
|
||||
_subIndex = index.clamp(0, maxIndex);
|
||||
_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() {
|
||||
switch (_subIndex) {
|
||||
final contentIndex = _subIndex - _contentIndexOffset;
|
||||
switch (contentIndex) {
|
||||
case -1:
|
||||
return 'À valider (pas de recherche)';
|
||||
case 0:
|
||||
return 'Rechercher un gestionnaire...';
|
||||
case 1:
|
||||
return 'Rechercher un parent...';
|
||||
case 2:
|
||||
case 1:
|
||||
return 'Rechercher une assistante...';
|
||||
case 2:
|
||||
return 'Rechercher un gestionnaire...';
|
||||
case 3:
|
||||
return 'Rechercher un administrateur...';
|
||||
default:
|
||||
@ -74,7 +120,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
}
|
||||
|
||||
Widget? _subBarFilterControl() {
|
||||
if (_subIndex == 1) {
|
||||
if (_subIndex == _contentIndexOffset + 0) {
|
||||
return DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String?>(
|
||||
value: _parentStatus,
|
||||
@ -122,7 +168,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
);
|
||||
}
|
||||
|
||||
if (_subIndex == 2) {
|
||||
if (_subIndex == _contentIndexOffset + 1) {
|
||||
return TextField(
|
||||
controller: _amCapacityController,
|
||||
decoration: const InputDecoration(
|
||||
@ -139,22 +185,26 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
switch (_subIndex) {
|
||||
final contentIndex = _subIndex - _contentIndexOffset;
|
||||
if (_hasPending && !_pendingLoading && contentIndex == -1) {
|
||||
return PendingValidationWidget(onRefresh: _loadPending);
|
||||
}
|
||||
switch (contentIndex) {
|
||||
case 0:
|
||||
return GestionnaireManagementWidget(
|
||||
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
|
||||
searchQuery: _searchController.text,
|
||||
);
|
||||
case 1:
|
||||
return ParentManagementWidget(
|
||||
searchQuery: _searchController.text,
|
||||
statusFilter: _parentStatus,
|
||||
);
|
||||
case 2:
|
||||
case 1:
|
||||
return AssistanteMaternelleManagementWidget(
|
||||
searchQuery: _searchController.text,
|
||||
capacityMin: int.tryParse(_amCapacityController.text),
|
||||
);
|
||||
case 2:
|
||||
return GestionnaireManagementWidget(
|
||||
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
|
||||
searchQuery: _searchController.text,
|
||||
);
|
||||
case 3:
|
||||
return AdminManagementWidget(
|
||||
key: ValueKey('admins-$_adminRefreshTick'),
|
||||
@ -167,7 +217,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final subTabCount = widget.showAdministrateursTab ? 4 : 3;
|
||||
final labels = _tabLabels;
|
||||
final isAValiderTab = _hasPending && !_pendingLoading && _subIndex == 0;
|
||||
return Column(
|
||||
children: [
|
||||
DashboardUserManagementSubBar(
|
||||
@ -176,9 +227,10 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
searchController: _searchController,
|
||||
searchHint: _searchHintForTab(),
|
||||
filterControl: _subBarFilterControl(),
|
||||
onAddPressed: _handleAddPressed,
|
||||
onAddPressed: isAValiderTab ? null : _handleAddPressed,
|
||||
addLabel: 'Ajouter',
|
||||
subTabCount: subTabCount,
|
||||
subTabCount: labels.length,
|
||||
tabLabels: labels,
|
||||
),
|
||||
Expanded(child: _buildBody()),
|
||||
],
|
||||
@ -186,7 +238,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
}
|
||||
|
||||
Future<void> _handleAddPressed() async {
|
||||
if (_subIndex == 0) {
|
||||
final contentIndex = _subIndex - _contentIndexOffset;
|
||||
if (contentIndex == 2) {
|
||||
final created = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
@ -204,7 +257,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_subIndex == 3) {
|
||||
if (contentIndex == 3) {
|
||||
final created = await showDialog<bool>(
|
||||
context: context,
|
||||
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).
|
||||
IconData _iconForRole(String? role) {
|
||||
if (role == null || role.isEmpty) return Icons.person_outline;
|
||||
final r = role.toLowerCase();
|
||||
final r = (role ?? '').trim().toLowerCase();
|
||||
if (r.isEmpty) return Icons.person_outline;
|
||||
if (r == 'super_admin') return Icons.verified_user_outlined;
|
||||
if (r == 'admin' || r == 'administrateur') return Icons.manage_accounts_outlined;
|
||||
if (r == 'gestionnaire') return Icons.assignment_ind_outlined;
|
||||
|
||||
@ -4,7 +4,7 @@ import '../utils/nir_utils.dart';
|
||||
import 'custom_app_text_field.dart';
|
||||
|
||||
/// 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.
|
||||
class NirTextField extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
@ -23,7 +23,7 @@ class NirTextField extends StatelessWidget {
|
||||
super.key,
|
||||
required this.controller,
|
||||
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.fieldWidth = double.infinity,
|
||||
this.fieldHeight = 53.0,
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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 'dart:math' as math;
|
||||
|
||||
@ -92,7 +94,7 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
super.initState();
|
||||
_lastNameController = TextEditingController(text: widget.initialData.lastName);
|
||||
_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);
|
||||
_addressController = TextEditingController(text: widget.initialData.address);
|
||||
_postalCodeController = TextEditingController(text: widget.initialData.postalCode);
|
||||
@ -134,7 +136,7 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
final data = PersonalInfoData(
|
||||
firstName: _firstNameController.text,
|
||||
lastName: _lastNameController.text,
|
||||
phone: _phoneController.text,
|
||||
phone: normalizePhone(_phoneController.text),
|
||||
email: _emailController.text,
|
||||
address: _addressController.text,
|
||||
postalCode: _postalCodeController.text,
|
||||
@ -631,6 +633,7 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
hint: 'Votre numéro de téléphone',
|
||||
keyboardType: TextInputType.phone,
|
||||
enabled: _fieldsEnabled,
|
||||
inputFormatters: _phoneInputFormatters,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
@ -732,7 +735,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
child: _buildDisplayFieldValue(
|
||||
context,
|
||||
'Téléphone :',
|
||||
_phoneController.text,
|
||||
_phoneController.text.trim().isNotEmpty
|
||||
? formatPhoneForDisplay(_phoneController.text)
|
||||
: _phoneController.text,
|
||||
labelFontSize: labelFontSize,
|
||||
valueFontSize: valueFontSize,
|
||||
),
|
||||
@ -868,6 +873,7 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
hint: 'Votre numéro de téléphone',
|
||||
keyboardType: TextInputType.phone,
|
||||
enabled: _fieldsEnabled,
|
||||
inputFormatters: _phoneInputFormatters,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
@ -923,13 +929,17 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
String? hint,
|
||||
TextInputType? keyboardType,
|
||||
bool enabled = true,
|
||||
List<TextInputFormatter>? inputFormatters,
|
||||
}) {
|
||||
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(
|
||||
config: config,
|
||||
label: label,
|
||||
value: controller.text,
|
||||
value: displayValue,
|
||||
);
|
||||
} else {
|
||||
// Mode éditable : style adapté mobile/desktop
|
||||
@ -944,10 +954,17 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
inputFontSize: config.isMobile ? 14.0 : 20.0,
|
||||
keyboardType: keyboardType ?? TextInputType.text,
|
||||
enabled: enabled,
|
||||
inputFormatters: inputFormatters,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static final _phoneInputFormatters = [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
FrenchPhoneNumberFormatter(),
|
||||
];
|
||||
|
||||
/// Retourne l'asset de carte vertical correspondant à la couleur
|
||||
String _getVerticalCardAsset() {
|
||||
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) {
|
||||
final raw = nirToRaw(value);
|
||||
return raw.length == 15 ? formatNir(raw) : value;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user