Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8c9cfbc4d | ||
|
|
530e896b66 |
@@ -36,6 +36,7 @@ import { MailService } from 'src/modules/mail/mail.service';
|
|||||||
import { ParentsService } from '../parents/parents.service';
|
import { ParentsService } from '../parents/parents.service';
|
||||||
import { DossiersService } from '../dossiers/dossiers.service';
|
import { DossiersService } from '../dossiers/dossiers.service';
|
||||||
import { DossierAmCompletDto } from '../dossiers/dto/dossier-am-complet.dto';
|
import { DossierAmCompletDto } from '../dossiers/dto/dossier-am-complet.dto';
|
||||||
|
import { StaffAddCoParentDto } from '../parents/dto/staff-add-co-parent.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
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<number>(
|
||||||
|
'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).
|
* Cœur partagé création dossier AM (#156).
|
||||||
* - public : statut en_attente + mail pending
|
* - public : statut en_attente + mail pending
|
||||||
|
|||||||
@@ -141,12 +141,20 @@ export class EnfantsController {
|
|||||||
RoleType.GESTIONNAIRE,
|
RoleType.GESTIONNAIRE,
|
||||||
)
|
)
|
||||||
@Patch(':id')
|
@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(
|
update(
|
||||||
@Param('id', new ParseUUIDPipe()) id: string,
|
@Param('id', new ParseUUIDPipe()) id: string,
|
||||||
@Body() dto: UpdateEnfantsDto,
|
@Body() dto: UpdateEnfantsDto,
|
||||||
|
@UploadedFile() photo: Express.Multer.File,
|
||||||
@User() currentUser: Users,
|
@User() currentUser: Users,
|
||||||
) {
|
) {
|
||||||
return this.enfantsService.update(id, dto, currentUser);
|
return this.enfantsService.update(id, dto, currentUser, photo);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN)
|
@Roles(RoleType.SUPER_ADMIN)
|
||||||
|
|||||||
@@ -195,7 +195,12 @@ export class EnfantsService {
|
|||||||
|
|
||||||
|
|
||||||
// Mise à jour
|
// Mise à jour
|
||||||
async update(id: string, dto: Partial<CreateEnfantsDto>, currentUser: Users): Promise<Children> {
|
async update(
|
||||||
|
id: string,
|
||||||
|
dto: Partial<CreateEnfantsDto>,
|
||||||
|
currentUser: Users,
|
||||||
|
photoFile?: Express.Multer.File,
|
||||||
|
): Promise<Children> {
|
||||||
const child = await this.childrenRepository.findOne({ where: { id } });
|
const child = await this.childrenRepository.findOne({ where: { id } });
|
||||||
if (!child) throw new NotFoundException('Enfant introuvable');
|
if (!child) throw new NotFoundException('Enfant introuvable');
|
||||||
|
|
||||||
@@ -205,6 +210,13 @@ export class EnfantsService {
|
|||||||
patch.consent_photo = dto.consent_photo;
|
patch.consent_photo = dto.consent_photo;
|
||||||
patch.consent_photo_at = dto.consent_photo ? new Date() : null!;
|
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);
|
await this.childrenRepository.update(id, patch);
|
||||||
return this.findOne(id, currentUser);
|
return this.findOne(id, currentUser);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ describe('ParentsController', () => {
|
|||||||
let controller: ParentsController;
|
let controller: ParentsController;
|
||||||
const authServiceMock = {
|
const authServiceMock = {
|
||||||
createParentDossierStaff: jest.fn(),
|
createParentDossierStaff: jest.fn(),
|
||||||
|
addCoParentStaff: jest.fn(),
|
||||||
};
|
};
|
||||||
const parentsServiceMock = {};
|
const parentsServiceMock = {};
|
||||||
const userServiceMock = {};
|
const userServiceMock = {};
|
||||||
@@ -77,4 +78,27 @@ describe('ParentsController', () => {
|
|||||||
expect(res.enfant_ids).toEqual(['e1']);
|
expect(res.enfant_ids).toEqual(['e1']);
|
||||||
expect(res.statut).toBe(StatutUtilisateurType.ACTIF);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import { UpdateParentsDto } from '../user/dto/update_parent.dto';
|
|||||||
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
|
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
|
||||||
import { StaffCreateParentDossierDto } from './dto/staff-create-parent-dossier.dto';
|
import { StaffCreateParentDossierDto } from './dto/staff-create-parent-dossier.dto';
|
||||||
import { StaffCreateParentDossierResponseDto } from './dto/staff-create-parent-dossier-response.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 { RegisterParentCompletDto } from '../auth/dto/register-parent-complet.dto';
|
||||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
@@ -176,6 +178,28 @@ export class ParentsController {
|
|||||||
return mapParentForApi(parent);
|
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<StaffAddCoParentResponseDto> {
|
||||||
|
return this.authService.addCoParentStaff(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
@Post(':id/enfants/:enfantId')
|
@Post(':id/enfants/:enfantId')
|
||||||
@ApiOperation({ summary: 'Rattacher un enfant à un parent — ticket #115' })
|
@ApiOperation({ summary: 'Rattacher un enfant à un parent — ticket #115' })
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -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`
|
||||||
@@ -215,8 +215,13 @@ class EnfantDossier {
|
|||||||
factory EnfantDossier.fromJson(Map<String, dynamic> json) {
|
factory EnfantDossier.fromJson(Map<String, dynamic> json) {
|
||||||
final rawPhoto = json['photo_url'] ?? json['photoUrl'];
|
final rawPhoto = json['photo_url'] ?? json['photoUrl'];
|
||||||
final resolvedPhoto = _optionalPhotoUrl(rawPhoto);
|
final resolvedPhoto = _optionalPhotoUrl(rawPhoto);
|
||||||
|
final rawId = json['id'] ??
|
||||||
|
json['enfant_id'] ??
|
||||||
|
json['enfantId'] ??
|
||||||
|
json['child_id'] ??
|
||||||
|
json['childId'];
|
||||||
return EnfantDossier(
|
return EnfantDossier(
|
||||||
id: json['id']?.toString() ?? '',
|
id: rawId?.toString().trim() ?? '',
|
||||||
firstName: (json['first_name'] ?? json['prenom'])?.toString(),
|
firstName: (json['first_name'] ?? json['prenom'])?.toString(),
|
||||||
lastName: (json['last_name'] ?? json['nom'])?.toString(),
|
lastName: (json['last_name'] ?? json['nom'])?.toString(),
|
||||||
birthDate: json['birth_date']?.toString(),
|
birthDate: json['birth_date']?.toString(),
|
||||||
|
|||||||
@@ -525,17 +525,58 @@ class UserService {
|
|||||||
return enfant.copyWith(parentLinks: enriched);
|
return enfant.copyWith(parentLinks: enriched);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mise à jour enfant. Avec [photoBytes] : multipart (champ `photo`), sinon JSON.
|
||||||
static Future<EnfantAdminModel> updateEnfant({
|
static Future<EnfantAdminModel> updateEnfant({
|
||||||
required String enfantId,
|
required String enfantId,
|
||||||
required Map<String, dynamic> body,
|
required Map<String, dynamic> body,
|
||||||
|
List<int>? photoBytes,
|
||||||
|
String? photoFilename,
|
||||||
}) async {
|
}) async {
|
||||||
final response = await http.patch(
|
final id = enfantId.trim();
|
||||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
|
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(),
|
headers: await _headers(),
|
||||||
body: jsonEncode(body),
|
body: jsonEncode(body),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
if (response.statusCode != 200) {
|
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(
|
final enfant = EnfantAdminModel.fromJson(
|
||||||
jsonDecode(response.body) as Map<String, dynamic>,
|
jsonDecode(response.body) as Map<String, dynamic>,
|
||||||
@@ -645,6 +686,57 @@ class UserService {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ajout d’un co-parent sur foyer mono-parent (staff, #135).
|
||||||
|
/// `POST /parents/:pivotUserId/co-parent` — succès 201.
|
||||||
|
static Future<Map<String, dynamic>> addCoParent(
|
||||||
|
String pivotUserId, {
|
||||||
|
required Map<String, dynamic> 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 <String, dynamic>{};
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is Map) {
|
||||||
|
return Map<String, dynamic>.from(decoded);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return <String, dynamic>{};
|
||||||
|
}
|
||||||
|
|
||||||
|
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).
|
/// Création dossier famille actif côté staff (#129).
|
||||||
/// `POST /parents/dossier` — body aligné sur register parent complet.
|
/// `POST /parents/dossier` — body aligné sur register parent complet.
|
||||||
/// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur, par parent créé).
|
/// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur, par parent créé).
|
||||||
|
|||||||
@@ -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/auth_network_image.dart';
|
||||||
import 'package:p_tits_pas/widgets/common/identity_block.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 {
|
class AmDossierWizard extends StatefulWidget {
|
||||||
final AmDossierWizardMode mode;
|
final AmDossierWizardMode mode;
|
||||||
final DossierAM? dossier;
|
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 isCreate => mode == AmDossierWizardMode.create;
|
||||||
|
bool get isEdit => mode == AmDossierWizardMode.edit;
|
||||||
|
|
||||||
/// Hauteur corps modale AM — dérivée de [ValidationFormMetrics] (4 lignes).
|
/// Hauteur corps modale AM — dérivée de [ValidationFormMetrics] (4 lignes).
|
||||||
static double get shellBodyHeight =>
|
static double get shellBodyHeight =>
|
||||||
@@ -118,9 +136,11 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
|||||||
String? _photoFilename;
|
String? _photoFilename;
|
||||||
|
|
||||||
bool get _isCreate => widget.isCreate;
|
bool get _isCreate => widget.isCreate;
|
||||||
|
bool get _isEdit => widget.isEdit;
|
||||||
|
bool get _isEditable => _isCreate || _isEdit;
|
||||||
DossierAM get _dossier => widget.dossier!;
|
DossierAM get _dossier => widget.dossier!;
|
||||||
bool get _isEnAttente =>
|
bool get _isEnAttente =>
|
||||||
!_isCreate && _dossier.user.statut == 'en_attente';
|
!_isCreate && !_isEdit && _dossier.user.statut == 'en_attente';
|
||||||
|
|
||||||
static String _v(String? s) =>
|
static String _v(String? s) =>
|
||||||
(s != null && s.trim().isNotEmpty) ? s.trim() : '–';
|
(s != null && s.trim().isNotEmpty) ? s.trim() : '–';
|
||||||
@@ -151,9 +171,33 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
|||||||
_capaciteCtrl = TextEditingController(text: '1');
|
_capaciteCtrl = TextEditingController(text: '1');
|
||||||
_placesCtrl = TextEditingController(text: '1');
|
_placesCtrl = TextEditingController(text: '1');
|
||||||
_presentationCtrl = TextEditingController();
|
_presentationCtrl = TextEditingController();
|
||||||
|
if (_isEdit) {
|
||||||
|
_prefillFromDossier();
|
||||||
|
}
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_nomCtrl.dispose();
|
_nomCtrl.dispose();
|
||||||
@@ -373,8 +417,8 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _validateStep1() {
|
String? _validateStep1({required bool requirePhoto}) {
|
||||||
if (_photoBytes == null || _photoBytes!.isEmpty) {
|
if (requirePhoto && (_photoBytes == null || _photoBytes!.isEmpty)) {
|
||||||
return 'Une photo de profil est requise.';
|
return 'Une photo de profil est requise.';
|
||||||
}
|
}
|
||||||
final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text);
|
final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text);
|
||||||
@@ -407,12 +451,12 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String? _validateCurrentStep() {
|
String? _validateCurrentStep() {
|
||||||
if (!_isCreate) return null;
|
if (!_isEditable) return null;
|
||||||
switch (_step) {
|
switch (_step) {
|
||||||
case 0:
|
case 0:
|
||||||
return _validateStep0();
|
return _validateStep0();
|
||||||
case 1:
|
case 1:
|
||||||
return _validateStep1();
|
return _validateStep1(requirePhoto: _isCreate);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -511,7 +555,7 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final err1 = _validateStep1();
|
final err1 = _validateStep1(requirePhoto: true);
|
||||||
if (err1 != null) {
|
if (err1 != null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(err1), backgroundColor: Colors.red.shade700),
|
SnackBar(content: Text(err1), backgroundColor: Colors.red.shade700),
|
||||||
@@ -554,6 +598,94 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_showRefusForm) {
|
if (_showRefusForm) {
|
||||||
@@ -587,7 +719,7 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStep0() {
|
Widget _buildStep0() {
|
||||||
if (_isCreate) {
|
if (_isEditable) {
|
||||||
return IdentityBlock.editable(
|
return IdentityBlock.editable(
|
||||||
title: 'Identité et coordonnées',
|
title: 'Identité et coordonnées',
|
||||||
nomController: _nomCtrl,
|
nomController: _nomCtrl,
|
||||||
@@ -620,7 +752,7 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
|||||||
.clamp(_photoColumnMinWidth, 360.0);
|
.clamp(_photoColumnMinWidth, 360.0);
|
||||||
if (photoW > maxPhotoW) photoW = maxPhotoW;
|
if (photoW > maxPhotoW) photoW = maxPhotoW;
|
||||||
|
|
||||||
final form = _isCreate
|
final form = _isEditable
|
||||||
? _buildCreateProFields()
|
? _buildCreateProFields()
|
||||||
: ValidationDetailSection(
|
: ValidationDetailSection(
|
||||||
title: 'Dossier professionnel',
|
title: 'Dossier professionnel',
|
||||||
@@ -628,20 +760,25 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
|||||||
rowLayout: _photoProRowLayout,
|
rowLayout: _photoProRowLayout,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Row(
|
final Widget photo;
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
if (_isCreate) {
|
||||||
children: [
|
photo = AdminAmPhotoFrame(
|
||||||
SizedBox(
|
|
||||||
width: photoW,
|
|
||||||
child: _isCreate
|
|
||||||
? AdminAmPhotoFrame(
|
|
||||||
imageBytes: _photoBytes,
|
imageBytes: _photoBytes,
|
||||||
onTap: _pickPhoto,
|
onTap: _pickPhoto,
|
||||||
onClear: _photoBytes != null ? _clearPhoto : null,
|
onClear: _photoBytes != null ? _clearPhoto : null,
|
||||||
emptyLabel: 'Ajouter une photo',
|
emptyLabel: 'Ajouter une photo',
|
||||||
)
|
);
|
||||||
: _buildPhotoSectionReview(_dossier.user),
|
} 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: photo),
|
||||||
const SizedBox(width: _photoProGap),
|
const SizedBox(width: _photoProGap),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Align(
|
child: Align(
|
||||||
@@ -721,7 +858,7 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStep2() {
|
Widget _buildStep2() {
|
||||||
if (_isCreate) {
|
if (_isEditable) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
@@ -832,6 +969,12 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
|||||||
onPressed: _submitting ? null : _createAndValidate,
|
onPressed: _submitting ? null : _createAndValidate,
|
||||||
child: Text(_submitting ? 'Envoi...' : 'Créer et valider'),
|
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) ...[
|
] else if (_isEnAttente) ...[
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onPressed: _submitting ? null : _refuser,
|
onPressed: _submitting ? null : _refuser,
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (context) => ValidationDossierModal(
|
builder: (context) => ValidationDossierModal(
|
||||||
numeroDossier: num,
|
numeroDossier: num,
|
||||||
|
openAsEdit: true,
|
||||||
onClose: () => Navigator.of(context).pop(),
|
onClose: () => Navigator.of(context).pop(),
|
||||||
onSuccess: () {
|
onSuccess: () {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
|
|||||||
@@ -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/auth_network_image.dart';
|
||||||
import 'package:p_tits_pas/widgets/common/identity_block.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 {
|
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 prenomCtrl = TextEditingController();
|
||||||
final TextEditingController nomCtrl = TextEditingController();
|
final TextEditingController nomCtrl = TextEditingController();
|
||||||
final TextEditingController dateCtrl = TextEditingController();
|
final TextEditingController dateCtrl = TextEditingController();
|
||||||
@@ -34,6 +39,8 @@ class _CreateChild {
|
|||||||
Uint8List? photoBytes;
|
Uint8List? photoBytes;
|
||||||
String? photoFilename;
|
String? photoFilename;
|
||||||
|
|
||||||
|
bool get hasExistingId => (existingChildId ?? '').trim().isNotEmpty;
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
prenomCtrl.dispose();
|
prenomCtrl.dispose();
|
||||||
nomCtrl.dispose();
|
nomCtrl.dispose();
|
||||||
@@ -41,8 +48,7 @@ class _CreateChild {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wizard dossier famille — modes [review] (validation dossier en attente, ticket #107)
|
/// Wizard dossier famille — [review] (#107), [create] (#129), [edit] (#135).
|
||||||
/// et [create] (création dossier famille actif par le staff, ticket #129).
|
|
||||||
class ParentDossierWizard extends StatefulWidget {
|
class ParentDossierWizard extends StatefulWidget {
|
||||||
final ParentDossierWizardMode mode;
|
final ParentDossierWizardMode mode;
|
||||||
final DossierFamille? dossier;
|
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 isCreate => mode == ParentDossierWizardMode.create;
|
||||||
|
bool get isEdit => mode == ParentDossierWizardMode.edit;
|
||||||
|
|
||||||
/// Hauteur corps modale famille — 4 lignes TF + marge pour le bandeau switch.
|
/// Hauteur corps modale famille — 4 lignes TF + marge pour le bandeau switch.
|
||||||
static double get shellBodyHeight =>
|
static double get shellBodyHeight =>
|
||||||
@@ -141,16 +165,27 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
final List<_CreateChild> _children = [];
|
final List<_CreateChild> _children = [];
|
||||||
late final TextEditingController _presentationCtrl;
|
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<String> _removedEnfantIds = [];
|
||||||
|
|
||||||
bool get _isCreate => widget.isCreate;
|
bool get _isCreate => widget.isCreate;
|
||||||
|
bool get _isEdit => widget.isEdit;
|
||||||
|
bool get _isEditable => _isCreate || _isEdit;
|
||||||
DossierFamille get _dossier => widget.dossier!;
|
DossierFamille get _dossier => widget.dossier!;
|
||||||
|
|
||||||
bool get _isEnAttente => !_isCreate && _dossier.isEnAttente;
|
bool get _isEnAttente => !_isCreate && !_isEdit && _dossier.isEnAttente;
|
||||||
|
|
||||||
String? get _firstParentId {
|
String? get _firstParentId {
|
||||||
if (_isCreate) return null;
|
if (_isCreate) return null;
|
||||||
return _dossier.parents.isNotEmpty ? _dossier.parents.first.id : 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) =>
|
static String _v(String? s) =>
|
||||||
(s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini';
|
(s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini';
|
||||||
|
|
||||||
@@ -184,11 +219,81 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
_presentationCtrl = TextEditingController();
|
_presentationCtrl = TextEditingController();
|
||||||
if (_isCreate) {
|
if (_isCreate) {
|
||||||
_children.add(_CreateChild());
|
_children.add(_CreateChild());
|
||||||
|
} else if (_isEdit) {
|
||||||
|
_prefillFromDossier();
|
||||||
}
|
}
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_enfantsScrollController.removeListener(_syncEnfantsScrollFades);
|
_enfantsScrollController.removeListener(_syncEnfantsScrollFades);
|
||||||
@@ -291,7 +396,7 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStep0() {
|
Widget _buildStep0() {
|
||||||
if (_isCreate) {
|
if (_isEditable) {
|
||||||
return IdentityBlock.editable(
|
return IdentityBlock.editable(
|
||||||
title: 'Parent principal',
|
title: 'Parent principal',
|
||||||
nomController: _p1NomCtrl,
|
nomController: _p1NomCtrl,
|
||||||
@@ -310,13 +415,15 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStep1() {
|
Widget _buildStep1() {
|
||||||
if (_isCreate) {
|
if (_isEditable) {
|
||||||
return _buildCoParentStepCreate();
|
return _buildCoParentStepEditable(
|
||||||
|
allowToggle: !_hadExistingCoParent,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return _buildParent2Step();
|
return _buildParent2Step();
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCoParentStepCreate() {
|
Widget _buildCoParentStepEditable({required bool allowToggle}) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
@@ -330,6 +437,7 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
color: Colors.black87,
|
color: Colors.black87,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (allowToggle) ...[
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
const Text(
|
const Text(
|
||||||
'Ajouter un co-parent',
|
'Ajouter un co-parent',
|
||||||
@@ -344,6 +452,7 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (!_hasCoParent)
|
if (!_hasCoParent)
|
||||||
@@ -539,7 +648,7 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEnfantsStep() {
|
Widget _buildEnfantsStep() {
|
||||||
if (_isCreate) {
|
if (_isCreate || _isEdit) {
|
||||||
return _buildEnfantsStepCreate();
|
return _buildEnfantsStepCreate();
|
||||||
}
|
}
|
||||||
final enfants = _dossier.enfants;
|
final enfants = _dossier.enfants;
|
||||||
@@ -618,6 +727,10 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
if (_children.length <= 1) return;
|
if (_children.length <= 1) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
final removed = _children.removeAt(index);
|
final removed = _children.removeAt(index);
|
||||||
|
final existingId = (removed.existingChildId ?? '').trim();
|
||||||
|
if (existingId.isNotEmpty) {
|
||||||
|
_removedEnfantIds.add(existingId);
|
||||||
|
}
|
||||||
removed.dispose();
|
removed.dispose();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -706,7 +819,8 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
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(
|
child: ValidationLabeledField(
|
||||||
label: 'Prénom',
|
label: 'Prénom',
|
||||||
field: ValidationEditableField(
|
field: ValidationEditableField(
|
||||||
@@ -747,6 +861,9 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
width: pw,
|
width: pw,
|
||||||
height: ph,
|
height: ph,
|
||||||
child: AdminAmPhotoFrame(
|
child: AdminAmPhotoFrame(
|
||||||
|
photoUrl: child.photoBytes == null
|
||||||
|
? child.existingPhotoUrl
|
||||||
|
: null,
|
||||||
imageBytes: child.photoBytes,
|
imageBytes: child.photoBytes,
|
||||||
onTap: () => _pickChildPhoto(index),
|
onTap: () => _pickChildPhoto(index),
|
||||||
onClear: child.photoBytes != null
|
onClear: child.photoBytes != null
|
||||||
@@ -833,19 +950,28 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
),
|
),
|
||||||
if (canRemove)
|
if (canRemove)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 4,
|
top: 6,
|
||||||
right: 4,
|
right: 6,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.white,
|
||||||
child: IconButton(
|
elevation: 1,
|
||||||
tooltip: 'Retirer cet enfant',
|
shape: const CircleBorder(),
|
||||||
visualDensity: VisualDensity.compact,
|
clipBehavior: Clip.antiAlias,
|
||||||
padding: EdgeInsets.zero,
|
child: InkWell(
|
||||||
constraints:
|
customBorder: const CircleBorder(),
|
||||||
const BoxConstraints(minWidth: 32, minHeight: 32),
|
onTap: () => _removeChild(index),
|
||||||
icon: Icon(Icons.close,
|
child: Tooltip(
|
||||||
size: 18, color: Colors.grey.shade700),
|
message: 'Retirer cet enfant',
|
||||||
onPressed: () => _removeChild(index),
|
child: SizedBox(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
child: Icon(
|
||||||
|
Icons.close,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.grey.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1264,6 +1390,18 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String? _validateCurrentStep() {
|
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;
|
if (!_isCreate) return null;
|
||||||
switch (_step) {
|
switch (_step) {
|
||||||
case 0:
|
case 0:
|
||||||
@@ -1440,6 +1578,299 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _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<void> _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 = <String, dynamic>{
|
||||||
|
'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<String, dynamic> _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 = <String, dynamic>{
|
||||||
|
'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<String, String> dossierIdByIdentity,
|
||||||
|
Set<String> 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<void> _saveEnfantsEdit(String pivotUserId) async {
|
||||||
|
// Index id connus du GET (ne jamais POST pour ceux-là).
|
||||||
|
final dossierIdByIdentity = <String, String>{};
|
||||||
|
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 = <String>{..._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() {
|
Widget _buildNavigation() {
|
||||||
if (_step == 3) {
|
if (_step == 3) {
|
||||||
return Row(
|
return Row(
|
||||||
@@ -1463,6 +1894,12 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
onPressed: _submitting ? null : _createAndValidate,
|
onPressed: _submitting ? null : _createAndValidate,
|
||||||
child: Text(_submitting ? 'Envoi...' : 'Créer et valider'),
|
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) ...[
|
] else if (_isEnAttente && _firstParentId != null) ...[
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onPressed: _submitting ? null : _refuser,
|
onPressed: _submitting ? null : _refuser,
|
||||||
|
|||||||
@@ -2,20 +2,25 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.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/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_am_wizard.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_family_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 {
|
class ValidationDossierModal extends StatefulWidget {
|
||||||
final String numeroDossier;
|
final String numeroDossier;
|
||||||
final VoidCallback onClose;
|
final VoidCallback onClose;
|
||||||
final VoidCallback? onSuccess;
|
final VoidCallback? onSuccess;
|
||||||
|
/// Liste Dossiers actifs → mode edit (#135). Pending reste en review (défaut).
|
||||||
|
final bool openAsEdit;
|
||||||
|
|
||||||
const ValidationDossierModal({
|
const ValidationDossierModal({
|
||||||
super.key,
|
super.key,
|
||||||
required this.numeroDossier,
|
required this.numeroDossier,
|
||||||
required this.onClose,
|
required this.onClose,
|
||||||
this.onSuccess,
|
this.onSuccess,
|
||||||
|
this.openAsEdit = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -77,12 +82,12 @@ class _ValidationDossierModalState extends State<ValidationDossierModal> {
|
|||||||
|
|
||||||
/// Largeur modale = 1,5 × 620.
|
/// Largeur modale = 1,5 × 620.
|
||||||
static const double _modalWidth = 930; // 620 * 1.5
|
static const double _modalWidth = 930; // 620 * 1.5
|
||||||
static const double _familyBodyHeight = 435;
|
|
||||||
|
|
||||||
double get _bodyHeight {
|
double get _bodyHeight {
|
||||||
final d = _dossier;
|
final d = _dossier;
|
||||||
if (d != null && d.isAm) return AmDossierWizard.shellBodyHeight;
|
if (d != null && d.isAm) return AmDossierWizard.shellBodyHeight;
|
||||||
return _familyBodyHeight;
|
// Aligné create (#129) / edit (#135) — évite overflow IdentityBlock (8px).
|
||||||
|
return ParentDossierWizard.shellBodyHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -162,6 +167,14 @@ class _ValidationDossierModalState extends State<ValidationDossierModal> {
|
|||||||
}
|
}
|
||||||
final d = _dossier!;
|
final d = _dossier!;
|
||||||
if (d.isAm) {
|
if (d.isAm) {
|
||||||
|
if (widget.openAsEdit) {
|
||||||
|
return AmDossierWizard.edit(
|
||||||
|
dossier: d.asAm,
|
||||||
|
onClose: widget.onClose,
|
||||||
|
onSuccess: _onSuccess,
|
||||||
|
onStepChanged: _onStepChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
return ValidationAmWizard(
|
return ValidationAmWizard(
|
||||||
dossier: d.asAm,
|
dossier: d.asAm,
|
||||||
onClose: widget.onClose,
|
onClose: widget.onClose,
|
||||||
@@ -169,6 +182,14 @@ class _ValidationDossierModalState extends State<ValidationDossierModal> {
|
|||||||
onStepChanged: _onStepChanged,
|
onStepChanged: _onStepChanged,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (widget.openAsEdit) {
|
||||||
|
return ParentDossierWizard.edit(
|
||||||
|
dossier: d.asFamily,
|
||||||
|
onClose: widget.onClose,
|
||||||
|
onSuccess: _onSuccess,
|
||||||
|
onStepChanged: _onStepChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
return ValidationFamilyWizard(
|
return ValidationFamilyWizard(
|
||||||
dossier: d.asFamily,
|
dossier: d.asFamily,
|
||||||
onClose: widget.onClose,
|
onClose: widget.onClose,
|
||||||
|
|||||||
Reference in New Issue
Block a user