From cde676c4f943a2e60be898735d3c394cdbe92c41 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 26 Mar 2026 00:20:47 +0100 Subject: [PATCH] feat: alignement master sur develop (squash) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/package.json | 3 +- backend/scripts/test-api-dossiers.js | 122 +++ backend/scripts/test-pending-api.js | 110 +++ .../update-gitea-issue-119-dossiers.js | 97 +++ backend/src/app.module.ts | 2 + .../src/entities/dossier_famille.entity.ts | 65 ++ backend/src/routes/auth/auth.service.ts | 28 + .../routes/dossiers/dossiers.controller.ts | 26 + .../src/routes/dossiers/dossiers.module.ts | 26 +- .../src/routes/dossiers/dossiers.service.ts | 81 ++ .../dossiers/dto/dossier-am-complet.dto.ts | 58 ++ .../routes/dossiers/dto/dossier-unifie.dto.ts | 14 + .../dto/dossier-famille-complet.dto.ts | 57 ++ .../routes/parents/dto/pending-family.dto.ts | 45 +- .../src/routes/parents/parents.controller.ts | 19 +- backend/src/routes/parents/parents.module.ts | 13 +- backend/src/routes/parents/parents.service.ts | 251 +++++- database/migrations/2026_dossier_famille.sql | 27 + .../2026_dossier_famille_simplifier.sql | 5 + docs/23_LISTE-TICKETS.md | 29 +- frontend/lib/models/dossier_unifie.dart | 210 +++++ frontend/lib/models/pending_family.dart | 232 ++++++ frontend/lib/models/user.dart | 56 +- .../creation/admin_create.dart | 15 +- .../creation/gestionnaires_create.dart | 61 +- frontend/lib/screens/auth/login_screen.dart | 36 +- frontend/lib/services/api/api_config.dart | 1 + frontend/lib/services/auth_service.dart | 5 +- frontend/lib/services/user_service.dart | 141 ++++ frontend/lib/utils/nir_utils.dart | 8 +- frontend/lib/utils/phone_utils.dart | 57 ++ .../admin/admin_management_widget.dart | 9 +- ...sistante_maternelle_management_widget.dart | 3 +- .../common/validation_detail_section.dart | 130 ++++ .../lib/widgets/admin/dashboard_admin.dart | 19 +- .../admin/parent_managmant_widget.dart | 3 +- .../admin/pending_validation_widget.dart | 354 +++++++++ .../admin/relais_management_panel.dart | 27 +- .../widgets/admin/user_management_panel.dart | 91 ++- .../widgets/admin/validation_am_wizard.dart | 507 ++++++++++++ .../admin/validation_dossier_modal.dart | 173 +++++ .../admin/validation_family_wizard.dart | 730 ++++++++++++++++++ .../widgets/admin/validation_modal_theme.dart | 18 + .../widgets/admin/validation_refus_form.dart | 123 +++ .../validation_valider_confirm_dialog.dart | 32 + .../widgets/dashboard/dashboard_bandeau.dart | 4 +- frontend/lib/widgets/nir_text_field.dart | 4 +- .../widgets/personal_info_form_screen.dart | 27 +- .../professional_info_form_screen.dart | 2 +- 49 files changed, 3934 insertions(+), 222 deletions(-) create mode 100644 backend/scripts/test-api-dossiers.js create mode 100644 backend/scripts/test-pending-api.js create mode 100644 backend/scripts/update-gitea-issue-119-dossiers.js create mode 100644 backend/src/entities/dossier_famille.entity.ts create mode 100644 backend/src/routes/dossiers/dossiers.controller.ts create mode 100644 backend/src/routes/dossiers/dossiers.service.ts create mode 100644 backend/src/routes/dossiers/dto/dossier-am-complet.dto.ts create mode 100644 backend/src/routes/dossiers/dto/dossier-unifie.dto.ts create mode 100644 backend/src/routes/parents/dto/dossier-famille-complet.dto.ts create mode 100644 database/migrations/2026_dossier_famille.sql create mode 100644 database/migrations/2026_dossier_famille_simplifier.sql create mode 100644 frontend/lib/models/dossier_unifie.dart create mode 100644 frontend/lib/models/pending_family.dart create mode 100644 frontend/lib/utils/phone_utils.dart create mode 100644 frontend/lib/widgets/admin/common/validation_detail_section.dart create mode 100644 frontend/lib/widgets/admin/pending_validation_widget.dart create mode 100644 frontend/lib/widgets/admin/validation_am_wizard.dart create mode 100644 frontend/lib/widgets/admin/validation_dossier_modal.dart create mode 100644 frontend/lib/widgets/admin/validation_family_wizard.dart create mode 100644 frontend/lib/widgets/admin/validation_modal_theme.dart create mode 100644 frontend/lib/widgets/admin/validation_refus_form.dart create mode 100644 frontend/lib/widgets/admin/validation_valider_confirm_dialog.dart diff --git a/backend/package.json b/backend/package.json index 0b68ed1..cb149bf 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/scripts/test-api-dossiers.js b/backend/scripts/test-api-dossiers.js new file mode 100644 index 0000000..1e48703 --- /dev/null +++ b/backend/scripts/test-api-dossiers.js @@ -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); +}); diff --git a/backend/scripts/test-pending-api.js b/backend/scripts/test-pending-api.js new file mode 100644 index 0000000..fb24b96 --- /dev/null +++ b/backend/scripts/test-pending-api.js @@ -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); +}); diff --git a/backend/scripts/update-gitea-issue-119-dossiers.js b/backend/scripts/update-gitea-issue-119-dossiers.js new file mode 100644 index 0000000..225dbcb --- /dev/null +++ b/backend/scripts/update-gitea-issue-119-dossiers.js @@ -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(); diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 0197cd2..ce8dbc2 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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: [ diff --git a/backend/src/entities/dossier_famille.entity.ts b/backend/src/entities/dossier_famille.entity.ts new file mode 100644 index 0000000..92082ed --- /dev/null +++ b/backend/src/entities/dossier_famille.entity.ts @@ -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; +} diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index c6fe021..5d7d943 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -43,6 +43,8 @@ export class AuthService { private readonly usersRepo: Repository, @InjectRepository(Children) private readonly childrenRepo: Repository, + @InjectRepository(AssistanteMaternelle) + private readonly assistantesMaternellesRepo: Repository, ) { } /** @@ -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( 'password_reset_token_expiry_days', 7, diff --git a/backend/src/routes/dossiers/dossiers.controller.ts b/backend/src/routes/dossiers/dossiers.controller.ts new file mode 100644 index 0000000..2f3f677 --- /dev/null +++ b/backend/src/routes/dossiers/dossiers.controller.ts @@ -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 { + return this.dossiersService.getDossierByNumero(numeroDossier); + } +} diff --git a/backend/src/routes/dossiers/dossiers.module.ts b/backend/src/routes/dossiers/dossiers.module.ts index 66026d2..e6ba196 100644 --- a/backend/src/routes/dossiers/dossiers.module.ts +++ b/backend/src/routes/dossiers/dossiers.module.ts @@ -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 {} diff --git a/backend/src/routes/dossiers/dossiers.service.ts b/backend/src/routes/dossiers/dossiers.service.ts new file mode 100644 index 0000000..59d9f47 --- /dev/null +++ b/backend/src/routes/dossiers/dossiers.service.ts @@ -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, + @InjectRepository(AssistanteMaternelle) + private readonly amRepository: Repository, + private readonly parentsService: ParentsService, + ) {} + + async getDossierByNumero(numeroDossier: string): Promise { + 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, + }; + } +} diff --git a/backend/src/routes/dossiers/dto/dossier-am-complet.dto.ts b/backend/src/routes/dossiers/dto/dossier-am-complet.dto.ts new file mode 100644 index 0000000..0ddc8a1 --- /dev/null +++ b/backend/src/routes/dossiers/dto/dossier-am-complet.dto.ts @@ -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; +} diff --git a/backend/src/routes/dossiers/dto/dossier-unifie.dto.ts b/backend/src/routes/dossiers/dto/dossier-unifie.dto.ts new file mode 100644 index 0000000..a51ae1b --- /dev/null +++ b/backend/src/routes/dossiers/dto/dossier-unifie.dto.ts @@ -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; +} diff --git a/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts b/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts new file mode 100644 index 0000000..29437cf --- /dev/null +++ b/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts @@ -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; +} diff --git a/backend/src/routes/parents/dto/pending-family.dto.ts b/backend/src/routes/parents/dto/pending-family.dto.ts index e7706d3..86389b2 100644 --- a/backend/src/routes/parents/dto/pending-family.dto.ts +++ b/backend/src/routes/parents/dto/pending-family.dto.ts @@ -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[]; } diff --git a/backend/src/routes/parents/parents.controller.ts b/backend/src/routes/parents/parents.controller.ts index edadf2b..261091f 100644 --- a/backend/src/routes/parents/parents.controller.ts +++ b/backend/src/routes/parents/parents.controller.ts @@ -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 { 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 { + 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)' }) diff --git a/backend/src/routes/parents/parents.module.ts b/backend/src/routes/parents/parents.module.ts index 6cb557b..7506259 100644 --- a/backend/src/routes/parents/parents.module.ts +++ b/backend/src/routes/parents/parents.module.ts @@ -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], diff --git a/backend/src/routes/parents/parents.service.ts b/backend/src/routes/parents/parents.service.ts index 3aa174d..f4d6f53 100644 --- a/backend/src/routes/parents/parents.service.ts +++ b/backend/src/routes/parents/parents.service.ts @@ -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, @InjectRepository(Users) private readonly usersRepository: Repository, + @InjectRepository(DossierFamille) + private readonly dossierFamilleRepository: Repository, ) {} // 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 { - 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 { + 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(); + 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 diff --git a/database/migrations/2026_dossier_famille.sql b/database/migrations/2026_dossier_famille.sql new file mode 100644 index 0000000..4afaa5e --- /dev/null +++ b/database/migrations/2026_dossier_famille.sql @@ -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); diff --git a/database/migrations/2026_dossier_famille_simplifier.sql b/database/migrations/2026_dossier_famille_simplifier.sql new file mode 100644 index 0000000..809dc1c --- /dev/null +++ b/database/migrations/2026_dossier_famille_simplifier.sql @@ -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; diff --git a/docs/23_LISTE-TICKETS.md b/docs/23_LISTE-TICKETS.md index 4e31cfd..b9eb010 100644 --- a/docs/23_LISTE-TICKETS.md +++ b/docs/23_LISTE-TICKETS.md @@ -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 --- diff --git a/frontend/lib/models/dossier_unifie.dart b/frontend/lib/models/dossier_unifie.dart new file mode 100644 index 0000000..83e3450 --- /dev/null +++ b/frontend/lib/models/dossier_unifie.dart @@ -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 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) { + throw FormatException('dossier manquant ou invalide'); + } + final dossierMap = Map.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 json) { + final userJson = json['user']; + final userMap = userJson is Map + ? userJson + : {}; + final nbMax = json['nb_max_enfants']; + final places = json['place_disponible']; + return DossierAM( + numeroDossier: json['numero_dossier']?.toString(), + user: AppUser.fromJson(Map.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 parents; + final List enfants; + final String? presentation; + + DossierFamille({ + this.numeroDossier, + required this.parents, + required this.enfants, + this.presentation, + }); + + factory DossierFamille.fromJson(Map json) { + final parentsRaw = json['parents']; + final parentsList = parentsRaw is List + ? (parentsRaw) + .where((e) => e is Map) + .map((e) => ParentDossier.fromJson(Map.from(e as Map))) + .toList() + : []; + final enfantsRaw = json['enfants']; + final enfantsList = enfantsRaw is List + ? (enfantsRaw) + .where((e) => e is Map) + .map((e) => EnfantDossier.fromJson(Map.from(e as Map))) + .toList() + : []; + 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 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 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, + ); + } +} diff --git a/frontend/lib/models/pending_family.dart b/frontend/lib/models/pending_family.dart new file mode 100644 index 0000000..a5273b7 --- /dev/null +++ b/frontend/lib/models/pending_family.dart @@ -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 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 emails; + + /// Une entrée par parent : email, tél., CP ville (si fournis par l’API). + final List 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 _parseStringList(dynamic raw) { + if (raw is! List) return []; + return raw + .map((e) => e?.toString().trim() ?? '') + .where((s) => s.isNotEmpty) + .toList(); + } + + static List _parseParentLinesFromMaps(dynamic raw) { + if (raw is! List) return []; + final out = []; + for (final e in raw) { + if (e is! Map) continue; + final m = Map.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 _buildParentLines( + Map json, + List emails, + ) { + final fromMaps = _parseParentLinesFromMaps( + json['parents'] ?? json['resume_parents'] ?? json['parent_summaries'] ?? json['parent_lines'], + ); + if (fromMaps.isNotEmpty) { + return fromMaps; + } + + List? 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 json) { + final parentIdsRaw = json['parentIds'] ?? json['parent_ids']; + final List 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 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, + ); + } +} diff --git a/frontend/lib/models/user.dart b/frontend/lib/models/user.dart index 01b89ce..fc407d1 100644 --- a/frontend/lib/models/user.dart +++ b/frontend/lib/models/user.dart @@ -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 json) { final relaisJson = json['relais']; final relaisMap = relaisJson is Map ? relaisJson : {}; 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, }; } diff --git a/frontend/lib/screens/administrateurs/creation/admin_create.dart b/frontend/lib/screens/administrateurs/creation/admin_create.dart index e57ac71..ae1477c 100644 --- a/frontend/lib/screens/administrateurs/creation/admin_create.dart +++ b/frontend/lib/screens/administrateurs/creation/admin_create.dart @@ -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 { _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 { 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 { 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 { 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'), diff --git a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart index 3a2ce05..8dbed7e 100644 --- a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart +++ b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart @@ -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 { 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 { _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 { } 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 { 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 { 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 { 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 { 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 { 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 { 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 { : [ 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 { ); } } - -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), - ); - } -} \ No newline at end of file diff --git a/frontend/lib/screens/auth/login_screen.dart b/frontend/lib/screens/auth/login_screen.dart index 96064e7..2814b72 100644 --- a/frontend/lib/screens/auth/login_screen.dart +++ b/frontend/lib/screens/auth/login_screen.dart @@ -17,7 +17,7 @@ class LoginScreen extends StatefulWidget { State createState() => _LoginPageState(); } -class _LoginPageState extends State with WidgetsBindingObserver { +class _LoginPageState extends State { final _formKey = GlobalKey(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); @@ -27,31 +27,30 @@ class _LoginPageState extends State 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 _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 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 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 with WidgetsBindingObserver { builder: (context, constraints) { final w = constraints.maxWidth; final h = constraints.maxHeight; - return FutureBuilder( - future: _getImageDimensions(), + return FutureBuilder( + future: _desktopRiverLogoDimensionsFuture, builder: (context, snapshot) { if (!snapshot.hasData) { return const Center(child: CircularProgressIndicator()); @@ -203,7 +203,7 @@ class _LoginPageState extends State 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 with WidgetsBindingObserver { hintText: 'Votre mot de passe', obscureText: true, autofillHints: const [ - AutofillHints.password + AutofillHints.password, ], textInputAction: TextInputAction.done, onFieldSubmitted: diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index 613146d..a219a2d 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -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'; diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart index f9b0e75..c995b9f 100644 --- a/frontend/lib/services/auth_service.dart +++ b/frontend/lib/services/auth_service.dart @@ -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(); diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index ab8e5a7..a70d011 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -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> 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 : []; + return data + .where((e) => e is Map) + .map((e) => AppUser.fromJson(Map.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> 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 : []; + return data + .where((e) => e is Map) + .map((e) => PendingFamily.fromJson(Map.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 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) { + throw FormatException('Réponse invalide'); + } + return DossierUnifie.fromJson(Map.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 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.from(data is Map ? data : {})); + } + + /// Valider tout le dossier famille. POST /parents/:parentId/valider-dossier. Ticket #108. + static Future> 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.from(e as Map))).toList(); + } + // Récupérer la liste des gestionnaires (endpoint dédié) static Future> getGestionnaires() async { final response = await http.get( diff --git a/frontend/lib/utils/nir_utils.dart b/frontend/lib/utils/nir_utils.dart index cfed04b..0ca0324 100644 --- a/frontend/lib/utils/nir_utils.dart +++ b/frontend/lib/utils/nir_utils.dart @@ -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( diff --git a/frontend/lib/utils/phone_utils.dart b/frontend/lib/utils/phone_utils.dart new file mode 100644 index 0000000..2a87b8c --- /dev/null +++ b/frontend/lib/utils/phone_utils.dart @@ -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), + ); + } +} diff --git a/frontend/lib/widgets/admin/admin_management_widget.dart b/frontend/lib/widgets/admin/admin_management_widget.dart index be51365..01f74d9 100644 --- a/frontend/lib/widgets/admin/admin_management_widget.dart +++ b/frontend/lib/widgets/admin/admin_management_widget.dart @@ -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 { 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 { : 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 diff --git a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart index d655055..432a782 100644 --- a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart +++ b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart @@ -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)), diff --git a/frontend/lib/widgets/admin/common/validation_detail_section.dart b/frontend/lib/widgets/admin/common/validation_detail_section.dart new file mode 100644 index 0000000..deae30c --- /dev/null +++ b/frontend/lib/widgets/admin/common/validation_detail_section.dart @@ -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 fields; + + /// Nombre de champs par ligne (1 = plein largeur, 2 = deux côte à côte). Ex. [2, 2, 1, 2] pour identité. + final List? rowLayout; + + /// Flex par ligne (index de ligne -> [flex1, flex2, ...]). Ex. {3: [2, 5]} pour Code postal | Ville. + final Map>? 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 = []; + 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, + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/dashboard_admin.dart b/frontend/lib/widgets/admin/dashboard_admin.dart index 55c99be..47c0983 100644 --- a/frontend/lib/widgets/admin/dashboard_admin.dart +++ b/frontend/lib/widgets/admin/dashboard_admin.dart @@ -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 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? tabLabels; - static const List _tabLabels = [ + static const List _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 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(), ], ), ); diff --git a/frontend/lib/widgets/admin/parent_managmant_widget.dart b/frontend/lib/widgets/admin/parent_managmant_widget.dart index 5b8e20a..f4c362d 100644 --- a/frontend/lib/widgets/admin/parent_managmant_widget.dart +++ b/frontend/lib/widgets/admin/parent_managmant_widget.dart @@ -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 { ), 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)), diff --git a/frontend/lib/widgets/admin/pending_validation_widget.dart b/frontend/lib/widgets/admin/pending_validation_widget.dart new file mode 100644 index 0000000..c1dac5b --- /dev/null +++ b/frontend/lib/widgets/admin/pending_validation_widget.dart @@ -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 createState() => _PendingValidationWidgetState(); +} + +class _PendingValidationWidgetState extends State { + bool _isLoading = true; + String? _error; + List _pendingAM = []; + List _pendingFamilies = []; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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( + 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 = []; + 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 = []; + 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 = []; + 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, + ), + ); + } +} + diff --git a/frontend/lib/widgets/admin/relais_management_panel.dart b/frontend/lib/widgets/admin/relais_management_panel.dart index 0940dcd..12327b7 100644 --- a/frontend/lib/widgets/admin/relais_management_panel.dart +++ b/frontend/lib/widgets/admin/relais_management_panel.dart @@ -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; diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index 18ba940..21958d3 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -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 { 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 _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 { setState(() {}); } + List 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 { }); } + /// 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 { } Widget? _subBarFilterControl() { - if (_subIndex == 1) { + if (_subIndex == _contentIndexOffset + 0) { return DropdownButtonHideUnderline( child: DropdownButton( value: _parentStatus, @@ -122,7 +168,7 @@ class _UserManagementPanelState extends State { ); } - if (_subIndex == 2) { + if (_subIndex == _contentIndexOffset + 1) { return TextField( controller: _amCapacityController, decoration: const InputDecoration( @@ -139,22 +185,26 @@ class _UserManagementPanelState extends State { } 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 { @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 { 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 { } Future _handleAddPressed() async { - if (_subIndex == 0) { + final contentIndex = _subIndex - _contentIndexOffset; + if (contentIndex == 2) { final created = await showDialog( context: context, barrierDismissible: false, @@ -204,7 +257,7 @@ class _UserManagementPanelState extends State { return; } - if (_subIndex == 3) { + if (contentIndex == 3) { final created = await showDialog( context: context, barrierDismissible: false, diff --git a/frontend/lib/widgets/admin/validation_am_wizard.dart b/frontend/lib/widgets/admin/validation_am_wizard.dart new file mode 100644 index 0000000..e15bae5 --- /dev/null +++ b/frontend/lib/widgets/admin/validation_am_wizard.dart @@ -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 createState() => _ValidationAmWizardState(); +} + +class _ValidationAmWizardState extends State { + 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 _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 _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 _personalRowLayout = [2, 2, 1, 2]; + static const Map> _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 _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 _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(); + }, + ), + ), + ], + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/validation_dossier_modal.dart b/frontend/lib/widgets/admin/validation_dossier_modal.dart new file mode 100644 index 0000000..88e2937 --- /dev/null +++ b/frontend/lib/widgets/admin/validation_dossier_modal.dart @@ -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 createState() => _ValidationDossierModalState(); +} + +class _ValidationDossierModalState extends State { + 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 _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, + ); + } +} diff --git a/frontend/lib/widgets/admin/validation_family_wizard.dart b/frontend/lib/widgets/admin/validation_family_wizard.dart new file mode 100644 index 0000000..de89bd9 --- /dev/null +++ b/frontend/lib/widgets/admin/validation_family_wizard.dart @@ -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 createState() => _ValidationFamilyWizardState(); +} + +class _ValidationFamilyWizardState extends State { + 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 _parentFields(ParentDossier p) => [ + AdminDetailField(label: 'Nom', value: _v(p.nom)), + AdminDetailField(label: 'Prénom', value: _v(p.prenom)), + AdminDetailField( + label: 'Téléphone', + value: _v(p.telephone) != 'Non défini' + ? formatPhoneForDisplay(_v(p.telephone)) + : 'Non défini'), + AdminDetailField(label: 'Email', value: _v(p.email)), + AdminDetailField(label: 'Adresse (N° et Rue)', value: _v(p.adresse)), + AdminDetailField(label: 'Code postal', value: _v(p.codePostal)), + AdminDetailField(label: 'Ville', value: _v(p.ville)), + ]; + + static const List _parentRowLayout = [2, 2, 1, 2]; + static const Map> _parentRowFlex = { + 3: [2, 5] + }; // Code postal étroit, Ville large + + static String _fullPhotoUrl(String? url) { + 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( + onNotification: (_) { + _syncEnfantsScrollFades(); + return false; + }, + child: ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (Rect bounds) { + final stops = [ + 0.0, + _enfantsFadeExtent, + 1.0 - _enfantsFadeExtent, + 1.0, + ]; + if (!_enfantsIsScrollable) { + return LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: const [ + 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: [ + 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 _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 _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 _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(); + }, + ), + ), + ], + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/validation_modal_theme.dart b/frontend/lib/widgets/admin/validation_modal_theme.dart new file mode 100644 index 0000000..aaeba43 --- /dev/null +++ b/frontend/lib/widgets/admin/validation_modal_theme.dart @@ -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), + ); + } +} + diff --git a/frontend/lib/widgets/admin/validation_refus_form.dart b/frontend/lib/widgets/admin/validation_refus_form.dart new file mode 100644 index 0000000..e2d6285 --- /dev/null +++ b/frontend/lib/widgets/admin/validation_refus_form.dart @@ -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 onSubmit; + + const ValidationRefusForm({ + super.key, + required this.onCancel, + required this.onPrevious, + required this.onSubmit, + }); + + @override + State createState() => _ValidationRefusFormState(); +} + +class _ValidationRefusFormState extends State { + final _controller = TextEditingController(); + final _formKey = GlobalKey(); + + 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'), + ), + ], + ), + ], + ), + ], + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/validation_valider_confirm_dialog.dart b/frontend/lib/widgets/admin/validation_valider_confirm_dialog.dart new file mode 100644 index 0000000..56b7867 --- /dev/null +++ b/frontend/lib/widgets/admin/validation_valider_confirm_dialog.dart @@ -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 showValidationValiderConfirmDialog( + BuildContext context, { + required String body, +}) async { + final result = await showDialog( + 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; +} diff --git a/frontend/lib/widgets/dashboard/dashboard_bandeau.dart b/frontend/lib/widgets/dashboard/dashboard_bandeau.dart index 6ac6560..1075288 100644 --- a/frontend/lib/widgets/dashboard/dashboard_bandeau.dart +++ b/frontend/lib/widgets/dashboard/dashboard_bandeau.dart @@ -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; diff --git a/frontend/lib/widgets/nir_text_field.dart b/frontend/lib/widgets/nir_text_field.dart index e1beca1..7222811 100644 --- a/frontend/lib/widgets/nir_text_field.dart +++ b/frontend/lib/widgets/nir_text_field.dart @@ -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, diff --git a/frontend/lib/widgets/personal_info_form_screen.dart b/frontend/lib/widgets/personal_info_form_screen.dart index 3ae6454..7e19729 100644 --- a/frontend/lib/widgets/personal_info_form_screen.dart +++ b/frontend/lib/widgets/personal_info_form_screen.dart @@ -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 { 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 { 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 { 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 { 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 { 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 { String? hint, TextInputType? keyboardType, bool enabled = true, + List? 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 { 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) { diff --git a/frontend/lib/widgets/professional_info_form_screen.dart b/frontend/lib/widgets/professional_info_form_screen.dart index 1923b96..b50273b 100644 --- a/frontend/lib/widgets/professional_info_form_screen.dart +++ b/frontend/lib/widgets/professional_info_form_screen.dart @@ -529,7 +529,7 @@ class _ProfessionalInfoFormScreenState extends State ); } - /// 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;