diff --git a/backend/src/modules/mail/mail.service.spec.ts b/backend/src/modules/mail/mail.service.spec.ts index d88d1d8..6d474a6 100644 --- a/backend/src/modules/mail/mail.service.spec.ts +++ b/backend/src/modules/mail/mail.service.spec.ts @@ -98,4 +98,21 @@ describe('MailService (ticket #28)', () => { expect(html).toContain(`https://app.test/reset-password?token=${encodeURIComponent(token)}`); expect(html).not.toContain('create-password'); }); + + it('sendResoumissionPendingEmail — accusé resoumission avec n° dossier', async () => { + await service.sendResoumissionPendingEmail( + 'parent@test.fr', + 'Claire', + 'MARTIN', + '2026-000024', + ); + expect(sendEmailSpy).toHaveBeenCalledTimes(1); + const [to, subject, html] = sendEmailSpy.mock.calls[0]; + expect(to).toBe('parent@test.fr'); + expect(subject).toContain('resoumis'); + expect(subject).toContain('2026-000024'); + expect(html).toContain('Claire'); + expect(html).toContain('2026-000024'); + expect(html).toContain('en attente de validation'); + }); }); diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index 2d96a6e..f063426 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -301,6 +301,43 @@ export class MailService { await this.sendEmail(to, subject, html); } + /** + * Accusé de resoumission après refus (reprise #112) : dossier à nouveau en attente de validation. + */ + async sendResoumissionPendingEmail( + to: string, + prenom: string, + nom: string, + numeroDossier: string, + ): Promise { + const appName = this.configService.get('app_name', "P'titsPas"); + const appUrl = this.configService.get('app_url', 'https://app.ptits-pas.fr'); + + const safe = (s: string) => + (s || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + + const subject = `Votre dossier a été resoumis — ${safe(numeroDossier)}`; + const html = ` +
+

Bonjour ${safe(prenom)} ${safe(nom)},

+

Nous avons bien reçu la resoumission de votre dossier sur ${safe(appName)}.

+

Numéro de dossier : ${safe(numeroDossier)}

+

Votre dossier est de nouveau en attente de validation par notre équipe. Vous recevrez un email lorsqu'il aura été traité.

+
+ Accéder au site +
+
+

Cet email a été envoyé automatiquement. Merci de ne pas y répondre.

+
+ `; + + await this.sendEmail(to, subject, html); + } + /** * Email de refus de dossier avec lien reprise (token). * Ticket #110 – Refus sans suppression diff --git a/backend/src/routes/auth/auth.controller.ts b/backend/src/routes/auth/auth.controller.ts index 7e2c6d3..cdc016e 100644 --- a/backend/src/routes/auth/auth.controller.ts +++ b/backend/src/routes/auth/auth.controller.ts @@ -102,8 +102,7 @@ export class AuthController { @ApiResponse({ status: 200, description: 'Dossier resoumis' }) @ApiResponse({ status: 404, description: 'Token invalide ou expiré' }) async resoumettreReprise(@Body() dto: ResoumettreRepriseDto) { - const { token, ...fields } = dto; - return this.authService.resoumettreReprise(token, fields); + return this.authService.resoumettreReprise(dto); } @Public() diff --git a/backend/src/routes/auth/auth.module.ts b/backend/src/routes/auth/auth.module.ts index 34d4631..3ebf0df 100644 --- a/backend/src/routes/auth/auth.module.ts +++ b/backend/src/routes/auth/auth.module.ts @@ -12,11 +12,25 @@ import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entit import { AppConfigModule } from 'src/modules/config'; import { NumeroDossierModule } from 'src/modules/numero-dossier/numero-dossier.module'; import { MailModule } from 'src/modules/mail/mail.module'; +import { ParentsModule } from '../parents/parents.module'; +import { DossiersModule } from '../dossiers/dossiers.module'; +import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity'; +import { ParentsChildren } from 'src/entities/parents_children.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]), + TypeOrmModule.forFeature([ + Users, + Parents, + Children, + AssistanteMaternelle, + DossierFamille, + DossierFamilleEnfant, + ParentsChildren, + ]), forwardRef(() => UserModule), + ParentsModule, + DossiersModule, AppConfigModule, MailModule, NumeroDossierModule, diff --git a/backend/src/routes/auth/auth.service.spec.ts b/backend/src/routes/auth/auth.service.spec.ts index 0361563..e373abd 100644 --- a/backend/src/routes/auth/auth.service.spec.ts +++ b/backend/src/routes/auth/auth.service.spec.ts @@ -5,11 +5,13 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { ConfigService } from '@nestjs/config'; import { AuthService } from './auth.service'; import { UserService } from '../user/user.service'; +import { ParentsService } from '../parents/parents.service'; +import { DossiersService } from '../dossiers/dossiers.service'; import { AppConfigService } from 'src/modules/config/config.service'; import { MailService } from 'src/modules/mail/mail.service'; import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service'; import { Parents } from 'src/entities/parents.entity'; -import { Users } from 'src/entities/users.entity'; +import { Users, RoleType, StatutUtilisateurType } from 'src/entities/users.entity'; import { Children } from 'src/entities/children.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; @@ -19,6 +21,20 @@ describe('AuthService (#118 create-password API)', () => { findOne: jest.fn(), createQueryBuilder: jest.fn(), update: jest.fn(), + manager: { transaction: jest.fn() }, + }; + + const userServiceMock = { + findByTokenReprise: jest.fn(), + }; + + const parentsServiceMock = { + getDossierFamilleByNumero: jest.fn(), + getFamilyUserIds: jest.fn(), + }; + + const dossiersServiceMock = { + getDossierByNumero: jest.fn(), }; const mailServiceMock = { @@ -45,7 +61,9 @@ describe('AuthService (#118 create-password API)', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ AuthService, - { provide: UserService, useValue: {} }, + { provide: UserService, useValue: userServiceMock }, + { provide: ParentsService, useValue: parentsServiceMock }, + { provide: DossiersService, useValue: dossiersServiceMock }, { provide: JwtService, useValue: {} }, { provide: ConfigService, useValue: { get: jest.fn() } }, { provide: AppConfigService, useValue: appConfigMock }, @@ -136,4 +154,69 @@ describe('AuthService (#118 create-password API)', () => { expect(exec).toHaveBeenCalled(); }); }); + + describe('Reprise après refus (#112)', () => { + const token = '11111111-1111-1111-1111-111111111111'; + + it('getRepriseDossier throws when token invalid', async () => { + userServiceMock.findByTokenReprise.mockResolvedValue(null); + await expect(service.getRepriseDossier(token)).rejects.toThrow(NotFoundException); + }); + + it('getRepriseDossier returns famille complète pour parent', async () => { + userServiceMock.findByTokenReprise.mockResolvedValue({ + id: 'p1', + email: 'claire@test.fr', + prenom: 'Claire', + nom: 'MARTIN', + role: RoleType.PARENT, + numero_dossier: '2026-000021', + statut: StatutUtilisateurType.REFUSE, + }); + parentsServiceMock.getDossierFamilleByNumero.mockResolvedValue({ + numero_dossier: '2026-000021', + parents: [{ user_id: 'p1', email: 'claire@test.fr', statut: StatutUtilisateurType.REFUSE }], + enfants: [{ id: 'e1', first_name: 'Emma', status: 'actif' }], + texte_motivation: 'Motivation test', + }); + + const result = await service.getRepriseDossier(token); + expect(result.parents).toHaveLength(1); + expect(result.enfants).toHaveLength(1); + expect(result.texte_motivation).toBe('Motivation test'); + expect(result.numero_dossier).toBe('2026-000021'); + }); + + it('getRepriseDossier returns fiche AM complète', async () => { + userServiceMock.findByTokenReprise.mockResolvedValue({ + id: 'am1', + email: 'am@test.fr', + role: RoleType.ASSISTANTE_MATERNELLE, + numero_dossier: '2026-000003', + }); + dossiersServiceMock.getDossierByNumero.mockResolvedValue({ + type: 'am', + dossier: { + numero_dossier: '2026-000003', + user: { + id: 'am1', + email: 'am@test.fr', + prenom: 'Marie', + nom: 'DUPONT', + statut: StatutUtilisateurType.REFUSE, + }, + numero_agrement: 'AGR-1', + nir: '123456789012345', + biographie: 'Bio', + nb_max_enfants: 4, + place_disponible: 2, + }, + }); + + const result = await service.getRepriseDossier(token); + expect(result.numero_agrement).toBe('AGR-1'); + expect(result.biographie).toBe('Bio'); + expect(result.nb_max_enfants).toBe(4); + }); + }); }); diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index b291009..f78a06e 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -7,7 +7,7 @@ import { BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { QueryFailedError, Repository } from 'typeorm'; +import { EntityManager, In, QueryFailedError, Repository } from 'typeorm'; import { UserService } from '../user/user.service'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; @@ -27,11 +27,15 @@ import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famil import { StatutDossierType } from 'src/entities/dossiers.entity'; import { LoginDto } from './dto/login.dto'; import { RepriseDossierDto } from './dto/reprise-dossier.dto'; +import { ResoumettreRepriseDto } from './dto/resoumettre-reprise.dto'; import { RepriseIdentifyResponseDto } from './dto/reprise-identify.dto'; import { AppConfigService } from 'src/modules/config/config.service'; import { validateNir } from 'src/common/utils/nir.util'; import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service'; import { MailService } from 'src/modules/mail/mail.service'; +import { ParentsService } from '../parents/parents.service'; +import { DossiersService } from '../dossiers/dossiers.service'; +import { DossierAmCompletDto } from '../dossiers/dto/dossier-am-complet.dto'; @Injectable() export class AuthService { @@ -44,6 +48,8 @@ export class AuthService { private readonly appConfigService: AppConfigService, private readonly mailService: MailService, private readonly numeroDossierService: NumeroDossierService, + private readonly parentsService: ParentsService, + private readonly dossiersService: DossiersService, @InjectRepository(Parents) private readonly parentsRepo: Repository, @InjectRepository(Users) @@ -873,12 +879,72 @@ export class AuthService { return { success: true, message: 'Deconnexion'} } - /** GET dossier reprise – token seul. Ticket #111 */ + /** GET dossier reprise – token seul. Ticket #111, enrichi #112 */ async getRepriseDossier(token: string): Promise { const user = await this.usersService.findByTokenReprise(token); if (!user) { throw new NotFoundException('Token reprise invalide ou expiré.'); } + + const base = this.userToRepriseBase(user); + + if (user.role === RoleType.PARENT && user.numero_dossier) { + try { + const famille = await this.parentsService.getDossierFamilleByNumero(user.numero_dossier); + return { + ...base, + parents: famille.parents, + enfants: famille.enfants, + texte_motivation: famille.texte_motivation, + }; + } catch (err) { + if (err instanceof NotFoundException) { + return base; + } + throw err; + } + } + + if (user.role === RoleType.ASSISTANTE_MATERNELLE && user.numero_dossier) { + try { + const unifie = await this.dossiersService.getDossierByNumero(user.numero_dossier); + if (unifie.type === 'am') { + return this.mergeAmRepriseDossier(base, unifie.dossier as DossierAmCompletDto); + } + } catch (err) { + if (err instanceof NotFoundException) { + return base; + } + throw err; + } + } + + return base; + } + + /** PATCH resoumission reprise – dossier complet. Ticket #111, enrichi #112 */ + async resoumettreReprise(dto: ResoumettreRepriseDto): Promise<{ + message: string; + statut: StatutUtilisateurType; + user_id: string; + numero_dossier?: string; + }> { + const user = await this.usersService.findByTokenReprise(dto.token); + if (!user) { + throw new NotFoundException('Token reprise invalide ou expiré.'); + } + + if (user.role === RoleType.PARENT) { + return this.resoumettreRepriseParent(user, dto); + } + if (user.role === RoleType.ASSISTANTE_MATERNELLE) { + return this.resoumettreRepriseAM(user, dto); + } + + throw new BadRequestException('Rôle non pris en charge pour la reprise.'); + } + + private userToRepriseBase(user: Users): RepriseDossierDto { return { id: user.id, email: user.email, @@ -896,12 +962,291 @@ export class AuthService { }; } - /** PUT resoumission reprise. Ticket #111 */ - async resoumettreReprise( - token: string, - dto: { prenom?: string; nom?: string; telephone?: string; adresse?: string; ville?: string; code_postal?: string; photo_url?: string }, - ): Promise { - return this.usersService.resoumettreReprise(token, dto); + private mergeAmRepriseDossier(base: RepriseDossierDto, dossier: DossierAmCompletDto): RepriseDossierDto { + const u = dossier.user; + return { + ...base, + email: u.email, + prenom: u.prenom, + nom: u.nom, + telephone: u.telephone, + adresse: u.adresse, + ville: u.ville, + code_postal: u.code_postal, + photo_url: u.photo_url, + consentement_photo: u.consentement_photo, + date_naissance: u.date_naissance, + lieu_naissance_ville: u.lieu_naissance_ville, + lieu_naissance_pays: u.lieu_naissance_pays, + numero_agrement: dossier.numero_agrement, + nir: dossier.nir, + date_agrement: dossier.date_agrement, + nb_max_enfants: dossier.nb_max_enfants, + place_disponible: dossier.place_disponible, + biographie: dossier.biographie, + }; + } + + private applyTitulaireUserFields(user: Users, dto: ResoumettreRepriseDto): void { + if (dto.prenom !== undefined) user.prenom = dto.prenom; + if (dto.nom !== undefined) user.nom = dto.nom; + if (dto.telephone !== undefined) user.telephone = dto.telephone; + if (dto.adresse !== undefined) user.adresse = dto.adresse; + if (dto.ville !== undefined) user.ville = dto.ville; + if (dto.code_postal !== undefined) user.code_postal = dto.code_postal; + } + + private async resoumettreRepriseParent( + titulaire: Users, + dto: ResoumettreRepriseDto, + ): Promise<{ message: string; statut: StatutUtilisateurType; user_id: string; numero_dossier?: string }> { + const motivation = + dto.texte_motivation?.trim() ?? + dto.presentation_dossier?.trim(); + + const result = await this.usersRepo.manager.transaction(async (manager) => { + const familyUsers = titulaire.numero_dossier + ? await manager.find(Users, { + where: { + role: RoleType.PARENT, + numero_dossier: titulaire.numero_dossier, + }, + }) + : [titulaire]; + + const titulaireLive = familyUsers.find((u) => u.id === titulaire.id) ?? titulaire; + this.applyTitulaireUserFields(titulaireLive, dto); + + const coParentUser = familyUsers.find((u) => u.id !== titulaire.id); + if (coParentUser) { + if (dto.co_parent_prenom !== undefined) coParentUser.prenom = dto.co_parent_prenom; + if (dto.co_parent_nom !== undefined) coParentUser.nom = dto.co_parent_nom; + if (dto.co_parent_telephone !== undefined) coParentUser.telephone = dto.co_parent_telephone; + + if (dto.co_parent_meme_adresse) { + coParentUser.adresse = titulaireLive.adresse; + coParentUser.code_postal = titulaireLive.code_postal; + coParentUser.ville = titulaireLive.ville; + } else { + if (dto.co_parent_adresse !== undefined) coParentUser.adresse = dto.co_parent_adresse; + if (dto.co_parent_code_postal !== undefined) coParentUser.code_postal = dto.co_parent_code_postal; + if (dto.co_parent_ville !== undefined) coParentUser.ville = dto.co_parent_ville; + } + } + + if (titulaire.numero_dossier && motivation !== undefined) { + const dossierFamille = await manager.findOne(DossierFamille, { + where: { numero_dossier: titulaire.numero_dossier }, + }); + if (dossierFamille) { + dossierFamille.presentation = motivation || undefined; + await manager.save(DossierFamille, dossierFamille); + } + } + + if (dto.enfants?.length) { + const familyChildIds = await this.getFamilyChildrenIds(manager, titulaireLive.id); + for (const enfantDto of dto.enfants) { + if (!familyChildIds.has(enfantDto.id)) { + throw new BadRequestException( + `Enfant inconnu pour ce dossier : ${enfantDto.id}`, + ); + } + + const enfant = await manager.findOne(Children, { where: { id: enfantDto.id } }); + if (!enfant) { + throw new BadRequestException(`Enfant introuvable : ${enfantDto.id}`); + } + + if (enfantDto.prenom !== undefined) enfant.first_name = enfantDto.prenom; + if (enfantDto.nom !== undefined) enfant.last_name = enfantDto.nom; + if (enfantDto.genre !== undefined) enfant.gender = enfantDto.genre; + if (enfantDto.date_naissance !== undefined) { + enfant.birth_date = new Date(enfantDto.date_naissance); + enfant.status = StatutEnfantType.ACTIF; + } + if (enfantDto.date_previsionnelle_naissance !== undefined) { + enfant.due_date = new Date(enfantDto.date_previsionnelle_naissance); + if (!enfantDto.date_naissance) { + enfant.status = StatutEnfantType.A_NAITRE; + } + } + if (enfantDto.grossesse_multiple !== undefined) { + enfant.is_multiple = enfantDto.grossesse_multiple; + } + + if (enfantDto.photo_base64 && enfantDto.photo_filename) { + enfant.photo_url = await this.sauvegarderPhotoDepuisBase64( + enfantDto.photo_base64, + enfantDto.photo_filename, + ); + } + + await manager.save(Children, enfant); + } + } + + for (const familyUser of familyUsers) { + familyUser.statut = StatutUtilisateurType.EN_ATTENTE; + familyUser.token_reprise = undefined; + familyUser.token_reprise_expire_le = undefined; + await manager.save(Users, familyUser); + } + + return { + message: 'Dossier resoumis avec succès. Il est de nouveau en attente de validation.', + statut: StatutUtilisateurType.EN_ATTENTE, + user_id: titulaireLive.id, + numero_dossier: titulaireLive.numero_dossier, + parentsPourEmail: familyUsers, + }; + }); + + const numeroDossier = result.numero_dossier ?? ''; + try { + for (const parent of result.parentsPourEmail) { + await this.mailService.sendResoumissionPendingEmail( + parent.email, + parent.prenom ?? '', + parent.nom ?? '', + numeroDossier, + ); + } + } catch (err) { + this.logger.error( + "[resoumettreRepriseParent] Échec envoi email accusé resoumission (dossier conservé)", + err instanceof Error ? err.stack : String(err), + ); + } + + const { parentsPourEmail: _parentsPourEmail, ...response } = result; + return response; + } + + private async resoumettreRepriseAM( + user: Users, + dto: ResoumettreRepriseDto, + ): Promise<{ message: string; statut: StatutUtilisateurType; user_id: string; numero_dossier?: string }> { + if ( + dto.capacite_accueil !== undefined && + dto.places_disponibles !== undefined && + dto.places_disponibles > dto.capacite_accueil + ) { + throw new BadRequestException( + 'Le nombre de places disponibles ne peut pas dépasser la capacité d\'accueil.', + ); + } + + let urlPhoto: string | undefined; + const photoB64 = (dto.photo_base64 ?? '').trim(); + if (photoB64) { + const nomFichier = (dto.photo_filename ?? '').trim() || 'photo_am.jpg'; + urlPhoto = await this.sauvegarderPhotoDepuisBase64(photoB64, nomFichier); + } else if (dto.photo_url !== undefined) { + urlPhoto = dto.photo_url; + } + + if (dto.nir !== undefined) { + const nirNormalized = dto.nir.replace(/\s/g, '').toUpperCase(); + const nirValidation = validateNir(nirNormalized, { + dateNaissance: dto.date_naissance ?? user.date_naissance?.toISOString().slice(0, 10), + }); + if (!nirValidation.valid) { + throw new BadRequestException(nirValidation.error || 'NIR invalide'); + } + } + + return this.usersRepo.manager.transaction(async (manager) => { + const userLive = await manager.findOne(Users, { where: { id: user.id } }); + if (!userLive) { + throw new NotFoundException('Utilisateur introuvable.'); + } + + this.applyTitulaireUserFields(userLive, dto); + if (urlPhoto !== undefined) userLive.photo_url = urlPhoto; + if (dto.consentement_photo !== undefined) { + userLive.consentement_photo = dto.consentement_photo; + if (dto.consentement_photo) { + userLive.date_consentement_photo = new Date(); + } + } + if (dto.date_naissance !== undefined) { + userLive.date_naissance = new Date(dto.date_naissance); + } + if (dto.lieu_naissance_ville !== undefined) { + userLive.lieu_naissance_ville = dto.lieu_naissance_ville; + } + if (dto.lieu_naissance_pays !== undefined) { + userLive.lieu_naissance_pays = dto.lieu_naissance_pays; + } + + const am = await manager.findOne(AssistanteMaternelle, { where: { user_id: user.id } }); + if (!am) { + throw new NotFoundException('Fiche assistante maternelle introuvable.'); + } + + if (dto.nir !== undefined) { + const nirNormalized = dto.nir.replace(/\s/g, '').toUpperCase(); + const nirDejaUtilise = await manager.findOne(AssistanteMaternelle, { + where: { nir: nirNormalized }, + }); + if (nirDejaUtilise && nirDejaUtilise.user_id !== user.id) { + throw new ConflictException( + 'Un compte assistante maternelle avec ce numéro NIR existe déjà.', + ); + } + am.nir = nirNormalized; + } + if (dto.numero_agrement !== undefined) { + const agrement = dto.numero_agrement.trim(); + const agrementDejaUtilise = await manager.findOne(AssistanteMaternelle, { + where: { approval_number: agrement }, + }); + if (agrementDejaUtilise && agrementDejaUtilise.user_id !== user.id) { + throw new ConflictException( + 'Un compte assistante maternelle avec ce numéro d\'agrément existe déjà.', + ); + } + am.approval_number = agrement; + } + if (dto.date_agrement !== undefined) { + am.agreement_date = new Date(dto.date_agrement); + } + if (dto.capacite_accueil !== undefined) { + am.max_children = dto.capacite_accueil; + } + if (dto.places_disponibles !== undefined) { + am.places_available = dto.places_disponibles; + } + if (dto.biographie !== undefined) { + am.biography = dto.biographie; + } + + userLive.statut = StatutUtilisateurType.EN_ATTENTE; + userLive.token_reprise = undefined; + userLive.token_reprise_expire_le = undefined; + + await manager.save(AssistanteMaternelle, am); + await manager.save(Users, userLive); + + return { + message: 'Dossier resoumis avec succès. Il est de nouveau en attente de validation.', + statut: StatutUtilisateurType.EN_ATTENTE, + user_id: userLive.id, + numero_dossier: userLive.numero_dossier, + }; + }); + } + + private async getFamilyChildrenIds( + manager: EntityManager, + parentUserId: string, + ): Promise> { + const familyUserIds = await this.parentsService.getFamilyUserIds(parentUserId); + const links = await manager.find(ParentsChildren, { + where: { parentId: In(familyUserIds) }, + }); + return new Set(links.map((l) => l.enfantId)); } /** POST reprise-identify : numero_dossier + email → type + token. Ticket #111 */ diff --git a/backend/src/routes/auth/dto/enfant-reprise.dto.ts b/backend/src/routes/auth/dto/enfant-reprise.dto.ts new file mode 100644 index 0000000..fa94060 --- /dev/null +++ b/backend/src/routes/auth/dto/enfant-reprise.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsUUID } from 'class-validator'; +import { EnfantInscriptionDto } from './enfant-inscription.dto'; + +/** Enfant existant modifiable lors de la resoumission reprise (#112). */ +export class EnfantRepriseDto extends EnfantInscriptionDto { + @ApiProperty({ description: 'UUID enfant existant (obligatoire en reprise)' }) + @IsUUID() + @IsNotEmpty() + id: string; +} diff --git a/backend/src/routes/auth/dto/reprise-dossier.dto.ts b/backend/src/routes/auth/dto/reprise-dossier.dto.ts index e81c6e4..308a1dd 100644 --- a/backend/src/routes/auth/dto/reprise-dossier.dto.ts +++ b/backend/src/routes/auth/dto/reprise-dossier.dto.ts @@ -1,7 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; import { RoleType } from 'src/entities/users.entity'; +import { + DossierFamilleEnfantDto, + DossierFamilleParentDto, +} from '../../parents/dto/dossier-famille-complet.dto'; -/** Réponse GET /auth/reprise-dossier – données dossier pour préremplir le formulaire reprise. Ticket #111 */ +/** Réponse GET /auth/reprise-dossier – dossier complet pour préremplir le wizard reprise. #111 + #112 */ export class RepriseDossierDto { @ApiProperty() id: string; @@ -41,4 +45,47 @@ export class RepriseDossierDto { @ApiProperty({ required: false }) situation_familiale?: string; + + // --- Parent (dossier famille, aligné #119) --- + + @ApiProperty({ type: [DossierFamilleParentDto], required: false }) + parents?: DossierFamilleParentDto[]; + + @ApiProperty({ type: [DossierFamilleEnfantDto], required: false }) + enfants?: DossierFamilleEnfantDto[]; + + @ApiProperty({ required: false, description: 'Motivation / présentation dossier famille' }) + texte_motivation?: string; + + // --- AM (aligné DossierAmCompletDto) --- + + @ApiProperty({ required: false }) + consentement_photo?: boolean; + + @ApiProperty({ required: false }) + date_naissance?: Date; + + @ApiProperty({ required: false }) + lieu_naissance_ville?: string; + + @ApiProperty({ required: false }) + lieu_naissance_pays?: string; + + @ApiProperty({ required: false }) + numero_agrement?: string; + + @ApiProperty({ required: false }) + nir?: string; + + @ApiProperty({ required: false }) + date_agrement?: Date; + + @ApiProperty({ required: false }) + nb_max_enfants?: number; + + @ApiProperty({ required: false }) + place_disponible?: number; + + @ApiProperty({ required: false }) + biographie?: string; } diff --git a/backend/src/routes/auth/dto/resoumettre-reprise.dto.ts b/backend/src/routes/auth/dto/resoumettre-reprise.dto.ts index efec456..046f5f5 100644 --- a/backend/src/routes/auth/dto/resoumettre-reprise.dto.ts +++ b/backend/src/routes/auth/dto/resoumettre-reprise.dto.ts @@ -1,12 +1,29 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsOptional, IsString, MaxLength, IsUUID } from 'class-validator'; +import { + IsOptional, + IsString, + MaxLength, + IsUUID, + IsBoolean, + IsArray, + ValidateNested, + IsInt, + Min, + Max, + IsDateString, + Matches, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { EnfantRepriseDto } from './enfant-reprise.dto'; -/** Body PUT /auth/reprise-resoumettre – token + champs modifiables. Ticket #111 */ +/** Body PATCH /auth/reprise-resoumettre – token + champs modifiables du wizard. #111 + #112 */ export class ResoumettreRepriseDto { @ApiProperty({ description: 'Token reprise (reçu par email)' }) @IsUUID() token: string; + // --- Identité titulaire (parent principal ou AM) --- + @ApiProperty({ required: false }) @IsOptional() @IsString() @@ -42,8 +59,147 @@ export class ResoumettreRepriseDto { @MaxLength(10) code_postal?: string; - @ApiProperty({ required: false, description: 'Pour AM' }) + @ApiProperty({ required: false, description: 'Pour AM (URL photo existante)' }) @IsOptional() @IsString() photo_url?: string; + + @ApiProperty({ required: false, description: 'Pour AM (nouvelle photo base64)' }) + @IsOptional() + @IsString() + photo_base64?: string; + + @ApiProperty({ required: false, description: 'Pour AM (nom fichier photo)' }) + @IsOptional() + @IsString() + photo_filename?: string; + + // --- Co-parent (reprise parent) --- + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + co_parent_email?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + co_parent_prenom?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + co_parent_nom?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @Matches(/^(\+33|0)[1-9](\d{2}){4}$/, { + message: 'Le numéro de téléphone du co-parent doit être valide', + }) + co_parent_telephone?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + co_parent_meme_adresse?: boolean; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + co_parent_adresse?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + co_parent_code_postal?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + co_parent_ville?: string; + + // --- Motivation dossier famille --- + + @ApiProperty({ required: false, description: 'Alias texte_motivation' }) + @IsOptional() + @IsString() + @MaxLength(2000) + texte_motivation?: string; + + @ApiProperty({ required: false, description: 'Alias presentation_dossier (inscription)' }) + @IsOptional() + @IsString() + @MaxLength(2000) + presentation_dossier?: string; + + @ApiProperty({ type: [EnfantRepriseDto], required: false }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => EnfantRepriseDto) + enfants?: EnfantRepriseDto[]; + + // --- AM --- + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + consentement_photo?: boolean; + + @ApiProperty({ required: false }) + @IsOptional() + @IsDateString() + date_naissance?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(100) + lieu_naissance_ville?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(100) + lieu_naissance_pays?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(50) + numero_agrement?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @Matches(/^[1-3]\d{4}(?:2A|2B|\d{2})\d{6}\d{2}$/, { + message: 'Le NIR doit contenir 15 caractères (chiffres, ou 2A/2B pour la Corse)', + }) + nir?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsDateString() + date_agrement?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsInt() + @Min(1) + @Max(10) + capacite_accueil?: number; + + @ApiProperty({ required: false }) + @IsOptional() + @IsInt() + @Min(0) + @Max(10) + places_disponibles?: number; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(2000) + biographie?: string; } diff --git a/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts b/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts index d051b0d..64b565c 100644 --- a/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts +++ b/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts @@ -54,6 +54,9 @@ export class DossierFamilleEnfantDto { description: 'Consentement affichage photo (colonne consentement_photo)', }) consent_photo?: boolean; + + @ApiProperty({ required: false, description: 'Grossesse multiple (est_multiple)' }) + est_multiple?: boolean; } /** Réponse GET /parents/dossier-famille/:numeroDossier – dossier famille complet. Ticket #119 */ diff --git a/backend/src/routes/parents/parents.service.ts b/backend/src/routes/parents/parents.service.ts index ae9bedd..2e06914 100644 --- a/backend/src/routes/parents/parents.service.ts +++ b/backend/src/routes/parents/parents.service.ts @@ -221,6 +221,7 @@ export class ParentsService { status: child.status, photo_url: child.photo_url ?? undefined, consent_photo: child.consent_photo, + est_multiple: child.is_multiple, }; } diff --git a/docs/tmp/112-back-reprise-alignement-front.md b/docs/tmp/112-back-reprise-alignement-front.md new file mode 100644 index 0000000..65eff55 --- /dev/null +++ b/docs/tmp/112-back-reprise-alignement-front.md @@ -0,0 +1,244 @@ +# #112 — Alignement front après évolution back (reprise dossier complet) + +**Branche déployée :** `feature/112-reprise-apres-refus-front` +**Commit back :** `d70577b1` — `feat(#112): reprise après refus — dossier complet GET/PATCH` +**Date :** 2026-06-16 + +Ce document décrit le **contrat API réel** après extension du back, et ce que le front doit encore brancher pour exploiter le dossier complet (au-delà de l’identité seule). + +--- + +## 1. Endpoints (inchangés côté URL) + +| Méthode | Route | Auth | +|---------|-------|------| +| `GET` | `/api/v1/auth/reprise-dossier?token={uuid}` | Public | +| `PATCH` | `/api/v1/auth/reprise-resoumettre` | Public | +| `POST` | `/api/v1/auth/reprise-identify` | Public (inchangé) | + +> **Note :** le ticket #111 parlait de `PUT` ; l’implémentation reste en **`PATCH`** (comme avant). + +--- + +## 2. `GET /auth/reprise-dossier` — réponse enrichie + +### Champs communs (toujours présents) + +Identiques à avant : `id`, `email`, `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal`, `numero_dossier`, `role`, `photo_url`, `genre`, `situation_familiale`. + +### Rôle `parent` (+ champs #119) + +Alignés sur `DossierFamilleCompletDto` : + +```json +{ + "parents": [ + { + "user_id": "uuid", + "email": "…", + "prenom": "…", + "nom": "…", + "telephone": "…", + "adresse": "…", + "ville": "…", + "code_postal": "…", + "statut": "refuse", + "co_parent_id": "uuid-parent-entity" + } + ], + "enfants": [ + { + "id": "uuid-enfant", + "first_name": "Emma", + "last_name": "MARTIN", + "genre": "F", + "status": "actif", + "birth_date": "2023-02-15T00:00:00.000Z", + "due_date": null, + "photo_url": "/uploads/photos/…", + "consent_photo": true, + "est_multiple": false + } + ], + "texte_motivation": "Nous recherchons…" +} +``` + +**Mapping front suggéré :** + +| JSON back | Modèle / wizard parent | +|-----------|-------------------------| +| `parents[]` | `UserRegistrationData.parent1` + `parent2` (matcher par `email` ou ordre : titulaire = `id` du GET racine) | +| `enfants[].first_name` / `last_name` | `ChildData.firstName` / `lastName` | +| `enfants[].birth_date` | `ChildData.birthDate` (ISO → `DateTime`) | +| `enfants[].due_date` | `ChildData.dueDate` (enfant `a_naitre`) | +| `enfants[].status` | `actif` = né, `a_naitre` = à naître | +| `enfants[].photo_url` | `ApiConfig.absoluteMediaUrl()` + conserver pour reprise sans re-upload | +| `enfants[].id` | **Obligatoire** pour le PATCH (update par id) | +| `enfants[].est_multiple` | `grossesse_multiple` si utilisé | +| `texte_motivation` | étape présentation / motivation | + +Si `numero_dossier` absent : pas de `parents[]` / `enfants[]` / `texte_motivation` (identité seule). + +### Rôle `assistante_maternelle` + +Champs racine + fiche pro (structure **aplatie**, pas de sous-objet `user`) : + +```json +{ + "consentement_photo": true, + "date_naissance": "1985-03-12T00:00:00.000Z", + "lieu_naissance_ville": "Paris", + "lieu_naissance_pays": "France", + "numero_agrement": "AGR-2024-12345", + "nir": "123456789012345", + "date_agrement": "2024-06-01T00:00:00.000Z", + "nb_max_enfants": 4, + "place_disponible": 2, + "biographie": "…" +} +``` + +**Mapping `AmRegistrationData` :** + +| JSON back | Champ front | +|-----------|-------------| +| `nb_max_enfants` | `capaciteAccueil` | +| `place_disponible` | `placesDisponibles` | +| `numero_agrement` | `numeroAgrement` | +| `biographie` | `biographie` / présentation | +| `photo_url` | déjà géré via `RepriseSession.photoUrl` | + +--- + +## 3. `PATCH /auth/reprise-resoumettre` — body étendu + +### Commun + +```json +{ "token": "uuid-reprise" } +``` + +### Parent — champs à envoyer depuis le wizard + +| Champ PATCH | Source wizard | Notes | +|-------------|---------------|-------| +| `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal` | Parent 1 (titulaire token) | Champs racine | +| `co_parent_prenom`, `co_parent_nom`, `co_parent_telephone` | Parent 2 | | +| `co_parent_meme_adresse`, `co_parent_adresse`, `co_parent_code_postal`, `co_parent_ville` | Parent 2 adresse | | +| `texte_motivation` **ou** `presentation_dossier` | Étape motivation | Les deux alias acceptés | +| `enfants[]` | Liste enfants | Voir ci-dessous | + +**Structure `enfants[]` (miroir inscription + `id` obligatoire) :** + +```json +{ + "id": "uuid-enfant-existant", + "prenom": "Emma", + "nom": "MARTIN", + "date_naissance": "2023-02-15", + "date_previsionnelle_naissance": null, + "genre": "F", + "photo_base64": "data:image/jpeg;base64,…", + "photo_filename": "emma.jpg", + "grossesse_multiple": false +} +``` + +- **v1 back :** update par `id` uniquement — pas de création/suppression d’enfant. +- Si `id` inconnu pour ce dossier → **400** `Enfant inconnu pour ce dossier : {id}`. +- Sans nouvelle photo : ne pas envoyer `photo_base64` (l’existant est conservé). + +### AM — champs à envoyer + +| Champ PATCH | Source | +|-------------|--------| +| Identité + `photo_url` ou `photo_base64` + `photo_filename` | Étapes 1–2 | +| `consentement_photo`, `date_naissance`, `lieu_naissance_ville`, `lieu_naissance_pays` | Identité | +| `numero_agrement`, `nir`, `date_agrement` | Pro | +| `capacite_accueil`, `places_disponibles` | Pro | +| `biographie` | Présentation | + +Validation NIR identique à l’inscription si `nir` fourni. + +### Réponse succès (nouveau format) + +```json +{ + "message": "Dossier resoumis avec succès. Il est de nouveau en attente de validation.", + "statut": "en_attente", + "user_id": "uuid", + "numero_dossier": "2026-000021" +} +``` + +Code HTTP : **200** (pas de corps `Users` brut comme l’ancien back). + +### Effet métier + +- **Parent :** tous les users `role=parent` avec le même `numero_dossier` passent en `en_attente` ; `token_reprise` invalidé sur **tous** (symétrique refus #110). +- **AM :** un seul user. + +### E-mail accusé resoumission (parent) + +Après `PATCH` réussi, un e-mail est envoyé à **chaque parent** du dossier (`sendResoumissionPendingEmail`) : +- confirmation de resoumission ; +- rappel du **numéro de dossier** ; +- mention « en attente de validation ». + +Échec SMTP : logué, **ne bloque pas** la resoumission (même règle que l'inscription initiale). + +--- + +## 4. Fichiers front à modifier (checklist) + +### Modèles + +- [ ] `lib/models/reprise_dossier.dart` — parser `parents[]`, `enfants[]`, `texte_motivation`, champs AM +- [ ] Réutiliser ou mapper vers `DossierFamilleEnfant` / structures existantes (#119 admin) si possible + +### Session / préremplissage + +- [ ] `lib/services/reprise_session.dart` + - `applyToParent` : remplir parent1/parent2 depuis `parents[]`, enfants, motivation + - `applyToAm` : remplir tous les champs AM + +### API + +- [ ] `lib/services/auth_service.dart` — `resoumettreReprise()` : accepter body complet (parent + AM), pas seulement identité +- [ ] Étendre `UserRegistrationData` / `AmRegistrationData` helpers `toReprisePatchBody()` si utile + +### Écrans fin de parcours + +- [ ] `parent_register_step5_screen.dart` — PATCH avec co-parent, enfants, motivation +- [ ] `am_register_step4_screen.dart` — PATCH avec fiche AM complète + +### Hors scope back (inchangé) + +RIB / IBAN / attestation CAF (étape 5 wizard parent) : **non persistés** — rien à envoyer en reprise. + +### Non implémenté front (ticket #112 initial) + +- [ ] Modale login « J’ai un numéro de dossier » → `POST /auth/reprise-identify` (back prêt, front absent) + +--- + +## 5. Tests manuels suggérés + +1. Refuser un dossier parent complet (≥1 enfant + co-parent + motivation). +2. Ouvrir le lien mail `/reprise?token=…`. +3. Vérifier dans DevTools que le GET contient `enfants[]` et `texte_motivation`. +4. Après branchement front : wizard prérempli sur toutes les étapes. +5. Resoumettre → statut `en_attente` pour les deux parents ; dossier visible file validation admin (#119). + +--- + +## 6. Références code back + +``` +backend/src/routes/auth/dto/reprise-dossier.dto.ts +backend/src/routes/auth/dto/resoumettre-reprise.dto.ts +backend/src/routes/auth/dto/enfant-reprise.dto.ts +backend/src/routes/auth/auth.service.ts → getRepriseDossier, resoumettreReprise +backend/src/routes/parents/dto/dossier-famille-complet.dto.ts +``` diff --git a/frontend/lib/config/app_router.dart b/frontend/lib/config/app_router.dart index 4dfe66e..693c424 100644 --- a/frontend/lib/config/app_router.dart +++ b/frontend/lib/config/app_router.dart @@ -21,6 +21,7 @@ import '../screens/auth/am_register_step4_screen.dart'; import '../screens/auth/create_password_screen.dart'; import '../screens/auth/forgot_password_screen.dart'; import '../screens/auth/reset_password_screen.dart'; +import '../screens/auth/reprise_entry_screen.dart'; import '../screens/home/home_screen.dart'; import '../screens/administrateurs/admin_dashboardScreen.dart'; import '../screens/gestionnaire/gestionnaire_dashboard_screen.dart'; @@ -66,6 +67,11 @@ class AppRouter { builder: (BuildContext context, GoRouterState state) => ResetPasswordScreen(token: state.uri.queryParameters['token']), ), + GoRoute( + path: '/reprise', + builder: (BuildContext context, GoRouterState state) => + RepriseEntryScreen(token: state.uri.queryParameters['token']), + ), GoRoute( path: '/home', builder: (BuildContext context, GoRouterState state) => const HomeScreen(), diff --git a/frontend/lib/models/am_registration_data.dart b/frontend/lib/models/am_registration_data.dart index 44604df..1de1aef 100644 --- a/frontend/lib/models/am_registration_data.dart +++ b/frontend/lib/models/am_registration_data.dart @@ -33,6 +33,9 @@ class AmRegistrationData extends ChangeNotifier { /// Places libres actuellement (0 ≤ valeur ≤ capacité) — API `places_disponibles`. int? placesAvailable; + /// Photo déjà en base lors d'une reprise (#112) — sert pour l'affichage et `photo_url` API. + String? repriseExistingPhotoUrl; + // Step 3: Presentation & CGU String presentationText = ''; bool cguAccepted = false; @@ -104,6 +107,53 @@ class AmRegistrationData extends ChangeNotifier { notifyListeners(); } + /// Reprise après refus (#112). + void resetForReprise({ + required String firstName, + required String lastName, + required String phone, + required String email, + required String streetAddress, + required String postalCode, + required String city, + String? existingPhotoUrl, + bool consentementPhoto = false, + DateTime? dateOfBirth, + String birthCity = '', + String birthCountry = '', + String nir = '', + String agrementNumber = '', + DateTime? agreementDate, + int? capacity, + int? placesAvailable, + String presentationText = '', + }) { + this.firstName = firstName; + this.lastName = lastName; + this.phone = phone; + this.email = email; + this.streetAddress = streetAddress; + this.postalCode = postalCode; + this.city = city; + password = ''; + repriseExistingPhotoUrl = existingPhotoUrl; + photoPath = existingPhotoUrl; + photoBytes = null; + photoFilename = null; + photoConsent = consentementPhoto; + this.dateOfBirth = dateOfBirth; + this.birthCity = birthCity; + this.birthCountry = birthCountry; + this.nir = nir; + this.agrementNumber = agrementNumber; + this.agreementDate = agreementDate; + this.capacity = capacity; + this.placesAvailable = placesAvailable; + this.presentationText = presentationText; + cguAccepted = false; + notifyListeners(); + } + // --- Getters for validation or display --- bool get isStep1Complete => firstName.trim().length >= 2 && @@ -118,6 +168,8 @@ class AmRegistrationData extends ChangeNotifier { /// Photo réelle (pas seulement un placeholder asset). bool get _hasUserPhoto => (photoBytes != null && photoBytes!.isNotEmpty) || + (repriseExistingPhotoUrl != null && + repriseExistingPhotoUrl!.trim().isNotEmpty) || (photoPath != null && photoPath!.isNotEmpty && !photoPath!.startsWith('assets/')); @@ -145,6 +197,9 @@ class AmRegistrationData extends ChangeNotifier { bool get isRegistrationComplete => isStep1Complete && isStep2Complete && isStep3Complete; + /// Reprise (#112) : dossier AM complet renvoyé par PATCH reprise-resoumettre. + bool get isRepriseSubmitReady => isRegistrationComplete; + @override String toString() { return 'AmRegistrationData(' diff --git a/frontend/lib/models/dossier_unifie.dart b/frontend/lib/models/dossier_unifie.dart index 98cde26..07b2d56 100644 --- a/frontend/lib/models/dossier_unifie.dart +++ b/frontend/lib/models/dossier_unifie.dart @@ -184,6 +184,7 @@ class EnfantDossier { final String? dueDate; final String? photoUrl; final bool consentPhoto; + final bool estMultiple; EnfantDossier({ required this.id, @@ -195,6 +196,7 @@ class EnfantDossier { this.dueDate, this.photoUrl, this.consentPhoto = false, + this.estMultiple = false, }); String get fullName => '${firstName ?? ''} ${lastName ?? ''}'.trim(); @@ -223,6 +225,8 @@ class EnfantDossier { photoUrl: resolvedPhoto, consentPhoto: json['consent_photo'] == true || json['consentPhoto'] == true, + estMultiple: + json['est_multiple'] == true || json['estMultiple'] == true, ); } } diff --git a/frontend/lib/models/reprise_dossier.dart b/frontend/lib/models/reprise_dossier.dart new file mode 100644 index 0000000..fe425ca --- /dev/null +++ b/frontend/lib/models/reprise_dossier.dart @@ -0,0 +1,180 @@ +import 'package:p_tits_pas/models/dossier_unifie.dart'; +import 'package:p_tits_pas/utils/reprise_mapper.dart'; + +/// Réponse GET /auth/reprise-dossier. Tickets #111, #112. +class RepriseDossier { + final String id; + final String email; + final String? prenom; + final String? nom; + final String? telephone; + final String? adresse; + final String? ville; + final String? codePostal; + final String? numeroDossier; + final String role; + final String? photoUrl; + final String? genre; + final String? situationFamiliale; + + final List parents; + final List enfants; + final String? texteMotivation; + + final bool consentementPhoto; + final String? dateNaissance; + final String? lieuNaissanceVille; + final String? lieuNaissancePays; + final String? numeroAgrement; + final String? nir; + final String? dateAgrement; + final int? nbMaxEnfants; + final int? placeDisponible; + final String? biographie; + + const RepriseDossier({ + required this.id, + required this.email, + this.prenom, + this.nom, + this.telephone, + this.adresse, + this.ville, + this.codePostal, + this.numeroDossier, + required this.role, + this.photoUrl, + this.genre, + this.situationFamiliale, + this.parents = const [], + this.enfants = const [], + this.texteMotivation, + this.consentementPhoto = false, + this.dateNaissance, + this.lieuNaissanceVille, + this.lieuNaissancePays, + this.numeroAgrement, + this.nir, + this.dateAgrement, + this.nbMaxEnfants, + this.placeDisponible, + this.biographie, + }); + + bool get isParent => role == 'parent'; + bool get isAm => role == 'assistante_maternelle'; + + static Map? _nestedMap(dynamic value) { + if (value is Map) return Map.from(value); + return null; + } + + static dynamic _firstNonNull(List values) { + for (final v in values) { + if (v != null) return v; + } + return null; + } + + factory RepriseDossier.fromJson(Map json) { + final nestedUser = _nestedMap(json['user']); + final nestedDossier = _nestedMap(json['dossier']); + + final parentsRaw = json['parents']; + final parentsList = parentsRaw is List + ? parentsRaw + .where((e) => e is Map) + .map((e) => ParentDossier.fromJson(Map.from(e as Map))) + .toList() + : []; + + final enfantsRaw = json['enfants']; + final enfantsList = enfantsRaw is List + ? enfantsRaw + .where((e) => e is Map) + .map((e) => EnfantDossier.fromJson(Map.from(e as Map))) + .toList() + : []; + + return RepriseDossier( + id: json['id']?.toString() ?? '', + email: json['email']?.toString() ?? '', + prenom: json['prenom']?.toString(), + nom: json['nom']?.toString(), + telephone: json['telephone']?.toString(), + adresse: json['adresse']?.toString(), + ville: json['ville']?.toString(), + codePostal: json['code_postal']?.toString(), + numeroDossier: json['numero_dossier']?.toString(), + role: json['role']?.toString() ?? '', + photoUrl: json['photo_url']?.toString(), + genre: json['genre']?.toString(), + situationFamiliale: json['situation_familiale']?.toString(), + parents: parentsList, + enfants: enfantsList, + texteMotivation: (json['texte_motivation'] ?? json['presentation_dossier']) + ?.toString(), + consentementPhoto: RepriseMapper.optionalBool(json['consentement_photo']) || + RepriseMapper.optionalBool(json['consent_photo']) || + RepriseMapper.optionalBool(nestedUser?['consentement_photo']), + dateNaissance: RepriseMapper.optionalDateString( + _firstNonNull([ + json['date_naissance'], + json['dateNaissance'], + nestedUser?['date_naissance'], + nestedUser?['dateNaissance'], + ]), + ), + lieuNaissanceVille: _firstNonNull([ + json['lieu_naissance_ville'], + json['lieuNaissanceVille'], + nestedUser?['lieu_naissance_ville'], + nestedUser?['lieuNaissanceVille'], + ])?.toString(), + lieuNaissancePays: _firstNonNull([ + json['lieu_naissance_pays'], + json['lieuNaissancePays'], + nestedUser?['lieu_naissance_pays'], + nestedUser?['lieuNaissancePays'], + ])?.toString(), + numeroAgrement: _firstNonNull([ + json['numero_agrement'], + json['numero_agrement_am'], + json['numeroAgrement'], + nestedDossier?['numero_agrement'], + nestedDossier?['numeroAgrement'], + ])?.toString(), + nir: _firstNonNull([ + json['nir'], + nestedDossier?['nir'], + ])?.toString(), + dateAgrement: RepriseMapper.optionalDateString( + _firstNonNull([ + json['date_agrement'], + json['dateAgrement'], + nestedDossier?['date_agrement'], + nestedDossier?['dateAgrement'], + ]), + ), + nbMaxEnfants: RepriseMapper.optionalInt( + _firstNonNull([ + json['nb_max_enfants'], + json['capacite_accueil'], + json['nbMaxEnfants'], + nestedDossier?['nb_max_enfants'], + nestedDossier?['capacite_accueil'], + ]), + ), + placeDisponible: RepriseMapper.optionalInt( + _firstNonNull([ + json['place_disponible'], + json['places_disponibles'], + json['placesDisponibles'], + nestedDossier?['place_disponible'], + nestedDossier?['places_disponibles'], + ]), + ), + biographie: (json['biographie'] ?? json['presentation'])?.toString(), + ); + } +} diff --git a/frontend/lib/models/user_registration_data.dart b/frontend/lib/models/user_registration_data.dart index 5e8892f..bedd247 100644 --- a/frontend/lib/models/user_registration_data.dart +++ b/frontend/lib/models/user_registration_data.dart @@ -30,6 +30,7 @@ class ParentData { class ChildData { static const Object _unsetImage = Object(); static const Object _unsetImageBytes = Object(); + static const Object _unsetExistingPhotoUrl = Object(); String firstName; String lastName; @@ -43,6 +44,10 @@ class ChildData { /// Octets de la photo (fiable à l’envoi API ; [imageFile] peut être absent sur le web). Uint8List? imageBytes; CardColorVertical cardColor; // Nouveau champ pour la couleur de la carte + /// UUID enfant en base (reprise #112) — requis pour PATCH reprise-resoumettre. + String? repriseChildId; + /// Photo déjà stockée (affichage reprise sans re-upload). + String? existingPhotoUrl; ChildData({ this.firstName = '', @@ -55,6 +60,8 @@ class ChildData { this.imageFile, this.imageBytes, required this.cardColor, // Rendre requis dans le constructeur + this.repriseChildId, + this.existingPhotoUrl, }); ChildData copyWith({ @@ -68,6 +75,8 @@ class ChildData { Object? imageFile = _unsetImage, Object? imageBytes = _unsetImageBytes, CardColorVertical? cardColor, + String? repriseChildId, + Object? existingPhotoUrl = _unsetExistingPhotoUrl, }) { return ChildData( firstName: firstName ?? this.firstName, @@ -81,6 +90,10 @@ class ChildData { imageBytes: identical(imageBytes, _unsetImageBytes) ? this.imageBytes : imageBytes as Uint8List?, cardColor: cardColor ?? this.cardColor, + repriseChildId: repriseChildId ?? this.repriseChildId, + existingPhotoUrl: identical(existingPhotoUrl, _unsetExistingPhotoUrl) + ? this.existingPhotoUrl + : existingPhotoUrl as String?, ); } } @@ -178,6 +191,38 @@ class UserRegistrationData extends ChangeNotifier { notifyListeners(); } + /// Reprise après refus (#112) : réinitialise le flux parent avec les données dossier. + void resetForReprise({ + required ParentData parent1Data, + ParentData? parent2Data, + List? childrenData, + String motivation = '', + }) { + parent1 = parent1Data; + parent2 = parent2Data; + children + ..clear() + ..addAll(childrenData ?? const []); + motivationText = motivation; + cguAccepted = false; + bankDetails = null; + attestationCafNumber = ''; + consentQuotientFamilial = false; + notifyListeners(); + } + + /// Reprise (#112) : coordonnées + enfants connus + CGU. + bool get isRepriseSubmitReady => + parent1.firstName.isNotEmpty && + parent1.lastName.isNotEmpty && + parent1.email.isNotEmpty && + children.isNotEmpty && + children.every( + (c) => + c.repriseChildId != null && c.repriseChildId!.trim().isNotEmpty, + ) && + cguAccepted; + // Méthode pour vérifier si toutes les données requises sont là (simplifié) bool isRegistrationComplete() { // Ajouter ici les validations nécessaires diff --git a/frontend/lib/screens/auth/am_register_step1_screen.dart b/frontend/lib/screens/auth/am_register_step1_screen.dart index 66129e6..1c5ca0f 100644 --- a/frontend/lib/screens/auth/am_register_step1_screen.dart +++ b/frontend/lib/screens/auth/am_register_step1_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import '../../models/am_registration_data.dart'; import '../../widgets/personal_info_form_screen.dart'; +import '../../services/reprise_session.dart'; import '../../models/card_assets.dart'; class AmRegisterStep1Screen extends StatelessWidget { @@ -29,7 +30,7 @@ class AmRegisterStep1Screen extends StatelessWidget { cardColor: CardColorHorizontal.blue, initialData: initialData, minPersonNameLength: 2, - previousRoute: '/register-choice', + previousRoute: RepriseSession.isActive ? '/login' : '/register-choice', onSubmit: (data, {hasSecondPerson, sameAddress}) { registrationData.updateIdentityInfo( firstName: data.firstName, diff --git a/frontend/lib/screens/auth/am_register_step2_screen.dart b/frontend/lib/screens/auth/am_register_step2_screen.dart index 2f98b0e..6f50782 100644 --- a/frontend/lib/screens/auth/am_register_step2_screen.dart +++ b/frontend/lib/screens/auth/am_register_step2_screen.dart @@ -11,45 +11,53 @@ class AmRegisterStep2Screen extends StatelessWidget { @override Widget build(BuildContext context) { - final registrationData = Provider.of(context, listen: false); - - final initialData = ProfessionalInfoData( - photoPath: registrationData.photoPath, - photoBytes: registrationData.photoBytes, - photoFilename: registrationData.photoFilename, - photoConsent: registrationData.photoConsent, - dateOfBirth: registrationData.dateOfBirth, - birthCity: registrationData.birthCity, - birthCountry: registrationData.birthCountry, - nir: registrationData.nir, - agrementNumber: registrationData.agrementNumber, - agreementDate: registrationData.agreementDate, - capacity: registrationData.capacity, - placesAvailable: registrationData.placesAvailable, - ); - - return ProfessionalInfoFormScreen( - stepText: 'Étape 2/4', - title: 'Vos informations professionnelles', - cardColor: CardColorHorizontal.green, - initialData: initialData, - previousRoute: '/am-register-step1', - onSubmit: (data) { - registrationData.updateProfessionalInfo( - photoPath: data.photoPath, - photoBytes: data.photoBytes, - photoFilename: data.photoFilename, - photoConsent: data.photoConsent, - dateOfBirth: data.dateOfBirth, - birthCity: data.birthCity, - birthCountry: data.birthCountry, - nir: data.nir, - agrementNumber: data.agrementNumber, - agreementDate: data.agreementDate, - capacity: data.capacity, - placesAvailable: data.placesAvailable, + return Consumer( + builder: (context, registrationData, _) { + final initialData = ProfessionalInfoData( + photoPath: registrationData.photoPath, + photoBytes: registrationData.photoBytes, + photoFilename: registrationData.photoFilename, + photoConsent: registrationData.photoConsent, + dateOfBirth: registrationData.dateOfBirth, + birthCity: registrationData.birthCity, + birthCountry: registrationData.birthCountry, + nir: registrationData.nir, + agrementNumber: registrationData.agrementNumber, + agreementDate: registrationData.agreementDate, + capacity: registrationData.capacity, + placesAvailable: registrationData.placesAvailable, + ); + + return ProfessionalInfoFormScreen( + key: ValueKey( + 'am-pro-${registrationData.dateOfBirth?.millisecondsSinceEpoch}' + '-${registrationData.agreementDate?.millisecondsSinceEpoch}' + '-${registrationData.placesAvailable}' + '-${registrationData.nir}', + ), + stepText: 'Étape 2/4', + title: 'Vos informations professionnelles', + cardColor: CardColorHorizontal.green, + initialData: initialData, + previousRoute: '/am-register-step1', + onSubmit: (data) { + registrationData.updateProfessionalInfo( + photoPath: data.photoPath, + photoBytes: data.photoBytes, + photoFilename: data.photoFilename, + photoConsent: data.photoConsent, + dateOfBirth: data.dateOfBirth, + birthCity: data.birthCity, + birthCountry: data.birthCountry, + nir: data.nir, + agrementNumber: data.agrementNumber, + agreementDate: data.agreementDate, + capacity: data.capacity, + placesAvailable: data.placesAvailable, + ); + context.go('/am-register-step3'); + }, ); - context.go('/am-register-step3'); }, ); } diff --git a/frontend/lib/screens/auth/am_register_step4_screen.dart b/frontend/lib/screens/auth/am_register_step4_screen.dart index 90f536f..83520b9 100644 --- a/frontend/lib/screens/auth/am_register_step4_screen.dart +++ b/frontend/lib/screens/auth/am_register_step4_screen.dart @@ -8,6 +8,8 @@ import '../../models/am_registration_data.dart'; import '../../models/card_assets.dart'; import '../../config/display_config.dart'; import '../../services/auth_service.dart'; +import '../../services/reprise_session.dart'; +import '../../utils/reprise_payload.dart'; import '../../widgets/hover_relief_widget.dart'; import '../../widgets/image_button.dart'; import '../../widgets/custom_navigation_button.dart'; @@ -27,7 +29,21 @@ class _AmRegisterStep4ScreenState extends State { Future _submitAMRegistration(AmRegistrationData registrationData) async { if (_isSubmitting) return; - if (!registrationData.isRegistrationComplete) { + if (RepriseSession.isActive) { + if (!registrationData.isRepriseSubmitReady) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Vérifiez vos coordonnées et acceptez les conditions.', + style: GoogleFonts.merienda(fontSize: 14), + ), + backgroundColor: Colors.red.shade700, + ), + ); + return; + } + } else if (!registrationData.isRegistrationComplete) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -42,6 +58,19 @@ class _AmRegisterStep4ScreenState extends State { } setState(() => _isSubmitting = true); try { + if (RepriseSession.isActive) { + await AuthService.resoumettreReprise( + await ReprisePayload.amPatch( + registrationData, + RepriseSession.token!, + existingPhotoUrl: RepriseSession.photoUrlForApi, + ), + ); + RepriseSession.clear(); + if (!mounted) return; + _showRepriseConfirmationModal(context); + return; + } await AuthService.registerAM(registrationData); if (!mounted) return; _showConfirmationModal(context); @@ -244,6 +273,34 @@ class _AmRegisterStep4ScreenState extends State { ); } + void _showRepriseConfirmationModal(BuildContext context) { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext dialogContext) { + return AlertDialog( + title: Text( + 'Dossier resoumis', + style: GoogleFonts.merienda(fontWeight: FontWeight.bold), + ), + content: Text( + 'Vos modifications ont été enregistrées. Votre dossier est de nouveau en attente de validation.', + style: GoogleFonts.merienda(fontSize: 14), + ), + actions: [ + TextButton( + child: Text('OK', style: GoogleFonts.merienda(fontWeight: FontWeight.bold)), + onPressed: () { + Navigator.of(dialogContext).pop(); + context.go('/login'); + }, + ), + ], + ); + }, + ); + } + void _showConfirmationModal(BuildContext context) { showDialog( context: context, diff --git a/frontend/lib/screens/auth/login_screen.dart b/frontend/lib/screens/auth/login_screen.dart index feb5476..6f1efd0 100644 --- a/frontend/lib/screens/auth/login_screen.dart +++ b/frontend/lib/screens/auth/login_screen.dart @@ -10,6 +10,7 @@ import '../../widgets/image_button.dart'; import '../../widgets/custom_app_text_field.dart'; import '../../services/auth_service.dart'; import '../../widgets/auth/change_password_dialog.dart'; +import '../../widgets/auth/reprise_identify_dialog.dart'; class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); @@ -100,6 +101,31 @@ class _LoginPageState extends State { _handleLogin(); } + Future _openRepriseIdentify() async { + final token = await showDialog( + context: context, + builder: (ctx) => RepriseIdentifyDialog( + initialEmail: _emailController.text.trim(), + ), + ); + if (!mounted || token == null || token.isEmpty) return; + context.go('/reprise?token=${Uri.encodeComponent(token)}'); + } + + Widget _buildRepriseDossierLink({double fontSize = 14}) { + return TextButton( + onPressed: _isLoading ? null : _openRepriseIdentify, + child: Text( + 'J’ai un numéro de dossier', + style: GoogleFonts.merienda( + fontSize: fontSize, + color: const Color(0xFF2D6A4F), + decoration: TextDecoration.underline, + ), + ), + ); + } + /// Gère la connexion de l'utilisateur Future _handleLogin() async { // Réinitialiser le message d'erreur @@ -384,6 +410,7 @@ class _LoginPageState extends State { ), ), ), + Center(child: _buildRepriseDossierLink()), ], ), ), @@ -640,6 +667,7 @@ class _LoginPageState extends State { ), ), ), + _buildRepriseDossierLink(), ], ), ), diff --git a/frontend/lib/screens/auth/parent_register_step1_screen.dart b/frontend/lib/screens/auth/parent_register_step1_screen.dart index becad34..fd5de45 100644 --- a/frontend/lib/screens/auth/parent_register_step1_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step1_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import '../../models/user_registration_data.dart'; import '../../widgets/personal_info_form_screen.dart'; +import '../../services/reprise_session.dart'; import '../../models/card_assets.dart'; class ParentRegisterStep1Screen extends StatelessWidget { @@ -29,7 +30,7 @@ class ParentRegisterStep1Screen extends StatelessWidget { title: 'Informations du Parent Principal', cardColor: CardColorHorizontal.peach, initialData: initialData, - previousRoute: '/register-choice', + previousRoute: RepriseSession.isActive ? '/login' : '/register-choice', onSubmit: (data, {hasSecondPerson, sameAddress}) { registrationData.updateParent1(ParentData( firstName: data.firstName, diff --git a/frontend/lib/screens/auth/parent_register_step3_screen.dart b/frontend/lib/screens/auth/parent_register_step3_screen.dart index e13b3a9..ec5ed5d 100644 --- a/frontend/lib/screens/auth/parent_register_step3_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step3_screen.dart @@ -166,7 +166,11 @@ class _ParentRegisterStep3ScreenState extends State { if (await f.exists()) file = f; } catch (_) {} } - final updatedChild = oldChild.copyWith(imageBytes: bytes, imageFile: file); + final updatedChild = oldChild.copyWith( + imageBytes: bytes, + imageFile: file, + existingPhotoUrl: null, + ); registrationData.updateChild(childIndex, updatedChild); } } @@ -306,7 +310,7 @@ class _ParentRegisterStep3ScreenState extends State { onClearImage: () => setState(() { final c = registrationData.children[index]; registrationData.updateChild( - index, c.copyWith(imageFile: null, imageBytes: null)); + index, c.copyWith(imageFile: null, imageBytes: null, existingPhotoUrl: null)); }), onDateSelect: () => _selectDate(context, index, registrationData), onFirstNameChanged: (value) => setState(() { @@ -414,7 +418,7 @@ class _ParentRegisterStep3ScreenState extends State { onClearImage: () => setState(() { final c = registrationData.children[index]; registrationData.updateChild( - index, c.copyWith(imageFile: null, imageBytes: null)); + index, c.copyWith(imageFile: null, imageBytes: null, existingPhotoUrl: null)); }), onDateSelect: () => _selectDate(context, index, registrationData), onFirstNameChanged: (value) => setState(() { diff --git a/frontend/lib/screens/auth/parent_register_step5_screen.dart b/frontend/lib/screens/auth/parent_register_step5_screen.dart index 883f9ee..87ef640 100644 --- a/frontend/lib/screens/auth/parent_register_step5_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step5_screen.dart @@ -14,6 +14,8 @@ import '../../widgets/personal_info_form_screen.dart'; import '../../widgets/child_card_widget.dart'; import '../../widgets/presentation_form_screen.dart'; import '../../services/auth_service.dart'; +import '../../services/reprise_session.dart'; +import '../../utils/reprise_payload.dart'; class ParentRegisterStep5Screen extends StatefulWidget { const ParentRegisterStep5Screen({super.key}); @@ -27,8 +29,32 @@ class _ParentRegisterStep5ScreenState extends State { Future _submitRegistration(BuildContext context, UserRegistrationData data) async { if (_isSubmitting) return; + if (RepriseSession.isActive) { + if (!data.isRepriseSubmitReady) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Vérifiez vos coordonnées, les enfants et acceptez les conditions.', + style: GoogleFonts.merienda(fontSize: 14), + ), + backgroundColor: Colors.red.shade700, + ), + ); + return; + } + } setState(() => _isSubmitting = true); try { + if (RepriseSession.isActive) { + final token = RepriseSession.token!; + await AuthService.resoumettreReprise( + ReprisePayload.parentPatch(data, token), + ); + RepriseSession.clear(); + if (!context.mounted) return; + _showRepriseSuccessModal(context); + return; + } await AuthService.registerParent(data); if (!context.mounted) return; _showSuccessModal(context); @@ -279,6 +305,34 @@ class _ParentRegisterStep5ScreenState extends State { ); } + void _showRepriseSuccessModal(BuildContext context) { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext dialogContext) { + return AlertDialog( + title: Text( + 'Dossier resoumis', + style: GoogleFonts.merienda(fontWeight: FontWeight.bold), + ), + content: Text( + 'Vos modifications ont été enregistrées. Votre dossier est de nouveau en attente de validation.', + style: GoogleFonts.merienda(fontSize: 14), + ), + actions: [ + TextButton( + child: Text('OK', style: GoogleFonts.merienda(fontWeight: FontWeight.bold)), + onPressed: () { + Navigator.of(dialogContext).pop(); + context.go('/login'); + }, + ), + ], + ); + }, + ); + } + void _showSuccessModal(BuildContext context) { showDialog( context: context, diff --git a/frontend/lib/screens/auth/reprise_entry_screen.dart b/frontend/lib/screens/auth/reprise_entry_screen.dart new file mode 100644 index 0000000..c2fff65 --- /dev/null +++ b/frontend/lib/screens/auth/reprise_entry_screen.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../config/app_router.dart'; +import '../../services/auth_service.dart'; +import '../../services/reprise_session.dart'; + +/// Point d'entrée `/reprise?token=` — charge le dossier et ouvre le wizard (#112). +class RepriseEntryScreen extends StatefulWidget { + final String? token; + + const RepriseEntryScreen({super.key, this.token}); + + @override + State createState() => _RepriseEntryScreenState(); +} + +class _RepriseEntryScreenState extends State { + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _load()); + } + + Future _load() async { + final token = widget.token?.trim() ?? ''; + if (token.isEmpty) { + setState(() { + _loading = false; + _error = 'Lien invalide ou expiré.'; + }); + return; + } + + setState(() { + _loading = true; + _error = null; + }); + + try { + final dossier = await AuthService.getRepriseDossier(token); + if (!mounted) return; + + RepriseSession.clear(); + RepriseSession.start(token: token, dossier: dossier); + + if (dossier.isParent) { + RepriseSession.applyToParent(userRegistrationDataNotifier, dossier); + context.go('/parent-register-step1'); + return; + } + if (dossier.isAm) { + RepriseSession.applyToAm(amRegistrationDataNotifier, dossier); + context.go('/am-register-step1'); + return; + } + + RepriseSession.clear(); + setState(() { + _loading = false; + _error = 'Type de dossier non pris en charge pour la reprise.'; + }); + } catch (e) { + if (!mounted) return; + RepriseSession.clear(); + setState(() { + _loading = false; + _error = e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Impossible de charger votre dossier.'; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + Positioned.fill( + child: Image.asset( + 'assets/images/paper2.png', + fit: BoxFit.cover, + repeat: ImageRepeat.repeat, + ), + ), + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Padding( + padding: const EdgeInsets.all(24), + child: _buildBody(), + ), + ), + ), + ], + ), + ); + } + + Widget _buildBody() { + if (_loading) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 20), + Text( + 'Chargement de votre dossier…', + style: GoogleFonts.merienda(fontSize: 16), + textAlign: TextAlign.center, + ), + ], + ); + } + + final err = _error ?? 'Lien invalide ou expiré.'; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 48, color: Colors.red.shade700), + const SizedBox(height: 16), + Text( + 'Reprise du dossier', + style: GoogleFonts.merienda( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Text( + err, + style: GoogleFonts.merienda(fontSize: 14, color: Colors.black87), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + TextButton( + onPressed: () => context.go('/login'), + child: Text( + 'Retour à la connexion', + style: GoogleFonts.merienda( + decoration: TextDecoration.underline, + color: const Color(0xFF2D6A4F), + ), + ), + ), + ], + ); + } +} diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index 11d6a28..da671bc 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -49,6 +49,10 @@ class ApiConfig { /// Ticket #127 — mot de passe oublié (back à livrer en parallèle). static const String forgotPassword = '/auth/forgot-password'; static const String resetPassword = '/auth/reset-password'; + /// Ticket #112 — reprise après refus (#111 back). + static const String repriseDossier = '/auth/reprise-dossier'; + static const String repriseResoumettre = '/auth/reprise-resoumettre'; + static const String repriseIdentify = '/auth/reprise-identify'; // Users endpoints static const String users = '/users'; diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart index 94f5d28..1871e07 100644 --- a/frontend/lib/services/auth_service.dart +++ b/frontend/lib/services/auth_service.dart @@ -8,6 +8,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../models/user.dart'; import '../models/am_registration_data.dart'; import '../models/user_registration_data.dart'; +import '../models/reprise_dossier.dart'; import '../utils/parent_registration_payload.dart'; import 'api/api_config.dart'; import 'api/tokenService.dart'; @@ -258,6 +259,116 @@ class AuthService { throw Exception(msg); } + /// Charge le dossier pour reprise (lien e-mail). GET /auth/reprise-dossier. Ticket #112. + static Future getRepriseDossier(String token) async { + final cleaned = token.trim(); + if (cleaned.isEmpty) { + throw Exception('Lien invalide ou expiré.'); + } + final uri = Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.repriseDossier}', + ).replace(queryParameters: {'token': cleaned}); + late final http.Response response; + try { + response = await http.get(uri, headers: ApiConfig.headers); + } on http.ClientException { + throw Exception( + 'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau puis réessayez.', + ); + } + if (response.statusCode == 404) { + throw Exception('Lien invalide ou expiré.'); + } + if (response.statusCode != 200) { + final decoded = _tryDecodeJsonMap(response.body); + throw Exception(_extractErrorMessage(decoded, response.statusCode)); + } + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw Exception('Réponse invalide du serveur.'); + } + final payload = decoded['data'] is Map + ? Map.from(decoded['data'] as Map) + : decoded; + return RepriseDossier.fromJson(payload); + } + + /// Modale login : numéro + e-mail → token reprise. POST /auth/reprise-identify. #112. + static Future identifyReprise({ + required String numeroDossier, + required String email, + }) async { + final num = numeroDossier.trim(); + final mail = normalizeEmailText(email); + if (num.isEmpty || mail.isEmpty) { + throw Exception('Numéro de dossier et e-mail requis.'); + } + late final http.Response response; + try { + response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.repriseIdentify}'), + headers: ApiConfig.headers, + body: jsonEncode({ + 'numero_dossier': num, + 'email': mail, + }), + ); + } on http.ClientException { + throw Exception( + 'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau puis réessayez.', + ); + } + if (response.statusCode == 404) { + throw Exception( + 'Aucun dossier en reprise trouvé pour ce numéro et cet e-mail.', + ); + } + if (response.statusCode != 200 && response.statusCode != 201) { + final decoded = _tryDecodeJsonMap(response.body); + throw Exception(_extractErrorMessage(decoded, response.statusCode)); + } + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw Exception('Réponse invalide du serveur.'); + } + final token = decoded['token']?.toString().trim() ?? ''; + if (token.isEmpty) { + throw Exception('Réponse invalide du serveur.'); + } + return token; + } + + /// Resoumission après refus. PATCH /auth/reprise-resoumettre. Ticket #112. + static Future resoumettreReprise(Map body) async { + final cleaned = body['token']?.toString().trim() ?? ''; + if (cleaned.isEmpty) { + throw Exception('Lien invalide ou expiré.'); + } + final payload = Map.from(body); + payload['token'] = cleaned; + + late final http.Response response; + try { + response = await http.patch( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.repriseResoumettre}'), + headers: ApiConfig.headers, + body: jsonEncode(payload), + ); + } on http.ClientException { + throw Exception( + 'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau puis réessayez.', + ); + } + if (response.statusCode == 404) { + throw Exception('Lien invalide ou expiré.'); + } + if (response.statusCode == 200 || response.statusCode == 201) { + return; + } + final decoded = _tryDecodeJsonMap(response.body); + throw Exception(_extractErrorMessage(decoded, response.statusCode)); + } + /// Déconnexion de l'utilisateur static Future logout() async { await TokenService.clearAll(); diff --git a/frontend/lib/services/reprise_session.dart b/frontend/lib/services/reprise_session.dart new file mode 100644 index 0000000..6f9fc8a --- /dev/null +++ b/frontend/lib/services/reprise_session.dart @@ -0,0 +1,58 @@ +import 'package:p_tits_pas/models/am_registration_data.dart'; +import 'package:p_tits_pas/models/reprise_dossier.dart'; +import 'package:p_tits_pas/models/user_registration_data.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/utils/reprise_mapper.dart'; + +/// Contexte reprise après refus (token e-mail ou identify). Ticket #112. +class RepriseSession { + RepriseSession._(); + + static String? _token; + static String? _role; + static String? _photoUrl; + static String? _photoUrlForApi; + + static bool get isActive => + _token != null && _token!.trim().isNotEmpty; + + static String? get token => _token; + + static bool get isParent => _role == 'parent'; + + static bool get isAm => _role == 'assistante_maternelle'; + + /// URL absolue pour l'affichage. + static String? get photoUrl => _photoUrl; + + /// Chemin relatif API (`/uploads/…`) pour PATCH sans re-upload. + static String? get photoUrlForApi => _photoUrlForApi; + + static void start({ + required String token, + required RepriseDossier dossier, + }) { + _token = token.trim(); + _role = dossier.role; + final raw = dossier.photoUrl?.trim(); + _photoUrlForApi = raw != null && raw.isNotEmpty ? raw : null; + _photoUrl = _photoUrlForApi != null + ? ApiConfig.absoluteMediaUrl(_photoUrlForApi) + : null; + } + + static void clear() { + _token = null; + _role = null; + _photoUrl = null; + _photoUrlForApi = null; + } + + static void applyToParent(UserRegistrationData data, RepriseDossier dossier) { + RepriseMapper.applyParentDossier(data, dossier); + } + + static void applyToAm(AmRegistrationData data, RepriseDossier dossier) { + RepriseMapper.applyAmDossier(data, dossier); + } +} diff --git a/frontend/lib/utils/parent_registration_payload.dart b/frontend/lib/utils/parent_registration_payload.dart index 05f8d79..70a9459 100644 --- a/frontend/lib/utils/parent_registration_payload.dart +++ b/frontend/lib/utils/parent_registration_payload.dart @@ -149,6 +149,20 @@ class ParentRegistrationPayload { return body; } + /// Enfant pour PATCH reprise (inclut `id` si connu). + static Map childToRepriseJson( + ChildData c, + int index, + String parentNom, + ) { + final map = _childToJson(c, index, parentNom); + final id = c.repriseChildId?.trim(); + if (id != null && id.isNotEmpty) { + map['id'] = id; + } + return map; + } + static Map _childToJson(ChildData c, int index, String parentNom) { final map = { 'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre', diff --git a/frontend/lib/utils/reprise_mapper.dart b/frontend/lib/utils/reprise_mapper.dart new file mode 100644 index 0000000..53c285b --- /dev/null +++ b/frontend/lib/utils/reprise_mapper.dart @@ -0,0 +1,223 @@ +import 'package:p_tits_pas/models/am_registration_data.dart'; +import 'package:p_tits_pas/models/card_assets.dart'; +import 'package:p_tits_pas/models/dossier_unifie.dart'; +import 'package:p_tits_pas/models/reprise_dossier.dart'; +import 'package:p_tits_pas/models/user_registration_data.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; + +/// Mapping GET reprise-dossier → modèles wizard. Ticket #112. +class RepriseMapper { + RepriseMapper._(); + + static const List _childCardColors = [ + CardColorVertical.lavender, + CardColorVertical.pink, + CardColorVertical.peach, + CardColorVertical.lime, + CardColorVertical.red, + CardColorVertical.green, + CardColorVertical.blue, + ]; + + static ParentData parentFromDossier(ParentDossier p) { + return ParentData( + firstName: p.prenom ?? '', + lastName: p.nom ?? '', + phone: p.telephone ?? '', + email: p.email, + address: p.adresse ?? '', + postalCode: p.codePostal ?? '', + city: p.ville ?? '', + password: '', + ); + } + + static ChildData childFromEnfant(EnfantDossier e, int index) { + final isUnborn = e.status == 'a_naitre'; + final dob = isUnborn + ? isoToDdMmYyyy(e.dueDate) + : isoToDdMmYyyy(e.birthDate); + final photo = e.photoUrl?.trim(); + final hasPhoto = photo != null && photo.isNotEmpty; + return ChildData( + firstName: e.firstName ?? '', + lastName: e.lastName ?? '', + dob: dob, + genre: e.gender ?? '', + // Inscription initiale exigeait la coche pour envoyer la photo ; le back + // ne persistait pas toujours consent_photo — on pré-coche si photo en base. + photoConsent: e.consentPhoto || hasPhoto, + multipleBirth: e.estMultiple, + isUnbornChild: isUnborn, + cardColor: _childCardColors[index % _childCardColors.length], + repriseChildId: e.id, + existingPhotoUrl: photo != null && photo.isNotEmpty + ? ApiConfig.absoluteMediaUrl(photo) + : null, + ); + } + + static void applyParentDossier(UserRegistrationData data, RepriseDossier dossier) { + ParentDossier? titulaire; + ParentDossier? coParent; + + if (dossier.parents.isNotEmpty) { + for (final p in dossier.parents) { + if (p.id == dossier.id) { + titulaire = p; + } else { + coParent = p; + } + } + titulaire ??= dossier.parents.first; + if (coParent == null && dossier.parents.length > 1) { + coParent = dossier.parents.firstWhere( + (p) => p.id != titulaire!.id, + orElse: () => dossier.parents.last, + ); + } + } + + final p1 = titulaire != null + ? parentFromDossier(titulaire) + : ParentData( + firstName: dossier.prenom ?? '', + lastName: dossier.nom ?? '', + phone: dossier.telephone ?? '', + email: dossier.email, + address: dossier.adresse ?? '', + postalCode: dossier.codePostal ?? '', + city: dossier.ville ?? '', + password: '', + ); + + final children = dossier.enfants + .asMap() + .entries + .map((e) => childFromEnfant(e.value, e.key)) + .toList(); + + data.resetForReprise( + parent1Data: p1, + parent2Data: coParent != null ? parentFromDossier(coParent) : null, + childrenData: children, + motivation: dossier.texteMotivation ?? '', + ); + } + + static DateTime? parseIsoDate(String? raw) { + if (raw == null || raw.trim().isEmpty) return null; + final s = raw.trim(); + final iso = RegExp(r'^(\d{4})-(\d{2})-(\d{2})'); + final isoMatch = iso.firstMatch(s); + if (isoMatch != null) { + return DateTime( + int.parse(isoMatch.group(1)!), + int.parse(isoMatch.group(2)!), + int.parse(isoMatch.group(3)!), + ); + } + try { + return DateTime.parse(s); + } catch (_) { + final parts = s.split('/'); + if (parts.length == 3) { + final day = int.tryParse(parts[0]); + final month = int.tryParse(parts[1]); + final year = int.tryParse(parts[2]); + if (day != null && month != null && year != null) { + return DateTime(year, month, day); + } + } + return null; + } + } + + static String? optionalDateString(dynamic value) { + if (value == null) return null; + if (value is String) { + final s = value.trim(); + return s.isEmpty || s == 'null' ? null : s; + } + if (value is DateTime) { + return '${value.year.toString().padLeft(4, '0')}-' + '${value.month.toString().padLeft(2, '0')}-' + '${value.day.toString().padLeft(2, '0')}'; + } + if (value is num) { + final ms = value.abs() > 9999999999 + ? value.toInt() + : value.toInt() * 1000; + final dt = DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true); + return '${dt.year.toString().padLeft(4, '0')}-' + '${dt.month.toString().padLeft(2, '0')}-' + '${dt.day.toString().padLeft(2, '0')}'; + } + if (value is Map) { + final y = value['year']; + final m = value['month'] ?? value['monthValue']; + final d = value['day'] ?? value['dayOfMonth']; + if (y is num && m is num && d is num) { + return '${y.toInt().toString().padLeft(4, '0')}-' + '${m.toInt().toString().padLeft(2, '0')}-' + '${d.toInt().toString().padLeft(2, '0')}'; + } + } + final s = value.toString().trim(); + return s.isEmpty || s == 'null' ? null : s; + } + + static int? optionalInt(dynamic value) { + if (value == null) return null; + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) { + final s = value.trim(); + if (s.isEmpty) return null; + return int.tryParse(s); + } + return null; + } + + static bool optionalBool(dynamic value) { + if (value == true) return true; + if (value is String && value.toLowerCase() == 'true') return true; + return false; + } + + static void applyAmDossier(AmRegistrationData data, RepriseDossier dossier) { + final rawPhoto = dossier.photoUrl?.trim(); + final hasPhoto = rawPhoto != null && rawPhoto.isNotEmpty; + final displayPhoto = + hasPhoto ? ApiConfig.absoluteMediaUrl(rawPhoto) : null; + + data.resetForReprise( + firstName: dossier.prenom ?? '', + lastName: dossier.nom ?? '', + phone: dossier.telephone ?? '', + email: dossier.email, + streetAddress: dossier.adresse ?? '', + postalCode: dossier.codePostal ?? '', + city: dossier.ville ?? '', + existingPhotoUrl: displayPhoto, + consentementPhoto: dossier.consentementPhoto || hasPhoto, + dateOfBirth: parseIsoDate(dossier.dateNaissance), + birthCity: dossier.lieuNaissanceVille ?? '', + birthCountry: dossier.lieuNaissancePays ?? '', + nir: dossier.nir ?? '', + agrementNumber: dossier.numeroAgrement ?? '', + agreementDate: parseIsoDate(dossier.dateAgrement), + capacity: dossier.nbMaxEnfants, + placesAvailable: dossier.placeDisponible, + presentationText: dossier.biographie ?? '', + ); + } + + static String isoToDdMmYyyy(String? iso) { + final dt = parseIsoDate(iso); + if (dt == null) return ''; + return '${dt.day.toString().padLeft(2, '0')}/' + '${dt.month.toString().padLeft(2, '0')}/' + '${dt.year}'; + } +} diff --git a/frontend/lib/utils/reprise_payload.dart b/frontend/lib/utils/reprise_payload.dart new file mode 100644 index 0000000..6ce887a --- /dev/null +++ b/frontend/lib/utils/reprise_payload.dart @@ -0,0 +1,150 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +import '../models/am_registration_data.dart'; +import '../models/user_registration_data.dart'; +import 'nir_utils.dart'; +import 'parent_registration_payload.dart'; + +/// Body PATCH /auth/reprise-resoumettre. Ticket #112. +class ReprisePayload { + ReprisePayload._(); + + static Map parentPatch( + UserRegistrationData data, + String token, + ) { + final base = ParentRegistrationPayload.toJson(data); + base.remove('email'); + base.remove('acceptation_cgu'); + base.remove('acceptation_privacy'); + base['token'] = token.trim(); + + final enfants = data.children.asMap().entries.map((e) { + final childMap = ParentRegistrationPayload.childToRepriseJson( + e.value, + e.key, + data.parent1.lastName.trim(), + ); + return childMap; + }).toList(); + base['enfants'] = enfants; + + return base; + } + + static Future> amPatch( + AmRegistrationData data, + String token, { + String? existingPhotoUrl, + }) async { + final body = {'token': token.trim()}; + + _put(body, 'prenom', data.firstName.trim()); + _put(body, 'nom', data.lastName.trim()); + _put(body, 'telephone', data.phone.trim()); + _put(body, 'adresse', data.streetAddress.trim()); + _put(body, 'code_postal', data.postalCode.trim()); + _put(body, 'ville', data.city.trim()); + + body['consentement_photo'] = data.photoConsent; + + if (data.dateOfBirth != null) { + final d = data.dateOfBirth!; + body['date_naissance'] = + '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; + } + _put(body, 'lieu_naissance_ville', data.birthCity.trim()); + _put(body, 'lieu_naissance_pays', data.birthCountry.trim()); + _put(body, 'numero_agrement', data.agrementNumber.trim()); + if (data.nir.trim().isNotEmpty) { + body['nir'] = normalizeNir(data.nir); + } + if (data.agreementDate != null) { + final d = data.agreementDate!; + body['date_agrement'] = + '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; + } + if (data.capacity != null) { + body['capacite_accueil'] = data.capacity; + } + if (data.placesAvailable != null) { + body['places_disponibles'] = data.placesAvailable; + } + if (data.presentationText.trim().isNotEmpty) { + body['biographie'] = data.presentationText.trim(); + } + + final photo = await _amPhotoPayload(data, existingPhotoUrl); + if (photo != null) { + body.addAll(photo); + } + + return body; + } + + static Future?> _amPhotoPayload( + AmRegistrationData data, + String? existingPhotoUrl, + ) async { + if (data.photoBytes != null && data.photoBytes!.isNotEmpty) { + final mime = _imageMimeForBytes(data.photoBytes!); + final fn = (data.photoFilename ?? '').trim(); + return { + 'photo_base64': + 'data:$mime;base64,${base64Encode(data.photoBytes!)}', + 'photo_filename': fn.isNotEmpty ? fn : 'photo_am.jpg', + }; + } + + if (!kIsWeb && + data.photoPath != null && + data.photoPath!.isNotEmpty && + !data.photoPath!.startsWith('assets/') && + !data.photoPath!.startsWith('http')) { + try { + final file = File(data.photoPath!); + if (await file.exists()) { + final bytes = await file.readAsBytes(); + final mime = _imageMimeForBytes(bytes); + return { + 'photo_base64': 'data:$mime;base64,${base64Encode(bytes)}', + 'photo_filename': + _basenameFromPath(data.photoPath!) ?? 'photo_am.jpg', + }; + } + } catch (_) {} + } + + final url = (existingPhotoUrl ?? data.repriseExistingPhotoUrl ?? '').trim(); + if (url.isNotEmpty) { + return {'photo_url': url}; + } + return null; + } + + static String _imageMimeForBytes(Uint8List bytes) { + if (bytes.length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xD8) { + return 'image/jpeg'; + } + if (bytes.length >= 8 && + bytes[0] == 0x89 && + bytes[1] == 0x50 && + bytes[2] == 0x4E && + bytes[3] == 0x47) { + return 'image/png'; + } + return 'image/jpeg'; + } + + static String? _basenameFromPath(String path) { + final parts = path.replaceAll('\\', '/').split('/'); + return parts.isEmpty ? null : parts.last; + } + + static void _put(Map m, String key, String value) { + if (value.isNotEmpty) m[key] = value; + } +} diff --git a/frontend/lib/widgets/auth/reprise_identify_dialog.dart b/frontend/lib/widgets/auth/reprise_identify_dialog.dart new file mode 100644 index 0000000..da8130a --- /dev/null +++ b/frontend/lib/widgets/auth/reprise_identify_dialog.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../services/auth_service.dart'; +import '../../utils/email_utils.dart'; +import '../custom_app_text_field.dart'; + +/// Modale login : numéro de dossier + e-mail → token reprise (#112). +class RepriseIdentifyDialog extends StatefulWidget { + final String? initialEmail; + + const RepriseIdentifyDialog({super.key, this.initialEmail}); + + @override + State createState() => _RepriseIdentifyDialogState(); +} + +class _RepriseIdentifyDialogState extends State { + final _formKey = GlobalKey(); + late final TextEditingController _numeroCtrl; + late final TextEditingController _emailCtrl; + + bool _loading = false; + String? _error; + + @override + void initState() { + super.initState(); + _numeroCtrl = TextEditingController(); + _emailCtrl = TextEditingController(text: widget.initialEmail ?? ''); + } + + @override + void dispose() { + _numeroCtrl.dispose(); + _emailCtrl.dispose(); + super.dispose(); + } + + String? _validateNumero(String? value) { + final v = value?.trim() ?? ''; + if (v.isEmpty) { + return 'Indiquez votre numéro de dossier.'; + } + return null; + } + + String? _validateEmail(String? value) { + final v = value?.trim() ?? ''; + if (v.isEmpty) { + return 'Indiquez votre adresse e-mail.'; + } + if (!isValidEmailFormat(v)) { + return 'L’adresse e-mail n’est pas valide.'; + } + return null; + } + + Future _submit() async { + if (_loading) return; + setState(() => _error = null); + if (!(_formKey.currentState?.validate() ?? false)) return; + + setState(() => _loading = true); + try { + final token = await AuthService.identifyReprise( + numeroDossier: _numeroCtrl.text, + email: _emailCtrl.text, + ); + if (!mounted) return; + Navigator.of(context).pop(token); + } catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _error = e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Impossible de retrouver votre dossier.'; + }); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text( + 'Reprendre mon dossier', + style: GoogleFonts.merienda(fontWeight: FontWeight.bold), + ), + content: SizedBox( + width: 420, + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Saisissez le numéro de dossier et l’e-mail utilisés lors ' + 'de l’inscription (dossier refusé en attente de correction).', + style: GoogleFonts.merienda(fontSize: 13), + ), + const SizedBox(height: 16), + CustomAppTextField( + controller: _numeroCtrl, + labelText: 'Numéro de dossier', + hintText: 'Ex. 2026-000021', + textInputAction: TextInputAction.next, + validator: _validateNumero, + style: CustomAppTextFieldStyle.lavande, + fieldHeight: 48, + fieldWidth: double.infinity, + ), + const SizedBox(height: 12), + CustomAppTextField( + controller: _emailCtrl, + labelText: 'E-mail', + hintText: 'Votre adresse e-mail', + keyboardType: TextInputType.emailAddress, + autocorrect: false, + enableSuggestions: false, + inputFormatters: const [EmailMaxLengthFormatter()], + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + validator: _validateEmail, + style: CustomAppTextFieldStyle.lavande, + fieldHeight: 48, + fieldWidth: double.infinity, + ), + if (_error != null) ...[ + const SizedBox(height: 12), + Text( + _error!, + style: GoogleFonts.merienda( + fontSize: 12, + color: Colors.red.shade700, + ), + ), + ], + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: _loading ? null : () => Navigator.of(context).pop(), + child: Text('Annuler', style: GoogleFonts.merienda()), + ), + TextButton( + onPressed: _loading ? null : _submit, + child: _loading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + 'Continuer', + style: GoogleFonts.merienda( + fontWeight: FontWeight.bold, + color: const Color(0xFF2D6A4F), + ), + ), + ), + ], + ); + } +} diff --git a/frontend/lib/widgets/child_card_widget.dart b/frontend/lib/widgets/child_card_widget.dart index 1afbf6e..4ff744f 100644 --- a/frontend/lib/widgets/child_card_widget.dart +++ b/frontend/lib/widgets/child_card_widget.dart @@ -1,4 +1,5 @@ import 'dart:math' as math; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -20,7 +21,7 @@ bool _hasChildPhoto(ChildData c) { return registrationPhotoSlotHasImage( imageBytes: c.imageBytes, imageFile: c.imageFile, - imagePathOrAsset: null, + imagePathOrAsset: c.existingPhotoUrl, ); } @@ -33,6 +34,20 @@ Widget _buildChildPhotoImage(ChildData c, {required BoxFit fit}) { if (f != null) { return kIsWeb ? Image.network(f.path, fit: fit) : Image.file(f, fit: fit); } + final url = c.existingPhotoUrl?.trim(); + if (url != null && url.isNotEmpty) { + if (url.startsWith('http://') || url.startsWith('https://')) { + return Image.network(url, fit: fit); + } + if (!kIsWeb) { + try { + final file = File(url); + if (file.existsSync()) { + return Image.file(file, fit: fit); + } + } catch (_) {} + } + } return Image.asset('assets/images/photo.png', fit: BoxFit.contain); } @@ -221,6 +236,7 @@ class _ChildCardWidgetState extends State { scaleFactor: scaleFactor, imageBytes: widget.childData.imageBytes, imageFile: widget.childData.imageFile, + imagePathOrAsset: widget.childData.existingPhotoUrl, onTapPick: !config.isReadonly ? widget.onPickImage : null, onClear: !config.isReadonly ? widget.onClearImage : null, baseShadowColor: baseCardColorForShadow, diff --git a/frontend/lib/widgets/professional_info_form_screen.dart b/frontend/lib/widgets/professional_info_form_screen.dart index 6225cd1..9a21bd0 100644 --- a/frontend/lib/widgets/professional_info_form_screen.dart +++ b/frontend/lib/widgets/professional_info_form_screen.dart @@ -134,30 +134,7 @@ class _ProfessionalInfoFormScreenState extends State @override void initState() { super.initState(); - - final data = widget.initialData; - if (data != null) { - _selectedDate = data.dateOfBirth; - _dateOfBirthController.text = data.dateOfBirth != null - ? DateFormat('dd/MM/yyyy').format(data.dateOfBirth!) - : ''; - _birthCityController.text = data.birthCity; - _birthCountryController.text = data.birthCountry; - final nirRaw = nirToRaw(data.nir); - _nirController.text = nirRaw.length == 15 ? formatNir(nirRaw) : data.nir; - _agrementController.text = data.agrementNumber; - _selectedAgreementDate = data.agreementDate; - _agreementDateController.text = data.agreementDate != null - ? DateFormat('dd/MM/yyyy').format(data.agreementDate!) - : ''; - _capacityController.text = data.capacity?.toString() ?? ''; - _placesAvailableController.text = data.placesAvailable?.toString() ?? ''; - _photoPathFramework = data.photoPath; - _photoFile = data.photoFile; - _photoBytes = data.photoBytes; - _photoFilename = data.photoFilename; - _photoConsent = data.photoConsent; - } + _applyInitialData(widget.initialData); if (widget.mode == DisplayMode.editable) { _birthCityFocus = FocusNode(); @@ -168,6 +145,51 @@ class _ProfessionalInfoFormScreenState extends State } } + @override + void didUpdateWidget(covariant ProfessionalInfoFormScreen oldWidget) { + super.didUpdateWidget(oldWidget); + final next = widget.initialData; + final prev = oldWidget.initialData; + if (next == null) return; + if (prev == null || + prev.dateOfBirth != next.dateOfBirth || + prev.agreementDate != next.agreementDate || + prev.placesAvailable != next.placesAvailable || + prev.capacity != next.capacity || + prev.nir != next.nir || + prev.birthCity != next.birthCity || + prev.birthCountry != next.birthCountry || + prev.agrementNumber != next.agrementNumber || + prev.photoPath != next.photoPath || + prev.photoConsent != next.photoConsent) { + _applyInitialData(next); + } + } + + void _applyInitialData(ProfessionalInfoData? data) { + if (data == null) return; + _selectedDate = data.dateOfBirth; + _dateOfBirthController.text = data.dateOfBirth != null + ? DateFormat('dd/MM/yyyy').format(data.dateOfBirth!) + : ''; + _birthCityController.text = data.birthCity; + _birthCountryController.text = data.birthCountry; + final nirRaw = nirToRaw(data.nir); + _nirController.text = nirRaw.length == 15 ? formatNir(nirRaw) : data.nir; + _agrementController.text = data.agrementNumber; + _selectedAgreementDate = data.agreementDate; + _agreementDateController.text = data.agreementDate != null + ? DateFormat('dd/MM/yyyy').format(data.agreementDate!) + : ''; + _capacityController.text = data.capacity?.toString() ?? ''; + _placesAvailableController.text = data.placesAvailable?.toString() ?? ''; + _photoPathFramework = data.photoPath; + _photoFile = data.photoFile; + _photoBytes = data.photoBytes; + _photoFilename = data.photoFilename; + _photoConsent = data.photoConsent; + } + void _onBirthCityFocusChange() { if (_birthCityFocus == null || _birthCityFocus!.hasFocus) return; _applyPlaceNameFormat(_birthCityController); diff --git a/frontend/lib/widgets/registration_photo_slot.dart b/frontend/lib/widgets/registration_photo_slot.dart index 7dde753..2338ef9 100644 --- a/frontend/lib/widgets/registration_photo_slot.dart +++ b/frontend/lib/widgets/registration_photo_slot.dart @@ -71,6 +71,9 @@ class RegistrationPhotoSlot extends StatelessWidget { if (p.startsWith('assets/')) { return Image.asset(p, fit: fit); } + if (p.startsWith('http://') || p.startsWith('https://')) { + return Image.network(p, fit: fit); + } if (!kIsWeb) { try { final file = File(p); diff --git a/frontend/test/reprise_am_parse_test.dart b/frontend/test/reprise_am_parse_test.dart new file mode 100644 index 0000000..3fcf621 --- /dev/null +++ b/frontend/test/reprise_am_parse_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:p_tits_pas/models/am_registration_data.dart'; +import 'package:p_tits_pas/models/reprise_dossier.dart'; +import 'package:p_tits_pas/utils/reprise_mapper.dart'; + +void main() { + test('RepriseDossier parse champs AM plats', () { + final d = RepriseDossier.fromJson({ + 'id': 'am1', + 'email': 'marie@test.fr', + 'role': 'assistante_maternelle', + 'date_naissance': '1980-06-08', + 'date_agrement': '2019-09-01', + 'place_disponible': 2, + 'nb_max_enfants': 4, + 'lieu_naissance_ville': 'Ajaccio', + 'nir': '280062A00100191', + 'numero_agrement': 'AGR-2019-095001', + }); + + expect(d.dateNaissance, '1980-06-08'); + expect(d.dateAgrement, '2019-09-01'); + expect(d.placeDisponible, 2); + + final data = AmRegistrationData(); + RepriseMapper.applyAmDossier(data, d); + expect(data.dateOfBirth, DateTime(1980, 6, 8)); + expect(data.agreementDate, DateTime(2019, 9, 1)); + expect(data.placesAvailable, 2); + }); + + test('RepriseDossier parse champs AM imbriqués + wrapper data', () { + final d = RepriseDossier.fromJson({ + 'id': 'am1', + 'email': 'marie@test.fr', + 'role': 'assistante_maternelle', + 'nir': '280062A00100191', + 'numero_agrement': 'AGR-2019-095001', + 'nb_max_enfants': 4, + 'lieu_naissance_ville': 'Ajaccio', + 'user': {'date_naissance': '1980-06-08T00:00:00.000Z'}, + 'dossier': { + 'date_agrement': '2019-09-01', + 'places_disponibles': 2, + }, + }); + + expect(d.dateNaissance, isNotNull); + expect(d.dateAgrement, '2019-09-01'); + expect(d.placeDisponible, 2); + }); +}