diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index 423b9b4..bfbd28e 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -36,6 +36,7 @@ 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'; +import { StaffAddCoParentDto } from '../parents/dto/staff-add-co-parent.dto'; @Injectable() export class AuthService { @@ -718,6 +719,167 @@ export class AuthService { }); } + /** + * Ajoute un co-parent à un foyer existant (mono-parent) — ticket #135. + * Compte actif + mail création MDP + liens Parents bidirectionnels + enfants du foyer. + */ + async addCoParentStaff(pivotUserId: string, dto: StaffAddCoParentDto) { + const pivotParent = await this.parentsRepo.findOne({ + where: { user_id: pivotUserId }, + relations: ['user', 'co_parent', 'parentChildren'], + }); + if (!pivotParent?.user) { + throw new NotFoundException('Parent introuvable'); + } + + if (pivotParent.co_parent) { + throw new BadRequestException('Ce foyer a déjà un co-parent.'); + } + + const numeroDossier = pivotParent.numero_dossier?.trim() || pivotParent.user.numero_dossier?.trim(); + if (!numeroDossier) { + throw new BadRequestException("Ce parent n'a pas de numéro de dossier."); + } + + const sameDossierCount = await this.parentsRepo.count({ + where: { numero_dossier: numeroDossier }, + }); + if (sameDossierCount >= 2) { + throw new BadRequestException('Ce dossier a déjà deux responsables.'); + } + + const email = dto.email.trim().toLowerCase(); + if (pivotParent.user.email.trim().toLowerCase() === email) { + throw new BadRequestException( + "L'email du co-parent doit être différent de celui du parent principal.", + ); + } + + const emailExiste = await this.usersService.findByEmailOrNull(dto.email); + if (emailExiste) { + throw new ConflictException("L'email du co-parent est déjà utilisé"); + } + + const memeAdresse = dto.meme_adresse ?? true; + if (!memeAdresse) { + if (!dto.adresse?.trim() || !dto.ville?.trim() || !dto.code_postal?.trim()) { + throw new BadRequestException( + "Adresse, code postal et ville du co-parent sont requis si meme_adresse est faux.", + ); + } + } + + const joursExpirationToken = await this.appConfigService.get( + 'password_reset_token_expiry_days', + 7, + ); + const tokenCreationMdp = crypto.randomUUID(); + const dateExpiration = new Date(); + dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken); + + let coParent: Users; + + try { + coParent = await this.usersRepo.manager.transaction(async (manager) => { + const pivotUser = await manager.findOne(Users, { + where: { id: pivotUserId }, + }); + if (!pivotUser) { + throw new NotFoundException('Parent introuvable'); + } + + const pivotEntite = await manager.findOne(Parents, { + where: { user_id: pivotUserId }, + relations: ['parentChildren'], + }); + if (!pivotEntite) { + throw new NotFoundException('Parent introuvable'); + } + + const coUser = manager.create(Users, { + email: dto.email.trim(), + prenom: dto.prenom, + nom: dto.nom, + role: RoleType.PARENT, + statut: StatutUtilisateurType.ACTIF, + telephone: dto.telephone, + adresse: memeAdresse ? pivotUser.adresse : dto.adresse, + code_postal: memeAdresse ? pivotUser.code_postal : dto.code_postal, + ville: memeAdresse ? pivotUser.ville : dto.ville, + token_creation_mdp: tokenCreationMdp, + token_creation_mdp_expire_le: dateExpiration, + numero_dossier: numeroDossier, + }); + const coUserSaved = await manager.save(Users, coUser); + + pivotEntite.co_parent = coUserSaved; + pivotEntite.numero_dossier = numeroDossier; + await manager.save(Parents, pivotEntite); + + const coEntite = manager.create(Parents, { + user_id: coUserSaved.id, + numero_dossier: numeroDossier, + }); + coEntite.user = coUserSaved; + coEntite.co_parent = pivotUser; + await manager.save(Parents, coEntite); + + const enfantIds = (pivotEntite.parentChildren ?? []) + .map((pc) => pc.enfantId) + .filter(Boolean); + for (const enfantId of enfantIds) { + const existing = await manager.findOne(ParentsChildren, { + where: { parentId: coUserSaved.id, enfantId }, + }); + if (existing) continue; + await manager.save( + ParentsChildren, + manager.create(ParentsChildren, { + parentId: coUserSaved.id, + enfantId, + }), + ); + } + + return coUserSaved; + }); + } catch (err) { + if (this.isPostgresUniqueViolation(err)) { + throw new ConflictException( + 'Un compte avec cet email existe déjà (contrainte unique en base).', + ); + } + throw err; + } + + try { + await this.mailService.sendValidatedAccountPasswordSetupEmail( + { + email: coParent.email, + prenom: coParent.prenom ?? '', + nom: coParent.nom ?? '', + token: tokenCreationMdp, + numeroDossier, + }, + 'parent', + ); + } catch (err) { + this.logger.error( + '[addCoParentStaff] Échec envoi email création MDP (co-parent conservé)', + err instanceof Error ? err.stack : String(err), + ); + } + + return { + message: + 'Co-parent ajouté au foyer. Un e-mail de création de mot de passe a été envoyé.', + numero_dossier: numeroDossier, + parent_user_id: pivotUserId, + co_parent_user_id: coParent.id, + statut: StatutUtilisateurType.ACTIF, + }; + } + /** * Cœur partagé création dossier AM (#156). * - public : statut en_attente + mail pending diff --git a/backend/src/routes/enfants/enfants.controller.ts b/backend/src/routes/enfants/enfants.controller.ts index 492c885..8b44228 100644 --- a/backend/src/routes/enfants/enfants.controller.ts +++ b/backend/src/routes/enfants/enfants.controller.ts @@ -141,12 +141,20 @@ export class EnfantsController { RoleType.GESTIONNAIRE, ) @Patch(':id') + @ApiOperation({ + summary: 'Mettre à jour un enfant', + description: + 'JSON sans photo OK ; avec nouvelle photo → multipart (champ fichier `photo`, max 5 Mo).', + }) + @ApiConsumes('application/json', 'multipart/form-data') + @UseInterceptors(OptionalEnfantPhotoInterceptor) update( @Param('id', new ParseUUIDPipe()) id: string, @Body() dto: UpdateEnfantsDto, + @UploadedFile() photo: Express.Multer.File, @User() currentUser: Users, ) { - return this.enfantsService.update(id, dto, currentUser); + return this.enfantsService.update(id, dto, currentUser, photo); } @Roles(RoleType.SUPER_ADMIN) diff --git a/backend/src/routes/enfants/enfants.service.ts b/backend/src/routes/enfants/enfants.service.ts index 4e0d393..43a4861 100644 --- a/backend/src/routes/enfants/enfants.service.ts +++ b/backend/src/routes/enfants/enfants.service.ts @@ -195,7 +195,12 @@ export class EnfantsService { // Mise à jour - async update(id: string, dto: Partial, currentUser: Users): Promise { + async update( + id: string, + dto: Partial, + currentUser: Users, + photoFile?: Express.Multer.File, + ): Promise { const child = await this.childrenRepository.findOne({ where: { id } }); if (!child) throw new NotFoundException('Enfant introuvable'); @@ -205,6 +210,13 @@ export class EnfantsService { patch.consent_photo = dto.consent_photo; patch.consent_photo_at = dto.consent_photo ? new Date() : null!; } + if (photoFile) { + patch.photo_url = `/uploads/photos/${photoFile.filename}`; + if (dto.consent_photo !== false) { + patch.consent_photo = true; + patch.consent_photo_at = new Date(); + } + } await this.childrenRepository.update(id, patch); return this.findOne(id, currentUser); diff --git a/backend/src/routes/parents/dto/staff-add-co-parent-response.dto.ts b/backend/src/routes/parents/dto/staff-add-co-parent-response.dto.ts new file mode 100644 index 0000000..6abf6af --- /dev/null +++ b/backend/src/routes/parents/dto/staff-add-co-parent-response.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; + +/** Réponse 201 POST /parents/:id/co-parent (#135). */ +export class StaffAddCoParentResponseDto { + @ApiProperty() + message: string; + + @ApiProperty({ example: '2026-000043' }) + numero_dossier: string; + + @ApiProperty({ format: 'uuid', description: 'UUID du parent pivot' }) + parent_user_id: string; + + @ApiProperty({ format: 'uuid', description: 'UUID du co-parent créé' }) + co_parent_user_id: string; + + @ApiProperty({ + enum: StatutUtilisateurType, + example: StatutUtilisateurType.ACTIF, + }) + statut: StatutUtilisateurType; +} diff --git a/backend/src/routes/parents/dto/staff-add-co-parent.dto.ts b/backend/src/routes/parents/dto/staff-add-co-parent.dto.ts new file mode 100644 index 0000000..0f7345c --- /dev/null +++ b/backend/src/routes/parents/dto/staff-add-co-parent.dto.ts @@ -0,0 +1,69 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsBoolean, + IsEmail, + IsNotEmpty, + IsOptional, + IsString, + Matches, + MaxLength, + MinLength, +} from 'class-validator'; + +/** + * Ajout d’un co-parent sur un foyer existant (staff) — ticket #135. + * Corps sans préfixe `co_parent_*` (l’URL cible déjà le pivot). + */ +export class StaffAddCoParentDto { + @ApiProperty({ example: 'thomas.martin@ptits-pas.fr' }) + @IsEmail({}, { message: 'Email invalide' }) + @IsNotEmpty({ message: "L'email est requis" }) + email: string; + + @ApiProperty({ example: 'Thomas' }) + @IsString() + @IsNotEmpty({ message: 'Le prénom est requis' }) + @MinLength(2) + @MaxLength(100) + prenom: string; + + @ApiProperty({ example: 'MARTIN' }) + @IsString() + @IsNotEmpty({ message: 'Le nom est requis' }) + @MinLength(2) + @MaxLength(100) + nom: string; + + @ApiProperty({ example: '0678456789' }) + @IsString() + @IsNotEmpty({ message: 'Le téléphone est requis' }) + @Matches(/^(\+33|0)[1-9](\d{2}){4}$/, { + message: 'Le numéro de téléphone doit être valide (ex: 0689567890 ou +33689567890)', + }) + telephone: string; + + @ApiPropertyOptional({ + example: true, + description: 'Si true, copie l’adresse du parent pivot', + }) + @IsOptional() + @IsBoolean() + meme_adresse?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + adresse?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(10) + code_postal?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(150) + ville?: string; +} diff --git a/backend/src/routes/parents/parents.controller.spec.ts b/backend/src/routes/parents/parents.controller.spec.ts index 0015373..4452cd6 100644 --- a/backend/src/routes/parents/parents.controller.spec.ts +++ b/backend/src/routes/parents/parents.controller.spec.ts @@ -11,6 +11,7 @@ describe('ParentsController', () => { let controller: ParentsController; const authServiceMock = { createParentDossierStaff: jest.fn(), + addCoParentStaff: jest.fn(), }; const parentsServiceMock = {}; const userServiceMock = {}; @@ -77,4 +78,27 @@ describe('ParentsController', () => { expect(res.enfant_ids).toEqual(['e1']); expect(res.statut).toBe(StatutUtilisateurType.ACTIF); }); + + it('addCoParent delegates to authService.addCoParentStaff', async () => { + authServiceMock.addCoParentStaff.mockResolvedValue({ + message: 'ok', + numero_dossier: '2026-000043', + parent_user_id: 'p1', + co_parent_user_id: 'p2', + statut: StatutUtilisateurType.ACTIF, + }); + + const body = { + email: 'coparent@test.fr', + prenom: 'Thomas', + nom: 'MARTIN', + telephone: '0678456789', + meme_adresse: true, + }; + + const res = await controller.addCoParent('p1', body as any); + expect(authServiceMock.addCoParentStaff).toHaveBeenCalledWith('p1', body); + expect(res.co_parent_user_id).toBe('p2'); + expect(res.statut).toBe(StatutUtilisateurType.ACTIF); + }); }); diff --git a/backend/src/routes/parents/parents.controller.ts b/backend/src/routes/parents/parents.controller.ts index 6d821ff..1567509 100644 --- a/backend/src/routes/parents/parents.controller.ts +++ b/backend/src/routes/parents/parents.controller.ts @@ -30,6 +30,8 @@ import { UpdateParentsDto } from '../user/dto/update_parent.dto'; import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto'; import { StaffCreateParentDossierDto } from './dto/staff-create-parent-dossier.dto'; import { StaffCreateParentDossierResponseDto } from './dto/staff-create-parent-dossier-response.dto'; +import { StaffAddCoParentDto } from './dto/staff-add-co-parent.dto'; +import { StaffAddCoParentResponseDto } from './dto/staff-add-co-parent-response.dto'; import { RegisterParentCompletDto } from '../auth/dto/register-parent-complet.dto'; import { AuthGuard } from 'src/common/guards/auth.guard'; import { RolesGuard } from 'src/common/guards/roles.guard'; @@ -176,6 +178,28 @@ export class ParentsController { return mapParentForApi(parent); } + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) + @Post(':id/co-parent') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'Ajouter un co-parent à un foyer existant (staff) — ticket #135', + description: + 'Foyer mono-parent uniquement. Crée le co-parent actif, liens foyer + enfants, ' + + 'e-mail de création de mot de passe. Ne pas utiliser POST /auth/register/parent.', + }) + @ApiParam({ name: 'id', description: 'UUID utilisateur du parent pivot' }) + @ApiBody({ type: StaffAddCoParentDto }) + @ApiResponse({ status: 201, type: StaffAddCoParentResponseDto }) + @ApiResponse({ status: 400, description: 'Foyer déjà à 2 parents / validation' }) + @ApiResponse({ status: 404, description: 'Parent introuvable' }) + @ApiResponse({ status: 409, description: 'Email déjà pris' }) + async addCoParent( + @Param('id') id: string, + @Body() dto: StaffAddCoParentDto, + ): Promise { + return this.authService.addCoParentStaff(id, dto); + } + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Post(':id/enfants/:enfantId') @ApiOperation({ summary: 'Rattacher un enfant à un parent — ticket #115' }) diff --git a/docs/tmp/135-contrat-api-ajout-co-parent.md b/docs/tmp/135-contrat-api-ajout-co-parent.md new file mode 100644 index 0000000..dd47421 --- /dev/null +++ b/docs/tmp/135-contrat-api-ajout-co-parent.md @@ -0,0 +1,73 @@ +# Mini-spec API — POST /parents/:id/co-parent (#135) + +Contrat back pour l’ajout d’un **2ᵉ parent** sur un foyer mono-parent (staff). + +## Endpoint + +| | | +|--|--| +| **Méthode** | `POST` | +| **URL** | `{base}/api/v1/parents/{parentUserId}/co-parent` | +| **Auth** | Bearer JWT | +| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` | +| **Succès** | **201** | + +`parentUserId` = UUID du **parent pivot** (déjà dans le dossier). + +Ne **pas** appeler `POST /auth/register/parent` ni `POST /parents/dossier`. + +--- + +## Body (JSON) + +| Champ | Type | Obligatoire | Notes | +|-------|------|-------------|--------| +| `email` | string | oui | unique | +| `prenom` | string | oui | | +| `nom` | string | oui | | +| `telephone` | string | oui | `0X…` ou `+33…` | +| `meme_adresse` | bool | non | défaut **true** → copie adresse du pivot | +| `adresse` | string | si `meme_adresse=false` | | +| `code_postal` | string | si `meme_adresse=false` | | +| `ville` | string | si `meme_adresse=false` | | + +--- + +## Comportement 201 + +- User co-parent **actif** + token création MDP +- Fiche `parents` + liens pivot ↔ co-parent + même `numero_dossier` +- Enfants du foyer rattachés au co-parent +- E-mail **création MDP** (pas mail « en attente ») + +```json +{ + "message": "Co-parent ajouté au foyer. Un e-mail de création de mot de passe a été envoyé.", + "numero_dossier": "2026-000043", + "parent_user_id": "uuid-pivot", + "co_parent_user_id": "uuid-co", + "statut": "actif" +} +``` + +## Erreurs + +| Code | Cas | +|------|-----| +| 400 | Déjà un co-parent / 2 responsables / validation adresse | +| 401 | Token invalide | +| 403 | Rôle non staff | +| 404 | Pivot introuvable | +| 409 | Email déjà pris | + +## Réemploi édition identité + +| Endpoint | Usage | +|----------|--------| +| `GET /dossiers/:numero` | Préremplir wizard edit | +| `PATCH /parents/:id/fiche` | Sauver identité pivot / co-parent existant | +| `PATCH /assistantes-maternelles/:id/fiche` | Édition AM | + +## Branche + +`feature/135-edition-dossier` diff --git a/docs/tmp/135-mini-spec-front-edition-dossier.md b/docs/tmp/135-mini-spec-front-edition-dossier.md new file mode 100644 index 0000000..f20113b --- /dev/null +++ b/docs/tmp/135-mini-spec-front-edition-dossier.md @@ -0,0 +1,83 @@ +# Mini-spec front — Mode édition dossier + ajout 2ᵉ parent (#135) + +Branche : `feature/135-edition-dossier` +Ticket : **#135** (full-stack) + +Prérequis : **#153** (liste Dossiers) livré. + +--- + +## Objectif + +1. Clic sur un dossier (liste #153) → ouvrir le wizard en mode **`edit`** +2. Foyer **mono-parent** : page co-parent → **switch** ajouter un 2ᵉ parent +3. Sauvegarder les champs via APIs existantes + nouvel endpoint co-parent + +--- + +## Modes wizard + +| Mode | Famille | AM | +|------|---------|-----| +| `review` | déjà | déjà | +| `create` | déjà (#129) | déjà (#156) | +| **`edit`** | **à faire** | **à faire** | + +Factories : `ParentDossierWizard.edit(...)` / `AmDossierWizard.edit(...)` +Préremplir via `UserService.getDossierByNumero(numero)`. + +--- + +## APIs + +| Action | Endpoint | +|--------|----------| +| Charger | `GET /dossiers/:numero` | +| Sauver parent | `PATCH /parents/:id/fiche` | +| Sauver AM | `PATCH /assistantes-maternelles/:id/fiche` | +| **Ajouter co-parent** | **`POST /parents/:pivotUserId/co-parent`** — voir `docs/tmp/135-contrat-api-ajout-co-parent.md` | + +Body co-parent : + +```json +{ + "email": "thomas@…", + "prenom": "Thomas", + "nom": "MARTIN", + "telephone": "0678456789", + "meme_adresse": true +} +``` + +`UserService.addCoParent(pivotUserId, body)` → cet endpoint. + +--- + +## UX + +- Depuis `DossiersManagementWidget` / carte liste : clic → edit (plus seulement review pending) +- Pending : garder validation (review) ; dossiers actifs → edit +- Mono-parent : switch « Ajouter un co-parent » (comme create) → au save, `POST …/co-parent` si nouveau +- Déjà 2 parents : éditer les deux fiches ; pas de 3ᵉ +- Pas de bouton créer dans l’onglet Dossiers + +--- + +## Hors scope + +- Famille N responsables (#139) +- Suppressions (#154) +- Création dossier initial (#129 / #156) + +--- + +## Critères d’acceptation + +- [ ] Clic dossier actif → wizard edit prérempli +- [ ] PATCH fiche enregistre les modifs +- [ ] Mono-parent + switch → co-parent créé (actif + mail MDP) +- [ ] review / create inchangés + +## Branche + +`feature/135-edition-dossier` diff --git a/frontend/lib/models/dossier_unifie.dart b/frontend/lib/models/dossier_unifie.dart index 78b2fb3..0e364fd 100644 --- a/frontend/lib/models/dossier_unifie.dart +++ b/frontend/lib/models/dossier_unifie.dart @@ -215,8 +215,13 @@ class EnfantDossier { factory EnfantDossier.fromJson(Map json) { final rawPhoto = json['photo_url'] ?? json['photoUrl']; final resolvedPhoto = _optionalPhotoUrl(rawPhoto); + final rawId = json['id'] ?? + json['enfant_id'] ?? + json['enfantId'] ?? + json['child_id'] ?? + json['childId']; return EnfantDossier( - id: json['id']?.toString() ?? '', + id: rawId?.toString().trim() ?? '', firstName: (json['first_name'] ?? json['prenom'])?.toString(), lastName: (json['last_name'] ?? json['nom'])?.toString(), birthDate: json['birth_date']?.toString(), diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index be87d9e..4c078aa 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -525,17 +525,58 @@ class UserService { return enfant.copyWith(parentLinks: enriched); } + /// Mise à jour enfant. Avec [photoBytes] : multipart (champ `photo`), sinon JSON. static Future updateEnfant({ required String enfantId, required Map body, + List? photoBytes, + String? photoFilename, }) async { - final response = await http.patch( - Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'), - headers: await _headers(), - body: jsonEncode(body), - ); + final id = enfantId.trim(); + if (id.isEmpty) { + throw Exception('Identifiant enfant manquant.'); + } + final hasPhoto = photoBytes != null && photoBytes.isNotEmpty; + final http.Response response; + if (hasPhoto) { + final token = await TokenService.getToken(); + final req = http.MultipartRequest( + 'PATCH', + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$id'), + ); + req.headers['Accept'] = 'application/json'; + if (token != null) { + req.headers['Authorization'] = 'Bearer $token'; + } + body.forEach((key, value) { + if (value == null) return; + req.fields[key] = value is bool + ? (value ? 'true' : 'false') + : value.toString(); + }); + final name = (photoFilename ?? '').trim(); + final filename = name.isNotEmpty ? name : 'photo.jpg'; + req.files.add( + http.MultipartFile.fromBytes( + 'photo', + photoBytes, + filename: filename, + contentType: _imageMediaType(filename, photoBytes), + ), + ); + final streamed = await req.send(); + response = await http.Response.fromStream(streamed); + } else { + response = await http.patch( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$id'), + headers: await _headers(), + body: jsonEncode(body), + ); + } if (response.statusCode != 200) { - throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant')); + throw Exception( + _extractErrorMessage(response.body, 'Erreur mise à jour enfant'), + ); } final enfant = EnfantAdminModel.fromJson( jsonDecode(response.body) as Map, @@ -645,6 +686,57 @@ class UserService { return fallback; } + /// Ajout d’un co-parent sur foyer mono-parent (staff, #135). + /// `POST /parents/:pivotUserId/co-parent` — succès 201. + static Future> addCoParent( + String pivotUserId, { + required Map body, + }) async { + final id = pivotUserId.trim(); + if (id.isEmpty) { + throw Exception('Identifiant du parent pivot manquant.'); + } + final http.Response response; + try { + response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$id/co-parent'), + headers: await _headers(), + body: jsonEncode(body), + ); + } on http.ClientException { + throw Exception( + 'Connexion au serveur impossible. Vérifiez votre réseau puis réessayez.', + ); + } + + if (response.statusCode == 200 || response.statusCode == 201) { + if (response.body.trim().isEmpty) return {}; + try { + final decoded = jsonDecode(response.body); + if (decoded is Map) { + return Map.from(decoded); + } + } catch (_) {} + return {}; + } + + final message = _extractErrorMessage( + response.body, + 'Erreur ajout co-parent', + ); + if (response.statusCode == 409) { + throw Exception( + message.isNotEmpty ? message : 'Conflit : e-mail déjà utilisé.', + ); + } + if (response.statusCode == 400) { + throw Exception( + message.isNotEmpty ? message : 'Données invalides (400).', + ); + } + throw Exception(message); + } + /// Création dossier famille actif côté staff (#129). /// `POST /parents/dossier` — body aligné sur register parent complet. /// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur, par parent créé). diff --git a/frontend/lib/widgets/admin/am_dossier_wizard.dart b/frontend/lib/widgets/admin/am_dossier_wizard.dart index affb5ea..dd6b0cb 100644 --- a/frontend/lib/widgets/admin/am_dossier_wizard.dart +++ b/frontend/lib/widgets/admin/am_dossier_wizard.dart @@ -23,9 +23,9 @@ import 'package:p_tits_pas/widgets/admin/validation_valider_confirm_dialog.dart' import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart'; -enum AmDossierWizardMode { review, create } +enum AmDossierWizardMode { review, create, edit } -/// Wizard dossier AM — modes [review] (validation pending) et [create] (staff #156). +/// Wizard dossier AM — [review] (#107), [create] (#156), [edit] (#135). class AmDossierWizard extends StatefulWidget { final AmDossierWizardMode mode; final DossierAM? dossier; @@ -74,7 +74,25 @@ class AmDossierWizard extends StatefulWidget { ); } + factory AmDossierWizard.edit({ + Key? key, + required DossierAM dossier, + required VoidCallback onClose, + required VoidCallback onSuccess, + void Function(int step, int total)? onStepChanged, + }) { + return AmDossierWizard._( + key: key, + mode: AmDossierWizardMode.edit, + dossier: dossier, + onClose: onClose, + onSuccess: onSuccess, + onStepChanged: onStepChanged, + ); + } + bool get isCreate => mode == AmDossierWizardMode.create; + bool get isEdit => mode == AmDossierWizardMode.edit; /// Hauteur corps modale AM — dérivée de [ValidationFormMetrics] (4 lignes). static double get shellBodyHeight => @@ -118,9 +136,11 @@ class _AmDossierWizardState extends State { String? _photoFilename; bool get _isCreate => widget.isCreate; + bool get _isEdit => widget.isEdit; + bool get _isEditable => _isCreate || _isEdit; DossierAM get _dossier => widget.dossier!; bool get _isEnAttente => - !_isCreate && _dossier.user.statut == 'en_attente'; + !_isCreate && !_isEdit && _dossier.user.statut == 'en_attente'; static String _v(String? s) => (s != null && s.trim().isNotEmpty) ? s.trim() : '–'; @@ -151,9 +171,33 @@ class _AmDossierWizardState extends State { _capaciteCtrl = TextEditingController(text: '1'); _placesCtrl = TextEditingController(text: '1'); _presentationCtrl = TextEditingController(); + if (_isEdit) { + _prefillFromDossier(); + } WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep()); } + void _prefillFromDossier() { + final u = _dossier.user; + _nomCtrl.text = (u.nom ?? '').trim(); + _prenomCtrl.text = (u.prenom ?? '').trim(); + _telCtrl.text = (u.telephone ?? '').trim(); + _emailCtrl.text = u.email.trim(); + _adresseCtrl.text = (u.adresse ?? '').trim(); + _cpCtrl.text = (u.codePostal ?? '').trim(); + _villeCtrl.text = (u.ville ?? '').trim(); + _nirCtrl.text = (_dossier.nir ?? '').trim(); + _dateNaissanceCtrl.text = formatIsoDateFrInput(u.dateNaissance); + _lieuNaissanceVilleCtrl.text = (u.lieuNaissanceVille ?? '').trim(); + final pays = (u.lieuNaissancePays ?? '').trim(); + _lieuNaissancePaysCtrl.text = pays.isEmpty ? 'France' : pays; + _agrementCtrl.text = (_dossier.numeroAgrement ?? '').trim(); + _dateAgrementCtrl.text = formatIsoDateFrInput(_dossier.dateAgrement); + _capaciteCtrl.text = '${_dossier.nbMaxEnfants ?? 1}'; + _placesCtrl.text = '${_dossier.placesDisponibles ?? 0}'; + _presentationCtrl.text = (_dossier.presentation ?? '').trim(); + } + @override void dispose() { _nomCtrl.dispose(); @@ -373,8 +417,8 @@ class _AmDossierWizardState extends State { return null; } - String? _validateStep1() { - if (_photoBytes == null || _photoBytes!.isEmpty) { + String? _validateStep1({required bool requirePhoto}) { + if (requirePhoto && (_photoBytes == null || _photoBytes!.isEmpty)) { return 'Une photo de profil est requise.'; } final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text); @@ -407,12 +451,12 @@ class _AmDossierWizardState extends State { } String? _validateCurrentStep() { - if (!_isCreate) return null; + if (!_isEditable) return null; switch (_step) { case 0: return _validateStep0(); case 1: - return _validateStep1(); + return _validateStep1(requirePhoto: _isCreate); default: return null; } @@ -511,7 +555,7 @@ class _AmDossierWizardState extends State { ); return; } - final err1 = _validateStep1(); + final err1 = _validateStep1(requirePhoto: true); if (err1 != null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(err1), backgroundColor: Colors.red.shade700), @@ -554,6 +598,94 @@ class _AmDossierWizardState extends State { } } + Future _saveEdit() async { + if (_submitting || !_isEdit) return; + final err0 = _validateStep0(); + if (err0 != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(err0), backgroundColor: Colors.red.shade700), + ); + return; + } + final err1 = _validateStep1(requirePhoto: false); + if (err1 != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(err1), backgroundColor: Colors.red.shade700), + ); + return; + } + + final amId = _dossier.user.id.trim(); + if (amId.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Identifiant AM manquant.'), + backgroundColor: Colors.red.shade700, + ), + ); + return; + } + + final ok = await showValidationValiderConfirmDialog( + context, + body: 'Enregistrer les modifications de la fiche assistante maternelle ?', + ); + if (!mounted || !ok) return; + + final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text); + final agrementIso = parseFrDateToIso(_dateAgrementCtrl.text); + final capa = int.tryParse(_capaciteCtrl.text.trim()); + final places = int.tryParse(_placesCtrl.text.trim()); + final biography = _presentationCtrl.text.trim(); + + setState(() => _submitting = true); + try { + await UserService.updateAmFiche( + amUserId: amId, + body: { + 'nom': formatPersonNameCase(_nomCtrl.text), + 'prenom': formatPersonNameCase(_prenomCtrl.text), + 'email': normalizeEmailText(_emailCtrl.text), + 'telephone': normalizePhone(_telCtrl.text), + 'adresse': _adresseCtrl.text.trim(), + 'ville': formatPersonNameCase(_villeCtrl.text), + 'code_postal': _cpCtrl.text.trim(), + 'approval_number': _agrementCtrl.text.trim(), + 'nir': nirToRaw(_nirCtrl.text), + if (birthIso != null) 'date_naissance': birthIso, + 'lieu_naissance_ville': + formatPersonNameCase(_lieuNaissanceVilleCtrl.text), + 'lieu_naissance_pays': + formatPersonNameCase(_lieuNaissancePaysCtrl.text), + if (agrementIso != null) 'agreement_date': agrementIso, + if (capa != null) 'max_children': capa, + if (places != null) 'places_available': places, + 'biography': biography, + }, + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Fiche AM enregistrée.'), + duration: Duration(seconds: 4), + ), + ); + widget.onSuccess(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur'), + backgroundColor: Colors.red.shade700, + ), + ); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + @override Widget build(BuildContext context) { if (_showRefusForm) { @@ -587,7 +719,7 @@ class _AmDossierWizardState extends State { } Widget _buildStep0() { - if (_isCreate) { + if (_isEditable) { return IdentityBlock.editable( title: 'Identité et coordonnées', nomController: _nomCtrl, @@ -620,7 +752,7 @@ class _AmDossierWizardState extends State { .clamp(_photoColumnMinWidth, 360.0); if (photoW > maxPhotoW) photoW = maxPhotoW; - final form = _isCreate + final form = _isEditable ? _buildCreateProFields() : ValidationDetailSection( title: 'Dossier professionnel', @@ -628,20 +760,25 @@ class _AmDossierWizardState extends State { rowLayout: _photoProRowLayout, ); + final Widget photo; + if (_isCreate) { + photo = AdminAmPhotoFrame( + imageBytes: _photoBytes, + onTap: _pickPhoto, + onClear: _photoBytes != null ? _clearPhoto : null, + emptyLabel: 'Ajouter une photo', + ); + } else if (_isEdit) { + // Édition : photo existante en lecture ; remplacement hors scope PATCH fiche. + photo = _buildPhotoSectionReview(_dossier.user); + } else { + photo = _buildPhotoSectionReview(_dossier.user); + } + return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SizedBox( - width: photoW, - child: _isCreate - ? AdminAmPhotoFrame( - imageBytes: _photoBytes, - onTap: _pickPhoto, - onClear: _photoBytes != null ? _clearPhoto : null, - emptyLabel: 'Ajouter une photo', - ) - : _buildPhotoSectionReview(_dossier.user), - ), + SizedBox(width: photoW, child: photo), const SizedBox(width: _photoProGap), Expanded( child: Align( @@ -721,7 +858,7 @@ class _AmDossierWizardState extends State { } Widget _buildStep2() { - if (_isCreate) { + if (_isEditable) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -832,6 +969,12 @@ class _AmDossierWizardState extends State { onPressed: _submitting ? null : _createAndValidate, child: Text(_submitting ? 'Envoi...' : 'Créer et valider'), ), + ] else if (_isEdit) ...[ + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: _submitting ? null : _saveEdit, + child: Text(_submitting ? 'Envoi...' : 'Enregistrer'), + ), ] else if (_isEnAttente) ...[ OutlinedButton( onPressed: _submitting ? null : _refuser, diff --git a/frontend/lib/widgets/admin/dossiers_management_widget.dart b/frontend/lib/widgets/admin/dossiers_management_widget.dart index 62ec3ab..4cafa2f 100644 --- a/frontend/lib/widgets/admin/dossiers_management_widget.dart +++ b/frontend/lib/widgets/admin/dossiers_management_widget.dart @@ -85,6 +85,7 @@ class _DossiersManagementWidgetState extends State { context: context, builder: (context) => ValidationDossierModal( numeroDossier: num, + openAsEdit: true, onClose: () => Navigator.of(context).pop(), onSuccess: () { Navigator.of(context).pop(); diff --git a/frontend/lib/widgets/admin/parent_dossier_wizard.dart b/frontend/lib/widgets/admin/parent_dossier_wizard.dart index 262a8a1..b9c7bf1 100644 --- a/frontend/lib/widgets/admin/parent_dossier_wizard.dart +++ b/frontend/lib/widgets/admin/parent_dossier_wizard.dart @@ -22,10 +22,15 @@ import 'package:p_tits_pas/widgets/admin/validation_valider_confirm_dialog.dart' import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart'; -enum ParentDossierWizardMode { review, create } +enum ParentDossierWizardMode { review, create, edit } -/// Enfant en cours de saisie (mode création). Contrôleurs propres, à disposer. +/// Enfant en cours de saisie (création / édition). Contrôleurs propres, à disposer. class _CreateChild { + /// Id existant en base (mode edit) — null = nouvel enfant → POST. + String? existingChildId; + String? existingPhotoUrl; + /// Statut d’origine (hors à naître) pour ne pas écraser garde/scolarise. + String? existingStatus; final TextEditingController prenomCtrl = TextEditingController(); final TextEditingController nomCtrl = TextEditingController(); final TextEditingController dateCtrl = TextEditingController(); @@ -34,6 +39,8 @@ class _CreateChild { Uint8List? photoBytes; String? photoFilename; + bool get hasExistingId => (existingChildId ?? '').trim().isNotEmpty; + void dispose() { prenomCtrl.dispose(); nomCtrl.dispose(); @@ -41,8 +48,7 @@ class _CreateChild { } } -/// Wizard dossier famille — modes [review] (validation dossier en attente, ticket #107) -/// et [create] (création dossier famille actif par le staff, ticket #129). +/// Wizard dossier famille — [review] (#107), [create] (#129), [edit] (#135). class ParentDossierWizard extends StatefulWidget { final ParentDossierWizardMode mode; final DossierFamille? dossier; @@ -91,7 +97,25 @@ class ParentDossierWizard extends StatefulWidget { ); } + factory ParentDossierWizard.edit({ + Key? key, + required DossierFamille dossier, + required VoidCallback onClose, + required VoidCallback onSuccess, + void Function(int step, int total)? onStepChanged, + }) { + return ParentDossierWizard._( + key: key, + mode: ParentDossierWizardMode.edit, + dossier: dossier, + onClose: onClose, + onSuccess: onSuccess, + onStepChanged: onStepChanged, + ); + } + bool get isCreate => mode == ParentDossierWizardMode.create; + bool get isEdit => mode == ParentDossierWizardMode.edit; /// Hauteur corps modale famille — 4 lignes TF + marge pour le bandeau switch. static double get shellBodyHeight => @@ -141,16 +165,27 @@ class _ParentDossierWizardState extends State { final List<_CreateChild> _children = []; late final TextEditingController _presentationCtrl; + /// Nb de parents déjà en base au moment de l’ouverture (mode edit). + int _initialParentCount = 0; + + /// Enfants existants retirés en edit → DELETE au save. + final List _removedEnfantIds = []; + bool get _isCreate => widget.isCreate; + bool get _isEdit => widget.isEdit; + bool get _isEditable => _isCreate || _isEdit; DossierFamille get _dossier => widget.dossier!; - bool get _isEnAttente => !_isCreate && _dossier.isEnAttente; + bool get _isEnAttente => !_isCreate && !_isEdit && _dossier.isEnAttente; String? get _firstParentId { if (_isCreate) return null; return _dossier.parents.isNotEmpty ? _dossier.parents.first.id : null; } + /// Co-parent déjà présent au chargement (edit) — pas un ajout via POST. + bool get _hadExistingCoParent => _isEdit && _initialParentCount >= 2; + static String _v(String? s) => (s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini'; @@ -184,11 +219,81 @@ class _ParentDossierWizardState extends State { _presentationCtrl = TextEditingController(); if (_isCreate) { _children.add(_CreateChild()); + } else if (_isEdit) { + _prefillFromDossier(); } WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep()); } + void _prefillFromDossier() { + final parents = _dossier.parents; + _initialParentCount = parents.length; + + if (parents.isNotEmpty) { + final p1 = parents.first; + _p1NomCtrl.text = (p1.nom ?? '').trim(); + _p1PrenomCtrl.text = (p1.prenom ?? '').trim(); + _p1TelCtrl.text = (p1.telephone ?? '').trim(); + _p1EmailCtrl.text = (p1.email).trim(); + _p1AdresseCtrl.text = (p1.adresse ?? '').trim(); + _p1CpCtrl.text = (p1.codePostal ?? '').trim(); + _p1VilleCtrl.text = (p1.ville ?? '').trim(); + + if (parents.length >= 2) { + _hasCoParent = true; + final p2 = parents[1]; + _p2NomCtrl.text = (p2.nom ?? '').trim(); + _p2PrenomCtrl.text = (p2.prenom ?? '').trim(); + _p2TelCtrl.text = (p2.telephone ?? '').trim(); + _p2EmailCtrl.text = (p2.email).trim(); + _p2AdresseCtrl.text = (p2.adresse ?? '').trim(); + _p2CpCtrl.text = (p2.codePostal ?? '').trim(); + _p2VilleCtrl.text = (p2.ville ?? '').trim(); + } else { + _hasCoParent = false; + } + } + + _prefillChildrenFromDossier(); + } + + void _prefillChildrenFromDossier() { + for (final c in _children) { + c.dispose(); + } + _children.clear(); + _removedEnfantIds.clear(); + + final enfants = _dossier.enfants; + if (enfants.isEmpty) { + _children.add(_CreateChild()); + return; + } + + for (final e in enfants) { + final status = (e.status ?? '').trim().toLowerCase(); + final id = e.id.trim(); + final child = _CreateChild() + ..existingChildId = id.isEmpty ? null : id + ..existingPhotoUrl = e.photoUrl + ..existingStatus = status.isEmpty ? null : status + ..isUnborn = status == 'a_naitre'; + child.prenomCtrl.text = (e.firstName ?? '').trim(); + child.nomCtrl.text = (e.lastName ?? '').trim(); + final g = (e.gender ?? '').trim(); + final gUp = g.toUpperCase(); + if (gUp == 'H' || gUp == 'F') { + child.genre = gUp; + } else if (g == 'Autre' || child.isUnborn) { + child.genre = child.isUnborn ? 'Autre' : g; + } + final dateSrc = child.isUnborn ? e.dueDate : e.birthDate; + child.dateCtrl.text = formatIsoDateFrInput(dateSrc); + _children.add(child); + } + } + @override void dispose() { _enfantsScrollController.removeListener(_syncEnfantsScrollFades); @@ -291,7 +396,7 @@ class _ParentDossierWizardState extends State { } Widget _buildStep0() { - if (_isCreate) { + if (_isEditable) { return IdentityBlock.editable( title: 'Parent principal', nomController: _p1NomCtrl, @@ -310,13 +415,15 @@ class _ParentDossierWizardState extends State { } Widget _buildStep1() { - if (_isCreate) { - return _buildCoParentStepCreate(); + if (_isEditable) { + return _buildCoParentStepEditable( + allowToggle: !_hadExistingCoParent, + ); } return _buildParent2Step(); } - Widget _buildCoParentStepCreate() { + Widget _buildCoParentStepEditable({required bool allowToggle}) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -330,19 +437,21 @@ class _ParentDossierWizardState extends State { color: Colors.black87, ), ), - const Spacer(), - const Text( - 'Ajouter un co-parent', - style: TextStyle(fontSize: 14, color: Colors.black87), - ), - Transform.scale( - scale: 0.75, - child: Switch( - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - value: _hasCoParent, - onChanged: (v) => setState(() => _hasCoParent = v), + if (allowToggle) ...[ + const Spacer(), + const Text( + 'Ajouter un co-parent', + style: TextStyle(fontSize: 14, color: Colors.black87), ), - ), + Transform.scale( + scale: 0.75, + child: Switch( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + value: _hasCoParent, + onChanged: (v) => setState(() => _hasCoParent = v), + ), + ), + ], ], ), const SizedBox(height: 8), @@ -539,7 +648,7 @@ class _ParentDossierWizardState extends State { } Widget _buildEnfantsStep() { - if (_isCreate) { + if (_isCreate || _isEdit) { return _buildEnfantsStepCreate(); } final enfants = _dossier.enfants; @@ -618,6 +727,10 @@ class _ParentDossierWizardState extends State { if (_children.length <= 1) return; setState(() { final removed = _children.removeAt(index); + final existingId = (removed.existingChildId ?? '').trim(); + if (existingId.isNotEmpty) { + _removedEnfantIds.add(existingId); + } removed.dispose(); }); } @@ -706,7 +819,8 @@ class _ParentDossierWizardState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( - padding: const EdgeInsets.fromLTRB(12, 12, 14, 8), + // Réserve l’angle haut-droit pour la croix de suppression. + padding: EdgeInsets.fromLTRB(12, 12, canRemove ? 36 : 14, 8), child: ValidationLabeledField( label: 'Prénom', field: ValidationEditableField( @@ -747,6 +861,9 @@ class _ParentDossierWizardState extends State { width: pw, height: ph, child: AdminAmPhotoFrame( + photoUrl: child.photoBytes == null + ? child.existingPhotoUrl + : null, imageBytes: child.photoBytes, onTap: () => _pickChildPhoto(index), onClear: child.photoBytes != null @@ -833,19 +950,28 @@ class _ParentDossierWizardState extends State { ), if (canRemove) Positioned( - top: 4, - right: 4, + top: 6, + right: 6, child: Material( - color: Colors.transparent, - child: IconButton( - tooltip: 'Retirer cet enfant', - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - constraints: - const BoxConstraints(minWidth: 32, minHeight: 32), - icon: Icon(Icons.close, - size: 18, color: Colors.grey.shade700), - onPressed: () => _removeChild(index), + color: Colors.white, + elevation: 1, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + customBorder: const CircleBorder(), + onTap: () => _removeChild(index), + child: Tooltip( + message: 'Retirer cet enfant', + child: SizedBox( + width: 22, + height: 22, + child: Icon( + Icons.close, + size: 14, + color: Colors.grey.shade800, + ), + ), + ), ), ), ), @@ -1264,6 +1390,18 @@ class _ParentDossierWizardState extends State { } String? _validateCurrentStep() { + if (_isEdit) { + switch (_step) { + case 0: + return _validateP1(); + case 1: + return _validateP2(); + case 2: + return _validateEnfants(); + default: + return null; + } + } if (!_isCreate) return null; switch (_step) { case 0: @@ -1440,6 +1578,299 @@ class _ParentDossierWizardState extends State { } } + Map _parentFicheBody({ + required TextEditingController nom, + required TextEditingController prenom, + required TextEditingController email, + required TextEditingController tel, + required TextEditingController adresse, + required TextEditingController cp, + required TextEditingController ville, + }) { + return { + 'nom': formatPersonNameCase(nom.text), + 'prenom': formatPersonNameCase(prenom.text), + 'email': normalizeEmailText(email.text), + 'telephone': normalizePhone(tel.text), + 'adresse': adresse.text.trim(), + 'ville': formatPersonNameCase(ville.text), + 'code_postal': cp.text.trim(), + }; + } + + Future _saveEdit() async { + if (_submitting || !_isEdit) return; + final err0 = _validateP1(); + if (err0 != null) { + _showError(err0); + return; + } + final err1 = _validateP2(); + if (err1 != null) { + _showError(err1); + return; + } + final err2 = _validateEnfants(); + if (err2 != null) { + _showError(err2); + return; + } + + final pivotId = (_firstParentId ?? '').trim(); + if (pivotId.isEmpty) { + _showError('Identifiant du parent principal manquant.'); + return; + } + + final addingCoParent = _hasCoParent && !_hadExistingCoParent; + final ok = await showValidationValiderConfirmDialog( + context, + body: addingCoParent + ? 'Enregistrer le dossier et ajouter le co-parent ? Un e-mail de création de mot de passe lui sera envoyé.' + : 'Enregistrer les modifications du dossier famille ?', + ); + if (!mounted || !ok) return; + + setState(() => _submitting = true); + try { + await UserService.updateParentFiche( + parentUserId: pivotId, + body: _parentFicheBody( + nom: _p1NomCtrl, + prenom: _p1PrenomCtrl, + email: _p1EmailCtrl, + tel: _p1TelCtrl, + adresse: _p1AdresseCtrl, + cp: _p1CpCtrl, + ville: _p1VilleCtrl, + ), + ); + + if (_hadExistingCoParent) { + final coId = _dossier.parents[1].id.trim(); + if (coId.isEmpty) { + throw Exception('Identifiant du co-parent manquant.'); + } + await UserService.updateParentFiche( + parentUserId: coId, + body: _parentFicheBody( + nom: _p2NomCtrl, + prenom: _p2PrenomCtrl, + email: _p2EmailCtrl, + tel: _p2TelCtrl, + adresse: _p2AdresseCtrl, + cp: _p2CpCtrl, + ville: _p2VilleCtrl, + ), + ); + } else if (addingCoParent) { + if (_sameAddress) _copyP1AddressToP2(); + final body = { + 'email': normalizeEmailText(_p2EmailCtrl.text), + 'prenom': formatPersonNameCase(_p2PrenomCtrl.text), + 'nom': formatPersonNameCase(_p2NomCtrl.text), + 'telephone': normalizePhone(_p2TelCtrl.text), + 'meme_adresse': _sameAddress, + }; + if (!_sameAddress) { + body['adresse'] = _p2AdresseCtrl.text.trim(); + body['code_postal'] = _p2CpCtrl.text.trim(); + body['ville'] = formatPersonNameCase(_p2VilleCtrl.text); + } + await UserService.addCoParent(pivotId, body: body); + } + + await _saveEnfantsEdit(pivotId); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + addingCoParent + ? 'Dossier enregistré. Co-parent ajouté.' + : 'Dossier enregistré.', + ), + duration: const Duration(seconds: 4), + ), + ); + widget.onSuccess(); + } catch (e) { + if (!mounted) return; + _showError( + e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur', + ); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + Map _enfantStaffBody(_CreateChild c) { + final prenom = formatPersonNameCase(c.prenomCtrl.text); + final nomRaw = c.nomCtrl.text.trim(); + final nom = nomRaw.isNotEmpty + ? formatPersonNameCase(nomRaw) + : formatPersonNameCase(_p1NomCtrl.text); + final dateIso = parseFrDateToIso(c.dateCtrl.text); + final existingStatus = (c.existingStatus ?? '').trim().toLowerCase(); + final status = c.isUnborn + ? 'a_naitre' + : (existingStatus.isNotEmpty && existingStatus != 'a_naitre' + ? existingStatus + : 'sans_garde'); + final gender = c.genre ?? 'H'; + + final map = { + 'status': status, + 'gender': gender, + 'consent_photo': true, + 'is_multiple': false, + }; + if (prenom.length >= 2) map['first_name'] = prenom; + if (nom.length >= 2) map['last_name'] = nom; + if (c.isUnborn) { + if (dateIso != null) map['due_date'] = dateIso; + } else if (dateIso != null) { + map['birth_date'] = dateIso; + } + return map; + } + + /// Empreinte identité pour rattacher un brouillon sans id à un enfant du GET. + String _enfantIdentityKey({ + required String prenom, + required String nom, + required String? dateIso, + required bool isUnborn, + }) { + final pn = formatPersonNameCase(prenom).toLowerCase().trim(); + final nm = formatPersonNameCase(nom).toLowerCase().trim(); + final d = _normalizeDateKey(dateIso); + final kind = isUnborn ? 'due' : 'birth'; + return '$pn|$nm|$kind|$d'; + } + + String _normalizeDateKey(String? raw) { + final s = (raw ?? '').trim(); + if (s.isEmpty) return ''; + final fromFr = parseFrDateToIso(s); + if (fromFr != null) return fromFr; + try { + final dt = DateTime.parse(s); + final y = dt.year.toString().padLeft(4, '0'); + final m = dt.month.toString().padLeft(2, '0'); + final d = dt.day.toString().padLeft(2, '0'); + return '$y-$m-$d'; + } catch (_) { + return s; + } + } + + String _enfantKeyFromCreate(_CreateChild c) { + final prenom = formatPersonNameCase(c.prenomCtrl.text); + final nomRaw = c.nomCtrl.text.trim(); + final nom = nomRaw.isNotEmpty + ? formatPersonNameCase(nomRaw) + : formatPersonNameCase(_p1NomCtrl.text); + return _enfantIdentityKey( + prenom: prenom, + nom: nom, + dateIso: parseFrDateToIso(c.dateCtrl.text), + isUnborn: c.isUnborn, + ); + } + + String _enfantKeyFromDossier(EnfantDossier e) { + final isUnborn = (e.status ?? '').trim().toLowerCase() == 'a_naitre'; + final nomRaw = (e.lastName ?? '').trim(); + final nom = nomRaw.isNotEmpty ? nomRaw : (_p1NomCtrl.text.trim()); + return _enfantIdentityKey( + prenom: e.firstName ?? '', + nom: nom, + dateIso: isUnborn ? e.dueDate : e.birthDate, + isUnborn: isUnborn, + ); + } + + bool _isBlankChildDraft(_CreateChild c) { + if (c.hasExistingId) return false; + final prenom = c.prenomCtrl.text.trim(); + final nom = c.nomCtrl.text.trim(); + final date = c.dateCtrl.text.trim(); + return prenom.isEmpty && nom.isEmpty && date.isEmpty && c.genre == null; + } + + /// Résout l’id enfant : tracker edit, sinon match sur le GET dossier. + String? _resolveExistingChildId( + _CreateChild c, + Map dossierIdByIdentity, + Set alreadyUsedIds, + ) { + final tracked = (c.existingChildId ?? '').trim(); + if (tracked.isNotEmpty && !alreadyUsedIds.contains(tracked)) { + return tracked; + } + final key = _enfantKeyFromCreate(c); + final matched = (dossierIdByIdentity[key] ?? '').trim(); + if (matched.isNotEmpty && !alreadyUsedIds.contains(matched)) { + return matched; + } + return null; + } + + Future _saveEnfantsEdit(String pivotUserId) async { + // Index id connus du GET (ne jamais POST pour ceux-là). + final dossierIdByIdentity = {}; + for (final e in _dossier.enfants) { + final id = e.id.trim(); + if (id.isEmpty) continue; + dossierIdByIdentity[_enfantKeyFromDossier(e)] = id; + } + + for (final id in _removedEnfantIds) { + await UserService.deleteEnfant(id); + } + + final usedIds = {..._removedEnfantIds}; + for (final c in _children) { + if (_isBlankChildDraft(c)) continue; + + final existingId = _resolveExistingChildId( + c, + dossierIdByIdentity, + usedIds, + ); + final body = _enfantStaffBody(c); + + if (existingId != null && existingId.isNotEmpty) { + // Ré-attache l’id au modèle (si match identité a récupéré un id perdu). + c.existingChildId = existingId; + final bytes = c.photoBytes; + await UserService.updateEnfant( + enfantId: existingId, + body: body, + photoBytes: (bytes != null && bytes.isNotEmpty) ? bytes : null, + photoFilename: c.photoFilename, + ); + usedIds.add(existingId); + continue; + } + + // Uniquement les vrais nouveaux enfants (pas d’id dossier). + final bytes = c.photoBytes; + final created = await UserService.createEnfant( + parentUserId: pivotUserId, + body: body, + photoBytes: (bytes != null && bytes.isNotEmpty) ? bytes : null, + photoFilename: c.photoFilename, + ); + final newId = created.id.trim(); + if (newId.isNotEmpty) { + c.existingChildId = newId; + usedIds.add(newId); + } + } + } + Widget _buildNavigation() { if (_step == 3) { return Row( @@ -1463,6 +1894,12 @@ class _ParentDossierWizardState extends State { onPressed: _submitting ? null : _createAndValidate, child: Text(_submitting ? 'Envoi...' : 'Créer et valider'), ), + ] else if (_isEdit) ...[ + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: _submitting ? null : _saveEdit, + child: Text(_submitting ? 'Envoi...' : 'Enregistrer'), + ), ] else if (_isEnAttente && _firstParentId != null) ...[ OutlinedButton( onPressed: _submitting ? null : _refuser, diff --git a/frontend/lib/widgets/admin/validation_dossier_modal.dart b/frontend/lib/widgets/admin/validation_dossier_modal.dart index 7bc3faf..fefac12 100644 --- a/frontend/lib/widgets/admin/validation_dossier_modal.dart +++ b/frontend/lib/widgets/admin/validation_dossier_modal.dart @@ -2,20 +2,25 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart'; +import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart'; import 'package:p_tits_pas/widgets/admin/validation_am_wizard.dart'; import 'package:p_tits_pas/widgets/admin/validation_family_wizard.dart'; -/// Modale (dialog) : charge le dossier par numéro puis affiche le wizard AM ou Famille. Ticket #107, #119. +/// Modale (dialog) : charge le dossier par numéro puis affiche le wizard AM ou Famille. +/// Ticket #107 / #119 (review), #135 (`openAsEdit`). class ValidationDossierModal extends StatefulWidget { final String numeroDossier; final VoidCallback onClose; final VoidCallback? onSuccess; + /// Liste Dossiers actifs → mode edit (#135). Pending reste en review (défaut). + final bool openAsEdit; const ValidationDossierModal({ super.key, required this.numeroDossier, required this.onClose, this.onSuccess, + this.openAsEdit = false, }); @override @@ -77,12 +82,12 @@ class _ValidationDossierModalState extends State { /// Largeur modale = 1,5 × 620. static const double _modalWidth = 930; // 620 * 1.5 - static const double _familyBodyHeight = 435; double get _bodyHeight { final d = _dossier; if (d != null && d.isAm) return AmDossierWizard.shellBodyHeight; - return _familyBodyHeight; + // Aligné create (#129) / edit (#135) — évite overflow IdentityBlock (8px). + return ParentDossierWizard.shellBodyHeight; } @override @@ -162,6 +167,14 @@ class _ValidationDossierModalState extends State { } final d = _dossier!; if (d.isAm) { + if (widget.openAsEdit) { + return AmDossierWizard.edit( + dossier: d.asAm, + onClose: widget.onClose, + onSuccess: _onSuccess, + onStepChanged: _onStepChanged, + ); + } return ValidationAmWizard( dossier: d.asAm, onClose: widget.onClose, @@ -169,6 +182,14 @@ class _ValidationDossierModalState extends State { onStepChanged: _onStepChanged, ); } + if (widget.openAsEdit) { + return ParentDossierWizard.edit( + dossier: d.asFamily, + onClose: widget.onClose, + onSuccess: _onSuccess, + onStepChanged: _onStepChanged, + ); + } return ValidationFamilyWizard( dossier: d.asFamily, onClose: widget.onClose,