Compare commits
9 Commits
c226c2fcdf
...
df776d8200
| Author | SHA1 | Date | |
|---|---|---|---|
| df776d8200 | |||
| c26ed00374 | |||
| 9d54d9b19b | |||
| c438009286 | |||
| 9b7231f1da | |||
| f300505225 | |||
| 25c10c885a | |||
| d70577b1c3 | |||
| 671da71752 |
@ -98,4 +98,21 @@ describe('MailService (ticket #28)', () => {
|
|||||||
expect(html).toContain(`https://app.test/reset-password?token=${encodeURIComponent(token)}`);
|
expect(html).toContain(`https://app.test/reset-password?token=${encodeURIComponent(token)}`);
|
||||||
expect(html).not.toContain('create-password');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -301,6 +301,43 @@ export class MailService {
|
|||||||
await this.sendEmail(to, subject, html);
|
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<void> {
|
||||||
|
const appName = this.configService.get<string>('app_name', "P'titsPas");
|
||||||
|
const appUrl = this.configService.get<string>('app_url', 'https://app.ptits-pas.fr');
|
||||||
|
|
||||||
|
const safe = (s: string) =>
|
||||||
|
(s || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
|
||||||
|
const subject = `Votre dossier a été resoumis — ${safe(numeroDossier)}`;
|
||||||
|
const html = `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #4CAF50;">Bonjour ${safe(prenom)} ${safe(nom)},</h2>
|
||||||
|
<p>Nous avons bien reçu la <strong>resoumission</strong> de votre dossier sur <strong>${safe(appName)}</strong>.</p>
|
||||||
|
<p><strong>Numéro de dossier :</strong> ${safe(numeroDossier)}</p>
|
||||||
|
<p>Votre dossier est de nouveau <strong>en attente de validation</strong> par notre équipe. Vous recevrez un email lorsqu'il aura été traité.</p>
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="${appUrl}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Accéder au site</a>
|
||||||
|
</div>
|
||||||
|
<hr style="border: 1px solid #eee; margin: 20px 0;">
|
||||||
|
<p style="color: #666; font-size: 12px;">Cet email a été envoyé automatiquement. Merci de ne pas y répondre.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await this.sendEmail(to, subject, html);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Email de refus de dossier avec lien reprise (token).
|
* Email de refus de dossier avec lien reprise (token).
|
||||||
* Ticket #110 – Refus sans suppression
|
* Ticket #110 – Refus sans suppression
|
||||||
|
|||||||
@ -102,8 +102,7 @@ export class AuthController {
|
|||||||
@ApiResponse({ status: 200, description: 'Dossier resoumis' })
|
@ApiResponse({ status: 200, description: 'Dossier resoumis' })
|
||||||
@ApiResponse({ status: 404, description: 'Token invalide ou expiré' })
|
@ApiResponse({ status: 404, description: 'Token invalide ou expiré' })
|
||||||
async resoumettreReprise(@Body() dto: ResoumettreRepriseDto) {
|
async resoumettreReprise(@Body() dto: ResoumettreRepriseDto) {
|
||||||
const { token, ...fields } = dto;
|
return this.authService.resoumettreReprise(dto);
|
||||||
return this.authService.resoumettreReprise(token, fields);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
|
|||||||
@ -12,11 +12,25 @@ import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entit
|
|||||||
import { AppConfigModule } from 'src/modules/config';
|
import { AppConfigModule } from 'src/modules/config';
|
||||||
import { NumeroDossierModule } from 'src/modules/numero-dossier/numero-dossier.module';
|
import { NumeroDossierModule } from 'src/modules/numero-dossier/numero-dossier.module';
|
||||||
import { MailModule } from 'src/modules/mail/mail.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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]),
|
TypeOrmModule.forFeature([
|
||||||
|
Users,
|
||||||
|
Parents,
|
||||||
|
Children,
|
||||||
|
AssistanteMaternelle,
|
||||||
|
DossierFamille,
|
||||||
|
DossierFamilleEnfant,
|
||||||
|
ParentsChildren,
|
||||||
|
]),
|
||||||
forwardRef(() => UserModule),
|
forwardRef(() => UserModule),
|
||||||
|
ParentsModule,
|
||||||
|
DossiersModule,
|
||||||
AppConfigModule,
|
AppConfigModule,
|
||||||
MailModule,
|
MailModule,
|
||||||
NumeroDossierModule,
|
NumeroDossierModule,
|
||||||
|
|||||||
@ -5,11 +5,13 @@ import { getRepositoryToken } from '@nestjs/typeorm';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { UserService } from '../user/user.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 { AppConfigService } from 'src/modules/config/config.service';
|
||||||
import { MailService } from 'src/modules/mail/mail.service';
|
import { MailService } from 'src/modules/mail/mail.service';
|
||||||
import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service';
|
import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service';
|
||||||
import { Parents } from 'src/entities/parents.entity';
|
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 { Children } from 'src/entities/children.entity';
|
||||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
|
|
||||||
@ -19,6 +21,20 @@ describe('AuthService (#118 create-password API)', () => {
|
|||||||
findOne: jest.fn(),
|
findOne: jest.fn(),
|
||||||
createQueryBuilder: jest.fn(),
|
createQueryBuilder: jest.fn(),
|
||||||
update: 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 = {
|
const mailServiceMock = {
|
||||||
@ -45,7 +61,9 @@ describe('AuthService (#118 create-password API)', () => {
|
|||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
{ provide: UserService, useValue: {} },
|
{ provide: UserService, useValue: userServiceMock },
|
||||||
|
{ provide: ParentsService, useValue: parentsServiceMock },
|
||||||
|
{ provide: DossiersService, useValue: dossiersServiceMock },
|
||||||
{ provide: JwtService, useValue: {} },
|
{ provide: JwtService, useValue: {} },
|
||||||
{ provide: ConfigService, useValue: { get: jest.fn() } },
|
{ provide: ConfigService, useValue: { get: jest.fn() } },
|
||||||
{ provide: AppConfigService, useValue: appConfigMock },
|
{ provide: AppConfigService, useValue: appConfigMock },
|
||||||
@ -136,4 +154,69 @@ describe('AuthService (#118 create-password API)', () => {
|
|||||||
expect(exec).toHaveBeenCalled();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { QueryFailedError, Repository } from 'typeorm';
|
import { EntityManager, In, QueryFailedError, Repository } from 'typeorm';
|
||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '../user/user.service';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as bcrypt from 'bcrypt';
|
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 { StatutDossierType } from 'src/entities/dossiers.entity';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { RepriseDossierDto } from './dto/reprise-dossier.dto';
|
import { RepriseDossierDto } from './dto/reprise-dossier.dto';
|
||||||
|
import { ResoumettreRepriseDto } from './dto/resoumettre-reprise.dto';
|
||||||
import { RepriseIdentifyResponseDto } from './dto/reprise-identify.dto';
|
import { RepriseIdentifyResponseDto } from './dto/reprise-identify.dto';
|
||||||
import { AppConfigService } from 'src/modules/config/config.service';
|
import { AppConfigService } from 'src/modules/config/config.service';
|
||||||
import { validateNir } from 'src/common/utils/nir.util';
|
import { validateNir } from 'src/common/utils/nir.util';
|
||||||
import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service';
|
import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service';
|
||||||
import { MailService } from 'src/modules/mail/mail.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()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@ -44,6 +48,8 @@ export class AuthService {
|
|||||||
private readonly appConfigService: AppConfigService,
|
private readonly appConfigService: AppConfigService,
|
||||||
private readonly mailService: MailService,
|
private readonly mailService: MailService,
|
||||||
private readonly numeroDossierService: NumeroDossierService,
|
private readonly numeroDossierService: NumeroDossierService,
|
||||||
|
private readonly parentsService: ParentsService,
|
||||||
|
private readonly dossiersService: DossiersService,
|
||||||
@InjectRepository(Parents)
|
@InjectRepository(Parents)
|
||||||
private readonly parentsRepo: Repository<Parents>,
|
private readonly parentsRepo: Repository<Parents>,
|
||||||
@InjectRepository(Users)
|
@InjectRepository(Users)
|
||||||
@ -873,12 +879,72 @@ export class AuthService {
|
|||||||
return { success: true, message: 'Deconnexion'}
|
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<RepriseDossierDto> {
|
async getRepriseDossier(token: string): Promise<RepriseDossierDto> {
|
||||||
const user = await this.usersService.findByTokenReprise(token);
|
const user = await this.usersService.findByTokenReprise(token);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new NotFoundException('Token reprise invalide ou expiré.');
|
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 {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@ -896,12 +962,291 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** PUT resoumission reprise. Ticket #111 */
|
private mergeAmRepriseDossier(base: RepriseDossierDto, dossier: DossierAmCompletDto): RepriseDossierDto {
|
||||||
async resoumettreReprise(
|
const u = dossier.user;
|
||||||
token: string,
|
return {
|
||||||
dto: { prenom?: string; nom?: string; telephone?: string; adresse?: string; ville?: string; code_postal?: string; photo_url?: string },
|
...base,
|
||||||
): Promise<Users> {
|
email: u.email,
|
||||||
return this.usersService.resoumettreReprise(token, dto);
|
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<Set<string>> {
|
||||||
|
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 */
|
/** POST reprise-identify : numero_dossier + email → type + token. Ticket #111 */
|
||||||
|
|||||||
11
backend/src/routes/auth/dto/enfant-reprise.dto.ts
Normal file
11
backend/src/routes/auth/dto/enfant-reprise.dto.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -1,7 +1,11 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { RoleType } from 'src/entities/users.entity';
|
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 {
|
export class RepriseDossierDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
id: string;
|
id: string;
|
||||||
@ -41,4 +45,47 @@ export class RepriseDossierDto {
|
|||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
situation_familiale?: string;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,29 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
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 {
|
export class ResoumettreRepriseDto {
|
||||||
@ApiProperty({ description: 'Token reprise (reçu par email)' })
|
@ApiProperty({ description: 'Token reprise (reçu par email)' })
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
token: string;
|
token: string;
|
||||||
|
|
||||||
|
// --- Identité titulaire (parent principal ou AM) ---
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -42,8 +59,147 @@ export class ResoumettreRepriseDto {
|
|||||||
@MaxLength(10)
|
@MaxLength(10)
|
||||||
code_postal?: string;
|
code_postal?: string;
|
||||||
|
|
||||||
@ApiProperty({ required: false, description: 'Pour AM' })
|
@ApiProperty({ required: false, description: 'Pour AM (URL photo existante)' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
photo_url?: string;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,6 +54,9 @@ export class DossierFamilleEnfantDto {
|
|||||||
description: 'Consentement affichage photo (colonne consentement_photo)',
|
description: 'Consentement affichage photo (colonne consentement_photo)',
|
||||||
})
|
})
|
||||||
consent_photo?: boolean;
|
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 */
|
/** Réponse GET /parents/dossier-famille/:numeroDossier – dossier famille complet. Ticket #119 */
|
||||||
|
|||||||
@ -221,6 +221,7 @@ export class ParentsService {
|
|||||||
status: child.status,
|
status: child.status,
|
||||||
photo_url: child.photo_url ?? undefined,
|
photo_url: child.photo_url ?? undefined,
|
||||||
consent_photo: child.consent_photo,
|
consent_photo: child.consent_photo,
|
||||||
|
est_multiple: child.is_multiple,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
244
docs/tmp/112-back-reprise-alignement-front.md
Normal file
244
docs/tmp/112-back-reprise-alignement-front.md
Normal file
@ -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
|
||||||
|
```
|
||||||
@ -21,6 +21,7 @@ import '../screens/auth/am_register_step4_screen.dart';
|
|||||||
import '../screens/auth/create_password_screen.dart';
|
import '../screens/auth/create_password_screen.dart';
|
||||||
import '../screens/auth/forgot_password_screen.dart';
|
import '../screens/auth/forgot_password_screen.dart';
|
||||||
import '../screens/auth/reset_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/home/home_screen.dart';
|
||||||
import '../screens/administrateurs/admin_dashboardScreen.dart';
|
import '../screens/administrateurs/admin_dashboardScreen.dart';
|
||||||
import '../screens/gestionnaire/gestionnaire_dashboard_screen.dart';
|
import '../screens/gestionnaire/gestionnaire_dashboard_screen.dart';
|
||||||
@ -66,6 +67,11 @@ class AppRouter {
|
|||||||
builder: (BuildContext context, GoRouterState state) =>
|
builder: (BuildContext context, GoRouterState state) =>
|
||||||
ResetPasswordScreen(token: state.uri.queryParameters['token']),
|
ResetPasswordScreen(token: state.uri.queryParameters['token']),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/reprise',
|
||||||
|
builder: (BuildContext context, GoRouterState state) =>
|
||||||
|
RepriseEntryScreen(token: state.uri.queryParameters['token']),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/home',
|
path: '/home',
|
||||||
builder: (BuildContext context, GoRouterState state) => const HomeScreen(),
|
builder: (BuildContext context, GoRouterState state) => const HomeScreen(),
|
||||||
|
|||||||
@ -33,6 +33,9 @@ class AmRegistrationData extends ChangeNotifier {
|
|||||||
/// Places libres actuellement (0 ≤ valeur ≤ capacité) — API `places_disponibles`.
|
/// Places libres actuellement (0 ≤ valeur ≤ capacité) — API `places_disponibles`.
|
||||||
int? placesAvailable;
|
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
|
// Step 3: Presentation & CGU
|
||||||
String presentationText = '';
|
String presentationText = '';
|
||||||
bool cguAccepted = false;
|
bool cguAccepted = false;
|
||||||
@ -104,6 +107,53 @@ class AmRegistrationData extends ChangeNotifier {
|
|||||||
notifyListeners();
|
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 ---
|
// --- Getters for validation or display ---
|
||||||
bool get isStep1Complete =>
|
bool get isStep1Complete =>
|
||||||
firstName.trim().length >= 2 &&
|
firstName.trim().length >= 2 &&
|
||||||
@ -118,6 +168,8 @@ class AmRegistrationData extends ChangeNotifier {
|
|||||||
/// Photo réelle (pas seulement un placeholder asset).
|
/// Photo réelle (pas seulement un placeholder asset).
|
||||||
bool get _hasUserPhoto =>
|
bool get _hasUserPhoto =>
|
||||||
(photoBytes != null && photoBytes!.isNotEmpty) ||
|
(photoBytes != null && photoBytes!.isNotEmpty) ||
|
||||||
|
(repriseExistingPhotoUrl != null &&
|
||||||
|
repriseExistingPhotoUrl!.trim().isNotEmpty) ||
|
||||||
(photoPath != null &&
|
(photoPath != null &&
|
||||||
photoPath!.isNotEmpty &&
|
photoPath!.isNotEmpty &&
|
||||||
!photoPath!.startsWith('assets/'));
|
!photoPath!.startsWith('assets/'));
|
||||||
@ -145,6 +197,9 @@ class AmRegistrationData extends ChangeNotifier {
|
|||||||
bool get isRegistrationComplete =>
|
bool get isRegistrationComplete =>
|
||||||
isStep1Complete && isStep2Complete && isStep3Complete;
|
isStep1Complete && isStep2Complete && isStep3Complete;
|
||||||
|
|
||||||
|
/// Reprise (#112) : dossier AM complet renvoyé par PATCH reprise-resoumettre.
|
||||||
|
bool get isRepriseSubmitReady => isRegistrationComplete;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'AmRegistrationData('
|
return 'AmRegistrationData('
|
||||||
|
|||||||
@ -184,6 +184,7 @@ class EnfantDossier {
|
|||||||
final String? dueDate;
|
final String? dueDate;
|
||||||
final String? photoUrl;
|
final String? photoUrl;
|
||||||
final bool consentPhoto;
|
final bool consentPhoto;
|
||||||
|
final bool estMultiple;
|
||||||
|
|
||||||
EnfantDossier({
|
EnfantDossier({
|
||||||
required this.id,
|
required this.id,
|
||||||
@ -195,6 +196,7 @@ class EnfantDossier {
|
|||||||
this.dueDate,
|
this.dueDate,
|
||||||
this.photoUrl,
|
this.photoUrl,
|
||||||
this.consentPhoto = false,
|
this.consentPhoto = false,
|
||||||
|
this.estMultiple = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
String get fullName => '${firstName ?? ''} ${lastName ?? ''}'.trim();
|
String get fullName => '${firstName ?? ''} ${lastName ?? ''}'.trim();
|
||||||
@ -223,6 +225,8 @@ class EnfantDossier {
|
|||||||
photoUrl: resolvedPhoto,
|
photoUrl: resolvedPhoto,
|
||||||
consentPhoto:
|
consentPhoto:
|
||||||
json['consent_photo'] == true || json['consentPhoto'] == true,
|
json['consent_photo'] == true || json['consentPhoto'] == true,
|
||||||
|
estMultiple:
|
||||||
|
json['est_multiple'] == true || json['estMultiple'] == true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
180
frontend/lib/models/reprise_dossier.dart
Normal file
180
frontend/lib/models/reprise_dossier.dart
Normal file
@ -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<ParentDossier> parents;
|
||||||
|
final List<EnfantDossier> 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<String, dynamic>? _nestedMap(dynamic value) {
|
||||||
|
if (value is Map) return Map<String, dynamic>.from(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static dynamic _firstNonNull(List<dynamic> values) {
|
||||||
|
for (final v in values) {
|
||||||
|
if (v != null) return v;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
factory RepriseDossier.fromJson(Map<String, dynamic> 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<String, dynamic>.from(e as Map)))
|
||||||
|
.toList()
|
||||||
|
: <ParentDossier>[];
|
||||||
|
|
||||||
|
final enfantsRaw = json['enfants'];
|
||||||
|
final enfantsList = enfantsRaw is List
|
||||||
|
? enfantsRaw
|
||||||
|
.where((e) => e is Map)
|
||||||
|
.map((e) => EnfantDossier.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||||
|
.toList()
|
||||||
|
: <EnfantDossier>[];
|
||||||
|
|
||||||
|
return 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -30,6 +30,7 @@ class ParentData {
|
|||||||
class ChildData {
|
class ChildData {
|
||||||
static const Object _unsetImage = Object();
|
static const Object _unsetImage = Object();
|
||||||
static const Object _unsetImageBytes = Object();
|
static const Object _unsetImageBytes = Object();
|
||||||
|
static const Object _unsetExistingPhotoUrl = Object();
|
||||||
|
|
||||||
String firstName;
|
String firstName;
|
||||||
String lastName;
|
String lastName;
|
||||||
@ -43,6 +44,10 @@ class ChildData {
|
|||||||
/// Octets de la photo (fiable à l’envoi API ; [imageFile] peut être absent sur le web).
|
/// Octets de la photo (fiable à l’envoi API ; [imageFile] peut être absent sur le web).
|
||||||
Uint8List? imageBytes;
|
Uint8List? imageBytes;
|
||||||
CardColorVertical cardColor; // Nouveau champ pour la couleur de la carte
|
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({
|
ChildData({
|
||||||
this.firstName = '',
|
this.firstName = '',
|
||||||
@ -55,6 +60,8 @@ class ChildData {
|
|||||||
this.imageFile,
|
this.imageFile,
|
||||||
this.imageBytes,
|
this.imageBytes,
|
||||||
required this.cardColor, // Rendre requis dans le constructeur
|
required this.cardColor, // Rendre requis dans le constructeur
|
||||||
|
this.repriseChildId,
|
||||||
|
this.existingPhotoUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
ChildData copyWith({
|
ChildData copyWith({
|
||||||
@ -68,6 +75,8 @@ class ChildData {
|
|||||||
Object? imageFile = _unsetImage,
|
Object? imageFile = _unsetImage,
|
||||||
Object? imageBytes = _unsetImageBytes,
|
Object? imageBytes = _unsetImageBytes,
|
||||||
CardColorVertical? cardColor,
|
CardColorVertical? cardColor,
|
||||||
|
String? repriseChildId,
|
||||||
|
Object? existingPhotoUrl = _unsetExistingPhotoUrl,
|
||||||
}) {
|
}) {
|
||||||
return ChildData(
|
return ChildData(
|
||||||
firstName: firstName ?? this.firstName,
|
firstName: firstName ?? this.firstName,
|
||||||
@ -81,6 +90,10 @@ class ChildData {
|
|||||||
imageBytes:
|
imageBytes:
|
||||||
identical(imageBytes, _unsetImageBytes) ? this.imageBytes : imageBytes as Uint8List?,
|
identical(imageBytes, _unsetImageBytes) ? this.imageBytes : imageBytes as Uint8List?,
|
||||||
cardColor: cardColor ?? this.cardColor,
|
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();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reprise après refus (#112) : réinitialise le flux parent avec les données dossier.
|
||||||
|
void resetForReprise({
|
||||||
|
required ParentData parent1Data,
|
||||||
|
ParentData? parent2Data,
|
||||||
|
List<ChildData>? 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é)
|
// Méthode pour vérifier si toutes les données requises sont là (simplifié)
|
||||||
bool isRegistrationComplete() {
|
bool isRegistrationComplete() {
|
||||||
// Ajouter ici les validations nécessaires
|
// Ajouter ici les validations nécessaires
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
import '../../models/am_registration_data.dart';
|
import '../../models/am_registration_data.dart';
|
||||||
import '../../widgets/personal_info_form_screen.dart';
|
import '../../widgets/personal_info_form_screen.dart';
|
||||||
|
import '../../services/reprise_session.dart';
|
||||||
import '../../models/card_assets.dart';
|
import '../../models/card_assets.dart';
|
||||||
|
|
||||||
class AmRegisterStep1Screen extends StatelessWidget {
|
class AmRegisterStep1Screen extends StatelessWidget {
|
||||||
@ -29,7 +30,7 @@ class AmRegisterStep1Screen extends StatelessWidget {
|
|||||||
cardColor: CardColorHorizontal.blue,
|
cardColor: CardColorHorizontal.blue,
|
||||||
initialData: initialData,
|
initialData: initialData,
|
||||||
minPersonNameLength: 2,
|
minPersonNameLength: 2,
|
||||||
previousRoute: '/register-choice',
|
previousRoute: RepriseSession.isActive ? '/login' : '/register-choice',
|
||||||
onSubmit: (data, {hasSecondPerson, sameAddress}) {
|
onSubmit: (data, {hasSecondPerson, sameAddress}) {
|
||||||
registrationData.updateIdentityInfo(
|
registrationData.updateIdentityInfo(
|
||||||
firstName: data.firstName,
|
firstName: data.firstName,
|
||||||
|
|||||||
@ -11,8 +11,8 @@ class AmRegisterStep2Screen extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final registrationData = Provider.of<AmRegistrationData>(context, listen: false);
|
return Consumer<AmRegistrationData>(
|
||||||
|
builder: (context, registrationData, _) {
|
||||||
final initialData = ProfessionalInfoData(
|
final initialData = ProfessionalInfoData(
|
||||||
photoPath: registrationData.photoPath,
|
photoPath: registrationData.photoPath,
|
||||||
photoBytes: registrationData.photoBytes,
|
photoBytes: registrationData.photoBytes,
|
||||||
@ -29,6 +29,12 @@ class AmRegisterStep2Screen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return ProfessionalInfoFormScreen(
|
return ProfessionalInfoFormScreen(
|
||||||
|
key: ValueKey(
|
||||||
|
'am-pro-${registrationData.dateOfBirth?.millisecondsSinceEpoch}'
|
||||||
|
'-${registrationData.agreementDate?.millisecondsSinceEpoch}'
|
||||||
|
'-${registrationData.placesAvailable}'
|
||||||
|
'-${registrationData.nir}',
|
||||||
|
),
|
||||||
stepText: 'Étape 2/4',
|
stepText: 'Étape 2/4',
|
||||||
title: 'Vos informations professionnelles',
|
title: 'Vos informations professionnelles',
|
||||||
cardColor: CardColorHorizontal.green,
|
cardColor: CardColorHorizontal.green,
|
||||||
@ -52,5 +58,7 @@ class AmRegisterStep2Screen extends StatelessWidget {
|
|||||||
context.go('/am-register-step3');
|
context.go('/am-register-step3');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import '../../models/am_registration_data.dart';
|
|||||||
import '../../models/card_assets.dart';
|
import '../../models/card_assets.dart';
|
||||||
import '../../config/display_config.dart';
|
import '../../config/display_config.dart';
|
||||||
import '../../services/auth_service.dart';
|
import '../../services/auth_service.dart';
|
||||||
|
import '../../services/reprise_session.dart';
|
||||||
|
import '../../utils/reprise_payload.dart';
|
||||||
import '../../widgets/hover_relief_widget.dart';
|
import '../../widgets/hover_relief_widget.dart';
|
||||||
import '../../widgets/image_button.dart';
|
import '../../widgets/image_button.dart';
|
||||||
import '../../widgets/custom_navigation_button.dart';
|
import '../../widgets/custom_navigation_button.dart';
|
||||||
@ -27,7 +29,21 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
|
|||||||
|
|
||||||
Future<void> _submitAMRegistration(AmRegistrationData registrationData) async {
|
Future<void> _submitAMRegistration(AmRegistrationData registrationData) async {
|
||||||
if (_isSubmitting) return;
|
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;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
@ -42,6 +58,19 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
|
|||||||
}
|
}
|
||||||
setState(() => _isSubmitting = true);
|
setState(() => _isSubmitting = true);
|
||||||
try {
|
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);
|
await AuthService.registerAM(registrationData);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_showConfirmationModal(context);
|
_showConfirmationModal(context);
|
||||||
@ -244,6 +273,34 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showRepriseConfirmationModal(BuildContext context) {
|
||||||
|
showDialog<void>(
|
||||||
|
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: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
child: Text('OK', style: GoogleFonts.merienda(fontWeight: FontWeight.bold)),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
context.go('/login');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _showConfirmationModal(BuildContext context) {
|
void _showConfirmationModal(BuildContext context) {
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import '../../widgets/image_button.dart';
|
|||||||
import '../../widgets/custom_app_text_field.dart';
|
import '../../widgets/custom_app_text_field.dart';
|
||||||
import '../../services/auth_service.dart';
|
import '../../services/auth_service.dart';
|
||||||
import '../../widgets/auth/change_password_dialog.dart';
|
import '../../widgets/auth/change_password_dialog.dart';
|
||||||
|
import '../../widgets/auth/reprise_identify_dialog.dart';
|
||||||
|
|
||||||
class LoginScreen extends StatefulWidget {
|
class LoginScreen extends StatefulWidget {
|
||||||
const LoginScreen({super.key});
|
const LoginScreen({super.key});
|
||||||
@ -100,6 +101,31 @@ class _LoginPageState extends State<LoginScreen> {
|
|||||||
_handleLogin();
|
_handleLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _openRepriseIdentify() async {
|
||||||
|
final token = await showDialog<String>(
|
||||||
|
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
|
/// Gère la connexion de l'utilisateur
|
||||||
Future<void> _handleLogin() async {
|
Future<void> _handleLogin() async {
|
||||||
// Réinitialiser le message d'erreur
|
// Réinitialiser le message d'erreur
|
||||||
@ -384,6 +410,7 @@ class _LoginPageState extends State<LoginScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Center(child: _buildRepriseDossierLink()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -640,6 +667,7 @@ class _LoginPageState extends State<LoginScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
_buildRepriseDossierLink(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
import '../../models/user_registration_data.dart';
|
import '../../models/user_registration_data.dart';
|
||||||
import '../../widgets/personal_info_form_screen.dart';
|
import '../../widgets/personal_info_form_screen.dart';
|
||||||
|
import '../../services/reprise_session.dart';
|
||||||
import '../../models/card_assets.dart';
|
import '../../models/card_assets.dart';
|
||||||
|
|
||||||
class ParentRegisterStep1Screen extends StatelessWidget {
|
class ParentRegisterStep1Screen extends StatelessWidget {
|
||||||
@ -29,7 +30,7 @@ class ParentRegisterStep1Screen extends StatelessWidget {
|
|||||||
title: 'Informations du Parent Principal',
|
title: 'Informations du Parent Principal',
|
||||||
cardColor: CardColorHorizontal.peach,
|
cardColor: CardColorHorizontal.peach,
|
||||||
initialData: initialData,
|
initialData: initialData,
|
||||||
previousRoute: '/register-choice',
|
previousRoute: RepriseSession.isActive ? '/login' : '/register-choice',
|
||||||
onSubmit: (data, {hasSecondPerson, sameAddress}) {
|
onSubmit: (data, {hasSecondPerson, sameAddress}) {
|
||||||
registrationData.updateParent1(ParentData(
|
registrationData.updateParent1(ParentData(
|
||||||
firstName: data.firstName,
|
firstName: data.firstName,
|
||||||
|
|||||||
@ -166,7 +166,11 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
|
|||||||
if (await f.exists()) file = f;
|
if (await f.exists()) file = f;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
final updatedChild = oldChild.copyWith(imageBytes: bytes, imageFile: file);
|
final updatedChild = oldChild.copyWith(
|
||||||
|
imageBytes: bytes,
|
||||||
|
imageFile: file,
|
||||||
|
existingPhotoUrl: null,
|
||||||
|
);
|
||||||
registrationData.updateChild(childIndex, updatedChild);
|
registrationData.updateChild(childIndex, updatedChild);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -306,7 +310,7 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
|
|||||||
onClearImage: () => setState(() {
|
onClearImage: () => setState(() {
|
||||||
final c = registrationData.children[index];
|
final c = registrationData.children[index];
|
||||||
registrationData.updateChild(
|
registrationData.updateChild(
|
||||||
index, c.copyWith(imageFile: null, imageBytes: null));
|
index, c.copyWith(imageFile: null, imageBytes: null, existingPhotoUrl: null));
|
||||||
}),
|
}),
|
||||||
onDateSelect: () => _selectDate(context, index, registrationData),
|
onDateSelect: () => _selectDate(context, index, registrationData),
|
||||||
onFirstNameChanged: (value) => setState(() {
|
onFirstNameChanged: (value) => setState(() {
|
||||||
@ -414,7 +418,7 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
|
|||||||
onClearImage: () => setState(() {
|
onClearImage: () => setState(() {
|
||||||
final c = registrationData.children[index];
|
final c = registrationData.children[index];
|
||||||
registrationData.updateChild(
|
registrationData.updateChild(
|
||||||
index, c.copyWith(imageFile: null, imageBytes: null));
|
index, c.copyWith(imageFile: null, imageBytes: null, existingPhotoUrl: null));
|
||||||
}),
|
}),
|
||||||
onDateSelect: () => _selectDate(context, index, registrationData),
|
onDateSelect: () => _selectDate(context, index, registrationData),
|
||||||
onFirstNameChanged: (value) => setState(() {
|
onFirstNameChanged: (value) => setState(() {
|
||||||
|
|||||||
@ -14,6 +14,8 @@ import '../../widgets/personal_info_form_screen.dart';
|
|||||||
import '../../widgets/child_card_widget.dart';
|
import '../../widgets/child_card_widget.dart';
|
||||||
import '../../widgets/presentation_form_screen.dart';
|
import '../../widgets/presentation_form_screen.dart';
|
||||||
import '../../services/auth_service.dart';
|
import '../../services/auth_service.dart';
|
||||||
|
import '../../services/reprise_session.dart';
|
||||||
|
import '../../utils/reprise_payload.dart';
|
||||||
|
|
||||||
class ParentRegisterStep5Screen extends StatefulWidget {
|
class ParentRegisterStep5Screen extends StatefulWidget {
|
||||||
const ParentRegisterStep5Screen({super.key});
|
const ParentRegisterStep5Screen({super.key});
|
||||||
@ -27,8 +29,32 @@ class _ParentRegisterStep5ScreenState extends State<ParentRegisterStep5Screen> {
|
|||||||
|
|
||||||
Future<void> _submitRegistration(BuildContext context, UserRegistrationData data) async {
|
Future<void> _submitRegistration(BuildContext context, UserRegistrationData data) async {
|
||||||
if (_isSubmitting) return;
|
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);
|
setState(() => _isSubmitting = true);
|
||||||
try {
|
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);
|
await AuthService.registerParent(data);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
_showSuccessModal(context);
|
_showSuccessModal(context);
|
||||||
@ -279,6 +305,34 @@ class _ParentRegisterStep5ScreenState extends State<ParentRegisterStep5Screen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showRepriseSuccessModal(BuildContext context) {
|
||||||
|
showDialog<void>(
|
||||||
|
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: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
child: Text('OK', style: GoogleFonts.merienda(fontWeight: FontWeight.bold)),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
context.go('/login');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _showSuccessModal(BuildContext context) {
|
void _showSuccessModal(BuildContext context) {
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
154
frontend/lib/screens/auth/reprise_entry_screen.dart
Normal file
154
frontend/lib/screens/auth/reprise_entry_screen.dart
Normal file
@ -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<RepriseEntryScreen> createState() => _RepriseEntryScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RepriseEntryScreenState extends State<RepriseEntryScreen> {
|
||||||
|
bool _loading = true;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -49,6 +49,10 @@ class ApiConfig {
|
|||||||
/// Ticket #127 — mot de passe oublié (back à livrer en parallèle).
|
/// Ticket #127 — mot de passe oublié (back à livrer en parallèle).
|
||||||
static const String forgotPassword = '/auth/forgot-password';
|
static const String forgotPassword = '/auth/forgot-password';
|
||||||
static const String resetPassword = '/auth/reset-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
|
// Users endpoints
|
||||||
static const String users = '/users';
|
static const String users = '/users';
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
import '../models/user.dart';
|
import '../models/user.dart';
|
||||||
import '../models/am_registration_data.dart';
|
import '../models/am_registration_data.dart';
|
||||||
import '../models/user_registration_data.dart';
|
import '../models/user_registration_data.dart';
|
||||||
|
import '../models/reprise_dossier.dart';
|
||||||
import '../utils/parent_registration_payload.dart';
|
import '../utils/parent_registration_payload.dart';
|
||||||
import 'api/api_config.dart';
|
import 'api/api_config.dart';
|
||||||
import 'api/tokenService.dart';
|
import 'api/tokenService.dart';
|
||||||
@ -258,6 +259,116 @@ class AuthService {
|
|||||||
throw Exception(msg);
|
throw Exception(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Charge le dossier pour reprise (lien e-mail). GET /auth/reprise-dossier. Ticket #112.
|
||||||
|
static Future<RepriseDossier> 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<String, dynamic>) {
|
||||||
|
throw Exception('Réponse invalide du serveur.');
|
||||||
|
}
|
||||||
|
final payload = decoded['data'] is Map
|
||||||
|
? Map<String, dynamic>.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<String> 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<String, dynamic>) {
|
||||||
|
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<void> resoumettreReprise(Map<String, dynamic> body) async {
|
||||||
|
final cleaned = body['token']?.toString().trim() ?? '';
|
||||||
|
if (cleaned.isEmpty) {
|
||||||
|
throw Exception('Lien invalide ou expiré.');
|
||||||
|
}
|
||||||
|
final payload = Map<String, dynamic>.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
|
/// Déconnexion de l'utilisateur
|
||||||
static Future<void> logout() async {
|
static Future<void> logout() async {
|
||||||
await TokenService.clearAll();
|
await TokenService.clearAll();
|
||||||
|
|||||||
58
frontend/lib/services/reprise_session.dart
Normal file
58
frontend/lib/services/reprise_session.dart
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -149,6 +149,20 @@ class ParentRegistrationPayload {
|
|||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enfant pour PATCH reprise (inclut `id` si connu).
|
||||||
|
static Map<String, dynamic> 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<String, dynamic> _childToJson(ChildData c, int index, String parentNom) {
|
static Map<String, dynamic> _childToJson(ChildData c, int index, String parentNom) {
|
||||||
final map = <String, dynamic>{
|
final map = <String, dynamic>{
|
||||||
'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre',
|
'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre',
|
||||||
|
|||||||
223
frontend/lib/utils/reprise_mapper.dart
Normal file
223
frontend/lib/utils/reprise_mapper.dart
Normal file
@ -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<CardColorVertical> _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}';
|
||||||
|
}
|
||||||
|
}
|
||||||
150
frontend/lib/utils/reprise_payload.dart
Normal file
150
frontend/lib/utils/reprise_payload.dart
Normal file
@ -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<String, dynamic> 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<Map<String, dynamic>> amPatch(
|
||||||
|
AmRegistrationData data,
|
||||||
|
String token, {
|
||||||
|
String? existingPhotoUrl,
|
||||||
|
}) async {
|
||||||
|
final body = <String, dynamic>{'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<Map<String, String>?> _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<String, dynamic> m, String key, String value) {
|
||||||
|
if (value.isNotEmpty) m[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
168
frontend/lib/widgets/auth/reprise_identify_dialog.dart
Normal file
168
frontend/lib/widgets/auth/reprise_identify_dialog.dart
Normal file
@ -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<RepriseIdentifyDialog> createState() => _RepriseIdentifyDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RepriseIdentifyDialogState extends State<RepriseIdentifyDialog> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
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<void> _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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
@ -20,7 +21,7 @@ bool _hasChildPhoto(ChildData c) {
|
|||||||
return registrationPhotoSlotHasImage(
|
return registrationPhotoSlotHasImage(
|
||||||
imageBytes: c.imageBytes,
|
imageBytes: c.imageBytes,
|
||||||
imageFile: c.imageFile,
|
imageFile: c.imageFile,
|
||||||
imagePathOrAsset: null,
|
imagePathOrAsset: c.existingPhotoUrl,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,6 +34,20 @@ Widget _buildChildPhotoImage(ChildData c, {required BoxFit fit}) {
|
|||||||
if (f != null) {
|
if (f != null) {
|
||||||
return kIsWeb ? Image.network(f.path, fit: fit) : Image.file(f, fit: fit);
|
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);
|
return Image.asset('assets/images/photo.png', fit: BoxFit.contain);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -221,6 +236,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
|
|||||||
scaleFactor: scaleFactor,
|
scaleFactor: scaleFactor,
|
||||||
imageBytes: widget.childData.imageBytes,
|
imageBytes: widget.childData.imageBytes,
|
||||||
imageFile: widget.childData.imageFile,
|
imageFile: widget.childData.imageFile,
|
||||||
|
imagePathOrAsset: widget.childData.existingPhotoUrl,
|
||||||
onTapPick: !config.isReadonly ? widget.onPickImage : null,
|
onTapPick: !config.isReadonly ? widget.onPickImage : null,
|
||||||
onClear: !config.isReadonly ? widget.onClearImage : null,
|
onClear: !config.isReadonly ? widget.onClearImage : null,
|
||||||
baseShadowColor: baseCardColorForShadow,
|
baseShadowColor: baseCardColorForShadow,
|
||||||
|
|||||||
@ -134,9 +134,40 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_applyInitialData(widget.initialData);
|
||||||
|
|
||||||
final data = widget.initialData;
|
if (widget.mode == DisplayMode.editable) {
|
||||||
if (data != null) {
|
_birthCityFocus = FocusNode();
|
||||||
|
_birthCountryFocus = FocusNode();
|
||||||
|
_nirFocus = FocusNode();
|
||||||
|
_birthCityFocus!.addListener(_onBirthCityFocusChange);
|
||||||
|
_birthCountryFocus!.addListener(_onBirthCountryFocusChange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
_selectedDate = data.dateOfBirth;
|
||||||
_dateOfBirthController.text = data.dateOfBirth != null
|
_dateOfBirthController.text = data.dateOfBirth != null
|
||||||
? DateFormat('dd/MM/yyyy').format(data.dateOfBirth!)
|
? DateFormat('dd/MM/yyyy').format(data.dateOfBirth!)
|
||||||
@ -159,15 +190,6 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
|||||||
_photoConsent = data.photoConsent;
|
_photoConsent = data.photoConsent;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widget.mode == DisplayMode.editable) {
|
|
||||||
_birthCityFocus = FocusNode();
|
|
||||||
_birthCountryFocus = FocusNode();
|
|
||||||
_nirFocus = FocusNode();
|
|
||||||
_birthCityFocus!.addListener(_onBirthCityFocusChange);
|
|
||||||
_birthCountryFocus!.addListener(_onBirthCountryFocusChange);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onBirthCityFocusChange() {
|
void _onBirthCityFocusChange() {
|
||||||
if (_birthCityFocus == null || _birthCityFocus!.hasFocus) return;
|
if (_birthCityFocus == null || _birthCityFocus!.hasFocus) return;
|
||||||
_applyPlaceNameFormat(_birthCityController);
|
_applyPlaceNameFormat(_birthCityController);
|
||||||
|
|||||||
@ -71,6 +71,9 @@ class RegistrationPhotoSlot extends StatelessWidget {
|
|||||||
if (p.startsWith('assets/')) {
|
if (p.startsWith('assets/')) {
|
||||||
return Image.asset(p, fit: fit);
|
return Image.asset(p, fit: fit);
|
||||||
}
|
}
|
||||||
|
if (p.startsWith('http://') || p.startsWith('https://')) {
|
||||||
|
return Image.network(p, fit: fit);
|
||||||
|
}
|
||||||
if (!kIsWeb) {
|
if (!kIsWeb) {
|
||||||
try {
|
try {
|
||||||
final file = File(p);
|
final file = File(p);
|
||||||
|
|||||||
52
frontend/test/reprise_am_parse_test.dart
Normal file
52
frontend/test/reprise_am_parse_test.dart
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user