From 14580c34e0857b39a8f69eb62a4cfa646503410b Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 24 Jul 2026 13:16:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(#129):=20cr=C3=A9ation=20dossier=20famille?= =?UTF-8?q?=20staff=20=E2=80=94=20API=20+=20wizard=20dashboard.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permet au dashboard de créer un dossier parent complet (pivot, co-parent optionnel, enfants, n° dossier) déjà actif, avec e-mail de création de mot de passe — miroir du parcours AM (#156), sans passer par l’inscription publique en attente. Co-authored-by: Cursor --- backend/src/routes/auth/auth.module.ts | 2 +- backend/src/routes/auth/auth.service.ts | 124 +- ...taff-create-parent-dossier-response.dto.ts | 37 + .../dto/staff-create-parent-dossier.dto.ts | 29 + .../routes/parents/parents.controller.spec.ts | 64 +- .../src/routes/parents/parents.controller.ts | 51 +- backend/src/routes/parents/parents.module.ts | 2 + docs/tmp/129-contrat-api-staff-parent.md | 89 + frontend/lib/services/api/api_config.dart | 2 + frontend/lib/services/user_service.dart | 48 + .../common/validation_detail_section.dart | 52 +- .../admin/parent_dossier_create_modal.dart | 93 + .../widgets/admin/parent_dossier_wizard.dart | 1596 +++++++++++++++++ .../widgets/admin/user_management_panel.dart | 21 + .../admin/validation_family_wizard.dart | 711 +------- 15 files changed, 2176 insertions(+), 745 deletions(-) create mode 100644 backend/src/routes/parents/dto/staff-create-parent-dossier-response.dto.ts create mode 100644 backend/src/routes/parents/dto/staff-create-parent-dossier.dto.ts create mode 100644 docs/tmp/129-contrat-api-staff-parent.md create mode 100644 frontend/lib/widgets/admin/parent_dossier_create_modal.dart create mode 100644 frontend/lib/widgets/admin/parent_dossier_wizard.dart diff --git a/backend/src/routes/auth/auth.module.ts b/backend/src/routes/auth/auth.module.ts index 3ebf0df..99bb6f4 100644 --- a/backend/src/routes/auth/auth.module.ts +++ b/backend/src/routes/auth/auth.module.ts @@ -29,7 +29,7 @@ import { ParentsChildren } from 'src/entities/parents_children.entity'; ParentsChildren, ]), forwardRef(() => UserModule), - ParentsModule, + forwardRef(() => ParentsModule), DossiersModule, AppConfigModule, MailModule, diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index 14d0cc4..423b9b4 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -416,11 +416,20 @@ export class AuthService { } /** - * Inscription Parent COMPLÈTE - Workflow CDC 6 étapes en 1 transaction - * Gère : Parent 1 + Parent 2 (opt) + Enfants + Présentation + CGU + * Cœur partagé création dossier parent (#129). + * - public : statut en_attente + mails pending + * - staff : statut actif + mails création MDP (pas de mail « dossier en attente ») */ - async inscrireParentComplet(dto: RegisterParentCompletDto) { - if (!dto.acceptation_cgu || !dto.acceptation_privacy) { + async createParentDossier( + dto: RegisterParentCompletDto, + options: { + statut: StatutUtilisateurType; + sendPendingEmail: boolean; + sendPasswordSetupEmail: boolean; + requireCgu: boolean; + }, + ) { + if (options.requireCgu && (!dto.acceptation_cgu || !dto.acceptation_privacy)) { throw new BadRequestException('L\'acceptation des CGU et de la politique de confidentialité est obligatoire'); } @@ -471,7 +480,7 @@ export class AuthService { prenom: dto.prenom, nom: dto.nom, role: RoleType.PARENT, - statut: StatutUtilisateurType.EN_ATTENTE, + statut: options.statut, telephone: dto.telephone, adresse: dto.adresse, code_postal: dto.code_postal, @@ -496,7 +505,7 @@ export class AuthService { prenom: dto.co_parent_prenom, nom: dto.co_parent_nom, role: RoleType.PARENT, - statut: StatutUtilisateurType.EN_ATTENTE, + statut: options.statut, telephone: dto.co_parent_telephone, adresse: dto.co_parent_meme_adresse ? dto.adresse : dto.co_parent_adresse, code_postal: dto.co_parent_meme_adresse ? dto.code_postal : dto.co_parent_code_postal, @@ -612,38 +621,103 @@ export class AuthService { const numeroDossier = resultat.parent1.numero_dossier ?? ''; - try { - await this.mailService.sendRegistrationPendingEmail( - resultat.parent1.email, - resultat.parent1.prenom ?? '', - resultat.parent1.nom ?? '', - numeroDossier, - ); - if (resultat.parent2) { + if (options.sendPendingEmail) { + try { await this.mailService.sendRegistrationPendingEmail( - resultat.parent2.email, - resultat.parent2.prenom ?? '', - resultat.parent2.nom ?? '', + resultat.parent1.email, + resultat.parent1.prenom ?? '', + resultat.parent1.nom ?? '', numeroDossier, ); + if (resultat.parent2) { + await this.mailService.sendRegistrationPendingEmail( + resultat.parent2.email, + resultat.parent2.prenom ?? '', + resultat.parent2.nom ?? '', + numeroDossier, + ); + } + } catch (err) { + this.logger.error( + "[createParentDossier] Échec envoi email d'accusé de réception (inscription conservée)", + err instanceof Error ? err.stack : String(err), + ); } - } catch (err) { - this.logger.error( - "[inscrireParentComplet] É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.parent1.email, + prenom: resultat.parent1.prenom ?? '', + nom: resultat.parent1.nom ?? '', + token: resultat.tokenCreationMdp, + numeroDossier, + }, + 'parent', + ); + if (resultat.parent2 && resultat.tokenCoParent) { + await this.mailService.sendValidatedAccountPasswordSetupEmail( + { + email: resultat.parent2.email, + prenom: resultat.parent2.prenom ?? '', + nom: resultat.parent2.nom ?? '', + token: resultat.tokenCoParent, + numeroDossier, + }, + 'parent', + ); + } + } catch (err) { + this.logger.error( + '[createParentDossier] Échec envoi email création MDP (dossier conservé)', + err instanceof Error ? err.stack : String(err), + ); + } + } + + const message = options.sendPasswordSetupEmail + ? 'Dossier famille 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, parent_id: resultat.parent1.id, - co_parent_id: resultat.parent2?.id, + parent_user_id: resultat.parent1.id, + co_parent_id: resultat.parent2?.id ?? null, + co_parent_user_id: resultat.parent2?.id ?? null, enfants_ids: resultat.enfants.map(e => e.id), - statut: StatutUtilisateurType.EN_ATTENTE, + enfant_ids: resultat.enfants.map(e => e.id), + statut: options.statut, numero_dossier: numeroDossier, }; } + /** + * Inscription Parent publique — CDC (statut en_attente + mail pending). + */ + async inscrireParentComplet(dto: RegisterParentCompletDto) { + return this.createParentDossier(dto, { + statut: StatutUtilisateurType.EN_ATTENTE, + sendPendingEmail: true, + sendPasswordSetupEmail: false, + requireCgu: true, + }); + } + + /** + * Création dossier parent par le staff (#129) — actif + mail création MDP. + */ + async createParentDossierStaff(dto: RegisterParentCompletDto) { + return this.createParentDossier(dto, { + statut: StatutUtilisateurType.ACTIF, + sendPendingEmail: false, + sendPasswordSetupEmail: true, + requireCgu: false, + }); + } + /** * Cœur partagé création dossier AM (#156). * - public : statut en_attente + mail pending diff --git a/backend/src/routes/parents/dto/staff-create-parent-dossier-response.dto.ts b/backend/src/routes/parents/dto/staff-create-parent-dossier-response.dto.ts new file mode 100644 index 0000000..f378245 --- /dev/null +++ b/backend/src/routes/parents/dto/staff-create-parent-dossier-response.dto.ts @@ -0,0 +1,37 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; + +/** Réponse 201 POST /parents/dossier (#129). */ +export class StaffCreateParentDossierResponseDto { + @ApiProperty() + message: string; + + @ApiProperty({ + example: '2026-000043', + description: 'Numéro de dossier famille attribué', + }) + numero_dossier: string; + + @ApiProperty({ format: 'uuid', description: 'UUID user du parent pivot' }) + parent_user_id: string; + + @ApiPropertyOptional({ + format: 'uuid', + nullable: true, + description: 'UUID user du co-parent, ou null', + }) + co_parent_user_id: string | null; + + @ApiProperty({ + enum: StatutUtilisateurType, + example: StatutUtilisateurType.ACTIF, + }) + statut: StatutUtilisateurType; + + @ApiProperty({ + type: [String], + format: 'uuid', + description: 'IDs des enfants créés', + }) + enfant_ids: string[]; +} diff --git a/backend/src/routes/parents/dto/staff-create-parent-dossier.dto.ts b/backend/src/routes/parents/dto/staff-create-parent-dossier.dto.ts new file mode 100644 index 0000000..440bb60 --- /dev/null +++ b/backend/src/routes/parents/dto/staff-create-parent-dossier.dto.ts @@ -0,0 +1,29 @@ +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { IsBoolean, IsOptional } from 'class-validator'; +import { RegisterParentCompletDto } from 'src/routes/auth/dto/register-parent-complet.dto'; + +/** + * Création dossier parent/famille par staff (#129). + * Mêmes champs que l'inscription publique, sans CGU/privacy obligatoires + * (acceptées côté serveur pour le compte du gestionnaire). + */ +export class StaffCreateParentDossierDto extends OmitType(RegisterParentCompletDto, [ + '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/parents/parents.controller.spec.ts b/backend/src/routes/parents/parents.controller.spec.ts index 6735376..0015373 100644 --- a/backend/src/routes/parents/parents.controller.spec.ts +++ b/backend/src/routes/parents/parents.controller.spec.ts @@ -1,18 +1,80 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ParentsController } from './parents.controller'; +import { ParentsService } from './parents.service'; +import { UserService } from '../user/user.service'; +import { AuthService } from '../auth/auth.service'; +import { AuthGuard } from 'src/common/guards/auth.guard'; +import { RolesGuard } from 'src/common/guards/roles.guard'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; describe('ParentsController', () => { let controller: ParentsController; + const authServiceMock = { + createParentDossierStaff: jest.fn(), + }; + const parentsServiceMock = {}; + const userServiceMock = {}; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [ParentsController], - }).compile(); + providers: [ + { provide: ParentsService, useValue: parentsServiceMock }, + { provide: UserService, useValue: userServiceMock }, + { provide: AuthService, useValue: authServiceMock }, + ], + }) + .overrideGuard(AuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); controller = module.get(ParentsController); + jest.clearAllMocks(); }); it('should be defined', () => { expect(controller).toBeDefined(); }); + + it('createDossier delegates to authService.createParentDossierStaff with CGU accepted', async () => { + authServiceMock.createParentDossierStaff.mockResolvedValue({ + message: 'Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.', + parent_user_id: 'p1', + co_parent_user_id: 'p2', + enfant_ids: ['e1'], + statut: StatutUtilisateurType.ACTIF, + numero_dossier: '2026-000043', + }); + + const body = { + email: 'parent.staff@test.fr', + prenom: 'Claire', + nom: 'MARTIN', + telephone: '0689567890', + enfants: [ + { + prenom: 'Emma', + nom: 'MARTIN', + date_naissance: '2023-02-15', + genre: 'F', + }, + ], + }; + + const res = await controller.createDossier(body as any); + expect(authServiceMock.createParentDossierStaff).toHaveBeenCalledWith( + expect.objectContaining({ + email: body.email, + acceptation_cgu: true, + acceptation_privacy: true, + }), + ); + expect(res.numero_dossier).toBe('2026-000043'); + expect(res.parent_user_id).toBe('p1'); + expect(res.co_parent_user_id).toBe('p2'); + expect(res.enfant_ids).toEqual(['e1']); + expect(res.statut).toBe(StatutUtilisateurType.ACTIF); + }); }); diff --git a/backend/src/routes/parents/parents.controller.ts b/backend/src/routes/parents/parents.controller.ts index 396ac2f..6d821ff 100644 --- a/backend/src/routes/parents/parents.controller.ts +++ b/backend/src/routes/parents/parents.controller.ts @@ -3,6 +3,8 @@ import { Controller, Delete, Get, + HttpCode, + HttpStatus, Param, Patch, Post, @@ -10,14 +12,25 @@ import { } from '@nestjs/common'; import { ParentsService } from './parents.service'; import { UserService } from '../user/user.service'; +import { AuthService } from '../auth/auth.service'; import { Parents } from 'src/entities/parents.entity'; import { Users } from 'src/entities/users.entity'; import { Roles } from 'src/common/decorators/roles.decorator'; import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity'; -import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { CreateParentDto } from '../user/dto/create_parent.dto'; import { UpdateParentsDto } from '../user/dto/update_parent.dto'; import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto'; +import { StaffCreateParentDossierDto } from './dto/staff-create-parent-dossier.dto'; +import { StaffCreateParentDossierResponseDto } from './dto/staff-create-parent-dossier-response.dto'; +import { RegisterParentCompletDto } from '../auth/dto/register-parent-complet.dto'; import { AuthGuard } from 'src/common/guards/auth.guard'; import { RolesGuard } from 'src/common/guards/roles.guard'; import { User } from 'src/common/decorators/user.decorator'; @@ -26,14 +39,50 @@ import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto'; import { mapParentForApi, mapParentsForApi } from './parents.mapper'; @ApiTags('Parents') +@ApiBearerAuth('access-token') @Controller('parents') @UseGuards(AuthGuard, RolesGuard) export class ParentsController { constructor( private readonly parentsService: ParentsService, private readonly userService: UserService, + private readonly authService: AuthService, ) {} + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) + @Post('dossier') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'Créer un dossier famille/parent complet (staff) — ticket #129', + description: + 'Crée parent (+ co-parent optionnel) + enfants + n° dossier avec statut actif, ' + + 'et envoie l’e-mail de création de mot de passe. ' + + 'Ne pas utiliser POST /auth/register/parent depuis le dashboard.', + }) + @ApiBody({ type: StaffCreateParentDossierDto }) + @ApiResponse({ status: 201, type: StaffCreateParentDossierResponseDto }) + @ApiResponse({ status: 400, description: 'Validation DTO / métier' }) + @ApiResponse({ status: 403, description: 'Rôle non autorisé' }) + @ApiResponse({ status: 409, description: 'Email pivot et/ou co-parent déjà pris' }) + async createDossier( + @Body() dto: StaffCreateParentDossierDto, + ): Promise { + const registerDto = { + ...dto, + acceptation_cgu: true, + acceptation_privacy: true, + } as RegisterParentCompletDto; + const result = await this.authService.createParentDossierStaff(registerDto); + return { + message: result.message, + numero_dossier: result.numero_dossier, + parent_user_id: result.parent_user_id, + co_parent_user_id: result.co_parent_user_id ?? null, + statut: result.statut, + enfant_ids: result.enfant_ids, + }; + } + @Get('pending-families') @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) @ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' }) diff --git a/backend/src/routes/parents/parents.module.ts b/backend/src/routes/parents/parents.module.ts index b746e87..35aeb2e 100644 --- a/backend/src/routes/parents/parents.module.ts +++ b/backend/src/routes/parents/parents.module.ts @@ -9,11 +9,13 @@ import { ParentsController } from './parents.controller'; import { ParentsService } from './parents.service'; import { Users } from 'src/entities/users.entity'; import { UserModule } from '../user/user.module'; +import { AuthModule } from '../auth/auth.module'; @Module({ imports: [ TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant, ParentsChildren]), forwardRef(() => UserModule), + forwardRef(() => AuthModule), JwtModule.registerAsync({ imports: [ConfigModule], useFactory: (config: ConfigService) => ({ diff --git a/docs/tmp/129-contrat-api-staff-parent.md b/docs/tmp/129-contrat-api-staff-parent.md new file mode 100644 index 0000000..b07e681 --- /dev/null +++ b/docs/tmp/129-contrat-api-staff-parent.md @@ -0,0 +1,89 @@ +# Mini-spec API — POST /parents/dossier (#129) + +Contrat pour le **plan front** (wizard création dossier famille staff). + +Miroir de **#156** (`POST /assistantes-maternelles/dossier`). + +## Endpoint + +| | | +|--|--| +| **Méthode** | `POST` | +| **URL** | `{base}/parents/dossier` | +| **Auth** | Bearer JWT | +| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` | +| **Content-Type** | `application/json` | + +Ne **pas** appeler `POST /auth/register/parent` depuis le dashboard. + +## Body (JSON) + +Aligné `RegisterParentCompletDto`, **sans** CGU/privacy obligatoires (acceptées serveur). + +### Parent 1 (obligatoire) + +| 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 | | + +### Co-parent (optionnel) + +`co_parent_email`, `co_parent_prenom`, `co_parent_nom`, `co_parent_telephone`, +`co_parent_meme_adresse`, `co_parent_adresse`, `co_parent_code_postal`, `co_parent_ville`. + +Si co-parent fourni : e-mail distinct ; mêmes règles téléphone / adresse que register. + +### Enfants (≥ 1) + +| Champ | Type | Notes | +|-------|------|--------| +| `enfants` | `EnfantInscriptionDto[]` | `prenom`, `nom`, `date_naissance` / `date_previsionnelle_naissance`, `genre`, `photo_base64`, `photo_filename`, etc. | + +### Présentation + +| Champ | Type | Obligatoire | +|-------|------|-------------| +| `presentation_dossier` | string | non (max 2000) | + +## Réponses + +### 201 Created + +```json +{ + "message": "Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.", + "numero_dossier": "2026-000043", + "parent_user_id": "uuid-pivot", + "co_parent_user_id": "uuid-ou-null", + "statut": "actif", + "enfant_ids": ["uuid", "..."] +} +``` + +Effets serveur : user(s) parent **actif**, fiches `parents`, enfants + foyer, n° dossier, +**e-mail création MDP** pour chaque compte sans MDP (pas d’accusé « en attente »). + +### Erreurs + +| Code | Cas | +|------|-----| +| 400 | Validation DTO / métier (enfants vides, dates, etc.) | +| 401 | Token manquant / invalide | +| 403 | Rôle non staff | +| 409 | Conflit e-mail (pivot et/ou co-parent) | + +## Front + +- `UserService.createParentDossier(body)` → cet endpoint +- Wizard create basé sur `ValidationFamilyWizard` +- Ne pas envoyer `acceptation_cgu` / `acceptation_privacy` (optionnels) + +## Branche + +`feature/129-creation-dossier-parent` diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index 75a9610..72e7667 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -60,6 +60,8 @@ class ApiConfig { static const String userChildren = '/users/children'; static const String gestionnaires = '/gestionnaires'; static const String parents = '/parents'; + /// Création dossier famille actif par le staff (#129) — body type register parent. + static const String parentsDossier = '/parents/dossier'; static const String assistantesMaternelles = '/assistantes-maternelles'; /// Création dossier AM actif par le staff (#156) — body type register AM. static const String assistantesMaternellesDossier = diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index ce8f662..be87d9e 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -645,6 +645,54 @@ class UserService { return fallback; } + /// Création dossier famille actif côté staff (#129). + /// `POST /parents/dossier` — body aligné sur register parent complet. + /// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur, par parent créé). + /// Ne pas utiliser `POST /auth/register/parent` (public / en_attente). + static Future> createParentDossier( + Map body, + ) async { + final http.Response response; + try { + response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parentsDossier}'), + 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 famille', + ); + if (response.statusCode == 409) { + throw Exception( + message.isNotEmpty ? message : 'Conflit : e-mail déjà utilisé.', + ); + } + if (response.statusCode == 400) { + throw Exception( + message.isNotEmpty ? message : 'Données invalides (400).', + ); + } + throw Exception(message); + } + /// 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). diff --git a/frontend/lib/widgets/admin/common/validation_detail_section.dart b/frontend/lib/widgets/admin/common/validation_detail_section.dart index faf9f6e..80f5a42 100644 --- a/frontend/lib/widgets/admin/common/validation_detail_section.dart +++ b/frontend/lib/widgets/admin/common/validation_detail_section.dart @@ -35,19 +35,23 @@ class ValidationFormMetrics { ); static double get sectionTitleBlockHeight => - sectionTitleFontSize + sectionTitleGapBelow; + sectionTitleFontSize * 1.25 + sectionTitleGapBelow; + /// Libellé : marge au-dessus de [fieldLabelFontSize] (métriques police). static double get labeledRowHeight => - fieldLabelFontSize + fieldLabelGapBelow + fieldHeight + rowGapBelow; + fieldLabelFontSize * 1.25 + + fieldLabelGapBelow + + fieldHeight + + rowGapBelow; - /// Corps modale AM : padding wizard + titre + [rows] lignes + nav. + /// Corps modale AM / famille : 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 + 48; // boutons (+ marge anti-overflow) } /// Bloc type formulaire (titre de section + champs read-only) pour les modales de validation. @@ -134,6 +138,7 @@ class ValidationFormGrid extends StatelessWidget { label: f.label, field: f.field, expand: expandVertically, + labelTrailing: f.labelTrailing, ), ) .toList(); @@ -262,28 +267,37 @@ class ValidationLabeledField extends StatelessWidget { final String label; final Widget field; final bool expand; + /// Widget aligné à droite sur la ligne du libellé (ex. switch « Même adresse »). + final Widget? labelTrailing; const ValidationLabeledField({ super.key, required this.label, required this.field, this.expand = false, + this.labelTrailing, }); @override Widget build(BuildContext context) { + final labelStyle = TextStyle( + fontSize: ValidationFormMetrics.fieldLabelFontSize, + fontWeight: FontWeight.w500, + color: Colors.grey.shade700, + ); return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min, children: [ - Text( - label, - style: TextStyle( - fontSize: ValidationFormMetrics.fieldLabelFontSize, - fontWeight: FontWeight.w500, - color: Colors.grey.shade700, + if (labelTrailing == null) + Text(label, style: labelStyle) + else + Row( + children: [ + Expanded(child: Text(label, style: labelStyle)), + labelTrailing!, + ], ), - ), SizedBox(height: ValidationFormMetrics.fieldLabelGapBelow), if (expand) Expanded(child: field) else field, ], @@ -299,6 +313,7 @@ class ValidationEditableField extends StatelessWidget { final String? hintText; final int maxLines; final bool compact; + final bool enabled; const ValidationEditableField({ super.key, @@ -308,6 +323,7 @@ class ValidationEditableField extends StatelessWidget { this.hintText, this.maxLines = 1, this.compact = false, + this.enabled = true, }); static const double _compactFieldHeight = 34; @@ -339,6 +355,7 @@ class ValidationEditableField extends StatelessWidget { if (maxLines > 1) { return TextField( controller: controller, + enabled: enabled, keyboardType: keyboardType, inputFormatters: inputFormatters, maxLines: maxLines, @@ -350,6 +367,7 @@ class ValidationEditableField extends StatelessWidget { return _validationFieldFillHeight( TextField( controller: controller, + enabled: enabled, keyboardType: keyboardType, inputFormatters: inputFormatters, maxLines: 1, @@ -365,6 +383,7 @@ class ValidationEditableField extends StatelessWidget { decoration: _compactDecoration(), child: TextField( controller: controller, + enabled: enabled, keyboardType: keyboardType, inputFormatters: inputFormatters, maxLines: 1, @@ -381,13 +400,15 @@ class ValidationEditableField extends StatelessWidget { } } -/// Étire le champ si le parent impose une hauteur (grille [expandVertically]). +/// Hauteur TF fixe ; en grille [expandVertically], étire jusqu’à la hauteur dispo. Widget _validationFieldFillHeight(Widget field) { return LayoutBuilder( builder: (context, c) { - if (!c.hasBoundedHeight || !c.maxHeight.isFinite) return field; + final h = (c.hasBoundedHeight && c.maxHeight.isFinite) + ? c.maxHeight + : ValidationFormMetrics.fieldHeight; return SizedBox( - height: c.maxHeight, + height: h, width: double.infinity, child: field, ); @@ -481,12 +502,14 @@ class ValidationPostalCodeField extends StatefulWidget { final TextEditingController controller; final String? hintText; final bool allowEmpty; + final bool enabled; const ValidationPostalCodeField({ super.key, required this.controller, this.hintText, this.allowEmpty = false, + this.enabled = true, }); @override @@ -536,6 +559,7 @@ class _ValidationPostalCodeFieldState extends State { key: _fieldKey, controller: widget.controller, focusNode: _focusNode, + enabled: widget.enabled, keyboardType: TextInputType.number, textInputAction: TextInputAction.next, textAlignVertical: TextAlignVertical.center, diff --git a/frontend/lib/widgets/admin/parent_dossier_create_modal.dart b/frontend/lib/widgets/admin/parent_dossier_create_modal.dart new file mode 100644 index 0000000..10f1126 --- /dev/null +++ b/frontend/lib/widgets/admin/parent_dossier_create_modal.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart'; + +/// Modale de création dossier famille (#129) — même shell que [AmDossierCreateModal]. +class ParentDossierCreateModal extends StatefulWidget { + final VoidCallback onClose; + final VoidCallback? onSuccess; + + const ParentDossierCreateModal({ + super.key, + required this.onClose, + this.onSuccess, + }); + + @override + State createState() => + _ParentDossierCreateModalState(); +} + +class _ParentDossierCreateModalState 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 [ParentDossierWizard.shellBodyHeight]). + static double get _bodyHeight => ParentDossierWizard.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( + 'Nouveau dossier famille', + 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: ParentDossierWizard.create( + onClose: widget.onClose, + onSuccess: _onSuccess, + onStepChanged: _onStepChanged, + ), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/parent_dossier_wizard.dart b/frontend/lib/widgets/admin/parent_dossier_wizard.dart new file mode 100644 index 0000000..262a8a1 --- /dev/null +++ b/frontend/lib/widgets/admin/parent_dossier_wizard.dart @@ -0,0 +1,1596 @@ +import 'dart:convert'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:p_tits_pas/models/dossier_unifie.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/services/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/enfant_status_utils.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/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/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 ParentDossierWizardMode { review, create } + +/// Enfant en cours de saisie (mode création). Contrôleurs propres, à disposer. +class _CreateChild { + final TextEditingController prenomCtrl = TextEditingController(); + final TextEditingController nomCtrl = TextEditingController(); + final TextEditingController dateCtrl = TextEditingController(); + String? genre; + bool isUnborn = false; + Uint8List? photoBytes; + String? photoFilename; + + void dispose() { + prenomCtrl.dispose(); + nomCtrl.dispose(); + dateCtrl.dispose(); + } +} + +/// Wizard dossier famille — modes [review] (validation dossier en attente, ticket #107) +/// et [create] (création dossier famille actif par le staff, ticket #129). +class ParentDossierWizard extends StatefulWidget { + final ParentDossierWizardMode mode; + final DossierFamille? dossier; + final VoidCallback onClose; + final VoidCallback onSuccess; + final void Function(int step, int total)? onStepChanged; + + const ParentDossierWizard._({ + super.key, + required this.mode, + this.dossier, + required this.onClose, + required this.onSuccess, + this.onStepChanged, + }); + + factory ParentDossierWizard.review({ + Key? key, + required DossierFamille dossier, + required VoidCallback onClose, + required VoidCallback onSuccess, + void Function(int step, int total)? onStepChanged, + }) { + return ParentDossierWizard._( + key: key, + mode: ParentDossierWizardMode.review, + dossier: dossier, + onClose: onClose, + onSuccess: onSuccess, + onStepChanged: onStepChanged, + ); + } + + factory ParentDossierWizard.create({ + Key? key, + required VoidCallback onClose, + required VoidCallback onSuccess, + void Function(int step, int total)? onStepChanged, + }) { + return ParentDossierWizard._( + key: key, + mode: ParentDossierWizardMode.create, + onClose: onClose, + onSuccess: onSuccess, + onStepChanged: onStepChanged, + ); + } + + bool get isCreate => mode == ParentDossierWizardMode.create; + + /// Hauteur corps modale famille — 4 lignes TF + marge pour le bandeau switch. + static double get shellBodyHeight => + ValidationFormMetrics.shellBodyHeightForRows(4) + 20; + + @override + State createState() => _ParentDossierWizardState(); +} + +class _ParentDossierWizardState extends State { + int _step = 0; + bool _showRefusForm = false; + bool _submitting = false; + final ScrollController _enfantsScrollController = ScrollController(); + + /// Même logique que [ParentRegisterStep3Screen] : masque alpha sur les bords (ShaderMask dstIn). + bool _enfantsIsScrollable = false; + bool _enfantsFadeLeft = false; + bool _enfantsFadeRight = false; + + /// Fraction de la largeur du viewport pour le fondu (identique inscription étape 3). + static const double _enfantsFadeExtent = 0.05; + + static const int _stepCount = 4; + + // --- Mode création : parent principal --- + late final TextEditingController _p1NomCtrl; + late final TextEditingController _p1PrenomCtrl; + late final TextEditingController _p1TelCtrl; + late final TextEditingController _p1EmailCtrl; + late final TextEditingController _p1AdresseCtrl; + late final TextEditingController _p1CpCtrl; + late final TextEditingController _p1VilleCtrl; + + // --- Mode création : co-parent (optionnel) --- + bool _hasCoParent = false; + bool _sameAddress = false; + late final TextEditingController _p2NomCtrl; + late final TextEditingController _p2PrenomCtrl; + late final TextEditingController _p2TelCtrl; + late final TextEditingController _p2EmailCtrl; + late final TextEditingController _p2AdresseCtrl; + late final TextEditingController _p2CpCtrl; + late final TextEditingController _p2VilleCtrl; + + // --- Mode création : enfants + présentation --- + final List<_CreateChild> _children = []; + late final TextEditingController _presentationCtrl; + + bool get _isCreate => widget.isCreate; + DossierFamille get _dossier => widget.dossier!; + + bool get _isEnAttente => !_isCreate && _dossier.isEnAttente; + + String? get _firstParentId { + if (_isCreate) return null; + return _dossier.parents.isNotEmpty ? _dossier.parents.first.id : null; + } + + static String _v(String? s) => + (s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini'; + + /// Date de naissance en jour/mois/année (dd/MM/yyyy). + static String _formatBirthDate(String? s) => + formatIsoDateFr(s, ifEmpty: 'Non défini'); + + static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url); + + @override + void initState() { + super.initState(); + _enfantsScrollController.addListener(_syncEnfantsScrollFades); + + _p1NomCtrl = TextEditingController(); + _p1PrenomCtrl = TextEditingController(); + _p1TelCtrl = TextEditingController(); + _p1EmailCtrl = TextEditingController(); + _p1AdresseCtrl = TextEditingController(); + _p1CpCtrl = TextEditingController(); + _p1VilleCtrl = TextEditingController(); + + _p2NomCtrl = TextEditingController(); + _p2PrenomCtrl = TextEditingController(); + _p2TelCtrl = TextEditingController(); + _p2EmailCtrl = TextEditingController(); + _p2AdresseCtrl = TextEditingController(); + _p2CpCtrl = TextEditingController(); + _p2VilleCtrl = TextEditingController(); + + _presentationCtrl = TextEditingController(); + if (_isCreate) { + _children.add(_CreateChild()); + } + + WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep()); + } + + @override + void dispose() { + _enfantsScrollController.removeListener(_syncEnfantsScrollFades); + _enfantsScrollController.dispose(); + + _p1NomCtrl.dispose(); + _p1PrenomCtrl.dispose(); + _p1TelCtrl.dispose(); + _p1EmailCtrl.dispose(); + _p1AdresseCtrl.dispose(); + _p1CpCtrl.dispose(); + _p1VilleCtrl.dispose(); + + _p2NomCtrl.dispose(); + _p2PrenomCtrl.dispose(); + _p2TelCtrl.dispose(); + _p2EmailCtrl.dispose(); + _p2AdresseCtrl.dispose(); + _p2CpCtrl.dispose(); + _p2VilleCtrl.dispose(); + + for (final c in _children) { + c.dispose(); + } + _presentationCtrl.dispose(); + super.dispose(); + } + + void _emitStep() => widget.onStepChanged?.call(_step, _stepCount); + + void _showError(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), backgroundColor: Colors.red.shade700), + ); + } + + void _syncEnfantsScrollFades() { + if (!mounted) return; + if (!_enfantsScrollController.hasClients) { + if (_enfantsFadeLeft || _enfantsFadeRight || _enfantsIsScrollable) { + setState(() { + _enfantsIsScrollable = false; + _enfantsFadeLeft = false; + _enfantsFadeRight = false; + }); + } + return; + } + final p = _enfantsScrollController.position; + final scrollable = p.maxScrollExtent > 0; + final left = scrollable && + p.pixels > (p.viewportDimension * _enfantsFadeExtent / 2); + final right = scrollable && + p.pixels < + (p.maxScrollExtent - + (p.viewportDimension * _enfantsFadeExtent / 2)); + if (scrollable != _enfantsIsScrollable || + left != _enfantsFadeLeft || + right != _enfantsFadeRight) { + setState(() { + _enfantsIsScrollable = scrollable; + _enfantsFadeLeft = left; + _enfantsFadeRight = right; + }); + } + } + + @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 _buildEnfantsStep(); + case 3: + return _buildPresentationStep(); + default: + return const SizedBox(); + } + } + + Widget _buildStep0() { + if (_isCreate) { + return IdentityBlock.editable( + title: 'Parent principal', + nomController: _p1NomCtrl, + prenomController: _p1PrenomCtrl, + telephoneController: _p1TelCtrl, + emailController: _p1EmailCtrl, + adresseController: _p1AdresseCtrl, + codePostalController: _p1CpCtrl, + villeController: _p1VilleCtrl, + ); + } + return IdentityBlock.readOnlyFromParentDossier( + _dossier.parents.first, + title: 'Parent principal', + ); + } + + Widget _buildStep1() { + if (_isCreate) { + return _buildCoParentStepCreate(); + } + return _buildParent2Step(); + } + + Widget _buildCoParentStepCreate() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Text( + 'Deuxième parent', + style: TextStyle( + fontSize: ValidationFormMetrics.sectionTitleFontSize, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const Spacer(), + const Text( + 'Ajouter un co-parent', + style: TextStyle(fontSize: 14, color: Colors.black87), + ), + Transform.scale( + scale: 0.75, + child: Switch( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + value: _hasCoParent, + onChanged: (v) => setState(() => _hasCoParent = v), + ), + ), + ], + ), + const SizedBox(height: 8), + if (!_hasCoParent) + const Text( + 'Un seul parent pour ce dossier.', + style: TextStyle(color: Colors.black87), + ) + else + ValidationFormGrid( + compact: true, + rowLayout: IdentityBlock.rowLayout, + rowFlex: IdentityBlock.rowFlex, + fields: [ + ValidationLabeledField( + label: 'Nom', + field: ValidationEditableField( + controller: _p2NomCtrl, + inputFormatters: const [PersonNameInputFormatter()], + ), + ), + ValidationLabeledField( + label: 'Prénom', + field: ValidationEditableField( + controller: _p2PrenomCtrl, + inputFormatters: const [PersonNameInputFormatter()], + ), + ), + ValidationLabeledField( + label: 'Téléphone', + field: ValidationPhoneField(controller: _p2TelCtrl), + ), + ValidationLabeledField( + label: 'Email', + field: ValidationEmailField(controller: _p2EmailCtrl), + ), + ValidationLabeledField( + label: 'Adresse (N° et Rue)', + labelTrailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Même adresse', + style: TextStyle( + fontSize: ValidationFormMetrics.fieldLabelFontSize, + color: Colors.black87, + ), + ), + Transform.scale( + scale: 0.75, + child: Switch( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + value: _sameAddress, + onChanged: (v) => setState(() { + _sameAddress = v; + if (v) _copyP1AddressToP2(); + }), + ), + ), + ], + ), + field: ValidationEditableField( + controller: _p2AdresseCtrl, + enabled: !_sameAddress, + ), + ), + ValidationLabeledField( + label: 'Code postal', + field: ValidationPostalCodeField( + controller: _p2CpCtrl, + enabled: !_sameAddress, + ), + ), + ValidationLabeledField( + label: 'Ville', + field: ValidationEditableField( + controller: _p2VilleCtrl, + enabled: !_sameAddress, + inputFormatters: const [PersonNameInputFormatter()], + ), + ), + ], + ), + ], + ); + } + + void _copyP1AddressToP2() { + _p2AdresseCtrl.text = _p1AdresseCtrl.text; + _p2CpCtrl.text = _p1CpCtrl.text; + _p2VilleCtrl.text = _p1VilleCtrl.text; + } + + Widget _buildParent2Step() { + if (_dossier.parents.length < 2) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Text('Un seul parent pour ce dossier.', + style: TextStyle(color: Colors.black87)), + ); + } + return IdentityBlock.readOnlyFromParentDossier( + _dossier.parents[1], + title: 'Deuxième parent', + ); + } + + static const double _idPhotoAspectRatio = 35 / 45; + + /// Liste horizontale scrollable (fondu bords) partagée par les cartes enfant + /// en lecture seule (review) et éditables (create). + Widget _buildEnfantsHorizontalList({ + required int itemCount, + required Widget Function(int index) itemBuilder, + }) { + return LayoutBuilder( + builder: (context, constraints) { + final cardHeight = constraints.maxHeight; + // Carte large : 1/3 photo + 2/3 champs (scroll horizontal si plusieurs enfants). + final cardWidth = (cardHeight * 1.72).clamp(500.0, 700.0); + return NotificationListener( + onNotification: (_) { + _syncEnfantsScrollFades(); + return false; + }, + child: ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (Rect bounds) { + final stops = [ + 0.0, + _enfantsFadeExtent, + 1.0 - _enfantsFadeExtent, + 1.0, + ]; + if (!_enfantsIsScrollable) { + return LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: const [ + Colors.black, + Colors.black, + Colors.black, + Colors.black, + ], + stops: stops, + ).createShader(bounds); + } + final leftMask = + _enfantsFadeLeft ? Colors.transparent : Colors.black; + final rightMask = + _enfantsFadeRight ? Colors.transparent : Colors.black; + return LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + leftMask, + Colors.black, + Colors.black, + rightMask, + ], + stops: stops, + ).createShader(bounds); + }, + child: Listener( + onPointerSignal: (event) { + if (event is PointerScrollEvent && + _enfantsScrollController.hasClients) { + final offset = _enfantsScrollController.offset + + event.scrollDelta.dy; + _enfantsScrollController.jumpTo(offset.clamp( + _enfantsScrollController.position.minScrollExtent, + _enfantsScrollController.position.maxScrollExtent, + )); + } + }, + child: ListView.builder( + controller: _enfantsScrollController, + scrollDirection: Axis.horizontal, + itemCount: itemCount, + itemBuilder: (_, i) => Padding( + padding: EdgeInsets.only(right: i < itemCount - 1 ? 16 : 0), + child: SizedBox( + width: cardWidth, + height: cardHeight, + child: itemBuilder(i), + ), + ), + ), + ), + ), + ); + }, + ); + } + + Widget _buildEnfantsStep() { + if (_isCreate) { + return _buildEnfantsStepCreate(); + } + final enfants = _dossier.enfants; + if (enfants.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Text('Aucun enfant renseigné.', + style: TextStyle(color: Colors.black87)), + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Enfants', + style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87), + ), + const SizedBox(height: 16), + Expanded( + child: _buildEnfantsHorizontalList( + itemCount: enfants.length, + itemBuilder: (i) => _buildEnfantCard(enfants[i]), + ), + ), + ], + ); + } + + Widget _buildEnfantsStepCreate() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Text( + 'Enfants', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const Spacer(), + TextButton.icon( + onPressed: _addChild, + icon: const Icon(Icons.add), + label: const Text('Ajouter un enfant'), + ), + ], + ), + const SizedBox(height: 8), + Expanded( + child: _buildEnfantsHorizontalList( + itemCount: _children.length, + itemBuilder: (i) => _buildCreateEnfantCard(i), + ), + ), + ], + ); + } + + void _addChild() { + setState(() => _children.add(_CreateChild())); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_enfantsScrollController.hasClients) return; + _enfantsScrollController.animateTo( + _enfantsScrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + }); + } + + void _removeChild(int index) { + if (_children.length <= 1) return; + setState(() { + final removed = _children.removeAt(index); + removed.dispose(); + }); + } + + Future _pickChildPhoto(int index) 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 || index >= _children.length) return; + setState(() { + _children[index].photoBytes = bytes; + _children[index].photoFilename = picked.name; + }); + } catch (_) { + if (!mounted) return; + _showError('Impossible de charger la photo.'); + } + } + + void _clearChildPhoto(int index) { + if (index >= _children.length) return; + setState(() { + _children[index].photoBytes = null; + _children[index].photoFilename = null; + }); + } + + /// Fond carte enfant : teintes très pastel ; bordure discrète ; accent léger (barre). + static const Color _enfantCardBoyBg = Color(0xFFF0F7FB); + static const Color _enfantCardBoyBorder = Color(0xFFE3EDF4); + static const Color _enfantCardGirlBg = Color(0xFFFCF5F8); + static const Color _enfantCardGirlBorder = Color(0xFFEAE3E7); + + static const double _enfantCardRadius = 12; + + static List _enfantCardShadows() => [ + BoxShadow( + color: Colors.black.withOpacity(0.06), + blurRadius: 14, + offset: const Offset(0, 4), + ), + ]; + + static BoxDecoration _enfantCardDecoration(String? gender) { + final g = (gender ?? '').trim().toUpperCase(); + if (g == 'H') { + return BoxDecoration( + color: _enfantCardBoyBg, + borderRadius: BorderRadius.circular(_enfantCardRadius), + border: Border.all(color: _enfantCardBoyBorder, width: 1), + boxShadow: _enfantCardShadows(), + ); + } + if (g == 'F') { + return BoxDecoration( + color: _enfantCardGirlBg, + borderRadius: BorderRadius.circular(_enfantCardRadius), + border: Border.all(color: _enfantCardGirlBorder, width: 1), + boxShadow: _enfantCardShadows(), + ); + } + return BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(_enfantCardRadius), + border: Border.all(color: Colors.grey.shade300), + boxShadow: _enfantCardShadows(), + ); + } + + /// Carte enfant éditable (création) : prénom pleine largeur, puis photo 1/3 + champs 2/3. + Widget _buildCreateEnfantCard(int index) { + final child = _children[index]; + final canRemove = _children.length > 1; + return ClipRRect( + borderRadius: BorderRadius.circular(_enfantCardRadius), + child: Container( + decoration: _enfantCardDecoration(child.genre), + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 14, 8), + child: ValidationLabeledField( + label: 'Prénom', + field: ValidationEditableField( + controller: child.prenomCtrl, + hintText: child.isUnborn ? 'Facultatif' : null, + inputFormatters: const [PersonNameInputFormatter()], + ), + ), + ), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 1, + child: LayoutBuilder( + builder: (context, c) { + const padL = 12.0; + const padR = 8.0; + const padV = 8.0; + final maxW = (c.maxWidth - padL - padR) + .clamp(0.0, double.infinity); + final maxH = (c.maxHeight - 2 * padV) + .clamp(0.0, double.infinity); + const ar = _idPhotoAspectRatio; + double ph = maxH; + double pw = ph * ar; + if (pw > maxW) { + pw = maxW; + ph = pw / ar; + } + return Padding( + padding: const EdgeInsets.fromLTRB( + padL, padV, padR, padV), + child: Align( + alignment: Alignment.centerLeft, + child: SizedBox( + width: pw, + height: ph, + child: AdminAmPhotoFrame( + imageBytes: child.photoBytes, + onTap: () => _pickChildPhoto(index), + onClear: child.photoBytes != null + ? () => _clearChildPhoto(index) + : null, + emptyLabel: 'Ajouter une photo', + ), + ), + ), + ); + }, + ), + ), + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsets.fromLTRB(4, 0, 14, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ValidationLabeledField( + label: 'Nom', + field: ValidationEditableField( + controller: child.nomCtrl, + inputFormatters: const [ + PersonNameInputFormatter(), + ], + ), + ), + const SizedBox(height: 6), + ValidationLabeledField( + label: child.isUnborn + ? 'Date prévisionnelle' + : 'Date de naissance', + labelTrailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'À naître', + style: TextStyle( + fontSize: ValidationFormMetrics + .fieldLabelFontSize, + color: Colors.black87, + ), + ), + Transform.scale( + scale: 0.75, + child: Switch( + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + value: child.isUnborn, + onChanged: (v) => setState(() { + child.isUnborn = v; + if (!v && child.genre == 'Autre') { + child.genre = null; + } + }), + ), + ), + ], + ), + field: ValidationEditableField( + controller: child.dateCtrl, + hintText: 'jj / mm / aaaa', + keyboardType: TextInputType.number, + inputFormatters: const [ + FrenchDateInputFormatter(), + ], + ), + ), + const SizedBox(height: 6), + ValidationLabeledField( + label: 'Genre', + field: _genreDropdown(index), + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + if (canRemove) + Positioned( + top: 4, + right: 4, + child: Material( + color: Colors.transparent, + child: IconButton( + tooltip: 'Retirer cet enfant', + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: + const BoxConstraints(minWidth: 32, minHeight: 32), + icon: Icon(Icons.close, + size: 18, color: Colors.grey.shade700), + onPressed: () => _removeChild(index), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _genreDropdown(int index) { + final child = _children[index]; + final items = >[ + const DropdownMenuItem(value: 'H', child: Text('Garçon')), + const DropdownMenuItem(value: 'F', child: Text('Fille')), + if (child.isUnborn) + const DropdownMenuItem(value: 'Autre', child: Text('Inconnu')), + ]; + final value = child.genre != null && + items.any((e) => e.value == child.genre) + ? child.genre + : null; + return SizedBox( + height: ValidationFormMetrics.fieldHeight, + child: DropdownButtonFormField( + key: ValueKey('genre-$index-${child.isUnborn}'), + value: value, + isExpanded: true, + hint: Text( + 'Sélectionner', + style: TextStyle(color: Colors.grey.shade600, fontSize: 14), + ), + decoration: ValidationFieldDecoration.input(), + items: items, + onChanged: (v) => setState(() => child.genre = v), + ), + ); + } + + /// Carte enfant : prénom pleine largeur, puis ligne photo 1/3 + colonne 2/3 (champs + statut hors TF si besoin). + Widget _buildEnfantCard(EnfantDossier e) { + final photoUrl = _fullPhotoUrl(e.photoUrl); + final columnStatusLabel = _enfantColumnStatusLabel(e); + return ClipRRect( + borderRadius: BorderRadius.circular(_enfantCardRadius), + child: Container( + decoration: _enfantCardDecoration(e.gender), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), + child: _enfantLabeledField('Prénom', _v(e.firstName)), + ), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 1, + child: LayoutBuilder( + builder: (context, c) { + // Même marge gauche que le bloc « Prénom » (12) ; droite / haut / bas 8. + const padL = 12.0; + const padR = 8.0; + const padV = 8.0; + final maxW = + (c.maxWidth - padL - padR).clamp(0.0, double.infinity); + final maxH = + (c.maxHeight - 2 * padV).clamp(0.0, double.infinity); + const ar = _idPhotoAspectRatio; + double ph = maxH; + double pw = ph * ar; + if (pw > maxW) { + pw = maxW; + ph = pw / ar; + } + return Padding( + padding: const EdgeInsets.fromLTRB(padL, padV, padR, padV), + child: Align( + alignment: Alignment.centerLeft, + child: _buildEnfantPhotoSlot(photoUrl, pw, ph), + ), + ); + }, + ), + ), + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsets.fromLTRB(4, 4, 14, 12), + child: columnStatusLabel == null + ? SingleChildScrollView( + child: _buildEnfantInfoFields(e), + ) + : CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: _buildEnfantInfoFields(e), + ), + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Text( + columnStatusLabel, + textAlign: TextAlign.center, + style: GoogleFonts.merienda( + fontSize: 14, + fontStyle: FontStyle.italic, + fontWeight: FontWeight.w600, + color: Colors.grey.shade800, + ), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + /// Statut dans la colonne 2/3 (scolarisé·e, à naître, sans garde, en garde). + String? _enfantColumnStatusLabel(EnfantDossier e) { + return enfantColumnStatusLabel(status: e.status, gender: e.gender); + } + + /// Nom ; date de naissance et genre sur une ligne (prénom au-dessus, pleine largeur). + Widget _buildEnfantInfoFields(EnfantDossier e) { + final isANaitre = (e.status ?? '').trim().toLowerCase() == 'a_naitre'; + final dueDateRenseignee = e.dueDate != null && e.dueDate!.trim().isNotEmpty; + final dateValue = isANaitre + ? (dueDateRenseignee ? '${_formatBirthDate(e.dueDate)} (P)' : '– (P)') + : _formatBirthDate(e.birthDate); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _enfantLabeledField('Nom', _formatNom(e.lastName)), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: _enfantLabeledField('Date de naissance', dateValue), + ), + const SizedBox(width: 16), + Expanded( + flex: 2, + child: _enfantLabeledField( + 'Genre', + _genreEnfantLabel(e.gender, e.status), + ), + ), + ], + ), + ], + ); + } + + Widget _enfantLabeledField(String label, String value) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Colors.grey.shade700, + ), + ), + const SizedBox(height: 4), + ValidationReadOnlyField(value: value), + ], + ); + } + + Widget _buildEnfantPhotoSlot(String photoUrl, double width, double height) { + const photoRadius = 8.0; + return Container( + width: width, + height: height, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(photoRadius), + border: Border.all(color: Colors.black.withOpacity(0.08)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: photoUrl.isEmpty + ? ColoredBox( + color: Colors.grey.shade100, + child: Center( + child: Icon(Icons.person_outline, size: 32, color: Colors.grey.shade400), + ), + ) + : AuthNetworkImage( + url: photoUrl, + fit: BoxFit.cover, + width: width, + height: height, + errorBuilder: (_, __, ___) => ColoredBox( + color: Colors.grey.shade100, + child: Center( + child: Icon(Icons.broken_image_outlined, size: 32, color: Colors.grey.shade400), + ), + ), + ), + ); + } + + static String _formatNom(String? lastName) { + final n = (lastName ?? '').trim().toUpperCase(); + return n.isEmpty ? 'Non défini' : n; + } + + /// Genre enfant : Garçon, Fille, ou "Non connu" (uniquement si l'enfant est à naître). + static String _genreEnfantLabel(String? gender, String? status) { + final g = (gender ?? '').trim().toUpperCase(); + final isANaitre = (status ?? '').trim().toLowerCase() == 'a_naitre'; + if (g == 'H') return 'Garçon'; + if (g == 'F') return 'Fille'; + if (isANaitre) return 'Non connu'; + if (g.isEmpty) return 'Non défini'; + return (gender ?? '').trim(); + } + + Widget _buildPresentationStep() { + 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 p = _dossier.presentation ?? ''; + final text = p.trim().isEmpty ? 'Non défini' : p; + 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( + text, + style: + const TextStyle(color: Colors.black87, fontSize: 14), + ), + ), + ), + ); + }, + ), + ), + ], + ); + } + + // --- Validation (mode création) --- + + String? _validateP1() { + final nom = _p1NomCtrl.text.trim(); + final prenom = _p1PrenomCtrl.text.trim(); + if (nom.length < 2) { + return 'Le nom du parent principal doit contenir au moins 2 caractères.'; + } + if (prenom.length < 2) { + return 'Le prénom du parent principal doit contenir au moins 2 caractères.'; + } + final phoneErr = validateFrenchNationalPhone(_p1TelCtrl.text); + if (phoneErr != null) return phoneErr; + final emailErr = validateEmail(_p1EmailCtrl.text); + if (emailErr != null) return emailErr; + if (_p1AdresseCtrl.text.trim().isEmpty) { + return 'L’adresse du parent principal est requise.'; + } + final cpErr = validateFrenchPostalCode(_p1CpCtrl.text); + if (cpErr != null) return cpErr; + if (_p1VilleCtrl.text.trim().isEmpty) { + return 'La ville du parent principal est requise.'; + } + return null; + } + + String? _validateP2() { + if (!_hasCoParent) return null; + final nom = _p2NomCtrl.text.trim(); + final prenom = _p2PrenomCtrl.text.trim(); + if (nom.length < 2) { + return 'Le nom du co-parent doit contenir au moins 2 caractères.'; + } + if (prenom.length < 2) { + return 'Le prénom du co-parent doit contenir au moins 2 caractères.'; + } + final phoneErr = validateFrenchNationalPhone(_p2TelCtrl.text); + if (phoneErr != null) return 'Co-parent : $phoneErr'; + final emailErr = validateEmail(_p2EmailCtrl.text); + if (emailErr != null) return 'Co-parent : $emailErr'; + if (normalizeEmailText(_p2EmailCtrl.text) == + normalizeEmailText(_p1EmailCtrl.text)) { + return 'L’email du co-parent doit être différent de celui du parent principal.'; + } + if (_sameAddress) { + _copyP1AddressToP2(); + return null; + } + if (_p2AdresseCtrl.text.trim().isEmpty) { + return 'L’adresse du co-parent est requise.'; + } + final cpErr = validateFrenchPostalCode(_p2CpCtrl.text); + if (cpErr != null) return 'Co-parent : $cpErr'; + if (_p2VilleCtrl.text.trim().isEmpty) { + return 'La ville du co-parent est requise.'; + } + return null; + } + + String? _validateEnfants() { + if (_children.isEmpty) return 'Au moins un enfant est requis.'; + for (var i = 0; i < _children.length; i++) { + final c = _children[i]; + final label = 'Enfant ${i + 1}'; + final prenom = c.prenomCtrl.text.trim(); + if (c.isUnborn) { + if (prenom.isNotEmpty && prenom.length < 2) { + return '$label : le prénom doit contenir au moins 2 caractères.'; + } + if (parseFrDateToIso(c.dateCtrl.text) == null) { + return '$label : indiquez une date prévisionnelle valide (jj/mm/aaaa).'; + } + if (c.genre != 'H' && c.genre != 'F' && c.genre != 'Autre') { + return '$label : indiquez le genre (Garçon, Fille ou Inconnu).'; + } + } else { + if (prenom.length < 2) { + return '$label : le prénom doit contenir au moins 2 caractères.'; + } + if (parseFrDateToIso(c.dateCtrl.text) == null) { + return '$label : indiquez une date de naissance valide (jj/mm/aaaa).'; + } + if (c.genre != 'H' && c.genre != 'F') { + return '$label : indiquez le genre (Garçon ou Fille).'; + } + } + } + return null; + } + + String? _validateCurrentStep() { + if (!_isCreate) return null; + switch (_step) { + case 0: + return _validateP1(); + case 1: + return _validateP2(); + case 2: + return _validateEnfants(); + default: + return null; + } + } + + void _goNext() { + final err = _validateCurrentStep(); + if (err != null) { + _showError(err); + 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 _childCreateToJson(_CreateChild c) { + final prenom = formatPersonNameCase(c.prenomCtrl.text); + final nomRaw = c.nomCtrl.text.trim(); + final nom = nomRaw.isNotEmpty + ? formatPersonNameCase(nomRaw) + : formatPersonNameCase(_p1NomCtrl.text); + final dateIso = parseFrDateToIso(c.dateCtrl.text); + + final map = { + 'genre': c.genre, + 'consent_photo': true, + 'grossesse_multiple': false, + }; + + if (prenom.length >= 2) { + map['prenom'] = prenom; + } + if (nom.length >= 2) { + map['nom'] = nom; + } + + if (c.isUnborn) { + if (dateIso != null) { + map['date_previsionnelle_naissance'] = dateIso; + } + } else if (dateIso != null) { + map['date_naissance'] = dateIso; + } + + final bytes = c.photoBytes; + if (bytes != null && bytes.isNotEmpty) { + final mime = _imageMimeForBytes(bytes); + final fn = (c.photoFilename ?? '').trim(); + map['photo_base64'] = 'data:$mime;base64,${base64Encode(bytes)}'; + map['photo_filename'] = fn.isNotEmpty ? fn : 'enfant.jpg'; + } + + return map; + } + + Map _buildCreateBody() { + final body = { + 'email': normalizeEmailText(_p1EmailCtrl.text), + 'prenom': formatPersonNameCase(_p1PrenomCtrl.text), + 'nom': formatPersonNameCase(_p1NomCtrl.text), + 'telephone': normalizePhone(_p1TelCtrl.text), + 'adresse': _p1AdresseCtrl.text.trim(), + 'code_postal': _p1CpCtrl.text.trim(), + 'ville': formatPersonNameCase(_p1VilleCtrl.text), + 'acceptation_cgu': true, + 'acceptation_privacy': true, + }; + + if (_hasCoParent) { + if (_sameAddress) _copyP1AddressToP2(); + body['co_parent_email'] = normalizeEmailText(_p2EmailCtrl.text); + body['co_parent_prenom'] = formatPersonNameCase(_p2PrenomCtrl.text); + body['co_parent_nom'] = formatPersonNameCase(_p2NomCtrl.text); + body['co_parent_telephone'] = normalizePhone(_p2TelCtrl.text); + body['co_parent_meme_adresse'] = _sameAddress; + if (!_sameAddress) { + body['co_parent_adresse'] = _p2AdresseCtrl.text.trim(); + body['co_parent_code_postal'] = _p2CpCtrl.text.trim(); + body['co_parent_ville'] = formatPersonNameCase(_p2VilleCtrl.text); + } + } + + final presentation = _presentationCtrl.text.trim(); + if (presentation.isNotEmpty) { + body['presentation_dossier'] = presentation; + } + + body['enfants'] = _children.map(_childCreateToJson).toList(); + + return body; + } + + Future _createAndValidate() async { + if (_submitting) return; + final err0 = _validateP1(); + if (err0 != null) { + _showError(err0); + return; + } + final err1 = _validateP2(); + if (err1 != null) { + _showError(err1); + return; + } + final err2 = _validateEnfants(); + if (err2 != null) { + _showError(err2); + return; + } + + final ok = await showValidationValiderConfirmDialog( + context, + body: + 'Créer et activer ce dossier famille ? Un e-mail de création de mot de passe sera envoyé à chaque parent créé.', + ); + if (!mounted || !ok) return; + + setState(() => _submitting = true); + try { + await UserService.createParentDossier(_buildCreateBody()); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Dossier créé et validé. La famille est active.'), + duration: Duration(seconds: 4), + ), + ); + widget.onSuccess(); + } catch (e) { + if (!mounted) return; + _showError( + e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur', + ); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + Widget _buildNavigation() { + if (_step == 3) { + return Row( + children: [ + TextButton(onPressed: widget.onClose, child: const Text('Annuler')), + const Spacer(), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: () { + setState(() => _step = 2); + _emitStep(); + }, + child: const Text('Précédent'), + ), + const SizedBox(width: 8), + if (_isCreate) ...[ + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: _submitting ? null : _createAndValidate, + child: Text(_submitting ? 'Envoi...' : 'Créer et valider'), + ), + ] else if (_isEnAttente && _firstParentId != null) ...[ + OutlinedButton( + onPressed: _submitting ? null : _refuser, + child: const Text('Refuser')), + const SizedBox(width: 12), + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: _submitting ? null : _onValiderPressed, + child: Text(_submitting ? 'Envoi...' : 'Valider'), + ), + ] else if (!_isEnAttente) + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: widget.onClose, + child: const Text('Fermer'), + ), + ], + ), + ], + ); + } + return Row( + children: [ + TextButton(onPressed: widget.onClose, child: const Text('Annuler')), + const Spacer(), + if (_step > 0) ...[ + TextButton( + onPressed: () { + setState(() => _step--); + _emitStep(); + }, + child: const Text('Précédent'), + ), + const SizedBox(width: 8), + ], + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: _goNext, + child: const Text('Suivant'), + ), + ], + ); + } + + Future _onValiderPressed() async { + if (_submitting || _firstParentId == null) return; + final ok = await showValidationValiderConfirmDialog( + context, + body: + 'Voulez-vous valider ce dossier famille ? Les comptes parents concernés seront confirmés.', + ); + if (!mounted || !ok) return; + await _valider(); + } + + Future _valider() async { + if (_submitting || _firstParentId == null) return; + setState(() => _submitting = true); + try { + await UserService.validerDossierFamille(_firstParentId!); + if (!mounted) return; + widget.onSuccess(); + } catch (e) { + if (!mounted) return; + _showError( + e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur', + ); + } 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()); + }, + ), + ), + ], + ), + ); + } + + /// Un seul appel : le back refuse tout le dossier famille (co-parents + mails). Ticket #110. + Future _refuserEnvoyer(String comment) async { + if (_submitting) return; + final parentId = _firstParentId?.trim(); + if (parentId == null || parentId.isEmpty || !_isEnAttente) { + if (!mounted) return; + _showError('Aucun compte en attente à refuser pour ce dossier.'); + return; + } + setState(() => _submitting = true); + try { + await UserService.refuseUser(parentId, comment: comment); + if (!mounted) return; + final nbEnAttente = + _dossier.parents.where((p) => p.statut == 'en_attente').length; + final msg = nbEnAttente > 1 + ? 'Refus enregistré. Un e-mail de reprise a été envoyé à chaque parent du dossier.' + : 'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(msg), + duration: const Duration(seconds: 4), + ), + ); + widget.onSuccess(); + } catch (e) { + if (!mounted) return; + _showError( + e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur', + ); + } finally { + if (mounted) setState(() => _submitting = false); + } + } +} diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index d13eca7..7be38c7 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -8,6 +8,7 @@ import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart'; +import 'package:p_tits_pas/widgets/admin/parent_dossier_create_modal.dart'; import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart'; import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart'; @@ -27,6 +28,7 @@ class UserManagementPanel extends StatefulWidget { class _UserManagementPanelState extends State { int _subIndex = 0; int _gestionnaireRefreshTick = 0; + int _parentRefreshTick = 0; int _adminRefreshTick = 0; int _enfantRefreshTick = 0; int _amRefreshTick = 0; @@ -263,6 +265,7 @@ class _UserManagementPanelState extends State { switch (contentIndex) { case 0: return ParentManagementWidget( + key: ValueKey('parents-$_parentRefreshTick'), searchQuery: _searchController.text, statusFilter: _parentStatus, ); @@ -318,6 +321,24 @@ class _UserManagementPanelState extends State { Future _handleAddPressed() async { final contentIndex = _subIndex - _contentIndexOffset; + if (contentIndex == 0) { + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return ParentDossierCreateModal( + onClose: () => Navigator.of(dialogContext).pop(), + onSuccess: () { + Navigator.of(dialogContext).pop(); + if (!mounted) return; + setState(() => _parentRefreshTick++); + }, + ); + }, + ); + return; + } + if (contentIndex == 1) { await showDialog( context: context, diff --git a/frontend/lib/widgets/admin/validation_family_wizard.dart b/frontend/lib/widgets/admin/validation_family_wizard.dart index 76ac741..f72fc4b 100644 --- a/frontend/lib/widgets/admin/validation_family_wizard.dart +++ b/frontend/lib/widgets/admin/validation_family_wizard.dart @@ -1,20 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:flutter/gestures.dart'; -import 'package:google_fonts/google_fonts.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/enfant_status_utils.dart'; -import 'package:p_tits_pas/services/user_service.dart'; -import 'package:p_tits_pas/services/api/api_config.dart'; -import 'package:p_tits_pas/widgets/admin/common/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/parent_dossier_wizard.dart'; -/// Wizard de validation dossier famille : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107. -class ValidationFamilyWizard extends StatefulWidget { +/// Wrapper historique (#107) — délègue à [ParentDossierWizard.review]. +class ValidationFamilyWizard extends StatelessWidget { final DossierFamille dossier; final VoidCallback onClose; final VoidCallback onSuccess; @@ -28,697 +17,13 @@ class ValidationFamilyWizard extends StatefulWidget { this.onStepChanged, }); - @override - State createState() => _ValidationFamilyWizardState(); -} - -class _ValidationFamilyWizardState extends State { - int _step = 0; - bool _showRefusForm = false; - bool _submitting = false; - final ScrollController _enfantsScrollController = ScrollController(); - - /// Même logique que [ParentRegisterStep3Screen] : masque alpha sur les bords (ShaderMask dstIn). - bool _enfantsIsScrollable = false; - bool _enfantsFadeLeft = false; - bool _enfantsFadeRight = false; - - /// Fraction de la largeur du viewport pour le fondu (identique inscription étape 3). - static const double _enfantsFadeExtent = 0.05; - - int get _stepCount => 4; - - @override - void initState() { - super.initState(); - _enfantsScrollController.addListener(_syncEnfantsScrollFades); - WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep()); - } - - @override - void dispose() { - _enfantsScrollController.removeListener(_syncEnfantsScrollFades); - _enfantsScrollController.dispose(); - super.dispose(); - } - - void _emitStep() => widget.onStepChanged?.call(_step, _stepCount); - - void _syncEnfantsScrollFades() { - if (!mounted) return; - if (!_enfantsScrollController.hasClients) { - if (_enfantsFadeLeft || _enfantsFadeRight || _enfantsIsScrollable) { - setState(() { - _enfantsIsScrollable = false; - _enfantsFadeLeft = false; - _enfantsFadeRight = false; - }); - } - return; - } - final p = _enfantsScrollController.position; - final scrollable = p.maxScrollExtent > 0; - final left = scrollable && - p.pixels > (p.viewportDimension * _enfantsFadeExtent / 2); - final right = scrollable && - p.pixels < - (p.maxScrollExtent - - (p.viewportDimension * _enfantsFadeExtent / 2)); - if (scrollable != _enfantsIsScrollable || - left != _enfantsFadeLeft || - right != _enfantsFadeRight) { - setState(() { - _enfantsIsScrollable = scrollable; - _enfantsFadeLeft = left; - _enfantsFadeRight = right; - }); - } - } - - bool get _isEnAttente => widget.dossier.isEnAttente; - - String? get _firstParentId => widget.dossier.parents.isNotEmpty - ? widget.dossier.parents.first.id - : null; - - static String _v(String? s) => - (s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini'; - - /// Date de naissance en jour/mois/année (dd/MM/yyyy). - static String _formatBirthDate(String? s) => - formatIsoDateFr(s, ifEmpty: 'Non défini'); - - static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url); - @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 ParentDossierWizard.review( + dossier: dossier, + onClose: onClose, + onSuccess: onSuccess, + onStepChanged: onStepChanged, ); } - - Widget _buildStepContent() { - final d = widget.dossier; - switch (_step) { - case 0: - return IdentityBlock.readOnlyFromParentDossier( - d.parents.first, - title: 'Parent principal', - ); - case 1: - return _buildParent2Step(); - case 2: - return _buildEnfantsStep(); - case 3: - return _buildPresentationStep(); - default: - return const SizedBox(); - } - } - - Widget _buildParent2Step() { - if (widget.dossier.parents.length < 2) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 12), - child: Text('Un seul parent pour ce dossier.', - style: TextStyle(color: Colors.black87)), - ); - } - return IdentityBlock.readOnlyFromParentDossier( - widget.dossier.parents[1], - title: 'Deuxième parent', - ); - } - - static const double _idPhotoAspectRatio = 35 / 45; - - Widget _buildEnfantsStep() { - final enfants = widget.dossier.enfants; - if (enfants.isEmpty) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 12), - child: Text('Aucun enfant renseigné.', - style: TextStyle(color: Colors.black87)), - ); - } - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text( - 'Enfants', - style: TextStyle( - fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87), - ), - const SizedBox(height: 16), - Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - final cardHeight = constraints.maxHeight; - // Carte large : 1/3 photo + 2/3 champs (scroll horizontal si plusieurs enfants). - final cardWidth = (cardHeight * 1.72).clamp(500.0, 700.0); - return NotificationListener( - onNotification: (_) { - _syncEnfantsScrollFades(); - return false; - }, - child: ShaderMask( - blendMode: BlendMode.dstIn, - shaderCallback: (Rect bounds) { - final stops = [ - 0.0, - _enfantsFadeExtent, - 1.0 - _enfantsFadeExtent, - 1.0, - ]; - if (!_enfantsIsScrollable) { - return LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: const [ - Colors.black, - Colors.black, - Colors.black, - Colors.black, - ], - stops: stops, - ).createShader(bounds); - } - final leftMask = - _enfantsFadeLeft ? Colors.transparent : Colors.black; - final rightMask = - _enfantsFadeRight ? Colors.transparent : Colors.black; - return LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - leftMask, - Colors.black, - Colors.black, - rightMask, - ], - stops: stops, - ).createShader(bounds); - }, - child: Listener( - onPointerSignal: (event) { - if (event is PointerScrollEvent && - _enfantsScrollController.hasClients) { - final offset = _enfantsScrollController.offset + - event.scrollDelta.dy; - _enfantsScrollController.jumpTo(offset.clamp( - _enfantsScrollController.position.minScrollExtent, - _enfantsScrollController.position.maxScrollExtent, - )); - } - }, - child: ListView.builder( - controller: _enfantsScrollController, - scrollDirection: Axis.horizontal, - itemCount: enfants.length, - itemBuilder: (_, i) => Padding( - padding: EdgeInsets.only( - right: i < enfants.length - 1 ? 16 : 0), - child: SizedBox( - width: cardWidth, - height: cardHeight, - child: _buildEnfantCard(enfants[i]), - ), - ), - ), - ), - ), - ); - }, - ), - ), - ], - ); - } - - /// Fond carte enfant : teintes très pastel ; bordure discrète ; accent léger (barre). - static const Color _enfantCardBoyBg = Color(0xFFF0F7FB); - static const Color _enfantCardBoyBorder = Color(0xFFE3EDF4); - static const Color _enfantCardGirlBg = Color(0xFFFCF5F8); - static const Color _enfantCardGirlBorder = Color(0xFFEAE3E7); - - static const double _enfantCardRadius = 12; - - static List _enfantCardShadows() => [ - BoxShadow( - color: Colors.black.withOpacity(0.06), - blurRadius: 14, - offset: const Offset(0, 4), - ), - ]; - - static BoxDecoration _enfantCardDecoration(String? gender) { - final g = (gender ?? '').trim().toUpperCase(); - if (g == 'H') { - return BoxDecoration( - color: _enfantCardBoyBg, - borderRadius: BorderRadius.circular(_enfantCardRadius), - border: Border.all(color: _enfantCardBoyBorder, width: 1), - boxShadow: _enfantCardShadows(), - ); - } - if (g == 'F') { - return BoxDecoration( - color: _enfantCardGirlBg, - borderRadius: BorderRadius.circular(_enfantCardRadius), - border: Border.all(color: _enfantCardGirlBorder, width: 1), - boxShadow: _enfantCardShadows(), - ); - } - return BoxDecoration( - color: Colors.grey.shade50, - borderRadius: BorderRadius.circular(_enfantCardRadius), - border: Border.all(color: Colors.grey.shade300), - boxShadow: _enfantCardShadows(), - ); - } - - /// Carte enfant : prénom pleine largeur, puis ligne photo 1/3 + colonne 2/3 (champs + statut hors TF si besoin). - Widget _buildEnfantCard(EnfantDossier e) { - final photoUrl = _fullPhotoUrl(e.photoUrl); - final columnStatusLabel = _enfantColumnStatusLabel(e); - return ClipRRect( - borderRadius: BorderRadius.circular(_enfantCardRadius), - child: Container( - decoration: _enfantCardDecoration(e.gender), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), - child: _enfantLabeledField('Prénom', _v(e.firstName)), - ), - Expanded( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - flex: 1, - child: LayoutBuilder( - builder: (context, c) { - // Même marge gauche que le bloc « Prénom » (12) ; droite / haut / bas 8. - const padL = 12.0; - const padR = 8.0; - const padV = 8.0; - final maxW = - (c.maxWidth - padL - padR).clamp(0.0, double.infinity); - final maxH = - (c.maxHeight - 2 * padV).clamp(0.0, double.infinity); - const ar = _idPhotoAspectRatio; - double ph = maxH; - double pw = ph * ar; - if (pw > maxW) { - pw = maxW; - ph = pw / ar; - } - return Padding( - padding: const EdgeInsets.fromLTRB(padL, padV, padR, padV), - child: Align( - alignment: Alignment.centerLeft, - child: _buildEnfantPhotoSlot(photoUrl, pw, ph), - ), - ); - }, - ), - ), - Expanded( - flex: 2, - child: Padding( - padding: const EdgeInsets.fromLTRB(4, 4, 14, 12), - child: columnStatusLabel == null - ? SingleChildScrollView( - child: _buildEnfantInfoFields(e), - ) - : CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: _buildEnfantInfoFields(e), - ), - SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Text( - columnStatusLabel, - textAlign: TextAlign.center, - style: GoogleFonts.merienda( - fontSize: 14, - fontStyle: FontStyle.italic, - fontWeight: FontWeight.w600, - color: Colors.grey.shade800, - ), - ), - ), - ), - ], - ), - ), - ), - ], - ), - ), - ], - ), - ), - ); - } - - /// Statut dans la colonne 2/3 (scolarisé·e, à naître, sans garde, en garde). - String? _enfantColumnStatusLabel(EnfantDossier e) { - return enfantColumnStatusLabel(status: e.status, gender: e.gender); - } - - /// Nom ; date de naissance et genre sur une ligne (prénom au-dessus, pleine largeur). - Widget _buildEnfantInfoFields(EnfantDossier e) { - final isANaitre = (e.status ?? '').trim().toLowerCase() == 'a_naitre'; - final dueDateRenseignee = e.dueDate != null && e.dueDate!.trim().isNotEmpty; - final dateValue = isANaitre - ? (dueDateRenseignee ? '${_formatBirthDate(e.dueDate)} (P)' : '– (P)') - : _formatBirthDate(e.birthDate); - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: _enfantLabeledField('Nom', _formatNom(e.lastName)), - ), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 3, - child: _enfantLabeledField('Date de naissance', dateValue), - ), - const SizedBox(width: 16), - Expanded( - flex: 2, - child: _enfantLabeledField( - 'Genre', - _genreEnfantLabel(e.gender, e.status), - ), - ), - ], - ), - ], - ); - } - - Widget _enfantLabeledField(String label, String value) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Colors.grey.shade700, - ), - ), - const SizedBox(height: 4), - ValidationReadOnlyField(value: value), - ], - ); - } - - Widget _buildEnfantPhotoSlot(String photoUrl, double width, double height) { - const photoRadius = 8.0; - return Container( - width: width, - height: height, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(photoRadius), - border: Border.all(color: Colors.black.withOpacity(0.08)), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 6, - offset: const Offset(0, 2), - ), - ], - ), - clipBehavior: Clip.antiAlias, - child: photoUrl.isEmpty - ? ColoredBox( - color: Colors.grey.shade100, - child: Center( - child: Icon(Icons.person_outline, size: 32, color: Colors.grey.shade400), - ), - ) - : AuthNetworkImage( - url: photoUrl, - fit: BoxFit.cover, - width: width, - height: height, - errorBuilder: (_, __, ___) => ColoredBox( - color: Colors.grey.shade100, - child: Center( - child: Icon(Icons.broken_image_outlined, size: 32, color: Colors.grey.shade400), - ), - ), - ), - ); - } - - static String _formatNom(String? lastName) { - final n = (lastName ?? '').trim().toUpperCase(); - return n.isEmpty ? 'Non défini' : n; - } - - /// Genre enfant : Garçon, Fille, ou "Non connu" (uniquement si l'enfant est à naître). - static String _genreEnfantLabel(String? gender, String? status) { - final g = (gender ?? '').trim().toUpperCase(); - final isANaitre = (status ?? '').trim().toLowerCase() == 'a_naitre'; - if (g == 'H') return 'Garçon'; - if (g == 'F') return 'Fille'; - if (isANaitre) return 'Non connu'; - if (g.isEmpty) return 'Non défini'; - return (gender ?? '').trim(); - } - - Widget _buildPresentationStep() { - final p = widget.dossier.presentation ?? ''; - final text = p.trim().isEmpty ? 'Non défini' : p; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text( - 'Présentation', - style: TextStyle( - fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87), - ), - const SizedBox(height: 12), - Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: Colors.grey.shade50, - borderRadius: BorderRadius.circular(6), - border: Border.all(color: Colors.grey.shade300), - ), - child: SelectableText( - text, - style: - const TextStyle(color: Colors.black87, fontSize: 14), - ), - ), - ), - ); - }, - ), - ), - ], - ); - } - - Widget _buildNavigation() { - if (_step == 3) { - return Row( - children: [ - TextButton(onPressed: widget.onClose, child: const Text('Annuler')), - const Spacer(), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - TextButton( - onPressed: () { - setState(() => _step = 2); - _emitStep(); - }, - child: const Text('Précédent'), - ), - const SizedBox(width: 8), - if (_isEnAttente && _firstParentId != null) ...[ - OutlinedButton( - onPressed: _submitting ? null : _refuser, - child: const Text('Refuser')), - const SizedBox(width: 12), - ElevatedButton( - style: ValidationModalTheme.primaryElevatedStyle, - onPressed: _submitting ? null : _onValiderPressed, - child: Text(_submitting ? 'Envoi...' : 'Valider'), - ), - ] else if (!_isEnAttente) - ElevatedButton( - style: ValidationModalTheme.primaryElevatedStyle, - onPressed: widget.onClose, - child: const Text('Fermer'), - ), - ], - ), - ], - ); - } - return Row( - children: [ - TextButton(onPressed: widget.onClose, child: const Text('Annuler')), - const Spacer(), - if (_step > 0) ...[ - TextButton( - onPressed: () { - setState(() => _step--); - _emitStep(); - }, - child: const Text('Précédent'), - ), - const SizedBox(width: 8), - ], - ElevatedButton( - style: ValidationModalTheme.primaryElevatedStyle, - onPressed: () { - setState(() => _step++); - _emitStep(); - }, - child: const Text('Suivant'), - ), - ], - ); - } - - Future _onValiderPressed() async { - if (_submitting || _firstParentId == null) return; - final ok = await showValidationValiderConfirmDialog( - context, - body: - 'Voulez-vous valider ce dossier famille ? Les comptes parents concernés seront confirmés.', - ); - if (!mounted || !ok) return; - await _valider(); - } - - Future _valider() async { - if (_submitting || _firstParentId == null) return; - setState(() => _submitting = true); - try { - await UserService.validerDossierFamille(_firstParentId!); - if (!mounted) return; - widget.onSuccess(); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(e is Exception - ? e.toString().replaceFirst('Exception: ', '') - : 'Erreur'), - backgroundColor: Colors.red.shade700, - ), - ); - } finally { - if (mounted) setState(() => _submitting = false); - } - } - - void _refuser() => setState(() => _showRefusForm = true); - - Widget _buildRefusPage() { - return Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: ValidationRefusForm( - isSubmitting: _submitting, - onCancel: widget.onClose, - onPrevious: () => setState(() => _showRefusForm = false), - onSubmit: (comment) { - if (comment == null || comment.trim().isEmpty) return; - _refuserEnvoyer(comment.trim()); - }, - ), - ), - ], - ), - ); - } - - /// Un seul appel : le back refuse tout le dossier famille (co-parents + mails). Ticket #110. - Future _refuserEnvoyer(String comment) async { - if (_submitting) return; - final parentId = _firstParentId?.trim(); - if (parentId == null || parentId.isEmpty || !_isEnAttente) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('Aucun compte en attente à refuser pour ce dossier.'), - backgroundColor: Colors.red.shade700, - ), - ); - return; - } - setState(() => _submitting = true); - try { - await UserService.refuseUser(parentId, comment: comment); - if (!mounted) return; - final nbEnAttente = widget.dossier.parents - .where((p) => p.statut == 'en_attente') - .length; - final msg = nbEnAttente > 1 - ? 'Refus enregistré. Un e-mail de reprise a été envoyé à chaque parent du dossier.' - : 'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.'; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(msg), - 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); - } - } }