From b99745e0fea1a67e8aaafa4ff20822f27e0db71b Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 16 Jun 2026 19:19:55 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(#112):=20reprise=20apr=C3=A8s=20refus?= =?UTF-8?q?=20=E2=80=94=20dossier=20complet,=20email=20resoumission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET/PATCH reprise-dossier enrichis (parents, enfants, motivation, fiche AM) - Front: lien mail, modale identify login, wizards préremplis, PATCH complet - Email accusé resoumission aux parents avec n° de dossier - Fixes préremplissage AM (dates, places, ValueKey étape 2) Co-authored-by: Cursor --- backend/src/modules/mail/mail.service.spec.ts | 17 + backend/src/modules/mail/mail.service.ts | 37 ++ backend/src/routes/auth/auth.controller.ts | 3 +- backend/src/routes/auth/auth.module.ts | 16 +- backend/src/routes/auth/auth.service.spec.ts | 87 ++++- backend/src/routes/auth/auth.service.ts | 361 +++++++++++++++++- .../src/routes/auth/dto/enfant-reprise.dto.ts | 11 + .../routes/auth/dto/reprise-dossier.dto.ts | 49 ++- .../auth/dto/resoumettre-reprise.dto.ts | 162 +++++++- .../dto/dossier-famille-complet.dto.ts | 3 + backend/src/routes/parents/parents.service.ts | 1 + docs/tmp/112-back-reprise-alignement-front.md | 244 ++++++++++++ frontend/lib/config/app_router.dart | 6 + frontend/lib/models/am_registration_data.dart | 55 +++ frontend/lib/models/dossier_unifie.dart | 4 + frontend/lib/models/reprise_dossier.dart | 180 +++++++++ .../lib/models/user_registration_data.dart | 45 +++ .../auth/am_register_step1_screen.dart | 3 +- .../auth/am_register_step2_screen.dart | 84 ++-- .../auth/am_register_step4_screen.dart | 59 ++- frontend/lib/screens/auth/login_screen.dart | 28 ++ .../auth/parent_register_step1_screen.dart | 3 +- .../auth/parent_register_step3_screen.dart | 10 +- .../auth/parent_register_step5_screen.dart | 54 +++ .../screens/auth/reprise_entry_screen.dart | 154 ++++++++ frontend/lib/services/api/api_config.dart | 4 + frontend/lib/services/auth_service.dart | 111 ++++++ frontend/lib/services/reprise_session.dart | 58 +++ .../utils/parent_registration_payload.dart | 14 + frontend/lib/utils/reprise_mapper.dart | 223 +++++++++++ frontend/lib/utils/reprise_payload.dart | 150 ++++++++ .../widgets/auth/reprise_identify_dialog.dart | 168 ++++++++ frontend/lib/widgets/child_card_widget.dart | 18 +- .../professional_info_form_screen.dart | 70 ++-- .../lib/widgets/registration_photo_slot.dart | 3 + frontend/test/reprise_am_parse_test.dart | 52 +++ 36 files changed, 2461 insertions(+), 86 deletions(-) create mode 100644 backend/src/routes/auth/dto/enfant-reprise.dto.ts create mode 100644 docs/tmp/112-back-reprise-alignement-front.md create mode 100644 frontend/lib/models/reprise_dossier.dart create mode 100644 frontend/lib/screens/auth/reprise_entry_screen.dart create mode 100644 frontend/lib/services/reprise_session.dart create mode 100644 frontend/lib/utils/reprise_mapper.dart create mode 100644 frontend/lib/utils/reprise_payload.dart create mode 100644 frontend/lib/widgets/auth/reprise_identify_dialog.dart create mode 100644 frontend/test/reprise_am_parse_test.dart diff --git a/backend/src/modules/mail/mail.service.spec.ts b/backend/src/modules/mail/mail.service.spec.ts index d88d1d8..6d474a6 100644 --- a/backend/src/modules/mail/mail.service.spec.ts +++ b/backend/src/modules/mail/mail.service.spec.ts @@ -98,4 +98,21 @@ describe('MailService (ticket #28)', () => { expect(html).toContain(`https://app.test/reset-password?token=${encodeURIComponent(token)}`); expect(html).not.toContain('create-password'); }); + + it('sendResoumissionPendingEmail — accusé resoumission avec n° dossier', async () => { + await service.sendResoumissionPendingEmail( + 'parent@test.fr', + 'Claire', + 'MARTIN', + '2026-000024', + ); + expect(sendEmailSpy).toHaveBeenCalledTimes(1); + const [to, subject, html] = sendEmailSpy.mock.calls[0]; + expect(to).toBe('parent@test.fr'); + expect(subject).toContain('resoumis'); + expect(subject).toContain('2026-000024'); + expect(html).toContain('Claire'); + expect(html).toContain('2026-000024'); + expect(html).toContain('en attente de validation'); + }); }); diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index 2d96a6e..f063426 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -301,6 +301,43 @@ export class MailService { await this.sendEmail(to, subject, html); } + /** + * Accusé de resoumission après refus (reprise #112) : dossier à nouveau en attente de validation. + */ + async sendResoumissionPendingEmail( + to: string, + prenom: string, + nom: string, + numeroDossier: string, + ): Promise { + const appName = this.configService.get('app_name', "P'titsPas"); + const appUrl = this.configService.get('app_url', 'https://app.ptits-pas.fr'); + + const safe = (s: string) => + (s || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + + const subject = `Votre dossier a été resoumis — ${safe(numeroDossier)}`; + const html = ` +
+

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

+

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

+

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

+

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

+ +
+

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

+
+ `; + + await this.sendEmail(to, subject, html); + } + /** * Email de refus de dossier avec lien reprise (token). * Ticket #110 – Refus sans suppression diff --git a/backend/src/routes/auth/auth.controller.ts b/backend/src/routes/auth/auth.controller.ts index 7e2c6d3..cdc016e 100644 --- a/backend/src/routes/auth/auth.controller.ts +++ b/backend/src/routes/auth/auth.controller.ts @@ -102,8 +102,7 @@ export class AuthController { @ApiResponse({ status: 200, description: 'Dossier resoumis' }) @ApiResponse({ status: 404, description: 'Token invalide ou expiré' }) async resoumettreReprise(@Body() dto: ResoumettreRepriseDto) { - const { token, ...fields } = dto; - return this.authService.resoumettreReprise(token, fields); + return this.authService.resoumettreReprise(dto); } @Public() diff --git a/backend/src/routes/auth/auth.module.ts b/backend/src/routes/auth/auth.module.ts index 34d4631..3ebf0df 100644 --- a/backend/src/routes/auth/auth.module.ts +++ b/backend/src/routes/auth/auth.module.ts @@ -12,11 +12,25 @@ import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entit import { AppConfigModule } from 'src/modules/config'; import { NumeroDossierModule } from 'src/modules/numero-dossier/numero-dossier.module'; import { MailModule } from 'src/modules/mail/mail.module'; +import { ParentsModule } from '../parents/parents.module'; +import { DossiersModule } from '../dossiers/dossiers.module'; +import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity'; +import { ParentsChildren } from 'src/entities/parents_children.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]), + TypeOrmModule.forFeature([ + Users, + Parents, + Children, + AssistanteMaternelle, + DossierFamille, + DossierFamilleEnfant, + ParentsChildren, + ]), forwardRef(() => UserModule), + ParentsModule, + DossiersModule, AppConfigModule, MailModule, NumeroDossierModule, diff --git a/backend/src/routes/auth/auth.service.spec.ts b/backend/src/routes/auth/auth.service.spec.ts index 0361563..e373abd 100644 --- a/backend/src/routes/auth/auth.service.spec.ts +++ b/backend/src/routes/auth/auth.service.spec.ts @@ -5,11 +5,13 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { ConfigService } from '@nestjs/config'; import { AuthService } from './auth.service'; import { UserService } from '../user/user.service'; +import { ParentsService } from '../parents/parents.service'; +import { DossiersService } from '../dossiers/dossiers.service'; import { AppConfigService } from 'src/modules/config/config.service'; import { MailService } from 'src/modules/mail/mail.service'; import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service'; import { Parents } from 'src/entities/parents.entity'; -import { Users } from 'src/entities/users.entity'; +import { Users, RoleType, StatutUtilisateurType } from 'src/entities/users.entity'; import { Children } from 'src/entities/children.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; @@ -19,6 +21,20 @@ describe('AuthService (#118 create-password API)', () => { findOne: jest.fn(), createQueryBuilder: jest.fn(), update: jest.fn(), + manager: { transaction: jest.fn() }, + }; + + const userServiceMock = { + findByTokenReprise: jest.fn(), + }; + + const parentsServiceMock = { + getDossierFamilleByNumero: jest.fn(), + getFamilyUserIds: jest.fn(), + }; + + const dossiersServiceMock = { + getDossierByNumero: jest.fn(), }; const mailServiceMock = { @@ -45,7 +61,9 @@ describe('AuthService (#118 create-password API)', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ AuthService, - { provide: UserService, useValue: {} }, + { provide: UserService, useValue: userServiceMock }, + { provide: ParentsService, useValue: parentsServiceMock }, + { provide: DossiersService, useValue: dossiersServiceMock }, { provide: JwtService, useValue: {} }, { provide: ConfigService, useValue: { get: jest.fn() } }, { provide: AppConfigService, useValue: appConfigMock }, @@ -136,4 +154,69 @@ describe('AuthService (#118 create-password API)', () => { expect(exec).toHaveBeenCalled(); }); }); + + describe('Reprise après refus (#112)', () => { + const token = '11111111-1111-1111-1111-111111111111'; + + it('getRepriseDossier throws when token invalid', async () => { + userServiceMock.findByTokenReprise.mockResolvedValue(null); + await expect(service.getRepriseDossier(token)).rejects.toThrow(NotFoundException); + }); + + it('getRepriseDossier returns famille complète pour parent', async () => { + userServiceMock.findByTokenReprise.mockResolvedValue({ + id: 'p1', + email: 'claire@test.fr', + prenom: 'Claire', + nom: 'MARTIN', + role: RoleType.PARENT, + numero_dossier: '2026-000021', + statut: StatutUtilisateurType.REFUSE, + }); + parentsServiceMock.getDossierFamilleByNumero.mockResolvedValue({ + numero_dossier: '2026-000021', + parents: [{ user_id: 'p1', email: 'claire@test.fr', statut: StatutUtilisateurType.REFUSE }], + enfants: [{ id: 'e1', first_name: 'Emma', status: 'actif' }], + texte_motivation: 'Motivation test', + }); + + const result = await service.getRepriseDossier(token); + expect(result.parents).toHaveLength(1); + expect(result.enfants).toHaveLength(1); + expect(result.texte_motivation).toBe('Motivation test'); + expect(result.numero_dossier).toBe('2026-000021'); + }); + + it('getRepriseDossier returns fiche AM complète', async () => { + userServiceMock.findByTokenReprise.mockResolvedValue({ + id: 'am1', + email: 'am@test.fr', + role: RoleType.ASSISTANTE_MATERNELLE, + numero_dossier: '2026-000003', + }); + dossiersServiceMock.getDossierByNumero.mockResolvedValue({ + type: 'am', + dossier: { + numero_dossier: '2026-000003', + user: { + id: 'am1', + email: 'am@test.fr', + prenom: 'Marie', + nom: 'DUPONT', + statut: StatutUtilisateurType.REFUSE, + }, + numero_agrement: 'AGR-1', + nir: '123456789012345', + biographie: 'Bio', + nb_max_enfants: 4, + place_disponible: 2, + }, + }); + + const result = await service.getRepriseDossier(token); + expect(result.numero_agrement).toBe('AGR-1'); + expect(result.biographie).toBe('Bio'); + expect(result.nb_max_enfants).toBe(4); + }); + }); }); diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index b291009..f78a06e 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -7,7 +7,7 @@ import { BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { QueryFailedError, Repository } from 'typeorm'; +import { EntityManager, In, QueryFailedError, Repository } from 'typeorm'; import { UserService } from '../user/user.service'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; @@ -27,11 +27,15 @@ import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famil import { StatutDossierType } from 'src/entities/dossiers.entity'; import { LoginDto } from './dto/login.dto'; import { RepriseDossierDto } from './dto/reprise-dossier.dto'; +import { ResoumettreRepriseDto } from './dto/resoumettre-reprise.dto'; import { RepriseIdentifyResponseDto } from './dto/reprise-identify.dto'; import { AppConfigService } from 'src/modules/config/config.service'; import { validateNir } from 'src/common/utils/nir.util'; import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service'; import { MailService } from 'src/modules/mail/mail.service'; +import { ParentsService } from '../parents/parents.service'; +import { DossiersService } from '../dossiers/dossiers.service'; +import { DossierAmCompletDto } from '../dossiers/dto/dossier-am-complet.dto'; @Injectable() export class AuthService { @@ -44,6 +48,8 @@ export class AuthService { private readonly appConfigService: AppConfigService, private readonly mailService: MailService, private readonly numeroDossierService: NumeroDossierService, + private readonly parentsService: ParentsService, + private readonly dossiersService: DossiersService, @InjectRepository(Parents) private readonly parentsRepo: Repository, @InjectRepository(Users) @@ -873,12 +879,72 @@ export class AuthService { return { success: true, message: 'Deconnexion'} } - /** GET dossier reprise – token seul. Ticket #111 */ + /** GET dossier reprise – token seul. Ticket #111, enrichi #112 */ async getRepriseDossier(token: string): Promise { const user = await this.usersService.findByTokenReprise(token); if (!user) { throw new NotFoundException('Token reprise invalide ou expiré.'); } + + const base = this.userToRepriseBase(user); + + if (user.role === RoleType.PARENT && user.numero_dossier) { + try { + const famille = await this.parentsService.getDossierFamilleByNumero(user.numero_dossier); + return { + ...base, + parents: famille.parents, + enfants: famille.enfants, + texte_motivation: famille.texte_motivation, + }; + } catch (err) { + if (err instanceof NotFoundException) { + return base; + } + throw err; + } + } + + if (user.role === RoleType.ASSISTANTE_MATERNELLE && user.numero_dossier) { + try { + const unifie = await this.dossiersService.getDossierByNumero(user.numero_dossier); + if (unifie.type === 'am') { + return this.mergeAmRepriseDossier(base, unifie.dossier as DossierAmCompletDto); + } + } catch (err) { + if (err instanceof NotFoundException) { + return base; + } + throw err; + } + } + + return base; + } + + /** PATCH resoumission reprise – dossier complet. Ticket #111, enrichi #112 */ + async resoumettreReprise(dto: ResoumettreRepriseDto): Promise<{ + message: string; + statut: StatutUtilisateurType; + user_id: string; + numero_dossier?: string; + }> { + const user = await this.usersService.findByTokenReprise(dto.token); + if (!user) { + throw new NotFoundException('Token reprise invalide ou expiré.'); + } + + if (user.role === RoleType.PARENT) { + return this.resoumettreRepriseParent(user, dto); + } + if (user.role === RoleType.ASSISTANTE_MATERNELLE) { + return this.resoumettreRepriseAM(user, dto); + } + + throw new BadRequestException('Rôle non pris en charge pour la reprise.'); + } + + private userToRepriseBase(user: Users): RepriseDossierDto { return { id: user.id, email: user.email, @@ -896,12 +962,291 @@ export class AuthService { }; } - /** PUT resoumission reprise. Ticket #111 */ - async resoumettreReprise( - token: string, - dto: { prenom?: string; nom?: string; telephone?: string; adresse?: string; ville?: string; code_postal?: string; photo_url?: string }, - ): Promise { - return this.usersService.resoumettreReprise(token, dto); + private mergeAmRepriseDossier(base: RepriseDossierDto, dossier: DossierAmCompletDto): RepriseDossierDto { + const u = dossier.user; + return { + ...base, + email: u.email, + prenom: u.prenom, + nom: u.nom, + telephone: u.telephone, + adresse: u.adresse, + ville: u.ville, + code_postal: u.code_postal, + photo_url: u.photo_url, + consentement_photo: u.consentement_photo, + date_naissance: u.date_naissance, + lieu_naissance_ville: u.lieu_naissance_ville, + lieu_naissance_pays: u.lieu_naissance_pays, + numero_agrement: dossier.numero_agrement, + nir: dossier.nir, + date_agrement: dossier.date_agrement, + nb_max_enfants: dossier.nb_max_enfants, + place_disponible: dossier.place_disponible, + biographie: dossier.biographie, + }; + } + + private applyTitulaireUserFields(user: Users, dto: ResoumettreRepriseDto): void { + if (dto.prenom !== undefined) user.prenom = dto.prenom; + if (dto.nom !== undefined) user.nom = dto.nom; + if (dto.telephone !== undefined) user.telephone = dto.telephone; + if (dto.adresse !== undefined) user.adresse = dto.adresse; + if (dto.ville !== undefined) user.ville = dto.ville; + if (dto.code_postal !== undefined) user.code_postal = dto.code_postal; + } + + private async resoumettreRepriseParent( + titulaire: Users, + dto: ResoumettreRepriseDto, + ): Promise<{ message: string; statut: StatutUtilisateurType; user_id: string; numero_dossier?: string }> { + const motivation = + dto.texte_motivation?.trim() ?? + dto.presentation_dossier?.trim(); + + const result = await this.usersRepo.manager.transaction(async (manager) => { + const familyUsers = titulaire.numero_dossier + ? await manager.find(Users, { + where: { + role: RoleType.PARENT, + numero_dossier: titulaire.numero_dossier, + }, + }) + : [titulaire]; + + const titulaireLive = familyUsers.find((u) => u.id === titulaire.id) ?? titulaire; + this.applyTitulaireUserFields(titulaireLive, dto); + + const coParentUser = familyUsers.find((u) => u.id !== titulaire.id); + if (coParentUser) { + if (dto.co_parent_prenom !== undefined) coParentUser.prenom = dto.co_parent_prenom; + if (dto.co_parent_nom !== undefined) coParentUser.nom = dto.co_parent_nom; + if (dto.co_parent_telephone !== undefined) coParentUser.telephone = dto.co_parent_telephone; + + if (dto.co_parent_meme_adresse) { + coParentUser.adresse = titulaireLive.adresse; + coParentUser.code_postal = titulaireLive.code_postal; + coParentUser.ville = titulaireLive.ville; + } else { + if (dto.co_parent_adresse !== undefined) coParentUser.adresse = dto.co_parent_adresse; + if (dto.co_parent_code_postal !== undefined) coParentUser.code_postal = dto.co_parent_code_postal; + if (dto.co_parent_ville !== undefined) coParentUser.ville = dto.co_parent_ville; + } + } + + if (titulaire.numero_dossier && motivation !== undefined) { + const dossierFamille = await manager.findOne(DossierFamille, { + where: { numero_dossier: titulaire.numero_dossier }, + }); + if (dossierFamille) { + dossierFamille.presentation = motivation || undefined; + await manager.save(DossierFamille, dossierFamille); + } + } + + if (dto.enfants?.length) { + const familyChildIds = await this.getFamilyChildrenIds(manager, titulaireLive.id); + for (const enfantDto of dto.enfants) { + if (!familyChildIds.has(enfantDto.id)) { + throw new BadRequestException( + `Enfant inconnu pour ce dossier : ${enfantDto.id}`, + ); + } + + const enfant = await manager.findOne(Children, { where: { id: enfantDto.id } }); + if (!enfant) { + throw new BadRequestException(`Enfant introuvable : ${enfantDto.id}`); + } + + if (enfantDto.prenom !== undefined) enfant.first_name = enfantDto.prenom; + if (enfantDto.nom !== undefined) enfant.last_name = enfantDto.nom; + if (enfantDto.genre !== undefined) enfant.gender = enfantDto.genre; + if (enfantDto.date_naissance !== undefined) { + enfant.birth_date = new Date(enfantDto.date_naissance); + enfant.status = StatutEnfantType.ACTIF; + } + if (enfantDto.date_previsionnelle_naissance !== undefined) { + enfant.due_date = new Date(enfantDto.date_previsionnelle_naissance); + if (!enfantDto.date_naissance) { + enfant.status = StatutEnfantType.A_NAITRE; + } + } + if (enfantDto.grossesse_multiple !== undefined) { + enfant.is_multiple = enfantDto.grossesse_multiple; + } + + if (enfantDto.photo_base64 && enfantDto.photo_filename) { + enfant.photo_url = await this.sauvegarderPhotoDepuisBase64( + enfantDto.photo_base64, + enfantDto.photo_filename, + ); + } + + await manager.save(Children, enfant); + } + } + + for (const familyUser of familyUsers) { + familyUser.statut = StatutUtilisateurType.EN_ATTENTE; + familyUser.token_reprise = undefined; + familyUser.token_reprise_expire_le = undefined; + await manager.save(Users, familyUser); + } + + return { + message: 'Dossier resoumis avec succès. Il est de nouveau en attente de validation.', + statut: StatutUtilisateurType.EN_ATTENTE, + user_id: titulaireLive.id, + numero_dossier: titulaireLive.numero_dossier, + parentsPourEmail: familyUsers, + }; + }); + + const numeroDossier = result.numero_dossier ?? ''; + try { + for (const parent of result.parentsPourEmail) { + await this.mailService.sendResoumissionPendingEmail( + parent.email, + parent.prenom ?? '', + parent.nom ?? '', + numeroDossier, + ); + } + } catch (err) { + this.logger.error( + "[resoumettreRepriseParent] Échec envoi email accusé resoumission (dossier conservé)", + err instanceof Error ? err.stack : String(err), + ); + } + + const { parentsPourEmail: _parentsPourEmail, ...response } = result; + return response; + } + + private async resoumettreRepriseAM( + user: Users, + dto: ResoumettreRepriseDto, + ): Promise<{ message: string; statut: StatutUtilisateurType; user_id: string; numero_dossier?: string }> { + if ( + dto.capacite_accueil !== undefined && + dto.places_disponibles !== undefined && + dto.places_disponibles > dto.capacite_accueil + ) { + throw new BadRequestException( + 'Le nombre de places disponibles ne peut pas dépasser la capacité d\'accueil.', + ); + } + + let urlPhoto: string | undefined; + const photoB64 = (dto.photo_base64 ?? '').trim(); + if (photoB64) { + const nomFichier = (dto.photo_filename ?? '').trim() || 'photo_am.jpg'; + urlPhoto = await this.sauvegarderPhotoDepuisBase64(photoB64, nomFichier); + } else if (dto.photo_url !== undefined) { + urlPhoto = dto.photo_url; + } + + if (dto.nir !== undefined) { + const nirNormalized = dto.nir.replace(/\s/g, '').toUpperCase(); + const nirValidation = validateNir(nirNormalized, { + dateNaissance: dto.date_naissance ?? user.date_naissance?.toISOString().slice(0, 10), + }); + if (!nirValidation.valid) { + throw new BadRequestException(nirValidation.error || 'NIR invalide'); + } + } + + return this.usersRepo.manager.transaction(async (manager) => { + const userLive = await manager.findOne(Users, { where: { id: user.id } }); + if (!userLive) { + throw new NotFoundException('Utilisateur introuvable.'); + } + + this.applyTitulaireUserFields(userLive, dto); + if (urlPhoto !== undefined) userLive.photo_url = urlPhoto; + if (dto.consentement_photo !== undefined) { + userLive.consentement_photo = dto.consentement_photo; + if (dto.consentement_photo) { + userLive.date_consentement_photo = new Date(); + } + } + if (dto.date_naissance !== undefined) { + userLive.date_naissance = new Date(dto.date_naissance); + } + if (dto.lieu_naissance_ville !== undefined) { + userLive.lieu_naissance_ville = dto.lieu_naissance_ville; + } + if (dto.lieu_naissance_pays !== undefined) { + userLive.lieu_naissance_pays = dto.lieu_naissance_pays; + } + + const am = await manager.findOne(AssistanteMaternelle, { where: { user_id: user.id } }); + if (!am) { + throw new NotFoundException('Fiche assistante maternelle introuvable.'); + } + + if (dto.nir !== undefined) { + const nirNormalized = dto.nir.replace(/\s/g, '').toUpperCase(); + const nirDejaUtilise = await manager.findOne(AssistanteMaternelle, { + where: { nir: nirNormalized }, + }); + if (nirDejaUtilise && nirDejaUtilise.user_id !== user.id) { + throw new ConflictException( + 'Un compte assistante maternelle avec ce numéro NIR existe déjà.', + ); + } + am.nir = nirNormalized; + } + if (dto.numero_agrement !== undefined) { + const agrement = dto.numero_agrement.trim(); + const agrementDejaUtilise = await manager.findOne(AssistanteMaternelle, { + where: { approval_number: agrement }, + }); + if (agrementDejaUtilise && agrementDejaUtilise.user_id !== user.id) { + throw new ConflictException( + 'Un compte assistante maternelle avec ce numéro d\'agrément existe déjà.', + ); + } + am.approval_number = agrement; + } + if (dto.date_agrement !== undefined) { + am.agreement_date = new Date(dto.date_agrement); + } + if (dto.capacite_accueil !== undefined) { + am.max_children = dto.capacite_accueil; + } + if (dto.places_disponibles !== undefined) { + am.places_available = dto.places_disponibles; + } + if (dto.biographie !== undefined) { + am.biography = dto.biographie; + } + + userLive.statut = StatutUtilisateurType.EN_ATTENTE; + userLive.token_reprise = undefined; + userLive.token_reprise_expire_le = undefined; + + await manager.save(AssistanteMaternelle, am); + await manager.save(Users, userLive); + + return { + message: 'Dossier resoumis avec succès. Il est de nouveau en attente de validation.', + statut: StatutUtilisateurType.EN_ATTENTE, + user_id: userLive.id, + numero_dossier: userLive.numero_dossier, + }; + }); + } + + private async getFamilyChildrenIds( + manager: EntityManager, + parentUserId: string, + ): Promise> { + const familyUserIds = await this.parentsService.getFamilyUserIds(parentUserId); + const links = await manager.find(ParentsChildren, { + where: { parentId: In(familyUserIds) }, + }); + return new Set(links.map((l) => l.enfantId)); } /** POST reprise-identify : numero_dossier + email → type + token. Ticket #111 */ diff --git a/backend/src/routes/auth/dto/enfant-reprise.dto.ts b/backend/src/routes/auth/dto/enfant-reprise.dto.ts new file mode 100644 index 0000000..fa94060 --- /dev/null +++ b/backend/src/routes/auth/dto/enfant-reprise.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsUUID } from 'class-validator'; +import { EnfantInscriptionDto } from './enfant-inscription.dto'; + +/** Enfant existant modifiable lors de la resoumission reprise (#112). */ +export class EnfantRepriseDto extends EnfantInscriptionDto { + @ApiProperty({ description: 'UUID enfant existant (obligatoire en reprise)' }) + @IsUUID() + @IsNotEmpty() + id: string; +} diff --git a/backend/src/routes/auth/dto/reprise-dossier.dto.ts b/backend/src/routes/auth/dto/reprise-dossier.dto.ts index e81c6e4..308a1dd 100644 --- a/backend/src/routes/auth/dto/reprise-dossier.dto.ts +++ b/backend/src/routes/auth/dto/reprise-dossier.dto.ts @@ -1,7 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; import { RoleType } from 'src/entities/users.entity'; +import { + DossierFamilleEnfantDto, + DossierFamilleParentDto, +} from '../../parents/dto/dossier-famille-complet.dto'; -/** Réponse GET /auth/reprise-dossier – données dossier pour préremplir le formulaire reprise. Ticket #111 */ +/** Réponse GET /auth/reprise-dossier – dossier complet pour préremplir le wizard reprise. #111 + #112 */ export class RepriseDossierDto { @ApiProperty() id: string; @@ -41,4 +45,47 @@ export class RepriseDossierDto { @ApiProperty({ required: false }) situation_familiale?: string; + + // --- Parent (dossier famille, aligné #119) --- + + @ApiProperty({ type: [DossierFamilleParentDto], required: false }) + parents?: DossierFamilleParentDto[]; + + @ApiProperty({ type: [DossierFamilleEnfantDto], required: false }) + enfants?: DossierFamilleEnfantDto[]; + + @ApiProperty({ required: false, description: 'Motivation / présentation dossier famille' }) + texte_motivation?: string; + + // --- AM (aligné DossierAmCompletDto) --- + + @ApiProperty({ required: false }) + consentement_photo?: boolean; + + @ApiProperty({ required: false }) + date_naissance?: Date; + + @ApiProperty({ required: false }) + lieu_naissance_ville?: string; + + @ApiProperty({ required: false }) + lieu_naissance_pays?: string; + + @ApiProperty({ required: false }) + numero_agrement?: string; + + @ApiProperty({ required: false }) + nir?: string; + + @ApiProperty({ required: false }) + date_agrement?: Date; + + @ApiProperty({ required: false }) + nb_max_enfants?: number; + + @ApiProperty({ required: false }) + place_disponible?: number; + + @ApiProperty({ required: false }) + biographie?: string; } diff --git a/backend/src/routes/auth/dto/resoumettre-reprise.dto.ts b/backend/src/routes/auth/dto/resoumettre-reprise.dto.ts index efec456..046f5f5 100644 --- a/backend/src/routes/auth/dto/resoumettre-reprise.dto.ts +++ b/backend/src/routes/auth/dto/resoumettre-reprise.dto.ts @@ -1,12 +1,29 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsOptional, IsString, MaxLength, IsUUID } from 'class-validator'; +import { + IsOptional, + IsString, + MaxLength, + IsUUID, + IsBoolean, + IsArray, + ValidateNested, + IsInt, + Min, + Max, + IsDateString, + Matches, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { EnfantRepriseDto } from './enfant-reprise.dto'; -/** Body PUT /auth/reprise-resoumettre – token + champs modifiables. Ticket #111 */ +/** Body PATCH /auth/reprise-resoumettre – token + champs modifiables du wizard. #111 + #112 */ export class ResoumettreRepriseDto { @ApiProperty({ description: 'Token reprise (reçu par email)' }) @IsUUID() token: string; + // --- Identité titulaire (parent principal ou AM) --- + @ApiProperty({ required: false }) @IsOptional() @IsString() @@ -42,8 +59,147 @@ export class ResoumettreRepriseDto { @MaxLength(10) code_postal?: string; - @ApiProperty({ required: false, description: 'Pour AM' }) + @ApiProperty({ required: false, description: 'Pour AM (URL photo existante)' }) @IsOptional() @IsString() photo_url?: string; + + @ApiProperty({ required: false, description: 'Pour AM (nouvelle photo base64)' }) + @IsOptional() + @IsString() + photo_base64?: string; + + @ApiProperty({ required: false, description: 'Pour AM (nom fichier photo)' }) + @IsOptional() + @IsString() + photo_filename?: string; + + // --- Co-parent (reprise parent) --- + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + co_parent_email?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + co_parent_prenom?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + co_parent_nom?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @Matches(/^(\+33|0)[1-9](\d{2}){4}$/, { + message: 'Le numéro de téléphone du co-parent doit être valide', + }) + co_parent_telephone?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + co_parent_meme_adresse?: boolean; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + co_parent_adresse?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + co_parent_code_postal?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + co_parent_ville?: string; + + // --- Motivation dossier famille --- + + @ApiProperty({ required: false, description: 'Alias texte_motivation' }) + @IsOptional() + @IsString() + @MaxLength(2000) + texte_motivation?: string; + + @ApiProperty({ required: false, description: 'Alias presentation_dossier (inscription)' }) + @IsOptional() + @IsString() + @MaxLength(2000) + presentation_dossier?: string; + + @ApiProperty({ type: [EnfantRepriseDto], required: false }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => EnfantRepriseDto) + enfants?: EnfantRepriseDto[]; + + // --- AM --- + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + consentement_photo?: boolean; + + @ApiProperty({ required: false }) + @IsOptional() + @IsDateString() + date_naissance?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(100) + lieu_naissance_ville?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(100) + lieu_naissance_pays?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(50) + numero_agrement?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @Matches(/^[1-3]\d{4}(?:2A|2B|\d{2})\d{6}\d{2}$/, { + message: 'Le NIR doit contenir 15 caractères (chiffres, ou 2A/2B pour la Corse)', + }) + nir?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsDateString() + date_agrement?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsInt() + @Min(1) + @Max(10) + capacite_accueil?: number; + + @ApiProperty({ required: false }) + @IsOptional() + @IsInt() + @Min(0) + @Max(10) + places_disponibles?: number; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(2000) + biographie?: string; } diff --git a/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts b/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts index d051b0d..64b565c 100644 --- a/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts +++ b/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts @@ -54,6 +54,9 @@ export class DossierFamilleEnfantDto { description: 'Consentement affichage photo (colonne consentement_photo)', }) consent_photo?: boolean; + + @ApiProperty({ required: false, description: 'Grossesse multiple (est_multiple)' }) + est_multiple?: boolean; } /** Réponse GET /parents/dossier-famille/:numeroDossier – dossier famille complet. Ticket #119 */ diff --git a/backend/src/routes/parents/parents.service.ts b/backend/src/routes/parents/parents.service.ts index ae9bedd..2e06914 100644 --- a/backend/src/routes/parents/parents.service.ts +++ b/backend/src/routes/parents/parents.service.ts @@ -221,6 +221,7 @@ export class ParentsService { status: child.status, photo_url: child.photo_url ?? undefined, consent_photo: child.consent_photo, + est_multiple: child.is_multiple, }; } diff --git a/docs/tmp/112-back-reprise-alignement-front.md b/docs/tmp/112-back-reprise-alignement-front.md new file mode 100644 index 0000000..65eff55 --- /dev/null +++ b/docs/tmp/112-back-reprise-alignement-front.md @@ -0,0 +1,244 @@ +# #112 — Alignement front après évolution back (reprise dossier complet) + +**Branche déployée :** `feature/112-reprise-apres-refus-front` +**Commit back :** `d70577b1` — `feat(#112): reprise après refus — dossier complet GET/PATCH` +**Date :** 2026-06-16 + +Ce document décrit le **contrat API réel** après extension du back, et ce que le front doit encore brancher pour exploiter le dossier complet (au-delà de l’identité seule). + +--- + +## 1. Endpoints (inchangés côté URL) + +| Méthode | Route | Auth | +|---------|-------|------| +| `GET` | `/api/v1/auth/reprise-dossier?token={uuid}` | Public | +| `PATCH` | `/api/v1/auth/reprise-resoumettre` | Public | +| `POST` | `/api/v1/auth/reprise-identify` | Public (inchangé) | + +> **Note :** le ticket #111 parlait de `PUT` ; l’implémentation reste en **`PATCH`** (comme avant). + +--- + +## 2. `GET /auth/reprise-dossier` — réponse enrichie + +### Champs communs (toujours présents) + +Identiques à avant : `id`, `email`, `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal`, `numero_dossier`, `role`, `photo_url`, `genre`, `situation_familiale`. + +### Rôle `parent` (+ champs #119) + +Alignés sur `DossierFamilleCompletDto` : + +```json +{ + "parents": [ + { + "user_id": "uuid", + "email": "…", + "prenom": "…", + "nom": "…", + "telephone": "…", + "adresse": "…", + "ville": "…", + "code_postal": "…", + "statut": "refuse", + "co_parent_id": "uuid-parent-entity" + } + ], + "enfants": [ + { + "id": "uuid-enfant", + "first_name": "Emma", + "last_name": "MARTIN", + "genre": "F", + "status": "actif", + "birth_date": "2023-02-15T00:00:00.000Z", + "due_date": null, + "photo_url": "/uploads/photos/…", + "consent_photo": true, + "est_multiple": false + } + ], + "texte_motivation": "Nous recherchons…" +} +``` + +**Mapping front suggéré :** + +| JSON back | Modèle / wizard parent | +|-----------|-------------------------| +| `parents[]` | `UserRegistrationData.parent1` + `parent2` (matcher par `email` ou ordre : titulaire = `id` du GET racine) | +| `enfants[].first_name` / `last_name` | `ChildData.firstName` / `lastName` | +| `enfants[].birth_date` | `ChildData.birthDate` (ISO → `DateTime`) | +| `enfants[].due_date` | `ChildData.dueDate` (enfant `a_naitre`) | +| `enfants[].status` | `actif` = né, `a_naitre` = à naître | +| `enfants[].photo_url` | `ApiConfig.absoluteMediaUrl()` + conserver pour reprise sans re-upload | +| `enfants[].id` | **Obligatoire** pour le PATCH (update par id) | +| `enfants[].est_multiple` | `grossesse_multiple` si utilisé | +| `texte_motivation` | étape présentation / motivation | + +Si `numero_dossier` absent : pas de `parents[]` / `enfants[]` / `texte_motivation` (identité seule). + +### Rôle `assistante_maternelle` + +Champs racine + fiche pro (structure **aplatie**, pas de sous-objet `user`) : + +```json +{ + "consentement_photo": true, + "date_naissance": "1985-03-12T00:00:00.000Z", + "lieu_naissance_ville": "Paris", + "lieu_naissance_pays": "France", + "numero_agrement": "AGR-2024-12345", + "nir": "123456789012345", + "date_agrement": "2024-06-01T00:00:00.000Z", + "nb_max_enfants": 4, + "place_disponible": 2, + "biographie": "…" +} +``` + +**Mapping `AmRegistrationData` :** + +| JSON back | Champ front | +|-----------|-------------| +| `nb_max_enfants` | `capaciteAccueil` | +| `place_disponible` | `placesDisponibles` | +| `numero_agrement` | `numeroAgrement` | +| `biographie` | `biographie` / présentation | +| `photo_url` | déjà géré via `RepriseSession.photoUrl` | + +--- + +## 3. `PATCH /auth/reprise-resoumettre` — body étendu + +### Commun + +```json +{ "token": "uuid-reprise" } +``` + +### Parent — champs à envoyer depuis le wizard + +| Champ PATCH | Source wizard | Notes | +|-------------|---------------|-------| +| `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal` | Parent 1 (titulaire token) | Champs racine | +| `co_parent_prenom`, `co_parent_nom`, `co_parent_telephone` | Parent 2 | | +| `co_parent_meme_adresse`, `co_parent_adresse`, `co_parent_code_postal`, `co_parent_ville` | Parent 2 adresse | | +| `texte_motivation` **ou** `presentation_dossier` | Étape motivation | Les deux alias acceptés | +| `enfants[]` | Liste enfants | Voir ci-dessous | + +**Structure `enfants[]` (miroir inscription + `id` obligatoire) :** + +```json +{ + "id": "uuid-enfant-existant", + "prenom": "Emma", + "nom": "MARTIN", + "date_naissance": "2023-02-15", + "date_previsionnelle_naissance": null, + "genre": "F", + "photo_base64": "data:image/jpeg;base64,…", + "photo_filename": "emma.jpg", + "grossesse_multiple": false +} +``` + +- **v1 back :** update par `id` uniquement — pas de création/suppression d’enfant. +- Si `id` inconnu pour ce dossier → **400** `Enfant inconnu pour ce dossier : {id}`. +- Sans nouvelle photo : ne pas envoyer `photo_base64` (l’existant est conservé). + +### AM — champs à envoyer + +| Champ PATCH | Source | +|-------------|--------| +| Identité + `photo_url` ou `photo_base64` + `photo_filename` | Étapes 1–2 | +| `consentement_photo`, `date_naissance`, `lieu_naissance_ville`, `lieu_naissance_pays` | Identité | +| `numero_agrement`, `nir`, `date_agrement` | Pro | +| `capacite_accueil`, `places_disponibles` | Pro | +| `biographie` | Présentation | + +Validation NIR identique à l’inscription si `nir` fourni. + +### Réponse succès (nouveau format) + +```json +{ + "message": "Dossier resoumis avec succès. Il est de nouveau en attente de validation.", + "statut": "en_attente", + "user_id": "uuid", + "numero_dossier": "2026-000021" +} +``` + +Code HTTP : **200** (pas de corps `Users` brut comme l’ancien back). + +### Effet métier + +- **Parent :** tous les users `role=parent` avec le même `numero_dossier` passent en `en_attente` ; `token_reprise` invalidé sur **tous** (symétrique refus #110). +- **AM :** un seul user. + +### E-mail accusé resoumission (parent) + +Après `PATCH` réussi, un e-mail est envoyé à **chaque parent** du dossier (`sendResoumissionPendingEmail`) : +- confirmation de resoumission ; +- rappel du **numéro de dossier** ; +- mention « en attente de validation ». + +Échec SMTP : logué, **ne bloque pas** la resoumission (même règle que l'inscription initiale). + +--- + +## 4. Fichiers front à modifier (checklist) + +### Modèles + +- [ ] `lib/models/reprise_dossier.dart` — parser `parents[]`, `enfants[]`, `texte_motivation`, champs AM +- [ ] Réutiliser ou mapper vers `DossierFamilleEnfant` / structures existantes (#119 admin) si possible + +### Session / préremplissage + +- [ ] `lib/services/reprise_session.dart` + - `applyToParent` : remplir parent1/parent2 depuis `parents[]`, enfants, motivation + - `applyToAm` : remplir tous les champs AM + +### API + +- [ ] `lib/services/auth_service.dart` — `resoumettreReprise()` : accepter body complet (parent + AM), pas seulement identité +- [ ] Étendre `UserRegistrationData` / `AmRegistrationData` helpers `toReprisePatchBody()` si utile + +### Écrans fin de parcours + +- [ ] `parent_register_step5_screen.dart` — PATCH avec co-parent, enfants, motivation +- [ ] `am_register_step4_screen.dart` — PATCH avec fiche AM complète + +### Hors scope back (inchangé) + +RIB / IBAN / attestation CAF (étape 5 wizard parent) : **non persistés** — rien à envoyer en reprise. + +### Non implémenté front (ticket #112 initial) + +- [ ] Modale login « J’ai un numéro de dossier » → `POST /auth/reprise-identify` (back prêt, front absent) + +--- + +## 5. Tests manuels suggérés + +1. Refuser un dossier parent complet (≥1 enfant + co-parent + motivation). +2. Ouvrir le lien mail `/reprise?token=…`. +3. Vérifier dans DevTools que le GET contient `enfants[]` et `texte_motivation`. +4. Après branchement front : wizard prérempli sur toutes les étapes. +5. Resoumettre → statut `en_attente` pour les deux parents ; dossier visible file validation admin (#119). + +--- + +## 6. Références code back + +``` +backend/src/routes/auth/dto/reprise-dossier.dto.ts +backend/src/routes/auth/dto/resoumettre-reprise.dto.ts +backend/src/routes/auth/dto/enfant-reprise.dto.ts +backend/src/routes/auth/auth.service.ts → getRepriseDossier, resoumettreReprise +backend/src/routes/parents/dto/dossier-famille-complet.dto.ts +``` diff --git a/frontend/lib/config/app_router.dart b/frontend/lib/config/app_router.dart index 4dfe66e..693c424 100644 --- a/frontend/lib/config/app_router.dart +++ b/frontend/lib/config/app_router.dart @@ -21,6 +21,7 @@ import '../screens/auth/am_register_step4_screen.dart'; import '../screens/auth/create_password_screen.dart'; import '../screens/auth/forgot_password_screen.dart'; import '../screens/auth/reset_password_screen.dart'; +import '../screens/auth/reprise_entry_screen.dart'; import '../screens/home/home_screen.dart'; import '../screens/administrateurs/admin_dashboardScreen.dart'; import '../screens/gestionnaire/gestionnaire_dashboard_screen.dart'; @@ -66,6 +67,11 @@ class AppRouter { builder: (BuildContext context, GoRouterState state) => ResetPasswordScreen(token: state.uri.queryParameters['token']), ), + GoRoute( + path: '/reprise', + builder: (BuildContext context, GoRouterState state) => + RepriseEntryScreen(token: state.uri.queryParameters['token']), + ), GoRoute( path: '/home', builder: (BuildContext context, GoRouterState state) => const HomeScreen(), diff --git a/frontend/lib/models/am_registration_data.dart b/frontend/lib/models/am_registration_data.dart index 44604df..1de1aef 100644 --- a/frontend/lib/models/am_registration_data.dart +++ b/frontend/lib/models/am_registration_data.dart @@ -33,6 +33,9 @@ class AmRegistrationData extends ChangeNotifier { /// Places libres actuellement (0 ≤ valeur ≤ capacité) — API `places_disponibles`. int? placesAvailable; + /// Photo déjà en base lors d'une reprise (#112) — sert pour l'affichage et `photo_url` API. + String? repriseExistingPhotoUrl; + // Step 3: Presentation & CGU String presentationText = ''; bool cguAccepted = false; @@ -104,6 +107,53 @@ class AmRegistrationData extends ChangeNotifier { notifyListeners(); } + /// Reprise après refus (#112). + void resetForReprise({ + required String firstName, + required String lastName, + required String phone, + required String email, + required String streetAddress, + required String postalCode, + required String city, + String? existingPhotoUrl, + bool consentementPhoto = false, + DateTime? dateOfBirth, + String birthCity = '', + String birthCountry = '', + String nir = '', + String agrementNumber = '', + DateTime? agreementDate, + int? capacity, + int? placesAvailable, + String presentationText = '', + }) { + this.firstName = firstName; + this.lastName = lastName; + this.phone = phone; + this.email = email; + this.streetAddress = streetAddress; + this.postalCode = postalCode; + this.city = city; + password = ''; + repriseExistingPhotoUrl = existingPhotoUrl; + photoPath = existingPhotoUrl; + photoBytes = null; + photoFilename = null; + photoConsent = consentementPhoto; + this.dateOfBirth = dateOfBirth; + this.birthCity = birthCity; + this.birthCountry = birthCountry; + this.nir = nir; + this.agrementNumber = agrementNumber; + this.agreementDate = agreementDate; + this.capacity = capacity; + this.placesAvailable = placesAvailable; + this.presentationText = presentationText; + cguAccepted = false; + notifyListeners(); + } + // --- Getters for validation or display --- bool get isStep1Complete => firstName.trim().length >= 2 && @@ -118,6 +168,8 @@ class AmRegistrationData extends ChangeNotifier { /// Photo réelle (pas seulement un placeholder asset). bool get _hasUserPhoto => (photoBytes != null && photoBytes!.isNotEmpty) || + (repriseExistingPhotoUrl != null && + repriseExistingPhotoUrl!.trim().isNotEmpty) || (photoPath != null && photoPath!.isNotEmpty && !photoPath!.startsWith('assets/')); @@ -145,6 +197,9 @@ class AmRegistrationData extends ChangeNotifier { bool get isRegistrationComplete => isStep1Complete && isStep2Complete && isStep3Complete; + /// Reprise (#112) : dossier AM complet renvoyé par PATCH reprise-resoumettre. + bool get isRepriseSubmitReady => isRegistrationComplete; + @override String toString() { return 'AmRegistrationData(' diff --git a/frontend/lib/models/dossier_unifie.dart b/frontend/lib/models/dossier_unifie.dart index 98cde26..07b2d56 100644 --- a/frontend/lib/models/dossier_unifie.dart +++ b/frontend/lib/models/dossier_unifie.dart @@ -184,6 +184,7 @@ class EnfantDossier { final String? dueDate; final String? photoUrl; final bool consentPhoto; + final bool estMultiple; EnfantDossier({ required this.id, @@ -195,6 +196,7 @@ class EnfantDossier { this.dueDate, this.photoUrl, this.consentPhoto = false, + this.estMultiple = false, }); String get fullName => '${firstName ?? ''} ${lastName ?? ''}'.trim(); @@ -223,6 +225,8 @@ class EnfantDossier { photoUrl: resolvedPhoto, consentPhoto: json['consent_photo'] == true || json['consentPhoto'] == true, + estMultiple: + json['est_multiple'] == true || json['estMultiple'] == true, ); } } diff --git a/frontend/lib/models/reprise_dossier.dart b/frontend/lib/models/reprise_dossier.dart new file mode 100644 index 0000000..fe425ca --- /dev/null +++ b/frontend/lib/models/reprise_dossier.dart @@ -0,0 +1,180 @@ +import 'package:p_tits_pas/models/dossier_unifie.dart'; +import 'package:p_tits_pas/utils/reprise_mapper.dart'; + +/// Réponse GET /auth/reprise-dossier. Tickets #111, #112. +class RepriseDossier { + final String id; + final String email; + final String? prenom; + final String? nom; + final String? telephone; + final String? adresse; + final String? ville; + final String? codePostal; + final String? numeroDossier; + final String role; + final String? photoUrl; + final String? genre; + final String? situationFamiliale; + + final List parents; + final List enfants; + final String? texteMotivation; + + final bool consentementPhoto; + final String? dateNaissance; + final String? lieuNaissanceVille; + final String? lieuNaissancePays; + final String? numeroAgrement; + final String? nir; + final String? dateAgrement; + final int? nbMaxEnfants; + final int? placeDisponible; + final String? biographie; + + const RepriseDossier({ + required this.id, + required this.email, + this.prenom, + this.nom, + this.telephone, + this.adresse, + this.ville, + this.codePostal, + this.numeroDossier, + required this.role, + this.photoUrl, + this.genre, + this.situationFamiliale, + this.parents = const [], + this.enfants = const [], + this.texteMotivation, + this.consentementPhoto = false, + this.dateNaissance, + this.lieuNaissanceVille, + this.lieuNaissancePays, + this.numeroAgrement, + this.nir, + this.dateAgrement, + this.nbMaxEnfants, + this.placeDisponible, + this.biographie, + }); + + bool get isParent => role == 'parent'; + bool get isAm => role == 'assistante_maternelle'; + + static Map? _nestedMap(dynamic value) { + if (value is Map) return Map.from(value); + return null; + } + + static dynamic _firstNonNull(List values) { + for (final v in values) { + if (v != null) return v; + } + return null; + } + + factory RepriseDossier.fromJson(Map json) { + final nestedUser = _nestedMap(json['user']); + final nestedDossier = _nestedMap(json['dossier']); + + final parentsRaw = json['parents']; + final parentsList = parentsRaw is List + ? parentsRaw + .where((e) => e is Map) + .map((e) => ParentDossier.fromJson(Map.from(e as Map))) + .toList() + : []; + + final enfantsRaw = json['enfants']; + final enfantsList = enfantsRaw is List + ? enfantsRaw + .where((e) => e is Map) + .map((e) => EnfantDossier.fromJson(Map.from(e as Map))) + .toList() + : []; + + return RepriseDossier( + id: json['id']?.toString() ?? '', + email: json['email']?.toString() ?? '', + prenom: json['prenom']?.toString(), + nom: json['nom']?.toString(), + telephone: json['telephone']?.toString(), + adresse: json['adresse']?.toString(), + ville: json['ville']?.toString(), + codePostal: json['code_postal']?.toString(), + numeroDossier: json['numero_dossier']?.toString(), + role: json['role']?.toString() ?? '', + photoUrl: json['photo_url']?.toString(), + genre: json['genre']?.toString(), + situationFamiliale: json['situation_familiale']?.toString(), + parents: parentsList, + enfants: enfantsList, + texteMotivation: (json['texte_motivation'] ?? json['presentation_dossier']) + ?.toString(), + consentementPhoto: RepriseMapper.optionalBool(json['consentement_photo']) || + RepriseMapper.optionalBool(json['consent_photo']) || + RepriseMapper.optionalBool(nestedUser?['consentement_photo']), + dateNaissance: RepriseMapper.optionalDateString( + _firstNonNull([ + json['date_naissance'], + json['dateNaissance'], + nestedUser?['date_naissance'], + nestedUser?['dateNaissance'], + ]), + ), + lieuNaissanceVille: _firstNonNull([ + json['lieu_naissance_ville'], + json['lieuNaissanceVille'], + nestedUser?['lieu_naissance_ville'], + nestedUser?['lieuNaissanceVille'], + ])?.toString(), + lieuNaissancePays: _firstNonNull([ + json['lieu_naissance_pays'], + json['lieuNaissancePays'], + nestedUser?['lieu_naissance_pays'], + nestedUser?['lieuNaissancePays'], + ])?.toString(), + numeroAgrement: _firstNonNull([ + json['numero_agrement'], + json['numero_agrement_am'], + json['numeroAgrement'], + nestedDossier?['numero_agrement'], + nestedDossier?['numeroAgrement'], + ])?.toString(), + nir: _firstNonNull([ + json['nir'], + nestedDossier?['nir'], + ])?.toString(), + dateAgrement: RepriseMapper.optionalDateString( + _firstNonNull([ + json['date_agrement'], + json['dateAgrement'], + nestedDossier?['date_agrement'], + nestedDossier?['dateAgrement'], + ]), + ), + nbMaxEnfants: RepriseMapper.optionalInt( + _firstNonNull([ + json['nb_max_enfants'], + json['capacite_accueil'], + json['nbMaxEnfants'], + nestedDossier?['nb_max_enfants'], + nestedDossier?['capacite_accueil'], + ]), + ), + placeDisponible: RepriseMapper.optionalInt( + _firstNonNull([ + json['place_disponible'], + json['places_disponibles'], + json['placesDisponibles'], + nestedDossier?['place_disponible'], + nestedDossier?['places_disponibles'], + ]), + ), + biographie: (json['biographie'] ?? json['presentation'])?.toString(), + ); + } +} diff --git a/frontend/lib/models/user_registration_data.dart b/frontend/lib/models/user_registration_data.dart index 5e8892f..bedd247 100644 --- a/frontend/lib/models/user_registration_data.dart +++ b/frontend/lib/models/user_registration_data.dart @@ -30,6 +30,7 @@ class ParentData { class ChildData { static const Object _unsetImage = Object(); static const Object _unsetImageBytes = Object(); + static const Object _unsetExistingPhotoUrl = Object(); String firstName; String lastName; @@ -43,6 +44,10 @@ class ChildData { /// Octets de la photo (fiable à l’envoi API ; [imageFile] peut être absent sur le web). Uint8List? imageBytes; CardColorVertical cardColor; // Nouveau champ pour la couleur de la carte + /// UUID enfant en base (reprise #112) — requis pour PATCH reprise-resoumettre. + String? repriseChildId; + /// Photo déjà stockée (affichage reprise sans re-upload). + String? existingPhotoUrl; ChildData({ this.firstName = '', @@ -55,6 +60,8 @@ class ChildData { this.imageFile, this.imageBytes, required this.cardColor, // Rendre requis dans le constructeur + this.repriseChildId, + this.existingPhotoUrl, }); ChildData copyWith({ @@ -68,6 +75,8 @@ class ChildData { Object? imageFile = _unsetImage, Object? imageBytes = _unsetImageBytes, CardColorVertical? cardColor, + String? repriseChildId, + Object? existingPhotoUrl = _unsetExistingPhotoUrl, }) { return ChildData( firstName: firstName ?? this.firstName, @@ -81,6 +90,10 @@ class ChildData { imageBytes: identical(imageBytes, _unsetImageBytes) ? this.imageBytes : imageBytes as Uint8List?, cardColor: cardColor ?? this.cardColor, + repriseChildId: repriseChildId ?? this.repriseChildId, + existingPhotoUrl: identical(existingPhotoUrl, _unsetExistingPhotoUrl) + ? this.existingPhotoUrl + : existingPhotoUrl as String?, ); } } @@ -178,6 +191,38 @@ class UserRegistrationData extends ChangeNotifier { notifyListeners(); } + /// Reprise après refus (#112) : réinitialise le flux parent avec les données dossier. + void resetForReprise({ + required ParentData parent1Data, + ParentData? parent2Data, + List? childrenData, + String motivation = '', + }) { + parent1 = parent1Data; + parent2 = parent2Data; + children + ..clear() + ..addAll(childrenData ?? const []); + motivationText = motivation; + cguAccepted = false; + bankDetails = null; + attestationCafNumber = ''; + consentQuotientFamilial = false; + notifyListeners(); + } + + /// Reprise (#112) : coordonnées + enfants connus + CGU. + bool get isRepriseSubmitReady => + parent1.firstName.isNotEmpty && + parent1.lastName.isNotEmpty && + parent1.email.isNotEmpty && + children.isNotEmpty && + children.every( + (c) => + c.repriseChildId != null && c.repriseChildId!.trim().isNotEmpty, + ) && + cguAccepted; + // Méthode pour vérifier si toutes les données requises sont là (simplifié) bool isRegistrationComplete() { // Ajouter ici les validations nécessaires diff --git a/frontend/lib/screens/auth/am_register_step1_screen.dart b/frontend/lib/screens/auth/am_register_step1_screen.dart index 66129e6..1c5ca0f 100644 --- a/frontend/lib/screens/auth/am_register_step1_screen.dart +++ b/frontend/lib/screens/auth/am_register_step1_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import '../../models/am_registration_data.dart'; import '../../widgets/personal_info_form_screen.dart'; +import '../../services/reprise_session.dart'; import '../../models/card_assets.dart'; class AmRegisterStep1Screen extends StatelessWidget { @@ -29,7 +30,7 @@ class AmRegisterStep1Screen extends StatelessWidget { cardColor: CardColorHorizontal.blue, initialData: initialData, minPersonNameLength: 2, - previousRoute: '/register-choice', + previousRoute: RepriseSession.isActive ? '/login' : '/register-choice', onSubmit: (data, {hasSecondPerson, sameAddress}) { registrationData.updateIdentityInfo( firstName: data.firstName, diff --git a/frontend/lib/screens/auth/am_register_step2_screen.dart b/frontend/lib/screens/auth/am_register_step2_screen.dart index 2f98b0e..6f50782 100644 --- a/frontend/lib/screens/auth/am_register_step2_screen.dart +++ b/frontend/lib/screens/auth/am_register_step2_screen.dart @@ -11,45 +11,53 @@ class AmRegisterStep2Screen extends StatelessWidget { @override Widget build(BuildContext context) { - final registrationData = Provider.of(context, listen: false); - - final initialData = ProfessionalInfoData( - photoPath: registrationData.photoPath, - photoBytes: registrationData.photoBytes, - photoFilename: registrationData.photoFilename, - photoConsent: registrationData.photoConsent, - dateOfBirth: registrationData.dateOfBirth, - birthCity: registrationData.birthCity, - birthCountry: registrationData.birthCountry, - nir: registrationData.nir, - agrementNumber: registrationData.agrementNumber, - agreementDate: registrationData.agreementDate, - capacity: registrationData.capacity, - placesAvailable: registrationData.placesAvailable, - ); - - return ProfessionalInfoFormScreen( - stepText: 'Étape 2/4', - title: 'Vos informations professionnelles', - cardColor: CardColorHorizontal.green, - initialData: initialData, - previousRoute: '/am-register-step1', - onSubmit: (data) { - registrationData.updateProfessionalInfo( - photoPath: data.photoPath, - photoBytes: data.photoBytes, - photoFilename: data.photoFilename, - photoConsent: data.photoConsent, - dateOfBirth: data.dateOfBirth, - birthCity: data.birthCity, - birthCountry: data.birthCountry, - nir: data.nir, - agrementNumber: data.agrementNumber, - agreementDate: data.agreementDate, - capacity: data.capacity, - placesAvailable: data.placesAvailable, + return Consumer( + builder: (context, registrationData, _) { + final initialData = ProfessionalInfoData( + photoPath: registrationData.photoPath, + photoBytes: registrationData.photoBytes, + photoFilename: registrationData.photoFilename, + photoConsent: registrationData.photoConsent, + dateOfBirth: registrationData.dateOfBirth, + birthCity: registrationData.birthCity, + birthCountry: registrationData.birthCountry, + nir: registrationData.nir, + agrementNumber: registrationData.agrementNumber, + agreementDate: registrationData.agreementDate, + capacity: registrationData.capacity, + placesAvailable: registrationData.placesAvailable, + ); + + return ProfessionalInfoFormScreen( + key: ValueKey( + 'am-pro-${registrationData.dateOfBirth?.millisecondsSinceEpoch}' + '-${registrationData.agreementDate?.millisecondsSinceEpoch}' + '-${registrationData.placesAvailable}' + '-${registrationData.nir}', + ), + stepText: 'Étape 2/4', + title: 'Vos informations professionnelles', + cardColor: CardColorHorizontal.green, + initialData: initialData, + previousRoute: '/am-register-step1', + onSubmit: (data) { + registrationData.updateProfessionalInfo( + photoPath: data.photoPath, + photoBytes: data.photoBytes, + photoFilename: data.photoFilename, + photoConsent: data.photoConsent, + dateOfBirth: data.dateOfBirth, + birthCity: data.birthCity, + birthCountry: data.birthCountry, + nir: data.nir, + agrementNumber: data.agrementNumber, + agreementDate: data.agreementDate, + capacity: data.capacity, + placesAvailable: data.placesAvailable, + ); + context.go('/am-register-step3'); + }, ); - context.go('/am-register-step3'); }, ); } diff --git a/frontend/lib/screens/auth/am_register_step4_screen.dart b/frontend/lib/screens/auth/am_register_step4_screen.dart index 90f536f..83520b9 100644 --- a/frontend/lib/screens/auth/am_register_step4_screen.dart +++ b/frontend/lib/screens/auth/am_register_step4_screen.dart @@ -8,6 +8,8 @@ import '../../models/am_registration_data.dart'; import '../../models/card_assets.dart'; import '../../config/display_config.dart'; import '../../services/auth_service.dart'; +import '../../services/reprise_session.dart'; +import '../../utils/reprise_payload.dart'; import '../../widgets/hover_relief_widget.dart'; import '../../widgets/image_button.dart'; import '../../widgets/custom_navigation_button.dart'; @@ -27,7 +29,21 @@ class _AmRegisterStep4ScreenState extends State { Future _submitAMRegistration(AmRegistrationData registrationData) async { if (_isSubmitting) return; - if (!registrationData.isRegistrationComplete) { + if (RepriseSession.isActive) { + if (!registrationData.isRepriseSubmitReady) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Vérifiez vos coordonnées et acceptez les conditions.', + style: GoogleFonts.merienda(fontSize: 14), + ), + backgroundColor: Colors.red.shade700, + ), + ); + return; + } + } else if (!registrationData.isRegistrationComplete) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -42,6 +58,19 @@ class _AmRegisterStep4ScreenState extends State { } setState(() => _isSubmitting = true); try { + if (RepriseSession.isActive) { + await AuthService.resoumettreReprise( + await ReprisePayload.amPatch( + registrationData, + RepriseSession.token!, + existingPhotoUrl: RepriseSession.photoUrlForApi, + ), + ); + RepriseSession.clear(); + if (!mounted) return; + _showRepriseConfirmationModal(context); + return; + } await AuthService.registerAM(registrationData); if (!mounted) return; _showConfirmationModal(context); @@ -244,6 +273,34 @@ class _AmRegisterStep4ScreenState extends State { ); } + void _showRepriseConfirmationModal(BuildContext context) { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext dialogContext) { + return AlertDialog( + title: Text( + 'Dossier resoumis', + style: GoogleFonts.merienda(fontWeight: FontWeight.bold), + ), + content: Text( + 'Vos modifications ont été enregistrées. Votre dossier est de nouveau en attente de validation.', + style: GoogleFonts.merienda(fontSize: 14), + ), + actions: [ + TextButton( + child: Text('OK', style: GoogleFonts.merienda(fontWeight: FontWeight.bold)), + onPressed: () { + Navigator.of(dialogContext).pop(); + context.go('/login'); + }, + ), + ], + ); + }, + ); + } + void _showConfirmationModal(BuildContext context) { showDialog( context: context, diff --git a/frontend/lib/screens/auth/login_screen.dart b/frontend/lib/screens/auth/login_screen.dart index feb5476..6f1efd0 100644 --- a/frontend/lib/screens/auth/login_screen.dart +++ b/frontend/lib/screens/auth/login_screen.dart @@ -10,6 +10,7 @@ import '../../widgets/image_button.dart'; import '../../widgets/custom_app_text_field.dart'; import '../../services/auth_service.dart'; import '../../widgets/auth/change_password_dialog.dart'; +import '../../widgets/auth/reprise_identify_dialog.dart'; class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); @@ -100,6 +101,31 @@ class _LoginPageState extends State { _handleLogin(); } + Future _openRepriseIdentify() async { + final token = await showDialog( + context: context, + builder: (ctx) => RepriseIdentifyDialog( + initialEmail: _emailController.text.trim(), + ), + ); + if (!mounted || token == null || token.isEmpty) return; + context.go('/reprise?token=${Uri.encodeComponent(token)}'); + } + + Widget _buildRepriseDossierLink({double fontSize = 14}) { + return TextButton( + onPressed: _isLoading ? null : _openRepriseIdentify, + child: Text( + 'J’ai un numéro de dossier', + style: GoogleFonts.merienda( + fontSize: fontSize, + color: const Color(0xFF2D6A4F), + decoration: TextDecoration.underline, + ), + ), + ); + } + /// Gère la connexion de l'utilisateur Future _handleLogin() async { // Réinitialiser le message d'erreur @@ -384,6 +410,7 @@ class _LoginPageState extends State { ), ), ), + Center(child: _buildRepriseDossierLink()), ], ), ), @@ -640,6 +667,7 @@ class _LoginPageState extends State { ), ), ), + _buildRepriseDossierLink(), ], ), ), diff --git a/frontend/lib/screens/auth/parent_register_step1_screen.dart b/frontend/lib/screens/auth/parent_register_step1_screen.dart index becad34..fd5de45 100644 --- a/frontend/lib/screens/auth/parent_register_step1_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step1_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import '../../models/user_registration_data.dart'; import '../../widgets/personal_info_form_screen.dart'; +import '../../services/reprise_session.dart'; import '../../models/card_assets.dart'; class ParentRegisterStep1Screen extends StatelessWidget { @@ -29,7 +30,7 @@ class ParentRegisterStep1Screen extends StatelessWidget { title: 'Informations du Parent Principal', cardColor: CardColorHorizontal.peach, initialData: initialData, - previousRoute: '/register-choice', + previousRoute: RepriseSession.isActive ? '/login' : '/register-choice', onSubmit: (data, {hasSecondPerson, sameAddress}) { registrationData.updateParent1(ParentData( firstName: data.firstName, diff --git a/frontend/lib/screens/auth/parent_register_step3_screen.dart b/frontend/lib/screens/auth/parent_register_step3_screen.dart index e13b3a9..ec5ed5d 100644 --- a/frontend/lib/screens/auth/parent_register_step3_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step3_screen.dart @@ -166,7 +166,11 @@ class _ParentRegisterStep3ScreenState extends State { if (await f.exists()) file = f; } catch (_) {} } - final updatedChild = oldChild.copyWith(imageBytes: bytes, imageFile: file); + final updatedChild = oldChild.copyWith( + imageBytes: bytes, + imageFile: file, + existingPhotoUrl: null, + ); registrationData.updateChild(childIndex, updatedChild); } } @@ -306,7 +310,7 @@ class _ParentRegisterStep3ScreenState extends State { onClearImage: () => setState(() { final c = registrationData.children[index]; registrationData.updateChild( - index, c.copyWith(imageFile: null, imageBytes: null)); + index, c.copyWith(imageFile: null, imageBytes: null, existingPhotoUrl: null)); }), onDateSelect: () => _selectDate(context, index, registrationData), onFirstNameChanged: (value) => setState(() { @@ -414,7 +418,7 @@ class _ParentRegisterStep3ScreenState extends State { onClearImage: () => setState(() { final c = registrationData.children[index]; registrationData.updateChild( - index, c.copyWith(imageFile: null, imageBytes: null)); + index, c.copyWith(imageFile: null, imageBytes: null, existingPhotoUrl: null)); }), onDateSelect: () => _selectDate(context, index, registrationData), onFirstNameChanged: (value) => setState(() { diff --git a/frontend/lib/screens/auth/parent_register_step5_screen.dart b/frontend/lib/screens/auth/parent_register_step5_screen.dart index 883f9ee..87ef640 100644 --- a/frontend/lib/screens/auth/parent_register_step5_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step5_screen.dart @@ -14,6 +14,8 @@ import '../../widgets/personal_info_form_screen.dart'; import '../../widgets/child_card_widget.dart'; import '../../widgets/presentation_form_screen.dart'; import '../../services/auth_service.dart'; +import '../../services/reprise_session.dart'; +import '../../utils/reprise_payload.dart'; class ParentRegisterStep5Screen extends StatefulWidget { const ParentRegisterStep5Screen({super.key}); @@ -27,8 +29,32 @@ class _ParentRegisterStep5ScreenState extends State { Future _submitRegistration(BuildContext context, UserRegistrationData data) async { if (_isSubmitting) return; + if (RepriseSession.isActive) { + if (!data.isRepriseSubmitReady) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Vérifiez vos coordonnées, les enfants et acceptez les conditions.', + style: GoogleFonts.merienda(fontSize: 14), + ), + backgroundColor: Colors.red.shade700, + ), + ); + return; + } + } setState(() => _isSubmitting = true); try { + if (RepriseSession.isActive) { + final token = RepriseSession.token!; + await AuthService.resoumettreReprise( + ReprisePayload.parentPatch(data, token), + ); + RepriseSession.clear(); + if (!context.mounted) return; + _showRepriseSuccessModal(context); + return; + } await AuthService.registerParent(data); if (!context.mounted) return; _showSuccessModal(context); @@ -279,6 +305,34 @@ class _ParentRegisterStep5ScreenState extends State { ); } + void _showRepriseSuccessModal(BuildContext context) { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext dialogContext) { + return AlertDialog( + title: Text( + 'Dossier resoumis', + style: GoogleFonts.merienda(fontWeight: FontWeight.bold), + ), + content: Text( + 'Vos modifications ont été enregistrées. Votre dossier est de nouveau en attente de validation.', + style: GoogleFonts.merienda(fontSize: 14), + ), + actions: [ + TextButton( + child: Text('OK', style: GoogleFonts.merienda(fontWeight: FontWeight.bold)), + onPressed: () { + Navigator.of(dialogContext).pop(); + context.go('/login'); + }, + ), + ], + ); + }, + ); + } + void _showSuccessModal(BuildContext context) { showDialog( context: context, diff --git a/frontend/lib/screens/auth/reprise_entry_screen.dart b/frontend/lib/screens/auth/reprise_entry_screen.dart new file mode 100644 index 0000000..c2fff65 --- /dev/null +++ b/frontend/lib/screens/auth/reprise_entry_screen.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../config/app_router.dart'; +import '../../services/auth_service.dart'; +import '../../services/reprise_session.dart'; + +/// Point d'entrée `/reprise?token=` — charge le dossier et ouvre le wizard (#112). +class RepriseEntryScreen extends StatefulWidget { + final String? token; + + const RepriseEntryScreen({super.key, this.token}); + + @override + State createState() => _RepriseEntryScreenState(); +} + +class _RepriseEntryScreenState extends State { + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _load()); + } + + Future _load() async { + final token = widget.token?.trim() ?? ''; + if (token.isEmpty) { + setState(() { + _loading = false; + _error = 'Lien invalide ou expiré.'; + }); + return; + } + + setState(() { + _loading = true; + _error = null; + }); + + try { + final dossier = await AuthService.getRepriseDossier(token); + if (!mounted) return; + + RepriseSession.clear(); + RepriseSession.start(token: token, dossier: dossier); + + if (dossier.isParent) { + RepriseSession.applyToParent(userRegistrationDataNotifier, dossier); + context.go('/parent-register-step1'); + return; + } + if (dossier.isAm) { + RepriseSession.applyToAm(amRegistrationDataNotifier, dossier); + context.go('/am-register-step1'); + return; + } + + RepriseSession.clear(); + setState(() { + _loading = false; + _error = 'Type de dossier non pris en charge pour la reprise.'; + }); + } catch (e) { + if (!mounted) return; + RepriseSession.clear(); + setState(() { + _loading = false; + _error = e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Impossible de charger votre dossier.'; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + Positioned.fill( + child: Image.asset( + 'assets/images/paper2.png', + fit: BoxFit.cover, + repeat: ImageRepeat.repeat, + ), + ), + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Padding( + padding: const EdgeInsets.all(24), + child: _buildBody(), + ), + ), + ), + ], + ), + ); + } + + Widget _buildBody() { + if (_loading) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 20), + Text( + 'Chargement de votre dossier…', + style: GoogleFonts.merienda(fontSize: 16), + textAlign: TextAlign.center, + ), + ], + ); + } + + final err = _error ?? 'Lien invalide ou expiré.'; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 48, color: Colors.red.shade700), + const SizedBox(height: 16), + Text( + 'Reprise du dossier', + style: GoogleFonts.merienda( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Text( + err, + style: GoogleFonts.merienda(fontSize: 14, color: Colors.black87), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + TextButton( + onPressed: () => context.go('/login'), + child: Text( + 'Retour à la connexion', + style: GoogleFonts.merienda( + decoration: TextDecoration.underline, + color: const Color(0xFF2D6A4F), + ), + ), + ), + ], + ); + } +} diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index 11d6a28..da671bc 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -49,6 +49,10 @@ class ApiConfig { /// Ticket #127 — mot de passe oublié (back à livrer en parallèle). static const String forgotPassword = '/auth/forgot-password'; static const String resetPassword = '/auth/reset-password'; + /// Ticket #112 — reprise après refus (#111 back). + static const String repriseDossier = '/auth/reprise-dossier'; + static const String repriseResoumettre = '/auth/reprise-resoumettre'; + static const String repriseIdentify = '/auth/reprise-identify'; // Users endpoints static const String users = '/users'; diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart index 94f5d28..1871e07 100644 --- a/frontend/lib/services/auth_service.dart +++ b/frontend/lib/services/auth_service.dart @@ -8,6 +8,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../models/user.dart'; import '../models/am_registration_data.dart'; import '../models/user_registration_data.dart'; +import '../models/reprise_dossier.dart'; import '../utils/parent_registration_payload.dart'; import 'api/api_config.dart'; import 'api/tokenService.dart'; @@ -258,6 +259,116 @@ class AuthService { throw Exception(msg); } + /// Charge le dossier pour reprise (lien e-mail). GET /auth/reprise-dossier. Ticket #112. + static Future getRepriseDossier(String token) async { + final cleaned = token.trim(); + if (cleaned.isEmpty) { + throw Exception('Lien invalide ou expiré.'); + } + final uri = Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.repriseDossier}', + ).replace(queryParameters: {'token': cleaned}); + late final http.Response response; + try { + response = await http.get(uri, headers: ApiConfig.headers); + } on http.ClientException { + throw Exception( + 'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau puis réessayez.', + ); + } + if (response.statusCode == 404) { + throw Exception('Lien invalide ou expiré.'); + } + if (response.statusCode != 200) { + final decoded = _tryDecodeJsonMap(response.body); + throw Exception(_extractErrorMessage(decoded, response.statusCode)); + } + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw Exception('Réponse invalide du serveur.'); + } + final payload = decoded['data'] is Map + ? Map.from(decoded['data'] as Map) + : decoded; + return RepriseDossier.fromJson(payload); + } + + /// Modale login : numéro + e-mail → token reprise. POST /auth/reprise-identify. #112. + static Future identifyReprise({ + required String numeroDossier, + required String email, + }) async { + final num = numeroDossier.trim(); + final mail = normalizeEmailText(email); + if (num.isEmpty || mail.isEmpty) { + throw Exception('Numéro de dossier et e-mail requis.'); + } + late final http.Response response; + try { + response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.repriseIdentify}'), + headers: ApiConfig.headers, + body: jsonEncode({ + 'numero_dossier': num, + 'email': mail, + }), + ); + } on http.ClientException { + throw Exception( + 'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau puis réessayez.', + ); + } + if (response.statusCode == 404) { + throw Exception( + 'Aucun dossier en reprise trouvé pour ce numéro et cet e-mail.', + ); + } + if (response.statusCode != 200 && response.statusCode != 201) { + final decoded = _tryDecodeJsonMap(response.body); + throw Exception(_extractErrorMessage(decoded, response.statusCode)); + } + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw Exception('Réponse invalide du serveur.'); + } + final token = decoded['token']?.toString().trim() ?? ''; + if (token.isEmpty) { + throw Exception('Réponse invalide du serveur.'); + } + return token; + } + + /// Resoumission après refus. PATCH /auth/reprise-resoumettre. Ticket #112. + static Future resoumettreReprise(Map body) async { + final cleaned = body['token']?.toString().trim() ?? ''; + if (cleaned.isEmpty) { + throw Exception('Lien invalide ou expiré.'); + } + final payload = Map.from(body); + payload['token'] = cleaned; + + late final http.Response response; + try { + response = await http.patch( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.repriseResoumettre}'), + headers: ApiConfig.headers, + body: jsonEncode(payload), + ); + } on http.ClientException { + throw Exception( + 'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau puis réessayez.', + ); + } + if (response.statusCode == 404) { + throw Exception('Lien invalide ou expiré.'); + } + if (response.statusCode == 200 || response.statusCode == 201) { + return; + } + final decoded = _tryDecodeJsonMap(response.body); + throw Exception(_extractErrorMessage(decoded, response.statusCode)); + } + /// Déconnexion de l'utilisateur static Future logout() async { await TokenService.clearAll(); diff --git a/frontend/lib/services/reprise_session.dart b/frontend/lib/services/reprise_session.dart new file mode 100644 index 0000000..6f9fc8a --- /dev/null +++ b/frontend/lib/services/reprise_session.dart @@ -0,0 +1,58 @@ +import 'package:p_tits_pas/models/am_registration_data.dart'; +import 'package:p_tits_pas/models/reprise_dossier.dart'; +import 'package:p_tits_pas/models/user_registration_data.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/utils/reprise_mapper.dart'; + +/// Contexte reprise après refus (token e-mail ou identify). Ticket #112. +class RepriseSession { + RepriseSession._(); + + static String? _token; + static String? _role; + static String? _photoUrl; + static String? _photoUrlForApi; + + static bool get isActive => + _token != null && _token!.trim().isNotEmpty; + + static String? get token => _token; + + static bool get isParent => _role == 'parent'; + + static bool get isAm => _role == 'assistante_maternelle'; + + /// URL absolue pour l'affichage. + static String? get photoUrl => _photoUrl; + + /// Chemin relatif API (`/uploads/…`) pour PATCH sans re-upload. + static String? get photoUrlForApi => _photoUrlForApi; + + static void start({ + required String token, + required RepriseDossier dossier, + }) { + _token = token.trim(); + _role = dossier.role; + final raw = dossier.photoUrl?.trim(); + _photoUrlForApi = raw != null && raw.isNotEmpty ? raw : null; + _photoUrl = _photoUrlForApi != null + ? ApiConfig.absoluteMediaUrl(_photoUrlForApi) + : null; + } + + static void clear() { + _token = null; + _role = null; + _photoUrl = null; + _photoUrlForApi = null; + } + + static void applyToParent(UserRegistrationData data, RepriseDossier dossier) { + RepriseMapper.applyParentDossier(data, dossier); + } + + static void applyToAm(AmRegistrationData data, RepriseDossier dossier) { + RepriseMapper.applyAmDossier(data, dossier); + } +} diff --git a/frontend/lib/utils/parent_registration_payload.dart b/frontend/lib/utils/parent_registration_payload.dart index 05f8d79..70a9459 100644 --- a/frontend/lib/utils/parent_registration_payload.dart +++ b/frontend/lib/utils/parent_registration_payload.dart @@ -149,6 +149,20 @@ class ParentRegistrationPayload { return body; } + /// Enfant pour PATCH reprise (inclut `id` si connu). + static Map childToRepriseJson( + ChildData c, + int index, + String parentNom, + ) { + final map = _childToJson(c, index, parentNom); + final id = c.repriseChildId?.trim(); + if (id != null && id.isNotEmpty) { + map['id'] = id; + } + return map; + } + static Map _childToJson(ChildData c, int index, String parentNom) { final map = { 'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre', diff --git a/frontend/lib/utils/reprise_mapper.dart b/frontend/lib/utils/reprise_mapper.dart new file mode 100644 index 0000000..53c285b --- /dev/null +++ b/frontend/lib/utils/reprise_mapper.dart @@ -0,0 +1,223 @@ +import 'package:p_tits_pas/models/am_registration_data.dart'; +import 'package:p_tits_pas/models/card_assets.dart'; +import 'package:p_tits_pas/models/dossier_unifie.dart'; +import 'package:p_tits_pas/models/reprise_dossier.dart'; +import 'package:p_tits_pas/models/user_registration_data.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; + +/// Mapping GET reprise-dossier → modèles wizard. Ticket #112. +class RepriseMapper { + RepriseMapper._(); + + static const List _childCardColors = [ + CardColorVertical.lavender, + CardColorVertical.pink, + CardColorVertical.peach, + CardColorVertical.lime, + CardColorVertical.red, + CardColorVertical.green, + CardColorVertical.blue, + ]; + + static ParentData parentFromDossier(ParentDossier p) { + return ParentData( + firstName: p.prenom ?? '', + lastName: p.nom ?? '', + phone: p.telephone ?? '', + email: p.email, + address: p.adresse ?? '', + postalCode: p.codePostal ?? '', + city: p.ville ?? '', + password: '', + ); + } + + static ChildData childFromEnfant(EnfantDossier e, int index) { + final isUnborn = e.status == 'a_naitre'; + final dob = isUnborn + ? isoToDdMmYyyy(e.dueDate) + : isoToDdMmYyyy(e.birthDate); + final photo = e.photoUrl?.trim(); + final hasPhoto = photo != null && photo.isNotEmpty; + return ChildData( + firstName: e.firstName ?? '', + lastName: e.lastName ?? '', + dob: dob, + genre: e.gender ?? '', + // Inscription initiale exigeait la coche pour envoyer la photo ; le back + // ne persistait pas toujours consent_photo — on pré-coche si photo en base. + photoConsent: e.consentPhoto || hasPhoto, + multipleBirth: e.estMultiple, + isUnbornChild: isUnborn, + cardColor: _childCardColors[index % _childCardColors.length], + repriseChildId: e.id, + existingPhotoUrl: photo != null && photo.isNotEmpty + ? ApiConfig.absoluteMediaUrl(photo) + : null, + ); + } + + static void applyParentDossier(UserRegistrationData data, RepriseDossier dossier) { + ParentDossier? titulaire; + ParentDossier? coParent; + + if (dossier.parents.isNotEmpty) { + for (final p in dossier.parents) { + if (p.id == dossier.id) { + titulaire = p; + } else { + coParent = p; + } + } + titulaire ??= dossier.parents.first; + if (coParent == null && dossier.parents.length > 1) { + coParent = dossier.parents.firstWhere( + (p) => p.id != titulaire!.id, + orElse: () => dossier.parents.last, + ); + } + } + + final p1 = titulaire != null + ? parentFromDossier(titulaire) + : ParentData( + firstName: dossier.prenom ?? '', + lastName: dossier.nom ?? '', + phone: dossier.telephone ?? '', + email: dossier.email, + address: dossier.adresse ?? '', + postalCode: dossier.codePostal ?? '', + city: dossier.ville ?? '', + password: '', + ); + + final children = dossier.enfants + .asMap() + .entries + .map((e) => childFromEnfant(e.value, e.key)) + .toList(); + + data.resetForReprise( + parent1Data: p1, + parent2Data: coParent != null ? parentFromDossier(coParent) : null, + childrenData: children, + motivation: dossier.texteMotivation ?? '', + ); + } + + static DateTime? parseIsoDate(String? raw) { + if (raw == null || raw.trim().isEmpty) return null; + final s = raw.trim(); + final iso = RegExp(r'^(\d{4})-(\d{2})-(\d{2})'); + final isoMatch = iso.firstMatch(s); + if (isoMatch != null) { + return DateTime( + int.parse(isoMatch.group(1)!), + int.parse(isoMatch.group(2)!), + int.parse(isoMatch.group(3)!), + ); + } + try { + return DateTime.parse(s); + } catch (_) { + final parts = s.split('/'); + if (parts.length == 3) { + final day = int.tryParse(parts[0]); + final month = int.tryParse(parts[1]); + final year = int.tryParse(parts[2]); + if (day != null && month != null && year != null) { + return DateTime(year, month, day); + } + } + return null; + } + } + + static String? optionalDateString(dynamic value) { + if (value == null) return null; + if (value is String) { + final s = value.trim(); + return s.isEmpty || s == 'null' ? null : s; + } + if (value is DateTime) { + return '${value.year.toString().padLeft(4, '0')}-' + '${value.month.toString().padLeft(2, '0')}-' + '${value.day.toString().padLeft(2, '0')}'; + } + if (value is num) { + final ms = value.abs() > 9999999999 + ? value.toInt() + : value.toInt() * 1000; + final dt = DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true); + return '${dt.year.toString().padLeft(4, '0')}-' + '${dt.month.toString().padLeft(2, '0')}-' + '${dt.day.toString().padLeft(2, '0')}'; + } + if (value is Map) { + final y = value['year']; + final m = value['month'] ?? value['monthValue']; + final d = value['day'] ?? value['dayOfMonth']; + if (y is num && m is num && d is num) { + return '${y.toInt().toString().padLeft(4, '0')}-' + '${m.toInt().toString().padLeft(2, '0')}-' + '${d.toInt().toString().padLeft(2, '0')}'; + } + } + final s = value.toString().trim(); + return s.isEmpty || s == 'null' ? null : s; + } + + static int? optionalInt(dynamic value) { + if (value == null) return null; + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) { + final s = value.trim(); + if (s.isEmpty) return null; + return int.tryParse(s); + } + return null; + } + + static bool optionalBool(dynamic value) { + if (value == true) return true; + if (value is String && value.toLowerCase() == 'true') return true; + return false; + } + + static void applyAmDossier(AmRegistrationData data, RepriseDossier dossier) { + final rawPhoto = dossier.photoUrl?.trim(); + final hasPhoto = rawPhoto != null && rawPhoto.isNotEmpty; + final displayPhoto = + hasPhoto ? ApiConfig.absoluteMediaUrl(rawPhoto) : null; + + data.resetForReprise( + firstName: dossier.prenom ?? '', + lastName: dossier.nom ?? '', + phone: dossier.telephone ?? '', + email: dossier.email, + streetAddress: dossier.adresse ?? '', + postalCode: dossier.codePostal ?? '', + city: dossier.ville ?? '', + existingPhotoUrl: displayPhoto, + consentementPhoto: dossier.consentementPhoto || hasPhoto, + dateOfBirth: parseIsoDate(dossier.dateNaissance), + birthCity: dossier.lieuNaissanceVille ?? '', + birthCountry: dossier.lieuNaissancePays ?? '', + nir: dossier.nir ?? '', + agrementNumber: dossier.numeroAgrement ?? '', + agreementDate: parseIsoDate(dossier.dateAgrement), + capacity: dossier.nbMaxEnfants, + placesAvailable: dossier.placeDisponible, + presentationText: dossier.biographie ?? '', + ); + } + + static String isoToDdMmYyyy(String? iso) { + final dt = parseIsoDate(iso); + if (dt == null) return ''; + return '${dt.day.toString().padLeft(2, '0')}/' + '${dt.month.toString().padLeft(2, '0')}/' + '${dt.year}'; + } +} diff --git a/frontend/lib/utils/reprise_payload.dart b/frontend/lib/utils/reprise_payload.dart new file mode 100644 index 0000000..6ce887a --- /dev/null +++ b/frontend/lib/utils/reprise_payload.dart @@ -0,0 +1,150 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +import '../models/am_registration_data.dart'; +import '../models/user_registration_data.dart'; +import 'nir_utils.dart'; +import 'parent_registration_payload.dart'; + +/// Body PATCH /auth/reprise-resoumettre. Ticket #112. +class ReprisePayload { + ReprisePayload._(); + + static Map parentPatch( + UserRegistrationData data, + String token, + ) { + final base = ParentRegistrationPayload.toJson(data); + base.remove('email'); + base.remove('acceptation_cgu'); + base.remove('acceptation_privacy'); + base['token'] = token.trim(); + + final enfants = data.children.asMap().entries.map((e) { + final childMap = ParentRegistrationPayload.childToRepriseJson( + e.value, + e.key, + data.parent1.lastName.trim(), + ); + return childMap; + }).toList(); + base['enfants'] = enfants; + + return base; + } + + static Future> amPatch( + AmRegistrationData data, + String token, { + String? existingPhotoUrl, + }) async { + final body = {'token': token.trim()}; + + _put(body, 'prenom', data.firstName.trim()); + _put(body, 'nom', data.lastName.trim()); + _put(body, 'telephone', data.phone.trim()); + _put(body, 'adresse', data.streetAddress.trim()); + _put(body, 'code_postal', data.postalCode.trim()); + _put(body, 'ville', data.city.trim()); + + body['consentement_photo'] = data.photoConsent; + + if (data.dateOfBirth != null) { + final d = data.dateOfBirth!; + body['date_naissance'] = + '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; + } + _put(body, 'lieu_naissance_ville', data.birthCity.trim()); + _put(body, 'lieu_naissance_pays', data.birthCountry.trim()); + _put(body, 'numero_agrement', data.agrementNumber.trim()); + if (data.nir.trim().isNotEmpty) { + body['nir'] = normalizeNir(data.nir); + } + if (data.agreementDate != null) { + final d = data.agreementDate!; + body['date_agrement'] = + '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; + } + if (data.capacity != null) { + body['capacite_accueil'] = data.capacity; + } + if (data.placesAvailable != null) { + body['places_disponibles'] = data.placesAvailable; + } + if (data.presentationText.trim().isNotEmpty) { + body['biographie'] = data.presentationText.trim(); + } + + final photo = await _amPhotoPayload(data, existingPhotoUrl); + if (photo != null) { + body.addAll(photo); + } + + return body; + } + + static Future?> _amPhotoPayload( + AmRegistrationData data, + String? existingPhotoUrl, + ) async { + if (data.photoBytes != null && data.photoBytes!.isNotEmpty) { + final mime = _imageMimeForBytes(data.photoBytes!); + final fn = (data.photoFilename ?? '').trim(); + return { + 'photo_base64': + 'data:$mime;base64,${base64Encode(data.photoBytes!)}', + 'photo_filename': fn.isNotEmpty ? fn : 'photo_am.jpg', + }; + } + + if (!kIsWeb && + data.photoPath != null && + data.photoPath!.isNotEmpty && + !data.photoPath!.startsWith('assets/') && + !data.photoPath!.startsWith('http')) { + try { + final file = File(data.photoPath!); + if (await file.exists()) { + final bytes = await file.readAsBytes(); + final mime = _imageMimeForBytes(bytes); + return { + 'photo_base64': 'data:$mime;base64,${base64Encode(bytes)}', + 'photo_filename': + _basenameFromPath(data.photoPath!) ?? 'photo_am.jpg', + }; + } + } catch (_) {} + } + + final url = (existingPhotoUrl ?? data.repriseExistingPhotoUrl ?? '').trim(); + if (url.isNotEmpty) { + return {'photo_url': url}; + } + return null; + } + + static String _imageMimeForBytes(Uint8List bytes) { + if (bytes.length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xD8) { + return 'image/jpeg'; + } + if (bytes.length >= 8 && + bytes[0] == 0x89 && + bytes[1] == 0x50 && + bytes[2] == 0x4E && + bytes[3] == 0x47) { + return 'image/png'; + } + return 'image/jpeg'; + } + + static String? _basenameFromPath(String path) { + final parts = path.replaceAll('\\', '/').split('/'); + return parts.isEmpty ? null : parts.last; + } + + static void _put(Map m, String key, String value) { + if (value.isNotEmpty) m[key] = value; + } +} diff --git a/frontend/lib/widgets/auth/reprise_identify_dialog.dart b/frontend/lib/widgets/auth/reprise_identify_dialog.dart new file mode 100644 index 0000000..da8130a --- /dev/null +++ b/frontend/lib/widgets/auth/reprise_identify_dialog.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../services/auth_service.dart'; +import '../../utils/email_utils.dart'; +import '../custom_app_text_field.dart'; + +/// Modale login : numéro de dossier + e-mail → token reprise (#112). +class RepriseIdentifyDialog extends StatefulWidget { + final String? initialEmail; + + const RepriseIdentifyDialog({super.key, this.initialEmail}); + + @override + State createState() => _RepriseIdentifyDialogState(); +} + +class _RepriseIdentifyDialogState extends State { + final _formKey = GlobalKey(); + late final TextEditingController _numeroCtrl; + late final TextEditingController _emailCtrl; + + bool _loading = false; + String? _error; + + @override + void initState() { + super.initState(); + _numeroCtrl = TextEditingController(); + _emailCtrl = TextEditingController(text: widget.initialEmail ?? ''); + } + + @override + void dispose() { + _numeroCtrl.dispose(); + _emailCtrl.dispose(); + super.dispose(); + } + + String? _validateNumero(String? value) { + final v = value?.trim() ?? ''; + if (v.isEmpty) { + return 'Indiquez votre numéro de dossier.'; + } + return null; + } + + String? _validateEmail(String? value) { + final v = value?.trim() ?? ''; + if (v.isEmpty) { + return 'Indiquez votre adresse e-mail.'; + } + if (!isValidEmailFormat(v)) { + return 'L’adresse e-mail n’est pas valide.'; + } + return null; + } + + Future _submit() async { + if (_loading) return; + setState(() => _error = null); + if (!(_formKey.currentState?.validate() ?? false)) return; + + setState(() => _loading = true); + try { + final token = await AuthService.identifyReprise( + numeroDossier: _numeroCtrl.text, + email: _emailCtrl.text, + ); + if (!mounted) return; + Navigator.of(context).pop(token); + } catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _error = e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Impossible de retrouver votre dossier.'; + }); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text( + 'Reprendre mon dossier', + style: GoogleFonts.merienda(fontWeight: FontWeight.bold), + ), + content: SizedBox( + width: 420, + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Saisissez le numéro de dossier et l’e-mail utilisés lors ' + 'de l’inscription (dossier refusé en attente de correction).', + style: GoogleFonts.merienda(fontSize: 13), + ), + const SizedBox(height: 16), + CustomAppTextField( + controller: _numeroCtrl, + labelText: 'Numéro de dossier', + hintText: 'Ex. 2026-000021', + textInputAction: TextInputAction.next, + validator: _validateNumero, + style: CustomAppTextFieldStyle.lavande, + fieldHeight: 48, + fieldWidth: double.infinity, + ), + const SizedBox(height: 12), + CustomAppTextField( + controller: _emailCtrl, + labelText: 'E-mail', + hintText: 'Votre adresse e-mail', + keyboardType: TextInputType.emailAddress, + autocorrect: false, + enableSuggestions: false, + inputFormatters: const [EmailMaxLengthFormatter()], + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + validator: _validateEmail, + style: CustomAppTextFieldStyle.lavande, + fieldHeight: 48, + fieldWidth: double.infinity, + ), + if (_error != null) ...[ + const SizedBox(height: 12), + Text( + _error!, + style: GoogleFonts.merienda( + fontSize: 12, + color: Colors.red.shade700, + ), + ), + ], + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: _loading ? null : () => Navigator.of(context).pop(), + child: Text('Annuler', style: GoogleFonts.merienda()), + ), + TextButton( + onPressed: _loading ? null : _submit, + child: _loading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + 'Continuer', + style: GoogleFonts.merienda( + fontWeight: FontWeight.bold, + color: const Color(0xFF2D6A4F), + ), + ), + ), + ], + ); + } +} diff --git a/frontend/lib/widgets/child_card_widget.dart b/frontend/lib/widgets/child_card_widget.dart index 1afbf6e..4ff744f 100644 --- a/frontend/lib/widgets/child_card_widget.dart +++ b/frontend/lib/widgets/child_card_widget.dart @@ -1,4 +1,5 @@ import 'dart:math' as math; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -20,7 +21,7 @@ bool _hasChildPhoto(ChildData c) { return registrationPhotoSlotHasImage( imageBytes: c.imageBytes, imageFile: c.imageFile, - imagePathOrAsset: null, + imagePathOrAsset: c.existingPhotoUrl, ); } @@ -33,6 +34,20 @@ Widget _buildChildPhotoImage(ChildData c, {required BoxFit fit}) { if (f != null) { return kIsWeb ? Image.network(f.path, fit: fit) : Image.file(f, fit: fit); } + final url = c.existingPhotoUrl?.trim(); + if (url != null && url.isNotEmpty) { + if (url.startsWith('http://') || url.startsWith('https://')) { + return Image.network(url, fit: fit); + } + if (!kIsWeb) { + try { + final file = File(url); + if (file.existsSync()) { + return Image.file(file, fit: fit); + } + } catch (_) {} + } + } return Image.asset('assets/images/photo.png', fit: BoxFit.contain); } @@ -221,6 +236,7 @@ class _ChildCardWidgetState extends State { scaleFactor: scaleFactor, imageBytes: widget.childData.imageBytes, imageFile: widget.childData.imageFile, + imagePathOrAsset: widget.childData.existingPhotoUrl, onTapPick: !config.isReadonly ? widget.onPickImage : null, onClear: !config.isReadonly ? widget.onClearImage : null, baseShadowColor: baseCardColorForShadow, diff --git a/frontend/lib/widgets/professional_info_form_screen.dart b/frontend/lib/widgets/professional_info_form_screen.dart index 6225cd1..9a21bd0 100644 --- a/frontend/lib/widgets/professional_info_form_screen.dart +++ b/frontend/lib/widgets/professional_info_form_screen.dart @@ -134,30 +134,7 @@ class _ProfessionalInfoFormScreenState extends State @override void initState() { super.initState(); - - final data = widget.initialData; - if (data != null) { - _selectedDate = data.dateOfBirth; - _dateOfBirthController.text = data.dateOfBirth != null - ? DateFormat('dd/MM/yyyy').format(data.dateOfBirth!) - : ''; - _birthCityController.text = data.birthCity; - _birthCountryController.text = data.birthCountry; - final nirRaw = nirToRaw(data.nir); - _nirController.text = nirRaw.length == 15 ? formatNir(nirRaw) : data.nir; - _agrementController.text = data.agrementNumber; - _selectedAgreementDate = data.agreementDate; - _agreementDateController.text = data.agreementDate != null - ? DateFormat('dd/MM/yyyy').format(data.agreementDate!) - : ''; - _capacityController.text = data.capacity?.toString() ?? ''; - _placesAvailableController.text = data.placesAvailable?.toString() ?? ''; - _photoPathFramework = data.photoPath; - _photoFile = data.photoFile; - _photoBytes = data.photoBytes; - _photoFilename = data.photoFilename; - _photoConsent = data.photoConsent; - } + _applyInitialData(widget.initialData); if (widget.mode == DisplayMode.editable) { _birthCityFocus = FocusNode(); @@ -168,6 +145,51 @@ class _ProfessionalInfoFormScreenState extends State } } + @override + void didUpdateWidget(covariant ProfessionalInfoFormScreen oldWidget) { + super.didUpdateWidget(oldWidget); + final next = widget.initialData; + final prev = oldWidget.initialData; + if (next == null) return; + if (prev == null || + prev.dateOfBirth != next.dateOfBirth || + prev.agreementDate != next.agreementDate || + prev.placesAvailable != next.placesAvailable || + prev.capacity != next.capacity || + prev.nir != next.nir || + prev.birthCity != next.birthCity || + prev.birthCountry != next.birthCountry || + prev.agrementNumber != next.agrementNumber || + prev.photoPath != next.photoPath || + prev.photoConsent != next.photoConsent) { + _applyInitialData(next); + } + } + + void _applyInitialData(ProfessionalInfoData? data) { + if (data == null) return; + _selectedDate = data.dateOfBirth; + _dateOfBirthController.text = data.dateOfBirth != null + ? DateFormat('dd/MM/yyyy').format(data.dateOfBirth!) + : ''; + _birthCityController.text = data.birthCity; + _birthCountryController.text = data.birthCountry; + final nirRaw = nirToRaw(data.nir); + _nirController.text = nirRaw.length == 15 ? formatNir(nirRaw) : data.nir; + _agrementController.text = data.agrementNumber; + _selectedAgreementDate = data.agreementDate; + _agreementDateController.text = data.agreementDate != null + ? DateFormat('dd/MM/yyyy').format(data.agreementDate!) + : ''; + _capacityController.text = data.capacity?.toString() ?? ''; + _placesAvailableController.text = data.placesAvailable?.toString() ?? ''; + _photoPathFramework = data.photoPath; + _photoFile = data.photoFile; + _photoBytes = data.photoBytes; + _photoFilename = data.photoFilename; + _photoConsent = data.photoConsent; + } + void _onBirthCityFocusChange() { if (_birthCityFocus == null || _birthCityFocus!.hasFocus) return; _applyPlaceNameFormat(_birthCityController); diff --git a/frontend/lib/widgets/registration_photo_slot.dart b/frontend/lib/widgets/registration_photo_slot.dart index 7dde753..2338ef9 100644 --- a/frontend/lib/widgets/registration_photo_slot.dart +++ b/frontend/lib/widgets/registration_photo_slot.dart @@ -71,6 +71,9 @@ class RegistrationPhotoSlot extends StatelessWidget { if (p.startsWith('assets/')) { return Image.asset(p, fit: fit); } + if (p.startsWith('http://') || p.startsWith('https://')) { + return Image.network(p, fit: fit); + } if (!kIsWeb) { try { final file = File(p); diff --git a/frontend/test/reprise_am_parse_test.dart b/frontend/test/reprise_am_parse_test.dart new file mode 100644 index 0000000..3fcf621 --- /dev/null +++ b/frontend/test/reprise_am_parse_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:p_tits_pas/models/am_registration_data.dart'; +import 'package:p_tits_pas/models/reprise_dossier.dart'; +import 'package:p_tits_pas/utils/reprise_mapper.dart'; + +void main() { + test('RepriseDossier parse champs AM plats', () { + final d = RepriseDossier.fromJson({ + 'id': 'am1', + 'email': 'marie@test.fr', + 'role': 'assistante_maternelle', + 'date_naissance': '1980-06-08', + 'date_agrement': '2019-09-01', + 'place_disponible': 2, + 'nb_max_enfants': 4, + 'lieu_naissance_ville': 'Ajaccio', + 'nir': '280062A00100191', + 'numero_agrement': 'AGR-2019-095001', + }); + + expect(d.dateNaissance, '1980-06-08'); + expect(d.dateAgrement, '2019-09-01'); + expect(d.placeDisponible, 2); + + final data = AmRegistrationData(); + RepriseMapper.applyAmDossier(data, d); + expect(data.dateOfBirth, DateTime(1980, 6, 8)); + expect(data.agreementDate, DateTime(2019, 9, 1)); + expect(data.placesAvailable, 2); + }); + + test('RepriseDossier parse champs AM imbriqués + wrapper data', () { + final d = RepriseDossier.fromJson({ + 'id': 'am1', + 'email': 'marie@test.fr', + 'role': 'assistante_maternelle', + 'nir': '280062A00100191', + 'numero_agrement': 'AGR-2019-095001', + 'nb_max_enfants': 4, + 'lieu_naissance_ville': 'Ajaccio', + 'user': {'date_naissance': '1980-06-08T00:00:00.000Z'}, + 'dossier': { + 'date_agrement': '2019-09-01', + 'places_disponibles': 2, + }, + }); + + expect(d.dateNaissance, isNotNull); + expect(d.dateAgrement, '2019-09-01'); + expect(d.placeDisponible, 2); + }); +} From 003fe6b7625ebeee2d2544af4fb1dcd2a8ec9aef Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Sat, 11 Jul 2026 22:49:57 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(#131):=20fiches=20parent/AM=20=C3=A9di?= =?UTF-8?q?table,=20placement=20AM=E2=86=94enfant,=20statuts=20garde/sans?= =?UTF-8?q?=5Fgarde?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squash merge develop → master. - Fiche parent éditable (co-parent, PATCH fiche, GET /parents) - Fiche AM 3 onglets (PATCH fiche, rattacher/détacher enfants) - Table enfants_assistantes_maternelles + enum garde/sans_garde - Migration SQL + BDD.sql canonique - Correctifs recette : @Get() parents, DTO fiche AM, fix NIR Co-authored-by: Cursor --- backend/scripts/close-gitea-issue-131.js | 123 +++ .../update-gitea-issue-140-ch6-famille.js | 153 ++++ .../utils/sanitize-user-for-api.spec.ts | 32 + .../src/common/utils/sanitize-user-for-api.ts | 23 + backend/src/entities/am_children.entity.ts | 47 + .../assistantes_maternelles.entity.ts | 6 +- backend/src/entities/children.entity.ts | 7 +- .../assistantes_maternelles.controller.ts | 78 +- .../assistantes_maternelles.mapper.ts | 28 + .../assistantes_maternelles.module.ts | 4 +- .../assistantes_maternelles.service.ts | 205 ++++- .../dto/update-am-fiche-admin.dto.ts | 126 +++ backend/src/routes/auth/auth.service.spec.ts | 2 +- backend/src/routes/auth/auth.service.ts | 4 +- .../routes/enfants/dto/create_enfants.dto.ts | 2 +- .../src/routes/enfants/enfants.controller.ts | 7 +- backend/src/routes/enfants/enfants.service.ts | 6 +- .../dto/update-parent-fiche-admin.dto.ts | 57 ++ .../src/routes/parents/parents.controller.ts | 71 +- .../src/routes/parents/parents.mapper.spec.ts | 38 + backend/src/routes/parents/parents.mapper.ts | 18 + backend/src/routes/parents/parents.module.ts | 3 +- backend/src/routes/parents/parents.service.ts | 81 +- database/BDD.sql | 228 ++--- database/README.md | 11 +- database/bdd/data_test/enfants.csv | 20 +- database/docker-compose.dev.yml | 5 +- database/docs/ENUMS.md | 9 +- database/docs/FK_POLICIES.md | 3 + .../2026_enfants_assistantes_maternelles.sql | 36 + database/seed/02_seed.sql | 4 +- database/seed/03_seed_test_data.sql | 30 +- docs/00_INDEX.md | 5 +- docs/24_DECISIONS-PROJET.md | 32 +- docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md | 331 +++++++ docs/EVOLUTIONS_CDC.md | 29 +- .../131-fiche-parent-co-parent-header.md | 127 +++ .../TEMP_112-back-reprise-alignement-front.md | 244 +++++ .../TEMP_131-back-fiche-am-enfants.md | 132 +++ .../TEMP_131-back-fiche-parent-co-parent.md | 124 +++ docs/serveur_ptitspas.code-workspace | 7 + .../models/assistante_maternelle_model.dart | 84 +- frontend/lib/models/dossier_unifie.dart | 3 +- frontend/lib/models/enfant_admin_model.dart | 109 +++ frontend/lib/models/parent_child_summary.dart | 92 ++ frontend/lib/models/parent_model.dart | 55 +- frontend/lib/services/api/api_config.dart | 1 + frontend/lib/services/user_service.dart | 280 +++++- frontend/lib/utils/am_vigilance.dart | 45 + frontend/lib/utils/date_display_utils.dart | 87 ++ frontend/lib/utils/enfant_status_utils.dart | 51 ++ ...sistante_maternelle_management_widget.dart | 57 +- .../admin_am_children_capacity_grid.dart | 301 +++++++ .../admin/common/admin_am_edit_modal.dart | 841 ++++++++++++++++++ .../admin/common/admin_am_photo_frame.dart | 115 +++ .../common/admin_child_detail_modal.dart | 411 +++++++++ .../admin_children_affiliation_panel.dart | 84 ++ .../admin/common/admin_enfant_user_card.dart | 98 ++ .../admin/common/admin_parent_edit_modal.dart | 462 ++++++++++ .../admin/common/admin_status_capsule.dart | 66 ++ .../widgets/admin/common/admin_user_card.dart | 62 +- .../common/validation_detail_section.dart | 314 ++++++- .../admin/enfant_management_widget.dart | 98 ++ .../admin/parent_managmant_widget.dart | 46 +- .../widgets/admin/user_management_panel.dart | 83 +- .../widgets/admin/validation_am_wizard.dart | 31 +- .../admin/validation_family_wizard.dart | 51 +- .../widgets/common/auth_network_image.dart | 4 + .../lib/widgets/common/identity_block.dart | 278 ++++++ 69 files changed, 6290 insertions(+), 417 deletions(-) create mode 100644 backend/scripts/close-gitea-issue-131.js create mode 100644 backend/scripts/update-gitea-issue-140-ch6-famille.js create mode 100644 backend/src/common/utils/sanitize-user-for-api.spec.ts create mode 100644 backend/src/common/utils/sanitize-user-for-api.ts create mode 100644 backend/src/entities/am_children.entity.ts create mode 100644 backend/src/routes/assistantes_maternelles/assistantes_maternelles.mapper.ts create mode 100644 backend/src/routes/assistantes_maternelles/dto/update-am-fiche-admin.dto.ts create mode 100644 backend/src/routes/parents/dto/update-parent-fiche-admin.dto.ts create mode 100644 backend/src/routes/parents/parents.mapper.spec.ts create mode 100644 backend/src/routes/parents/parents.mapper.ts create mode 100644 database/migrations/2026_enfants_assistantes_maternelles.sql create mode 100644 docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md create mode 100644 docs/archive/temporaires/131-fiche-parent-co-parent-header.md create mode 100644 docs/archive/temporaires/TEMP_112-back-reprise-alignement-front.md create mode 100644 docs/archive/temporaires/TEMP_131-back-fiche-am-enfants.md create mode 100644 docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md create mode 100644 docs/serveur_ptitspas.code-workspace create mode 100644 frontend/lib/models/enfant_admin_model.dart create mode 100644 frontend/lib/models/parent_child_summary.dart create mode 100644 frontend/lib/utils/am_vigilance.dart create mode 100644 frontend/lib/utils/enfant_status_utils.dart create mode 100644 frontend/lib/widgets/admin/common/admin_am_children_capacity_grid.dart create mode 100644 frontend/lib/widgets/admin/common/admin_am_edit_modal.dart create mode 100644 frontend/lib/widgets/admin/common/admin_am_photo_frame.dart create mode 100644 frontend/lib/widgets/admin/common/admin_child_detail_modal.dart create mode 100644 frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart create mode 100644 frontend/lib/widgets/admin/common/admin_enfant_user_card.dart create mode 100644 frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart create mode 100644 frontend/lib/widgets/admin/common/admin_status_capsule.dart create mode 100644 frontend/lib/widgets/admin/enfant_management_widget.dart create mode 100644 frontend/lib/widgets/common/identity_block.dart diff --git a/backend/scripts/close-gitea-issue-131.js b/backend/scripts/close-gitea-issue-131.js new file mode 100644 index 0000000..756a8e8 --- /dev/null +++ b/backend/scripts/close-gitea-issue-131.js @@ -0,0 +1,123 @@ +/** + * Commentaire de clôture + fermeture issue Gitea #131. + * Usage: node backend/scripts/close-gitea-issue-131.js + */ +const https = require('https'); +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.join(__dirname, '../..'); +const ISSUE = 131; +const REPO = 'jmartin/petitspas'; + +const body = `## Fermeture ticket #131 — livré sur \`develop\` et \`master\` + +Branche **\`feature/131\`** mergée dans **\`develop\`**, puis squash merge **\`develop\` → \`master\`** (déploiement production). + +### Fiche parent (dashboard admin) +- Modale **\`AdminParentEditModal\`** éditable dès l'ouverture +- En-tête dynamique : **nom/prénom** + sous-titre **co-parent** (si connu) +- Gélule **statut** modifiable +- **\`PATCH /api/v1/parents/:id/fiche\`** — identité + statut +- **\`GET /api/v1/parents\`** — liste parents (fix décorateur \`@Get()\` manquant) +- Réponses API : \`co_parent\` peuplé, secrets user masqués (\`sanitizeUserForApi\`) +- Liste enfants en bas de fiche + rattachement/détachement + +### Fiche AM (dashboard admin) +- Modale **\`AdminAmEditModal\`** — 3 onglets : Identité | Fiche pro | Enfants accueillis +- **\`PATCH /api/v1/assistantes-maternelles/:id/fiche\`** — identité + champs pro (NIR, date/lieu naissance, date agrément, places, disponibilité) +- **\`POST/DELETE …/enfants/:enfantId\`** — rattacher / détacher un enfant +- Grille capacité **\`AdminAmChildrenCapacityGrid\`** (2×2) +- Rattachement enfants **différé jusqu'à Sauvegarder** (pas d'appel API immédiat) + +### Back — placement AM ↔ enfant +- Table **\`enfants_assistantes_maternelles\`** (placement temporel, 1 garde active/enfant) +- Enum enfant : \`a_naitre\`, \`garde\`, \`sans_garde\`, \`scolarise\` — **plus \`actif\`** +- Rattachement → statut enfant \`garde\` ; détachement → \`sans_garde\` +- Migration : \`database/migrations/2026_enfants_assistantes_maternelles.sql\` +- Schéma canonique : \`database/BDD.sql\` + +### Front — statuts & polish +- **\`enfant_status_utils.dart\`** — libellés/couleurs \`garde\`/\`sans_garde\` +- Mise à jour cartes enfants, modale détail, filtres dashboard + +### Correctifs recette +- \`GET /parents\` 404 → ajout \`@Get()\` sur \`getAll()\` +- Sauvegarde AM 400 → alignement DTO \`UpdateAmFicheAdminDto\` sur payload front +- Crash NIR → fix \`toISOString\` + pas d'effacement NIR (colonne NOT NULL) + +### Hors périmètre #131 (tickets voisins) +- **#137** onglet Enfants global · **#138** fiche enfant complète · **#115/#116** affiliation avancée + +--- +*Issue fermée après merge sur \`develop\` + \`master\` et déploiement production.*`; + +let token = process.env.GITEA_TOKEN; +if (!token) { + try { + const tokenFile = path.join(repoRoot, '.gitea-token'); + if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim(); + } catch (_) {} +} +if (!token) { + try { + const briefing = fs.readFileSync( + path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'), + 'utf8', + ); + const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/); + if (m) token = m[1].trim(); + } catch (_) {} +} +if (!token) { + console.error('Token non trouvé : .gitea-token ou GITEA_TOKEN'); + process.exit(1); +} + +function request(method, apiPath, payloadObj) { + const payload = payloadObj ? JSON.stringify(payloadObj) : null; + return new Promise((resolve, reject) => { + const opts = { + hostname: 'git.ptits-pas.fr', + path: `/api/v1/repos/${REPO}${apiPath}`, + method, + headers: { + Authorization: 'token ' + token, + 'Content-Type': 'application/json', + ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}), + }, + }; + const req = https.request(opts, (res) => { + let d = ''; + res.on('data', (c) => (d += c)); + res.on('end', () => { + if (res.statusCode !== 200 && res.statusCode !== 201) { + reject(new Error(`HTTP ${res.statusCode}: ${d}`)); + return; + } + try { + resolve(d ? JSON.parse(d) : {}); + } catch (_) { + resolve({}); + } + }); + }); + req.on('error', reject); + if (payload) req.write(payload); + req.end(); + }); +} + +(async () => { + try { + console.log(`POST commentaire issue #${ISSUE}...`); + await request('POST', `/issues/${ISSUE}/comments`, { body }); + console.log('Commentaire publié.'); + console.log(`PATCH fermeture issue #${ISSUE}...`); + await request('PATCH', `/issues/${ISSUE}`, { state: 'closed' }); + console.log(`Issue #${ISSUE} fermée.`); + } catch (e) { + console.error(e.message || e); + process.exit(1); + } +})(); diff --git a/backend/scripts/update-gitea-issue-140-ch6-famille.js b/backend/scripts/update-gitea-issue-140-ch6-famille.js new file mode 100644 index 0000000..d2f0992 --- /dev/null +++ b/backend/scripts/update-gitea-issue-140-ch6-famille.js @@ -0,0 +1,153 @@ +/** + * Met à jour l'issue Gitea #140 — epic dashboard admin ch.6 famille. + * Usage: node backend/scripts/update-gitea-issue-140-ch6-famille.js + * Token : .gitea-token (racine), GITEA_TOKEN, ou docs/27_BRIEFING-FRONTEND.md + */ +const https = require('https'); +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.join(__dirname, '../..'); +let token = process.env.GITEA_TOKEN; +if (!token) { + try { + const tokenFile = path.join(repoRoot, '.gitea-token'); + if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim(); + } catch (_) {} +} +if (!token) { + try { + const briefing = fs.readFileSync( + path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'), + 'utf8', + ); + const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/); + if (m) token = m[1].trim(); + } catch (_) {} +} +if (!token) { + console.error('Token non trouvé : .gitea-token ou GITEA_TOKEN (voir docs/26_GITEA-API.md)'); + process.exit(1); +} + +const body = `## Rôle de ce ticket + +**#140 est un ticket epic / livraison** : il regroupe la mise en œuvre du **chapitre 6** du doc [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](../docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) (§6.1 et §6.2) sur la branche \`feature/140-dashboard-admin-ch6-famille\`. + +Il **ne remplace pas** les tickets détaillés ci-dessous : il sert de **fil de livraison** (PR, recette, fermeture coordonnée). Chaque sous-ticket garde son périmètre propre ; #140 est clos quand l'ensemble est **fonctionnel et homogène en UI**. + +--- + +## Tickets couverts (périmètres embarqués) + +| Ticket | Sujet | Rôle dans #140 | +|--------|--------|----------------| +| **#115** | Rattachement parent — **backend** | API \`POST/DELETE …/enfants/:enfantId\` | +| **#116** | Rattachement parent — **frontend** | UI rattacher / détacher depuis la fiche parent | +| **#130** | \`UserService\` — APIs admin | Appels \`getParents\`, \`getParent\`, \`getEnfants\`, \`updateParentFiche\`, etc. | +| **#131** | Fiche parent éditable | Modale parent (dashboard) — *hors fiche AM* | +| **#137** | Onglet **Enfants** | Liste globale admin + accès fiche enfant | +| **#138** | Fiche enfant + liste dans fiche parent | \`AdminChildDetailModal\` + liste enfants en bas de fiche parent | + +> **Note :** fermer #140 peut entraîner la fermeture **partielle ou totale** de ces tickets selon ce qui est réellement livré et recetté dans la PR. + +--- + +## Hors scope #140 + +- **§6.3** — Création dossier admin sans numéro → **#129** +- Fiche **AM** éditable (dashboard) → reste **#131** (partie AM) +- Parcours gestionnaire « famille complexe » → **#139** +- Qualification responsable légal (combobox sur lien) → ticket à créer + +--- + +## Backend (attendu / livré) + +- [x] \`PATCH /parents/:id/fiche\` — édition fiche parent (admin/gestionnaire) +- [x] \`POST /parents/:id/enfants/:enfantId\` — rattacher un enfant existant +- [x] \`DELETE /parents/:id/enfants/:enfantId\` — détacher (garde-fou : ≥1 responsable / enfant) +- [x] \`GET /enfants\` enrichi ; \`PATCH /enfants/:id\` pour gestionnaire + +--- + +## Frontend — fait + +- [x] Modale **fiche parent** éditable (shell aligné validation, statut gélule, téléphone formaté, \`IdentityBlock\`) +- [x] Onglet **Enfants** — liste globale (\`EnfantManagementWidget\`) +- [x] Liste enfants dans fiche parent — cartes (photo, nom, âge ans/mois, statut), cadre blanc, scroll 2,5 lignes +- [x] Rattacher / détacher un enfant **existant** (API branchée) +- [x] Parsing robuste \`parentChildren\` + URLs médias \`/uploads\` en Flutter web +- [x] \`UserService\` — APIs parents / enfants / affiliation + +--- + +## Frontend — reste à faire (bloquant clôture) + +### Fiche enfant — #138 (UI) +- [ ] Reprendre \`AdminChildDetailModal\` : même **look & feel** que fiche parent / modales validation (largeur ~930 px, grille champs, photo enfant) +- [ ] Aligner dates, genre, statut sur les wizards validation famille + +### Modale **rattacher** un enfant — #116 (UI) +- [ ] Remplacer le \`SimpleDialog\` actuel par une modale cohérente : liste type \`AdminEnfantUserCard\` (photo, nom, âge), recherche éventuelle + +### Création d'un **nouvel** enfant depuis la fiche parent — doc §6.2 +- [ ] **Non implémenté** aujourd'hui (seul le rattachement d'un enfant déjà en base existe) +- [ ] À trancher : inclus dans #140 si le back expose un \`POST\` admin depuis le contexte parent, sinon ticket dédié / extension #129 + +--- + +## Recette avant merge + +1. Parent avec 0 / 1 / 3+ enfants — liste, scroll, compteur +2. Rattacher puis détacher (message si dernier responsable) +3. Clic enfant → fiche enfant (après refonte UI) +4. Onglet Enfants — même rendu cartes +5. Avatars photos (URLs absolues web) + +--- + +## Branche + +\`feature/140-dashboard-admin-ch6-famille\` + +## Références + +- Doc produit : \`docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md\` §6.1, §6.2`; + +const payload = JSON.stringify({ body }); + +const req = https.request( + { + hostname: 'git.ptits-pas.fr', + path: '/api/v1/repos/jmartin/petitspas/issues/140', + method: 'PATCH', + headers: { + Authorization: `token ${token}`, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + }, + }, + (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + const json = JSON.parse(data); + console.log('Issue #140 mise à jour :', json.html_url); + console.log('updated_at:', json.updated_at); + } else { + console.error('Erreur', res.statusCode, data); + process.exit(1); + } + }); + }, +); +req.on('error', (err) => { + console.error(err); + process.exit(1); +}); +req.write(payload); +req.end(); diff --git a/backend/src/common/utils/sanitize-user-for-api.spec.ts b/backend/src/common/utils/sanitize-user-for-api.spec.ts new file mode 100644 index 0000000..c4ad2b2 --- /dev/null +++ b/backend/src/common/utils/sanitize-user-for-api.spec.ts @@ -0,0 +1,32 @@ +import { sanitizeUserForApi } from './sanitize-user-for-api'; +import { RoleType, StatutUtilisateurType, Users } from '../../entities/users.entity'; + +describe('sanitizeUserForApi', () => { + const base: Users = { + id: 'u1', + email: 'a@b.fr', + prenom: 'Paul', + nom: 'Parent', + role: RoleType.PARENT, + statut: StatutUtilisateurType.ACTIF, + password: 'hash', + token_creation_mdp: 'tok', + token_creation_mdp_expire_le: new Date(), + password_reset_token: 'rst', + password_reset_expires: new Date(), + } as Users; + + it('retire password et tokens', () => { + const out = sanitizeUserForApi(base)!; + expect(out.prenom).toBe('Paul'); + expect(out.nom).toBe('Parent'); + expect(out.password).toBeUndefined(); + expect(out.token_creation_mdp).toBeUndefined(); + expect(out.password_reset_token).toBeUndefined(); + }); + + it('retourne undefined si user absent', () => { + expect(sanitizeUserForApi(null)).toBeUndefined(); + expect(sanitizeUserForApi(undefined)).toBeUndefined(); + }); +}); diff --git a/backend/src/common/utils/sanitize-user-for-api.ts b/backend/src/common/utils/sanitize-user-for-api.ts new file mode 100644 index 0000000..dcd7e89 --- /dev/null +++ b/backend/src/common/utils/sanitize-user-for-api.ts @@ -0,0 +1,23 @@ +import { Users } from 'src/entities/users.entity'; + +/** Champs sensibles exclus des réponses API (ticket #131 — user / co_parent). */ +const SENSITIVE_USER_KEYS: (keyof Users)[] = [ + 'password', + 'token_creation_mdp', + 'token_creation_mdp_expire_le', + 'password_reset_token', + 'password_reset_expires', +]; + +/** + * Retourne une copie utilisateur sans secrets (hash MDP, tokens). + * Utilisé pour `user` et `co_parent` dans les réponses Parents. + */ +export function sanitizeUserForApi(user?: Users | null): Users | undefined { + if (!user) return undefined; + const safe = { ...user } as Users; + for (const key of SENSITIVE_USER_KEYS) { + delete safe[key]; + } + return safe; +} diff --git a/backend/src/entities/am_children.entity.ts b/backend/src/entities/am_children.entity.ts new file mode 100644 index 0000000..06d209b --- /dev/null +++ b/backend/src/entities/am_children.entity.ts @@ -0,0 +1,47 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, +} from 'typeorm'; +import { AssistanteMaternelle } from './assistantes_maternelles.entity'; +import { Children } from './children.entity'; +import { Users } from './users.entity'; + +@Entity('enfants_assistantes_maternelles', { schema: 'public' }) +export class AmChildren { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'id_am', type: 'uuid' }) + amId: string; + + @Column({ name: 'id_enfant', type: 'uuid' }) + enfantId: string; + + @Column({ name: 'date_debut', type: 'date' }) + date_debut: Date; + + @Column({ name: 'date_fin', type: 'date', nullable: true }) + date_fin?: Date; + + @CreateDateColumn({ name: 'cree_le', type: 'timestamptz' }) + cree_le: Date; + + @Column({ name: 'cree_par', type: 'uuid', nullable: true }) + cree_par?: string; + + @ManyToOne(() => AssistanteMaternelle, (am) => am.amChildren, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'id_am', referencedColumnName: 'user_id' }) + am: AssistanteMaternelle; + + @ManyToOne(() => Children, (c) => c.amLinks, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'id_enfant', referencedColumnName: 'id' }) + child: Children; + + @ManyToOne(() => Users, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'cree_par', referencedColumnName: 'id' }) + createdBy?: Users; +} diff --git a/backend/src/entities/assistantes_maternelles.entity.ts b/backend/src/entities/assistantes_maternelles.entity.ts index 77db5f1..65d46b8 100644 --- a/backend/src/entities/assistantes_maternelles.entity.ts +++ b/backend/src/entities/assistantes_maternelles.entity.ts @@ -1,5 +1,6 @@ -import { Entity, PrimaryColumn, Column, OneToOne, JoinColumn } from 'typeorm'; +import { Entity, PrimaryColumn, Column, OneToOne, OneToMany, JoinColumn } from 'typeorm'; import { Users } from './users.entity'; +import { AmChildren } from './am_children.entity'; @Entity('assistantes_maternelles') export class AssistanteMaternelle { @@ -51,4 +52,7 @@ export class AssistanteMaternelle { /** Numéro de dossier (format AAAA-NNNNNN), même valeur que sur utilisateurs (ticket #103) */ @Column({ name: 'numero_dossier', length: 20, nullable: true }) numero_dossier?: string; + + @OneToMany(() => AmChildren, (ac) => ac.am) + amChildren: AmChildren[]; } diff --git a/backend/src/entities/children.entity.ts b/backend/src/entities/children.entity.ts index 3dd691f..3c1db67 100644 --- a/backend/src/entities/children.entity.ts +++ b/backend/src/entities/children.entity.ts @@ -4,12 +4,14 @@ import { } from 'typeorm'; import { Parents } from './parents.entity'; import { ParentsChildren } from './parents_children.entity'; +import { AmChildren } from './am_children.entity'; import { Dossier } from './dossiers.entity'; export enum StatutEnfantType { A_NAITRE = 'a_naitre', - ACTIF = 'actif', SCOLARISE = 'scolarise', + GARDE = 'garde', + SANS_GARDE = 'sans_garde', } export enum GenreType { @@ -68,6 +70,9 @@ export class Children { @OneToMany(() => ParentsChildren, pc => pc.child) parentLinks: ParentsChildren[]; + @OneToMany(() => AmChildren, (ac) => ac.child) + amLinks: AmChildren[]; + // Relation avec Dossier @OneToMany(() => Dossier, d => d.child) dossiers: Dossier[]; diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts index 47d51eb..540c73e 100644 --- a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.controller.ts @@ -12,11 +12,14 @@ import { AssistantesMaternellesService } from './assistantes_maternelles.service import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { Roles } from 'src/common/decorators/roles.decorator'; -import { RoleType } from 'src/entities/users.entity'; +import { RoleType, Users } from 'src/entities/users.entity'; import { CreateAssistanteDto } from '../user/dto/create_assistante.dto'; import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto'; +import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto'; import { RolesGuard } from 'src/common/guards/roles.guard'; import { AuthGuard } from 'src/common/guards/auth.guard'; +import { User } from 'src/common/decorators/user.decorator'; +import { mapAmForApi, mapAmsForApi } from './assistantes_maternelles.mapper'; @ApiTags("Assistantes Maternelles") @ApiBearerAuth('access-token') @@ -31,28 +34,74 @@ export class AssistantesMaternellesController { @ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' }) @ApiBody({ type: CreateAssistanteDto }) @Post() - create(@Body() dto: CreateAssistanteDto): Promise { - return this.assistantesMaternellesService.create(dto); + async create(@Body() dto: CreateAssistanteDto): Promise { + const am = await this.assistantesMaternellesService.create(dto); + return mapAmForApi(am); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Get() - @ApiOperation({ summary: 'Récupérer la liste des nounous' }) + @ApiOperation({ summary: 'Récupérer la liste des nounous (inclut amChildren actifs) — ticket #131' }) @ApiResponse({ status: 200, description: 'Liste des nounous' }) @ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' }) - getAll(): Promise { - return this.assistantesMaternellesService.findAll(); + async getAll(): Promise { + const ams = await this.assistantesMaternellesService.findAll(); + return mapAmsForApi(ams); } - @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Get(':id') @ApiParam({ name: 'id', description: "UUID de la nounou" }) - @ApiOperation({ summary: 'Récupérer une nounou par id' }) + @ApiOperation({ summary: 'Récupérer une nounou par id (inclut amChildren) — ticket #131' }) @ApiResponse({ status: 200, description: 'Détails de la nounou' }) @ApiResponse({ status: 404, description: 'Nounou non trouvée' }) - @ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' }) - getOne(@Param('id') user_id: string): Promise { - return this.assistantesMaternellesService.findOne(user_id); + @ApiResponse({ status: 403, description: 'Accès refusé' }) + async getOne(@Param('id') user_id: string): Promise { + const am = await this.assistantesMaternellesService.findOne(user_id); + return mapAmForApi(am); + } + + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) + @Patch(':id/fiche') + @ApiBody({ type: UpdateAmFicheAdminDto }) + @ApiOperation({ summary: 'Mettre à jour la fiche AM (identité + pro) — ticket #131' }) + @ApiParam({ name: 'id', description: "UUID utilisateur de l'AM" }) + @ApiResponse({ status: 200, description: 'Fiche AM mise à jour' }) + async updateFicheAdmin( + @Param('id') id: string, + @Body() dto: UpdateAmFicheAdminDto, + ): Promise { + const am = await this.assistantesMaternellesService.updateFicheAdmin(id, dto); + return mapAmForApi(am); + } + + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) + @Post(':id/enfants/:enfantId') + @ApiOperation({ summary: 'Rattacher un enfant à une AM (statut enfant → garde) — ticket #131' }) + @ApiParam({ name: 'id', description: "UUID utilisateur de l'AM" }) + @ApiParam({ name: 'enfantId', description: "UUID de l'enfant" }) + @ApiResponse({ status: 200, description: 'AM avec enfants mis à jour' }) + async attachEnfant( + @Param('id') id: string, + @Param('enfantId') enfantId: string, + @User() currentUser: Users, + ): Promise { + const am = await this.assistantesMaternellesService.attachEnfant(id, enfantId, currentUser); + return mapAmForApi(am); + } + + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) + @Delete(':id/enfants/:enfantId') + @ApiOperation({ summary: "Clôturer le placement d'un enfant chez une AM — ticket #131" }) + @ApiParam({ name: 'id', description: "UUID utilisateur de l'AM" }) + @ApiParam({ name: 'enfantId', description: "UUID de l'enfant" }) + @ApiResponse({ status: 200, description: 'AM avec enfants mis à jour' }) + async detachEnfant( + @Param('id') id: string, + @Param('enfantId') enfantId: string, + ): Promise { + const am = await this.assistantesMaternellesService.detachEnfant(id, enfantId); + return mapAmForApi(am); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) @@ -63,14 +112,15 @@ export class AssistantesMaternellesController { @ApiResponse({ status: 404, description: 'Nounou non trouvée' }) @ApiParam({ name: 'id', description: "UUID de la nounou" }) @Patch(':id') - update(@Param('id') id: string, @Body() dto: UpdateAssistanteDto): Promise { - return this.assistantesMaternellesService.update(id, dto); + async update(@Param('id') id: string, @Body() dto: UpdateAssistanteDto): Promise { + const am = await this.assistantesMaternellesService.update(id, dto); + return mapAmForApi(am); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @ApiOperation({ summary: 'Supprimer une nounou' }) @ApiResponse({ status: 200, description: 'Nounou supprimée avec succès' }) - @ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins, gestionnaires et administrateurs' }) + @ApiResponse({ status: 403, description: 'Accès refusé' }) @ApiResponse({ status: 404, description: 'Nounou non trouvée' }) @ApiParam({ name: 'id', description: "UUID de la nounou" }) @Delete(':id') diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.mapper.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.mapper.ts new file mode 100644 index 0000000..cc33c4c --- /dev/null +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.mapper.ts @@ -0,0 +1,28 @@ +import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; +import { sanitizeUserForApi } from '../../common/utils/sanitize-user-for-api'; + +/** + * Sérialisation API fiche AM — ticket #131. + * Expose `amChildren` actifs (date_fin null) avec enfant imbriqué, sans secrets user. + */ +export function mapAmForApi(am: AssistanteMaternelle): AssistanteMaternelle { + const activeChildren = (am.amChildren ?? []).filter((link) => !link.date_fin); + + return { + ...am, + user: sanitizeUserForApi(am.user)!, + amChildren: activeChildren.map((link) => ({ + ...link, + child: link.child, + })), + }; +} + +export function mapAmsForApi(ams: AssistanteMaternelle[]): AssistanteMaternelle[] { + return ams.map(mapAmForApi); +} + +export function filterActiveAmChildren(links: AmChildren[] | undefined): AmChildren[] { + return (links ?? []).filter((link) => !link.date_fin); +} diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.module.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.module.ts index 9e2e231..54a10ae 100644 --- a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.module.ts +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.module.ts @@ -2,12 +2,14 @@ import { Module } from '@nestjs/common'; import { AssistantesMaternellesService } from './assistantes_maternelles.service'; import { AssistantesMaternellesController } from './assistantes_maternelles.controller'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; +import { Children } from 'src/entities/children.entity'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Users } from 'src/entities/users.entity'; import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [TypeOrmModule.forFeature([AssistanteMaternelle, Users]), + imports: [TypeOrmModule.forFeature([AssistanteMaternelle, AmChildren, Children, Users]), AuthModule ], controllers: [AssistantesMaternellesController], diff --git a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts index c9ba68f..ced0e0e 100644 --- a/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts +++ b/backend/src/routes/assistantes_maternelles/assistantes_maternelles.service.ts @@ -5,11 +5,17 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { IsNull, Repository } from 'typeorm'; import { RoleType, Users } from 'src/entities/users.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; +import { AmChildren } from 'src/entities/am_children.entity'; +import { Children, StatutEnfantType } from 'src/entities/children.entity'; import { CreateAssistanteDto } from '../user/dto/create_assistante.dto'; import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto'; +import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto'; +import { validateNir } from 'src/common/utils/nir.util'; + +const AM_CHILDREN_RELATIONS = ['user', 'amChildren', 'amChildren.child'] as const; @Injectable() export class AssistantesMaternellesService { @@ -17,10 +23,13 @@ export class AssistantesMaternellesService { @InjectRepository(AssistanteMaternelle) private readonly assistantesMaternelleRepository: Repository, @InjectRepository(Users) - private readonly usersRepository: Repository + private readonly usersRepository: Repository, + @InjectRepository(AmChildren) + private readonly amChildrenRepository: Repository, + @InjectRepository(Children) + private readonly childrenRepository: Repository, ) {} - // Création d’une assistante maternelle async create(dto: CreateAssistanteDto): Promise { const user = await this.usersRepository.findOneBy({ id: dto.user_id }); if (!user) throw new NotFoundException('Utilisateur introuvable'); @@ -49,30 +58,208 @@ export class AssistantesMaternellesService { return this.assistantesMaternelleRepository.save(entity); } - // Liste des assistantes maternelles async findAll(): Promise { return this.assistantesMaternelleRepository.find({ - relations: ['user'], + relations: [...AM_CHILDREN_RELATIONS], }); } - // Récupérer une assistante maternelle par user_id async findOne(user_id: string): Promise { const assistante = await this.assistantesMaternelleRepository.findOne({ where: { user_id }, - relations: ['user'], + relations: [...AM_CHILDREN_RELATIONS], }); if (!assistante) throw new NotFoundException('Assistante maternelle introuvable'); return assistante; } - // Mise à jour async update(id: string, dto: UpdateAssistanteDto): Promise { await this.assistantesMaternelleRepository.update(id, dto); return this.findOne(id); } - // Suppression d’une assistante maternelle + /** + * Mise à jour fiche AM (identité + champs pro) par admin/gestionnaire. Ticket #131. + */ + async updateFicheAdmin(amUserId: string, dto: UpdateAmFicheAdminDto): Promise { + const am = await this.findOne(amUserId); + const user = am.user; + + if (dto.email && dto.email !== user.email) { + const existing = await this.usersRepository.findOne({ where: { email: dto.email } }); + if (existing && existing.id !== user.id) { + throw new ConflictException('Cet email est déjà utilisé'); + } + user.email = dto.email; + } + + if (dto.nom !== undefined) user.nom = dto.nom; + if (dto.prenom !== undefined) user.prenom = dto.prenom; + 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; + if (dto.statut !== undefined) user.statut = dto.statut; + if (dto.date_naissance !== undefined) { + user.date_naissance = dto.date_naissance ? new Date(dto.date_naissance) : undefined; + } + if (dto.lieu_naissance_ville !== undefined) { + user.lieu_naissance_ville = dto.lieu_naissance_ville || undefined; + } + if (dto.lieu_naissance_pays !== undefined) { + user.lieu_naissance_pays = dto.lieu_naissance_pays || undefined; + } + + await this.usersRepository.save(user); + + const amPatch: Partial = {}; + if (dto.approval_number !== undefined) amPatch.approval_number = dto.approval_number; + if (dto.residence_city !== undefined) amPatch.residence_city = dto.residence_city; + if (dto.max_children !== undefined) amPatch.max_children = dto.max_children; + if (dto.places_available !== undefined) amPatch.places_available = dto.places_available; + if (dto.biography !== undefined) amPatch.biography = dto.biography; + if (dto.available !== undefined) amPatch.available = dto.available; + if (dto.agreement_date !== undefined) { + amPatch.agreement_date = dto.agreement_date ? new Date(dto.agreement_date) : undefined; + } + + if (dto.nir !== undefined) { + const nirNormalized = dto.nir.replace(/\s/g, '').toUpperCase(); + if (nirNormalized) { + const dateNaissanceForNir = + dto.date_naissance ?? + (user.date_naissance instanceof Date + ? user.date_naissance.toISOString().slice(0, 10) + : user.date_naissance + ? String(user.date_naissance).slice(0, 10) + : undefined); + const nirValidation = validateNir(nirNormalized, { + dateNaissance: dateNaissanceForNir, + }); + if (!nirValidation.valid) { + throw new BadRequestException(nirValidation.error || 'NIR invalide'); + } + const nirDejaUtilise = await this.assistantesMaternelleRepository.findOne({ + where: { nir: nirNormalized }, + }); + if (nirDejaUtilise && nirDejaUtilise.user_id !== amUserId) { + throw new ConflictException( + 'Un compte assistante maternelle avec ce numéro NIR existe déjà.', + ); + } + amPatch.nir = nirNormalized; + } + // NIR vide : ne pas effacer (colonne NOT NULL en BDD) — le front renvoie toujours la clé. + } + + if (Object.keys(amPatch).length > 0) { + await this.assistantesMaternelleRepository.update(amUserId, amPatch); + } + + return this.findOne(amUserId); + } + + /** + * Rattacher un enfant à une AM (placement actif). Ticket #131. + * Passe le statut enfant à `garde` (sauf a_naitre / scolarise). + */ + async attachEnfant(amUserId: string, enfantId: string, createdBy?: Users): Promise { + const am = await this.findOne(amUserId); + + const existingForAm = await this.amChildrenRepository.findOne({ + where: { amId: amUserId, enfantId, date_fin: IsNull() }, + }); + if (existingForAm) { + throw new ConflictException('Cet enfant est déjà rattaché à cette assistante maternelle'); + } + + const child = await this.childrenRepository.findOne({ where: { id: enfantId } }); + if (!child) { + throw new NotFoundException('Enfant introuvable'); + } + + const activeForChild = await this.amChildrenRepository.findOne({ + where: { enfantId, date_fin: IsNull() }, + }); + if (activeForChild && activeForChild.amId !== amUserId) { + throw new ConflictException( + 'Cet enfant est déjà en garde chez une autre assistante maternelle', + ); + } + + const activeCount = await this.amChildrenRepository.count({ + where: { amId: amUserId, date_fin: IsNull() }, + }); + if (am.max_children != null && activeCount >= am.max_children) { + throw new BadRequestException( + `Capacité maximale atteinte (${am.max_children} enfant(s))`, + ); + } + + await this.amChildrenRepository.save( + this.amChildrenRepository.create({ + amId: amUserId, + enfantId, + date_debut: new Date(), + cree_par: createdBy?.id, + }), + ); + + await this.applyGardeStatusOnAttach(child); + + return this.findOne(amUserId); + } + + /** + * Clôturer le placement AM ↔ enfant. Ticket #131. + * Repasse l'enfant en `sans_garde` s'il n'a plus de placement actif. + */ + async detachEnfant(amUserId: string, enfantId: string): Promise { + await this.findOne(amUserId); + + const link = await this.amChildrenRepository.findOne({ + where: { amId: amUserId, enfantId, date_fin: IsNull() }, + relations: ['child'], + }); + if (!link) { + throw new NotFoundException('Lien assistante maternelle-enfant introuvable'); + } + + link.date_fin = new Date(); + await this.amChildrenRepository.save(link); + + const remaining = await this.amChildrenRepository.count({ + where: { enfantId, date_fin: IsNull() }, + }); + if (remaining === 0 && link.child) { + await this.applySansGardeStatusOnDetach(link.child); + } + + return this.findOne(amUserId); + } + + private async applyGardeStatusOnAttach(child: Children): Promise { + if ( + child.status === StatutEnfantType.A_NAITRE || + child.status === StatutEnfantType.SCOLARISE + ) { + return; + } + child.status = StatutEnfantType.GARDE; + await this.childrenRepository.save(child); + } + + private async applySansGardeStatusOnDetach(child: Children): Promise { + if ( + child.status === StatutEnfantType.A_NAITRE || + child.status === StatutEnfantType.SCOLARISE + ) { + return; + } + child.status = StatutEnfantType.SANS_GARDE; + await this.childrenRepository.save(child); + } + async remove(id: string): Promise<{ message: string }> { await this.assistantesMaternelleRepository.delete(id); return { message: 'Assistante maternelle supprimée' }; diff --git a/backend/src/routes/assistantes_maternelles/dto/update-am-fiche-admin.dto.ts b/backend/src/routes/assistantes_maternelles/dto/update-am-fiche-admin.dto.ts new file mode 100644 index 0000000..cbac068 --- /dev/null +++ b/backend/src/routes/assistantes_maternelles/dto/update-am-fiche-admin.dto.ts @@ -0,0 +1,126 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsBoolean, + IsDateString, + IsEmail, + IsEnum, + IsInt, + IsOptional, + IsString, + Max, + MaxLength, + Min, +} from 'class-validator'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; + +/** Mise à jour fiche AM par admin/gestionnaire (doc 28 §6.1, ticket #131). */ +export class UpdateAmFicheAdminDto { + @ApiPropertyOptional({ example: 'MARTIN' }) + @IsOptional() + @IsString() + @MaxLength(100) + nom?: string; + + @ApiPropertyOptional({ example: 'Claire' }) + @IsOptional() + @IsString() + @MaxLength(100) + prenom?: string; + + @ApiPropertyOptional({ example: 'claire@example.com' }) + @IsOptional() + @IsEmail() + email?: string; + + @ApiPropertyOptional({ example: '0612345678' }) + @IsOptional() + @IsString() + @MaxLength(20) + telephone?: string; + + @ApiPropertyOptional({ example: '5 place Bellecour' }) + @IsOptional() + @IsString() + adresse?: string; + + @ApiPropertyOptional({ example: 'Lyon' }) + @IsOptional() + @IsString() + @MaxLength(150) + ville?: string; + + @ApiPropertyOptional({ example: '69002' }) + @IsOptional() + @IsString() + @MaxLength(10) + code_postal?: string; + + @ApiPropertyOptional({ enum: StatutUtilisateurType }) + @IsOptional() + @IsEnum(StatutUtilisateurType) + statut?: StatutUtilisateurType; + + @ApiPropertyOptional({ example: '123456789012345' }) + @IsOptional() + @IsString() + @MaxLength(15) + nir?: string; + + @ApiPropertyOptional({ example: '1985-03-12' }) + @IsOptional() + @IsDateString() + date_naissance?: string; + + @ApiPropertyOptional({ example: 'Lyon' }) + @IsOptional() + @IsString() + @MaxLength(100) + lieu_naissance_ville?: string; + + @ApiPropertyOptional({ example: 'France' }) + @IsOptional() + @IsString() + @MaxLength(100) + lieu_naissance_pays?: string; + + @ApiPropertyOptional({ example: 'AGR-2024-12345' }) + @IsOptional() + @IsString() + @MaxLength(50) + approval_number?: string; + + @ApiPropertyOptional({ example: '2020-01-15' }) + @IsOptional() + @IsDateString() + agreement_date?: string; + + @ApiPropertyOptional({ example: 'Lyon' }) + @IsOptional() + @IsString() + @MaxLength(100) + residence_city?: string; + + @ApiPropertyOptional({ example: 4 }) + @IsOptional() + @IsInt() + @Min(1) + @Max(10) + max_children?: number; + + @ApiPropertyOptional({ example: 2 }) + @IsOptional() + @IsInt() + @Min(0) + @Max(10) + places_available?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + biography?: string; + + @ApiPropertyOptional({ example: true }) + @IsOptional() + @IsBoolean() + available?: boolean; +} diff --git a/backend/src/routes/auth/auth.service.spec.ts b/backend/src/routes/auth/auth.service.spec.ts index e373abd..f24b719 100644 --- a/backend/src/routes/auth/auth.service.spec.ts +++ b/backend/src/routes/auth/auth.service.spec.ts @@ -176,7 +176,7 @@ describe('AuthService (#118 create-password API)', () => { 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' }], + enfants: [{ id: 'e1', first_name: 'Emma', status: 'sans_garde' }], texte_motivation: 'Motivation test', }); diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index f78a06e..308bb81 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -551,7 +551,7 @@ export class AuthService { ? new Date(enfantDto.date_previsionnelle_naissance) : undefined; enfant.photo_url = urlPhoto || undefined; - enfant.status = enfantDto.date_naissance ? StatutEnfantType.ACTIF : StatutEnfantType.A_NAITRE; + enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE; enfant.consent_photo = false; enfant.is_multiple = enfantDto.grossesse_multiple || false; @@ -1063,7 +1063,7 @@ export class AuthService { 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; + enfant.status = StatutEnfantType.SANS_GARDE; } if (enfantDto.date_previsionnelle_naissance !== undefined) { enfant.due_date = new Date(enfantDto.date_previsionnelle_naissance); diff --git a/backend/src/routes/enfants/dto/create_enfants.dto.ts b/backend/src/routes/enfants/dto/create_enfants.dto.ts index 3ede09d..7d69067 100644 --- a/backend/src/routes/enfants/dto/create_enfants.dto.ts +++ b/backend/src/routes/enfants/dto/create_enfants.dto.ts @@ -12,7 +12,7 @@ import { import { GenreType, StatutEnfantType } from 'src/entities/children.entity'; export class CreateEnfantsDto { - @ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.ACTIF }) + @ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE }) @IsEnum(StatutEnfantType) @IsNotEmpty() status: StatutEnfantType; diff --git a/backend/src/routes/enfants/enfants.controller.ts b/backend/src/routes/enfants/enfants.controller.ts index e112972..2579a45 100644 --- a/backend/src/routes/enfants/enfants.controller.ts +++ b/backend/src/routes/enfants/enfants.controller.ts @@ -83,7 +83,12 @@ export class EnfantsController { return this.enfantsService.findOne(id, currentUser); } - @Roles(RoleType.ADMINISTRATEUR, RoleType.SUPER_ADMIN, RoleType.PARENT) + @Roles( + RoleType.PARENT, + RoleType.ADMINISTRATEUR, + RoleType.SUPER_ADMIN, + RoleType.GESTIONNAIRE, + ) @Patch(':id') update( @Param('id', new ParseUUIDPipe()) id: string, diff --git a/backend/src/routes/enfants/enfants.service.ts b/backend/src/routes/enfants/enfants.service.ts index bfefedb..e4b46c4 100644 --- a/backend/src/routes/enfants/enfants.service.ts +++ b/backend/src/routes/enfants/enfants.service.ts @@ -34,7 +34,7 @@ export class EnfantsService { // Vérif métier simple if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) { - throw new BadRequestException('Un enfant actif doit avoir une date de naissance'); + throw new BadRequestException('Un enfant né doit avoir une date de naissance'); } // Vérif doublon éventuel (ex: même prénom + date de naissance pour ce parent) @@ -78,10 +78,10 @@ export class EnfantsService { return this.findOne(child.id, currentUser); } - // Liste des enfants + // Liste des enfants (admin/gestionnaire) async findAll(): Promise { return this.childrenRepository.find({ - relations: ['parentLinks'], + relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'], order: { last_name: 'ASC', first_name: 'ASC' }, }); } diff --git a/backend/src/routes/parents/dto/update-parent-fiche-admin.dto.ts b/backend/src/routes/parents/dto/update-parent-fiche-admin.dto.ts new file mode 100644 index 0000000..7d680bd --- /dev/null +++ b/backend/src/routes/parents/dto/update-parent-fiche-admin.dto.ts @@ -0,0 +1,57 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsEmail, + IsEnum, + IsOptional, + IsString, + MaxLength, +} from 'class-validator'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; + +/** Mise à jour fiche parent par admin/gestionnaire (doc 28 §6.1, ticket #131). */ +export class UpdateParentFicheAdminDto { + @ApiPropertyOptional({ example: 'Dupont' }) + @IsOptional() + @IsString() + @MaxLength(100) + nom?: string; + + @ApiPropertyOptional({ example: 'Marie' }) + @IsOptional() + @IsString() + @MaxLength(100) + prenom?: string; + + @ApiPropertyOptional({ example: 'marie.dupont@example.com' }) + @IsOptional() + @IsEmail() + email?: string; + + @ApiPropertyOptional({ example: '+33612345678' }) + @IsOptional() + @IsString() + @MaxLength(20) + telephone?: string; + + @ApiPropertyOptional({ example: '10 rue de la Paix' }) + @IsOptional() + @IsString() + adresse?: string; + + @ApiPropertyOptional({ example: 'Paris' }) + @IsOptional() + @IsString() + @MaxLength(150) + ville?: string; + + @ApiPropertyOptional({ example: '75001' }) + @IsOptional() + @IsString() + @MaxLength(10) + code_postal?: string; + + @ApiPropertyOptional({ enum: StatutUtilisateurType }) + @IsOptional() + @IsEnum(StatutUtilisateurType) + statut?: StatutUtilisateurType; +} diff --git a/backend/src/routes/parents/parents.controller.ts b/backend/src/routes/parents/parents.controller.ts index 261091f..396ac2f 100644 --- a/backend/src/routes/parents/parents.controller.ts +++ b/backend/src/routes/parents/parents.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Delete, Get, Param, Patch, @@ -16,11 +17,13 @@ import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity'; import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { CreateParentDto } from '../user/dto/create_parent.dto'; import { UpdateParentsDto } from '../user/dto/update_parent.dto'; +import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto'; import { AuthGuard } from 'src/common/guards/auth.guard'; import { RolesGuard } from 'src/common/guards/roles.guard'; import { User } from 'src/common/decorators/user.decorator'; import { PendingFamilyDto } from './dto/pending-family.dto'; import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto'; +import { mapParentForApi, mapParentsForApi } from './parents.mapper'; @ApiTags('Parents') @Controller('parents') @@ -79,21 +82,25 @@ export class ParentsController { return validated; } - @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Get() + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) + @ApiOperation({ summary: 'Liste des parents (user, co_parent, parentChildren) — ticket #131' }) @ApiResponse({ status: 200, type: [Parents], description: 'Liste des parents' }) @ApiResponse({ status: 403, description: 'Accès refusé !' }) - getAll(): Promise { - return this.parentsService.findAll(); + async getAll(): Promise { + const parents = await this.parentsService.findAll(); + return mapParentsForApi(parents); } - @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Get(':id') + @ApiOperation({ summary: 'Détail parent par user_id (inclut co_parent si id_co_parent renseigné) — ticket #131' }) @ApiResponse({ status: 200, type: Parents, description: 'Détails du parent par ID utilisateur' }) @ApiResponse({ status: 404, description: 'Parent non trouvé' }) @ApiResponse({ status: 403, description: 'Accès refusé !' }) - getOne(@Param('id') user_id: string): Promise { - return this.parentsService.findOne(user_id); + async getOne(@Param('id') user_id: string): Promise { + const parent = await this.parentsService.findOne(user_id); + return mapParentForApi(parent); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) @@ -101,8 +108,51 @@ export class ParentsController { @ApiBody({ type: CreateParentDto }) @ApiResponse({ status: 201, type: Parents, description: 'Parent créé avec succès' }) @ApiResponse({ status: 403, description: 'Accès refusé !' }) - create(@Body() dto: CreateParentDto): Promise { - return this.parentsService.create(dto); + async create(@Body() dto: CreateParentDto): Promise { + const parent = await this.parentsService.create(dto); + return mapParentForApi(parent); + } + + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) + @Patch(':id/fiche') + @ApiOperation({ summary: 'Mettre à jour la fiche parent (admin/gestionnaire) — ticket #131' }) + @ApiParam({ name: 'id', description: "UUID utilisateur du parent" }) + @ApiBody({ type: UpdateParentFicheAdminDto }) + @ApiResponse({ status: 200, type: Parents, description: 'Fiche parent mise à jour' }) + async updateFicheAdmin( + @Param('id') id: string, + @Body() dto: UpdateParentFicheAdminDto, + ): Promise { + const parent = await this.parentsService.updateFicheAdmin(id, dto); + return mapParentForApi(parent); + } + + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) + @Post(':id/enfants/:enfantId') + @ApiOperation({ summary: 'Rattacher un enfant à un parent — ticket #115' }) + @ApiParam({ name: 'id', description: "UUID utilisateur du parent" }) + @ApiParam({ name: 'enfantId', description: "UUID de l'enfant" }) + @ApiResponse({ status: 200, type: Parents, description: 'Parent avec enfants mis à jour' }) + async attachEnfant( + @Param('id') id: string, + @Param('enfantId') enfantId: string, + ): Promise { + const parent = await this.parentsService.attachEnfant(id, enfantId); + return mapParentForApi(parent); + } + + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) + @Delete(':id/enfants/:enfantId') + @ApiOperation({ summary: "Détacher un enfant d'un parent — ticket #115" }) + @ApiParam({ name: 'id', description: "UUID utilisateur du parent" }) + @ApiParam({ name: 'enfantId', description: "UUID de l'enfant" }) + @ApiResponse({ status: 200, type: Parents, description: 'Parent avec enfants mis à jour' }) + async detachEnfant( + @Param('id') id: string, + @Param('enfantId') enfantId: string, + ): Promise { + const parent = await this.parentsService.detachEnfant(id, enfantId); + return mapParentForApi(parent); } @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) @@ -111,7 +161,8 @@ export class ParentsController { @ApiResponse({ status: 200, type: Parents, description: 'Parent mis à jour avec succès' }) @ApiResponse({ status: 404, description: 'Parent introuvable' }) @ApiResponse({ status: 403, description: 'Accès refusé !' }) - update(@Param('id') id: string, @Body() dto: UpdateParentsDto): Promise { - return this.parentsService.update(id, dto); + async update(@Param('id') id: string, @Body() dto: UpdateParentsDto): Promise { + const parent = await this.parentsService.update(id, dto); + return mapParentForApi(parent); } } diff --git a/backend/src/routes/parents/parents.mapper.spec.ts b/backend/src/routes/parents/parents.mapper.spec.ts new file mode 100644 index 0000000..e34ecc0 --- /dev/null +++ b/backend/src/routes/parents/parents.mapper.spec.ts @@ -0,0 +1,38 @@ +import { mapParentForApi } from './parents.mapper'; +import { Parents } from '../../entities/parents.entity'; +import { RoleType, StatutUtilisateurType, Users } from '../../entities/users.entity'; + +describe('mapParentForApi', () => { + it('expose co_parent avec prenom/nom sans secrets', () => { + const coParent = { + id: 'cp1', + email: 'co@b.fr', + prenom: 'Clara', + nom: 'Co', + role: RoleType.PARENT, + statut: StatutUtilisateurType.ACTIF, + password: 'secret', + } as Users; + + const parent = { + user_id: 'u1', + numero_dossier: '2026-000042', + user: { + id: 'u1', + email: 'p@b.fr', + prenom: 'Paul', + nom: 'Parent', + role: RoleType.PARENT, + password: 'secret', + } as Users, + co_parent: coParent, + parentChildren: [], + } as Parents; + + const out = mapParentForApi(parent); + expect(out.co_parent?.prenom).toBe('Clara'); + expect(out.co_parent?.nom).toBe('Co'); + expect(out.co_parent?.password).toBeUndefined(); + expect(out.user.password).toBeUndefined(); + }); +}); diff --git a/backend/src/routes/parents/parents.mapper.ts b/backend/src/routes/parents/parents.mapper.ts new file mode 100644 index 0000000..5c9e517 --- /dev/null +++ b/backend/src/routes/parents/parents.mapper.ts @@ -0,0 +1,18 @@ +import { Parents } from 'src/entities/parents.entity'; +import { sanitizeUserForApi } from '../../common/utils/sanitize-user-for-api'; + +/** + * Sérialisation API fiche parent — ticket #131. + * Garantit `user`, `co_parent` (si présent) et relations sans champs sensibles. + */ +export function mapParentForApi(parent: Parents): Parents { + return { + ...parent, + user: sanitizeUserForApi(parent.user)!, + co_parent: sanitizeUserForApi(parent.co_parent), + }; +} + +export function mapParentsForApi(parents: Parents[]): Parents[] { + return parents.map(mapParentForApi); +} diff --git a/backend/src/routes/parents/parents.module.ts b/backend/src/routes/parents/parents.module.ts index 7506259..b746e87 100644 --- a/backend/src/routes/parents/parents.module.ts +++ b/backend/src/routes/parents/parents.module.ts @@ -4,6 +4,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; import { Parents } from 'src/entities/parents.entity'; import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity'; +import { ParentsChildren } from 'src/entities/parents_children.entity'; import { ParentsController } from './parents.controller'; import { ParentsService } from './parents.service'; import { Users } from 'src/entities/users.entity'; @@ -11,7 +12,7 @@ import { UserModule } from '../user/user.module'; @Module({ imports: [ - TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant]), + TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant, ParentsChildren]), forwardRef(() => UserModule), JwtModule.registerAsync({ imports: [ConfigModule], diff --git a/backend/src/routes/parents/parents.service.ts b/backend/src/routes/parents/parents.service.ts index 2e06914..2696593 100644 --- a/backend/src/routes/parents/parents.service.ts +++ b/backend/src/routes/parents/parents.service.ts @@ -17,7 +17,9 @@ import { DossierFamilleParentDto, DossierFamilleEnfantDto, } from './dto/dossier-famille-complet.dto'; +import { ParentsChildren } from 'src/entities/parents_children.entity'; import { Children } from 'src/entities/children.entity'; +import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto'; @Injectable() export class ParentsService { @@ -28,6 +30,8 @@ export class ParentsService { private readonly usersRepository: Repository, @InjectRepository(DossierFamille) private readonly dossierFamilleRepository: Repository, + @InjectRepository(ParentsChildren) + private readonly parentsChildrenRepository: Repository, ) {} // Création d’un parent @@ -62,7 +66,7 @@ export class ParentsService { // Liste des parents async findAll(): Promise { return this.parentsRepository.find({ - relations: ['user', 'co_parent', 'parentChildren', 'dossiers'], + relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers'], }); } @@ -70,7 +74,7 @@ export class ParentsService { async findOne(user_id: string): Promise { const parent = await this.parentsRepository.findOne({ where: { user_id }, - relations: ['user', 'co_parent', 'parentChildren', 'dossiers'], + relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers'], }); if (!parent) throw new NotFoundException('Parent introuvable'); return parent; @@ -82,6 +86,79 @@ export class ParentsService { return this.findOne(id); } + /** + * Mise à jour fiche parent (champs user + statut) par admin/gestionnaire. Ticket #131 / doc 28 §6.1. + */ + async updateFicheAdmin(parentUserId: string, dto: UpdateParentFicheAdminDto): Promise { + const parent = await this.findOne(parentUserId); + const user = parent.user; + + if (dto.email && dto.email !== user.email) { + const existing = await this.usersRepository.findOne({ where: { email: dto.email } }); + if (existing && existing.id !== user.id) { + throw new ConflictException('Cet email est déjà utilisé'); + } + user.email = dto.email; + } + + if (dto.nom !== undefined) user.nom = dto.nom; + if (dto.prenom !== undefined) user.prenom = dto.prenom; + 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; + if (dto.statut !== undefined) user.statut = dto.statut; + + await this.usersRepository.save(user); + return this.findOne(parentUserId); + } + + /** + * Rattacher un enfant existant à un parent (enfants_parents). Ticket #115 / doc 28 §6.2. + */ + async attachEnfant(parentUserId: string, enfantId: string): Promise { + await this.findOne(parentUserId); + + const existing = await this.parentsChildrenRepository.findOne({ + where: { parentId: parentUserId, enfantId }, + }); + if (existing) { + throw new ConflictException('Cet enfant est déjà rattaché à ce parent'); + } + + const child = await this.parentsRepository.manager.findOne(Children, { where: { id: enfantId } }); + if (!child) { + throw new NotFoundException('Enfant introuvable'); + } + + await this.parentsChildrenRepository.save( + this.parentsChildrenRepository.create({ parentId: parentUserId, enfantId }), + ); + return this.findOne(parentUserId); + } + + /** + * Détacher un enfant d'un parent sans supprimer l'enfant. Ticket #115 / doc 28 §6.2. + */ + async detachEnfant(parentUserId: string, enfantId: string): Promise { + await this.findOne(parentUserId); + + const link = await this.parentsChildrenRepository.findOne({ + where: { parentId: parentUserId, enfantId }, + }); + if (!link) { + throw new NotFoundException('Lien parent-enfant introuvable'); + } + + const totalLinks = await this.parentsChildrenRepository.count({ where: { enfantId } }); + if (totalLinks <= 1) { + throw new BadRequestException('Un enfant doit rester rattaché à au moins un responsable'); + } + + await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId }); + return this.findOne(parentUserId); + } + /** * Liste des familles en attente (une entrée par famille). * Famille = lien co_parent ou partage d'enfants (même logique que backfill #103). diff --git a/database/BDD.sql b/database/BDD.sql index b7cd431..9cd9f5e 100644 --- a/database/BDD.sql +++ b/database/BDD.sql @@ -1,3 +1,9 @@ +-- ========================================================== +-- P'titsPas — Schéma PostgreSQL (création from scratch) +-- Fichier canonique : toute nouvelle BDD doit partir d'ici. +-- Migrations dans database/migrations/ : BDD existantes uniquement. +-- ========================================================== + CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- ========================================================== @@ -5,40 +11,63 @@ CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- ========================================================== DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'role_type') THEN - CREATE TYPE role_type AS ENUM ('parent', 'gestionnaire', 'super_admin', 'administrateur', 'assistante_maternelle'); + CREATE TYPE role_type AS ENUM ( + 'parent', 'gestionnaire', 'super_admin', 'administrateur', 'assistante_maternelle' + ); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'genre_type') THEN CREATE TYPE genre_type AS ENUM ('H', 'F', 'Autre'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_utilisateur_type') THEN - CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente','actif','suspendu','refuse'); + CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente', 'actif', 'suspendu', 'refuse'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_enfant_type') THEN - CREATE TYPE statut_enfant_type AS ENUM ('a_naitre','actif','scolarise'); + CREATE TYPE statut_enfant_type AS ENUM ('a_naitre', 'garde', 'sans_garde', 'scolarise'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_dossier_type') THEN - CREATE TYPE statut_dossier_type AS ENUM ('envoye','accepte','refuse'); + CREATE TYPE statut_dossier_type AS ENUM ('envoye', 'accepte', 'refuse'); END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_contrat_type') THEN - CREATE TYPE statut_contrat_type AS ENUM ('brouillon','en_attente_signature','valide','resilie'); + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_contrat_type') THEN + CREATE TYPE statut_contrat_type AS ENUM ( + 'brouillon', 'en_attente_signature', 'valide', 'resilie' + ); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_avenant_type') THEN - CREATE TYPE statut_avenant_type AS ENUM ('propose','accepte','refuse'); + CREATE TYPE statut_avenant_type AS ENUM ('propose', 'accepte', 'refuse'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_evenement_type') THEN - CREATE TYPE type_evenement_type AS ENUM ('absence_enfant','conge_am','conge_parent','arret_maladie_am','evenement_rpe'); + CREATE TYPE type_evenement_type AS ENUM ( + 'absence_enfant', 'conge_am', 'conge_parent', 'arret_maladie_am', 'evenement_rpe' + ); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_evenement_type') THEN - CREATE TYPE statut_evenement_type AS ENUM ('propose','valide','refuse'); + CREATE TYPE statut_evenement_type AS ENUM ('propose', 'valide', 'refuse'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_validation_type') THEN - CREATE TYPE statut_validation_type AS ENUM ('en_attente','valide','refuse'); + CREATE TYPE statut_validation_type AS ENUM ('en_attente', 'valide', 'refuse'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'situation_familiale_type') THEN - CREATE TYPE situation_familiale_type AS ENUM ('celibataire','marie','concubinage','pacse','separe','divorce','veuf','parent_isole'); + CREATE TYPE situation_familiale_type AS ENUM ( + 'celibataire', 'marie', 'concubinage', 'pacse', 'separe', 'divorce', 'veuf', 'parent_isole' + ); END IF; END $$; +-- ========================================================== +-- Table : relais (avant utilisateurs — FK relais_id) +-- ========================================================== +CREATE TABLE relais ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + nom VARCHAR(255) NOT NULL, + adresse TEXT NOT NULL, + horaires_ouverture JSONB, + ligne_fixe VARCHAR(20), + actif BOOLEAN DEFAULT true, + notes TEXT, + cree_le TIMESTAMPTZ DEFAULT now(), + modifie_le TIMESTAMPTZ DEFAULT now() +); + -- ========================================================== -- Table : utilisateurs -- ========================================================== @@ -46,26 +75,33 @@ CREATE TABLE utilisateurs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email VARCHAR(255) NOT NULL UNIQUE, CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'), - password TEXT, -- NULL avant création via token + password TEXT, prenom VARCHAR(100), nom VARCHAR(100), genre genre_type, role role_type NOT NULL, statut statut_utilisateur_type DEFAULT 'en_attente', - telephone VARCHAR(20), -- Unifié (mobile privilégié) + telephone VARCHAR(20), adresse TEXT, date_naissance DATE, lieu_naissance_ville VARCHAR(100), lieu_naissance_pays VARCHAR(100), - photo_url TEXT, -- Obligatoire pour AM, non utilisé pour parents + photo_url TEXT, consentement_photo BOOLEAN DEFAULT false, date_consentement_photo TIMESTAMPTZ, - token_creation_mdp VARCHAR(255), -- Token pour créer MDP après validation - token_creation_mdp_expire_le TIMESTAMPTZ, -- Expiration 7 jours - -- Ticket #127 : réinitialisation mot de passe oublié (distinct de token_creation_mdp) - password_reset_token VARCHAR(255) NULL, - password_reset_expires TIMESTAMPTZ NULL, + token_creation_mdp VARCHAR(255), + token_creation_mdp_expire_le TIMESTAMPTZ, + password_reset_token VARCHAR(255), + password_reset_expires TIMESTAMPTZ, + token_reprise VARCHAR(255), + token_reprise_expire_le TIMESTAMPTZ, changement_mdp_obligatoire BOOLEAN DEFAULT false, + numero_dossier VARCHAR(20), + cgu_version_acceptee INTEGER, + cgu_acceptee_le TIMESTAMPTZ, + privacy_version_acceptee INTEGER, + privacy_acceptee_le TIMESTAMPTZ, + relais_id UUID REFERENCES relais(id) ON DELETE SET NULL, cree_le TIMESTAMPTZ DEFAULT now(), modifie_le TIMESTAMPTZ DEFAULT now(), ville VARCHAR(150), @@ -74,15 +110,22 @@ CREATE TABLE utilisateurs ( situation_familiale situation_familiale_type ); --- Index pour recherche par token -CREATE INDEX idx_utilisateurs_token_creation_mdp - ON utilisateurs(token_creation_mdp) +CREATE INDEX idx_utilisateurs_token_creation_mdp + ON utilisateurs(token_creation_mdp) WHERE token_creation_mdp IS NOT NULL; CREATE INDEX idx_utilisateurs_password_reset_token ON utilisateurs(password_reset_token) WHERE password_reset_token IS NOT NULL; +CREATE INDEX idx_utilisateurs_token_reprise + ON utilisateurs(token_reprise) + WHERE token_reprise IS NOT NULL; + +CREATE INDEX idx_utilisateurs_numero_dossier + ON utilisateurs(numero_dossier) + WHERE numero_dossier IS NOT NULL; + -- ========================================================== -- Table : assistantes_maternelles -- ========================================================== @@ -97,17 +140,27 @@ CREATE TABLE assistantes_maternelles ( date_agrement DATE, annee_experience SMALLINT, specialite VARCHAR(100), - place_disponible INT + place_disponible INT, + numero_dossier VARCHAR(20) ); +CREATE INDEX idx_assistantes_maternelles_numero_dossier + ON assistantes_maternelles(numero_dossier) + WHERE numero_dossier IS NOT NULL; + -- ========================================================== -- Table : parents -- ========================================================== CREATE TABLE parents ( id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE, - id_co_parent UUID REFERENCES utilisateurs(id) + id_co_parent UUID REFERENCES utilisateurs(id), + numero_dossier VARCHAR(20) ); +CREATE INDEX idx_parents_numero_dossier + ON parents(numero_dossier) + WHERE numero_dossier IS NOT NULL; + -- ========================================================== -- Table : enfants -- ========================================================== @@ -116,7 +169,7 @@ CREATE TABLE enfants ( statut statut_enfant_type, prenom VARCHAR(100), nom VARCHAR(100), - genre genre_type NOT NULL, -- Obligatoire selon CDC + genre genre_type NOT NULL, date_naissance DATE, date_prevue_naissance DATE, photo_url TEXT, @@ -135,7 +188,27 @@ CREATE TABLE enfants_parents ( ); -- ========================================================== --- Table : dossier_famille (inscription parent, schéma simplifié — ticket #119) +-- Table : enfants_assistantes_maternelles (placement AM ↔ enfant — #131) +-- ========================================================== +CREATE TABLE enfants_assistantes_maternelles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + id_am UUID NOT NULL REFERENCES assistantes_maternelles(id_utilisateur) ON DELETE CASCADE, + id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE, + date_debut DATE NOT NULL DEFAULT CURRENT_DATE, + date_fin DATE NULL, + cree_le TIMESTAMPTZ NOT NULL DEFAULT now(), + cree_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL +); + +CREATE INDEX idx_eam_am ON enfants_assistantes_maternelles(id_am); +CREATE INDEX idx_eam_enfant ON enfants_assistantes_maternelles(id_enfant); + +CREATE UNIQUE INDEX uq_enfant_garde_active + ON enfants_assistantes_maternelles (id_enfant) + WHERE date_fin IS NULL; + +-- ========================================================== +-- Table : dossier_famille (inscription parent — ticket #119) -- ========================================================== CREATE TABLE dossier_famille ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -276,10 +349,6 @@ CREATE TABLE notifications ( -- ========================================================== -- Table : validations -- ========================================================== --- Historique des décisions (validation / refus / suspension de compte). --- Colonnes commentaire + valide_par : requises par l’API Nest (TypeORM). --- FK en ON DELETE SET NULL : conserver la ligne si l’utilisateur référencé --- est supprimé (voir database/docs/FK_POLICIES.md). CREATE TABLE validations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE SET NULL, @@ -305,13 +374,10 @@ CREATE TABLE configuration ( modifie_par UUID REFERENCES utilisateurs(id) ); --- Index pour performance CREATE INDEX idx_configuration_cle ON configuration(cle); CREATE INDEX idx_configuration_categorie ON configuration(categorie); --- Seed initial de configuration INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES --- === Configuration Email (SMTP) === ('smtp_host', 'localhost', 'string', 'email', 'Serveur SMTP (ex: mail.mairie-bezons.fr, smtp.gmail.com)'), ('smtp_port', '25', 'number', 'email', 'Port SMTP (25, 465, 587)'), ('smtp_secure', 'false', 'boolean', 'email', 'Utiliser SSL/TLS (true pour port 465)'), @@ -320,14 +386,10 @@ INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES ('smtp_password', '', 'encrypted', 'email', 'Mot de passe SMTP (chiffré en AES-256)'), ('email_from_name', 'P''titsPas', 'string', 'email', 'Nom de l''expéditeur affiché dans les emails'), ('email_from_address', 'no-reply@ptits-pas.fr', 'string', 'email', 'Adresse email de l''expéditeur'), - --- === Configuration Application === ('app_name', 'P''titsPas', 'string', 'app', 'Nom de l''application (affiché dans l''interface)'), ('app_url', 'https://app.ptits-pas.fr', 'string', 'app', 'URL publique de l''application (pour les liens dans emails)'), ('app_logo_url', '/assets/logo.png', 'string', 'app', 'URL du logo de l''application'), ('setup_completed', 'false', 'boolean', 'app', 'Configuration initiale terminée'), - --- === Configuration Sécurité === ('password_reset_token_expiry_days', '7', 'number', 'security', 'Durée de validité des tokens de création/réinitialisation de mot de passe (en jours)'), ('jwt_expiry_hours', '24', 'number', 'security', 'Durée de validité des sessions JWT (en heures)'), ('max_upload_size_mb', '5', 'number', 'security', 'Taille maximale des fichiers uploadés (en MB)'), @@ -338,19 +400,18 @@ INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES -- ========================================================== CREATE TABLE documents_legaux ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - type VARCHAR(50) NOT NULL, -- 'cgu' ou 'privacy' - version INTEGER NOT NULL, -- Numéro de version (auto-incrémenté) - fichier_nom VARCHAR(255) NOT NULL, -- Nom original du fichier - fichier_path VARCHAR(500) NOT NULL, -- Chemin de stockage - fichier_hash VARCHAR(64) NOT NULL, -- Hash SHA-256 pour intégrité - actif BOOLEAN DEFAULT false, -- Version actuellement active - televerse_par UUID REFERENCES utilisateurs(id), -- Qui a uploadé - televerse_le TIMESTAMPTZ DEFAULT now(), -- Date d'upload - active_le TIMESTAMPTZ, -- Date d'activation - UNIQUE(type, version) -- Pas de doublon version + type VARCHAR(50) NOT NULL, + version INTEGER NOT NULL, + fichier_nom VARCHAR(255) NOT NULL, + fichier_path VARCHAR(500) NOT NULL, + fichier_hash VARCHAR(64) NOT NULL, + actif BOOLEAN DEFAULT false, + televerse_par UUID REFERENCES utilisateurs(id), + televerse_le TIMESTAMPTZ DEFAULT now(), + active_le TIMESTAMPTZ, + UNIQUE(type, version) ); --- Index pour performance CREATE INDEX idx_documents_legaux_type_actif ON documents_legaux(type, actif); CREATE INDEX idx_documents_legaux_version ON documents_legaux(type, version DESC); @@ -361,73 +422,23 @@ CREATE TABLE acceptations_documents ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE CASCADE, id_document UUID REFERENCES documents_legaux(id), - type_document VARCHAR(50) NOT NULL, -- 'cgu' ou 'privacy' - version_document INTEGER NOT NULL, -- Version acceptée - accepte_le TIMESTAMPTZ DEFAULT now(), -- Date d'acceptation - ip_address INET, -- IP de l'utilisateur (RGPD) - user_agent TEXT -- Navigateur (preuve) + type_document VARCHAR(50) NOT NULL, + version_document INTEGER NOT NULL, + accepte_le TIMESTAMPTZ DEFAULT now(), + ip_address INET, + user_agent TEXT ); --- Index pour performance CREATE INDEX idx_acceptations_utilisateur ON acceptations_documents(id_utilisateur); CREATE INDEX idx_acceptations_document ON acceptations_documents(id_document); --- ========================================================== --- Table : relais --- ========================================================== -CREATE TABLE relais ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - nom VARCHAR(255) NOT NULL, - adresse TEXT NOT NULL, - horaires_ouverture JSONB, - ligne_fixe VARCHAR(20), - actif BOOLEAN DEFAULT true, - notes TEXT, - cree_le TIMESTAMPTZ DEFAULT now(), - modifie_le TIMESTAMPTZ DEFAULT now() -); - --- ========================================================== --- Modification Table : utilisateurs (ajout colonnes documents et relais) --- ========================================================== -ALTER TABLE utilisateurs - ADD COLUMN IF NOT EXISTS cgu_version_acceptee INTEGER, - ADD COLUMN IF NOT EXISTS cgu_acceptee_le TIMESTAMPTZ, - ADD COLUMN IF NOT EXISTS privacy_version_acceptee INTEGER, - ADD COLUMN IF NOT EXISTS privacy_acceptee_le TIMESTAMPTZ, - ADD COLUMN IF NOT EXISTS relais_id UUID REFERENCES relais(id) ON DELETE SET NULL; - -- ========================================================== -- Ticket #103 : Numéro de dossier (AAAA-NNNNNN, séquence par année) -- ========================================================== -CREATE TABLE IF NOT EXISTS numero_dossier_sequence ( - annee INT PRIMARY KEY, - prochain INT NOT NULL DEFAULT 1 +CREATE TABLE numero_dossier_sequence ( + annee INT PRIMARY KEY, + prochain INT NOT NULL DEFAULT 1 ); -ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL; -ALTER TABLE assistantes_maternelles ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL; -ALTER TABLE parents ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL; -CREATE INDEX IF NOT EXISTS idx_utilisateurs_numero_dossier ON utilisateurs(numero_dossier) WHERE numero_dossier IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_assistantes_maternelles_numero_dossier ON assistantes_maternelles(numero_dossier) WHERE numero_dossier IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_parents_numero_dossier ON parents(numero_dossier) WHERE numero_dossier IS NOT NULL; - --- ========================================================== --- Ticket #110 : Token reprise après refus (lien email) --- ========================================================== -ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise VARCHAR(255) NULL; -ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL; -CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise ON utilisateurs(token_reprise) WHERE token_reprise IS NOT NULL; - --- ========================================================== --- Ticket #127 : Mot de passe oublié (token dédié, ≠ inscription) --- ========================================================== -ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS password_reset_token VARCHAR(255) NULL; -ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS password_reset_expires TIMESTAMPTZ NULL; -CREATE INDEX IF NOT EXISTS idx_utilisateurs_password_reset_token ON utilisateurs(password_reset_token) WHERE password_reset_token IS NOT NULL; - --- Lieu de naissance (aligné CREATE TABLE utilisateurs — idempotent si colonnes déjà présentes) -ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_ville VARCHAR(100) NULL; -ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_pays VARCHAR(100) NULL; -- ========================================================== -- Seed : Documents légaux génériques v1 @@ -438,10 +449,7 @@ INSERT INTO documents_legaux (type, version, fichier_nom, fichier_path, fichier_ -- ========================================================== -- Seed : Super Administrateur par défaut --- ========================================================== --- Email: admin@ptits-pas.fr --- Mot de passe: 4dm1n1strateur (hashé bcrypt) --- IMPORTANT: Changer ce mot de passe en production ! +-- Email: admin@ptits-pas.fr | Mot de passe: 4dm1n1strateur -- ========================================================== INSERT INTO utilisateurs ( email, diff --git a/database/README.md b/database/README.md index 98200b5..16846eb 100644 --- a/database/README.md +++ b/database/README.md @@ -15,12 +15,13 @@ Ce projet contient la **base de données** pour l'application PtitsPas, avec scr ## Structure du projet -- `migrations/` : scripts SQL pour la création et l'import de la base -- `bdd/data_test/` : fichiers CSV pour l'import de données de test -- `docs/` : documentation métier et technique -- `seed/` : scripts de seed +- **`BDD.sql`** : schéma canonique (création from scratch — utilisé par `docker-compose.yml` racine) +- `migrations/` : scripts SQL pour **mettre à jour** une BDD déjà en service +- `bdd/data_test/` : fichiers CSV (import legacy) +- `docs/` : documentation métier et technique (`ENUMS.md`, `FK_POLICIES.md`) +- `seed/` : scripts de seed (`03_seed_test_data.sql` — jeu dashboard admin) - `tests/` : tests SQL -- `docker-compose.dev.yml` : configuration Docker pour le développement +- `docker-compose.dev.yml` : Postgres standalone (BDD.sql + seed auto) --- diff --git a/database/bdd/data_test/enfants.csv b/database/bdd/data_test/enfants.csv index 1cf2ab6..43a2275 100644 --- a/database/bdd/data_test/enfants.csv +++ b/database/bdd/data_test/enfants.csv @@ -1,10 +1,10 @@ -"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo","est_multiple" -"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","actif","Emma","Dupont","F","2020-06-01",,,False,,False -"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","actif",,,,"2020-01-01","2025-01-01",,False,,False -"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","actif","Emma","Martin",,"2023-02-15",,,False,,False -"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","actif","Noah","Martin",,"2023-02-15",,,False,,False -"e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e","actif","Léa","Martin",,"2023-02-15",,,False,,False -"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","actif","Chloé","Rousseau",,"2022-04-20",,,False,,False -"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","actif","Hugo","Rousseau",,"2024-03-10",,,False,,False -"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","actif","Maxime","Lecomte",,"2023-04-15",,,False,,False -"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,,False +"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo","est_multiple" +"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","sans_garde","Emma","Dupont","F","2020-06-01",,,False,,False +"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","sans_garde",,,,"2020-01-01","2025-01-01",,False,,False +"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","sans_garde","Emma","Martin",,"2023-02-15",,,False,,False +"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","sans_garde","Noah","Martin",,"2023-02-15",,,False,,False +"e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e","sans_garde","Léa","Martin",,"2023-02-15",,,False,,False +"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","sans_garde","Chloé","Rousseau",,"2022-04-20",,,False,,False +"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","sans_garde","Hugo","Rousseau",,"2024-03-10",,,False,,False +"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","sans_garde","Maxime","Lecomte",,"2023-04-15",,,False,,False +"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,,False diff --git a/database/docker-compose.dev.yml b/database/docker-compose.dev.yml index 6c608be..74d6db2 100644 --- a/database/docker-compose.dev.yml +++ b/database/docker-compose.dev.yml @@ -14,9 +14,8 @@ services: ports: - "5433:5432" volumes: - - ./migrations/01_init.sql:/docker-entrypoint-initdb.d/01_init.sql - - ./migrations/07_import.sql:/docker-entrypoint-initdb.d/07_import.sql - - ./bdd/data_test:/bdd/data_test + - ./BDD.sql:/docker-entrypoint-initdb.d/01_init.sql + - ./seed/03_seed_test_data.sql:/docker-entrypoint-initdb.d/02_seed_test_data.sql - postgres_standalone_data:/var/lib/postgresql/data networks: - ptitspas_dev diff --git a/database/docs/ENUMS.md b/database/docs/ENUMS.md index c24e516..bda4d68 100644 --- a/database/docs/ENUMS.md +++ b/database/docs/ENUMS.md @@ -51,12 +51,17 @@ Ce document recense **toutes les valeurs énumérées** utilisées dans la base | Valeur | Description | |---|---| | `a_naitre` | Enfant à naître (date prévue renseignée) | -| `actif` | Enfant pris en charge / en cours de garde | +| `sans_garde` | Enfant né, pas encore placé chez une AM (défaut à l'inscription) | +| `garde` | Enfant placé chez une AM (`enfants_assistantes_maternelles` actif) | | `scolarise` | Enfant scolarisé, garde potentiellement périscolaire | **Contraintes associées** : - `a_naitre` → **`date_prevue_naissance` obligatoire** -- `actif`/`scolarise` → **`date_naissance` obligatoire** +- `sans_garde` / `garde` / `scolarise` → **`date_naissance` obligatoire** + +**Transitions** (API admin #131) : +- Rattachement AM ↔ enfant → `garde` (sauf `a_naitre` / `scolarise`) +- Détachement sans autre placement actif → `sans_garde` --- diff --git a/database/docs/FK_POLICIES.md b/database/docs/FK_POLICIES.md index d4686b1..2d861fa 100644 --- a/database/docs/FK_POLICIES.md +++ b/database/docs/FK_POLICIES.md @@ -32,6 +32,9 @@ Documenter, de façon unique et partagée, les règles de suppression/mise à jo | **parents(id_co_parent)** → `utilisateurs(id)` | **SET NULL** | Conserver le parent principal si co-parent disparaît | | **enfants_parents(id_parent)** → `parents(id_utilisateur)` | **CASCADE** | Nettoyage liaisons N:N | | **enfants_parents(id_enfant)** → `enfants(id)` | **CASCADE** | Idem | +| **enfants_assistantes_maternelles(id_am)** → `assistantes_maternelles(id_utilisateur)` | **CASCADE** | Placement supprimé avec l’AM | +| **enfants_assistantes_maternelles(id_enfant)** → `enfants(id)` | **CASCADE** | Idem | +| **enfants_assistantes_maternelles(cree_par)** → `utilisateurs(id)` | **SET NULL** | Historique placement conservé | | **dossiers(id_parent)** → `parents(id_utilisateur)` | **CASCADE** | Dossier n’a pas de sens sans parent | | **dossiers(id_enfant)** → `enfants(id)` | **CASCADE** | Dossier n’a pas de sens sans enfant | | **messages(id_dossier)** → `dossiers(id)` | **CASCADE** | Messages détruits avec le dossier | diff --git a/database/migrations/2026_enfants_assistantes_maternelles.sql b/database/migrations/2026_enfants_assistantes_maternelles.sql new file mode 100644 index 0000000..9175051 --- /dev/null +++ b/database/migrations/2026_enfants_assistantes_maternelles.sql @@ -0,0 +1,36 @@ +-- Ticket #131 / #141 — Placement AM ↔ enfant + statuts garde/sans_garde +-- ⚠️ BDD EXISTANTES UNIQUEMENT — ne pas exécuter sur une base créée via BDD.sql à jour. +-- Idempotent : safe à rejouer sur recette / prod. + +-- 1) Nouveaux statuts enfant +DO $$ BEGIN + ALTER TYPE statut_enfant_type ADD VALUE IF NOT EXISTS 'garde'; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TYPE statut_enfant_type ADD VALUE IF NOT EXISTS 'sans_garde'; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- actif → sans_garde (enfants sans placement AM connu au déploiement) +UPDATE enfants SET statut = 'sans_garde' WHERE statut = 'actif'; + +-- 2) Table de placement AM ↔ enfant +CREATE TABLE IF NOT EXISTS enfants_assistantes_maternelles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + id_am UUID NOT NULL REFERENCES assistantes_maternelles(id_utilisateur) ON DELETE CASCADE, + id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE, + date_debut DATE NOT NULL DEFAULT CURRENT_DATE, + date_fin DATE NULL, + cree_le TIMESTAMPTZ NOT NULL DEFAULT now(), + cree_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_eam_am ON enfants_assistantes_maternelles(id_am); +CREATE INDEX IF NOT EXISTS idx_eam_enfant ON enfants_assistantes_maternelles(id_enfant); + +-- Au plus une garde active par enfant +CREATE UNIQUE INDEX IF NOT EXISTS uq_enfant_garde_active + ON enfants_assistantes_maternelles (id_enfant) + WHERE date_fin IS NULL; diff --git a/database/seed/02_seed.sql b/database/seed/02_seed.sql index a0a109c..bd95d05 100644 --- a/database/seed/02_seed.sql +++ b/database/seed/02_seed.sql @@ -65,12 +65,12 @@ ON CONFLICT (id_utilisateur) DO NOTHING; -- ------------------------------------------------------------ -- Enfants --- - child A : déjà né (statut = 'actif' et date_naissance requise) +-- - child A : déjà né (statut = 'sans_garde' et date_naissance requise) -- - child B : à naître (statut = 'a_naitre' et date_prevue_naissance requise) -- ------------------------------------------------------------ INSERT INTO enfants (id, prenom, nom, statut, date_naissance, jumeau_multiple) -VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'actif', '2022-04-12', false) +VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'sans_garde', '2022-04-12', false) ON CONFLICT (id) DO NOTHING; INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance, jumeau_multiple) diff --git a/database/seed/03_seed_test_data.sql b/database/seed/03_seed_test_data.sql index 6d54592..8a54c7f 100644 --- a/database/seed/03_seed_test_data.sql +++ b/database/seed/03_seed_test_data.sql @@ -18,15 +18,15 @@ BEGIN; INSERT INTO utilisateurs (id, email, password, prenom, nom, role, statut, telephone, adresse, ville, code_postal, profession, situation_familiale, date_naissance, consentement_photo) VALUES - ('a0000001-0001-0001-0001-000000000001', 'sophie.bernard@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Sophie', 'BERNARD', 'administrateur', 'actif', '0678123456', '12 Avenue Gabriel Péri', 'Bezons', '95870', 'Responsable administrative', 'marie', '1978-03-15', false), - ('a0000002-0002-0002-0002-000000000002', 'lucas.moreau@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Lucas', 'MOREAU', 'gestionnaire', 'actif', '0687234567', '8 Rue Jean Jaurès', 'Bezons', '95870', 'Gestionnaire des placements', 'celibataire', '1985-09-22', false), - ('a0000003-0003-0003-0003-000000000003', 'marie.dubois@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Marie', 'DUBOIS', 'assistante_maternelle', 'actif', '0696345678', '25 Rue de la République', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1980-06-08', true), - ('a0000004-0004-0004-0004-000000000004', 'fatima.elmansouri@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Fatima', 'EL MANSOURI', 'assistante_maternelle', 'actif', '0675456789', '17 Boulevard Aristide Briand', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1975-11-12', true), - ('a0000005-0005-0005-0005-000000000005', 'claire.martin@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Claire', 'MARTIN', 'parent', 'actif', '0689567890', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Infirmière', 'marie', '1990-04-03', false), - ('a0000006-0006-0006-0006-000000000006', 'thomas.martin@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Thomas', 'MARTIN', 'parent', 'actif', '0678456789', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Ingénieur', 'marie', '1988-07-18', false), - ('a0000007-0007-0007-0007-000000000007', 'amelie.durand@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Amélie', 'DURAND', 'parent', 'actif', '0667788990', '23 Rue Victor Hugo', 'Bezons', '95870', 'Comptable', 'divorce', '1987-12-14', false), - ('a0000008-0008-0008-0008-000000000008', 'julien.rousseau@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Julien', 'ROUSSEAU', 'parent', 'actif', '0656677889', '14 Rue Pasteur', 'Bezons', '95870', 'Commercial', 'divorce', '1985-08-29', false), - ('a0000009-0009-0009-0009-000000000009', 'david.lecomte@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'David', 'LECOMTE', 'parent', 'actif', '0645566778', '31 Rue Émile Zola', 'Bezons', '95870', 'Développeur web', 'parent_isole', '1992-10-07', false) + ('a0000001-0001-0001-0001-000000000001', 'sophie.bernard@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Sophie', 'BERNARD', 'administrateur', 'actif', '0678123456', '12 Avenue Gabriel Péri', 'Bezons', '95870', 'Responsable administrative', 'marie', '1978-03-15', false), + ('a0000002-0002-0002-0002-000000000002', 'lucas.moreau@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Lucas', 'MOREAU', 'gestionnaire', 'actif', '0687234567', '8 Rue Jean Jaurès', 'Bezons', '95870', 'Gestionnaire des placements', 'celibataire', '1985-09-22', false), + ('a0000003-0003-0003-0003-000000000003', 'marie.dubois@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Marie', 'DUBOIS', 'assistante_maternelle', 'actif', '0696345678', '25 Rue de la République', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1980-06-08', true), + ('a0000004-0004-0004-0004-000000000004', 'fatima.elmansouri@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Fatima', 'EL MANSOURI', 'assistante_maternelle', 'actif', '0675456789', '17 Boulevard Aristide Briand', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1975-11-12', true), + ('a0000005-0005-0005-0005-000000000005', 'claire.martin@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Claire', 'MARTIN', 'parent', 'actif', '0689567890', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Infirmière', 'marie', '1990-04-03', false), + ('a0000006-0006-0006-0006-000000000006', 'thomas.martin@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Thomas', 'MARTIN', 'parent', 'actif', '0678456789', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Ingénieur', 'marie', '1988-07-18', false), + ('a0000007-0007-0007-0007-000000000007', 'amelie.durand@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Amélie', 'DURAND', 'parent', 'actif', '0667788990', '23 Rue Victor Hugo', 'Bezons', '95870', 'Comptable', 'divorce', '1987-12-14', false), + ('a0000008-0008-0008-0008-000000000008', 'julien.rousseau@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Julien', 'ROUSSEAU', 'parent', 'actif', '0656677889', '14 Rue Pasteur', 'Bezons', '95870', 'Commercial', 'divorce', '1985-08-29', false), + ('a0000009-0009-0009-0009-000000000009', 'david.lecomte@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'David', 'LECOMTE', 'parent', 'actif', '0645566778', '31 Rue Émile Zola', 'Bezons', '95870', 'Développeur web', 'parent_isole', '1992-10-07', false) ON CONFLICT (email) DO NOTHING; -- ========== PARENTS (avec co-parent pour le couple Martin) ========== @@ -51,12 +51,12 @@ ON CONFLICT (id_utilisateur) DO NOTHING; -- ========== ENFANTS ========== INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut, est_multiple) VALUES - ('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'actif', true), - ('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'actif', true), - ('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'actif', true), - ('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'actif', false), - ('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'actif', false), - ('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'actif', false) + ('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'sans_garde', true), + ('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'sans_garde', true), + ('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'sans_garde', true), + ('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'sans_garde', false), + ('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'sans_garde', false), + ('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'sans_garde', false) ON CONFLICT (id) DO NOTHING; -- ========== ENFANTS_PARENTS (liaison N:N) ========== diff --git a/docs/00_INDEX.md b/docs/00_INDEX.md index 2f8a601..7e5b9f6 100644 --- a/docs/00_INDEX.md +++ b/docs/00_INDEX.md @@ -36,6 +36,7 @@ Ce fichier sert d'index pour naviguer dans toute la documentation du projet. - [**23 - Liste des Tickets**](./23_LISTE-TICKETS.md) - 61 tickets Phase 1 détaillés - [**24 - Décisions Projet**](./24_DECISIONS-PROJET.md) - Décisions architecturales et fonctionnelles - [**25 - Backlog Phase 2**](./25_PHASE-2-BACKLOG.md) - Fonctionnalités techniques reportées +- [**28 - Évolution famille et responsables**](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) - Modèle dossier/famille, recompositions, v1.0.0 vs post-1.0.0 - [**26 - API Gitea**](./26_GITEA-API.md) - Procédure d'utilisation de l'API Gitea (issues, PR, branches, labels) - [**27 - Briefing frontend**](./27_BRIEFING-FRONTEND.md) - Accès Git, priorités, scripts Gitea (token) @@ -49,7 +50,7 @@ Fichiers **sans préfixe numérique** encore à la racine par **héritage** ou références outils (`.cursorrules`, etc.) — **à renommer** en `NN_` quand possible : - `CHARTE_GRAPHIQUE.md` -- `EVOLUTIONS_CDC.md` +- [`EVOLUTIONS_CDC.md`](./EVOLUTIONS_CDC.md) — écarts CDC / app ; voir aussi [**28 - Évolution famille**](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) - `SuperNounou_Cahier_Des_Charges_Complet_V1.1.md` - `SuperNounou_SSS-001.md` @@ -89,5 +90,5 @@ PgAdmin: https://app.ptits-pas.fr/pgadmin Cette documentation est maintenue par Julien Martin (julien.martin@ptits-pas.fr). -Dernière mise à jour : Novembre 2025 +Dernière mise à jour : Juin 2026 diff --git a/docs/24_DECISIONS-PROJET.md b/docs/24_DECISIONS-PROJET.md index 62bcbe5..cf38376 100644 --- a/docs/24_DECISIONS-PROJET.md +++ b/docs/24_DECISIONS-PROJET.md @@ -1,7 +1,7 @@ # 📋 Décisions Projet - P'titsPas -**Version** : 1.1 -**Date** : 9 Février 2026 +**Version** : 1.2 +**Date** : 16 Juin 2026 **Auteur** : Équipe PtitsPas --- @@ -122,6 +122,27 @@ ptitspas-app/ --- +### 5bis. Familles recomposées — contournement v1.0.0 + +**Décision** : ✅ **Second compte / second email pour les cas multi-contextes en v1.0.0** ; évolution structurelle reportée post-1.0.0 + +**Contexte** : +- Le numéro de dossier et le graphe « famille » (co-parent + enfants partagés) conviennent aux cas simples (un couple, N enfants). +- Ils ne couvrent pas proprement une même personne responsable dans **plusieurs unités familiales** (recompositions, plusieurs co-responsables successifs, tuteur/GP sur plusieurs contextes). + +**Décision v1.0.0** : +- **Contournement opérationnel** : créer un **second compte** avec **email distinct** et un **second numéro de dossier** (souvent via le gestionnaire). +- Le numéro de dossier reste la **norme** pour les cas simples, **sans obligation absolue** pour les cas complexes. +- Rôle applicatif unique **`parent`** pour tous les responsables (tuteur, GP inclus) ; qualification juridique = évolution ultérieure. + +**Évolution post-1.0.0** : +- **Parcours gestionnaire « famille complexe »** (§ 7.5 doc 28) : N responsables, **M un seul compte** avec tous ses enfants ; **A / B** limités à leur enfant via `enfants_parents`. +- Visibilité et workflows **sans fusion graphe familial** ; numéro de dossier optionnel ; qualification du lien responsable–enfant. + +**Référence** : [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) — § 4.4, § 7.5 + +--- + ### 6. Genre enfant obligatoire (H/F) **Décision** : ✅ **Genre obligatoire (H/F uniquement)** @@ -562,6 +583,7 @@ docs/ | 14 | Migration données | ❌ Rejeté | N/A | | 15 | Doc utilisateur | ⏸️ Phase 2 | Formation | | 31 | Logs Winston | ✅ Phase 1 | Monitoring | +| 5bis | Familles recomposées — 2ᵉ compte v1.0.0 | ✅ v1.0.0 | Métier / dossier | --- @@ -571,10 +593,12 @@ docs/ |------|---------|---------------| | 25/11/2025 | 1.0 | Création du document - Toutes les décisions initiales | | 09/02/2026 | 1.1 | Configuration initiale : un seul panneau Paramètres (3 sections) dans le dashboard, plus de Setup Wizard dédié ; navigation bloquée jusqu'à sauvegarde | +| 16/06/2026 | 1.2 | Décision 5bis — familles recomposées, contournement v1.0.0 ; lien doc [28](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) | +| 16/06/2026 | 1.3 | Précision 5bis — cible post-1.0.0 : parcours gestionnaire § 7.5 (#139), visibilité par enfant | --- -**Dernière mise à jour** : 9 Février 2026 -**Version** : 1.1 +**Dernière mise à jour** : 16 Juin 2026 +**Version** : 1.2 **Statut** : ✅ Document validé diff --git a/docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md b/docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md new file mode 100644 index 0000000..b8cbfbe --- /dev/null +++ b/docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md @@ -0,0 +1,331 @@ +# Évolution — Modèle famille, responsables légaux et dossiers + +**Version** : 1.1 +**Date** : 16 juin 2026 +**Statut** : Réflexions produit / architecture — complément au [CDC](./01_CAHIER-DES-CHARGES.md) +**Documents liés** : [EVOLUTIONS_CDC.md](./EVOLUTIONS_CDC.md), [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md), [23_LISTE-TICKETS.md](./23_LISTE-TICKETS.md) + +--- + +## 1. Objet de ce document + +Ce document **trace les réflexions** menées en 2026 sur : + +- les **limites du modèle « famille / numéro de dossier »** en v1.0.0 ; +- les **contournements** acceptés pour la release **1.0.0** ; +- les **évolutions** envisagées post-1.0.0 (unités de dossier, affiliation parent–enfant, terminologie). + +Il ne remplace pas le CDC : il documente l’**écart assumé** entre le modèle idéal long terme et ce qui est livré en **1.0.0**, ainsi que la **feuille de route** pour aller plus loin. + +--- + +## 2. Modèle actuel (v1.0.0) — rappel + +| Concept | Implémentation | +|--------|----------------| +| **Responsable inscrit** | Rôle applicatif `parent` + entité `parents` | +| **Second adulte** | Co-parent optionnel (`id_co_parent`) — **un seul** | +| **Enfant** | Entité `enfants` | +| **Affiliation** | Table `enfants_parents` (liens many-to-many) | +| **Dossier famille** | `dossier_famille` + `dossier_famille_enfants` (motivation, etc.) | +| **Numéro de dossier** | Format `AAAA-NNNNNN`, sur `utilisateurs` et `parents` | +| **« Famille » calculée** | Graphe : co-parent **ou** enfants partagés (`getFamilyUserIds`) | +| **Workflows** | Validation, refus, reprise : souvent **par `numero_dossier`** ou par ce graphe | + +Le numéro de dossier est aujourd’hui une **aide forte** pour les cas simples (un couple, N enfants, une motivation), mais il tend à devenir **l’identifiant métier** de la famille — ce qui pose problème dans les cas complexes. + +--- + +## 3. Limitation connue v1.0.0 — familles recomposées et multi-contextes + +### 3.1 Exemple type (illustration) + +``` +Année 1 : Responsable M + co-responsable A → enfant α +Année 2 : Responsable M + co-responsable B → enfant β +``` + +Même personne **M** au centre, **deux contextes de vie** distincts. **A** n’est pas parent de β ; **B** n’est pas parent de α. + +### 3.2 Autres cas couverts par la même limitation + +Les exemples « maman / papa » sont **illustratifs**. La limitation s’applique **à toute configuration** : + +| Configuration | Même règle | +|---------------|------------| +| Couple **HH** ou **FF** | Oui | +| **Père** avec deux partenaires et deux enfants (même ville, écarts d’âge courts) | Oui | +| **Grand-parent** en tutelle ou **tuteur légal** | Oui *(voir § 5)* | +| Recomposition, demi-fratrie, garde alternée complexe | Oui | + +### 3.3 Pourquoi le modèle casse + +1. **Un `numero_dossier` par user** — M ne peut pas appartenir proprement à deux unités. +2. **Un seul co-parent** par fiche `parents`. +3. **Graphe famille trop large** — M liée à A (via α) et à B (via β) → A, B et M peuvent être fusionnés en **une seule « famille »** pour validation/refus/reprise. +4. **`dossier_famille`** — une motivation / une ancre par numéro, pas deux contextes pour la même personne. + +--- + +## 4. Décision v1.0.0 — contournement opérationnel + +### 4.1 Principe + +> **Le numéro de dossier reste la norme pour les cas simples, pas une obligation absolue.** +> Pour les cas trop complexes, le **gestionnaire** compose manuellement (création admin, rattachements) ou applique le contournement ci-dessous. + +### 4.2 Contournement accepté pour la 1.0.0 + +**Créer un second compte** avec une **adresse e-mail distincte** et un **second dossier** (second `numero_dossier`). + +| Dossier | Compte | Co-responsable | Enfant | +|---------|--------|----------------|--------| +| 1 | `m.personne@…` | A | α | +| 2 | `m.personne.famille2@…` *(ou alias)* | B | β | + +**Conséquences assumées :** + +- Une **même personne physique** peut avoir **deux identités** dans l’app. +- Pas de vue unifiée « une personne, deux contextes » en v1.0.0. +- Procédure interne gestionnaire recommandée (note « même personne physique »). +- Emails distincts **volontaires** (alias, +tag, boîte dédiée selon infra mail). + +### 4.3 Nuance — inscription publique + +Si le **dossier 1** existe déjà avec l’email de P (titulaire ou co-parent), une **2ᵉ inscription publique** où l’on saisit **le même email** comme co-parent est **bloquée** (*email déjà utilisé*). + +Le **2ᵉ dossier** passe donc surtout par : + +- **création / composition par le gestionnaire** (tickets admin #129+), ou +- **2ᵉ email** dès le départ pour la même personne physique. + +### 4.4 Piste cible — parcours gestionnaire « famille complexe » *(réflexion juin 2026)* + +Le contournement § 4.2 reste valable en **v1.0.0**. La **solution produit visée** pour les cas complexes est différente : + +> **Seul le gestionnaire** dispose d’un **parcours de création dédié** permettant de constituer une configuration avec **plus de deux responsables** (parents, co-parents, tuteurs…) **sans** se limiter au seul champ `co_parent`, en s’appuyant sur **`enfants_parents`** comme vérité de visibilité. + +**Exemple M / A / B / α / β :** + +| Compte | Enfants visibles / rattachés | +|--------|------------------------------| +| **M** (un seul compte, un email) | α **et** β | +| **A** | α uniquement | +| **B** | β uniquement | + +``` + α ─── M ─── β + │ │ + A B +``` + +- **M** voit et gère **ses deux enfants** sur **le même compte** (plus besoin d’un 2ᵉ email pour M). +- **A** et **B** ne voient **que** l’enfant qui leur est rattaché via `enfants_parents` — pas de fusion « famille » qui mélange A et B. +- Le parcours n’est **pas** proposé à l’inscription publique (trop error-prone) : **réservé au gestionnaire** (#129+ ou ticket dédié « famille complexe »). + +**Conséquences techniques (post-1.0.0) :** + +1. **Visibilité** : filtrer listes, fiches et actions parent par **liens `enfants_parents`**, pas par `getFamilyUserIds` / co-parent. +2. **Workflows** (validation, refus, reprise) : périmètre par **enfant** ou **soumission**, pas par graphe familial élargi. +3. **`co_parent`** : reste utile pour le cas simple (couple + N enfants communs) ; **insuffisant seul** pour les cas complexes — le gestionnaire compose les liens enfant par enfant. +4. **Numéro de dossier** : optionnel ou secondaire ; peut rester sur M ou sur une « unité » admin, sans imposer un numéro par co-responsable. + +*Détail : § 7.5.* + +--- + +## 5. Terminologie — « parent » aujourd’hui, qualification demain + +### 5.1 v1.0.0 + +- Rôle technique : **`parent`** pour tout responsable inscrit (y compris tuteur, grand-parent tutrice, etc.). +- UI : « Parent 1 », « co-parent », parfois exemples maman/papa — **pas de statut juridique distinct**. + +### 5.2 Évolution post-1.0.0 (piste) + +Séparer deux niveaux : + +| Niveau | Évolution | +|--------|-----------| +| **Rôle applicatif** | Conserver `parent` = « responsable du dossier » (auth, API) | +| **Qualification métier** | Nouveau champ, ex. `qualite_responsable` / `lien_avec_enfant` | + +**Valeurs possibles (exemples)** : parent biologique ou adoptif, co-parent, tuteur légal, grand-parent exerçant la garde, autre responsable légal. + +**Emplacement recommandé** : sur le **lien** `enfants_parents` (par enfant), pas seulement sur le user global. + +**UI** : + +- Libellés neutres : « Responsable 1 / 2 », « 2ᵉ responsable » ; +- **Combobox** pour qualifier le lien (admin + éventuellement inscription). + +--- + +## 6. Évolutions UI / fonctionnelles liées (backlog) + +Réflexions dashboard admin / gestionnaire (complément CDC §4.5.2–4.5.3). + +> **Statut implémentation (juin 2026)** : §6.1 et §6.2 **en cours de livraison** (tickets #130–#131, #115–#116, #137–#138). §6.3 reporté post-1.0.0 (#129). + +### 6.1 Fiche parent (#131) + +- Modale **éditable** dès l’ouverture (pas lecture seule + « Modifier » factice). +- **Retirer l’ID** UUID ; option : **n° de dossier** en lecture seule. +- **Statut** en combobox (règles métier à cadrer vs validation/refus #110). +- Bas de modale : + - `Nombre d'enfants : N` (lecture seule) ; + - **Liste des prénoms/noms** (cadre) — consultation, clic → fiche enfant (#138). + +### 6.2 Affiliation parent ↔ enfant (#115, #116, #138) + +- **Vérité métier** : `enfants_parents`. +- **Modifier l’affiliation** ≠ modifier le téléphone : gestion des **liens** (détacher / rattacher), avec garde-fous : + - ne pas supprimer l’enfant pour retirer un lien ; + - au moins un responsable par enfant ; + - prudence sur fusion « famille » et co-parents. +- **Création enfant** : prioritaire depuis **fiche parent** (contexte famille) ; onglet **Enfants** (#137) pour vue globale + rattachement. + +### 6.3 Création admin sans numéro (tickets #129+) + +Le gestionnaire doit pouvoir **tout créer** depuis l’interface ; le numéro reste **généré si utile**, **optionnel** si le cas est trop complexe — **objectif post-1.0.0** (unité de dossier). + +--- + +## 7. Évolution structurelle post-1.0.0 — « unité de dossier » + +### 7.1 Principe cible + +| Aujourd’hui | Cible | +|-------------|--------| +| `numero_dossier` = clé de la famille | **Liens parent↔enfant** = vérité | +| Famille déduite du graphe | **Unité de dossier** = regroupement **optionnel** | +| Un numéro par inscription | Numéro **optionnel** ; plusieurs unités par personne possibles | + +### 7.2 Exemple cible (cas M / A / B) — deux approches + +**Approche A — unités de dossier séparées** *(piste initiale § 7)* : + +``` +Unité 1 : numero 2026-000021 — M, A, α +Unité 2 : sans numéro (ou 2026-000089) — M, B, β +``` + +M appartient à **deux unités** ; A et B ne partagent pas la même unité. Peut impliquer **deux contextes de connexion** ou une agrégation côté M. + +**Approche B — parcours gestionnaire « famille complexe »** *(préférée, § 4.4)* : + +``` +Compte M → enfants α, β +Compte A → enfant α +Compte B → enfant β +(liens enfants_parents ; pas de fusion A↔B) +``` + +- **Un seul compte pour M** avec **les deux enfants**. +- **A** et **B** isolés sur **leur** enfant respectif. +- Création **uniquement** par le gestionnaire ; inscription publique inchangée (couple + co-parent classique). + +Les deux approches supposent de **cesser de déduire une « famille » unique** pour les workflows lorsque les liens enfant par enfant divergent. L’approche B maximise l’UX du responsable central (M) sans dupliquer son identité. + +### 7.5 Parcours gestionnaire — création « famille complexe » + +#### 7.5.1 Objectif + +Permettre au **gestionnaire** de monter un dossier où : + +- **N responsables** (≥ 2, parents ou tuteurs) sont créés ou rattachés ; +- chaque enfant est lié **explicitement** à un ou plusieurs responsables via `enfants_parents` ; +- un responsable (ex. **M**) peut être lié à **plusieurs enfants** dont les **autres** responsables (A, B) ne partagent **pas** la garde. + +#### 7.5.2 Règles produit + +| Règle | Détail | +|-------|--------| +| **Accès** | Parcours **gestionnaire uniquement** (dashboard), pas inscription publique | +| **Responsables** | Création ou rattachement de comptes `parent` ; qualification future sur le lien (§ 5) | +| **Visibilité** | Chaque responsable ne voit que **ses** enfants (liens `enfants_parents`) | +| **Co-parent UI** | Ne pas forcer « Parent 1 + co-parent unique » ; composition libre côté staff | +| **Garde-fous** | Au moins un responsable par enfant ; pas de suppression enfant pour retirer un lien | + +#### 7.5.3 Esquisse du parcours UI (gestionnaire) + +1. **Créer ou identifier** le responsable principal (ex. M). +2. **Ajouter d’autres responsables** (A, B, tuteur…) — comptes distincts. +3. **Créer les enfants** (α, β…) et, pour chaque enfant, **cocher les responsables** rattachés. +4. **Motivation / dossier** : texte global ou par enfant (à cadrer). +5. **Validation** : par soumission ou par enfant, sans valider « toute la famille » d’un coup si A et B ne doivent pas être fusionnés. + +#### 7.5.4 Impacts techniques majeurs + +| Domaine | Changement | +|---------|------------| +| **Auth / API parent** | `GET` enfants, dashboard parent : filtre `enfants_parents` pour l’utilisateur courant | +| **`getFamilyUserIds`** | Ne plus utiliser pour visibilité parent ; réservé au cas simple ou deprecated progressivement | +| **Validation / refus** | Périmètre enfant ou responsable, pas fusion A+B via graphe | +| **Reprise (#112)** | Token / périmètre = enfants liés au compte refusé, pas toute la composante connexe | +| **Admin (#115–#138)** | Prérequis : rattachement/détachement déjà en place ; ce parcours **compose** ces briques | + +#### 7.5.5 Lien avec v1.0.0 + +| Phase | Comportement | +|-------|--------------| +| **v1.0.0** | Contournement § 4.2 (2ᵉ email M) + composition manuelle gestionnaire (#115–#138) | +| **Post-1.0.0** | Parcours § 7.5 + refonte visibilité / workflows | + +### 7.3 Impacts workflows + +| Flux | Adaptation future | +|------|-------------------| +| Validation / refus (#110) | Par **enfant / soumission**, pas par graphe global | +| Reprise (#112) | Périmètre = enfants liés au compte, pas composante connexe | +| Liste « à valider » | Par soumission ou par enfant | +| Recherche gestionnaire | Numéro **ou** nom **ou** enfant | +| Visibilité parent | **Uniquement** enfants via `enfants_parents` (approche B § 7.2) | + +### 7.4 Migration + +- **Court terme** : contournement § 4 + tickets admin. +- **Moyen terme** : table **unité de dossier**, `numero_dossier` nullable. +- **Long terme** : workflows branchés sur l’unité. +- Dossiers **existants** (couple + enfants) : une unité = un numéro → **comportement actuel préservé**. + +--- + +## 8. Tickets Gitea associés + +| Ticket | Sujet | +|--------|--------| +| #110 | Refus dossier (token reprise) — livré | +| #112 | Reprise après refus — livré | +| #115 / #116 | Rattachement parent — backend / front | +| #129+ | Création dossier admin (parent / AM) | +| #131 | Fiche parent éditable (dashboard) | +| #137 | Onglet Enfants — liste globale | +| #138 | Fiche enfant + liste dans fiche parent | +| *(à créer)* | Epic « Unité de dossier / numéro optionnel » | +| #139 | **Parcours gestionnaire « famille complexe »** (§ 7.5) — N responsables, visibilité par enfant | +| *(à créer)* | Qualification responsable légal (combobox sur lien enfant) | + +--- + +## 9. Formulation type — release notes / doc gestionnaire (v1.0.0) + +> **Familles recomposées ou responsabilités multiples** +> Une même personne ne peut pas gérer proprement deux unités familiales distinctes (recompositions, plusieurs co-responsables successifs, tuteur/GP pour plusieurs contextes) avec **un seul compte**. +> **Contournement v1.0.0** : second compte avec **email distinct** et second numéro de dossier. +> S’applique à **tous les responsables inscrits** (couples HH/FF, tuteurs, grands-parents, etc.). +> **Évolution post-1.0.0** : parcours **gestionnaire** « famille complexe » (un compte M, enfants α+β ; A et B limités à leur enfant) ; visibilité par `enfants_parents` ; workflows sans fusion graphe familial. + +--- + +## 10. Historique des mises à jour + +| Date | Auteur | Modification | +|------|--------|--------------| +| 2026-06-16 | Équipe / session produit | Création — synthèse réflexions famille, tutelle, v1.0.0 vs post-1.0.0 | +| 2026-06-16 | Implémentation ch.6 | Back : `PATCH /parents/:id/fiche`, attach/detach enfant. Front : modale parent éditable, onglet Enfants, fiche enfant | +| 2026-06-16 | Réflexion produit | § 4.4 / § 7.5 — parcours gestionnaire famille complexe : M un compte (α+β), A/B visibilité restreinte par enfant | + +--- + +*Ce document sera enrichi au fil des décisions. Pour les décisions formelles archivées, voir aussi [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md).* diff --git a/docs/EVOLUTIONS_CDC.md b/docs/EVOLUTIONS_CDC.md index 6ee6d1e..98b57b6 100644 --- a/docs/EVOLUTIONS_CDC.md +++ b/docs/EVOLUTIONS_CDC.md @@ -2,6 +2,8 @@ Ce document liste les modifications à apporter au cahier des charges original pour le rendre conforme à l'application développée. +> **Document complémentaire (juin 2026)** — réflexions sur le **modèle famille / numéro de dossier**, familles recomposées, tuteurs et responsables légaux : voir **[28 - Évolution famille et responsables](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md)**. + ## 1. Gestion des Enfants ### Modifications à apporter dans la section "Création de compte parent" @@ -275,4 +277,29 @@ Pour chaque évolution identifiée, ce document suivra la structure suivante : - Évolution du modèle de données vers un RBAC intra-RPE. - Adaptation des écrans d'administration pour gérer les rôles locaux. - Renforcement des contrôles d'accès backend et des règles métier. -- Clarification des workflows décisionnels dans l'application. \ No newline at end of file +- Clarification des workflows décisionnels dans l'application. + +## 9. Modèle famille, numéro de dossier et responsables légaux + +### 9.1 Situation actuelle (v1.0.0) + +- Un **numéro de dossier** par inscription (`AAAA-NNNNNN`), fortement utilisé dans les workflows (validation, refus, reprise). +- **Un co-parent** par fiche `parents` ; affiliation réelle via `enfants_parents`. +- La « famille » est **déduite** du graphe (co-parent + enfants partagés) — fusion parfois trop large en cas de recompositions. + +### 9.2 Limitation v1.0.0 — familles recomposées + +Cas type : même responsable M avec co-responsable A (enfant α) puis co-responsable B (enfant β). Le modèle actuel ne permet pas de séparer proprement **deux unités familiales** pour une même personne. + +**Contournement accepté pour la release 1.0.0** : second compte avec **email distinct** et second numéro de dossier (procédure gestionnaire). S'applique aussi aux couples HH/FF, tuteurs, grands-parents, etc. + +### 9.3 Évolutions post-1.0.0 (pistes) + +| Sujet | Piste | +|-------|--------| +| **Unité de dossier** | Numéro **optionnel** ; plusieurs unités par personne | +| **Affiliation** | Gestion admin des liens parent↔enfant (#115, #116, #138) | +| **Qualification** | Combobox « lien responsable–enfant » (tuteur, GP, parent…) sur `enfants_parents` | +| **UI admin** | Fiche parent éditable (#131), liste enfants en bas de fiche | + +**Détail complet, scénarios, tickets et formulation release notes** : [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md). \ No newline at end of file diff --git a/docs/archive/temporaires/131-fiche-parent-co-parent-header.md b/docs/archive/temporaires/131-fiche-parent-co-parent-header.md new file mode 100644 index 0000000..6f8a3c8 --- /dev/null +++ b/docs/archive/temporaires/131-fiche-parent-co-parent-header.md @@ -0,0 +1,127 @@ +# #131 — En-tête fiche parent : co-parent (note front → back) + +**Ticket :** #131 (fiche parent dashboard, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1) +**Date :** 2026-06-01 +**Statut front :** livré (en-tête dynamique) +**Modif backend demandée :** **aucune fonctionnelle** — ce document fixe le contrat attendu ; le back valide `co_parent` et masque les champs sensibles. + +--- + +## 1. Comportement UI (front) + +Dans la modale **fiche parent** (`AdminParentEditModal`) : + +| Zone | Contenu | +|------|---------| +| **Titre** | `prenom` + `nom` du parent affiché (plus le libellé fixe « Fiche parent ») | +| **Sous-titre** | `Co-parent : {prenom} {nom}` — affiché **uniquement** si un co-parent est connu | + +Le titre se met à jour en direct pendant l’édition des champs nom/prénom. +Le sous-titre provient du co-parent **chargé depuis l’API** (pas saisi à la main dans la modale). + +--- + +## 2. Endpoints consommés + +| Méthode | Route | Usage front | +|---------|-------|-------------| +| `GET` | `/api/v1/parents` | Liste parents (onglet Parents) | +| `GET` | `/api/v1/parents/:userId` | Rechargement fiche après rattachement/détachement enfant | +| `PATCH` | `/api/v1/parents/:userId/fiche` | Sauvegarde identité + statut (inchangé) | + +Rôles : `super_admin`, `gestionnaire`, `administrateur` (selon route). + +--- + +## 3. Contrat JSON attendu pour `co_parent` + +Le front parse `ParentModel.fromJson` avec la clé **`co_parent`** (snake_case), objet utilisateur imbriqué. + +### Champs minimum utilisés pour le sous-titre + +| Clé JSON | Usage | +|----------|--------| +| `co_parent` | Objet ou absent/`null` | +| `co_parent.id` | Identifiant (futur lien cliquable éventuel) | +| `co_parent.prenom` | Affichage | +| `co_parent.nom` | Affichage | + +Affichage front : `'{prenom} {nom}'.trim()` → libellé `Co-parent : …`. + +### Exemple de fragment de réponse (`GET /parents/:id`) + +```json +{ + "user_id": "33333333-3333-3333-3333-333333333333", + "numero_dossier": "2026-000042", + "user": { + "id": "33333333-3333-3333-3333-333333333333", + "email": "parent1@example.com", + "prenom": "Paul", + "nom": "PARENT", + "statut": "actif", + "telephone": "0601020304" + }, + "co_parent": { + "id": "44444444-4444-4444-4444-444444444444", + "email": "coparent1@example.com", + "prenom": "Clara", + "nom": "COPARENT", + "role": "parent", + "statut": "actif" + }, + "parentChildren": [] +} +``` + +> **Note :** le front lit `user` (pas `utilisateur`). La doc `11_API.md` § Parents mentionne encore `utilisateur` / `id_co_parent` seul — le contrat **effectif** côté Nest/TypeORM est l’entité `Parents` sérialisée (`user`, `co_parent`, `parentChildren`, …). + +--- + +## 4. État backend + +### Relations (déjà en place) + +- `findAll()` et `findOne(user_id)` chargent **`co_parent`** ; +- FK : `parents.id_co_parent` → `utilisateurs.id` ; +- inscription couple : les deux sens renseignés en principe (`auth.service.ts`). + +### Livraison back (#131) + +- `mapParentForApi` / `sanitizeUserForApi` : réponses `GET/PATCH/POST/DELETE` parents **sans** `password`, `token_creation_mdp`, `password_reset_*` sur `user` et `co_parent`. + +**Checklist validation :** + +- [x] `GET /parents/:id` renvoie `co_parent` peuplé quand `id_co_parent` est non null +- [x] `GET /parents` (liste) inclut `co_parent` +- [x] `prenom` / `nom` du co-parent présents +- [x] Pas de fuite `password` / tokens sur `user` ni `co_parent` + +--- + +## 5. Points d’attention (hors périmètre immédiat) + +| Sujet | Détail | +|-------|--------| +| **Lien inverse** | Si B est co-parent de A (`A.id_co_parent = B`) mais `B.id_co_parent` est `null`, le sous-titre **ne s’affichera pas** sur la fiche de B. Pas de résolution inverse côté front. | +| **Familles > 2 adultes** | Sous-titre = co-parent direct (`id_co_parent`) uniquement. | +| **Trou AM ↔ enfants en garde** | Pas de lien AM–enfant aujourd’hui (à documenter / traiter plus tard). | + +--- + +## 6. Fichiers back concernés + +| Fichier | Rôle | +|---------|------| +| `backend/src/routes/parents/parents.service.ts` | `findOne`, `findAll` + relations | +| `backend/src/routes/parents/parents.controller.ts` | `mapParentForApi` sur les réponses | +| `backend/src/routes/parents/parents.mapper.ts` | Sérialisation API | +| `backend/src/common/utils/sanitize-user-for-api.ts` | Masquage secrets | +| `backend/src/entities/parents.entity.ts` | relation `co_parent` | + +--- + +## 7. Références + +- `docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1 +- Ticket Gitea **#131** diff --git a/docs/archive/temporaires/TEMP_112-back-reprise-alignement-front.md b/docs/archive/temporaires/TEMP_112-back-reprise-alignement-front.md new file mode 100644 index 0000000..65eff55 --- /dev/null +++ b/docs/archive/temporaires/TEMP_112-back-reprise-alignement-front.md @@ -0,0 +1,244 @@ +# #112 — Alignement front après évolution back (reprise dossier complet) + +**Branche déployée :** `feature/112-reprise-apres-refus-front` +**Commit back :** `d70577b1` — `feat(#112): reprise après refus — dossier complet GET/PATCH` +**Date :** 2026-06-16 + +Ce document décrit le **contrat API réel** après extension du back, et ce que le front doit encore brancher pour exploiter le dossier complet (au-delà de l’identité seule). + +--- + +## 1. Endpoints (inchangés côté URL) + +| Méthode | Route | Auth | +|---------|-------|------| +| `GET` | `/api/v1/auth/reprise-dossier?token={uuid}` | Public | +| `PATCH` | `/api/v1/auth/reprise-resoumettre` | Public | +| `POST` | `/api/v1/auth/reprise-identify` | Public (inchangé) | + +> **Note :** le ticket #111 parlait de `PUT` ; l’implémentation reste en **`PATCH`** (comme avant). + +--- + +## 2. `GET /auth/reprise-dossier` — réponse enrichie + +### Champs communs (toujours présents) + +Identiques à avant : `id`, `email`, `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal`, `numero_dossier`, `role`, `photo_url`, `genre`, `situation_familiale`. + +### Rôle `parent` (+ champs #119) + +Alignés sur `DossierFamilleCompletDto` : + +```json +{ + "parents": [ + { + "user_id": "uuid", + "email": "…", + "prenom": "…", + "nom": "…", + "telephone": "…", + "adresse": "…", + "ville": "…", + "code_postal": "…", + "statut": "refuse", + "co_parent_id": "uuid-parent-entity" + } + ], + "enfants": [ + { + "id": "uuid-enfant", + "first_name": "Emma", + "last_name": "MARTIN", + "genre": "F", + "status": "actif", + "birth_date": "2023-02-15T00:00:00.000Z", + "due_date": null, + "photo_url": "/uploads/photos/…", + "consent_photo": true, + "est_multiple": false + } + ], + "texte_motivation": "Nous recherchons…" +} +``` + +**Mapping front suggéré :** + +| JSON back | Modèle / wizard parent | +|-----------|-------------------------| +| `parents[]` | `UserRegistrationData.parent1` + `parent2` (matcher par `email` ou ordre : titulaire = `id` du GET racine) | +| `enfants[].first_name` / `last_name` | `ChildData.firstName` / `lastName` | +| `enfants[].birth_date` | `ChildData.birthDate` (ISO → `DateTime`) | +| `enfants[].due_date` | `ChildData.dueDate` (enfant `a_naitre`) | +| `enfants[].status` | `actif` = né, `a_naitre` = à naître | +| `enfants[].photo_url` | `ApiConfig.absoluteMediaUrl()` + conserver pour reprise sans re-upload | +| `enfants[].id` | **Obligatoire** pour le PATCH (update par id) | +| `enfants[].est_multiple` | `grossesse_multiple` si utilisé | +| `texte_motivation` | étape présentation / motivation | + +Si `numero_dossier` absent : pas de `parents[]` / `enfants[]` / `texte_motivation` (identité seule). + +### Rôle `assistante_maternelle` + +Champs racine + fiche pro (structure **aplatie**, pas de sous-objet `user`) : + +```json +{ + "consentement_photo": true, + "date_naissance": "1985-03-12T00:00:00.000Z", + "lieu_naissance_ville": "Paris", + "lieu_naissance_pays": "France", + "numero_agrement": "AGR-2024-12345", + "nir": "123456789012345", + "date_agrement": "2024-06-01T00:00:00.000Z", + "nb_max_enfants": 4, + "place_disponible": 2, + "biographie": "…" +} +``` + +**Mapping `AmRegistrationData` :** + +| JSON back | Champ front | +|-----------|-------------| +| `nb_max_enfants` | `capaciteAccueil` | +| `place_disponible` | `placesDisponibles` | +| `numero_agrement` | `numeroAgrement` | +| `biographie` | `biographie` / présentation | +| `photo_url` | déjà géré via `RepriseSession.photoUrl` | + +--- + +## 3. `PATCH /auth/reprise-resoumettre` — body étendu + +### Commun + +```json +{ "token": "uuid-reprise" } +``` + +### Parent — champs à envoyer depuis le wizard + +| Champ PATCH | Source wizard | Notes | +|-------------|---------------|-------| +| `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal` | Parent 1 (titulaire token) | Champs racine | +| `co_parent_prenom`, `co_parent_nom`, `co_parent_telephone` | Parent 2 | | +| `co_parent_meme_adresse`, `co_parent_adresse`, `co_parent_code_postal`, `co_parent_ville` | Parent 2 adresse | | +| `texte_motivation` **ou** `presentation_dossier` | Étape motivation | Les deux alias acceptés | +| `enfants[]` | Liste enfants | Voir ci-dessous | + +**Structure `enfants[]` (miroir inscription + `id` obligatoire) :** + +```json +{ + "id": "uuid-enfant-existant", + "prenom": "Emma", + "nom": "MARTIN", + "date_naissance": "2023-02-15", + "date_previsionnelle_naissance": null, + "genre": "F", + "photo_base64": "data:image/jpeg;base64,…", + "photo_filename": "emma.jpg", + "grossesse_multiple": false +} +``` + +- **v1 back :** update par `id` uniquement — pas de création/suppression d’enfant. +- Si `id` inconnu pour ce dossier → **400** `Enfant inconnu pour ce dossier : {id}`. +- Sans nouvelle photo : ne pas envoyer `photo_base64` (l’existant est conservé). + +### AM — champs à envoyer + +| Champ PATCH | Source | +|-------------|--------| +| Identité + `photo_url` ou `photo_base64` + `photo_filename` | Étapes 1–2 | +| `consentement_photo`, `date_naissance`, `lieu_naissance_ville`, `lieu_naissance_pays` | Identité | +| `numero_agrement`, `nir`, `date_agrement` | Pro | +| `capacite_accueil`, `places_disponibles` | Pro | +| `biographie` | Présentation | + +Validation NIR identique à l’inscription si `nir` fourni. + +### Réponse succès (nouveau format) + +```json +{ + "message": "Dossier resoumis avec succès. Il est de nouveau en attente de validation.", + "statut": "en_attente", + "user_id": "uuid", + "numero_dossier": "2026-000021" +} +``` + +Code HTTP : **200** (pas de corps `Users` brut comme l’ancien back). + +### Effet métier + +- **Parent :** tous les users `role=parent` avec le même `numero_dossier` passent en `en_attente` ; `token_reprise` invalidé sur **tous** (symétrique refus #110). +- **AM :** un seul user. + +### E-mail accusé resoumission (parent) + +Après `PATCH` réussi, un e-mail est envoyé à **chaque parent** du dossier (`sendResoumissionPendingEmail`) : +- confirmation de resoumission ; +- rappel du **numéro de dossier** ; +- mention « en attente de validation ». + +Échec SMTP : logué, **ne bloque pas** la resoumission (même règle que l'inscription initiale). + +--- + +## 4. Fichiers front à modifier (checklist) + +### Modèles + +- [ ] `lib/models/reprise_dossier.dart` — parser `parents[]`, `enfants[]`, `texte_motivation`, champs AM +- [ ] Réutiliser ou mapper vers `DossierFamilleEnfant` / structures existantes (#119 admin) si possible + +### Session / préremplissage + +- [ ] `lib/services/reprise_session.dart` + - `applyToParent` : remplir parent1/parent2 depuis `parents[]`, enfants, motivation + - `applyToAm` : remplir tous les champs AM + +### API + +- [ ] `lib/services/auth_service.dart` — `resoumettreReprise()` : accepter body complet (parent + AM), pas seulement identité +- [ ] Étendre `UserRegistrationData` / `AmRegistrationData` helpers `toReprisePatchBody()` si utile + +### Écrans fin de parcours + +- [ ] `parent_register_step5_screen.dart` — PATCH avec co-parent, enfants, motivation +- [ ] `am_register_step4_screen.dart` — PATCH avec fiche AM complète + +### Hors scope back (inchangé) + +RIB / IBAN / attestation CAF (étape 5 wizard parent) : **non persistés** — rien à envoyer en reprise. + +### Non implémenté front (ticket #112 initial) + +- [ ] Modale login « J’ai un numéro de dossier » → `POST /auth/reprise-identify` (back prêt, front absent) + +--- + +## 5. Tests manuels suggérés + +1. Refuser un dossier parent complet (≥1 enfant + co-parent + motivation). +2. Ouvrir le lien mail `/reprise?token=…`. +3. Vérifier dans DevTools que le GET contient `enfants[]` et `texte_motivation`. +4. Après branchement front : wizard prérempli sur toutes les étapes. +5. Resoumettre → statut `en_attente` pour les deux parents ; dossier visible file validation admin (#119). + +--- + +## 6. Références code back + +``` +backend/src/routes/auth/dto/reprise-dossier.dto.ts +backend/src/routes/auth/dto/resoumettre-reprise.dto.ts +backend/src/routes/auth/dto/enfant-reprise.dto.ts +backend/src/routes/auth/auth.service.ts → getRepriseDossier, resoumettreReprise +backend/src/routes/parents/dto/dossier-famille-complet.dto.ts +``` diff --git a/docs/archive/temporaires/TEMP_131-back-fiche-am-enfants.md b/docs/archive/temporaires/TEMP_131-back-fiche-am-enfants.md new file mode 100644 index 0000000..870ef51 --- /dev/null +++ b/docs/archive/temporaires/TEMP_131-back-fiche-am-enfants.md @@ -0,0 +1,132 @@ +# #131 — Fiche AM éditable + affiliation enfants (note front → back) + +**Ticket :** #131 (partie AM, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1) +**Date :** 2026-06-01 +**Statut front :** modale livrée (2 onglets) — **API affiliation AM↔enfant à implémenter** + +--- + +## 1. Comportement UI (front) + +Modale `AdminAmEditModal` — même shell que la fiche parent (~930 px) : + +| Onglet | Contenu | +|--------|---------| +| **Identité & professionnel** | `IdentityBlock` éditable + grille pro (agrément, ville résidence, capacité, places, NIR/agrément date en lecture seule, biographie, switch disponible) + gélule statut | +| **Enfants accueillis** | Liste cartes enfants (réutilise `AdminChildrenAffiliationPanel` / `AdminEnfantUserCard`) + rattacher / détacher | + +En-tête : prénom nom · sous-titre `Zone · Agrément · Dossier`. + +--- + +## 2. Endpoints consommés + +### Déjà existants (partiels) + +| Méthode | Route | Usage | +|---------|-------|-------| +| `GET` | `/api/v1/assistantes-maternelles` | Liste AM | +| `GET` | `/api/v1/assistantes-maternelles/:userId` | Détail (403 possible pour `administrateur` → fallback liste) | +| `PATCH` | `/api/v1/users/:userId` | Identité + statut (admin / super_admin uniquement) | +| `PATCH` | `/api/v1/assistantes-maternelles/:userId` | Champs pro (gestionnaire / super_admin) | + +### À créer (recommandé — miroir parent #131 / #115) + +| Méthode | Route | Rôle | +|---------|-------|------| +| `PATCH` | `/api/v1/assistantes-maternelles/:userId/fiche` | Mise à jour unifiée identité + pro + statut (`super_admin`, `gestionnaire`, `administrateur`) | +| `POST` | `/api/v1/assistantes-maternelles/:userId/enfants/:enfantId` | Rattacher un enfant | +| `DELETE` | `/api/v1/assistantes-maternelles/:userId/enfants/:enfantId` | Détacher un enfant | +| `GET` | `/api/v1/assistantes-maternelles/:userId` | Inclure `amChildren[]` (relation enfant) | + +Le front appelle déjà ces routes ; en l’absence de `PATCH …/fiche`, il tente un fallback `PATCH users` + `PATCH assistantes-maternelles` (échoue selon le rôle connecté). + +--- + +## 3. Modèle de données affiliation AM ↔ enfant + +**À définir côté BDD** (pas de table dédiée aujourd’hui, contrairement à `enfants_parents`) : + +Proposition alignée parent : + +```sql +-- Piste : enfants_assistantes_maternelles +CREATE TABLE enfants_assistantes_maternelles ( + id_am UUID NOT NULL REFERENCES utilisateurs(id) ON DELETE CASCADE, + id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE, + PRIMARY KEY (id_am, id_enfant) +); +``` + +Réponse API attendue sur `GET /assistantes-maternelles/:id` : + +```json +{ + "user_id": "uuid-am", + "user": { "id": "…", "prenom": "Claire", "nom": "MARTIN", "statut": "actif" }, + "approval_number": "AGR-2024-12345", + "residence_city": "Bezons", + "max_children": 4, + "places_available": 2, + "available": true, + "amChildren": [ + { + "child": { + "id": "uuid-enfant", + "first_name": "Emma", + "last_name": "MARTIN", + "status": "actif", + "birth_date": "2023-02-15" + } + } + ] +} +``` + +Le front parse `amChildren` / `am_children` / `assistanteChildren` (même logique que `parentChildren`). + +--- + +## 4. Body `PATCH …/fiche` suggéré + +```json +{ + "nom": "MARTIN", + "prenom": "Claire", + "email": "claire@example.com", + "telephone": "0612345678", + "adresse": "5 place Bellecour", + "ville": "Lyon", + "code_postal": "69002", + "statut": "actif", + "approval_number": "AGR-2024-12345", + "residence_city": "Lyon", + "max_children": 4, + "places_available": 2, + "biography": "…", + "available": true +} +``` + +NIR et date d’agrément : lecture seule dans la modale (modification hors périmètre admin v1). + +--- + +## 5. Fichiers front concernés + +| Fichier | Rôle | +|---------|------| +| `frontend/lib/widgets/admin/common/admin_am_edit_modal.dart` | Modale 2 onglets | +| `frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart` | Liste enfants partagée parent/AM | +| `frontend/lib/widgets/admin/common/admin_status_capsule.dart` | Gélule statut partagée | +| `frontend/lib/models/assistante_maternelle_model.dart` | Parse champs pro + `amChildren` | +| `frontend/lib/services/user_service.dart` | `getAssistanteMaternelle`, `updateAmFiche`, `attachEnfantToAm`, `detachEnfantFromAm` | +| `frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart` | Ouverture modale au clic Modifier | + +--- + +## 6. Références + +- Fiche parent : `PATCH /parents/:id/fiche`, `POST|DELETE /parents/:id/enfants/:enfantId` +- Ticket Gitea **#131**, **#115** +- `docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md` diff --git a/docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md b/docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md new file mode 100644 index 0000000..43ac761 --- /dev/null +++ b/docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md @@ -0,0 +1,124 @@ +# #131 — En-tête fiche parent : co-parent (note front → back) + +**Ticket :** #131 (fiche parent dashboard, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1) +**Date :** 2026-06-01 +**Statut front :** livré (en-tête dynamique) +**Modif backend demandée :** **aucune** — ce document fixe le contrat attendu et invite à valider que l’existant le couvre. + +--- + +## 1. Comportement UI (front) + +Dans la modale **fiche parent** (`AdminParentEditModal`) : + +| Zone | Contenu | +|------|---------| +| **Titre** | `prenom` + `nom` du parent affiché (plus le libellé fixe « Fiche parent ») | +| **Sous-titre** | `Co-parent : {prenom} {nom}` — affiché **uniquement** si un co-parent est connu | + +Le titre se met à jour en direct pendant l’édition des champs nom/prénom. +Le sous-titre provient du co-parent **chargé depuis l’API** (pas saisi à la main dans la modale). + +--- + +## 2. Endpoints consommés + +| Méthode | Route | Usage front | +|---------|-------|-------------| +| `GET` | `/api/v1/parents` | Liste parents (onglet Parents) | +| `GET` | `/api/v1/parents/:userId` | Rechargement fiche après rattachement/détachement enfant | +| `PATCH` | `/api/v1/parents/:userId/fiche` | Sauvegarde identité + statut (inchangé) | + +Rôles : `super_admin`, `gestionnaire`, `administrateur` (selon route). + +--- + +## 3. Contrat JSON attendu pour `co_parent` + +Le front parse `ParentModel.fromJson` avec la clé **`co_parent`** (snake_case), objet utilisateur imbriqué. + +### Champs minimum utilisés pour le sous-titre + +| Clé JSON | Usage | +|----------|--------| +| `co_parent` | Objet ou absent/`null` | +| `co_parent.id` | Identifiant (futur lien cliquable éventuel) | +| `co_parent.prenom` | Affichage | +| `co_parent.nom` | Affichage | + +Affichage front : `'{prenom} {nom}'.trim()` → libellé `Co-parent : …`. + +### Exemple de fragment de réponse (`GET /parents/:id`) + +```json +{ + "user_id": "33333333-3333-3333-3333-333333333333", + "numero_dossier": "2026-000042", + "user": { + "id": "33333333-3333-3333-3333-333333333333", + "email": "parent1@example.com", + "prenom": "Paul", + "nom": "PARENT", + "statut": "actif", + "telephone": "0601020304" + }, + "co_parent": { + "id": "44444444-4444-4444-4444-444444444444", + "email": "coparent1@example.com", + "prenom": "Clara", + "nom": "COPARENT", + "role": "parent", + "statut": "actif" + }, + "parentChildren": [] +} +``` + +> **Note :** le front lit `user` (pas `utilisateur`). La doc `11_API.md` § Parents mentionne encore `utilisateur` / `id_co_parent` seul — le contrat **effectif** côté Nest/TypeORM est l’entité `Parents` sérialisée (`user`, `co_parent`, `parentChildren`, …). + +--- + +## 4. État backend (à valider, pas à refaire) + +D’après le code actuel (`parents.service.ts`) : + +- `findAll()` et `findOne(user_id)` chargent déjà la relation **`co_parent`** ; +- la FK métier est `parents.id_co_parent` → `utilisateurs.id` ; +- à l’inscription couple, les deux sens sont en principe renseignés (`auth.service.ts`). + +**Checklist validation back :** + +- [ ] `GET /parents/:id` renvoie bien `co_parent` peuplé quand `id_co_parent` est non null +- [ ] `GET /parents` (liste) inclut aussi `co_parent` (sous-titre disponible dès l’ouverture sans re-fetch) +- [ ] Les champs `prenom` / `nom` du co-parent sont présents dans la réponse JSON + +Si ces trois points passent en recette, **aucun changement backend n’est nécessaire** pour cette fonctionnalité. + +--- + +## 5. Points d’attention (hors périmètre immédiat) + +| Sujet | Détail | +|-------|--------| +| **Lien inverse** | Si le parent B est le co-parent de A (`A.id_co_parent = B`) mais que `B.id_co_parent` est `null`, le sous-titre **ne s’affichera pas** sur la fiche de B. Le front ne fait pas de résolution inverse. À traiter côté back **seulement si** des données legacy ont un lien à sens unique. | +| **Familles > 2 adultes** | Le sous-titre n’affiche que le co-parent direct (`id_co_parent`). Les autres responsables liés uniquement via `enfants_parents` ne sont pas listés ici (cf. doc 28 §6). | +| **Données sensibles** | Vérifier que la sérialisation de `co_parent` n’expose pas `password` / tokens (même remarque que pour `user`). | + +--- + +## 6. Fichiers front concernés + +| Fichier | Rôle | +|---------|------| +| `frontend/lib/models/parent_model.dart` | Parse `co_parent` → `AppUser? coParent` | +| `frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart` | Titre + sous-titre | +| `frontend/lib/services/user_service.dart` | `getParents()` / `getParent()` | + +--- + +## 7. Références + +- `docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1 +- `backend/src/routes/parents/parents.service.ts` — `findOne`, `findAll` +- `backend/src/entities/parents.entity.ts` — relation `co_parent` +- Ticket Gitea **#131** diff --git a/docs/serveur_ptitspas.code-workspace b/docs/serveur_ptitspas.code-workspace new file mode 100644 index 0000000..ef9f5d2 --- /dev/null +++ b/docs/serveur_ptitspas.code-workspace @@ -0,0 +1,7 @@ +{ + "folders": [ + { + "path": "." + } + ] +} \ No newline at end of file diff --git a/frontend/lib/models/assistante_maternelle_model.dart b/frontend/lib/models/assistante_maternelle_model.dart index 2d53fb0..9a8cb96 100644 --- a/frontend/lib/models/assistante_maternelle_model.dart +++ b/frontend/lib/models/assistante_maternelle_model.dart @@ -1,30 +1,102 @@ +import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/models/user.dart'; class AssistanteMaternelleModel { final AppUser user; final String? approvalNumber; + final String? nir; final String? residenceCity; final int? maxChildren; final int? placesAvailable; + final String? biography; + final bool? available; + final String? agreementDate; + final List children; AssistanteMaternelleModel({ required this.user, this.approvalNumber, + this.nir, this.residenceCity, this.maxChildren, this.placesAvailable, + this.biography, + this.available, + this.agreementDate, + this.children = const [], }); factory AssistanteMaternelleModel.fromJson(Map json) { - final userJson = json['user'] ?? json; + final root = Map.from(json); + final userJson = Map.from(root['user'] ?? root); + if (root['numero_dossier'] != null && userJson['numero_dossier'] == null) { + userJson['numero_dossier'] = root['numero_dossier']; + } final user = AppUser.fromJson(userJson); - + final children = _parseChildren(root); + return AssistanteMaternelleModel( user: user, - approvalNumber: json['numero_agrement'] as String?, - residenceCity: json['ville_residence'] as String?, - maxChildren: json['nb_max_enfants'] as int?, - placesAvailable: json['place_disponible'] as int?, + approvalNumber: _str( + root['approval_number'] ?? root['numero_agrement'], + ), + nir: _str(root['nir'] ?? root['nir_chiffre']), + residenceCity: _str( + root['residence_city'] ?? root['ville_residence'], + ), + maxChildren: _int(root['max_children'] ?? root['nb_max_enfants']), + placesAvailable: _int( + root['places_available'] ?? root['place_disponible'], + ), + biography: _str(root['biography'] ?? root['biographie']), + available: root['available'] as bool? ?? root['disponible'] as bool?, + agreementDate: _dateString( + root['agreement_date'] ?? root['date_agrement'], + ), + children: children, ); } + + static String? _str(dynamic v) { + if (v == null) return null; + final s = v.toString().trim(); + return s.isEmpty ? null : s; + } + + static int? _int(dynamic v) { + if (v == null) return null; + if (v is int) return v; + return int.tryParse(v.toString()); + } + + static String? _dateString(dynamic v) { + if (v == null) return null; + return v.toString().split('T').first; + } + + static List _parseChildren(Map json) { + final children = []; + final seen = {}; + + void add(ParentChildSummary? child) { + if (child == null || child.id.isEmpty || seen.contains(child.id)) return; + seen.add(child.id); + children.add(child); + } + + final links = json['amChildren'] ?? + json['am_children'] ?? + json['assistanteChildren'] ?? + json['assistante_children']; + if (links is List) { + for (final link in links) { + if (link is! Map) continue; + add(ParentChildSummary.fromParentChildLink( + Map.from(link), + )); + } + } + + return children; + } } diff --git a/frontend/lib/models/dossier_unifie.dart b/frontend/lib/models/dossier_unifie.dart index 07b2d56..78b2fb3 100644 --- a/frontend/lib/models/dossier_unifie.dart +++ b/frontend/lib/models/dossier_unifie.dart @@ -1,4 +1,5 @@ import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; /// Réponse unifiée GET /dossiers/:numeroDossier. Ticket #119, #107. class DossierUnifie { @@ -220,7 +221,7 @@ class EnfantDossier { lastName: (json['last_name'] ?? json['nom'])?.toString(), birthDate: json['birth_date']?.toString(), gender: (json['gender'] ?? json['genre'])?.toString(), - status: json['status']?.toString(), + status: normalizeEnfantStatus(json['status']?.toString()), dueDate: json['due_date']?.toString(), photoUrl: resolvedPhoto, consentPhoto: diff --git a/frontend/lib/models/enfant_admin_model.dart b/frontend/lib/models/enfant_admin_model.dart new file mode 100644 index 0000000..5c7c84d --- /dev/null +++ b/frontend/lib/models/enfant_admin_model.dart @@ -0,0 +1,109 @@ +import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; + +/// Enfant tel que renvoyé par `GET /enfants` (dashboard admin). +class EnfantAdminModel { + final String id; + final String? firstName; + final String? lastName; + final String? gender; + final String? birthDate; + final String? dueDate; + final String status; + final String? photoUrl; + final bool consentPhoto; + final bool isMultiple; + final List parentLinks; + + EnfantAdminModel({ + required this.id, + this.firstName, + this.lastName, + this.gender, + this.birthDate, + this.dueDate, + required this.status, + this.photoUrl, + this.consentPhoto = false, + this.isMultiple = false, + this.parentLinks = const [], + }); + + String get fullName { + final fn = (firstName ?? '').trim(); + final ln = (lastName ?? '').trim(); + if (fn.isEmpty && ln.isEmpty) return 'Enfant'; + if (ln.isEmpty) return fn; + return '$fn $ln'; + } + + factory EnfantAdminModel.fromJson(Map json) { + final linksRaw = json['parentLinks'] as List?; + final links = []; + if (linksRaw != null) { + for (final item in linksRaw) { + if (item is Map) { + links.add(EnfantParentLink.fromJson(item)); + } + } + } + + return EnfantAdminModel( + id: (json['id'] ?? '').toString(), + firstName: json['first_name'] as String?, + lastName: json['last_name'] as String?, + gender: json['gender'] as String?, + birthDate: _dateString(json['birth_date']), + dueDate: _dateString(json['due_date']), + status: normalizeEnfantStatus(json['status']?.toString()), + photoUrl: json['photo_url'] as String?, + consentPhoto: json['consent_photo'] == true, + isMultiple: json['is_multiple'] == true, + parentLinks: links, + ); + } + + Map toUpdateJson() { + return { + if (firstName != null) 'first_name': firstName, + if (lastName != null) 'last_name': lastName, + if (gender != null && gender!.isNotEmpty) 'gender': gender, + 'status': status, + if (birthDate != null && birthDate!.isNotEmpty) 'birth_date': birthDate, + if (dueDate != null && dueDate!.isNotEmpty) 'due_date': dueDate, + 'consent_photo': consentPhoto, + 'is_multiple': isMultiple, + }; + } + + static String? _dateString(dynamic v) { + if (v == null) return null; + if (v is String) return v.split('T').first; + return v.toString().split('T').first; + } +} + +class EnfantParentLink { + final String parentId; + final String? parentName; + + EnfantParentLink({required this.parentId, this.parentName}); + + factory EnfantParentLink.fromJson(Map json) { + final parentId = + (json['parentId'] ?? json['id_parent'] ?? '').toString(); + String? name; + final parent = json['parent']; + if (parent is Map) { + final user = parent['user']; + if (user is Map) { + final u = AppUser.fromJson(user); + name = u.fullName.isNotEmpty ? u.fullName : u.email; + } + } + return EnfantParentLink( + parentId: parentId, + parentName: name, + ); + } +} diff --git a/frontend/lib/models/parent_child_summary.dart b/frontend/lib/models/parent_child_summary.dart new file mode 100644 index 0000000..3a30bcb --- /dev/null +++ b/frontend/lib/models/parent_child_summary.dart @@ -0,0 +1,92 @@ +import 'package:p_tits_pas/models/enfant_admin_model.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; + +/// Résumé enfant affiché dans la fiche parent (dashboard admin). +class ParentChildSummary { + final String id; + final String? firstName; + final String? lastName; + final String status; + final String? photoUrl; + final String? birthDate; + final String? dueDate; + + ParentChildSummary({ + required this.id, + this.firstName, + this.lastName, + required this.status, + this.photoUrl, + this.birthDate, + this.dueDate, + }); + + String get fullName { + final fn = (firstName ?? '').trim(); + final ln = (lastName ?? '').trim(); + if (fn.isEmpty && ln.isEmpty) return 'Enfant'; + if (ln.isEmpty) return fn; + return '$fn $ln'; + } + + factory ParentChildSummary.fromJson(Map json) { + return ParentChildSummary( + id: (json['id'] ?? '').toString(), + firstName: (json['first_name'] ?? json['prenom'])?.toString(), + lastName: (json['last_name'] ?? json['nom'])?.toString(), + status: normalizeEnfantStatus( + (json['status'] ?? json['statut'])?.toString(), + ), + photoUrl: json['photo_url']?.toString(), + birthDate: _dateString(json['birth_date']), + dueDate: _dateString(json['due_date']), + ); + } + + factory ParentChildSummary.fromEnfant(EnfantAdminModel enfant) { + return ParentChildSummary( + id: enfant.id, + firstName: enfant.firstName, + lastName: enfant.lastName, + status: enfant.status, + photoUrl: enfant.photoUrl, + birthDate: enfant.birthDate, + dueDate: enfant.dueDate, + ); + } + + static String? _dateString(dynamic v) { + if (v == null) return null; + if (v is String) return v.split('T').first; + return v.toString().split('T').first; + } + + /// Parse un lien `parentChildren` (objet enfant imbriqué ou id seul). + static ParentChildSummary? fromParentChildLink(Map link) { + final childRaw = link['child'] ?? link['enfant']; + if (childRaw is Map) { + return ParentChildSummary.fromJson(Map.from(childRaw)); + } + + if (link.containsKey('first_name') || + link.containsKey('prenom') || + link.containsKey('id')) { + final id = (link['id'] ?? '').toString(); + if (id.isNotEmpty && + (link.containsKey('first_name') || link.containsKey('prenom'))) { + return ParentChildSummary.fromJson(link); + } + } + + final enfantId = + link['enfantId'] ?? link['id_enfant'] ?? link['enfant_id']; + if (enfantId != null && enfantId.toString().isNotEmpty) { + return ParentChildSummary( + id: enfantId.toString(), + status: '', + ); + } + + return null; + } +} diff --git a/frontend/lib/models/parent_model.dart b/frontend/lib/models/parent_model.dart index 5b893fd..3a0cd5e 100644 --- a/frontend/lib/models/parent_model.dart +++ b/frontend/lib/models/parent_model.dart @@ -1,18 +1,65 @@ +import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/models/user.dart'; class ParentModel { final AppUser user; + final AppUser? coParent; final int childrenCount; + final List children; - ParentModel({required this.user, this.childrenCount = 0}); + ParentModel({ + required this.user, + this.coParent, + this.childrenCount = 0, + this.children = const [], + }); factory ParentModel.fromJson(Map json) { - final userJson = json['user'] ?? json; + final root = Map.from(json); + final userJson = Map.from(root['user'] ?? root); + if (root['numero_dossier'] != null && userJson['numero_dossier'] == null) { + userJson['numero_dossier'] = root['numero_dossier']; + } final user = AppUser.fromJson(userJson); - final children = json['parentChildren'] as List?; + + AppUser? coParent; + final coParentRaw = root['co_parent']; + if (coParentRaw is Map) { + coParent = AppUser.fromJson(Map.from(coParentRaw)); + } + + final children = _parseChildren(root); + final links = root['parentChildren'] ?? root['parent_children']; + final linkCount = links is List ? links.length : 0; + return ParentModel( user: user, - childrenCount: children?.length ?? 0, + coParent: coParent, + childrenCount: children.isNotEmpty ? children.length : linkCount, + children: children, ); } + + static List _parseChildren(Map json) { + final children = []; + final seen = {}; + + void add(ParentChildSummary? child) { + if (child == null || child.id.isEmpty || seen.contains(child.id)) return; + seen.add(child.id); + children.add(child); + } + + final links = json['parentChildren'] ?? json['parent_children']; + if (links is List) { + for (final link in links) { + if (link is! Map) continue; + add(ParentChildSummary.fromParentChildLink( + Map.from(link), + )); + } + } + + return children; + } } diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index da671bc..30508a4 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -61,6 +61,7 @@ class ApiConfig { static const String gestionnaires = '/gestionnaires'; static const String parents = '/parents'; static const String assistantesMaternelles = '/assistantes-maternelles'; + static const String enfants = '/enfants'; static const String relais = '/relais'; static const String dossiers = '/dossiers'; diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index 533af83..31ea722 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/models/parent_model.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; import 'package:p_tits_pas/models/pending_family.dart'; @@ -366,7 +367,136 @@ class UserService { } final List data = jsonDecode(response.body); - return data.map((e) => ParentModel.fromJson(e)).toList(); + return data + .map((e) => ParentModel.fromJson(Map.from(e as Map))) + .toList(); + } + + static Future getParent(String userId) async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$userId'), + headers: await _headers(), + ); + if (response.statusCode != 200) { + final err = jsonDecode(response.body) as Map?; + throw Exception(_toStr(err?['message']) ?? 'Erreur chargement parent'); + } + final decoded = jsonDecode(response.body); + return ParentModel.fromJson( + Map.from(decoded is Map ? decoded : {}), + ); + } + + static Future updateParentFiche({ + required String parentUserId, + required Map body, + }) async { + final response = await http.patch( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$parentUserId/fiche'), + headers: await _headers(), + body: jsonEncode(body), + ); + if (response.statusCode != 200) { + throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour parent')); + } + return _parentModelFromBody(response.body); + } + + static Future attachEnfantToParent({ + required String parentUserId, + required String enfantId, + }) async { + final response = await http.post( + Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.parents}/$parentUserId/enfants/$enfantId', + ), + headers: await _headers(), + ); + if (response.statusCode != 200 && response.statusCode != 201) { + throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant')); + } + return _parentModelFromBody(response.body); + } + + static Future detachEnfantFromParent({ + required String parentUserId, + required String enfantId, + }) async { + final response = await http.delete( + Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.parents}/$parentUserId/enfants/$enfantId', + ), + headers: await _headers(), + ); + if (response.statusCode != 200 && response.statusCode != 204) { + throw Exception(_extractErrorMessage(response.body, 'Erreur détachement enfant')); + } + if (response.body.isEmpty) { + return getParent(parentUserId); + } + return _parentModelFromBody(response.body); + } + + static Future> getEnfants() async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'), + headers: await _headers(), + ); + if (response.statusCode != 200) { + throw Exception(_extractErrorMessage(response.body, 'Erreur chargement enfants')); + } + final List data = jsonDecode(response.body); + return data + .whereType>() + .map(EnfantAdminModel.fromJson) + .toList(); + } + + static Future getEnfant(String enfantId) async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'), + headers: await _headers(), + ); + if (response.statusCode != 200) { + throw Exception(_extractErrorMessage(response.body, 'Erreur chargement enfant')); + } + return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map); + } + + static Future updateEnfant({ + required String enfantId, + required Map body, + }) async { + final response = await http.patch( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'), + headers: await _headers(), + body: jsonEncode(body), + ); + if (response.statusCode != 200) { + throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant')); + } + return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map); + } + + static ParentModel _parentModelFromBody(String body) { + final decoded = jsonDecode(body); + return ParentModel.fromJson( + Map.from(decoded is Map ? decoded : {}), + ); + } + + static String _extractErrorMessage(String body, String fallback) { + try { + final decoded = jsonDecode(body); + if (decoded is Map) { + final message = decoded['message']; + if (message is List && message.isNotEmpty) { + return message.join(' - '); + } + return _toStr(message) ?? fallback; + } + } catch (_) {} + return fallback; } // Récupérer la liste des assistantes maternelles @@ -383,7 +513,153 @@ class UserService { } final List data = jsonDecode(response.body); - return data.map((e) => AssistanteMaternelleModel.fromJson(e)).toList(); + return data + .map((e) => AssistanteMaternelleModel.fromJson( + Map.from(e as Map), + )) + .toList(); + } + + static Future getAssistanteMaternelle( + String userId, + ) async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$userId'), + headers: await _headers(), + ); + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body); + return AssistanteMaternelleModel.fromJson( + Map.from(decoded is Map ? decoded : {}), + ); + } + if (response.statusCode == 403 || response.statusCode == 404) { + final all = await getAssistantesMaternelles(); + return all.firstWhere( + (a) => a.user.id == userId, + orElse: () => throw Exception('Assistante maternelle introuvable'), + ); + } + final err = jsonDecode(response.body) as Map?; + throw Exception(_toStr(err?['message']) ?? 'Erreur chargement AM'); + } + + /// Mise à jour fiche AM (identité + champs pro). Ticket #131. + static Future updateAmFiche({ + required String amUserId, + required Map body, + }) async { + final ficheResponse = await http.patch( + Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId/fiche', + ), + headers: await _headers(), + body: jsonEncode(body), + ); + if (ficheResponse.statusCode == 200) { + return _amModelFromBody(ficheResponse.body); + } + if (ficheResponse.statusCode != 404) { + throw Exception( + _extractErrorMessage(ficheResponse.body, 'Erreur mise à jour AM'), + ); + } + + final userFields = {}; + for (final k in [ + 'nom', + 'prenom', + 'email', + 'telephone', + 'adresse', + 'ville', + 'code_postal', + 'statut', + 'date_naissance', + 'lieu_naissance_ville', + 'lieu_naissance_pays', + ]) { + if (body.containsKey(k)) userFields[k] = body[k]; + } + + final proFields = {}; + for (final entry in body.entries) { + if (!userFields.containsKey(entry.key)) { + proFields[entry.key] = entry.value; + } + } + + if (userFields.isNotEmpty) { + final userResponse = await http.patch( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$amUserId'), + headers: await _headers(), + body: jsonEncode(userFields), + ); + if (userResponse.statusCode != 200) { + throw Exception( + _extractErrorMessage(userResponse.body, 'Erreur mise à jour identité'), + ); + } + } + + if (proFields.isNotEmpty) { + final proResponse = await http.patch( + Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId', + ), + headers: await _headers(), + body: jsonEncode(proFields), + ); + if (proResponse.statusCode != 200) { + throw Exception( + _extractErrorMessage(proResponse.body, 'Erreur mise à jour fiche pro'), + ); + } + } + + return getAssistanteMaternelle(amUserId); + } + + static Future attachEnfantToAm({ + required String amUserId, + required String enfantId, + }) async { + final response = await http.post( + Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId/enfants/$enfantId', + ), + headers: await _headers(), + ); + if (response.statusCode != 200 && response.statusCode != 201) { + throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant')); + } + return _amModelFromBody(response.body); + } + + static Future detachEnfantFromAm({ + required String amUserId, + required String enfantId, + }) async { + final response = await http.delete( + Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId/enfants/$enfantId', + ), + headers: await _headers(), + ); + if (response.statusCode != 200 && response.statusCode != 204) { + throw Exception(_extractErrorMessage(response.body, 'Erreur détachement enfant')); + } + if (response.body.isEmpty) { + return getAssistanteMaternelle(amUserId); + } + return _amModelFromBody(response.body); + } + + static AssistanteMaternelleModel _amModelFromBody(String body) { + final decoded = jsonDecode(body); + return AssistanteMaternelleModel.fromJson( + Map.from(decoded is Map ? decoded : {}), + ); } // Récupérer la liste des administrateurs (via /users filtré ou autre) diff --git a/frontend/lib/utils/am_vigilance.dart b/frontend/lib/utils/am_vigilance.dart new file mode 100644 index 0000000..944dc93 --- /dev/null +++ b/frontend/lib/utils/am_vigilance.dart @@ -0,0 +1,45 @@ +import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; + +/// Places disponibles attendues : capacité max − enfants rattachés. +int? amExpectedPlacesAvailable({ + required int? maxChildren, + required int childrenCount, +}) { + if (maxChildren == null) return null; + return (maxChildren - childrenCount).clamp(0, maxChildren); +} + +/// True si la valeur déclarée par l'AM ne correspond pas au calcul métier. +bool amHasPlacesInconsistency({ + required int? maxChildren, + required int? placesAvailable, + required int childrenCount, +}) { + final expected = amExpectedPlacesAvailable( + maxChildren: maxChildren, + childrenCount: childrenCount, + ); + if (expected == null) return false; + if (placesAvailable == null) return childrenCount > (maxChildren ?? 0); + return placesAvailable != expected; +} + +String? amPlacesVigilanceMessage(AssistanteMaternelleModel am) { + if (!amHasPlacesInconsistency( + maxChildren: am.maxChildren, + placesAvailable: am.placesAvailable, + childrenCount: am.children.length, + )) { + return null; + } + final stored = am.placesAvailable; + final expected = amExpectedPlacesAvailable( + maxChildren: am.maxChildren, + childrenCount: am.children.length, + ); + final storedLabel = stored?.toString() ?? 'non renseigné'; + final expectedLabel = expected?.toString() ?? '–'; + return 'Point de vigilance : l\'AM déclare $storedLabel place(s) disponible(s), ' + 'alors que capacité ${am.maxChildren ?? '–'} − ' + '${am.children.length} enfant(s) rattaché(s) = $expectedLabel.'; +} diff --git a/frontend/lib/utils/date_display_utils.dart b/frontend/lib/utils/date_display_utils.dart index 075f2d2..61ecb5e 100644 --- a/frontend/lib/utils/date_display_utils.dart +++ b/frontend/lib/utils/date_display_utils.dart @@ -1,4 +1,5 @@ import 'package:intl/intl.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; /// Affiche une date ISO / parseable en `dd/MM/yyyy`, avec repli sur la chaîne ou [ifEmpty]. String formatIsoDateFr(String? s, {String ifEmpty = '–'}) { @@ -9,3 +10,89 @@ String formatIsoDateFr(String? s, {String ifEmpty = '–'}) { return s.trim(); } } + +/// Convertit `dd/MM/yyyy` (ou ISO) en `yyyy-MM-dd` pour l'API. +String? parseFrDateToIso(String text) { + final t = text.trim(); + if (t.isEmpty) return null; + try { + return DateFormat('dd/MM/yyyy') + .parseStrict(t) + .toIso8601String() + .split('T') + .first; + } catch (_) { + try { + return DateTime.parse(t).toIso8601String().split('T').first; + } catch (_) { + return null; + } + } +} + +({int years, int months, int days}) computeChildAgeParts( + DateTime birth, + DateTime reference, +) { + final birthDay = DateTime(birth.year, birth.month, birth.day); + final refDay = DateTime(reference.year, reference.month, reference.day); + + var years = refDay.year - birthDay.year; + var months = refDay.month - birthDay.month; + var days = refDay.day - birthDay.day; + + if (days < 0) { + months--; + final prevMonth = DateTime(refDay.year, refDay.month, 0); + days += prevMonth.day; + } + if (months < 0) { + years--; + months += 12; + } + + return (years: years, months: months, days: days); +} + +String _yearLabel(int years) => years == 1 ? '1 an' : '$years ans'; + +String _monthLabel(int months) => months == 1 ? '1 mois' : '$months mois'; + +/// Libellé d'âge pour un enfant (liste admin, fiche parent, etc.). +String formatChildAgeLabel({ + String? birthDate, + String? dueDate, + String? status, +}) { + if (status == 'a_naitre' || normalizeEnfantStatus(status) == 'a_naitre') { + final due = formatIsoDateFr(dueDate, ifEmpty: ''); + if (due.isNotEmpty) return 'Naissance prévue : $due'; + return 'À naître'; + } + + if (birthDate == null || birthDate.trim().isEmpty) return ''; + + try { + final birth = DateTime.parse(birthDate.trim().split('T').first); + final now = DateTime.now(); + final parts = computeChildAgeParts(birth, now); + + if (parts.years >= 1) { + if (parts.months > 0) { + return 'Âge : ${_yearLabel(parts.years)} ${parts.months} mois'; + } + return 'Âge : ${_yearLabel(parts.years)}'; + } + if (parts.months >= 1) { + return 'Âge : ${_monthLabel(parts.months)}'; + } + + final totalDays = DateTime(now.year, now.month, now.day) + .difference(DateTime(birth.year, birth.month, birth.day)) + .inDays; + if (totalDays <= 0) return 'Âge : né récemment'; + return 'Âge : $totalDays jour${totalDays > 1 ? 's' : ''}'; + } catch (_) { + return 'Né le ${formatIsoDateFr(birthDate)}'; + } +} diff --git a/frontend/lib/utils/enfant_status_utils.dart b/frontend/lib/utils/enfant_status_utils.dart new file mode 100644 index 0000000..9ea84b5 --- /dev/null +++ b/frontend/lib/utils/enfant_status_utils.dart @@ -0,0 +1,51 @@ +/// Statuts enfant alignés sur `statut_enfant_type` (BDD / back #131). +const enfantStatusValues = [ + 'a_naitre', + 'sans_garde', + 'garde', + 'scolarise', +]; + +/// Normalise une valeur API (legacy `actif` → `sans_garde`). +String normalizeEnfantStatus(String? raw) { + final s = (raw ?? '').trim().toLowerCase(); + if (s.isEmpty) return 'sans_garde'; + if (s == 'actif') return 'sans_garde'; + return s; +} + +String _scolariseAccordeAuGenre(String? gender) { + final g = (gender ?? '').trim().toUpperCase(); + if (g == 'F') return 'Scolarisée'; + return 'Scolarisé'; +} + +/// Libellé affiché pour un statut enfant. +String enfantStatusLabel(String? status, {String? gender}) { + switch (normalizeEnfantStatus(status)) { + case 'a_naitre': + return 'À naître'; + case 'sans_garde': + return 'Sans garde'; + case 'garde': + return 'En garde'; + case 'scolarise': + return _scolariseAccordeAuGenre(gender); + default: + return status?.trim().isNotEmpty == true ? status!.trim() : '–'; + } +} + +/// Libellé court pour colonnes validation (null = pas de bandeau). +String? enfantColumnStatusLabel({String? status, String? gender}) { + final s = normalizeEnfantStatus(status); + switch (s) { + case 'a_naitre': + case 'sans_garde': + case 'garde': + case 'scolarise': + return enfantStatusLabel(s, gender: gender); + default: + return null; + } +} diff --git a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart index 432a782..5ee5ed1 100644 --- a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart +++ b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; -import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/utils/am_vigilance.dart'; import 'package:p_tits_pas/services/user_service.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; @@ -77,10 +77,12 @@ class _AssistanteMaternelleManagementWidgetState itemCount: filteredAssistantes.length, itemBuilder: (context, index) { final assistante = filteredAssistantes[index]; + final vigilance = amPlacesVigilanceMessage(assistante); return AdminUserCard( title: assistante.user.fullName, avatarUrl: assistante.user.photoUrl, fallbackIcon: Icons.face, + vigilanceTooltip: vigilance, subtitleLines: [ assistante.user.email, 'Zone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}', @@ -102,55 +104,10 @@ class _AssistanteMaternelleManagementWidgetState void _openAssistanteDetails(AssistanteMaternelleModel assistante) { showDialog( context: context, - builder: (context) => AdminDetailModal( - title: assistante.user.fullName.isEmpty - ? 'Assistante maternelle' - : assistante.user.fullName, - subtitle: assistante.user.email, - fields: [ - AdminDetailField(label: 'ID', value: _v(assistante.user.id)), - AdminDetailField( - label: 'Numero agrement', - value: _v(assistante.approvalNumber), - ), - AdminDetailField( - label: 'Ville residence', - value: _v(assistante.residenceCity), - ), - AdminDetailField( - label: 'Capacite max', - value: assistante.maxChildren?.toString() ?? '-', - ), - AdminDetailField( - label: 'Places disponibles', - value: assistante.placesAvailable?.toString() ?? '-', - ), - AdminDetailField( - label: 'Telephone', - value: _v(assistante.user.telephone) != '–' ? formatPhoneForDisplay(_v(assistante.user.telephone)) : '–', - ), - AdminDetailField(label: 'Adresse', value: _v(assistante.user.adresse)), - AdminDetailField(label: 'Ville', value: _v(assistante.user.ville)), - AdminDetailField( - label: 'Code postal', - value: _v(assistante.user.codePostal), - ), - ], - onEdit: () { - Navigator.of(context).pop(); - ScaffoldMessenger.of(this.context).showSnackBar( - const SnackBar(content: Text('Action Modifier a implementer')), - ); - }, - onDelete: () { - Navigator.of(context).pop(); - ScaffoldMessenger.of(this.context).showSnackBar( - const SnackBar(content: Text('Action Supprimer a implementer')), - ); - }, + builder: (context) => AdminAmEditModal( + assistante: assistante, + onSaved: _loadAssistantes, ), ); } - - String _v(String? value) => (value == null || value.isEmpty) ? '-' : value; } diff --git a/frontend/lib/widgets/admin/common/admin_am_children_capacity_grid.dart b/frontend/lib/widgets/admin/common/admin_am_children_capacity_grid.dart new file mode 100644 index 0000000..b77871c --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_am_children_capacity_grid.dart @@ -0,0 +1,301 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/parent_child_summary.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/utils/date_display_utils.dart'; +import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; + +/// Grille 2×2 des places d'accueil AM (max 4, limitée à [capacity]). +class AdminAmChildrenCapacityGrid extends StatelessWidget { + static const int _gridSlots = 4; + static const double _slotHeight = 44; + static const double _gridPadding = 10; + static const double _gridGap = 8; + static const double _borderWidth = 1; + static const double fixedHeight = _borderWidth * 2 + + _gridPadding * 2 + + _slotHeight * 2 + + _gridGap; + + final List children; + final int capacity; + final void Function(ParentChildSummary child) onOpen; + final void Function(ParentChildSummary child) onDetach; + + const AdminAmChildrenCapacityGrid({ + super.key, + required this.children, + required this.capacity, + required this.onOpen, + required this.onDetach, + }); + + @override + Widget build(BuildContext context) { + final maxSlots = capacity.clamp(0, _gridSlots); + + return Container( + height: fixedHeight, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300, width: _borderWidth), + ), + padding: const EdgeInsets.all(_gridPadding), + child: Column( + children: [ + _buildRow(0, 1, maxSlots), + const SizedBox(height: _gridGap), + _buildRow(2, 3, maxSlots), + ], + ), + ); + } + + Widget _buildRow(int leftIndex, int rightIndex, int maxSlots) { + return Row( + children: [ + Expanded(child: _buildSlot(leftIndex, maxSlots)), + const SizedBox(width: _gridGap), + Expanded(child: _buildSlot(rightIndex, maxSlots)), + ], + ); + } + + Widget _buildSlot(int index, int maxSlots) { + return SizedBox( + height: _slotHeight, + child: _buildSlotContent(index, maxSlots), + ); + } + + Widget _buildSlotContent(int index, int maxSlots) { + if (index < children.length) { + return _OccupiedSlot( + child: children[index], + overCapacity: index >= maxSlots, + onOpen: () => onOpen(children[index]), + onDetach: () => onDetach(children[index]), + ); + } + if (index < maxSlots) { + return const _EmptySlot(); + } + return const _UnavailableSlot(); + } +} + +class _SlotShell extends StatelessWidget { + final Color backgroundColor; + final Color borderColor; + final Widget child; + + const _SlotShell({ + required this.backgroundColor, + required this.borderColor, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return SizedBox.expand( + child: DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: borderColor), + ), + child: child, + ), + ); + } +} + +class _UnavailableSlot extends StatelessWidget { + const _UnavailableSlot(); + + @override + Widget build(BuildContext context) { + return _SlotShell( + backgroundColor: Colors.grey.shade100, + borderColor: Colors.grey.shade200, + child: Center( + child: Icon( + Icons.block_outlined, + size: 20, + color: Colors.grey.shade400, + ), + ), + ); + } +} + +class _EmptySlot extends StatelessWidget { + const _EmptySlot(); + + @override + Widget build(BuildContext context) { + return _SlotShell( + backgroundColor: Colors.grey.shade50, + borderColor: Colors.grey.shade300, + child: Center( + child: Text( + 'Place libre', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + ), + ); + } +} + +class _OccupiedSlot extends StatefulWidget { + final ParentChildSummary child; + final bool overCapacity; + final VoidCallback onOpen; + final VoidCallback onDetach; + + const _OccupiedSlot({ + required this.child, + required this.overCapacity, + required this.onOpen, + required this.onDetach, + }); + + @override + State<_OccupiedSlot> createState() => _OccupiedSlotState(); +} + +class _OccupiedSlotState extends State<_OccupiedSlot> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final age = formatChildAgeLabel( + birthDate: widget.child.birthDate, + dueDate: widget.child.dueDate, + status: widget.child.status, + ); + final avatarUrl = ApiConfig.absoluteMediaUrl(widget.child.photoUrl ?? ''); + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: _SlotShell( + backgroundColor: widget.overCapacity + ? Colors.red.shade50 + : const Color(0xFFF8F5FC), + borderColor: widget.overCapacity + ? Colors.red.shade300 + : const Color(0xFFD8CCE8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildAvatar(avatarUrl), + const SizedBox(width: 8), + Expanded( + child: Row( + children: [ + Flexible( + fit: FlexFit.loose, + child: Text( + widget.child.fullName, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (age.isNotEmpty) ...[ + const SizedBox(width: 8), + Expanded( + child: Text( + age, + style: const TextStyle( + fontSize: 11, + color: Colors.black54, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ], + ), + ), + AnimatedOpacity( + duration: const Duration(milliseconds: 120), + opacity: _hovered ? 1 : 0, + child: IgnorePointer( + ignoring: !_hovered, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Voir / modifier', + icon: const Icon(Icons.visibility_outlined, size: 20), + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + onPressed: widget.onOpen, + ), + IconButton( + tooltip: 'Détacher', + icon: Icon( + Icons.link_off, + size: 20, + color: Colors.orange.shade800, + ), + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + onPressed: widget.onDetach, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildAvatar(String url) { + const size = 28.0; + const bg = Color(0xFFEDE5FA); + const iconColor = Color(0xFF6B3FA0); + + if (url.isEmpty) { + return CircleAvatar( + radius: 14, + backgroundColor: bg, + child: const Icon(Icons.child_care_outlined, size: 15, color: iconColor), + ); + } + + return ClipOval( + child: SizedBox( + width: size, + height: size, + child: AuthNetworkImage( + url: url, + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => ColoredBox( + color: bg, + child: const Icon(Icons.child_care_outlined, size: 15, color: iconColor), + ), + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart new file mode 100644 index 0000000..b3ca7f1 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart @@ -0,0 +1,841 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; +import 'package:p_tits_pas/models/enfant_admin_model.dart'; +import 'package:p_tits_pas/models/parent_child_summary.dart'; +import 'package:p_tits_pas/utils/am_vigilance.dart'; +import 'package:p_tits_pas/utils/date_display_utils.dart'; +import 'package:p_tits_pas/utils/nir_utils.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_am_children_capacity_grid.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart'; +import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/common/identity_block.dart'; +import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; + +/// Fiche AM éditable (ticket #131) — identité | fiche pro (photo) | enfants. +class AdminAmEditModal extends StatefulWidget { + final AssistanteMaternelleModel assistante; + final VoidCallback? onSaved; + + const AdminAmEditModal({ + super.key, + required this.assistante, + this.onSaved, + }); + + @override + State createState() => _AdminAmEditModalState(); +} + +class _AdminAmEditModalState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabCtrl; + late final TextEditingController _nomCtrl; + late final TextEditingController _prenomCtrl; + late final TextEditingController _emailCtrl; + late final TextEditingController _telCtrl; + late final TextEditingController _adresseCtrl; + late final TextEditingController _villeCtrl; + late final TextEditingController _cpCtrl; + late final TextEditingController _agrementCtrl; + late final TextEditingController _nirCtrl; + late final TextEditingController _dateNaissanceCtrl; + late final TextEditingController _lieuNaissanceVilleCtrl; + late final TextEditingController _lieuNaissancePaysCtrl; + late final TextEditingController _dateAgrementCtrl; + late final TextEditingController _capaciteCtrl; + + late String _statut; + late bool _disponible; + late int? _placesAvailable; + late List _children; + late Set _baselineChildIds; + + bool _saving = false; + bool _dirty = false; + + static const double _modalWidth = 930; + static const double _photoProGap = 24; + static const double _proColumnMinWidth = 260; + static const double _photoColumnMinWidth = 160; + static const double _proTabHeight = 300; + static const List _photoProRowLayout = [2, 2, 2]; + + /// Hauteur onglet enfants : champs + titre + grille 2×2 (+ alerte places si besoin). + double _childrenTabHeight() { + const capacityFields = 72.0; + const titleSection = 40.0; + const inconsistencyExtra = 46.0; + var h = capacityFields + + titleSection + + AdminAmChildrenCapacityGrid.fixedHeight; + if (_placesInconsistent()) h += inconsistencyExtra; + return h + 4; + } + + double _tabViewHeight(int index) { + switch (index) { + case 1: + return _proTabHeight; + case 2: + return _childrenTabHeight(); + default: + return 292; + } + } + + @override + void initState() { + super.initState(); + _tabCtrl = TabController(length: 3, vsync: this); + final u = widget.assistante.user; + final am = widget.assistante; + + _nomCtrl = TextEditingController(text: u.nom ?? ''); + _prenomCtrl = TextEditingController(text: u.prenom ?? ''); + _emailCtrl = TextEditingController(text: u.email); + _telCtrl = TextEditingController( + text: formatPhoneForDisplay(u.telephone ?? ''), + ); + _adresseCtrl = TextEditingController(text: u.adresse ?? ''); + _villeCtrl = TextEditingController(text: u.ville ?? ''); + _cpCtrl = TextEditingController(text: u.codePostal ?? ''); + _agrementCtrl = TextEditingController(text: am.approvalNumber ?? ''); + _nirCtrl = TextEditingController(text: _formatNirDisplay()); + _dateNaissanceCtrl = TextEditingController( + text: formatIsoDateFr(u.dateNaissance, ifEmpty: ''), + ); + _lieuNaissanceVilleCtrl = TextEditingController( + text: u.lieuNaissanceVille ?? '', + ); + _lieuNaissancePaysCtrl = TextEditingController( + text: u.lieuNaissancePays ?? '', + ); + _dateAgrementCtrl = TextEditingController( + text: formatIsoDateFr(am.agreementDate, ifEmpty: ''), + ); + _capaciteCtrl = TextEditingController( + text: am.maxChildren?.toString() ?? '', + ); + _statut = u.statut ?? 'en_attente'; + _disponible = am.available ?? true; + _placesAvailable = am.placesAvailable; + _children = List.of(am.children); + _baselineChildIds = _children.map((c) => c.id).toSet(); + + for (final c in [ + _nomCtrl, + _prenomCtrl, + _emailCtrl, + _telCtrl, + _adresseCtrl, + _villeCtrl, + _cpCtrl, + _agrementCtrl, + _nirCtrl, + _dateNaissanceCtrl, + _lieuNaissanceVilleCtrl, + _lieuNaissancePaysCtrl, + _dateAgrementCtrl, + _capaciteCtrl, + ]) { + c.addListener(_markDirty); + } + _capaciteCtrl.addListener(_onCapacityChanged); + _nomCtrl.addListener(_onNameFieldChanged); + _prenomCtrl.addListener(_onNameFieldChanged); + _tabCtrl.addListener(_onTabChanged); + _fetchChildrenFromServer(); + } + + bool _childrenChanged() { + final current = _children.map((c) => c.id).toSet(); + return current.length != _baselineChildIds.length || + !current.containsAll(_baselineChildIds); + } + + void _syncPlacesAfterChildrenChange() { + final expected = _computedPlacesAvailable(); + if (expected != null) _placesAvailable = expected; + } + + void _onTabChanged() { + if (!_tabCtrl.indexIsChanging) setState(() {}); + } + + void _onNameFieldChanged() => setState(() {}); + + void _onCapacityChanged() => setState(() {}); + + @override + void dispose() { + _tabCtrl.dispose(); + for (final c in [ + _nomCtrl, + _prenomCtrl, + _emailCtrl, + _telCtrl, + _adresseCtrl, + _villeCtrl, + _cpCtrl, + _agrementCtrl, + _nirCtrl, + _dateNaissanceCtrl, + _lieuNaissanceVilleCtrl, + _lieuNaissancePaysCtrl, + _dateAgrementCtrl, + _capaciteCtrl, + ]) { + c.dispose(); + } + super.dispose(); + } + + void _markDirty() { + if (!_dirty) setState(() => _dirty = true); + } + + String _headerTitle() { + final fn = _prenomCtrl.text.trim(); + final ln = _nomCtrl.text.trim(); + if (fn.isEmpty && ln.isEmpty) { + final fallback = widget.assistante.user.fullName.trim(); + return fallback.isNotEmpty ? fallback : 'Assistante maternelle'; + } + return '$fn $ln'.trim(); + } + + String? _headerSubtitle() { + final parts = []; + final zone = (widget.assistante.residenceCity ?? '').trim(); + if (zone.isNotEmpty) parts.add('Zone : $zone'); + final agrement = _agrementCtrl.text.trim(); + if (agrement.isNotEmpty) parts.add('Agrément : $agrement'); + final dossier = widget.assistante.user.numeroDossier?.trim(); + if (dossier != null && dossier.isNotEmpty) { + parts.add('Dossier $dossier'); + } + if (parts.isEmpty) return null; + return parts.join(' · '); + } + + String _formatNirDisplay() { + final raw = widget.assistante.nir?.trim() ?? ''; + if (raw.isEmpty) return ''; + final digits = nirToRaw(raw).toUpperCase(); + return digits.length == 15 ? formatNir(digits) : raw; + } + + String? _frDateToIso(String text) => parseFrDateToIso(text); + + int? _capaciteMax() => _parseIntField(_capaciteCtrl); + + int? _computedPlacesAvailable() => amExpectedPlacesAvailable( + maxChildren: _capaciteMax(), + childrenCount: _children.length, + ); + + bool _placesInconsistent() => amHasPlacesInconsistency( + maxChildren: _capaciteMax(), + placesAvailable: _placesAvailable, + childrenCount: _children.length, + ); + + String _placesDisplayValue() { + if (_placesAvailable != null) return _placesAvailable.toString(); + return '–'; + } + + void _applyPlacesCorrection() { + final expected = _computedPlacesAvailable(); + if (expected == null) return; + setState(() { + _placesAvailable = expected; + _dirty = true; + }); + } + + String? _placesInconsistencyMessage() { + if (!_placesInconsistent()) return null; + final stored = _placesAvailable; + final expected = _computedPlacesAvailable(); + final storedLabel = stored?.toString() ?? 'non renseigné'; + final expectedLabel = expected?.toString() ?? '–'; + return 'Incohérence : l\'AM déclare $storedLabel place(s) disponible(s), ' + 'le calcul (capacité ${_capaciteMax() ?? '–'} − ${_children.length} ' + 'enfant(s) rattaché(s)) donne $expectedLabel.'; + } + + int? _parseIntField(TextEditingController c) { + final t = c.text.trim(); + if (t.isEmpty) return null; + return int.tryParse(t); + } + + Future _save() async { + if (!_dirty) return; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Confirmer'), + content: const Text( + 'Enregistrer les modifications de la fiche assistante maternelle ?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Sauvegarder'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + setState(() => _saving = true); + try { + if (_childrenChanged()) _syncPlacesAfterChildrenChange(); + + final currentIds = _children.map((c) => c.id).toSet(); + for (final id in _baselineChildIds.difference(currentIds)) { + await UserService.detachEnfantFromAm( + amUserId: widget.assistante.user.id, + enfantId: id, + ); + } + for (final id in currentIds.difference(_baselineChildIds)) { + await UserService.attachEnfantToAm( + amUserId: widget.assistante.user.id, + enfantId: id, + ); + } + + await UserService.updateAmFiche( + amUserId: widget.assistante.user.id, + body: { + 'nom': _nomCtrl.text.trim(), + 'prenom': _prenomCtrl.text.trim(), + 'email': _emailCtrl.text.trim(), + 'telephone': normalizePhone(_telCtrl.text), + 'adresse': _adresseCtrl.text.trim(), + 'ville': _villeCtrl.text.trim(), + 'code_postal': _cpCtrl.text.trim(), + 'statut': _statut, + 'approval_number': _agrementCtrl.text.trim(), + 'nir': nirToRaw(_nirCtrl.text), + if (_frDateToIso(_dateNaissanceCtrl.text) != null) + 'date_naissance': _frDateToIso(_dateNaissanceCtrl.text), + 'lieu_naissance_ville': _lieuNaissanceVilleCtrl.text.trim(), + 'lieu_naissance_pays': _lieuNaissancePaysCtrl.text.trim(), + if (_frDateToIso(_dateAgrementCtrl.text) != null) + 'agreement_date': _frDateToIso(_dateAgrementCtrl.text), + if (_capaciteMax() != null) 'max_children': _capaciteMax(), + if (_placesAvailable != null) 'places_available': _placesAvailable, + 'available': _disponible, + }, + ); + if (!mounted) return; + setState(() { + _dirty = false; + _saving = false; + _baselineChildIds = currentIds; + }); + widget.onSaved?.call(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Fiche assistante maternelle enregistrée')), + ); + } catch (e) { + if (!mounted) return; + setState(() => _saving = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + + Future _fetchChildrenFromServer() async { + try { + final refreshed = + await UserService.getAssistanteMaternelle(widget.assistante.user.id); + final all = await UserService.getEnfants(); + final byId = {for (final e in all) e.id: e}; + + final kids = refreshed.children.map((c) { + final full = byId[c.id]; + if (full != null) return ParentChildSummary.fromEnfant(full); + return c; + }).toList(); + + if (!mounted) return; + setState(() { + _children = kids; + _baselineChildIds = kids.map((c) => c.id).toSet(); + }); + } catch (_) {} + } + + Future _refreshChildrenDetails() async { + try { + final all = await UserService.getEnfants(); + final byId = {for (final e in all) e.id: e}; + + if (!mounted) return; + setState(() { + _children = _children.map((c) { + final full = byId[c.id]; + if (full != null) return ParentChildSummary.fromEnfant(full); + return c; + }).toList(); + }); + } catch (_) {} + } + + Future _openChild(ParentChildSummary child) async { + try { + final enfant = await UserService.getEnfant(child.id); + if (!mounted) return; + await showDialog( + context: context, + builder: (ctx) => AdminChildDetailModal( + enfant: enfant, + onSaved: _refreshChildrenDetails, + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + + Future _detachChild(ParentChildSummary child) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Détacher l\'enfant'), + content: Text( + 'Retirer ${child.fullName} de la fiche de cette assistante ?\n' + '(L\'enfant ne sera pas supprimé.)', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ValidationModalTheme.primaryElevatedStyle, + child: const Text('Détacher'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + setState(() { + _children = _children.where((c) => c.id != child.id).toList(); + _syncPlacesAfterChildrenChange(); + _dirty = true; + }); + } + + Future _attachChild() async { + List all; + try { + all = await UserService.getEnfants(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + return; + } + + final linkedIds = _children.map((c) => c.id).toSet(); + final candidates = all.where((e) => !linkedIds.contains(e.id)).toList(); + if (candidates.isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Aucun enfant disponible à rattacher')), + ); + return; + } + + if (!mounted) return; + final selected = await showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: const Text('Rattacher un enfant'), + children: candidates + .map( + (e) => SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, e), + child: Text(e.fullName), + ), + ) + .toList(), + ), + ); + if (selected == null || !mounted) return; + + setState(() { + _children = [ + ..._children, + ParentChildSummary.fromEnfant(selected), + ]; + _syncPlacesAfterChildrenChange(); + _dirty = true; + }); + } + + + Widget _identityTab() { + return SingleChildScrollView( + child: IdentityBlock.editable( + title: 'Identité et coordonnées', + nomController: _nomCtrl, + prenomController: _prenomCtrl, + telephoneController: _telCtrl, + emailController: _emailCtrl, + adresseController: _adresseCtrl, + codePostalController: _cpCtrl, + villeController: _villeCtrl, + ), + ); + } + + Widget _proFieldsGrid() { + return ValidationFormGrid( + title: 'Dossier professionnel', + rowLayout: _photoProRowLayout, + fields: [ + ValidationLabeledField( + label: 'NIR', + field: ValidationEditableField( + controller: _nirCtrl, + hintText: '15 chiffres', + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[\d\s]')), + ], + ), + ), + ValidationLabeledField( + label: 'Date de naissance', + field: ValidationEditableField( + controller: _dateNaissanceCtrl, + hintText: 'jj/mm/aaaa', + ), + ), + ValidationLabeledField( + label: 'Ville de naissance', + field: ValidationEditableField( + controller: _lieuNaissanceVilleCtrl, + ), + ), + ValidationLabeledField( + label: 'Pays de naissance', + field: ValidationEditableField( + controller: _lieuNaissancePaysCtrl, + ), + ), + ValidationLabeledField( + label: 'N° Agrément', + field: ValidationEditableField( + controller: _agrementCtrl, + ), + ), + ValidationLabeledField( + label: 'Date d\'agrément', + field: ValidationEditableField( + controller: _dateAgrementCtrl, + hintText: 'jj/mm/aaaa', + ), + ), + ], + ); + } + + Widget _proTab() { + return LayoutBuilder( + builder: (context, c) { + final maxRowW = c.maxWidth; + final maxRowH = c.maxHeight; + final bodyH = maxRowH; + final idealPhotoW = bodyH * AdminAmPhotoFrame.idPhotoAspectRatio + 16; + final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth) + .clamp(0.0, double.infinity); + var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 220.0); + if (photoW > maxPhotoW) photoW = maxPhotoW; + + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: photoW, + child: AdminAmPhotoFrame( + photoUrl: widget.assistante.user.photoUrl, + ), + ), + const SizedBox(width: _photoProGap), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _proFieldsGrid(), + SwitchListTile( + contentPadding: EdgeInsets.zero, + dense: true, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + title: const Text( + 'Disponible pour accueillir', + style: TextStyle(fontSize: 13), + ), + value: _disponible, + onChanged: (v) => setState(() { + _disponible = v; + _dirty = true; + }), + ), + ], + ), + ), + ), + ], + ); + }, + ); + } + + Widget _childrenCapacityFields() { + final inconsistent = _placesInconsistent(); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ValidationEditableSection( + rowLayout: const [2], + fields: [ + ValidationLabeledField( + label: 'Capacité max (enfants)', + field: ValidationEditableField( + controller: _capaciteCtrl, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + ), + ValidationLabeledField( + label: 'Places disponibles', + field: ValidationReadOnlyField( + value: _placesDisplayValue(), + error: inconsistent, + ), + ), + ], + ), + if (inconsistent) ...[ + const SizedBox(height: 6), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 6, + runSpacing: 4, + children: [ + Text( + _placesInconsistencyMessage()!, + style: TextStyle( + fontSize: 12, + color: Colors.red.shade700, + height: 1.3, + ), + ), + OutlinedButton( + onPressed: _saving ? null : _applyPlacesCorrection, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red.shade800, + disabledForegroundColor: Colors.red.shade300, + side: BorderSide(color: Colors.red.shade400), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + minimumSize: const Size(0, 28), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + child: const Text('Mettre à jour'), + ), + ], + ), + ], + ], + ); + } + + Widget _childrenTab() { + final capacity = (_capaciteMax() ?? 4).clamp(1, 4); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _childrenCapacityFields(), + const SizedBox(height: 10), + Text( + 'Enfants accueillis : ${_children.length}', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const SizedBox(height: 8), + AdminAmChildrenCapacityGrid( + children: _children, + capacity: capacity, + onOpen: _openChild, + onDetach: _detachChild, + ), + ], + ); + } + + Widget _buildFooter() { + final isChildrenTab = _tabCtrl.index == 2; + return Row( + children: [ + TextButton( + onPressed: _saving ? null : () => Navigator.of(context).pop(), + child: const Text('Fermer'), + ), + const Spacer(), + if (isChildrenTab) + TextButton.icon( + onPressed: _attachChild, + icon: const Icon(Icons.link, size: 18), + label: const Text('Rattacher un enfant'), + ), + if (isChildrenTab) const SizedBox(width: 12), + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: !_dirty || _saving ? null : _save, + child: _saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text(_dirty ? 'Sauvegarder' : 'Aucune modification'), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _modalWidth), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 4, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _headerTitle(), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + if (_headerSubtitle() != null) ...[ + const SizedBox(height: 4), + Text( + _headerSubtitle()!, + style: const TextStyle( + fontSize: 13, + color: Colors.black54, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 160, + child: AdminStatusCapsule( + statut: _statut, + onChanged: (v) => setState(() { + _statut = v; + _dirty = true; + }), + ), + ), + IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 40, + minHeight: 40, + ), + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + tooltip: 'Fermer', + ), + ], + ), + ), + TabBar( + controller: _tabCtrl, + onTap: (_) => setState(() {}), + labelColor: ValidationModalTheme.primaryActionBackground, + unselectedLabelColor: Colors.black54, + indicatorColor: ValidationModalTheme.primaryActionBackground, + tabs: const [ + Tab(text: 'Identité'), + Tab(text: 'Fiche professionnelle'), + Tab(text: 'Enfants accueillis'), + ], + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), + child: SizedBox( + height: _tabViewHeight(_tabCtrl.index), + child: TabBarView( + controller: _tabCtrl, + children: [ + _identityTab(), + _proTab(), + _childrenTab(), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), + child: _buildFooter(), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart b/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart new file mode 100644 index 0000000..17ac62e --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; + +/// Cadre photo identité AM (35×45 mm) — même logique que [ValidationAmWizard]. +class AdminAmPhotoFrame extends StatelessWidget { + final String? photoUrl; + + static const double idPhotoAspectRatio = 35 / 45; + + const AdminAmPhotoFrame({super.key, this.photoUrl}); + + /// Largeur colonne photo pour remplir [height] (cadre inclus). + static double columnWidthForHeight(double height) { + const frame = 16.0; + final innerH = (height - frame).clamp(0.0, double.infinity); + return innerH * idPhotoAspectRatio + frame; + } + + @override + Widget build(BuildContext context) { + final fullUrl = ApiConfig.absoluteMediaUrl(photoUrl); + return LayoutBuilder( + builder: (context, c) { + const uniformFrame = 8.0; + final maxPhotoW = + (c.maxWidth - 2 * uniformFrame).clamp(0.0, double.infinity); + final maxPhotoH = + (c.maxHeight - 2 * uniformFrame).clamp(0.0, double.infinity); + const ar = idPhotoAspectRatio; + + double ph = maxPhotoH; + double pw = ph * ar; + if (pw > maxPhotoW) { + pw = maxPhotoW; + ph = pw / ar; + } + + // Cadre gris = taille photo + padding uniforme ; centré dans la colonne + // (évite le vide blanc en bas quand le conteneur parent est plus haut). + return Align( + alignment: Alignment.topCenter, + child: Container( + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(uniformFrame), + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SizedBox( + width: pw, + height: ph, + child: _photoContent(fullUrl, pw, ph), + ), + ), + ), + ), + ); + }, + ); + } + + Widget _photoContent(String fullUrl, double pw, double ph) { + if (fullUrl.isEmpty) { + return ColoredBox( + color: Colors.grey.shade200, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.person_off_outlined, size: 36, color: Colors.grey.shade400), + const SizedBox(height: 6), + Text( + 'Aucune photo', + style: TextStyle(color: Colors.grey.shade600, fontSize: 11), + ), + ], + ), + ); + } + return AuthNetworkImage( + url: fullUrl, + width: pw, + height: ph, + fit: BoxFit.cover, + alignment: Alignment.topCenter, + loadingBuilder: (_, child, progress) { + if (progress == null) return child; + return ColoredBox( + color: Colors.grey.shade200, + child: Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + value: progress.expectedTotalBytes != null + ? progress.cumulativeBytesLoaded / + (progress.expectedTotalBytes!) + : null, + ), + ), + ), + ); + }, + errorBuilder: (_, __, ___) => ColoredBox( + color: Colors.grey.shade200, + child: Icon(Icons.broken_image_outlined, size: 36, color: Colors.grey.shade400), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart new file mode 100644 index 0000000..30941ed --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -0,0 +1,411 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/enfant_admin_model.dart'; +import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; + +/// Fiche enfant consultation / édition (ticket #138). +class AdminChildDetailModal extends StatefulWidget { + final EnfantAdminModel enfant; + final VoidCallback? onSaved; + + const AdminChildDetailModal({ + super.key, + required this.enfant, + this.onSaved, + }); + + @override + State createState() => _AdminChildDetailModalState(); +} + +class _AdminChildDetailModalState extends State { + late final TextEditingController _prenomCtrl; + late final TextEditingController _nomCtrl; + late final TextEditingController _birthCtrl; + late final TextEditingController _dueCtrl; + late String _status; + late String _gender; + late bool _consentPhoto; + late bool _isMultiple; + bool _dirty = false; + bool _saving = false; + + static const _genders = ['H', 'F', 'Autre']; + + static const _labelStyle = TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.black87, + ); + + @override + void initState() { + super.initState(); + final e = widget.enfant; + _prenomCtrl = TextEditingController(text: e.firstName ?? ''); + _nomCtrl = TextEditingController(text: e.lastName ?? ''); + _birthCtrl = TextEditingController(text: e.birthDate ?? ''); + _dueCtrl = TextEditingController(text: e.dueDate ?? ''); + _status = normalizeEnfantStatus(e.status); + if (!enfantStatusValues.contains(_status)) { + _status = 'sans_garde'; + } + _gender = _normalizeGender(e.gender); + _consentPhoto = e.consentPhoto; + _isMultiple = e.isMultiple; + for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) { + c.addListener(_markDirty); + } + } + + @override + void dispose() { + for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) { + c.dispose(); + } + super.dispose(); + } + + static String _normalizeGender(String? raw) { + final g = (raw ?? '').trim(); + if (g == 'M') return 'H'; + if (_genders.contains(g)) return g; + return 'H'; + } + + void _markDirty() { + if (!_dirty) setState(() => _dirty = true); + } + + Future _save() async { + if (!_dirty) return; + setState(() => _saving = true); + try { + await UserService.updateEnfant( + enfantId: widget.enfant.id, + body: { + 'first_name': _prenomCtrl.text.trim(), + 'last_name': _nomCtrl.text.trim(), + 'status': _status, + 'gender': _gender, + if (_birthCtrl.text.trim().isNotEmpty) + 'birth_date': _birthCtrl.text.trim(), + if (_dueCtrl.text.trim().isNotEmpty) 'due_date': _dueCtrl.text.trim(), + 'consent_photo': _consentPhoto, + 'is_multiple': _isMultiple, + }, + ); + if (!mounted) return; + setState(() { + _dirty = false; + _saving = false; + }); + widget.onSaved?.call(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Fiche enfant enregistrée')), + ); + } catch (e) { + if (!mounted) return; + setState(() => _saving = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + + String _parentsLine() { + final names = widget.enfant.parentLinks + .map((l) { + final n = (l.parentName ?? '').trim(); + return n.isNotEmpty ? n : 'Parent rattaché'; + }) + .where((s) => s.isNotEmpty) + .toList(); + if (names.isEmpty) return ''; + return names.join(', '); + } + + @override + Widget build(BuildContext context) { + final parents = _parentsLine(); + final showDueDate = _status == 'a_naitre'; + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: SizedBox( + width: 480, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 640), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.enfant.fullName, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + if (parents.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + 'Responsables : $parents', + style: const TextStyle( + fontSize: 13, + color: Colors.black54, + ), + ), + ], + ], + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + tooltip: 'Fermer', + ), + ], + ), + const SizedBox(height: 12), + const Divider(height: 1), + const SizedBox(height: 12), + Flexible( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _labeledField( + 'Prénom', + TextField( + controller: _prenomCtrl, + decoration: _decoration(), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _labeledField( + 'Nom', + TextField( + controller: _nomCtrl, + decoration: _decoration(), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _labeledDropdown( + 'Statut', + _status, + enfantStatusValues + .map( + (s) => MapEntry( + s, + enfantStatusLabel(s, gender: _gender), + ), + ) + .toList(), + (v) => setState(() { + _status = v; + _dirty = true; + }), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _labeledDropdown( + 'Genre', + _gender, + _genders + .map((g) => MapEntry(g, _genderLabel(g))) + .toList(), + (v) => setState(() { + _gender = v; + _dirty = true; + }), + ), + ), + ], + ), + const SizedBox(height: 12), + if (showDueDate) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _labeledField( + 'Date de naissance', + TextField( + controller: _birthCtrl, + decoration: + _decoration(hint: 'AAAA-MM-JJ'), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _labeledField( + 'Date prévue', + TextField( + controller: _dueCtrl, + decoration: + _decoration(hint: 'AAAA-MM-JJ'), + ), + ), + ), + ], + ) + else + _labeledField( + 'Date de naissance', + TextField( + controller: _birthCtrl, + decoration: _decoration(hint: 'AAAA-MM-JJ'), + ), + ), + const SizedBox(height: 16), + _switchRow( + 'Consentement photo', + _consentPhoto, + (v) => setState(() { + _consentPhoto = v; + _dirty = true; + }), + ), + _switchRow( + 'Naissance multiple', + _isMultiple, + (v) => setState(() { + _isMultiple = v; + _dirty = true; + }), + ), + ], + ), + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Fermer'), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: !_dirty || _saving ? null : _save, + icon: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save), + label: const Text('Sauvegarder'), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + + InputDecoration _decoration({String? hint}) { + return InputDecoration( + isDense: true, + border: const OutlineInputBorder(), + hintText: hint, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ); + } + + Widget _labeledField(String label, Widget field) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(label, style: _labelStyle), + const SizedBox(height: 4), + field, + ], + ); + } + + Widget _labeledDropdown( + String label, + String value, + List> items, + ValueChanged onChanged, + ) { + final safeValue = + items.any((e) => e.key == value) ? value : items.first.key; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(label, style: _labelStyle), + const SizedBox(height: 4), + DropdownButtonFormField( + value: safeValue, + isExpanded: true, + decoration: _decoration(), + items: items + .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) + .toList(), + onChanged: (v) { + if (v != null) onChanged(v); + }, + ), + ], + ); + } + + Widget _switchRow(String label, bool value, ValueChanged onChanged) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Expanded(child: Text(label, style: _labelStyle)), + Switch( + value: value, + onChanged: onChanged, + ), + ], + ), + ); + } + + String _genderLabel(String gender) { + switch (gender) { + case 'H': + return 'Garçon'; + case 'F': + return 'Fille'; + case 'Autre': + return 'Autre'; + default: + return gender; + } + } +} diff --git a/frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart b/frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart new file mode 100644 index 0000000..e984e66 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/parent_child_summary.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart'; + +/// Liste scrollable d'enfants rattachés (fiche parent / fiche AM). +class AdminChildrenAffiliationPanel extends StatelessWidget { + final List children; + final ScrollController scrollController; + final void Function(ParentChildSummary child) onOpen; + final void Function(ParentChildSummary child) onDetach; + final String emptyMessage; + final double? height; + + static const double _itemHeight = 58; + static const double defaultViewportHeight = _itemHeight * 2.5 + 8; + + const AdminChildrenAffiliationPanel({ + super.key, + required this.children, + required this.scrollController, + required this.onOpen, + required this.onDetach, + this.emptyMessage = 'Aucun enfant rattaché', + this.height, + }); + + @override + Widget build(BuildContext context) { + if (height != null) { + return _panel(height!); + } + return LayoutBuilder( + builder: (context, constraints) { + final h = constraints.maxHeight.isFinite && constraints.maxHeight > 0 + ? constraints.maxHeight + : defaultViewportHeight; + return _panel(h); + }, + ); + } + + Widget _panel(double panelHeight) { + return Container( + height: panelHeight, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + clipBehavior: Clip.antiAlias, + child: children.isEmpty + ? Center( + child: Text( + emptyMessage, + style: const TextStyle(fontSize: 14, color: Colors.black54), + ), + ) + : ListView.builder( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), + itemExtent: _itemHeight, + itemCount: children.length, + itemBuilder: (_, i) { + final c = children[i]; + return AdminEnfantUserCard.fromSummary( + c, + actions: [ + IconButton( + icon: const Icon(Icons.visibility_outlined), + tooltip: 'Voir / modifier', + onPressed: () => onOpen(c), + ), + IconButton( + tooltip: 'Détacher', + icon: Icon(Icons.link_off, color: Colors.orange.shade800), + onPressed: () => onDetach(c), + ), + ], + ); + }, + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart b/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart new file mode 100644 index 0000000..a6349b8 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/enfant_admin_model.dart'; +import 'package:p_tits_pas/models/parent_child_summary.dart'; +import 'package:p_tits_pas/utils/date_display_utils.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; + +List enfantAdminSubtitleLines({ + required String status, + String? birthDate, + String? dueDate, + String? gender, + List extra = const [], +}) { + final lines = []; + final age = formatChildAgeLabel( + birthDate: birthDate, + dueDate: dueDate, + status: normalizeEnfantStatus(status), + ); + if (age.isNotEmpty) lines.add(age); + final normalized = normalizeEnfantStatus(status); + if (normalized.isNotEmpty) { + lines.add('Statut : ${enfantStatusLabel(normalized, gender: gender)}'); + } + lines.addAll(extra); + return lines; +} + +/// Carte enfant admin (photo, nom, âge) — même rendu onglet Enfants / fiche parent. +class AdminEnfantUserCard extends StatelessWidget { + final String title; + final String? photoUrl; + final List subtitleLines; + final List actions; + + const AdminEnfantUserCard({ + super.key, + required this.title, + this.photoUrl, + required this.subtitleLines, + this.actions = const [], + }); + + factory AdminEnfantUserCard.fromEnfant( + EnfantAdminModel enfant, { + List extraSubtitleLines = const [], + List actions = const [], + }) { + final parents = enfant.parentLinks + .map((l) => l.parentName ?? 'Parent') + .join(', '); + return AdminEnfantUserCard( + title: enfant.fullName, + photoUrl: enfant.photoUrl, + subtitleLines: enfantAdminSubtitleLines( + status: enfant.status, + birthDate: enfant.birthDate, + dueDate: enfant.dueDate, + gender: enfant.gender, + extra: [ + if (parents.isNotEmpty) 'Responsables : $parents', + ...extraSubtitleLines, + ], + ), + actions: actions, + ); + } + + factory AdminEnfantUserCard.fromSummary( + ParentChildSummary child, { + List extraSubtitleLines = const [], + List actions = const [], + }) { + return AdminEnfantUserCard( + title: child.fullName, + photoUrl: child.photoUrl, + subtitleLines: enfantAdminSubtitleLines( + status: child.status, + birthDate: child.birthDate, + dueDate: child.dueDate, + extra: extraSubtitleLines, + ), + actions: actions, + ); + } + + @override + Widget build(BuildContext context) { + return AdminUserCard( + title: title, + fallbackIcon: Icons.child_care_outlined, + avatarUrl: photoUrl, + subtitleLines: subtitleLines, + actions: actions, + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart new file mode 100644 index 0000000..b1fe436 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart @@ -0,0 +1,462 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/enfant_admin_model.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/models/parent_child_summary.dart'; +import 'package:p_tits_pas/models/parent_model.dart'; +import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart'; +import 'package:p_tits_pas/widgets/common/identity_block.dart'; +import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; + +/// Fiche parent éditable (doc 28 §6.1, tickets #131 / #138). +/// Shell et typo alignés sur [ValidationDossierModal] / wizards validation. +class AdminParentEditModal extends StatefulWidget { + final ParentModel parent; + final VoidCallback? onSaved; + + const AdminParentEditModal({ + super.key, + required this.parent, + this.onSaved, + }); + + @override + State createState() => _AdminParentEditModalState(); +} + +class _AdminParentEditModalState extends State { + late final TextEditingController _nomCtrl; + late final TextEditingController _prenomCtrl; + late final TextEditingController _emailCtrl; + late final TextEditingController _telCtrl; + late final TextEditingController _adresseCtrl; + late final TextEditingController _villeCtrl; + late final TextEditingController _cpCtrl; + + late String _statut; + late List _children; + AppUser? _coParent; + late final ScrollController _childrenScrollCtrl; + bool _saving = false; + bool _dirty = false; + + static const double _modalWidth = 930; + + @override + void initState() { + super.initState(); + final u = widget.parent.user; + _nomCtrl = TextEditingController(text: u.nom ?? ''); + _prenomCtrl = TextEditingController(text: u.prenom ?? ''); + _emailCtrl = TextEditingController(text: u.email); + _telCtrl = TextEditingController( + text: formatPhoneForDisplay(u.telephone ?? ''), + ); + _adresseCtrl = TextEditingController(text: u.adresse ?? ''); + _villeCtrl = TextEditingController(text: u.ville ?? ''); + _cpCtrl = TextEditingController(text: u.codePostal ?? ''); + _statut = u.statut ?? 'en_attente'; + _coParent = widget.parent.coParent; + _children = List.of(widget.parent.children); + _childrenScrollCtrl = ScrollController(); + for (final c in [ + _nomCtrl, + _prenomCtrl, + _emailCtrl, + _telCtrl, + _adresseCtrl, + _villeCtrl, + _cpCtrl, + ]) { + c.addListener(_markDirty); + } + _nomCtrl.addListener(_onNameFieldChanged); + _prenomCtrl.addListener(_onNameFieldChanged); + _reloadChildren(); + } + + void _onNameFieldChanged() { + setState(() {}); + } + + @override + void dispose() { + for (final c in [ + _nomCtrl, + _prenomCtrl, + _emailCtrl, + _telCtrl, + _adresseCtrl, + _villeCtrl, + _cpCtrl, + ]) { + c.dispose(); + } + _childrenScrollCtrl.dispose(); + super.dispose(); + } + + void _markDirty() { + if (!_dirty) setState(() => _dirty = true); + } + + String _headerTitle() { + final fn = _prenomCtrl.text.trim(); + final ln = _nomCtrl.text.trim(); + if (fn.isEmpty && ln.isEmpty) { + final fallback = widget.parent.user.fullName.trim(); + return fallback.isNotEmpty ? fallback : 'Parent'; + } + return '$fn $ln'.trim(); + } + + String? _coParentSubtitle() { + final name = _coParent?.fullName.trim() ?? ''; + if (name.isEmpty) return null; + return 'Co-parent : $name'; + } + + Future _save() async { + if (!_dirty) return; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Confirmer'), + content: const Text( + 'Enregistrer les modifications de la fiche parent ?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Sauvegarder'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + setState(() => _saving = true); + try { + await UserService.updateParentFiche( + parentUserId: widget.parent.user.id, + body: { + 'nom': _nomCtrl.text.trim(), + 'prenom': _prenomCtrl.text.trim(), + 'email': _emailCtrl.text.trim(), + 'telephone': normalizePhone(_telCtrl.text), + 'adresse': _adresseCtrl.text.trim(), + 'ville': _villeCtrl.text.trim(), + 'code_postal': _cpCtrl.text.trim(), + 'statut': _statut, + }, + ); + if (!mounted) return; + setState(() { + _dirty = false; + _saving = false; + }); + widget.onSaved?.call(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Fiche parent enregistrée')), + ); + } catch (e) { + if (!mounted) return; + setState(() => _saving = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + + Future _openChild(ParentChildSummary child) async { + try { + final enfant = await UserService.getEnfant(child.id); + if (!mounted) return; + await showDialog( + context: context, + builder: (ctx) => AdminChildDetailModal( + enfant: enfant, + onSaved: _reloadChildren, + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + + Future _reloadChildren() async { + try { + final refreshed = await UserService.getParent(widget.parent.user.id); + final all = await UserService.getEnfants(); + final byId = {for (final e in all) e.id: e}; + + final kids = refreshed.children.map((c) { + final full = byId[c.id]; + if (full != null) return ParentChildSummary.fromEnfant(full); + return c; + }).toList(); + + if (!mounted) return; + setState(() { + _children = kids; + _coParent = refreshed.coParent; + }); + } catch (_) {} + } + + Future _detachChild(ParentChildSummary child) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Détacher l\'enfant'), + content: Text( + 'Retirer ${child.fullName} de la fiche de ce parent ?\n' + '(L\'enfant ne sera pas supprimé.)', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ValidationModalTheme.primaryElevatedStyle, + child: const Text('Détacher'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + try { + await UserService.detachEnfantFromParent( + parentUserId: widget.parent.user.id, + enfantId: child.id, + ); + if (!mounted) return; + await _reloadChildren(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Enfant détaché')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + + Future _attachChild() async { + List all; + try { + all = await UserService.getEnfants(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + return; + } + + final linkedIds = _children.map((c) => c.id).toSet(); + final candidates = all.where((e) => !linkedIds.contains(e.id)).toList(); + if (candidates.isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Aucun enfant disponible à rattacher')), + ); + return; + } + + if (!mounted) return; + final selected = await showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: const Text('Rattacher un enfant'), + children: candidates + .map( + (e) => SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, e), + child: Text(e.fullName), + ), + ) + .toList(), + ), + ); + if (selected == null || !mounted) return; + + try { + await UserService.attachEnfantToParent( + parentUserId: widget.parent.user.id, + enfantId: selected.id, + ); + if (!mounted) return; + await _reloadChildren(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Enfant rattaché')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + + Widget _childrenPanel() { + return AdminChildrenAffiliationPanel( + children: _children, + scrollController: _childrenScrollCtrl, + onOpen: _openChild, + onDetach: _detachChild, + ); + } + + Widget _identityFields() { + return IdentityBlock.editable( + nomController: _nomCtrl, + prenomController: _prenomCtrl, + telephoneController: _telCtrl, + emailController: _emailCtrl, + adresseController: _adresseCtrl, + codePostalController: _cpCtrl, + villeController: _villeCtrl, + ); + } + + Widget _buildFooter() { + return Row( + children: [ + TextButton( + onPressed: _saving ? null : () => Navigator.of(context).pop(), + child: const Text('Fermer'), + ), + const Spacer(), + TextButton.icon( + onPressed: _attachChild, + icon: const Icon(Icons.link, size: 18), + label: const Text('Rattacher un enfant'), + ), + const SizedBox(width: 12), + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: !_dirty || _saving ? null : _save, + child: _saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text(_dirty ? 'Sauvegarder' : 'Aucune modification'), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _modalWidth), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 4, 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _headerTitle(), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + if (_coParentSubtitle() != null) ...[ + const SizedBox(height: 4), + Text( + _coParentSubtitle()!, + style: const TextStyle( + fontSize: 13, + color: Colors.black54, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 160, + child: AdminStatusCapsule( + statut: _statut, + onChanged: (v) => setState(() { + _statut = v; + _dirty = true; + }), + ), + ), + IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 40, + minHeight: 40, + ), + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + tooltip: 'Fermer', + ), + ], + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _identityFields(), + const SizedBox(height: 10), + Text( + 'Nombre d\'enfants : ${_children.length}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const SizedBox(height: 6), + _childrenPanel(), + const SizedBox(height: 12), + _buildFooter(), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_status_capsule.dart b/frontend/lib/widgets/admin/common/admin_status_capsule.dart new file mode 100644 index 0000000..a42b015 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_status_capsule.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +/// Gélule de sélection du statut utilisateur (fiches admin parent / AM). +class AdminStatusCapsule extends StatelessWidget { + final String statut; + final ValueChanged? onChanged; + + static const statuts = ['actif', 'en_attente', 'suspendu', 'refuse']; + + const AdminStatusCapsule({ + super.key, + required this.statut, + this.onChanged, + }); + + static String displayStatus(String status) { + switch (status) { + case 'actif': + return 'Actif'; + case 'en_attente': + return 'En attente'; + case 'suspendu': + return 'Suspendu'; + case 'refuse': + return 'Refusé'; + default: + return status; + } + } + + @override + Widget build(BuildContext context) { + final value = statuts.contains(statut) ? statut : statuts.first; + return Container( + height: 34, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: Colors.black26), + ), + padding: const EdgeInsets.symmetric(horizontal: 12), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + isExpanded: true, + isDense: true, + style: const TextStyle(fontSize: 13, color: Colors.black87), + icon: const Icon(Icons.arrow_drop_down, size: 20), + items: statuts + .map( + (s) => DropdownMenuItem( + value: s, + child: Text(displayStatus(s)), + ), + ) + .toList(), + onChanged: onChanged == null + ? null + : (v) { + if (v != null) onChanged!(v); + }, + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_user_card.dart b/frontend/lib/widgets/admin/common/admin_user_card.dart index 92d4237..7caafe1 100644 --- a/frontend/lib/widgets/admin/common/admin_user_card.dart +++ b/frontend/lib/widgets/admin/common/admin_user_card.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; class AdminUserCard extends StatefulWidget { final String title; @@ -10,6 +12,7 @@ class AdminUserCard extends StatefulWidget { final Color? backgroundColor; final Color? titleColor; final Color? infoColor; + final String? vigilanceTooltip; const AdminUserCard({ super.key, @@ -22,6 +25,7 @@ class AdminUserCard extends StatefulWidget { this.backgroundColor, this.titleColor, this.infoColor, + this.vigilanceTooltip, }); @override @@ -37,6 +41,7 @@ class _AdminUserCardState extends State { widget.subtitleLines.where((e) => e.trim().isNotEmpty).join(' '); final actionsWidth = widget.actions.isNotEmpty ? widget.actions.length * 30.0 : 0.0; + final avatarUrl = ApiConfig.absoluteMediaUrl(widget.avatarUrl); return MouseRegion( onEnter: (_) => setState(() => _isHovered = true), @@ -60,21 +65,19 @@ class _AdminUserCardState extends State { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), child: Row( children: [ - CircleAvatar( - radius: 14, - backgroundColor: const Color(0xFFEDE5FA), - backgroundImage: widget.avatarUrl != null - ? NetworkImage(widget.avatarUrl!) - : null, - child: widget.avatarUrl == null - ? Icon( - widget.fallbackIcon, - size: 16, - color: const Color(0xFF6B3FA0), - ) - : null, - ), + _buildAvatar(avatarUrl), const SizedBox(width: 10), + if (widget.vigilanceTooltip != null) ...[ + Tooltip( + message: widget.vigilanceTooltip!, + child: Icon( + Icons.error_outline, + size: 20, + color: Colors.orange.shade800, + ), + ), + const SizedBox(width: 6), + ], Expanded( child: Row( children: [ @@ -140,4 +143,35 @@ class _AdminUserCardState extends State { ), ); } + + Widget _buildAvatar(String url) { + const size = 28.0; + const bg = Color(0xFFEDE5FA); + const iconColor = Color(0xFF6B3FA0); + + if (url.isEmpty) { + return CircleAvatar( + radius: 14, + backgroundColor: bg, + child: Icon(widget.fallbackIcon, size: 16, color: iconColor), + ); + } + + return ClipOval( + child: SizedBox( + width: size, + height: size, + child: AuthNetworkImage( + url: url, + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => ColoredBox( + color: bg, + child: Icon(widget.fallbackIcon, size: 16, color: iconColor), + ), + ), + ), + ); + } } diff --git a/frontend/lib/widgets/admin/common/validation_detail_section.dart b/frontend/lib/widgets/admin/common/validation_detail_section.dart index a0f36e2..610dd98 100644 --- a/frontend/lib/widgets/admin/common/validation_detail_section.dart +++ b/frontend/lib/widgets/admin/common/validation_detail_section.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'admin_detail_modal.dart'; /// Bloc type formulaire (titre de section + champs read-only) pour les modales de validation. @@ -23,6 +24,41 @@ class ValidationDetailSection extends StatelessWidget { this.rowFlex, }); + @override + Widget build(BuildContext context) { + return ValidationFormGrid( + title: title, + rowLayout: rowLayout, + rowFlex: rowFlex, + fields: fields + .map( + (f) => ValidationLabeledField( + label: f.label, + field: ValidationReadOnlyField(value: f.value), + ), + ) + .toList(), + ); + } +} + +/// Grille label/champ réutilisable (validation, fiches admin). +class ValidationFormGrid extends StatelessWidget { + final String? title; + final List fields; + final List? rowLayout; + final Map>? rowFlex; + final bool compact; + + const ValidationFormGrid({ + super.key, + this.title, + required this.fields, + this.rowLayout, + this.rowFlex, + this.compact = false, + }); + @override Widget build(BuildContext context) { final layout = rowLayout ?? List.filled(fields.length, 1); @@ -38,22 +74,22 @@ class ValidationDetailSection extends StatelessWidget { rowIndex++; if (count == 1) { rows.add(Padding( - padding: const EdgeInsets.only(bottom: 12), - child: _buildFieldCell(rowFields.first), + padding: EdgeInsets.only(bottom: compact ? 8 : 12), + child: rowFields.first, )); } else { rows.add(Padding( - padding: const EdgeInsets.only(bottom: 12), + padding: EdgeInsets.only(bottom: compact ? 8 : 12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (int i = 0; i < rowFields.length; i++) ...[ - if (i > 0) const SizedBox(width: 16), + if (i > 0) SizedBox(width: compact ? 12 : 16), Expanded( flex: (flexForRow != null && i < flexForRow.length) ? flexForRow[i] : 1, - child: _buildFieldCell(rowFields[i]), + child: rowFields[i], ), ], ], @@ -69,26 +105,96 @@ class ValidationDetailSection extends StatelessWidget { if (showTitle) ...[ Text( title!.trim(), - style: const TextStyle( - fontSize: 16, + style: TextStyle( + fontSize: compact ? 15 : 16, fontWeight: FontWeight.w600, color: Colors.black87, ), ), - const SizedBox(height: 12), + SizedBox(height: compact ? 8 : 12), ], ...rows, ], ); } +} - Widget _buildFieldCell(AdminDetailField field) { +/// Décoration commune lecture seule / édition (modales validation, fiches admin). +class ValidationFieldDecoration { + ValidationFieldDecoration._(); + + static InputDecoration input({String? hint, bool compact = false}) { + return InputDecoration( + isDense: true, + filled: true, + fillColor: Colors.grey.shade50, + hintText: hint, + contentPadding: EdgeInsets.symmetric( + horizontal: compact ? 10 : 12, + vertical: compact ? 7 : 10, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: Colors.grey.shade500), + ), + ); + } + + static InputDecoration readOnly({bool error = false, bool compact = false}) { + final borderColor = error ? Colors.red.shade400 : Colors.grey.shade300; + final fillColor = error ? Colors.red.shade50 : Colors.grey.shade50; + return input(compact: compact).copyWith( + filled: true, + fillColor: fillColor, + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: borderColor), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: borderColor), + ), + ); + } + + static BoxDecoration container({bool error = false}) { + return BoxDecoration( + color: error ? Colors.red.shade50 : Colors.grey.shade50, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: error ? Colors.red.shade400 : Colors.grey.shade300, + ), + ); + } +} + +/// Libellé au-dessus d’un champ (même typo que [ValidationDetailSection]). +class ValidationLabeledField extends StatelessWidget { + final String label; + final Widget field; + + const ValidationLabeledField({ + super.key, + required this.label, + required this.field, + }); + + @override + Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( - field.label, + label, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, @@ -96,37 +202,203 @@ class ValidationDetailSection extends StatelessWidget { ), ), const SizedBox(height: 4), - ValidationReadOnlyField(value: field.value), + field, ], ); } } -/// Champ texte en lecture seule, style formulaire (fond gris léger, bordure). Réutilisable en éditable plus tard. -class ValidationReadOnlyField extends StatelessWidget { +/// Champ texte éditable, même rendu que [ValidationReadOnlyField]. +class ValidationEditableField extends StatelessWidget { + final TextEditingController controller; + final TextInputType keyboardType; + final List? inputFormatters; + final String? hintText; + final int maxLines; + final bool compact; + + const ValidationEditableField({ + super.key, + required this.controller, + this.keyboardType = TextInputType.text, + this.inputFormatters, + this.hintText, + this.maxLines = 1, + this.compact = false, + }); + + static const double _compactFieldHeight = 34; + + static BoxDecoration _compactDecoration({bool error = false}) { + return BoxDecoration( + color: error ? Colors.red.shade50 : Colors.grey.shade50, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: error ? Colors.red.shade400 : Colors.grey.shade300, + ), + ); + } + + static InputDecoration _compactInputDecoration({String? hint}) { + return InputDecoration( + isDense: true, + filled: false, + hintText: hint, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + ); + } + + @override + Widget build(BuildContext context) { + if (maxLines > 1) { + return TextField( + controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + maxLines: maxLines, + style: const TextStyle(color: Colors.black87, fontSize: 14), + decoration: ValidationFieldDecoration.input(hint: hintText), + ); + } + if (!compact) { + return TextField( + controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + maxLines: 1, + style: const TextStyle(color: Colors.black87, fontSize: 14), + decoration: ValidationFieldDecoration.input(hint: hintText), + ); + } + return SizedBox( + height: _compactFieldHeight, + child: DecoratedBox( + decoration: _compactDecoration(), + child: TextField( + controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + maxLines: 1, + textAlignVertical: TextAlignVertical.center, + style: const TextStyle( + color: Colors.black87, + fontSize: 13, + height: 1.0, + ), + decoration: _compactInputDecoration(hint: hintText), + ), + ), + ); + } +} + +/// Grille label/champ éditable (délègue à [ValidationFormGrid]). +class ValidationEditableSection extends StatelessWidget { + final List fields; + final List? rowLayout; + final Map>? rowFlex; + final bool compact; + + const ValidationEditableSection({ + super.key, + required this.fields, + this.rowLayout, + this.rowFlex, + this.compact = false, + }); + + @override + Widget build(BuildContext context) { + return ValidationFormGrid( + rowLayout: rowLayout, + rowFlex: rowFlex, + compact: compact, + fields: fields, + ); + } +} + +/// Champ texte en lecture seule, même coque [TextField] que [ValidationEditableField]. +class ValidationReadOnlyField extends StatefulWidget { final String value; final int? maxLines; + final bool compact; + final bool error; const ValidationReadOnlyField({ super.key, required this.value, this.maxLines = 1, + this.compact = false, + this.error = false, }); + @override + State createState() => _ValidationReadOnlyFieldState(); +} + +class _ValidationReadOnlyFieldState extends State { + late final TextEditingController _controller; + + static const double _compactFieldHeight = 34; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.value); + } + + @override + void didUpdateWidget(ValidationReadOnlyField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.value != widget.value) { + _controller.text = widget.value; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + if (!widget.compact && widget.maxLines == 1) { + return TextField( + controller: _controller, + readOnly: true, + enableInteractiveSelection: false, + style: TextStyle( + color: widget.error ? Colors.red.shade800 : Colors.black87, + fontSize: 14, + fontWeight: widget.error ? FontWeight.w600 : null, + ), + decoration: ValidationFieldDecoration.readOnly(error: widget.error), + ); + } + return Container( width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: Colors.grey.shade50, - borderRadius: BorderRadius.circular(6), - border: Border.all(color: Colors.grey.shade300), + height: widget.compact && widget.maxLines == 1 ? _compactFieldHeight : null, + alignment: widget.compact ? Alignment.centerLeft : null, + padding: EdgeInsets.symmetric( + horizontal: widget.compact ? 10 : 12, + vertical: widget.compact ? 7 : 10, ), + decoration: ValidationFieldDecoration.container(error: widget.error), child: Text( - value, - style: const TextStyle(color: Colors.black87, fontSize: 14), - maxLines: maxLines, + widget.value, + style: TextStyle( + color: widget.error ? Colors.red.shade800 : Colors.black87, + fontSize: widget.compact ? 13 : 14, + height: widget.compact ? 1.0 : null, + fontWeight: widget.error ? FontWeight.w600 : null, + ), + maxLines: widget.maxLines, overflow: TextOverflow.ellipsis, ), ); diff --git a/frontend/lib/widgets/admin/enfant_management_widget.dart b/frontend/lib/widgets/admin/enfant_management_widget.dart new file mode 100644 index 0000000..bc90aaf --- /dev/null +++ b/frontend/lib/widgets/admin/enfant_management_widget.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; +import 'package:p_tits_pas/models/enfant_admin_model.dart'; +import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; + +/// Onglet liste globale des enfants (doc 28 §6.2, ticket #137). +class EnfantManagementWidget extends StatefulWidget { + final String searchQuery; + final String? statusFilter; + + const EnfantManagementWidget({ + super.key, + required this.searchQuery, + this.statusFilter, + }); + + @override + State createState() => _EnfantManagementWidgetState(); +} + +class _EnfantManagementWidgetState extends State { + bool _isLoading = false; + String? _error; + List _enfants = []; + + @override + void initState() { + super.initState(); + _loadEnfants(); + } + + Future _loadEnfants() async { + setState(() { + _isLoading = true; + _error = null; + }); + try { + final list = await UserService.getEnfants(); + if (!mounted) return; + setState(() { + _enfants = list; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + Future _openEnfant(EnfantAdminModel enfant) async { + await showDialog( + context: context, + builder: (ctx) => AdminChildDetailModal( + enfant: enfant, + onSaved: _loadEnfants, + ), + ); + } + + @override + Widget build(BuildContext context) { + final query = widget.searchQuery.toLowerCase(); + final filtered = _enfants.where((e) { + final matchesName = e.fullName.toLowerCase().contains(query); + final matchesStatus = widget.statusFilter == null || + normalizeEnfantStatus(e.status) == + normalizeEnfantStatus(widget.statusFilter); + return matchesName && matchesStatus; + }).toList(); + + return UserList( + isLoading: _isLoading, + error: _error, + isEmpty: filtered.isEmpty, + emptyMessage: 'Aucun enfant trouvé.', + itemCount: filtered.length, + itemBuilder: (context, index) { + final enfant = filtered[index]; + return AdminEnfantUserCard.fromEnfant( + enfant, + actions: [ + IconButton( + icon: const Icon(Icons.visibility_outlined), + tooltip: 'Voir / modifier', + onPressed: () => _openEnfant(enfant), + ), + ], + ); + }, + ); + } +} diff --git a/frontend/lib/widgets/admin/parent_managmant_widget.dart b/frontend/lib/widgets/admin/parent_managmant_widget.dart index 6e7e32d..becbd6c 100644 --- a/frontend/lib/widgets/admin/parent_managmant_widget.dart +++ b/frontend/lib/widgets/admin/parent_managmant_widget.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/parent_model.dart'; -import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/services/user_service.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; @@ -80,7 +79,7 @@ class _ParentManagementWidgetState extends State { avatarUrl: parent.user.photoUrl, subtitleLines: [ parent.user.email, - 'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.childrenCount}', + 'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.children.isNotEmpty ? parent.children.length : parent.childrenCount}', ], actions: [ IconButton( @@ -114,45 +113,10 @@ class _ParentManagementWidgetState extends State { void _openParentDetails(ParentModel parent) { showDialog( context: context, - builder: (context) => AdminDetailModal( - title: parent.user.fullName.isEmpty ? 'Parent' : parent.user.fullName, - subtitle: parent.user.email, - fields: [ - AdminDetailField(label: 'ID', value: _v(parent.user.id)), - AdminDetailField( - label: 'Statut', - value: _displayStatus(parent.user.statut), - ), - AdminDetailField( - label: 'Telephone', - value: _v(parent.user.telephone) != '–' ? formatPhoneForDisplay(_v(parent.user.telephone)) : '–', - ), - AdminDetailField(label: 'Adresse', value: _v(parent.user.adresse)), - AdminDetailField(label: 'Ville', value: _v(parent.user.ville)), - AdminDetailField( - label: 'Code postal', - value: _v(parent.user.codePostal), - ), - AdminDetailField( - label: 'Nombre d\'enfants', - value: parent.childrenCount.toString(), - ), - ], - onEdit: () { - Navigator.of(context).pop(); - ScaffoldMessenger.of(this.context).showSnackBar( - const SnackBar(content: Text('Action Modifier a implementer')), - ); - }, - onDelete: () { - Navigator.of(context).pop(); - ScaffoldMessenger.of(this.context).showSnackBar( - const SnackBar(content: Text('Action Supprimer a implementer')), - ); - }, + builder: (context) => AdminParentEditModal( + parent: parent, + onSaved: _loadParents, ), ); } - - String _v(String? value) => (value == null || value.isEmpty) ? '-' : value; } diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index 83c12de..9de03bc 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -4,6 +4,7 @@ import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; +import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart'; import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart'; @@ -28,6 +29,7 @@ class _UserManagementPanelState extends State { final TextEditingController _searchController = TextEditingController(); final TextEditingController _amCapacityController = TextEditingController(); String? _parentStatus; + String? _enfantStatus; bool _hasPending = false; bool _pendingLoading = true; @@ -80,7 +82,7 @@ class _UserManagementPanelState extends State { } List get _tabLabels { - const base = ['Parents', 'Assistantes maternelles', 'Gestionnaires']; + const base = ['Parents', 'Enfants', 'Assistantes maternelles', 'Gestionnaires']; final withAdmin = [...base, 'Administrateurs']; final list = widget.showAdministrateursTab ? withAdmin : base; // Onglet « À valider » visible seulement s'il y a des dossiers en attente (ticket #107). @@ -94,11 +96,12 @@ class _UserManagementPanelState extends State { _subIndex = index.clamp(0, maxIndex); _searchController.clear(); _parentStatus = null; + _enfantStatus = null; _amCapacityController.clear(); }); } - /// Index du contenu : -1 = À valider (si visible), 0 = Parents, 1 = AM, 2 = Gestionnaires, 3 = Admin. + /// Index du contenu : -1 = À valider, 0 = Parents, 1 = Enfants, 2 = AM, 3 = Gestionnaires, 4 = Admin. int get _contentIndexOffset => (_hasPending && !_pendingLoading) ? 1 : 0; String _searchHintForTab() { @@ -109,10 +112,12 @@ class _UserManagementPanelState extends State { case 0: return 'Rechercher un parent...'; case 1: - return 'Rechercher une assistante...'; + return 'Rechercher un enfant...'; case 2: - return 'Rechercher un gestionnaire...'; + return 'Rechercher une assistante...'; case 3: + return 'Rechercher un gestionnaire...'; + case 4: return 'Rechercher un administrateur...'; default: return 'Rechercher...'; @@ -176,6 +181,61 @@ class _UserManagementPanelState extends State { } if (_subIndex == _contentIndexOffset + 1) { + return DropdownButtonHideUnderline( + child: DropdownButton( + value: _enfantStatus, + isExpanded: true, + hint: const Padding( + padding: EdgeInsets.only(left: 10), + child: Text('Statut', style: TextStyle(fontSize: 12)), + ), + items: const [ + DropdownMenuItem( + value: null, + child: Padding( + padding: EdgeInsets.only(left: 10), + child: Text('Tous', style: TextStyle(fontSize: 12)), + ), + ), + DropdownMenuItem( + value: 'a_naitre', + child: Padding( + padding: EdgeInsets.only(left: 10), + child: Text('À naître', style: TextStyle(fontSize: 12)), + ), + ), + DropdownMenuItem( + value: 'sans_garde', + child: Padding( + padding: EdgeInsets.only(left: 10), + child: Text('Sans garde', style: TextStyle(fontSize: 12)), + ), + ), + DropdownMenuItem( + value: 'garde', + child: Padding( + padding: EdgeInsets.only(left: 10), + child: Text('En garde', style: TextStyle(fontSize: 12)), + ), + ), + DropdownMenuItem( + value: 'scolarise', + child: Padding( + padding: EdgeInsets.only(left: 10), + child: Text('Scolarisé', style: TextStyle(fontSize: 12)), + ), + ), + ], + onChanged: (value) { + setState(() { + _enfantStatus = value; + }); + }, + ), + ); + } + + if (_subIndex == _contentIndexOffset + 2) { return TextField( controller: _amCapacityController, decoration: const InputDecoration( @@ -203,16 +263,21 @@ class _UserManagementPanelState extends State { statusFilter: _parentStatus, ); case 1: + return EnfantManagementWidget( + searchQuery: _searchController.text, + statusFilter: _enfantStatus, + ); + case 2: return AssistanteMaternelleManagementWidget( searchQuery: _searchController.text, capacityMin: int.tryParse(_amCapacityController.text), ); - case 2: + case 3: return GestionnaireManagementWidget( key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'), searchQuery: _searchController.text, ); - case 3: + case 4: return AdminManagementWidget( key: ValueKey('admins-$_adminRefreshTick'), searchQuery: _searchController.text, @@ -246,7 +311,7 @@ class _UserManagementPanelState extends State { Future _handleAddPressed() async { final contentIndex = _subIndex - _contentIndexOffset; - if (contentIndex == 2) { + if (contentIndex == 3) { final created = await showDialog( context: context, barrierDismissible: false, @@ -264,7 +329,7 @@ class _UserManagementPanelState extends State { return; } - if (contentIndex == 3) { + if (contentIndex == 4) { final created = await showDialog( context: context, barrierDismissible: false, @@ -289,7 +354,7 @@ class _UserManagementPanelState extends State { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( - 'La création est disponible pour les gestionnaires et administrateurs.', + 'La création parent / enfant / AM sera disponible avec le ticket #129.', ), ), ); diff --git a/frontend/lib/widgets/admin/validation_am_wizard.dart b/frontend/lib/widgets/admin/validation_am_wizard.dart index 21356b9..5ea035a 100644 --- a/frontend/lib/widgets/admin/validation_am_wizard.dart +++ b/frontend/lib/widgets/admin/validation_am_wizard.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart'; -import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/nir_utils.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/common/identity_block.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'validation_modal_theme.dart'; import 'validation_refus_form.dart'; @@ -60,21 +60,6 @@ class _ValidationAmWizardState extends State { void _emitStep() => widget.onStepChanged?.call(_step, _stepCount); - /// Même ordre et disposition que le formulaire de création de compte (Nom/Prénom, Tél/Email, Adresse, CP/Ville). - List _personalFields(AppUser u) => [ - AdminDetailField(label: 'Nom', value: _v(u.nom)), - AdminDetailField(label: 'Prénom', value: _v(u.prenom)), - AdminDetailField( - label: 'Téléphone', - value: _v(u.telephone) != '–' - ? formatPhoneForDisplay(_v(u.telephone)) - : '–'), - AdminDetailField(label: 'Email', value: _v(u.email)), - AdminDetailField(label: 'Adresse (N° et Rue)', value: _v(u.adresse)), - AdminDetailField(label: 'Code postal', value: _v(u.codePostal)), - AdminDetailField(label: 'Ville', value: _v(u.ville)), - ]; - /// Panneau photo + grille droite : NIR|naissance, ville|pays, agrément|date, capa|places. List _photoProFields(DossierAM d) { final u = d.user; @@ -110,10 +95,7 @@ class _ValidationAmWizardState extends State { ]; } - static const List _personalRowLayout = [2, 2, 1, 2]; - static const Map> _personalRowFlex = { - 3: [2, 5] - }; // Code postal étroit, Ville large + static const List _photoProRowLayout = [2, 2, 2, 2]; /// Proportion photo d’identité (35×45 mm). static const double _idPhotoAspectRatio = 35 / 45; @@ -266,11 +248,10 @@ class _ValidationAmWizardState extends State { return SingleChildScrollView( child: ConstrainedBox( constraints: BoxConstraints(minWidth: constraints.maxWidth), - child: ValidationDetailSection( + child: IdentityBlock.readOnlyFromUser( + u, title: 'Identité et coordonnées', - fields: _personalFields(u), - rowLayout: _personalRowLayout, - rowFlex: _personalRowFlex, + emptyLabel: '–', ), ), ); @@ -311,7 +292,7 @@ class _ValidationAmWizardState extends State { child: ValidationDetailSection( title: 'Dossier professionnel', fields: _photoProFields(d), - rowLayout: const [2, 2, 2, 2], + rowLayout: _photoProRowLayout, ), ), ); diff --git a/frontend/lib/widgets/admin/validation_family_wizard.dart b/frontend/lib/widgets/admin/validation_family_wizard.dart index 96297bc..76ac741 100644 --- a/frontend/lib/widgets/admin/validation_family_wizard.dart +++ b/frontend/lib/widgets/admin/validation_family_wizard.dart @@ -3,11 +3,11 @@ import 'package:flutter/gestures.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart'; -import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/api/api_config.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/common/identity_block.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'validation_modal_theme.dart'; import 'validation_refus_form.dart'; @@ -108,26 +108,6 @@ class _ValidationFamilyWizardState extends State { static String _formatBirthDate(String? s) => formatIsoDateFr(s, ifEmpty: 'Non défini'); - /// Même ordre et disposition que le formulaire de création (Nom/Prénom, Tél/Email, Adresse, CP/Ville). - List _parentFields(ParentDossier p) => [ - AdminDetailField(label: 'Nom', value: _v(p.nom)), - AdminDetailField(label: 'Prénom', value: _v(p.prenom)), - AdminDetailField( - label: 'Téléphone', - value: _v(p.telephone) != 'Non défini' - ? formatPhoneForDisplay(_v(p.telephone)) - : 'Non défini'), - AdminDetailField(label: 'Email', value: _v(p.email)), - AdminDetailField(label: 'Adresse (N° et Rue)', value: _v(p.adresse)), - AdminDetailField(label: 'Code postal', value: _v(p.codePostal)), - AdminDetailField(label: 'Ville', value: _v(p.ville)), - ]; - - static const List _parentRowLayout = [2, 2, 1, 2]; - static const Map> _parentRowFlex = { - 3: [2, 5] - }; // Code postal étroit, Ville large - static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url); @override @@ -153,11 +133,9 @@ class _ValidationFamilyWizardState extends State { final d = widget.dossier; switch (_step) { case 0: - return ValidationDetailSection( + return IdentityBlock.readOnlyFromParentDossier( + d.parents.first, title: 'Parent principal', - fields: _parentFields(d.parents.first), - rowLayout: _parentRowLayout, - rowFlex: _parentRowFlex, ); case 1: return _buildParent2Step(); @@ -178,11 +156,9 @@ class _ValidationFamilyWizardState extends State { style: TextStyle(color: Colors.black87)), ); } - return ValidationDetailSection( + return IdentityBlock.readOnlyFromParentDossier( + widget.dossier.parents[1], title: 'Deuxième parent', - fields: _parentFields(widget.dossier.parents[1]), - rowLayout: _parentRowLayout, - rowFlex: _parentRowFlex, ); } @@ -422,20 +398,9 @@ class _ValidationFamilyWizardState extends State { ); } - /// « Scolarisé » / « Scolarisée » selon le genre enfant (`F` / sinon masculin par défaut). - static String _scolariseAccordeAuGenre(String? gender) { - final g = (gender ?? '').trim().toUpperCase(); - if (g == 'F') return 'Scolarisée'; - return 'Scolarisé'; - } - - /// Statut dans la colonne 2/3 uniquement (pas de [ValidationReadOnlyField]) : scolarisé·e ou « À naître ». - /// `actif` : pas de ligne statut. + /// Statut dans la colonne 2/3 (scolarisé·e, à naître, sans garde, en garde). String? _enfantColumnStatusLabel(EnfantDossier e) { - final s = (e.status ?? '').trim().toLowerCase(); - if (s == 'a_naitre') return 'À naître'; - if (s == 'scolarise') return _scolariseAccordeAuGenre(e.gender); - return null; + return enfantColumnStatusLabel(status: e.status, gender: e.gender); } /// Nom ; date de naissance et genre sur une ligne (prénom au-dessus, pleine largeur). diff --git a/frontend/lib/widgets/common/auth_network_image.dart b/frontend/lib/widgets/common/auth_network_image.dart index 60ff0f2..10221ed 100644 --- a/frontend/lib/widgets/common/auth_network_image.dart +++ b/frontend/lib/widgets/common/auth_network_image.dart @@ -13,6 +13,7 @@ class AuthNetworkImage extends StatefulWidget { this.width, this.height, this.fit = BoxFit.cover, + this.alignment = Alignment.center, this.loadingBuilder, this.errorBuilder, }); @@ -21,6 +22,7 @@ class AuthNetworkImage extends StatefulWidget { final double? width; final double? height; final BoxFit fit; + final AlignmentGeometry alignment; final ImageLoadingBuilder? loadingBuilder; final ImageErrorWidgetBuilder? errorBuilder; @@ -75,6 +77,7 @@ class _AuthNetworkImageState extends State { width: widget.width, height: widget.height, fit: widget.fit, + alignment: widget.alignment, loadingBuilder: widget.loadingBuilder, errorBuilder: err, ); @@ -105,6 +108,7 @@ class _AuthNetworkImageState extends State { width: widget.width, height: widget.height, fit: widget.fit, + alignment: widget.alignment, headers: headers, loadingBuilder: widget.loadingBuilder, errorBuilder: err, diff --git a/frontend/lib/widgets/common/identity_block.dart b/frontend/lib/widgets/common/identity_block.dart new file mode 100644 index 0000000..91a1d30 --- /dev/null +++ b/frontend/lib/widgets/common/identity_block.dart @@ -0,0 +1,278 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/dossier_unifie.dart'; +import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; + +/// Valeurs affichées dans un [IdentityBlock] en lecture seule. +class IdentityValues { + final String nom; + final String prenom; + final String telephone; + final String email; + final String adresse; + final String codePostal; + final String ville; + + const IdentityValues({ + required this.nom, + required this.prenom, + required this.telephone, + required this.email, + required this.adresse, + required this.codePostal, + required this.ville, + }); + + static String _fmt(String? s, String empty) { + final t = (s ?? '').trim(); + return t.isEmpty ? empty : t; + } + + static String _fmtPhone(String? s, String empty) { + final tel = _fmt(s, empty); + return tel != empty ? formatPhoneForDisplay(tel) : tel; + } + + factory IdentityValues.fromUser( + AppUser user, { + String emptyLabel = 'Non défini', + }) { + return IdentityValues( + nom: _fmt(user.nom, emptyLabel), + prenom: _fmt(user.prenom, emptyLabel), + telephone: _fmtPhone(user.telephone, emptyLabel), + email: _fmt(user.email, emptyLabel), + adresse: _fmt(user.adresse, emptyLabel), + codePostal: _fmt(user.codePostal, emptyLabel), + ville: _fmt(user.ville, emptyLabel), + ); + } + + factory IdentityValues.fromParentDossier( + ParentDossier parent, { + String emptyLabel = 'Non défini', + }) { + return IdentityValues( + nom: _fmt(parent.nom, emptyLabel), + prenom: _fmt(parent.prenom, emptyLabel), + telephone: _fmtPhone(parent.telephone, emptyLabel), + email: _fmt(parent.email, emptyLabel), + adresse: _fmt(parent.adresse, emptyLabel), + codePostal: _fmt(parent.codePostal, emptyLabel), + ville: _fmt(parent.ville, emptyLabel), + ); + } +} + +/// Bloc identité : Nom/Prénom, Tél/Email, Adresse, CP/Ville. +/// Grille partagée (création de compte, validation AM/famille, fiches admin, etc.). +class IdentityBlock extends StatelessWidget { final String? title; + + final String? _nom; + final String? _prenom; + final String? _telephone; + final String? _email; + final String? _adresse; + final String? _codePostal; + final String? _ville; + + final TextEditingController? _nomCtrl; + final TextEditingController? _prenomCtrl; + final TextEditingController? _telCtrl; + final TextEditingController? _emailCtrl; + final TextEditingController? _adresseCtrl; + final TextEditingController? _cpCtrl; + final TextEditingController? _villeCtrl; + + static const List rowLayout = [2, 2, 1, 2]; + static const Map> rowFlex = {3: [2, 5]}; + + const IdentityBlock.readOnly({ + super.key, + this.title, + required String nom, + required String prenom, + required String telephone, + required String email, + required String adresse, + required String codePostal, + required String ville, + }) : _nom = nom, + _prenom = prenom, + _telephone = telephone, + _email = email, + _adresse = adresse, + _codePostal = codePostal, + _ville = ville, + _nomCtrl = null, + _prenomCtrl = null, + _telCtrl = null, + _emailCtrl = null, + _adresseCtrl = null, + _cpCtrl = null, + _villeCtrl = null; + + const IdentityBlock.editable({ + super.key, + this.title, + required TextEditingController nomController, + required TextEditingController prenomController, + required TextEditingController telephoneController, + required TextEditingController emailController, + required TextEditingController adresseController, + required TextEditingController codePostalController, + required TextEditingController villeController, + }) : _nom = null, + _prenom = null, + _telephone = null, + _email = null, + _adresse = null, + _codePostal = null, + _ville = null, + _nomCtrl = nomController, + _prenomCtrl = prenomController, + _telCtrl = telephoneController, + _emailCtrl = emailController, + _adresseCtrl = adresseController, + _cpCtrl = codePostalController, + _villeCtrl = villeController; + + /// Lecture seule à partir de [IdentityValues]. + factory IdentityBlock.readOnlyValues({ + Key? key, + String? title, + required IdentityValues values, + }) { + return IdentityBlock.readOnly( + key: key, + title: title, + nom: values.nom, + prenom: values.prenom, + telephone: values.telephone, + email: values.email, + adresse: values.adresse, + codePostal: values.codePostal, + ville: values.ville, + ); + } + + /// Lecture seule depuis un [AppUser] (validation AM, fiche AM, etc.). + factory IdentityBlock.readOnlyFromUser( + AppUser user, { + Key? key, + String? title, + String emptyLabel = 'Non défini', + }) { + return IdentityBlock.readOnlyValues( + key: key, + title: title, + values: IdentityValues.fromUser(user, emptyLabel: emptyLabel), + ); + } + + /// Lecture seule depuis un [ParentDossier] (validation famille). + factory IdentityBlock.readOnlyFromParentDossier( + ParentDossier parent, { + Key? key, + String? title, + String emptyLabel = 'Non défini', + }) { + return IdentityBlock.readOnlyValues( + key: key, + title: title, + values: IdentityValues.fromParentDossier( + parent, + emptyLabel: emptyLabel, + ), + ); + } + + bool get _isEditable => _nomCtrl != null; + @override + Widget build(BuildContext context) { + if (_isEditable) { + return ValidationFormGrid( + title: title, + rowLayout: rowLayout, + rowFlex: rowFlex, + fields: [ + ValidationLabeledField( + label: 'Nom', + field: ValidationEditableField(controller: _nomCtrl!), + ), + ValidationLabeledField( + label: 'Prénom', + field: ValidationEditableField(controller: _prenomCtrl!), + ), + ValidationLabeledField( + label: 'Téléphone', + field: ValidationEditableField( + controller: _telCtrl!, + keyboardType: TextInputType.phone, + inputFormatters: frenchPhoneInputFormatters, + ), + ), + ValidationLabeledField( + label: 'Email', + field: ValidationEditableField( + controller: _emailCtrl!, + keyboardType: TextInputType.emailAddress, + ), + ), + ValidationLabeledField( + label: 'Adresse (N° et Rue)', + field: ValidationEditableField(controller: _adresseCtrl!), + ), + ValidationLabeledField( + label: 'Code postal', + field: ValidationEditableField( + controller: _cpCtrl!, + keyboardType: TextInputType.number, + ), + ), + ValidationLabeledField( + label: 'Ville', + field: ValidationEditableField(controller: _villeCtrl!), + ), + ], + ); + } + + return ValidationFormGrid( + title: title, + rowLayout: rowLayout, + rowFlex: rowFlex, + fields: [ + ValidationLabeledField( + label: 'Nom', + field: ValidationReadOnlyField(value: _nom!), + ), + ValidationLabeledField( + label: 'Prénom', + field: ValidationReadOnlyField(value: _prenom!), + ), + ValidationLabeledField( + label: 'Téléphone', + field: ValidationReadOnlyField(value: _telephone!), + ), + ValidationLabeledField( + label: 'Email', + field: ValidationReadOnlyField(value: _email!), + ), + ValidationLabeledField( + label: 'Adresse (N° et Rue)', + field: ValidationReadOnlyField(value: _adresse!), + ), + ValidationLabeledField( + label: 'Code postal', + field: ValidationReadOnlyField(value: _codePostal!), + ), + ValidationLabeledField( + label: 'Ville', + field: ValidationReadOnlyField(value: _ville!), + ), + ], + ); + } +} From d6dac181d2d85f6d96068dc902b48d3d4823b432 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Sat, 11 Jul 2026 23:20:56 +0200 Subject: [PATCH 3/4] feat(#142): ouvrir la modale au clic sur les cartes du dashboard admin. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squash merge develop → master. Co-authored-by: Cursor --- frontend/lib/widgets/admin/admin_management_widget.dart | 1 + .../admin/assistante_maternelle_management_widget.dart | 1 + .../lib/widgets/admin/common/admin_enfant_user_card.dart | 7 +++++++ frontend/lib/widgets/admin/common/admin_user_card.dart | 7 ++++++- frontend/lib/widgets/admin/enfant_management_widget.dart | 1 + .../lib/widgets/admin/gestionnaire_management_widget.dart | 1 + frontend/lib/widgets/admin/parent_managmant_widget.dart | 1 + frontend/lib/widgets/admin/pending_validation_widget.dart | 3 ++- 8 files changed, 20 insertions(+), 2 deletions(-) diff --git a/frontend/lib/widgets/admin/admin_management_widget.dart b/frontend/lib/widgets/admin/admin_management_widget.dart index 01f74d9..d0a2bed 100644 --- a/frontend/lib/widgets/admin/admin_management_widget.dart +++ b/frontend/lib/widgets/admin/admin_management_widget.dart @@ -122,6 +122,7 @@ class _AdminManagementWidgetState extends State { fallbackIcon: isSuperAdmin ? Icons.verified_user_outlined : Icons.manage_accounts_outlined, + onCardTap: () => _openAdminEditDialog(user), subtitleLines: [ user.email, 'Téléphone : ${user.telephone?.trim().isNotEmpty == true ? formatPhoneForDisplay(user.telephone!) : 'Non renseigné'}', diff --git a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart index 5ee5ed1..dc28b11 100644 --- a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart +++ b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart @@ -83,6 +83,7 @@ class _AssistanteMaternelleManagementWidgetState avatarUrl: assistante.user.photoUrl, fallbackIcon: Icons.face, vigilanceTooltip: vigilance, + onCardTap: () => _openAssistanteDetails(assistante), subtitleLines: [ assistante.user.email, 'Zone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}', diff --git a/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart b/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart index a6349b8..69dfa65 100644 --- a/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart +++ b/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart @@ -33,6 +33,7 @@ class AdminEnfantUserCard extends StatelessWidget { final String? photoUrl; final List subtitleLines; final List actions; + final VoidCallback? onCardTap; const AdminEnfantUserCard({ super.key, @@ -40,12 +41,14 @@ class AdminEnfantUserCard extends StatelessWidget { this.photoUrl, required this.subtitleLines, this.actions = const [], + this.onCardTap, }); factory AdminEnfantUserCard.fromEnfant( EnfantAdminModel enfant, { List extraSubtitleLines = const [], List actions = const [], + VoidCallback? onCardTap, }) { final parents = enfant.parentLinks .map((l) => l.parentName ?? 'Parent') @@ -64,6 +67,7 @@ class AdminEnfantUserCard extends StatelessWidget { ], ), actions: actions, + onCardTap: onCardTap, ); } @@ -71,6 +75,7 @@ class AdminEnfantUserCard extends StatelessWidget { ParentChildSummary child, { List extraSubtitleLines = const [], List actions = const [], + VoidCallback? onCardTap, }) { return AdminEnfantUserCard( title: child.fullName, @@ -82,6 +87,7 @@ class AdminEnfantUserCard extends StatelessWidget { extra: extraSubtitleLines, ), actions: actions, + onCardTap: onCardTap, ); } @@ -93,6 +99,7 @@ class AdminEnfantUserCard extends StatelessWidget { avatarUrl: photoUrl, subtitleLines: subtitleLines, actions: actions, + onCardTap: onCardTap, ); } } diff --git a/frontend/lib/widgets/admin/common/admin_user_card.dart b/frontend/lib/widgets/admin/common/admin_user_card.dart index 7caafe1..354ae68 100644 --- a/frontend/lib/widgets/admin/common/admin_user_card.dart +++ b/frontend/lib/widgets/admin/common/admin_user_card.dart @@ -13,6 +13,7 @@ class AdminUserCard extends StatefulWidget { final Color? titleColor; final Color? infoColor; final String? vigilanceTooltip; + final VoidCallback? onCardTap; const AdminUserCard({ super.key, @@ -26,6 +27,7 @@ class AdminUserCard extends StatefulWidget { this.titleColor, this.infoColor, this.vigilanceTooltip, + this.onCardTap, }); @override @@ -46,11 +48,14 @@ class _AdminUserCardState extends State { return MouseRegion( onEnter: (_) => setState(() => _isHovered = true), onExit: (_) => setState(() => _isHovered = false), + cursor: widget.onCardTap != null + ? SystemMouseCursors.click + : MouseCursor.defer, child: Material( color: Colors.transparent, borderRadius: BorderRadius.circular(10), child: InkWell( - onTap: () {}, + onTap: widget.onCardTap, borderRadius: BorderRadius.circular(10), hoverColor: const Color(0x149CC5C0), child: Card( diff --git a/frontend/lib/widgets/admin/enfant_management_widget.dart b/frontend/lib/widgets/admin/enfant_management_widget.dart index bc90aaf..9aaf59b 100644 --- a/frontend/lib/widgets/admin/enfant_management_widget.dart +++ b/frontend/lib/widgets/admin/enfant_management_widget.dart @@ -84,6 +84,7 @@ class _EnfantManagementWidgetState extends State { final enfant = filtered[index]; return AdminEnfantUserCard.fromEnfant( enfant, + onCardTap: () => _openEnfant(enfant), actions: [ IconButton( icon: const Icon(Icons.visibility_outlined), diff --git a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart index 2692831..1ce89d6 100644 --- a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart +++ b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart @@ -88,6 +88,7 @@ class _GestionnaireManagementWidgetState title: user.fullName, fallbackIcon: Icons.assignment_ind_outlined, avatarUrl: user.photoUrl, + onCardTap: () => _openGestionnaireEditDialog(user), subtitleLines: [ user.email, 'Statut : ${user.statut ?? 'Inconnu'}', diff --git a/frontend/lib/widgets/admin/parent_managmant_widget.dart b/frontend/lib/widgets/admin/parent_managmant_widget.dart index becbd6c..dcc2a66 100644 --- a/frontend/lib/widgets/admin/parent_managmant_widget.dart +++ b/frontend/lib/widgets/admin/parent_managmant_widget.dart @@ -77,6 +77,7 @@ class _ParentManagementWidgetState extends State { title: parent.user.fullName, fallbackIcon: Icons.supervisor_account_outlined, avatarUrl: parent.user.photoUrl, + onCardTap: () => _openParentDetails(parent), subtitleLines: [ parent.user.email, 'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.children.isNotEmpty ? parent.children.length : parent.childrenCount}', diff --git a/frontend/lib/widgets/admin/pending_validation_widget.dart b/frontend/lib/widgets/admin/pending_validation_widget.dart index e1e68a9..361a54c 100644 --- a/frontend/lib/widgets/admin/pending_validation_widget.dart +++ b/frontend/lib/widgets/admin/pending_validation_widget.dart @@ -321,11 +321,12 @@ class _PendingValidationRowState extends State<_PendingValidationRow> { return MouseRegion( onEnter: (_) => setState(() => _isHovered = true), onExit: (_) => setState(() => _isHovered = false), + cursor: SystemMouseCursors.click, child: Material( color: Colors.transparent, borderRadius: BorderRadius.circular(10), child: InkWell( - onTap: () {}, + onTap: widget.onOpen, borderRadius: BorderRadius.circular(10), hoverColor: const Color(0x149CC5C0), child: Card( From 6788351070f59b2419d827ba4d20763e4bc28766 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Sun, 12 Jul 2026 12:10:06 +0200 Subject: [PATCH 4/4] =?UTF-8?q?fix(#143):=20masquer=20Supprimer=20quand=20?= =?UTF-8?q?un=20gestionnaire=20=C3=A9dite=20sa=20propre=20fiche?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- ...te-gitea-issue-gestionnaire-self-delete.js | 111 ++++++++++++++++++ .../creation/gestionnaires_create.dart | 28 ++++- 2 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 backend/scripts/create-gitea-issue-gestionnaire-self-delete.js diff --git a/backend/scripts/create-gitea-issue-gestionnaire-self-delete.js b/backend/scripts/create-gitea-issue-gestionnaire-self-delete.js new file mode 100644 index 0000000..0bc75cb --- /dev/null +++ b/backend/scripts/create-gitea-issue-gestionnaire-self-delete.js @@ -0,0 +1,111 @@ +/** + * Crée l'issue Gitea — gestionnaire ne doit pas pouvoir se supprimer. + * Usage: node backend/scripts/create-gitea-issue-gestionnaire-self-delete.js + */ +const https = require('https'); +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.join(__dirname, '../..'); +const MILESTONE_0_1_0 = 10; + +let token = process.env.GITEA_TOKEN; +if (!token) { + try { + const tokenFile = path.join(repoRoot, '.gitea-token'); + if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim(); + } catch (_) {} +} +if (!token) { + try { + const briefing = fs.readFileSync( + path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'), + 'utf8', + ); + const m = briefing.match(/Token:\s*(gitebu_[a-f0-9]+)/); + if (m) token = m[1].trim(); + } catch (_) {} +} +if (!token) { + console.error('Token non trouvé : .gitea-token ou GITEA_TOKEN'); + process.exit(1); +} + +const body = `## Contexte + +Quand un **gestionnaire** connecté ouvre sa propre fiche dans l'onglet **Gestionnaires** (Gestion des utilisateurs), la modale **Modifier un "Gestionnaire"** affiche le bouton **Supprimer**. + +Comportement actuel : le gestionnaire voit et peut tenter de supprimer son propre compte. + +## Comportement attendu + +Comme pour le **super administrateur** (bouton Supprimer masqué sur la fiche super admin) : + +- **Pas de bouton Supprimer** quand l'utilisateur édite **sa propre fiche** +- **Modification** des informations (prénom, nom, email, téléphone, relais, mot de passe) **toujours autorisée** + +## Périmètre + +- Frontend : \`AdminUserFormDialog\` (\`gestionnaires_create.dart\`) +- Comparer \`initialUser.id\` avec l'utilisateur connecté (\`AuthService.getCurrentUser\`) +- Masquer Supprimer si édition de soi-même ; conserver garde existante super admin + +## Critères d'acceptation + +- [ ] Gestionnaire connecté → ouvre sa fiche → **pas** de bouton Supprimer +- [ ] Gestionnaire connecté → peut **Modifier** ses informations +- [ ] Super admin / admin → peut toujours supprimer **un autre** gestionnaire (si droits API) +- [ ] Pas de régression sur fiche super administrateur (Supprimer toujours masqué) + +## Fichiers clés + +- \`frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart\` +- \`frontend/lib/widgets/admin/gestionnaire_management_widget.dart\` + +## Milestone + +**0.1.0** — correction UX / sécurité gestion utilisateurs.`; + +const payloadClean = JSON.stringify({ + title: '[Bug] Gestionnaire peut voir Supprimer sur sa propre fiche', + body, + milestone: MILESTONE_0_1_0, +}); + +const opts = { + hostname: 'git.ptits-pas.fr', + path: '/api/v1/repos/jmartin/petitspas/issues', + method: 'POST', + headers: { + Authorization: 'token ' + token, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payloadClean), + }, +}; + +const req = https.request(opts, (res) => { + let d = ''; + res.on('data', (c) => (d += c)); + res.on('end', () => { + try { + const o = JSON.parse(d); + if (o.number) { + console.log('NUMBER:', o.number); + console.log('URL:', o.html_url); + console.log('MILESTONE:', o.milestone?.title ?? '(aucun)'); + } else { + console.error('Réponse:', d); + process.exit(1); + } + } catch (e) { + console.error(d); + process.exit(1); + } + }); +}); +req.on('error', (e) => { + console.error(e); + process.exit(1); +}); +req.write(payloadClean); +req.end(); diff --git a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart index a1b5f92..4f89ab7 100644 --- a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart +++ b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart @@ -5,6 +5,7 @@ import 'package:p_tits_pas/utils/email_utils.dart'; import 'package:p_tits_pas/widgets/email_text_field.dart'; import 'package:p_tits_pas/widgets/french_phone_field.dart'; import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/relais_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; @@ -41,9 +42,15 @@ class _AdminUserFormDialogState extends State { bool _isLoadingRelais = true; List _relais = []; String? _selectedRelaisId; + String? _currentUserId; bool get _isEditMode => widget.initialUser != null; bool get _isSuperAdminTarget => (widget.initialUser?.role ?? '').toLowerCase() == 'super_admin'; + bool get _isSelfTarget => + _isEditMode && + _currentUserId != null && + widget.initialUser!.id == _currentUserId; + bool get _canDeleteTarget => !_isSuperAdminTarget && !_isSelfTarget; bool get _isLockedAdminIdentity => _isEditMode && widget.adminMode && _isSuperAdminTarget; String get _targetRoleKey { @@ -109,6 +116,23 @@ class _AdminUserFormDialogState extends State { } else { _isLoadingRelais = false; } + _loadCurrentUserId(); + } + + Future _loadCurrentUserId() async { + final cached = await AuthService.getCurrentUser(); + if (!mounted) return; + if (cached != null) { + setState(() { + _currentUserId = cached.id; + }); + return; + } + final refreshed = await AuthService.refreshCurrentUser(); + if (!mounted || refreshed == null) return; + setState(() { + _currentUserId = refreshed.id; + }); } @override @@ -335,7 +359,7 @@ class _AdminUserFormDialogState extends State { Future _delete() async { if (widget.readOnly) return; - if (_isSuperAdminTarget) return; + if (!_canDeleteTarget) return; if (!_isEditMode || _isSubmitting) return; final confirmed = await showDialog( @@ -462,7 +486,7 @@ class _AdminUserFormDialogState extends State { child: const Text('Fermer'), ), ] else if (_isEditMode) ...[ - if (!_isSuperAdminTarget) + if (_canDeleteTarget) OutlinedButton( onPressed: _isSubmitting ? null : _delete, style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),