From ae610733cccb6878a71f8d6d3a82446b06878444 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 23 Jul 2026 16:53:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(#156):=20cr=C3=A9ation=20dossier=20AM=20st?= =?UTF-8?q?aff=20=E2=80=94=20API=20+=20wizard=20dashboard.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squash depuis develop : POST /assistantes-maternelles/dossier (actif + mail MDP), AmDossierWizard create/review, validations inscription. Co-authored-by: Cursor --- backend/package.json | 3 + ...assistantes_maternelles.controller.spec.ts | 53 +- .../assistantes_maternelles.controller.ts | 36 +- .../staff-create-am-dossier-response.dto.ts | 23 + .../dto/staff-create-am-dossier.dto.ts | 29 + backend/src/routes/auth/auth.service.ts | 201 ++-- docs/tmp/156-contrat-api-staff-am.md | 75 ++ frontend/lib/services/api/api_config.dart | 3 + frontend/lib/services/user_service.dart | 50 + frontend/lib/utils/nir_utils.dart | 46 +- .../admin/am_dossier_create_modal.dart | 92 ++ .../lib/widgets/admin/am_dossier_wizard.dart | 965 ++++++++++++++++++ .../common/validation_detail_section.dart | 520 +++++++++- .../widgets/admin/user_management_panel.dart | 23 +- .../widgets/admin/validation_am_wizard.dart | 494 +-------- .../admin/validation_dossier_modal.dart | 10 +- .../lib/widgets/common/identity_block.dart | 44 +- frontend/lib/widgets/nir_text_field.dart | 2 +- 18 files changed, 2044 insertions(+), 625 deletions(-) create mode 100644 backend/src/routes/assistantes_maternelles/dto/staff-create-am-dossier-response.dto.ts create mode 100644 backend/src/routes/assistantes_maternelles/dto/staff-create-am-dossier.dto.ts create mode 100644 docs/tmp/156-contrat-api-staff-am.md create mode 100644 frontend/lib/widgets/admin/am_dossier_create_modal.dart create mode 100644 frontend/lib/widgets/admin/am_dossier_wizard.dart diff --git a/backend/package.json b/backend/package.json index d5a1710..6cdacdb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -89,6 +89,9 @@ "transform": { "^.+\\.(t|j)s$": "ts-jest" }, + "moduleNameMapper": { + "^src/(.*)$": "/$1" + }, "collectCoverageFrom": [ "**/*.(t|j)s" ], diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.spec.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.spec.ts index b0932ef..5a7322d 100644 --- a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.spec.ts +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.spec.ts @@ -1,20 +1,69 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AssistantesMaternellesController } from './assistantes_maternelles.controller'; import { AssistantesMaternellesService } from './assistantes_maternelles.service'; +import { AuthService } from '../auth/auth.service'; +import { AuthGuard } from 'src/common/guards/auth.guard'; +import { RolesGuard } from 'src/common/guards/roles.guard'; describe('AssistantesMaternellesController', () => { let controller: AssistantesMaternellesController; + const authServiceMock = { + createAmDossierStaff: jest.fn(), + }; + const amServiceMock = {}; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [AssistantesMaternellesController], - providers: [AssistantesMaternellesService], - }).compile(); + providers: [ + { provide: AssistantesMaternellesService, useValue: amServiceMock }, + { provide: AuthService, useValue: authServiceMock }, + ], + }) + .overrideGuard(AuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); controller = module.get(AssistantesMaternellesController); + jest.clearAllMocks(); }); it('should be defined', () => { expect(controller).toBeDefined(); }); + + it('createDossier delegates to authService.createAmDossierStaff with CGU accepted', async () => { + authServiceMock.createAmDossierStaff.mockResolvedValue({ + message: 'ok', + user_id: 'u1', + statut: 'actif', + numero_dossier: '2026-000001', + }); + + const body = { + email: 'am.staff@test.fr', + prenom: 'Marie', + nom: 'TEST', + telephone: '0689567890', + consentement_photo: false, + lieu_naissance_ville: 'Paris', + lieu_naissance_pays: 'France', + nir: '285017512345678', + numero_agrement: 'AGR-TEST-001', + capacite_accueil: 3, + places_disponibles: 2, + }; + + const res = await controller.createDossier(body as any); + expect(authServiceMock.createAmDossierStaff).toHaveBeenCalledWith( + expect.objectContaining({ + email: body.email, + acceptation_cgu: true, + acceptation_privacy: true, + }), + ); + expect(res.numero_dossier).toBe('2026-000001'); + }); }); diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts index 540c73e..7d5609e 100644 --- a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts @@ -7,6 +7,8 @@ import { Param, Delete, UseGuards, + HttpCode, + HttpStatus, } from '@nestjs/common'; import { AssistantesMaternellesService } from './assistantes_maternelles.service'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; @@ -16,17 +18,49 @@ import { RoleType, Users } from 'src/entities/users.entity'; import { CreateAssistanteDto } from '../user/dto/create_assistante.dto'; import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto'; import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto'; +import { StaffCreateAmDossierDto } from './dto/staff-create-am-dossier.dto'; +import { StaffCreateAmDossierResponseDto } from './dto/staff-create-am-dossier-response.dto'; import { RolesGuard } from 'src/common/guards/roles.guard'; import { AuthGuard } from 'src/common/guards/auth.guard'; import { User } from 'src/common/decorators/user.decorator'; import { mapAmForApi, mapAmsForApi } from './assistantes_maternelles.mapper'; +import { AuthService } from '../auth/auth.service'; +import { RegisterAMCompletDto } from '../auth/dto/register-am-complet.dto'; @ApiTags("Assistantes Maternelles") @ApiBearerAuth('access-token') @UseGuards(AuthGuard, RolesGuard) @Controller('assistantes-maternelles') export class AssistantesMaternellesController { - constructor(private readonly assistantesMaternellesService: AssistantesMaternellesService) { } + constructor( + private readonly assistantesMaternellesService: AssistantesMaternellesService, + private readonly authService: AuthService, + ) { } + + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) + @Post('dossier') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'Créer un dossier AM complet (staff) — ticket #156', + description: + 'Crée user + fiche AM avec statut actif, n° dossier, et envoie l’e-mail de création de mot de passe. ' + + 'Ne pas utiliser POST /auth/register/am depuis le dashboard.', + }) + @ApiBody({ type: StaffCreateAmDossierDto }) + @ApiResponse({ status: 201, type: StaffCreateAmDossierResponseDto }) + @ApiResponse({ status: 400, description: 'Validation métier / NIR' }) + @ApiResponse({ status: 403, description: 'Rôle non autorisé' }) + @ApiResponse({ status: 409, description: 'Email / NIR / agrément déjà pris' }) + async createDossier( + @Body() dto: StaffCreateAmDossierDto, + ): Promise { + const registerDto = { + ...dto, + acceptation_cgu: true, + acceptation_privacy: true, + } as RegisterAMCompletDto; + return this.authService.createAmDossierStaff(registerDto); + } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) @ApiOperation({ summary: 'Créer nounou' }) diff --git a/backend/src/routes/assistantes_maternelles/dto/staff-create-am-dossier-response.dto.ts b/backend/src/routes/assistantes_maternelles/dto/staff-create-am-dossier-response.dto.ts new file mode 100644 index 0000000..305d382 --- /dev/null +++ b/backend/src/routes/assistantes_maternelles/dto/staff-create-am-dossier-response.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; + +/** Réponse 201 POST /assistantes-maternelles/dossier (#156). */ +export class StaffCreateAmDossierResponseDto { + @ApiProperty() + message: string; + + @ApiProperty({ format: 'uuid' }) + user_id: string; + + @ApiProperty({ + enum: StatutUtilisateurType, + example: StatutUtilisateurType.ACTIF, + }) + statut: StatutUtilisateurType; + + @ApiProperty({ + example: '2026-000042', + description: 'Numéro de dossier attribué', + }) + numero_dossier: string; +} diff --git a/backend/src/routes/assistantes_maternelles/dto/staff-create-am-dossier.dto.ts b/backend/src/routes/assistantes_maternelles/dto/staff-create-am-dossier.dto.ts new file mode 100644 index 0000000..eed1381 --- /dev/null +++ b/backend/src/routes/assistantes_maternelles/dto/staff-create-am-dossier.dto.ts @@ -0,0 +1,29 @@ +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { IsBoolean, IsOptional } from 'class-validator'; +import { RegisterAMCompletDto } from 'src/routes/auth/dto/register-am-complet.dto'; + +/** + * Création dossier AM par staff (#156). + * Mêmes champs que l'inscription publique, sans CGU/privacy obligatoires + * (acceptées côté serveur pour le compte du gestionnaire). + */ +export class StaffCreateAmDossierDto extends OmitType(RegisterAMCompletDto, [ + 'acceptation_cgu', + 'acceptation_privacy', +] as const) { + @ApiPropertyOptional({ + description: 'Ignoré côté staff (CGU acceptées serveur). Conservé pour compat éventuelle.', + default: true, + }) + @IsOptional() + @IsBoolean() + acceptation_cgu?: boolean; + + @ApiPropertyOptional({ + description: 'Ignoré côté staff (privacy acceptée serveur).', + default: true, + }) + @IsOptional() + @IsBoolean() + acceptation_privacy?: boolean; +} diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index e31085e..14d0cc4 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -645,11 +645,28 @@ export class AuthService { } /** - * Inscription Assistante Maternelle COMPLÈTE - Un seul endpoint (identité + pro + photo + CGU) - * Crée User (role AM) + entrée assistantes_maternelles, token création MDP + * Cœur partagé création dossier AM (#156). + * - public : statut en_attente + mail pending + * - staff : statut actif + mail création MDP */ - async inscrireAMComplet(dto: RegisterAMCompletDto) { - if (!dto.acceptation_cgu || !dto.acceptation_privacy) { + async createAmDossier( + dto: RegisterAMCompletDto, + options: { + statut: StatutUtilisateurType; + sendPendingEmail: boolean; + sendPasswordSetupEmail: boolean; + requireCgu: boolean; + logContext?: string; + }, + ): Promise<{ + message: string; + user_id: string; + statut: StatutUtilisateurType; + numero_dossier: string; + }> { + const logCtx = options.logContext ?? 'createAmDossier'; + + if (options.requireCgu && (!dto.acceptation_cgu || !dto.acceptation_privacy)) { throw new BadRequestException( "L'acceptation des CGU et de la politique de confidentialité est obligatoire", ); @@ -669,8 +686,7 @@ export class AuthService { throw new BadRequestException(nirValidation.error || 'NIR invalide'); } if (nirValidation.warning) { - // Warning uniquement : on ne bloque pas (AM souvent étrangères, DOM-TOM, Corse) - console.warn('[inscrireAMComplet] NIR warning:', nirValidation.warning, 'email=', dto.email); + console.warn(`[${logCtx}] NIR warning:`, nirValidation.warning, 'email=', dto.email); } const existe = await this.usersService.findByEmailOrNull(dto.email); @@ -721,79 +737,142 @@ export class AuthService { let resultat: { user: Users }; try { resultat = await this.usersRepo.manager.transaction(async (manager) => { - const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager); + const { numero: numeroDossier } = + await this.numeroDossierService.getNextNumeroDossier(manager); - const user = manager.create(Users, { - email: dto.email, - prenom: dto.prenom, - nom: dto.nom, - role: RoleType.ASSISTANTE_MATERNELLE, - statut: StatutUtilisateurType.EN_ATTENTE, - telephone: dto.telephone, - adresse: dto.adresse, - code_postal: dto.code_postal, - ville: dto.ville, - token_creation_mdp: tokenCreationMdp, - token_creation_mdp_expire_le: dateExpiration, - photo_url: urlPhoto ?? undefined, - consentement_photo: dto.consentement_photo, - date_consentement_photo: dateConsentementPhoto, - date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined, - lieu_naissance_ville: dto.lieu_naissance_ville, - lieu_naissance_pays: dto.lieu_naissance_pays, - numero_dossier: numeroDossier, + const user = manager.create(Users, { + email: dto.email, + prenom: dto.prenom, + nom: dto.nom, + role: RoleType.ASSISTANTE_MATERNELLE, + statut: options.statut, + telephone: dto.telephone, + adresse: dto.adresse, + code_postal: dto.code_postal, + ville: dto.ville, + token_creation_mdp: tokenCreationMdp, + token_creation_mdp_expire_le: dateExpiration, + photo_url: urlPhoto ?? undefined, + consentement_photo: dto.consentement_photo, + date_consentement_photo: dateConsentementPhoto, + date_naissance: dto.date_naissance + ? new Date(dto.date_naissance) + : undefined, + lieu_naissance_ville: dto.lieu_naissance_ville, + lieu_naissance_pays: dto.lieu_naissance_pays, + numero_dossier: numeroDossier, + }); + const userEnregistre = await manager.save(Users, user); + + const amRepo = manager.getRepository(AssistanteMaternelle); + const am = amRepo.create({ + user_id: userEnregistre.id, + approval_number: dto.numero_agrement, + nir: nirNormalized, + max_children: dto.capacite_accueil, + places_available: dto.places_disponibles, + biography: dto.biographie, + residence_city: dto.ville ?? undefined, + agreement_date: dto.date_agrement + ? new Date(dto.date_agrement) + : undefined, + available: true, + numero_dossier: numeroDossier, + }); + await amRepo.save(am); + + return { user: userEnregistre }; }); - const userEnregistre = await manager.save(Users, user); - - const amRepo = manager.getRepository(AssistanteMaternelle); - const am = amRepo.create({ - user_id: userEnregistre.id, - approval_number: dto.numero_agrement, - nir: nirNormalized, - max_children: dto.capacite_accueil, - places_available: dto.places_disponibles, - biography: dto.biographie, - residence_city: dto.ville ?? undefined, - agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined, - available: true, - numero_dossier: numeroDossier, - }); - await amRepo.save(am); - - return { user: userEnregistre }; - }); } catch (err) { if (this.isPostgresUniqueViolation(err)) { - throw new ConflictException('Un compte avec cet email existe déjà (contrainte unique en base).'); + throw new ConflictException( + 'Un compte avec cet email existe déjà (contrainte unique en base).', + ); } throw err; } const numeroDossier = resultat.user.numero_dossier ?? ''; - try { - await this.mailService.sendRegistrationPendingEmail( - resultat.user.email, - resultat.user.prenom ?? '', - resultat.user.nom ?? '', - numeroDossier, - ); - } catch (err) { - this.logger.error( - "[inscrireAMComplet] Échec envoi email d'accusé de réception (inscription conservée)", - err instanceof Error ? err.stack : String(err), - ); + if (options.sendPendingEmail) { + try { + await this.mailService.sendRegistrationPendingEmail( + resultat.user.email, + resultat.user.prenom ?? '', + resultat.user.nom ?? '', + numeroDossier, + ); + } catch (err) { + this.logger.error( + `[${logCtx}] Échec envoi email d'accusé de réception (inscription conservée)`, + err instanceof Error ? err.stack : String(err), + ); + } } + if (options.sendPasswordSetupEmail) { + try { + await this.mailService.sendValidatedAccountPasswordSetupEmail( + { + email: resultat.user.email, + prenom: resultat.user.prenom ?? '', + nom: resultat.user.nom ?? '', + token: tokenCreationMdp, + numeroDossier, + }, + 'am', + ); + } catch (err) { + this.logger.error( + `[${logCtx}] Échec envoi email création MDP (dossier conservé)`, + err instanceof Error ? err.stack : String(err), + ); + } + } + + const message = + options.statut === StatutUtilisateurType.ACTIF + ? 'Dossier AM créé et validé. Un e-mail de création de mot de passe a été envoyé.' + : 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.'; + return { - message: - 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.', + message, user_id: resultat.user.id, - statut: StatutUtilisateurType.EN_ATTENTE, + statut: options.statut, numero_dossier: numeroDossier, }; } + /** + * Inscription Assistante Maternelle COMPLÈTE - Un seul endpoint (identité + pro + photo + CGU) + * Crée User (role AM) + entrée assistantes_maternelles, token création MDP + */ + async inscrireAMComplet(dto: RegisterAMCompletDto) { + return this.createAmDossier(dto, { + statut: StatutUtilisateurType.EN_ATTENTE, + sendPendingEmail: true, + sendPasswordSetupEmail: false, + requireCgu: true, + logContext: 'inscrireAMComplet', + }); + } + + /** + * Création dossier AM par staff (#156) — statut actif + e-mail création MDP. + */ + async createAmDossierStaff(dto: RegisterAMCompletDto) { + return this.createAmDossier(dto, { + statut: StatutUtilisateurType.ACTIF, + sendPendingEmail: false, + sendPasswordSetupEmail: true, + requireCgu: false, + logContext: 'createAmDossierStaff', + }); + } + + /** + * Sauvegarde une photo depuis base64 vers le système de fichiers + */ /** * Sauvegarde une photo depuis base64 vers le système de fichiers */ diff --git a/docs/tmp/156-contrat-api-staff-am.md b/docs/tmp/156-contrat-api-staff-am.md new file mode 100644 index 0000000..312e148 --- /dev/null +++ b/docs/tmp/156-contrat-api-staff-am.md @@ -0,0 +1,75 @@ +# Mini-spec API — POST /assistantes-maternelles/dossier (#156) + +Contrat pour le **plan front** (wizard création AM staff). + +## Endpoint + +| | | +|--|--| +| **Méthode** | `POST` | +| **URL** | `{base}/assistantes-maternelles/dossier` | +| **Auth** | Bearer JWT | +| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` | +| **Content-Type** | `application/json` | + +Ne **pas** appeler `POST /auth/register/am` depuis le dashboard. + +## Body (JSON) + +Aligné inscription AM publique, **sans** CGU/privacy obligatoires (acceptées serveur). + +| Champ | Type | Obligatoire | Notes | +|-------|------|-------------|--------| +| `email` | string | oui | unique | +| `prenom` | string | oui | | +| `nom` | string | oui | | +| `telephone` | string | oui | `0X…` ou `+33…` | +| `adresse` | string | non | | +| `code_postal` | string | non | | +| `ville` | string | non | | +| `photo_base64` | string | non | data-URL `data:image/…;base64,…` | +| `photo_filename` | string | non | hint nom fichier | +| `consentement_photo` | bool | oui | | +| `date_naissance` | date ISO | non | `YYYY-MM-DD` | +| `lieu_naissance_ville` | string | oui | | +| `lieu_naissance_pays` | string | oui | | +| `nir` | string | oui | 15 car. (Corse 2A/2B OK) | +| `numero_agrement` | string | oui | unique | +| `date_agrement` | date ISO | non | | +| `capacite_accueil` | int | oui | 1–10 | +| `places_disponibles` | int | oui | 0–10, ≤ capacité | +| `biographie` | string | non | max 2000 | + +## Réponses + +### 201 Created + +```json +{ + "message": "Dossier AM créé et validé. Un e-mail de création de mot de passe a été envoyé.", + "user_id": "uuid", + "statut": "actif", + "numero_dossier": "2026-000042" +} +``` + +Effets serveur : user AM **actif**, fiche `assistantes_maternelles`, n° dossier, **e-mail création MDP** (pas d’accusé « en attente »). + +### Erreurs + +| Code | Cas | +|------|-----| +| 400 | Validation / NIR / places > capacité | +| 403 | Rôle non staff | +| 409 | Email, NIR ou agrément déjà pris | +| 401 | Token manquant / invalide | + +## Front + +- `UserService.createAmDossier(body)` → cet endpoint +- Après 201 : refresh liste AM ; snackbar OK +- Wizard create : ne pas envoyer `acceptation_cgu` / `acceptation_privacy` (optionnels) + +## Branche + +`feature/156-creation-dossier-am` diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index 30508a4..75a9610 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -61,6 +61,9 @@ class ApiConfig { static const String gestionnaires = '/gestionnaires'; static const String parents = '/parents'; static const String assistantesMaternelles = '/assistantes-maternelles'; + /// Création dossier AM actif par le staff (#156) — body type register AM. + static const String assistantesMaternellesDossier = + '/assistantes-maternelles/dossier'; static const String enfants = '/enfants'; static const String relais = '/relais'; static const String dossiers = '/dossiers'; diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index 86f5cad..ce8f662 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -645,6 +645,56 @@ class UserService { return fallback; } + /// Création dossier AM actif côté staff (#156). + /// `POST /assistantes-maternelles/dossier` — body aligné sur register AM. + /// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur). + /// Ne pas utiliser `POST /auth/register/am` (public / en_attente). + static Future> createAmDossier( + Map body, + ) async { + final http.Response response; + try { + response = await http.post( + Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.assistantesMaternellesDossier}', + ), + headers: await _headers(), + body: jsonEncode(body), + ); + } on http.ClientException { + throw Exception( + 'Connexion au serveur impossible. Vérifiez votre réseau puis réessayez.', + ); + } + + if (response.statusCode == 200 || response.statusCode == 201) { + if (response.body.trim().isEmpty) return {}; + try { + final decoded = jsonDecode(response.body); + if (decoded is Map) { + return Map.from(decoded); + } + } catch (_) {} + return {}; + } + + final message = _extractErrorMessage( + response.body, + 'Erreur création dossier AM', + ); + if (response.statusCode == 409) { + throw Exception(message.isNotEmpty + ? message + : 'Conflit : e-mail, NIR ou agrément déjà utilisé.'); + } + if (response.statusCode == 400) { + throw Exception( + message.isNotEmpty ? message : 'Données invalides (400).', + ); + } + throw Exception(message); + } + // Récupérer la liste des assistantes maternelles static Future> getAssistantesMaternelles() async { diff --git a/frontend/lib/utils/nir_utils.dart b/frontend/lib/utils/nir_utils.dart index ac64e05..01beb8e 100644 --- a/frontend/lib/utils/nir_utils.dart +++ b/frontend/lib/utils/nir_utils.dart @@ -49,12 +49,21 @@ 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 (complet ou en cours de saisie) : +/// `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)}'; + final r = nirToRaw(raw).toUpperCase(); + if (r.isEmpty) return ''; + final buf = StringBuffer(); + for (var i = 0; i < r.length && i < 15; i++) { + if (i == 1 || i == 3 || i == 5 || i == 7 || i == 10) { + buf.write(' '); + } else if (i == 13) { + buf.write(' - '); + } + buf.write(r[i]); + } + return buf.toString(); } /// Aligné sur le backend (NIR côté API / nir.util.ts) : sexe 1–3, département 2A ou 2B pour la Corse. @@ -92,20 +101,39 @@ 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. +/// Validation pendant la saisie : pas d’erreur si incomplet encore plausible ; +/// dès 15 caractères, même contrôles que [validateNir]. +String? validateNirTyping(String? value) { + if (value == null || value.trim().isEmpty) return null; + final raw = nirToRaw(value).toUpperCase(); + if (raw.isEmpty) return null; + if (raw[0] != '1' && raw[0] != '2' && raw[0] != '3') { + return 'Format NIR invalide (ex. 1 12 34 56 789 012 - 34 ou 2A pour la Corse)'; + } + if (raw.length < 15) return null; + return validateNir(value); +} + +/// Formateur de saisie : affiche le NIR formaté au fil de la frappe et limite à 15 caractères utiles. class NirInputFormatter extends TextInputFormatter { + const NirInputFormatter(); + @override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue, ) { final raw = normalizeNir(newValue.text); - if (raw.isEmpty) return newValue; + if (raw.isEmpty) { + return const TextEditingValue( + text: '', + selection: TextSelection.collapsed(offset: 0), + ); + } final formatted = formatNir(raw); - final offset = formatted.length; return TextEditingValue( text: formatted, - selection: TextSelection.collapsed(offset: offset), + selection: TextSelection.collapsed(offset: formatted.length), ); } } diff --git a/frontend/lib/widgets/admin/am_dossier_create_modal.dart b/frontend/lib/widgets/admin/am_dossier_create_modal.dart new file mode 100644 index 0000000..f221d93 --- /dev/null +++ b/frontend/lib/widgets/admin/am_dossier_create_modal.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart'; + +/// Modale de création dossier AM (#156) — même shell que [ValidationDossierModal]. +class AmDossierCreateModal extends StatefulWidget { + final VoidCallback onClose; + final VoidCallback? onSuccess; + + const AmDossierCreateModal({ + super.key, + required this.onClose, + this.onSuccess, + }); + + @override + State createState() => _AmDossierCreateModalState(); +} + +class _AmDossierCreateModalState extends State { + int? _stepIndex; + int? _stepTotal; + + void _onStepChanged(int step, int total) { + if (!mounted) return; + setState(() { + _stepIndex = step; + _stepTotal = total; + }); + } + + void _onSuccess() { + widget.onSuccess?.call(); + } + + static const double _modalWidth = 930; + /// Hauteur calculée depuis 4 lignes de TF (voir [AmDossierWizard.shellBodyHeight]). + static double get _bodyHeight => AmDossierWizard.shellBodyHeight; + + @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: [ + const Padding( + padding: EdgeInsets.fromLTRB(18, 18, 0, 12), + child: Text( + 'Nouvelle assistante maternelle', + style: 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: AmDossierWizard.create( + onClose: widget.onClose, + onSuccess: _onSuccess, + onStepChanged: _onStepChanged, + ), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/am_dossier_wizard.dart b/frontend/lib/widgets/admin/am_dossier_wizard.dart new file mode 100644 index 0000000..affb5ea --- /dev/null +++ b/frontend/lib/widgets/admin/am_dossier_wizard.dart @@ -0,0 +1,965 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:p_tits_pas/models/am_registration_data.dart'; +import 'package:p_tits_pas/models/dossier_unifie.dart'; +import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/utils/date_display_utils.dart'; +import 'package:p_tits_pas/utils/email_utils.dart'; +import 'package:p_tits_pas/utils/name_format_utils.dart'; +import 'package:p_tits_pas/utils/nir_utils.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/utils/postal_utils.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; +import 'package:p_tits_pas/widgets/admin/validation_refus_form.dart'; +import 'package:p_tits_pas/widgets/admin/validation_valider_confirm_dialog.dart'; +import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; +import 'package:p_tits_pas/widgets/common/identity_block.dart'; + +enum AmDossierWizardMode { review, create } + +/// Wizard dossier AM — modes [review] (validation pending) et [create] (staff #156). +class AmDossierWizard extends StatefulWidget { + final AmDossierWizardMode mode; + final DossierAM? dossier; + final VoidCallback onClose; + final VoidCallback onSuccess; + final void Function(int step, int total)? onStepChanged; + + const AmDossierWizard._({ + super.key, + required this.mode, + this.dossier, + required this.onClose, + required this.onSuccess, + this.onStepChanged, + }); + + factory AmDossierWizard.review({ + Key? key, + required DossierAM dossier, + required VoidCallback onClose, + required VoidCallback onSuccess, + void Function(int step, int total)? onStepChanged, + }) { + return AmDossierWizard._( + key: key, + mode: AmDossierWizardMode.review, + dossier: dossier, + onClose: onClose, + onSuccess: onSuccess, + onStepChanged: onStepChanged, + ); + } + + factory AmDossierWizard.create({ + Key? key, + required VoidCallback onClose, + required VoidCallback onSuccess, + void Function(int step, int total)? onStepChanged, + }) { + return AmDossierWizard._( + key: key, + mode: AmDossierWizardMode.create, + onClose: onClose, + onSuccess: onSuccess, + onStepChanged: onStepChanged, + ); + } + + bool get isCreate => mode == AmDossierWizardMode.create; + + /// Hauteur corps modale AM — dérivée de [ValidationFormMetrics] (4 lignes). + static double get shellBodyHeight => + ValidationFormMetrics.shellBodyHeightForRows(4); + + @override + State createState() => _AmDossierWizardState(); +} + +class _AmDossierWizardState extends State { + int _step = 0; + bool _showRefusForm = false; + bool _submitting = false; + + static const int _stepCount = 3; + static const List _photoProRowLayout = [2, 2, 2, 2]; + static const double _idPhotoAspectRatio = 35 / 45; + static const double _photoProGap = 24; + static const double _proColumnMinWidth = 260; + static const double _photoColumnMinWidth = 160; + + // --- Create mode controllers / state --- + late final TextEditingController _nomCtrl; + late final TextEditingController _prenomCtrl; + late final TextEditingController _telCtrl; + late final TextEditingController _emailCtrl; + late final TextEditingController _adresseCtrl; + late final TextEditingController _cpCtrl; + late final TextEditingController _villeCtrl; + late final TextEditingController _nirCtrl; + late final TextEditingController _dateNaissanceCtrl; + late final TextEditingController _lieuNaissanceVilleCtrl; + late final TextEditingController _lieuNaissancePaysCtrl; + late final TextEditingController _agrementCtrl; + late final TextEditingController _dateAgrementCtrl; + late final TextEditingController _capaciteCtrl; + late final TextEditingController _placesCtrl; + late final TextEditingController _presentationCtrl; + + Uint8List? _photoBytes; + String? _photoFilename; + + bool get _isCreate => widget.isCreate; + DossierAM get _dossier => widget.dossier!; + bool get _isEnAttente => + !_isCreate && _dossier.user.statut == 'en_attente'; + + static String _v(String? s) => + (s != null && s.trim().isNotEmpty) ? s.trim() : '–'; + + 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(); + _nomCtrl = TextEditingController(); + _prenomCtrl = TextEditingController(); + _telCtrl = TextEditingController(); + _emailCtrl = TextEditingController(); + _adresseCtrl = TextEditingController(); + _cpCtrl = TextEditingController(); + _villeCtrl = TextEditingController(); + _nirCtrl = TextEditingController(); + _dateNaissanceCtrl = TextEditingController(); + _lieuNaissanceVilleCtrl = TextEditingController(); + _lieuNaissancePaysCtrl = TextEditingController(text: 'France'); + _agrementCtrl = TextEditingController(); + _dateAgrementCtrl = TextEditingController(); + _capaciteCtrl = TextEditingController(text: '1'); + _placesCtrl = TextEditingController(text: '1'); + _presentationCtrl = TextEditingController(); + WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep()); + } + + @override + void dispose() { + _nomCtrl.dispose(); + _prenomCtrl.dispose(); + _telCtrl.dispose(); + _emailCtrl.dispose(); + _adresseCtrl.dispose(); + _cpCtrl.dispose(); + _villeCtrl.dispose(); + _nirCtrl.dispose(); + _dateNaissanceCtrl.dispose(); + _lieuNaissanceVilleCtrl.dispose(); + _lieuNaissancePaysCtrl.dispose(); + _agrementCtrl.dispose(); + _dateAgrementCtrl.dispose(); + _capaciteCtrl.dispose(); + _placesCtrl.dispose(); + _presentationCtrl.dispose(); + super.dispose(); + } + + void _emitStep() => widget.onStepChanged?.call(_step, _stepCount); + + List _photoProFields(DossierAM d) { + final u = d.user; + return [ + AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)), + AdminDetailField( + label: 'Date de naissance', + value: formatIsoDateFr(u.dateNaissance), + ), + AdminDetailField( + label: 'Ville de naissance', + value: _v(u.lieuNaissanceVille), + ), + AdminDetailField( + label: 'Pays de naissance', + value: _v(u.lieuNaissancePays), + ), + AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)), + AdminDetailField( + label: 'Date d’agrément', + value: formatIsoDateFr(d.dateAgrement), + ), + AdminDetailField( + label: 'Capacité max (enfants)', + value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '–', + ), + AdminDetailField( + label: 'Places disponibles', + value: d.placesDisponibles != null + ? d.placesDisponibles.toString() + : '–', + ), + ]; + } + + static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url); + + Widget _buildPhotoSectionReview(AppUser u) { + final photoUrl = _fullPhotoUrl(u.photoUrl); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: LayoutBuilder( + builder: (context, c) { + const uniformFrame = 8.0; + final maxPhotoW = + (c.maxWidth - 2 * uniformFrame).clamp(0.0, double.infinity); + final maxPhotoH = + (c.maxHeight - 2 * uniformFrame).clamp(0.0, double.infinity); + const ar = _idPhotoAspectRatio; + double ph = maxPhotoH; + double pw = ph * ar; + if (pw > maxPhotoW) { + pw = maxPhotoW; + ph = pw / ar; + } + 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), + ), + ], + ), + ) + : AuthNetworkImage( + url: 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), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + }, + ), + ), + ), + ], + ); + } + + Future _pickPhoto() async { + try { + final picked = await ImagePicker().pickImage( + source: ImageSource.gallery, + maxWidth: 1200, + imageQuality: 85, + ); + if (picked == null) return; + final bytes = await picked.readAsBytes(); + if (!mounted) return; + setState(() { + _photoBytes = bytes; + _photoFilename = picked.name; + }); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Impossible de charger la photo'), + backgroundColor: Colors.red.shade700, + ), + ); + } + } + + void _clearPhoto() => setState(() { + _photoBytes = null; + _photoFilename = null; + }); + + String? _validateStep0() { + final nom = _nomCtrl.text.trim(); + final prenom = _prenomCtrl.text.trim(); + if (nom.length < 2) return 'Le nom doit contenir au moins 2 caractères.'; + if (prenom.length < 2) { + return 'Le prénom doit contenir au moins 2 caractères.'; + } + final phoneErr = validateFrenchNationalPhone(_telCtrl.text); + if (phoneErr != null) return phoneErr; + final emailErr = validateEmail(_emailCtrl.text); + if (emailErr != null) return emailErr; + if (_adresseCtrl.text.trim().isEmpty) { + return 'L’adresse est requise.'; + } + final cpErr = validateFrenchPostalCode(_cpCtrl.text); + if (cpErr != null) return cpErr; + if (_villeCtrl.text.trim().isEmpty) return 'La ville est requise.'; + return null; + } + + String? _validateStep1() { + if (_photoBytes == null || _photoBytes!.isEmpty) { + return 'Une photo de profil est requise.'; + } + final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text); + if (birthIso == null) { + return 'La date de naissance est invalide (jj/mm/aaaa).'; + } + final ville = _lieuNaissanceVilleCtrl.text.trim(); + final pays = _lieuNaissancePaysCtrl.text.trim(); + if (ville.length < 2 || pays.length < 2) { + return 'La ville et le pays de naissance sont requis (au moins 2 caractères).'; + } + final nirErr = validateNir(_nirCtrl.text); + if (nirErr != null) return nirErr; + if (_agrementCtrl.text.trim().isEmpty) { + return 'Le numéro d’agrément est requis.'; + } + final agrementIso = parseFrDateToIso(_dateAgrementCtrl.text); + if (agrementIso == null) { + return 'La date d’agrément est invalide (jj/mm/aaaa).'; + } + final capa = int.tryParse(_capaciteCtrl.text.trim()); + if (capa == null || capa < 1 || capa > kAmCapaciteAccueilMax) { + return 'La capacité doit être entre 1 et $kAmCapaciteAccueilMax.'; + } + final places = int.tryParse(_placesCtrl.text.trim()); + if (places == null || places < 0 || places > capa) { + return 'Les places disponibles doivent être entre 0 et la capacité.'; + } + return null; + } + + String? _validateCurrentStep() { + if (!_isCreate) return null; + switch (_step) { + case 0: + return _validateStep0(); + case 1: + return _validateStep1(); + default: + return null; + } + } + + void _goNext() { + final err = _validateCurrentStep(); + if (err != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(err), backgroundColor: Colors.red.shade700), + ); + return; + } + setState(() => _step++); + _emitStep(); + } + + static String _imageMimeForBytes(Uint8List bytes) { + if (bytes.length >= 8 && + bytes[0] == 0x89 && + bytes[1] == 0x50 && + bytes[2] == 0x4E && + bytes[3] == 0x47) { + return 'image/png'; + } + if (bytes.length >= 3 && + bytes[0] == 0xFF && + bytes[1] == 0xD8 && + bytes[2] == 0xFF) { + return 'image/jpeg'; + } + if (bytes.length >= 6 && + bytes[0] == 0x47 && + bytes[1] == 0x49 && + bytes[2] == 0x46) { + return 'image/gif'; + } + if (bytes.length >= 12 && + bytes[0] == 0x52 && + bytes[1] == 0x49 && + bytes[2] == 0x46 && + bytes[3] == 0x46) { + return 'image/webp'; + } + return 'image/jpeg'; + } + + Map _buildCreateBody() { + final bytes = _photoBytes!; + final mime = _imageMimeForBytes(bytes); + final photoBase64 = 'data:$mime;base64,${base64Encode(bytes)}'; + final fn = (_photoFilename ?? '').trim(); + final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text)!; + final agrementIso = parseFrDateToIso(_dateAgrementCtrl.text)!; + final capa = int.parse(_capaciteCtrl.text.trim()); + final places = int.parse(_placesCtrl.text.trim()); + final presentation = _presentationCtrl.text.trim(); + + return { + 'email': normalizeEmailText(_emailCtrl.text), + 'prenom': formatPersonNameCase(_prenomCtrl.text), + 'nom': formatPersonNameCase(_nomCtrl.text), + 'telephone': normalizePhone(_telCtrl.text), + 'adresse': + _adresseCtrl.text.trim().isNotEmpty ? _adresseCtrl.text.trim() : null, + 'code_postal': + _cpCtrl.text.trim().isNotEmpty ? _cpCtrl.text.trim() : null, + 'ville': _villeCtrl.text.trim().isNotEmpty + ? formatPersonNameCase(_villeCtrl.text) + : null, + 'photo_base64': photoBase64, + 'photo_filename': fn.isNotEmpty ? fn : 'photo_am.jpg', + 'consentement_photo': true, + 'date_naissance': birthIso, + 'lieu_naissance_ville': + formatPersonNameCase(_lieuNaissanceVilleCtrl.text), + 'lieu_naissance_pays': + formatPersonNameCase(_lieuNaissancePaysCtrl.text), + 'nir': normalizeNir(_nirCtrl.text), + 'numero_agrement': _agrementCtrl.text.trim(), + 'date_agrement': agrementIso, + 'capacite_accueil': capa, + 'places_disponibles': places, + 'biographie': presentation.isNotEmpty ? presentation : null, + 'acceptation_cgu': true, + 'acceptation_privacy': true, + }; + } + + Future _createAndValidate() async { + if (_submitting) return; + final err0 = _validateStep0(); + if (err0 != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(err0), backgroundColor: Colors.red.shade700), + ); + return; + } + final err1 = _validateStep1(); + if (err1 != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(err1), backgroundColor: Colors.red.shade700), + ); + return; + } + + final ok = await showValidationValiderConfirmDialog( + context, + body: + 'Créer et activer le dossier de cette assistante maternelle ? Un e-mail de création de mot de passe sera envoyé.', + ); + if (!mounted || !ok) return; + + setState(() => _submitting = true); + try { + await UserService.createAmDossier(_buildCreateBody()); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Dossier créé et validé. L’assistante maternelle est active.', + ), + duration: Duration(seconds: 4), + ), + ); + 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); + } + } + + @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() { + switch (_step) { + case 0: + return _buildStep0(); + case 1: + return _buildStep1(); + case 2: + return _buildStep2(); + default: + return const SizedBox(); + } + } + + Widget _buildStep0() { + if (_isCreate) { + return IdentityBlock.editable( + title: 'Identité et coordonnées', + nomController: _nomCtrl, + prenomController: _prenomCtrl, + telephoneController: _telCtrl, + emailController: _emailCtrl, + adresseController: _adresseCtrl, + codePostalController: _cpCtrl, + villeController: _villeCtrl, + ); + } + final u = _dossier.user; + return IdentityBlock.readOnlyFromUser( + u, + title: 'Identité et coordonnées', + emptyLabel: '–', + ); + } + + Widget _buildStep1() { + // Modale calée sur les TF ; photo étirée sur toute la hauteur utile + // (largeur = [AdminAmPhotoFrame.columnWidthForHeight], cadre inclus). + return LayoutBuilder( + builder: (context, c) { + final maxRowW = c.maxWidth; + final maxRowH = c.maxHeight.clamp(0.0, double.infinity); + final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth) + .clamp(0.0, double.infinity); + var photoW = AdminAmPhotoFrame.columnWidthForHeight(maxRowH) + .clamp(_photoColumnMinWidth, 360.0); + if (photoW > maxPhotoW) photoW = maxPhotoW; + + final form = _isCreate + ? _buildCreateProFields() + : ValidationDetailSection( + title: 'Dossier professionnel', + fields: _photoProFields(_dossier), + rowLayout: _photoProRowLayout, + ); + + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: photoW, + child: _isCreate + ? AdminAmPhotoFrame( + imageBytes: _photoBytes, + onTap: _pickPhoto, + onClear: _photoBytes != null ? _clearPhoto : null, + emptyLabel: 'Ajouter une photo', + ) + : _buildPhotoSectionReview(_dossier.user), + ), + const SizedBox(width: _photoProGap), + Expanded( + child: Align( + alignment: Alignment.topCenter, + child: form, + ), + ), + ], + ); + }, + ); + } + + Widget _buildCreateProFields() { + return ValidationFormGrid( + title: 'Dossier professionnel', + rowLayout: _photoProRowLayout, + fields: [ + ValidationLabeledField( + label: 'NIR', + field: ValidationNirField(controller: _nirCtrl), + ), + ValidationLabeledField( + label: 'Date de naissance', + field: ValidationEditableField( + controller: _dateNaissanceCtrl, + hintText: 'jj / mm / aaaa', + keyboardType: TextInputType.number, + inputFormatters: const [FrenchDateInputFormatter()], + ), + ), + ValidationLabeledField( + label: 'Ville de naissance', + field: ValidationEditableField( + controller: _lieuNaissanceVilleCtrl, + inputFormatters: const [PersonNameInputFormatter()], + ), + ), + ValidationLabeledField( + label: 'Pays de naissance', + field: ValidationEditableField( + controller: _lieuNaissancePaysCtrl, + inputFormatters: const [PersonNameInputFormatter()], + ), + ), + ValidationLabeledField( + label: 'N° Agrément', + field: ValidationEditableField(controller: _agrementCtrl), + ), + ValidationLabeledField( + label: 'Date d’agrément', + field: ValidationEditableField( + controller: _dateAgrementCtrl, + hintText: 'jj / mm / aaaa', + keyboardType: TextInputType.number, + inputFormatters: const [FrenchDateInputFormatter()], + ), + ), + ValidationLabeledField( + label: 'Capacité max (enfants)', + field: ValidationEditableField( + controller: _capaciteCtrl, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + ), + ValidationLabeledField( + label: 'Places disponibles', + field: ValidationEditableField( + controller: _placesCtrl, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + ), + ], + ); + } + + Widget _buildStep2() { + if (_isCreate) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Présentation', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const SizedBox(height: 12), + Expanded( + child: TextField( + controller: _presentationCtrl, + maxLines: null, + expands: true, + maxLength: 2000, + textAlignVertical: TextAlignVertical.top, + decoration: InputDecoration( + hintText: 'Présentation (optionnel, max 2000 caractères)', + filled: true, + fillColor: Colors.grey.shade50, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + ), + ), + ], + ); + } + + final presentation = + (_dossier.presentation != null && _dossier.presentation!.trim().isNotEmpty) + ? _dossier.presentation! + : '–'; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Présentation', + 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( + presentation, + style: const TextStyle(color: Colors.black87, fontSize: 14), + ), + ), + ), + ); + }, + ), + ), + ], + ); + } + + 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 (_isCreate) ...[ + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: _submitting ? null : _createAndValidate, + child: Text(_submitting ? 'Envoi...' : 'Créer et valider'), + ), + ] else 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: _goNext, + 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(_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( + isSubmitting: _submitting, + onCancel: widget.onClose, + onPrevious: () => setState(() => _showRefusForm = false), + onSubmit: (comment) { + if (comment == null || comment.trim().isEmpty) return; + _refuserEnvoyer(comment.trim()); + }, + ), + ), + ], + ), + ); + } + + Future _refuserEnvoyer(String comment) async { + if (_submitting) return; + setState(() => _submitting = true); + try { + await UserService.refuseUser(_dossier.user.id, comment: comment); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.', + ), + duration: Duration(seconds: 4), + ), + ); + 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); + } + } +} diff --git a/frontend/lib/widgets/admin/common/validation_detail_section.dart b/frontend/lib/widgets/admin/common/validation_detail_section.dart index 610dd98..faf9f6e 100644 --- a/frontend/lib/widgets/admin/common/validation_detail_section.dart +++ b/frontend/lib/widgets/admin/common/validation_detail_section.dart @@ -1,7 +1,55 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:p_tits_pas/utils/email_utils.dart'; +import 'package:p_tits_pas/utils/nir_utils.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/utils/postal_utils.dart'; import 'admin_detail_modal.dart'; +/// Réglages des formulaires validation / wizard AM — **jouer sur ces 3 leviers**. +class ValidationFormMetrics { + ValidationFormMetrics._(); + + // --- 1. Titres de section --- + static const double sectionTitleFontSize = 16; + static const double sectionTitleGapBelow = 12; + + // --- 2. TF : texte intérieur + padding vertical (= hauteur) --- + static const double fieldTextFontSize = 14; + static const double fieldContentPaddingV = 12; + static const double fieldContentPaddingH = 12; + /// Hauteur estimée du TF (texte + padding haut/bas + bordure). + static const double fieldHeight = + fieldTextFontSize + fieldContentPaddingV * 2 + 4; + + // --- 3. Espace entre les lignes de TF --- + static const double rowGapBelow = 12; + + // Libellé au-dessus du TF (titre du champ) + static const double fieldLabelFontSize = 13; + static const double fieldLabelGapBelow = 4; + + static const TextStyle fieldTextStyle = TextStyle( + color: Colors.black87, + fontSize: fieldTextFontSize, + ); + + static double get sectionTitleBlockHeight => + sectionTitleFontSize + sectionTitleGapBelow; + + static double get labeledRowHeight => + fieldLabelFontSize + fieldLabelGapBelow + fieldHeight + rowGapBelow; + + /// Corps modale AM : padding wizard + titre + [rows] lignes + nav. + static double shellBodyHeightForRows(int rows) => + 20 * 2 + // padding wizard + 4 + // espace haut + sectionTitleBlockHeight + + rows * labeledRowHeight + + 24 + // avant nav + 44; // boutons +} + /// 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). @@ -16,12 +64,16 @@ class ValidationDetailSection extends StatelessWidget { /// Flex par ligne (index de ligne -> [flex1, flex2, ...]). Ex. {3: [2, 5]} pour Code postal | Ville. final Map>? rowFlex; + /// Remplit la hauteur disponible (wizard AM étapes 1–2). + final bool expandVertically; + const ValidationDetailSection({ super.key, this.title, required this.fields, this.rowLayout, this.rowFlex, + this.expandVertically = false, }); @override @@ -30,6 +82,7 @@ class ValidationDetailSection extends StatelessWidget { title: title, rowLayout: rowLayout, rowFlex: rowFlex, + expandVertically: expandVertically, fields: fields .map( (f) => ValidationLabeledField( @@ -49,6 +102,8 @@ class ValidationFormGrid extends StatelessWidget { final List? rowLayout; final Map>? rowFlex; final bool compact; + /// Répartit la hauteur dispo entre les lignes (remplit le blanc sans scroll). + final bool expandVertically; const ValidationFormGrid({ super.key, @@ -57,6 +112,7 @@ class ValidationFormGrid extends StatelessWidget { this.rowLayout, this.rowFlex, this.compact = false, + this.expandVertically = false, }); @override @@ -64,7 +120,7 @@ class ValidationFormGrid extends StatelessWidget { final layout = rowLayout ?? List.filled(fields.length, 1); int index = 0; int rowIndex = 0; - final rows = []; + final rowWidgets = []; for (final count in layout) { if (index >= fields.length) break; final rowFields = fields.skip(index).take(count).toList(); @@ -72,48 +128,73 @@ class ValidationFormGrid extends StatelessWidget { if (rowFields.isEmpty) continue; final flexForRow = rowFlex?[rowIndex]; rowIndex++; + final labeled = rowFields + .map( + (f) => ValidationLabeledField( + label: f.label, + field: f.field, + expand: expandVertically, + ), + ) + .toList(); + + Widget row; if (count == 1) { - rows.add(Padding( - padding: EdgeInsets.only(bottom: compact ? 8 : 12), - child: rowFields.first, - )); + row = labeled.first; } else { - rows.add(Padding( - padding: EdgeInsets.only(bottom: compact ? 8 : 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (int i = 0; i < rowFields.length; i++) ...[ - if (i > 0) SizedBox(width: compact ? 12 : 16), - Expanded( - flex: (flexForRow != null && i < flexForRow.length) - ? flexForRow[i] - : 1, - child: rowFields[i], - ), - ], + row = Row( + crossAxisAlignment: expandVertically + ? CrossAxisAlignment.stretch + : CrossAxisAlignment.start, + children: [ + for (int i = 0; i < labeled.length; i++) ...[ + if (i > 0) SizedBox(width: compact ? 12 : 16), + Expanded( + flex: (flexForRow != null && i < flexForRow.length) + ? flexForRow[i] + : 1, + child: labeled[i], + ), ], + ], + ); + } + + if (expandVertically) { + rowWidgets.add(Expanded(child: row)); + } else { + rowWidgets.add(Padding( + padding: EdgeInsets.only( + bottom: compact ? 8 : ValidationFormMetrics.rowGapBelow, ), + child: row, )); } } + final showTitle = title != null && title!.trim().isNotEmpty; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, + mainAxisSize: expandVertically ? MainAxisSize.max : MainAxisSize.min, children: [ if (showTitle) ...[ Text( title!.trim(), style: TextStyle( - fontSize: compact ? 15 : 16, + fontSize: compact + ? ValidationFormMetrics.sectionTitleFontSize - 1 + : ValidationFormMetrics.sectionTitleFontSize, fontWeight: FontWeight.w600, color: Colors.black87, ), ), - SizedBox(height: compact ? 8 : 12), + SizedBox( + height: compact + ? 8 + : ValidationFormMetrics.sectionTitleGapBelow, + ), ], - ...rows, + ...rowWidgets, ], ); } @@ -130,8 +211,8 @@ class ValidationFieldDecoration { fillColor: Colors.grey.shade50, hintText: hint, contentPadding: EdgeInsets.symmetric( - horizontal: compact ? 10 : 12, - vertical: compact ? 7 : 10, + horizontal: compact ? 10 : ValidationFormMetrics.fieldContentPaddingH, + vertical: compact ? 7 : ValidationFormMetrics.fieldContentPaddingV, ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(6), @@ -180,29 +261,31 @@ class ValidationFieldDecoration { class ValidationLabeledField extends StatelessWidget { final String label; final Widget field; + final bool expand; const ValidationLabeledField({ super.key, required this.label, required this.field, + this.expand = false, }); @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min, children: [ Text( label, style: TextStyle( - fontSize: 12, + fontSize: ValidationFormMetrics.fieldLabelFontSize, fontWeight: FontWeight.w500, color: Colors.grey.shade700, ), ), - const SizedBox(height: 4), - field, + SizedBox(height: ValidationFormMetrics.fieldLabelGapBelow), + if (expand) Expanded(child: field) else field, ], ); } @@ -264,13 +347,16 @@ class ValidationEditableField extends StatelessWidget { ); } if (!compact) { - return TextField( - controller: controller, - keyboardType: keyboardType, - inputFormatters: inputFormatters, - maxLines: 1, - style: const TextStyle(color: Colors.black87, fontSize: 14), - decoration: ValidationFieldDecoration.input(hint: hintText), + return _validationFieldFillHeight( + TextField( + controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + maxLines: 1, + textAlignVertical: TextAlignVertical.center, + style: ValidationFormMetrics.fieldTextStyle, + decoration: ValidationFieldDecoration.input(hint: hintText), + ), ); } return SizedBox( @@ -295,6 +381,347 @@ class ValidationEditableField extends StatelessWidget { } } +/// Étire le champ si le parent impose une hauteur (grille [expandVertically]). +Widget _validationFieldFillHeight(Widget field) { + return LayoutBuilder( + builder: (context, c) { + if (!c.hasBoundedHeight || !c.maxHeight.isFinite) return field; + return SizedBox( + height: c.maxHeight, + width: double.infinity, + child: field, + ); + }, + ); +} + +/// E-mail style validation — même supervision que login / création de compte : +/// [EmailMaxLengthFormatter] + à la perte de focus : trim/minuscules + validation. +class ValidationEmailField extends StatefulWidget { + final TextEditingController controller; + final String? hintText; + final bool allowEmpty; + + const ValidationEmailField({ + super.key, + required this.controller, + this.hintText, + this.allowEmpty = false, + }); + + @override + State createState() => _ValidationEmailFieldState(); +} + +class _ValidationEmailFieldState extends State { + final GlobalKey> _fieldKey = + GlobalKey>(); + late final FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + _focusNode.addListener(_onFocusChange); + } + + void _onFocusChange() { + if (_focusNode.hasFocus) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _focusNode.hasFocus) return; + final c = widget.controller; + final normalized = normalizeEmailText(c.text); + if (normalized != c.text) { + c.value = TextEditingValue( + text: normalized, + selection: TextSelection.collapsed(offset: normalized.length), + ); + } + _fieldKey.currentState?.validate(); + }); + } + + @override + void dispose() { + _focusNode.removeListener(_onFocusChange); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _validationFieldFillHeight( + TextFormField( + key: _fieldKey, + controller: widget.controller, + focusNode: _focusNode, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + enableSuggestions: false, + autofillHints: const [AutofillHints.email], + textInputAction: TextInputAction.next, + textAlignVertical: TextAlignVertical.center, + inputFormatters: const [EmailMaxLengthFormatter()], + style: ValidationFormMetrics.fieldTextStyle, + decoration: + ValidationFieldDecoration.input(hint: widget.hintText).copyWith( + errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11), + errorMaxLines: 2, + ), + validator: (value) => + validateEmail(value, allowEmpty: widget.allowEmpty), + ), + ); + } +} + +/// Code postal FR — même supervision que création de compte : +/// chiffres uniquement (max 5) + validation à la perte de focus. +class ValidationPostalCodeField extends StatefulWidget { + final TextEditingController controller; + final String? hintText; + final bool allowEmpty; + + const ValidationPostalCodeField({ + super.key, + required this.controller, + this.hintText, + this.allowEmpty = false, + }); + + @override + State createState() => + _ValidationPostalCodeFieldState(); +} + +class _ValidationPostalCodeFieldState extends State { + final GlobalKey> _fieldKey = + GlobalKey>(); + late final FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + _focusNode.addListener(_onFocusChange); + } + + void _onFocusChange() { + if (_focusNode.hasFocus) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _focusNode.hasFocus) return; + final c = widget.controller; + final trimmed = c.text.trim(); + if (trimmed != c.text) { + c.value = TextEditingValue( + text: trimmed, + selection: TextSelection.collapsed(offset: trimmed.length), + ); + } + _fieldKey.currentState?.validate(); + }); + } + + @override + void dispose() { + _focusNode.removeListener(_onFocusChange); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _validationFieldFillHeight( + TextFormField( + key: _fieldKey, + controller: widget.controller, + focusNode: _focusNode, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + textAlignVertical: TextAlignVertical.center, + inputFormatters: kFrenchPostalCodeInputFormatters, + style: ValidationFormMetrics.fieldTextStyle, + decoration: ValidationFieldDecoration.input( + hint: widget.hintText ?? '5 chiffres', + ).copyWith( + errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11), + errorMaxLines: 2, + ), + validator: (value) => + validateFrenchPostalCode(value, allowEmpty: widget.allowEmpty), + ), + ); + } +} + +/// Téléphone FR — formatters live + [validateFrenchNationalPhone] à la perte de focus. +class ValidationPhoneField extends StatefulWidget { + final TextEditingController controller; + final String? hintText; + final bool allowEmpty; + + const ValidationPhoneField({ + super.key, + required this.controller, + this.hintText, + this.allowEmpty = false, + }); + + @override + State createState() => _ValidationPhoneFieldState(); +} + +class _ValidationPhoneFieldState extends State { + final GlobalKey> _fieldKey = + GlobalKey>(); + late final FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + _focusNode.addListener(_onFocusChange); + } + + void _onFocusChange() { + if (_focusNode.hasFocus) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _focusNode.hasFocus) return; + final c = widget.controller; + final digits = normalizePhone(c.text); + final formatted = digits.isEmpty ? '' : formatPhoneForDisplay(digits); + if (formatted != c.text) { + c.value = TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); + } + _fieldKey.currentState?.validate(); + }); + } + + @override + void dispose() { + _focusNode.removeListener(_onFocusChange); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _validationFieldFillHeight( + TextFormField( + key: _fieldKey, + controller: widget.controller, + focusNode: _focusNode, + keyboardType: TextInputType.phone, + textInputAction: TextInputAction.next, + textAlignVertical: TextAlignVertical.center, + inputFormatters: frenchPhoneInputFormatters, + style: ValidationFormMetrics.fieldTextStyle, + decoration: + ValidationFieldDecoration.input(hint: widget.hintText).copyWith( + errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11), + errorMaxLines: 2, + ), + validator: (value) => + validateFrenchNationalPhone(value, allowEmpty: widget.allowEmpty), + ), + ); + } +} + +/// NIR — formatage live ([NirInputFormatter]) + validation au fil de la saisie / blur. +class ValidationNirField extends StatefulWidget { + final TextEditingController controller; + final String? hintText; + final bool allowEmpty; + + const ValidationNirField({ + super.key, + required this.controller, + this.hintText, + this.allowEmpty = false, + }); + + @override + State createState() => _ValidationNirFieldState(); +} + +class _ValidationNirFieldState extends State { + final GlobalKey> _fieldKey = + GlobalKey>(); + late final FocusNode _focusNode; + bool _blurred = false; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + _focusNode.addListener(_onFocusChange); + } + + void _onFocusChange() { + if (_focusNode.hasFocus) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _focusNode.hasFocus) return; + setState(() => _blurred = true); + final c = widget.controller; + final raw = nirToRaw(c.text).toUpperCase(); + final formatted = raw.isEmpty ? '' : formatNir(raw); + if (formatted != c.text) { + c.value = TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); + } + _fieldKey.currentState?.validate(); + }); + } + + @override + void dispose() { + _focusNode.removeListener(_onFocusChange); + _focusNode.dispose(); + super.dispose(); + } + + String? _validator(String? value) { + if (_blurred) { + if (widget.allowEmpty && (value == null || value.trim().isEmpty)) { + return null; + } + return validateNir(value); + } + return validateNirTyping(value); + } + + @override + Widget build(BuildContext context) { + return _validationFieldFillHeight( + TextFormField( + key: _fieldKey, + controller: widget.controller, + focusNode: _focusNode, + keyboardType: TextInputType.text, + textInputAction: TextInputAction.next, + textAlignVertical: TextAlignVertical.center, + autovalidateMode: AutovalidateMode.onUserInteraction, + inputFormatters: const [NirInputFormatter()], + style: ValidationFormMetrics.fieldTextStyle, + decoration: ValidationFieldDecoration.input( + hint: widget.hintText ?? '1 12 34 56 789 012 - 34', + ).copyWith( + errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11), + errorMaxLines: 2, + ), + onChanged: (_) => _fieldKey.currentState?.validate(), + validator: _validator, + ), + ); + } +} + /// Grille label/champ éditable (délègue à [ValidationFormGrid]). class ValidationEditableSection extends StatelessWidget { final List fields; @@ -368,16 +795,19 @@ class _ValidationReadOnlyFieldState extends State { @override Widget build(BuildContext context) { if (!widget.compact && widget.maxLines == 1) { - return TextField( - controller: _controller, - readOnly: true, - enableInteractiveSelection: false, - style: TextStyle( - color: widget.error ? Colors.red.shade800 : Colors.black87, - fontSize: 14, - fontWeight: widget.error ? FontWeight.w600 : null, + return _validationFieldFillHeight( + TextField( + controller: _controller, + readOnly: true, + enableInteractiveSelection: false, + textAlignVertical: TextAlignVertical.center, + style: TextStyle( + color: widget.error ? Colors.red.shade800 : Colors.black87, + fontSize: ValidationFormMetrics.fieldTextFontSize, + fontWeight: widget.error ? FontWeight.w600 : null, + ), + decoration: ValidationFieldDecoration.readOnly(error: widget.error), ), - decoration: ValidationFieldDecoration.readOnly(error: widget.error), ); } diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index 3abcb4c..d13eca7 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -2,6 +2,7 @@ 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/am_dossier_create_modal.dart'; import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; @@ -28,6 +29,7 @@ class _UserManagementPanelState extends State { int _gestionnaireRefreshTick = 0; int _adminRefreshTick = 0; int _enfantRefreshTick = 0; + int _amRefreshTick = 0; final TextEditingController _searchController = TextEditingController(); final TextEditingController _amCapacityController = TextEditingController(); String? _parentStatus; @@ -272,6 +274,7 @@ class _UserManagementPanelState extends State { ); case 2: return AssistanteMaternelleManagementWidget( + key: ValueKey('ams-$_amRefreshTick'), searchQuery: _searchController.text, capacityMin: int.tryParse(_amCapacityController.text), ); @@ -331,6 +334,24 @@ class _UserManagementPanelState extends State { return; } + if (contentIndex == 2) { + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return AmDossierCreateModal( + onClose: () => Navigator.of(dialogContext).pop(), + onSuccess: () { + Navigator.of(dialogContext).pop(); + if (!mounted) return; + setState(() => _amRefreshTick++); + }, + ); + }, + ); + return; + } + if (contentIndex == 3) { final created = await showDialog( context: context, @@ -374,7 +395,7 @@ class _UserManagementPanelState extends State { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( - 'La création parent / enfant / AM sera disponible avec le ticket #129.', + 'La création parent sera disponible avec le ticket #129.', ), ), ); diff --git a/frontend/lib/widgets/admin/validation_am_wizard.dart b/frontend/lib/widgets/admin/validation_am_wizard.dart index 5ea035a..0edc9ac 100644 --- a/frontend/lib/widgets/admin/validation_am_wizard.dart +++ b/frontend/lib/widgets/admin/validation_am_wizard.dart @@ -1,20 +1,9 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart'; -import 'package:p_tits_pas/utils/date_display_utils.dart'; -import 'package:p_tits_pas/utils/nir_utils.dart'; -import 'package:p_tits_pas/models/user.dart'; -import 'package:p_tits_pas/services/user_service.dart'; -import 'package:p_tits_pas/services/api/api_config.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; -import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; -import 'package:p_tits_pas/widgets/common/identity_block.dart'; -import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; -import 'validation_modal_theme.dart'; -import 'validation_refus_form.dart'; -import 'validation_valider_confirm_dialog.dart'; +import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart'; -/// Wizard de validation dossier AM : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107. -class ValidationAmWizard extends StatefulWidget { +/// Wrapper historique (#107) — délègue à [AmDossierWizard.review]. +class ValidationAmWizard extends StatelessWidget { final DossierAM dossier; final VoidCallback onClose; final VoidCallback onSuccess; @@ -28,480 +17,13 @@ class ValidationAmWizard extends StatefulWidget { 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); - - /// Panneau photo + grille droite : NIR|naissance, ville|pays, agrément|date, capa|places. - List _photoProFields(DossierAM d) { - final u = d.user; - return [ - AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)), - AdminDetailField( - label: 'Date de naissance', - value: formatIsoDateFr(u.dateNaissance), - ), - AdminDetailField( - label: 'Ville de naissance', - value: _v(u.lieuNaissanceVille), - ), - AdminDetailField( - label: 'Pays de naissance', - value: _v(u.lieuNaissancePays), - ), - AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)), - AdminDetailField( - label: 'Date d’agrément', - value: formatIsoDateFr(d.dateAgrement), - ), - AdminDetailField( - label: 'Capacité max (enfants)', - value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '–', - ), - AdminDetailField( - label: 'Places disponibles', - value: d.placesDisponibles != null - ? d.placesDisponibles.toString() - : '–', - ), - ]; - } - - static const List _photoProRowLayout = [2, 2, 2, 2]; - - /// 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, préfixe [ApiConfig.baseUrl] (ex. `/api/v1/uploads/...`). - static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url); - - Widget _buildPhotoSection(AppUser u) { - final photoUrl = _fullPhotoUrl(u.photoUrl); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - 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), - ), - ], - ), - ) - : AuthNetworkImage( - url: 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(), - ], - ), + return AmDossierWizard.review( + dossier: dossier, + onClose: onClose, + onSuccess: onSuccess, + onStepChanged: onStepChanged, ); } - - 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: IdentityBlock.readOnlyFromUser( - u, - title: 'Identité et coordonnées', - emptyLabel: '–', - ), - ), - ); - }, - ); - 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; - const photoHeaderH = 0.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: 'Dossier professionnel', - fields: _photoProFields(d), - rowLayout: _photoProRowLayout, - ), - ), - ); - }, - ), - ), - ], - ); - }, - ); - case 2: - final presentation = - (d.presentation != null && d.presentation!.trim().isNotEmpty) - ? d.presentation! - : '–'; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text( - 'Présentation', - 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( - 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( - isSubmitting: _submitting, - onCancel: widget.onClose, - onPrevious: () => setState(() => _showRefusForm = false), - onSubmit: (comment) { - if (comment == null || comment.trim().isEmpty) return; - _refuserEnvoyer(comment.trim()); - }, - ), - ), - ], - ), - ); - } - - Future _refuserEnvoyer(String comment) async { - if (_submitting) return; - setState(() => _submitting = true); - try { - await UserService.refuseUser(widget.dossier.user.id, comment: comment); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text( - 'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.', - ), - duration: const Duration(seconds: 4), - ), - ); - 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); - } - } } diff --git a/frontend/lib/widgets/admin/validation_dossier_modal.dart b/frontend/lib/widgets/admin/validation_dossier_modal.dart index 88e2937..7bc3faf 100644 --- a/frontend/lib/widgets/admin/validation_dossier_modal.dart +++ b/frontend/lib/widgets/admin/validation_dossier_modal.dart @@ -1,6 +1,7 @@ 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/am_dossier_wizard.dart'; import 'package:p_tits_pas/widgets/admin/validation_am_wizard.dart'; import 'package:p_tits_pas/widgets/admin/validation_family_wizard.dart'; @@ -76,8 +77,13 @@ class _ValidationDossierModalState extends State { /// 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; + static const double _familyBodyHeight = 435; + + double get _bodyHeight { + final d = _dossier; + if (d != null && d.isAm) return AmDossierWizard.shellBodyHeight; + return _familyBodyHeight; + } @override Widget build(BuildContext context) { diff --git a/frontend/lib/widgets/common/identity_block.dart b/frontend/lib/widgets/common/identity_block.dart index 91a1d30..2aee3e8 100644 --- a/frontend/lib/widgets/common/identity_block.dart +++ b/frontend/lib/widgets/common/identity_block.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/utils/name_format_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; @@ -67,7 +68,9 @@ class IdentityValues { /// Bloc identité : Nom/Prénom, Tél/Email, Adresse, CP/Ville. /// Grille partagée (création de compte, validation AM/famille, fiches admin, etc.). -class IdentityBlock extends StatelessWidget { final String? title; +class IdentityBlock extends StatelessWidget { + final String? title; + final bool expandVertically; final String? _nom; final String? _prenom; @@ -91,6 +94,7 @@ class IdentityBlock extends StatelessWidget { final String? title; const IdentityBlock.readOnly({ super.key, this.title, + this.expandVertically = false, required String nom, required String prenom, required String telephone, @@ -116,6 +120,7 @@ class IdentityBlock extends StatelessWidget { final String? title; const IdentityBlock.editable({ super.key, this.title, + this.expandVertically = false, required TextEditingController nomController, required TextEditingController prenomController, required TextEditingController telephoneController, @@ -142,11 +147,13 @@ class IdentityBlock extends StatelessWidget { final String? title; factory IdentityBlock.readOnlyValues({ Key? key, String? title, + bool expandVertically = false, required IdentityValues values, }) { return IdentityBlock.readOnly( key: key, title: title, + expandVertically: expandVertically, nom: values.nom, prenom: values.prenom, telephone: values.telephone, @@ -163,10 +170,12 @@ class IdentityBlock extends StatelessWidget { final String? title; Key? key, String? title, String emptyLabel = 'Non défini', + bool expandVertically = false, }) { return IdentityBlock.readOnlyValues( key: key, title: title, + expandVertically: expandVertically, values: IdentityValues.fromUser(user, emptyLabel: emptyLabel), ); } @@ -196,29 +205,29 @@ class IdentityBlock extends StatelessWidget { final String? title; title: title, rowLayout: rowLayout, rowFlex: rowFlex, + expandVertically: expandVertically, fields: [ ValidationLabeledField( label: 'Nom', - field: ValidationEditableField(controller: _nomCtrl!), + field: ValidationEditableField( + controller: _nomCtrl!, + inputFormatters: const [PersonNameInputFormatter()], + ), ), ValidationLabeledField( label: 'Prénom', - field: ValidationEditableField(controller: _prenomCtrl!), + field: ValidationEditableField( + controller: _prenomCtrl!, + inputFormatters: const [PersonNameInputFormatter()], + ), ), ValidationLabeledField( label: 'Téléphone', - field: ValidationEditableField( - controller: _telCtrl!, - keyboardType: TextInputType.phone, - inputFormatters: frenchPhoneInputFormatters, - ), + field: ValidationPhoneField(controller: _telCtrl!), ), ValidationLabeledField( label: 'Email', - field: ValidationEditableField( - controller: _emailCtrl!, - keyboardType: TextInputType.emailAddress, - ), + field: ValidationEmailField(controller: _emailCtrl!), ), ValidationLabeledField( label: 'Adresse (N° et Rue)', @@ -226,14 +235,14 @@ class IdentityBlock extends StatelessWidget { final String? title; ), ValidationLabeledField( label: 'Code postal', - field: ValidationEditableField( - controller: _cpCtrl!, - keyboardType: TextInputType.number, - ), + field: ValidationPostalCodeField(controller: _cpCtrl!), ), ValidationLabeledField( label: 'Ville', - field: ValidationEditableField(controller: _villeCtrl!), + field: ValidationEditableField( + controller: _villeCtrl!, + inputFormatters: const [PersonNameInputFormatter()], + ), ), ], ); @@ -243,6 +252,7 @@ class IdentityBlock extends StatelessWidget { final String? title; title: title, rowLayout: rowLayout, rowFlex: rowFlex, + expandVertically: expandVertically, fields: [ ValidationLabeledField( label: 'Nom', diff --git a/frontend/lib/widgets/nir_text_field.dart b/frontend/lib/widgets/nir_text_field.dart index 7ea3d94..16fcff9 100644 --- a/frontend/lib/widgets/nir_text_field.dart +++ b/frontend/lib/widgets/nir_text_field.dart @@ -54,7 +54,7 @@ class NirTextField extends StatelessWidget { inputFontSize: inputFontSize, keyboardType: TextInputType.text, validator: validator ?? validateNir, - inputFormatters: [NirInputFormatter()], + inputFormatters: const [NirInputFormatter()], enabled: enabled, readOnly: readOnly, style: style,