Compare commits
8 Commits
fix/151-ge
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ae610733cc | |||
| 846afed86c | |||
| 99a6c17c23 | |||
| 04f49cb62f | |||
| 3c7f4f6e16 | |||
| dcd407a3da | |||
| fde63f8e72 | |||
| 1f8f1b9507 |
@ -89,6 +89,9 @@
|
|||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^src/(.*)$": "<rootDir>/$1"
|
||||||
|
},
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": [
|
||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,20 +1,69 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { AssistantesMaternellesController } from './assistantes_maternelles.controller';
|
import { AssistantesMaternellesController } from './assistantes_maternelles.controller';
|
||||||
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
|
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
|
||||||
|
import { AuthService } from '../auth/auth.service';
|
||||||
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
|
|
||||||
describe('AssistantesMaternellesController', () => {
|
describe('AssistantesMaternellesController', () => {
|
||||||
let controller: AssistantesMaternellesController;
|
let controller: AssistantesMaternellesController;
|
||||||
|
const authServiceMock = {
|
||||||
|
createAmDossierStaff: jest.fn(),
|
||||||
|
};
|
||||||
|
const amServiceMock = {};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [AssistantesMaternellesController],
|
controllers: [AssistantesMaternellesController],
|
||||||
providers: [AssistantesMaternellesService],
|
providers: [
|
||||||
}).compile();
|
{ provide: AssistantesMaternellesService, useValue: amServiceMock },
|
||||||
|
{ provide: AuthService, useValue: authServiceMock },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.overrideGuard(RolesGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.compile();
|
||||||
|
|
||||||
controller = module.get<AssistantesMaternellesController>(AssistantesMaternellesController);
|
controller = module.get<AssistantesMaternellesController>(AssistantesMaternellesController);
|
||||||
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(controller).toBeDefined();
|
expect(controller).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('createDossier delegates to authService.createAmDossierStaff with CGU accepted', async () => {
|
||||||
|
authServiceMock.createAmDossierStaff.mockResolvedValue({
|
||||||
|
message: 'ok',
|
||||||
|
user_id: 'u1',
|
||||||
|
statut: 'actif',
|
||||||
|
numero_dossier: '2026-000001',
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
email: 'am.staff@test.fr',
|
||||||
|
prenom: 'Marie',
|
||||||
|
nom: 'TEST',
|
||||||
|
telephone: '0689567890',
|
||||||
|
consentement_photo: false,
|
||||||
|
lieu_naissance_ville: 'Paris',
|
||||||
|
lieu_naissance_pays: 'France',
|
||||||
|
nir: '285017512345678',
|
||||||
|
numero_agrement: 'AGR-TEST-001',
|
||||||
|
capacite_accueil: 3,
|
||||||
|
places_disponibles: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await controller.createDossier(body as any);
|
||||||
|
expect(authServiceMock.createAmDossierStaff).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
email: body.email,
|
||||||
|
acceptation_cgu: true,
|
||||||
|
acceptation_privacy: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.numero_dossier).toBe('2026-000001');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Delete,
|
Delete,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
|
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
|
||||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
@ -16,17 +18,49 @@ import { RoleType, Users } from 'src/entities/users.entity';
|
|||||||
import { CreateAssistanteDto } from '../user/dto/create_assistante.dto';
|
import { CreateAssistanteDto } from '../user/dto/create_assistante.dto';
|
||||||
import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto';
|
import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto';
|
||||||
import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto';
|
import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto';
|
||||||
|
import { StaffCreateAmDossierDto } from './dto/staff-create-am-dossier.dto';
|
||||||
|
import { StaffCreateAmDossierResponseDto } from './dto/staff-create-am-dossier-response.dto';
|
||||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
import { User } from 'src/common/decorators/user.decorator';
|
import { User } from 'src/common/decorators/user.decorator';
|
||||||
import { mapAmForApi, mapAmsForApi } from './assistantes_maternelles.mapper';
|
import { mapAmForApi, mapAmsForApi } from './assistantes_maternelles.mapper';
|
||||||
|
import { AuthService } from '../auth/auth.service';
|
||||||
|
import { RegisterAMCompletDto } from '../auth/dto/register-am-complet.dto';
|
||||||
|
|
||||||
@ApiTags("Assistantes Maternelles")
|
@ApiTags("Assistantes Maternelles")
|
||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
@UseGuards(AuthGuard, RolesGuard)
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
@Controller('assistantes-maternelles')
|
@Controller('assistantes-maternelles')
|
||||||
export class AssistantesMaternellesController {
|
export class AssistantesMaternellesController {
|
||||||
constructor(private readonly assistantesMaternellesService: AssistantesMaternellesService) { }
|
constructor(
|
||||||
|
private readonly assistantesMaternellesService: AssistantesMaternellesService,
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
|
@Post('dossier')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Créer un dossier AM complet (staff) — ticket #156',
|
||||||
|
description:
|
||||||
|
'Crée user + fiche AM avec statut actif, n° dossier, et envoie l’e-mail de création de mot de passe. ' +
|
||||||
|
'Ne pas utiliser POST /auth/register/am depuis le dashboard.',
|
||||||
|
})
|
||||||
|
@ApiBody({ type: StaffCreateAmDossierDto })
|
||||||
|
@ApiResponse({ status: 201, type: StaffCreateAmDossierResponseDto })
|
||||||
|
@ApiResponse({ status: 400, description: 'Validation métier / NIR' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Rôle non autorisé' })
|
||||||
|
@ApiResponse({ status: 409, description: 'Email / NIR / agrément déjà pris' })
|
||||||
|
async createDossier(
|
||||||
|
@Body() dto: StaffCreateAmDossierDto,
|
||||||
|
): Promise<StaffCreateAmDossierResponseDto> {
|
||||||
|
const registerDto = {
|
||||||
|
...dto,
|
||||||
|
acceptation_cgu: true,
|
||||||
|
acceptation_privacy: true,
|
||||||
|
} as RegisterAMCompletDto;
|
||||||
|
return this.authService.createAmDossierStaff(registerDto);
|
||||||
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
||||||
@ApiOperation({ summary: 'Créer nounou' })
|
@ApiOperation({ summary: 'Créer nounou' })
|
||||||
|
|||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
|
/** Réponse 201 POST /assistantes-maternelles/dossier (#156). */
|
||||||
|
export class StaffCreateAmDossierResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
user_id: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
enum: StatutUtilisateurType,
|
||||||
|
example: StatutUtilisateurType.ACTIF,
|
||||||
|
})
|
||||||
|
statut: StatutUtilisateurType;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: '2026-000042',
|
||||||
|
description: 'Numéro de dossier attribué',
|
||||||
|
})
|
||||||
|
numero_dossier: string;
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
import { ApiPropertyOptional, OmitType } from '@nestjs/swagger';
|
||||||
|
import { IsBoolean, IsOptional } from 'class-validator';
|
||||||
|
import { RegisterAMCompletDto } from 'src/routes/auth/dto/register-am-complet.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Création dossier AM par staff (#156).
|
||||||
|
* Mêmes champs que l'inscription publique, sans CGU/privacy obligatoires
|
||||||
|
* (acceptées côté serveur pour le compte du gestionnaire).
|
||||||
|
*/
|
||||||
|
export class StaffCreateAmDossierDto extends OmitType(RegisterAMCompletDto, [
|
||||||
|
'acceptation_cgu',
|
||||||
|
'acceptation_privacy',
|
||||||
|
] as const) {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Ignoré côté staff (CGU acceptées serveur). Conservé pour compat éventuelle.',
|
||||||
|
default: true,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
acceptation_cgu?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Ignoré côté staff (privacy acceptée serveur).',
|
||||||
|
default: true,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
acceptation_privacy?: boolean;
|
||||||
|
}
|
||||||
@ -645,11 +645,28 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inscription Assistante Maternelle COMPLÈTE - Un seul endpoint (identité + pro + photo + CGU)
|
* Cœur partagé création dossier AM (#156).
|
||||||
* Crée User (role AM) + entrée assistantes_maternelles, token création MDP
|
* - public : statut en_attente + mail pending
|
||||||
|
* - staff : statut actif + mail création MDP
|
||||||
*/
|
*/
|
||||||
async inscrireAMComplet(dto: RegisterAMCompletDto) {
|
async createAmDossier(
|
||||||
if (!dto.acceptation_cgu || !dto.acceptation_privacy) {
|
dto: RegisterAMCompletDto,
|
||||||
|
options: {
|
||||||
|
statut: StatutUtilisateurType;
|
||||||
|
sendPendingEmail: boolean;
|
||||||
|
sendPasswordSetupEmail: boolean;
|
||||||
|
requireCgu: boolean;
|
||||||
|
logContext?: string;
|
||||||
|
},
|
||||||
|
): Promise<{
|
||||||
|
message: string;
|
||||||
|
user_id: string;
|
||||||
|
statut: StatutUtilisateurType;
|
||||||
|
numero_dossier: string;
|
||||||
|
}> {
|
||||||
|
const logCtx = options.logContext ?? 'createAmDossier';
|
||||||
|
|
||||||
|
if (options.requireCgu && (!dto.acceptation_cgu || !dto.acceptation_privacy)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"L'acceptation des CGU et de la politique de confidentialité est obligatoire",
|
"L'acceptation des CGU et de la politique de confidentialité est obligatoire",
|
||||||
);
|
);
|
||||||
@ -669,8 +686,7 @@ export class AuthService {
|
|||||||
throw new BadRequestException(nirValidation.error || 'NIR invalide');
|
throw new BadRequestException(nirValidation.error || 'NIR invalide');
|
||||||
}
|
}
|
||||||
if (nirValidation.warning) {
|
if (nirValidation.warning) {
|
||||||
// Warning uniquement : on ne bloque pas (AM souvent étrangères, DOM-TOM, Corse)
|
console.warn(`[${logCtx}] NIR warning:`, nirValidation.warning, 'email=', dto.email);
|
||||||
console.warn('[inscrireAMComplet] NIR warning:', nirValidation.warning, 'email=', dto.email);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const existe = await this.usersService.findByEmailOrNull(dto.email);
|
const existe = await this.usersService.findByEmailOrNull(dto.email);
|
||||||
@ -721,79 +737,142 @@ export class AuthService {
|
|||||||
let resultat: { user: Users };
|
let resultat: { user: Users };
|
||||||
try {
|
try {
|
||||||
resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
||||||
const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager);
|
const { numero: numeroDossier } =
|
||||||
|
await this.numeroDossierService.getNextNumeroDossier(manager);
|
||||||
|
|
||||||
const user = manager.create(Users, {
|
const user = manager.create(Users, {
|
||||||
email: dto.email,
|
email: dto.email,
|
||||||
prenom: dto.prenom,
|
prenom: dto.prenom,
|
||||||
nom: dto.nom,
|
nom: dto.nom,
|
||||||
role: RoleType.ASSISTANTE_MATERNELLE,
|
role: RoleType.ASSISTANTE_MATERNELLE,
|
||||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
statut: options.statut,
|
||||||
telephone: dto.telephone,
|
telephone: dto.telephone,
|
||||||
adresse: dto.adresse,
|
adresse: dto.adresse,
|
||||||
code_postal: dto.code_postal,
|
code_postal: dto.code_postal,
|
||||||
ville: dto.ville,
|
ville: dto.ville,
|
||||||
token_creation_mdp: tokenCreationMdp,
|
token_creation_mdp: tokenCreationMdp,
|
||||||
token_creation_mdp_expire_le: dateExpiration,
|
token_creation_mdp_expire_le: dateExpiration,
|
||||||
photo_url: urlPhoto ?? undefined,
|
photo_url: urlPhoto ?? undefined,
|
||||||
consentement_photo: dto.consentement_photo,
|
consentement_photo: dto.consentement_photo,
|
||||||
date_consentement_photo: dateConsentementPhoto,
|
date_consentement_photo: dateConsentementPhoto,
|
||||||
date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined,
|
date_naissance: dto.date_naissance
|
||||||
lieu_naissance_ville: dto.lieu_naissance_ville,
|
? new Date(dto.date_naissance)
|
||||||
lieu_naissance_pays: dto.lieu_naissance_pays,
|
: undefined,
|
||||||
numero_dossier: numeroDossier,
|
lieu_naissance_ville: dto.lieu_naissance_ville,
|
||||||
|
lieu_naissance_pays: dto.lieu_naissance_pays,
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
|
});
|
||||||
|
const userEnregistre = await manager.save(Users, user);
|
||||||
|
|
||||||
|
const amRepo = manager.getRepository(AssistanteMaternelle);
|
||||||
|
const am = amRepo.create({
|
||||||
|
user_id: userEnregistre.id,
|
||||||
|
approval_number: dto.numero_agrement,
|
||||||
|
nir: nirNormalized,
|
||||||
|
max_children: dto.capacite_accueil,
|
||||||
|
places_available: dto.places_disponibles,
|
||||||
|
biography: dto.biographie,
|
||||||
|
residence_city: dto.ville ?? undefined,
|
||||||
|
agreement_date: dto.date_agrement
|
||||||
|
? new Date(dto.date_agrement)
|
||||||
|
: undefined,
|
||||||
|
available: true,
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
|
});
|
||||||
|
await amRepo.save(am);
|
||||||
|
|
||||||
|
return { user: userEnregistre };
|
||||||
});
|
});
|
||||||
const userEnregistre = await manager.save(Users, user);
|
|
||||||
|
|
||||||
const amRepo = manager.getRepository(AssistanteMaternelle);
|
|
||||||
const am = amRepo.create({
|
|
||||||
user_id: userEnregistre.id,
|
|
||||||
approval_number: dto.numero_agrement,
|
|
||||||
nir: nirNormalized,
|
|
||||||
max_children: dto.capacite_accueil,
|
|
||||||
places_available: dto.places_disponibles,
|
|
||||||
biography: dto.biographie,
|
|
||||||
residence_city: dto.ville ?? undefined,
|
|
||||||
agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined,
|
|
||||||
available: true,
|
|
||||||
numero_dossier: numeroDossier,
|
|
||||||
});
|
|
||||||
await amRepo.save(am);
|
|
||||||
|
|
||||||
return { user: userEnregistre };
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (this.isPostgresUniqueViolation(err)) {
|
if (this.isPostgresUniqueViolation(err)) {
|
||||||
throw new ConflictException('Un compte avec cet email existe déjà (contrainte unique en base).');
|
throw new ConflictException(
|
||||||
|
'Un compte avec cet email existe déjà (contrainte unique en base).',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const numeroDossier = resultat.user.numero_dossier ?? '';
|
const numeroDossier = resultat.user.numero_dossier ?? '';
|
||||||
|
|
||||||
try {
|
if (options.sendPendingEmail) {
|
||||||
await this.mailService.sendRegistrationPendingEmail(
|
try {
|
||||||
resultat.user.email,
|
await this.mailService.sendRegistrationPendingEmail(
|
||||||
resultat.user.prenom ?? '',
|
resultat.user.email,
|
||||||
resultat.user.nom ?? '',
|
resultat.user.prenom ?? '',
|
||||||
numeroDossier,
|
resultat.user.nom ?? '',
|
||||||
);
|
numeroDossier,
|
||||||
} catch (err) {
|
);
|
||||||
this.logger.error(
|
} catch (err) {
|
||||||
"[inscrireAMComplet] Échec envoi email d'accusé de réception (inscription conservée)",
|
this.logger.error(
|
||||||
err instanceof Error ? err.stack : String(err),
|
`[${logCtx}] Échec envoi email d'accusé de réception (inscription conservée)`,
|
||||||
);
|
err instanceof Error ? err.stack : String(err),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.sendPasswordSetupEmail) {
|
||||||
|
try {
|
||||||
|
await this.mailService.sendValidatedAccountPasswordSetupEmail(
|
||||||
|
{
|
||||||
|
email: resultat.user.email,
|
||||||
|
prenom: resultat.user.prenom ?? '',
|
||||||
|
nom: resultat.user.nom ?? '',
|
||||||
|
token: tokenCreationMdp,
|
||||||
|
numeroDossier,
|
||||||
|
},
|
||||||
|
'am',
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`[${logCtx}] Échec envoi email création MDP (dossier conservé)`,
|
||||||
|
err instanceof Error ? err.stack : String(err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
options.statut === StatutUtilisateurType.ACTIF
|
||||||
|
? 'Dossier AM créé et validé. Un e-mail de création de mot de passe a été envoyé.'
|
||||||
|
: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message:
|
message,
|
||||||
'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
|
|
||||||
user_id: resultat.user.id,
|
user_id: resultat.user.id,
|
||||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
statut: options.statut,
|
||||||
numero_dossier: numeroDossier,
|
numero_dossier: numeroDossier,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inscription Assistante Maternelle COMPLÈTE - Un seul endpoint (identité + pro + photo + CGU)
|
||||||
|
* Crée User (role AM) + entrée assistantes_maternelles, token création MDP
|
||||||
|
*/
|
||||||
|
async inscrireAMComplet(dto: RegisterAMCompletDto) {
|
||||||
|
return this.createAmDossier(dto, {
|
||||||
|
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||||
|
sendPendingEmail: true,
|
||||||
|
sendPasswordSetupEmail: false,
|
||||||
|
requireCgu: true,
|
||||||
|
logContext: 'inscrireAMComplet',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Création dossier AM par staff (#156) — statut actif + e-mail création MDP.
|
||||||
|
*/
|
||||||
|
async createAmDossierStaff(dto: RegisterAMCompletDto) {
|
||||||
|
return this.createAmDossier(dto, {
|
||||||
|
statut: StatutUtilisateurType.ACTIF,
|
||||||
|
sendPendingEmail: false,
|
||||||
|
sendPasswordSetupEmail: true,
|
||||||
|
requireCgu: false,
|
||||||
|
logContext: 'createAmDossierStaff',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sauvegarde une photo depuis base64 vers le système de fichiers
|
||||||
|
*/
|
||||||
/**
|
/**
|
||||||
* Sauvegarde une photo depuis base64 vers le système de fichiers
|
* Sauvegarde une photo depuis base64 vers le système de fichiers
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
@ -6,11 +7,23 @@ import {
|
|||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
IsUUID,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { GenreType, StatutEnfantType } from 'src/entities/children.entity';
|
import { GenreType, StatutEnfantType } from 'src/entities/children.entity';
|
||||||
|
|
||||||
|
/** Multipart envoie des strings ("true"/"false") — JSON envoie déjà des booleans. */
|
||||||
|
function toBoolean({ value }: { value: unknown }): boolean | unknown {
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const v = value.trim().toLowerCase();
|
||||||
|
if (v === 'true' || v === '1') return true;
|
||||||
|
if (v === 'false' || v === '0' || v === '') return false;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export class CreateEnfantsDto {
|
export class CreateEnfantsDto {
|
||||||
@ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE })
|
@ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE })
|
||||||
@IsEnum(StatutEnfantType)
|
@IsEnum(StatutEnfantType)
|
||||||
@ -52,6 +65,7 @@ export class CreateEnfantsDto {
|
|||||||
photo_url?: string;
|
photo_url?: string;
|
||||||
|
|
||||||
@ApiProperty({ default: false })
|
@ApiProperty({ default: false })
|
||||||
|
@Transform(toBoolean)
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
consent_photo: boolean;
|
consent_photo: boolean;
|
||||||
|
|
||||||
@ -61,6 +75,20 @@ export class CreateEnfantsDto {
|
|||||||
consent_photo_at?: string;
|
consent_photo_at?: string;
|
||||||
|
|
||||||
@ApiProperty({ default: false })
|
@ApiProperty({ default: false })
|
||||||
|
@Transform(toBoolean)
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
is_multiple: boolean;
|
is_multiple: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parent pivot du foyer — obligatoire pour staff (gestionnaire/admin).
|
||||||
|
* Ignoré / interdit en externe pour un PARENT (ticket #132).
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'UUID du parent pivot (staff only). Obligatoire pour GESTIONNAIRE / ADMIN / SUPER_ADMIN.',
|
||||||
|
format: 'uuid',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
parent_user_id?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +1,33 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
|
CallHandler,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
|
ExecutionContext,
|
||||||
Get,
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
UploadedFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
UploadedFile,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { ApiBearerAuth, ApiTags, ApiConsumes } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiConsumes,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import { diskStorage } from 'multer';
|
import { diskStorage } from 'multer';
|
||||||
import { extname } from 'path';
|
import { extname } from 'path';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
import { EnfantsService } from './enfants.service';
|
import { EnfantsService } from './enfants.service';
|
||||||
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
||||||
import { UpdateEnfantsDto } from './dto/update_enfants.dto';
|
import { UpdateEnfantsDto } from './dto/update_enfants.dto';
|
||||||
@ -24,6 +37,47 @@ import { AuthGuard } from 'src/common/guards/auth.guard';
|
|||||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
|
|
||||||
|
const photoMulterOptions = {
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: './uploads/photos',
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||||
|
const ext = extname(file.originalname);
|
||||||
|
cb(null, `enfant-${uniqueSuffix}${ext}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) {
|
||||||
|
return cb(new Error('Seules les images sont autorisées'), false);
|
||||||
|
}
|
||||||
|
cb(null, true);
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
fileSize: 5 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multer uniquement si Content-Type multipart (parent ou staff + photo).
|
||||||
|
* JSON sans photo (#132) passe sans interceptor fichier.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
class OptionalEnfantPhotoInterceptor implements NestInterceptor {
|
||||||
|
private readonly multipart = new (FileInterceptor(
|
||||||
|
'photo',
|
||||||
|
photoMulterOptions,
|
||||||
|
))();
|
||||||
|
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> | Promise<Observable<unknown>> {
|
||||||
|
const req = context.switchToHttp().getRequest();
|
||||||
|
const ct = String(req.headers['content-type'] ?? '');
|
||||||
|
if (!ct.includes('multipart/form-data')) {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
return this.multipart.intercept(context, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
@ApiTags('Enfants')
|
@ApiTags('Enfants')
|
||||||
@UseGuards(AuthGuard, RolesGuard)
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
@ -31,30 +85,27 @@ import { RolesGuard } from 'src/common/guards/roles.guard';
|
|||||||
export class EnfantsController {
|
export class EnfantsController {
|
||||||
constructor(private readonly enfantsService: EnfantsService) { }
|
constructor(private readonly enfantsService: EnfantsService) { }
|
||||||
|
|
||||||
@Roles(RoleType.PARENT)
|
@Roles(
|
||||||
@Post()
|
RoleType.PARENT,
|
||||||
@ApiConsumes('multipart/form-data')
|
RoleType.GESTIONNAIRE,
|
||||||
@UseInterceptors(
|
RoleType.ADMINISTRATEUR,
|
||||||
FileInterceptor('photo', {
|
RoleType.SUPER_ADMIN,
|
||||||
storage: diskStorage({
|
|
||||||
destination: './uploads/photos',
|
|
||||||
filename: (req, file, cb) => {
|
|
||||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
|
||||||
const ext = extname(file.originalname);
|
|
||||||
cb(null, `enfant-${uniqueSuffix}${ext}`);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
fileFilter: (req, file, cb) => {
|
|
||||||
if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) {
|
|
||||||
return cb(new Error('Seules les images sont autorisées'), false);
|
|
||||||
}
|
|
||||||
cb(null, true);
|
|
||||||
},
|
|
||||||
limits: {
|
|
||||||
fileSize: 5 * 1024 * 1024,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
@Post()
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Créer un enfant',
|
||||||
|
description:
|
||||||
|
'PARENT : multipart éventuel, rattache au compte connecté. ' +
|
||||||
|
'Staff : parent_user_id obligatoire ; JSON sans photo OK ; avec photo → multipart (champ fichier `photo`, max 5 Mo). Ticket #132.',
|
||||||
|
})
|
||||||
|
@ApiConsumes('application/json', 'multipart/form-data')
|
||||||
|
@ApiBody({
|
||||||
|
description:
|
||||||
|
'Champs métier (+ parent_user_id côté staff). Fichier optionnel `photo` en multipart.',
|
||||||
|
type: CreateEnfantsDto,
|
||||||
|
})
|
||||||
|
@UseInterceptors(OptionalEnfantPhotoInterceptor)
|
||||||
create(
|
create(
|
||||||
@Body() dto: CreateEnfantsDto,
|
@Body() dto: CreateEnfantsDto,
|
||||||
@UploadedFile() photo: Express.Multer.File,
|
@UploadedFile() photo: Express.Multer.File,
|
||||||
|
|||||||
@ -13,6 +13,12 @@ import { ParentsChildren } from 'src/entities/parents_children.entity';
|
|||||||
import { RoleType, Users } from 'src/entities/users.entity';
|
import { RoleType, Users } from 'src/entities/users.entity';
|
||||||
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
||||||
|
|
||||||
|
const STAFF_ROLES: RoleType[] = [
|
||||||
|
RoleType.GESTIONNAIRE,
|
||||||
|
RoleType.ADMINISTRATEUR,
|
||||||
|
RoleType.SUPER_ADMIN,
|
||||||
|
];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EnfantsService {
|
export class EnfantsService {
|
||||||
constructor(
|
constructor(
|
||||||
@ -24,20 +30,35 @@ export class EnfantsService {
|
|||||||
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
// Création d'un enfant
|
private isStaff(user: Users): boolean {
|
||||||
async create(dto: CreateEnfantsDto, currentUser: Users, photoFile?: Express.Multer.File): Promise<Children> {
|
return STAFF_ROLES.includes(user.role);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Création d'un enfant.
|
||||||
|
* - PARENT : rattache au parent connecté (multipart photo optionnel).
|
||||||
|
* - Staff : `parent_user_id` obligatoire ; JSON sans photo OK ;
|
||||||
|
* avec photo → multipart (même stockage `/uploads/photos/...`). Ticket #132.
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
|
dto: CreateEnfantsDto,
|
||||||
|
currentUser: Users,
|
||||||
|
photoFile?: Express.Multer.File,
|
||||||
|
): Promise<Children> {
|
||||||
|
const pivotUserId = this.resolvePivotParentUserId(dto, currentUser);
|
||||||
|
|
||||||
const parent = await this.parentsRepository.findOne({
|
const parent = await this.parentsRepository.findOne({
|
||||||
where: { user_id: currentUser.id },
|
where: { user_id: pivotUserId },
|
||||||
relations: ['co_parent'],
|
relations: ['co_parent'],
|
||||||
});
|
});
|
||||||
if (!parent) throw new NotFoundException('Parent introuvable');
|
if (!parent) throw new NotFoundException('Parent introuvable');
|
||||||
|
|
||||||
// Vérif métier simple
|
// Vérif métier simple (aligné comportement historique parent)
|
||||||
if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) {
|
if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) {
|
||||||
throw new BadRequestException('Un enfant né doit avoir une date de naissance');
|
throw new BadRequestException('Un enfant né doit avoir une date de naissance');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérif doublon éventuel (ex: même prénom + date de naissance pour ce parent)
|
// Vérif doublon éventuel (ex: même prénom + date de naissance)
|
||||||
const exist = await this.childrenRepository.findOne({
|
const exist = await this.childrenRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
first_name: dto.first_name,
|
first_name: dto.first_name,
|
||||||
@ -47,50 +68,108 @@ export class EnfantsService {
|
|||||||
});
|
});
|
||||||
if (exist) throw new ConflictException('Cet enfant existe déjà');
|
if (exist) throw new ConflictException('Cet enfant existe déjà');
|
||||||
|
|
||||||
// Gestion de la photo uploadée
|
// Gestion de la photo uploadée (multipart parent ou staff)
|
||||||
|
let photoUrl = dto.photo_url;
|
||||||
|
let consentAt: Date | undefined;
|
||||||
if (photoFile) {
|
if (photoFile) {
|
||||||
dto.photo_url = `/uploads/photos/${photoFile.filename}`;
|
photoUrl = `/uploads/photos/${photoFile.filename}`;
|
||||||
if (dto.consent_photo) {
|
if (dto.consent_photo) {
|
||||||
dto.consent_photo_at = new Date().toISOString();
|
consentAt = new Date();
|
||||||
}
|
}
|
||||||
|
} else if (dto.consent_photo) {
|
||||||
|
consentAt = dto.consent_photo_at
|
||||||
|
? new Date(dto.consent_photo_at)
|
||||||
|
: new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Création
|
const child = this.childrenRepository.create({
|
||||||
const child = this.childrenRepository.create(dto);
|
status: dto.status,
|
||||||
|
first_name: dto.first_name,
|
||||||
|
last_name: dto.last_name,
|
||||||
|
gender: dto.gender,
|
||||||
|
birth_date: dto.birth_date ? new Date(dto.birth_date) : undefined,
|
||||||
|
due_date: dto.due_date ? new Date(dto.due_date) : undefined,
|
||||||
|
photo_url: photoUrl,
|
||||||
|
consent_photo: !!dto.consent_photo,
|
||||||
|
consent_photo_at: consentAt,
|
||||||
|
is_multiple: !!dto.is_multiple,
|
||||||
|
});
|
||||||
await this.childrenRepository.save(child);
|
await this.childrenRepository.save(child);
|
||||||
|
|
||||||
// Lien parent-enfant (Parent 1)
|
// Lien parent-enfant (pivot)
|
||||||
const parentLink = this.parentsChildrenRepository.create({
|
await this.parentsChildrenRepository.save(
|
||||||
parentId: parent.user_id,
|
this.parentsChildrenRepository.create({
|
||||||
enfantId: child.id,
|
parentId: parent.user_id,
|
||||||
});
|
enfantId: child.id,
|
||||||
await this.parentsChildrenRepository.save(parentLink);
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Rattachement automatique au co-parent s'il existe
|
// Rattachement automatique au co-parent s'il existe
|
||||||
if (parent.co_parent) {
|
if (parent.co_parent) {
|
||||||
const coParentLink = this.parentsChildrenRepository.create({
|
await this.parentsChildrenRepository.save(
|
||||||
parentId: parent.co_parent.id,
|
this.parentsChildrenRepository.create({
|
||||||
enfantId: child.id,
|
parentId: parent.co_parent.id,
|
||||||
});
|
enfantId: child.id,
|
||||||
await this.parentsChildrenRepository.save(coParentLink);
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.findOne(child.id, currentUser);
|
return this.findOne(child.id, currentUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Liste des enfants (admin/gestionnaire)
|
private resolvePivotParentUserId(
|
||||||
async findAll(): Promise<Children[]> {
|
dto: CreateEnfantsDto,
|
||||||
return this.childrenRepository.find({
|
currentUser: Users,
|
||||||
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
): string {
|
||||||
order: { last_name: 'ASC', first_name: 'ASC' },
|
if (this.isStaff(currentUser)) {
|
||||||
|
const id = dto.parent_user_id?.trim();
|
||||||
|
if (!id) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'parent_user_id est obligatoire pour créer un enfant (staff)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUser.role === RoleType.PARENT) {
|
||||||
|
if (
|
||||||
|
dto.parent_user_id &&
|
||||||
|
dto.parent_user_id.trim() !== currentUser.id
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Un parent ne peut pas créer un enfant pour un autre compte',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return currentUser.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ForbiddenException('Accès interdit');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flag API #157 — true si aucun lien enfants_parents. */
|
||||||
|
private withSansResponsable(child: Children): Children & { sans_responsable: boolean } {
|
||||||
|
return Object.assign(child, {
|
||||||
|
sans_responsable: !child.parentLinks || child.parentLinks.length === 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Liste des enfants (admin/gestionnaire) — inclut les orphelins (parentLinks: [])
|
||||||
|
async findAll(): Promise<Array<Children & { sans_responsable: boolean }>> {
|
||||||
|
const children = await this.childrenRepository.find({
|
||||||
|
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
||||||
|
order: { last_name: 'ASC', first_name: 'ASC' },
|
||||||
|
});
|
||||||
|
return children.map((c) => this.withSansResponsable(c));
|
||||||
|
}
|
||||||
|
|
||||||
// Récupérer un enfant par id
|
// Récupérer un enfant par id
|
||||||
async findOne(id: string, currentUser: Users): Promise<Children> {
|
async findOne(
|
||||||
|
id: string,
|
||||||
|
currentUser: Users,
|
||||||
|
): Promise<Children & { sans_responsable: boolean }> {
|
||||||
const child = await this.childrenRepository.findOne({
|
const child = await this.childrenRepository.findOne({
|
||||||
where: { id },
|
where: { id },
|
||||||
relations: ['parentLinks'],
|
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
||||||
});
|
});
|
||||||
if (!child) throw new NotFoundException('Enfant introuvable');
|
if (!child) throw new NotFoundException('Enfant introuvable');
|
||||||
|
|
||||||
@ -104,14 +183,14 @@ export class EnfantsService {
|
|||||||
case RoleType.ADMINISTRATEUR:
|
case RoleType.ADMINISTRATEUR:
|
||||||
case RoleType.SUPER_ADMIN:
|
case RoleType.SUPER_ADMIN:
|
||||||
case RoleType.GESTIONNAIRE:
|
case RoleType.GESTIONNAIRE:
|
||||||
// accès complet
|
// accès complet (y compris orphelins)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new ForbiddenException('Accès interdit');
|
throw new ForbiddenException('Accès interdit');
|
||||||
}
|
}
|
||||||
|
|
||||||
return child;
|
return this.withSansResponsable(child);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -120,7 +199,8 @@ export class EnfantsService {
|
|||||||
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');
|
||||||
|
|
||||||
const patch: Partial<Children> = { ...dto } as Partial<Children>;
|
const { parent_user_id: _ignored, ...rest } = dto;
|
||||||
|
const patch: Partial<Children> = { ...rest } as Partial<Children>;
|
||||||
if (dto.consent_photo !== undefined) {
|
if (dto.consent_photo !== undefined) {
|
||||||
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!;
|
||||||
|
|||||||
@ -114,34 +114,86 @@ export class ParentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rattacher un enfant existant à un parent (enfants_parents). Ticket #115 / doc 28 §6.2.
|
* Membres du foyer (user ids) pour affiliation enfant.
|
||||||
|
* Pivot + co-parent (A→B et B→A) + même numero_dossier. Ticket #158.
|
||||||
*/
|
*/
|
||||||
async attachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
private async resolveFoyerParentUserIds(parent: Parents): Promise<string[]> {
|
||||||
await this.findOne(parentUserId);
|
const ids = new Set<string>([parent.user_id]);
|
||||||
|
|
||||||
const existing = await this.parentsChildrenRepository.findOne({
|
if (parent.co_parent?.id) {
|
||||||
where: { parentId: parentUserId, enfantId },
|
ids.add(parent.co_parent.id);
|
||||||
});
|
|
||||||
if (existing) {
|
|
||||||
throw new ConflictException('Cet enfant est déjà rattaché à ce parent');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const child = await this.parentsRepository.manager.findOne(Children, { where: { id: enfantId } });
|
// Sens inverse : parents qui déclarent ce user comme co-parent
|
||||||
|
const reverseLinks = await this.parentsRepository.find({
|
||||||
|
where: { co_parent: { id: parent.user_id } },
|
||||||
|
relations: ['co_parent'],
|
||||||
|
});
|
||||||
|
for (const p of reverseLinks) {
|
||||||
|
ids.add(p.user_id);
|
||||||
|
if (p.co_parent?.id) ids.add(p.co_parent.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dossier = parent.numero_dossier?.trim();
|
||||||
|
if (dossier) {
|
||||||
|
const sameDossier = await this.parentsRepository.find({
|
||||||
|
where: { numero_dossier: dossier },
|
||||||
|
relations: ['co_parent'],
|
||||||
|
});
|
||||||
|
for (const p of sameDossier) {
|
||||||
|
ids.add(p.user_id);
|
||||||
|
if (p.co_parent?.id) ids.add(p.co_parent.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rattacher un enfant au foyer du parent (tous les responsables). Ticket #158.
|
||||||
|
* Un seul POST suffit : liens créés pour pivot + co-parent / même dossier.
|
||||||
|
*/
|
||||||
|
async attachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
||||||
|
const parent = await this.findOne(parentUserId);
|
||||||
|
|
||||||
|
const child = await this.parentsRepository.manager.findOne(Children, {
|
||||||
|
where: { id: enfantId },
|
||||||
|
});
|
||||||
if (!child) {
|
if (!child) {
|
||||||
throw new NotFoundException('Enfant introuvable');
|
throw new NotFoundException('Enfant introuvable');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.parentsChildrenRepository.save(
|
const foyerIds = await this.resolveFoyerParentUserIds(parent);
|
||||||
this.parentsChildrenRepository.create({ parentId: parentUserId, enfantId }),
|
let created = 0;
|
||||||
);
|
|
||||||
|
for (const memberId of foyerIds) {
|
||||||
|
const existing = await this.parentsChildrenRepository.findOne({
|
||||||
|
where: { parentId: memberId, enfantId },
|
||||||
|
});
|
||||||
|
if (existing) continue;
|
||||||
|
|
||||||
|
await this.parentsChildrenRepository.save(
|
||||||
|
this.parentsChildrenRepository.create({
|
||||||
|
parentId: memberId,
|
||||||
|
enfantId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
created += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (created === 0) {
|
||||||
|
throw new ConflictException('Cet enfant est déjà rattaché à ce foyer');
|
||||||
|
}
|
||||||
|
|
||||||
return this.findOne(parentUserId);
|
return this.findOne(parentUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Détacher un enfant d'un parent sans supprimer l'enfant. Ticket #115 / doc 28 §6.2.
|
* Détacher un enfant du foyer du parent (tous les responsables). Ticket #158.
|
||||||
|
* Si plus aucun lien ensuite → enfant orphelin (#157).
|
||||||
*/
|
*/
|
||||||
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
||||||
await this.findOne(parentUserId);
|
const parent = await this.findOne(parentUserId);
|
||||||
|
|
||||||
const link = await this.parentsChildrenRepository.findOne({
|
const link = await this.parentsChildrenRepository.findOne({
|
||||||
where: { parentId: parentUserId, enfantId },
|
where: { parentId: parentUserId, enfantId },
|
||||||
@ -150,12 +202,12 @@ export class ParentsService {
|
|||||||
throw new NotFoundException('Lien parent-enfant introuvable');
|
throw new NotFoundException('Lien parent-enfant introuvable');
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalLinks = await this.parentsChildrenRepository.count({ where: { enfantId } });
|
const foyerIds = await this.resolveFoyerParentUserIds(parent);
|
||||||
if (totalLinks <= 1) {
|
await this.parentsChildrenRepository.delete({
|
||||||
throw new BadRequestException('Un enfant doit rester rattaché à au moins un responsable');
|
parentId: In(foyerIds),
|
||||||
}
|
enfantId,
|
||||||
|
});
|
||||||
|
|
||||||
await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId });
|
|
||||||
return this.findOne(parentUserId);
|
return this.findOne(parentUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
104
docs/métriques/2026-07-22_analyse-empreinte.md
Normal file
104
docs/métriques/2026-07-22_analyse-empreinte.md
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
# Analyse d’empreinte — P'titsPas
|
||||||
|
|
||||||
|
**Date :** 2026-07-22
|
||||||
|
**Contexte :** snapshot pour mémoire (après alerte disque plein + purge cache Gitea)
|
||||||
|
**Périmètre :** application `jmartin/petitspas` déployée sur le VPS (`/home/deploy/dev/ptitspas-app`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Lignes de code
|
||||||
|
|
||||||
|
Comptage brut (`wc -l`), hors `node_modules` / builds / `.dart_tool`.
|
||||||
|
|
||||||
|
| Zone | Lignes | Fichiers |
|
||||||
|
|------|--------|----------|
|
||||||
|
| **Frontend** (Dart `frontend/lib/`) | ~29 800 | 141 |
|
||||||
|
| **Backend** (TypeScript `backend/src/`) | ~10 000 | 141 |
|
||||||
|
| **BDD** (SQL sous `database/`) | ~1 250 | ~15 |
|
||||||
|
| **Total** | **~41 000** | |
|
||||||
|
|
||||||
|
### Frontend (détail)
|
||||||
|
|
||||||
|
| Dossier | Lignes |
|
||||||
|
|---------|--------|
|
||||||
|
| `widgets/` | ~18 400 |
|
||||||
|
| `screens/` | ~5 000 |
|
||||||
|
| `services/` | ~2 300 |
|
||||||
|
| `models/` | ~2 200 |
|
||||||
|
| `utils/` | ~1 400 |
|
||||||
|
| reste | ~500 |
|
||||||
|
|
||||||
|
### Backend (détail)
|
||||||
|
|
||||||
|
| Dossier | Lignes |
|
||||||
|
|---------|--------|
|
||||||
|
| `routes/` | ~6 500 |
|
||||||
|
| `modules/` | ~1 700 |
|
||||||
|
| `entities/` | ~1 000 |
|
||||||
|
| `common/` + `config/` | ~400 |
|
||||||
|
|
||||||
|
### BDD (détail)
|
||||||
|
|
||||||
|
| Élément | Lignes |
|
||||||
|
|---------|--------|
|
||||||
|
| `BDD.sql` (schéma) | ~470 |
|
||||||
|
| seeds | ~300 |
|
||||||
|
| migrations / patches | ~280 |
|
||||||
|
| tests SQL | ~205 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Empreinte disque — runtime (Docker)
|
||||||
|
|
||||||
|
| Composant | Taille | Notes |
|
||||||
|
|-----------|--------|--------|
|
||||||
|
| Image `ptitspas-app-backend` | ~300 Mo | NestJS |
|
||||||
|
| Image `ptitspas-app-frontend` | ~122 Mo | Flutter web + Nginx |
|
||||||
|
| Image `postgres:17` | ~454 Mo | |
|
||||||
|
| Image `dpage/pgadmin4` | ~534 Mo | optionnel |
|
||||||
|
| Volume `postgres_data` | ~49 Mo | données BDD live |
|
||||||
|
| Volume `backend_uploads` | ~20 Mo | photos |
|
||||||
|
| Volume `backend_documents_legaux` | ~0 | |
|
||||||
|
| **Stack complète (avec pgAdmin)** | **~1,5 Go** | |
|
||||||
|
| **Stack prod sans pgAdmin** | **~945 Mo** | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Empreinte disque — code source
|
||||||
|
|
||||||
|
| Élément | Taille |
|
||||||
|
|---------|--------|
|
||||||
|
| Clone `/home/deploy/dev/ptitspas-app` | ~701 Mo |
|
||||||
|
| dont `backend/node_modules` | ~354 Mo |
|
||||||
|
| dont `frontend` (+ `.dart_tool`) | ~114 Mo |
|
||||||
|
| **Code utile** (hors deps / `.git` / builds) | **~51 Mo** |
|
||||||
|
| Repo Gitea `petitspas.git` | ~109 Mo |
|
||||||
|
| Dump `database/BDD.sql` | ~19 Ko |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Pour (re)déployer
|
||||||
|
|
||||||
|
Minimum requis :
|
||||||
|
|
||||||
|
1. Images custom backend + frontend (~422 Mo), ou code + build Docker
|
||||||
|
2. Image `postgres:17` (~454 Mo)
|
||||||
|
3. Volumes persistants (~70 Mo au snapshot)
|
||||||
|
4. Optionnel : pgAdmin (~534 Mo)
|
||||||
|
|
||||||
|
**Ordre de grandeur :** ~1 Go pour faire tourner l’app en prod (sans pgAdmin).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Notes infra du jour (lié)
|
||||||
|
|
||||||
|
- Disque VPS passé de **100 %** à ~**44 %** après :
|
||||||
|
- purge cache Docker / journals
|
||||||
|
- purge officielle Gitea `delete_repo_archives` (~24 Go de zip/bundle `petitspas`)
|
||||||
|
- Crons Gitea activés :
|
||||||
|
- `archive_cleanup` @midnight (`OLDER_THAN = 24h`)
|
||||||
|
- `delete_repo_archives` @weekly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Fichier généré pour historique projet — ne pas considérer comme métrique CI automatisée.*
|
||||||
75
docs/tmp/156-contrat-api-staff-am.md
Normal file
75
docs/tmp/156-contrat-api-staff-am.md
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
# Mini-spec API — POST /assistantes-maternelles/dossier (#156)
|
||||||
|
|
||||||
|
Contrat pour le **plan front** (wizard création AM staff).
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **Méthode** | `POST` |
|
||||||
|
| **URL** | `{base}/assistantes-maternelles/dossier` |
|
||||||
|
| **Auth** | Bearer JWT |
|
||||||
|
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
|
||||||
|
| **Content-Type** | `application/json` |
|
||||||
|
|
||||||
|
Ne **pas** appeler `POST /auth/register/am` depuis le dashboard.
|
||||||
|
|
||||||
|
## Body (JSON)
|
||||||
|
|
||||||
|
Aligné inscription AM publique, **sans** CGU/privacy obligatoires (acceptées serveur).
|
||||||
|
|
||||||
|
| Champ | Type | Obligatoire | Notes |
|
||||||
|
|-------|------|-------------|--------|
|
||||||
|
| `email` | string | oui | unique |
|
||||||
|
| `prenom` | string | oui | |
|
||||||
|
| `nom` | string | oui | |
|
||||||
|
| `telephone` | string | oui | `0X…` ou `+33…` |
|
||||||
|
| `adresse` | string | non | |
|
||||||
|
| `code_postal` | string | non | |
|
||||||
|
| `ville` | string | non | |
|
||||||
|
| `photo_base64` | string | non | data-URL `data:image/…;base64,…` |
|
||||||
|
| `photo_filename` | string | non | hint nom fichier |
|
||||||
|
| `consentement_photo` | bool | oui | |
|
||||||
|
| `date_naissance` | date ISO | non | `YYYY-MM-DD` |
|
||||||
|
| `lieu_naissance_ville` | string | oui | |
|
||||||
|
| `lieu_naissance_pays` | string | oui | |
|
||||||
|
| `nir` | string | oui | 15 car. (Corse 2A/2B OK) |
|
||||||
|
| `numero_agrement` | string | oui | unique |
|
||||||
|
| `date_agrement` | date ISO | non | |
|
||||||
|
| `capacite_accueil` | int | oui | 1–10 |
|
||||||
|
| `places_disponibles` | int | oui | 0–10, ≤ capacité |
|
||||||
|
| `biographie` | string | non | max 2000 |
|
||||||
|
|
||||||
|
## Réponses
|
||||||
|
|
||||||
|
### 201 Created
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Dossier AM créé et validé. Un e-mail de création de mot de passe a été envoyé.",
|
||||||
|
"user_id": "uuid",
|
||||||
|
"statut": "actif",
|
||||||
|
"numero_dossier": "2026-000042"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Effets serveur : user AM **actif**, fiche `assistantes_maternelles`, n° dossier, **e-mail création MDP** (pas d’accusé « en attente »).
|
||||||
|
|
||||||
|
### Erreurs
|
||||||
|
|
||||||
|
| Code | Cas |
|
||||||
|
|------|-----|
|
||||||
|
| 400 | Validation / NIR / places > capacité |
|
||||||
|
| 403 | Rôle non staff |
|
||||||
|
| 409 | Email, NIR ou agrément déjà pris |
|
||||||
|
| 401 | Token manquant / invalide |
|
||||||
|
|
||||||
|
## Front
|
||||||
|
|
||||||
|
- `UserService.createAmDossier(body)` → cet endpoint
|
||||||
|
- Après 201 : refresh liste AM ; snackbar OK
|
||||||
|
- Wizard create : ne pas envoyer `acceptation_cgu` / `acceptation_privacy` (optionnels)
|
||||||
|
|
||||||
|
## Branche
|
||||||
|
|
||||||
|
`feature/156-creation-dossier-am`
|
||||||
@ -14,6 +14,8 @@ class EnfantAdminModel {
|
|||||||
final bool consentPhoto;
|
final bool consentPhoto;
|
||||||
final bool isMultiple;
|
final bool isMultiple;
|
||||||
final List<EnfantParentLink> parentLinks;
|
final List<EnfantParentLink> parentLinks;
|
||||||
|
/// Flag API #157 (sinon déduit de [parentLinks]).
|
||||||
|
final bool? sansResponsable;
|
||||||
|
|
||||||
EnfantAdminModel({
|
EnfantAdminModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
@ -27,6 +29,7 @@ class EnfantAdminModel {
|
|||||||
this.consentPhoto = false,
|
this.consentPhoto = false,
|
||||||
this.isMultiple = false,
|
this.isMultiple = false,
|
||||||
this.parentLinks = const [],
|
this.parentLinks = const [],
|
||||||
|
this.sansResponsable,
|
||||||
});
|
});
|
||||||
|
|
||||||
String get fullName {
|
String get fullName {
|
||||||
@ -37,6 +40,12 @@ class EnfantAdminModel {
|
|||||||
return '$fn $ln';
|
return '$fn $ln';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Aucun lien parent valide — ticket #157.
|
||||||
|
bool get hasNoResponsable {
|
||||||
|
if (sansResponsable != null) return sansResponsable!;
|
||||||
|
return !parentLinks.any((l) => l.parentId.trim().isNotEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
EnfantAdminModel copyWith({
|
EnfantAdminModel copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
String? firstName,
|
String? firstName,
|
||||||
@ -49,6 +58,7 @@ class EnfantAdminModel {
|
|||||||
bool? consentPhoto,
|
bool? consentPhoto,
|
||||||
bool? isMultiple,
|
bool? isMultiple,
|
||||||
List<EnfantParentLink>? parentLinks,
|
List<EnfantParentLink>? parentLinks,
|
||||||
|
bool? sansResponsable,
|
||||||
}) {
|
}) {
|
||||||
return EnfantAdminModel(
|
return EnfantAdminModel(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@ -62,6 +72,7 @@ class EnfantAdminModel {
|
|||||||
consentPhoto: consentPhoto ?? this.consentPhoto,
|
consentPhoto: consentPhoto ?? this.consentPhoto,
|
||||||
isMultiple: isMultiple ?? this.isMultiple,
|
isMultiple: isMultiple ?? this.isMultiple,
|
||||||
parentLinks: parentLinks ?? this.parentLinks,
|
parentLinks: parentLinks ?? this.parentLinks,
|
||||||
|
sansResponsable: sansResponsable ?? this.sansResponsable,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,6 +92,13 @@ class EnfantAdminModel {
|
|||||||
_parseBool(json['consentement_photo']) ||
|
_parseBool(json['consentement_photo']) ||
|
||||||
_parseBool(json['consentPhoto']);
|
_parseBool(json['consentPhoto']);
|
||||||
|
|
||||||
|
bool? sansResponsable;
|
||||||
|
if (json.containsKey('sans_responsable') ||
|
||||||
|
json.containsKey('sansResponsable')) {
|
||||||
|
sansResponsable = _parseBool(json['sans_responsable']) ||
|
||||||
|
_parseBool(json['sansResponsable']);
|
||||||
|
}
|
||||||
|
|
||||||
return EnfantAdminModel(
|
return EnfantAdminModel(
|
||||||
id: (json['id'] ?? '').toString(),
|
id: (json['id'] ?? '').toString(),
|
||||||
firstName: json['first_name'] as String? ?? json['prenom'] as String?,
|
firstName: json['first_name'] as String? ?? json['prenom'] as String?,
|
||||||
@ -96,6 +114,7 @@ class EnfantAdminModel {
|
|||||||
isMultiple: _parseBool(json['is_multiple']) ||
|
isMultiple: _parseBool(json['is_multiple']) ||
|
||||||
_parseBool(json['est_multiple']),
|
_parseBool(json['est_multiple']),
|
||||||
parentLinks: links,
|
parentLinks: links,
|
||||||
|
sansResponsable: sansResponsable,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -62,4 +62,56 @@ class ParentModel {
|
|||||||
|
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Nombre d’enfants distincts du foyer (ce parent + co-parent / même dossier).
|
||||||
|
/// Évite Claire=7 / Thomas=6 quand un lien n’est que sur un des deux (#157).
|
||||||
|
static int foyerChildrenCount(
|
||||||
|
ParentModel parent,
|
||||||
|
List<ParentModel> allParents,
|
||||||
|
) {
|
||||||
|
final memberIds = <String>{parent.user.id};
|
||||||
|
final coId = parent.coParent?.id.trim();
|
||||||
|
if (coId != null && coId.isNotEmpty) memberIds.add(coId);
|
||||||
|
|
||||||
|
final dossier = (parent.user.numeroDossier ?? '').trim();
|
||||||
|
|
||||||
|
for (final other in allParents) {
|
||||||
|
if (memberIds.contains(other.user.id)) continue;
|
||||||
|
final otherCo = other.coParent?.id.trim();
|
||||||
|
if (otherCo != null && memberIds.contains(otherCo)) {
|
||||||
|
memberIds.add(other.user.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (dossier.isNotEmpty &&
|
||||||
|
(other.user.numeroDossier ?? '').trim() == dossier) {
|
||||||
|
memberIds.add(other.user.id);
|
||||||
|
final oc = other.coParent?.id.trim();
|
||||||
|
if (oc != null && oc.isNotEmpty) memberIds.add(oc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final childIds = <String>{};
|
||||||
|
for (final p in allParents) {
|
||||||
|
if (!memberIds.contains(p.user.id)) continue;
|
||||||
|
for (final c in p.children) {
|
||||||
|
final id = c.id.trim();
|
||||||
|
if (id.isNotEmpty) childIds.add(id);
|
||||||
|
}
|
||||||
|
// Repli si la liste enfants n’est pas hydratée.
|
||||||
|
if (p.children.isEmpty && p.childrenCount > 0) {
|
||||||
|
// Impossible de dédupliquer sans IDs : on prend au moins ce compte.
|
||||||
|
// (évite d’afficher 0 si l’API n’envoie que childrenCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (childIds.isNotEmpty) return childIds.length;
|
||||||
|
|
||||||
|
var maxCount = 0;
|
||||||
|
for (final p in allParents) {
|
||||||
|
if (!memberIds.contains(p.user.id)) continue;
|
||||||
|
final n = p.children.isNotEmpty ? p.children.length : p.childrenCount;
|
||||||
|
if (n > maxCount) maxCount = n;
|
||||||
|
}
|
||||||
|
return maxCount;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,6 +61,9 @@ class ApiConfig {
|
|||||||
static const String gestionnaires = '/gestionnaires';
|
static const String gestionnaires = '/gestionnaires';
|
||||||
static const String parents = '/parents';
|
static const String parents = '/parents';
|
||||||
static const String assistantesMaternelles = '/assistantes-maternelles';
|
static const String assistantesMaternelles = '/assistantes-maternelles';
|
||||||
|
/// Création dossier AM actif par le staff (#156) — body type register AM.
|
||||||
|
static const String assistantesMaternellesDossier =
|
||||||
|
'/assistantes-maternelles/dossier';
|
||||||
static const String enfants = '/enfants';
|
static const String enfants = '/enfants';
|
||||||
static const String relais = '/relais';
|
static const String relais = '/relais';
|
||||||
static const String dossiers = '/dossiers';
|
static const String dossiers = '/dossiers';
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http_parser/http_parser.dart';
|
||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||||
import 'package:p_tits_pas/models/parent_model.dart';
|
import 'package:p_tits_pas/models/parent_model.dart';
|
||||||
@ -57,6 +58,33 @@ class UserService {
|
|||||||
return v.toString();
|
return v.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// MIME pour upload photo enfant (filtre Nest : jpg/jpeg/png/gif).
|
||||||
|
static MediaType _imageMediaType(String filename, List<int> bytes) {
|
||||||
|
if (bytes.length >= 3 &&
|
||||||
|
bytes[0] == 0xFF &&
|
||||||
|
bytes[1] == 0xD8 &&
|
||||||
|
bytes[2] == 0xFF) {
|
||||||
|
return MediaType('image', 'jpeg');
|
||||||
|
}
|
||||||
|
if (bytes.length >= 8 &&
|
||||||
|
bytes[0] == 0x89 &&
|
||||||
|
bytes[1] == 0x50 &&
|
||||||
|
bytes[2] == 0x4E &&
|
||||||
|
bytes[3] == 0x47) {
|
||||||
|
return MediaType('image', 'png');
|
||||||
|
}
|
||||||
|
if (bytes.length >= 6 &&
|
||||||
|
bytes[0] == 0x47 &&
|
||||||
|
bytes[1] == 0x49 &&
|
||||||
|
bytes[2] == 0x46) {
|
||||||
|
return MediaType('image', 'gif');
|
||||||
|
}
|
||||||
|
final lower = filename.toLowerCase();
|
||||||
|
if (lower.endsWith('.png')) return MediaType('image', 'png');
|
||||||
|
if (lower.endsWith('.gif')) return MediaType('image', 'gif');
|
||||||
|
return MediaType('image', 'jpeg');
|
||||||
|
}
|
||||||
|
|
||||||
static String _errMessage(dynamic err) {
|
static String _errMessage(dynamic err) {
|
||||||
if (err == null) return 'Erreur inconnue';
|
if (err == null) return 'Erreur inconnue';
|
||||||
if (err is String) return err;
|
if (err is String) return err;
|
||||||
@ -515,6 +543,68 @@ class UserService {
|
|||||||
return enrichEnfantParentNames(enfant);
|
return enrichEnfantParentNames(enfant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Création enfant côté staff (#132) — nécessite `POST /enfants` étendu (voir mini-spec).
|
||||||
|
/// [parentUserId] : parent pivot du foyer (`parent_user_id`).
|
||||||
|
/// Avec [photoBytes] : `multipart/form-data` (champ fichier `photo`), sinon JSON.
|
||||||
|
static Future<EnfantAdminModel> createEnfant({
|
||||||
|
required String parentUserId,
|
||||||
|
required Map<String, dynamic> body,
|
||||||
|
List<int>? photoBytes,
|
||||||
|
String? photoFilename,
|
||||||
|
}) async {
|
||||||
|
final hasPhoto = photoBytes != null && photoBytes.isNotEmpty;
|
||||||
|
final http.Response response;
|
||||||
|
if (hasPhoto) {
|
||||||
|
final token = await TokenService.getToken();
|
||||||
|
final req = http.MultipartRequest(
|
||||||
|
'POST',
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
|
||||||
|
);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
req.fields['parent_user_id'] = parentUserId;
|
||||||
|
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 {
|
||||||
|
final payload = <String, dynamic>{
|
||||||
|
...body,
|
||||||
|
'parent_user_id': parentUserId,
|
||||||
|
};
|
||||||
|
response = await http.post(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(payload),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||||
|
throw Exception(
|
||||||
|
_extractErrorMessage(response.body, 'Erreur création enfant'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final enfant = EnfantAdminModel.fromJson(
|
||||||
|
jsonDecode(response.body) as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
return enrichEnfantParentNames(enfant);
|
||||||
|
}
|
||||||
|
|
||||||
static Future<void> deleteEnfant(String enfantId) async {
|
static Future<void> deleteEnfant(String enfantId) async {
|
||||||
final response = await http.delete(
|
final response = await http.delete(
|
||||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
|
||||||
@ -555,6 +645,56 @@ class UserService {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Création dossier AM actif côté staff (#156).
|
||||||
|
/// `POST /assistantes-maternelles/dossier` — body aligné sur register AM.
|
||||||
|
/// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur).
|
||||||
|
/// Ne pas utiliser `POST /auth/register/am` (public / en_attente).
|
||||||
|
static Future<Map<String, dynamic>> createAmDossier(
|
||||||
|
Map<String, dynamic> body,
|
||||||
|
) async {
|
||||||
|
final http.Response response;
|
||||||
|
try {
|
||||||
|
response = await http.post(
|
||||||
|
Uri.parse(
|
||||||
|
'${ApiConfig.baseUrl}${ApiConfig.assistantesMaternellesDossier}',
|
||||||
|
),
|
||||||
|
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 création dossier AM',
|
||||||
|
);
|
||||||
|
if (response.statusCode == 409) {
|
||||||
|
throw Exception(message.isNotEmpty
|
||||||
|
? message
|
||||||
|
: 'Conflit : e-mail, NIR ou agrément déjà utilisé.');
|
||||||
|
}
|
||||||
|
if (response.statusCode == 400) {
|
||||||
|
throw Exception(
|
||||||
|
message.isNotEmpty ? message : 'Données invalides (400).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw Exception(message);
|
||||||
|
}
|
||||||
|
|
||||||
// Récupérer la liste des assistantes maternelles
|
// Récupérer la liste des assistantes maternelles
|
||||||
static Future<List<AssistanteMaternelleModel>>
|
static Future<List<AssistanteMaternelleModel>>
|
||||||
getAssistantesMaternelles() async {
|
getAssistantesMaternelles() async {
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||||
|
|
||||||
@ -11,10 +12,50 @@ String formatIsoDateFr(String? s, {String ifEmpty = '–'}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convertit `dd/MM/yyyy` (ou ISO) en `yyyy-MM-dd` pour l'API.
|
/// Affiche une date ISO en saisie guidée `jj / mm / aaaa`.
|
||||||
|
String formatIsoDateFrInput(String? s, {String ifEmpty = ''}) {
|
||||||
|
if (s == null || s.trim().isEmpty) return ifEmpty;
|
||||||
|
try {
|
||||||
|
final dt = DateTime.parse(s.trim());
|
||||||
|
return formatFrenchDateDigits(
|
||||||
|
DateFormat('ddMMyyyy').format(dt),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return formatFrenchDateDigits(s.replaceAll(RegExp(r'\D'), ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formate jusqu’à 8 chiffres en `jj / mm / aaaa`.
|
||||||
|
String formatFrenchDateDigits(String digits) {
|
||||||
|
final d = digits.replaceAll(RegExp(r'\D'), '');
|
||||||
|
final limited = d.length > 8 ? d.substring(0, 8) : d;
|
||||||
|
final buf = StringBuffer();
|
||||||
|
for (var i = 0; i < limited.length; i++) {
|
||||||
|
if (i == 2 || i == 4) buf.write(' / ');
|
||||||
|
buf.write(limited[i]);
|
||||||
|
}
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convertit `dd/MM/yyyy`, `dd / MM / yyyy`, 8 chiffres ou ISO en `yyyy-MM-dd`.
|
||||||
String? parseFrDateToIso(String text) {
|
String? parseFrDateToIso(String text) {
|
||||||
final t = text.trim();
|
final t = text.trim();
|
||||||
if (t.isEmpty) return null;
|
if (t.isEmpty) return null;
|
||||||
|
|
||||||
|
final digits = t.replaceAll(RegExp(r'\D'), '');
|
||||||
|
if (digits.length == 8) {
|
||||||
|
final dd = digits.substring(0, 2);
|
||||||
|
final mm = digits.substring(2, 4);
|
||||||
|
final yyyy = digits.substring(4, 8);
|
||||||
|
try {
|
||||||
|
return DateFormat('dd/MM/yyyy')
|
||||||
|
.parseStrict('$dd/$mm/$yyyy')
|
||||||
|
.toIso8601String()
|
||||||
|
.split('T')
|
||||||
|
.first;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return DateFormat('dd/MM/yyyy')
|
return DateFormat('dd/MM/yyyy')
|
||||||
.parseStrict(t)
|
.parseStrict(t)
|
||||||
@ -96,3 +137,43 @@ String formatChildAgeLabel({
|
|||||||
return 'Né le ${formatIsoDateFr(birthDate)}';
|
return 'Né le ${formatIsoDateFr(birthDate)}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Saisie date FR : 8 chiffres → `jj / mm / aaaa`.
|
||||||
|
class FrenchDateInputFormatter extends TextInputFormatter {
|
||||||
|
const FrenchDateInputFormatter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextEditingValue formatEditUpdate(
|
||||||
|
TextEditingValue oldValue,
|
||||||
|
TextEditingValue newValue,
|
||||||
|
) {
|
||||||
|
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
||||||
|
final limited = digits.length > 8 ? digits.substring(0, 8) : digits;
|
||||||
|
final formatted = formatFrenchDateDigits(limited);
|
||||||
|
|
||||||
|
final digitsBeforeCursor = newValue.text
|
||||||
|
.substring(0, newValue.selection.start.clamp(0, newValue.text.length))
|
||||||
|
.replaceAll(RegExp(r'\D'), '')
|
||||||
|
.length
|
||||||
|
.clamp(0, limited.length);
|
||||||
|
|
||||||
|
return TextEditingValue(
|
||||||
|
text: formatted,
|
||||||
|
selection: TextSelection.collapsed(
|
||||||
|
offset: _cursorOffset(formatted, digitsBeforeCursor),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _cursorOffset(String formatted, int digitsBeforeCursor) {
|
||||||
|
if (digitsBeforeCursor <= 0) return 0;
|
||||||
|
var seen = 0;
|
||||||
|
for (var i = 0; i < formatted.length; i++) {
|
||||||
|
if (RegExp(r'\d').hasMatch(formatted[i])) {
|
||||||
|
seen++;
|
||||||
|
if (seen >= digitsBeforeCursor) return i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return formatted.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
10
frontend/lib/utils/enfant_vigilance.dart
Normal file
10
frontend/lib/utils/enfant_vigilance.dart
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||||
|
|
||||||
|
/// Enfant sans lien parent (orphelins d’affiliation) — ticket #157.
|
||||||
|
bool enfantHasNoResponsable(EnfantAdminModel enfant) => enfant.hasNoResponsable;
|
||||||
|
|
||||||
|
/// Message vigilance liste Enfants (même usage que [amPlacesVigilanceMessage]).
|
||||||
|
String? enfantSansResponsableVigilanceMessage(EnfantAdminModel enfant) {
|
||||||
|
if (!enfantHasNoResponsable(enfant)) return null;
|
||||||
|
return 'Aucun responsable rattaché — à rattacher à un foyer';
|
||||||
|
}
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
/// Formatage affichage prénom / nom (capitalisation par mot et segments après `-` ou `'`).
|
/// Formatage affichage prénom / nom (capitalisation par mot et segments après `-` ou `'`).
|
||||||
|
|
||||||
String formatPersonNameCase(String raw) {
|
String formatPersonNameCase(String raw) {
|
||||||
@ -9,6 +11,22 @@ String formatPersonNameCase(String raw) {
|
|||||||
return words.map(_capitalizeComposedWord).join(' ');
|
return words.map(_capitalizeComposedWord).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Variante saisie live : pas de majuscule tant que le mot n’a qu’une lettre ;
|
||||||
|
/// dès la 2ᵉ lettre, capitalisation comme [formatPersonNameCase].
|
||||||
|
String formatPersonNameCaseTyping(String raw) {
|
||||||
|
if (raw.isEmpty) return raw;
|
||||||
|
final trailingMatch = RegExp(r'(\s*)$').firstMatch(raw);
|
||||||
|
final trailing = trailingMatch?.group(1) ?? '';
|
||||||
|
final core = raw.substring(0, raw.length - trailing.length);
|
||||||
|
if (core.isEmpty) return raw;
|
||||||
|
final words = core.split(RegExp(r'\s+'));
|
||||||
|
final formatted = words.map((w) {
|
||||||
|
if (w.length < 2) return w;
|
||||||
|
return _capitalizeComposedWord(w);
|
||||||
|
}).join(' ');
|
||||||
|
return formatted + trailing;
|
||||||
|
}
|
||||||
|
|
||||||
String _capitalizeComposedWord(String word) {
|
String _capitalizeComposedWord(String word) {
|
||||||
if (word.isEmpty) {
|
if (word.isEmpty) {
|
||||||
return word;
|
return word;
|
||||||
@ -30,3 +48,26 @@ String _capitalizeComposedWord(String word) {
|
|||||||
}
|
}
|
||||||
return buffer.toString();
|
return buffer.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Formateur de saisie prénom / nom (capitalisation progressive).
|
||||||
|
class PersonNameInputFormatter extends TextInputFormatter {
|
||||||
|
const PersonNameInputFormatter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextEditingValue formatEditUpdate(
|
||||||
|
TextEditingValue oldValue,
|
||||||
|
TextEditingValue newValue,
|
||||||
|
) {
|
||||||
|
final formatted = formatPersonNameCaseTyping(newValue.text);
|
||||||
|
if (formatted == newValue.text) return newValue;
|
||||||
|
|
||||||
|
final sel = newValue.selection;
|
||||||
|
final offset = sel.isValid
|
||||||
|
? sel.baseOffset.clamp(0, formatted.length)
|
||||||
|
: formatted.length;
|
||||||
|
return TextEditingValue(
|
||||||
|
text: formatted,
|
||||||
|
selection: TextSelection.collapsed(offset: offset),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -49,12 +49,21 @@ String nirToRaw(String normalized) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Formate pour affichage : 1 12 34 56 789 012 - 34 ou 1 12 34 2A 789 012 - 34 (Corse).
|
/// Formate pour affichage (complet ou en cours de saisie) :
|
||||||
|
/// `1 12 34 56 789 012 - 34` ou `1 12 34 2A 789 012 - 34` (Corse).
|
||||||
String formatNir(String raw) {
|
String formatNir(String raw) {
|
||||||
final r = nirToRaw(raw);
|
final r = nirToRaw(raw).toUpperCase();
|
||||||
if (r.length < 15) return r;
|
if (r.isEmpty) return '';
|
||||||
// Même structure pour tous : sexe + année + mois + département + commune + ordre-clé.
|
final buf = StringBuffer();
|
||||||
return '${r.substring(0, 1)} ${r.substring(1, 3)} ${r.substring(3, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)} - ${r.substring(13, 15)}';
|
for (var i = 0; i < r.length && i < 15; i++) {
|
||||||
|
if (i == 1 || i == 3 || i == 5 || i == 7 || i == 10) {
|
||||||
|
buf.write(' ');
|
||||||
|
} else if (i == 13) {
|
||||||
|
buf.write(' - ');
|
||||||
|
}
|
||||||
|
buf.write(r[i]);
|
||||||
|
}
|
||||||
|
return buf.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Aligné sur le backend (NIR côté API / nir.util.ts) : sexe 1–3, département 2A ou 2B pour la Corse.
|
/// Aligné sur le backend (NIR côté API / nir.util.ts) : sexe 1–3, département 2A ou 2B pour la Corse.
|
||||||
@ -92,20 +101,39 @@ String? validateNir(String? value) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Formateur de saisie : affiche le NIR formaté (1 12 34 56 789 012 - 34) et limite à 15 caractères utiles.
|
/// Validation pendant la saisie : pas d’erreur si incomplet encore plausible ;
|
||||||
|
/// dès 15 caractères, même contrôles que [validateNir].
|
||||||
|
String? validateNirTyping(String? value) {
|
||||||
|
if (value == null || value.trim().isEmpty) return null;
|
||||||
|
final raw = nirToRaw(value).toUpperCase();
|
||||||
|
if (raw.isEmpty) return null;
|
||||||
|
if (raw[0] != '1' && raw[0] != '2' && raw[0] != '3') {
|
||||||
|
return 'Format NIR invalide (ex. 1 12 34 56 789 012 - 34 ou 2A pour la Corse)';
|
||||||
|
}
|
||||||
|
if (raw.length < 15) return null;
|
||||||
|
return validateNir(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formateur de saisie : affiche le NIR formaté au fil de la frappe et limite à 15 caractères utiles.
|
||||||
class NirInputFormatter extends TextInputFormatter {
|
class NirInputFormatter extends TextInputFormatter {
|
||||||
|
const NirInputFormatter();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
TextEditingValue formatEditUpdate(
|
TextEditingValue formatEditUpdate(
|
||||||
TextEditingValue oldValue,
|
TextEditingValue oldValue,
|
||||||
TextEditingValue newValue,
|
TextEditingValue newValue,
|
||||||
) {
|
) {
|
||||||
final raw = normalizeNir(newValue.text);
|
final raw = normalizeNir(newValue.text);
|
||||||
if (raw.isEmpty) return newValue;
|
if (raw.isEmpty) {
|
||||||
|
return const TextEditingValue(
|
||||||
|
text: '',
|
||||||
|
selection: TextSelection.collapsed(offset: 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
final formatted = formatNir(raw);
|
final formatted = formatNir(raw);
|
||||||
final offset = formatted.length;
|
|
||||||
return TextEditingValue(
|
return TextEditingValue(
|
||||||
text: formatted,
|
text: formatted,
|
||||||
selection: TextSelection.collapsed(offset: offset),
|
selection: TextSelection.collapsed(offset: formatted.length),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
92
frontend/lib/widgets/admin/am_dossier_create_modal.dart
Normal file
92
frontend/lib/widgets/admin/am_dossier_create_modal.dart
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart';
|
||||||
|
|
||||||
|
/// Modale de création dossier AM (#156) — même shell que [ValidationDossierModal].
|
||||||
|
class AmDossierCreateModal extends StatefulWidget {
|
||||||
|
final VoidCallback onClose;
|
||||||
|
final VoidCallback? onSuccess;
|
||||||
|
|
||||||
|
const AmDossierCreateModal({
|
||||||
|
super.key,
|
||||||
|
required this.onClose,
|
||||||
|
this.onSuccess,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AmDossierCreateModal> createState() => _AmDossierCreateModalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AmDossierCreateModalState extends State<AmDossierCreateModal> {
|
||||||
|
int? _stepIndex;
|
||||||
|
int? _stepTotal;
|
||||||
|
|
||||||
|
void _onStepChanged(int step, int total) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_stepIndex = step;
|
||||||
|
_stepTotal = total;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSuccess() {
|
||||||
|
widget.onSuccess?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
static const double _modalWidth = 930;
|
||||||
|
/// Hauteur calculée depuis 4 lignes de TF (voir [AmDossierWizard.shellBodyHeight]).
|
||||||
|
static double get _bodyHeight => AmDossierWizard.shellBodyHeight;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final maxH = MediaQuery.of(context).size.height * 0.85;
|
||||||
|
final showStep =
|
||||||
|
_stepIndex != null && _stepTotal != null && (_stepTotal ?? 0) > 0;
|
||||||
|
return Dialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxWidth: _modalWidth, maxHeight: maxH),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(18, 18, 0, 12),
|
||||||
|
child: Text(
|
||||||
|
'Nouvelle assistante maternelle',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (showStep) ...[
|
||||||
|
Text(
|
||||||
|
'Étape ${(_stepIndex ?? 0) + 1}/${_stepTotal ?? 1}',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
color: Colors.black54,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: widget.onClose,
|
||||||
|
tooltip: 'Fermer',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
SizedBox(
|
||||||
|
height: _bodyHeight,
|
||||||
|
child: AmDossierWizard.create(
|
||||||
|
onClose: widget.onClose,
|
||||||
|
onSuccess: _onSuccess,
|
||||||
|
onStepChanged: _onStepChanged,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
965
frontend/lib/widgets/admin/am_dossier_wizard.dart
Normal file
965
frontend/lib/widgets/admin/am_dossier_wizard.dart
Normal file
@ -0,0 +1,965 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:p_tits_pas/models/am_registration_data.dart';
|
||||||
|
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||||||
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||||
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
||||||
|
import 'package:p_tits_pas/utils/email_utils.dart';
|
||||||
|
import 'package:p_tits_pas/utils/name_format_utils.dart';
|
||||||
|
import 'package:p_tits_pas/utils/nir_utils.dart';
|
||||||
|
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||||
|
import 'package:p_tits_pas/utils/postal_utils.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/validation_refus_form.dart';
|
||||||
|
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 }
|
||||||
|
|
||||||
|
/// Wizard dossier AM — modes [review] (validation pending) et [create] (staff #156).
|
||||||
|
class AmDossierWizard extends StatefulWidget {
|
||||||
|
final AmDossierWizardMode mode;
|
||||||
|
final DossierAM? dossier;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
final VoidCallback onSuccess;
|
||||||
|
final void Function(int step, int total)? onStepChanged;
|
||||||
|
|
||||||
|
const AmDossierWizard._({
|
||||||
|
super.key,
|
||||||
|
required this.mode,
|
||||||
|
this.dossier,
|
||||||
|
required this.onClose,
|
||||||
|
required this.onSuccess,
|
||||||
|
this.onStepChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AmDossierWizard.review({
|
||||||
|
Key? key,
|
||||||
|
required DossierAM dossier,
|
||||||
|
required VoidCallback onClose,
|
||||||
|
required VoidCallback onSuccess,
|
||||||
|
void Function(int step, int total)? onStepChanged,
|
||||||
|
}) {
|
||||||
|
return AmDossierWizard._(
|
||||||
|
key: key,
|
||||||
|
mode: AmDossierWizardMode.review,
|
||||||
|
dossier: dossier,
|
||||||
|
onClose: onClose,
|
||||||
|
onSuccess: onSuccess,
|
||||||
|
onStepChanged: onStepChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory AmDossierWizard.create({
|
||||||
|
Key? key,
|
||||||
|
required VoidCallback onClose,
|
||||||
|
required VoidCallback onSuccess,
|
||||||
|
void Function(int step, int total)? onStepChanged,
|
||||||
|
}) {
|
||||||
|
return AmDossierWizard._(
|
||||||
|
key: key,
|
||||||
|
mode: AmDossierWizardMode.create,
|
||||||
|
onClose: onClose,
|
||||||
|
onSuccess: onSuccess,
|
||||||
|
onStepChanged: onStepChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isCreate => mode == AmDossierWizardMode.create;
|
||||||
|
|
||||||
|
/// Hauteur corps modale AM — dérivée de [ValidationFormMetrics] (4 lignes).
|
||||||
|
static double get shellBodyHeight =>
|
||||||
|
ValidationFormMetrics.shellBodyHeightForRows(4);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AmDossierWizard> createState() => _AmDossierWizardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AmDossierWizardState extends State<AmDossierWizard> {
|
||||||
|
int _step = 0;
|
||||||
|
bool _showRefusForm = false;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
static const int _stepCount = 3;
|
||||||
|
static const List<int> _photoProRowLayout = [2, 2, 2, 2];
|
||||||
|
static const double _idPhotoAspectRatio = 35 / 45;
|
||||||
|
static const double _photoProGap = 24;
|
||||||
|
static const double _proColumnMinWidth = 260;
|
||||||
|
static const double _photoColumnMinWidth = 160;
|
||||||
|
|
||||||
|
// --- Create mode controllers / state ---
|
||||||
|
late final TextEditingController _nomCtrl;
|
||||||
|
late final TextEditingController _prenomCtrl;
|
||||||
|
late final TextEditingController _telCtrl;
|
||||||
|
late final TextEditingController _emailCtrl;
|
||||||
|
late final TextEditingController _adresseCtrl;
|
||||||
|
late final TextEditingController _cpCtrl;
|
||||||
|
late final TextEditingController _villeCtrl;
|
||||||
|
late final TextEditingController _nirCtrl;
|
||||||
|
late final TextEditingController _dateNaissanceCtrl;
|
||||||
|
late final TextEditingController _lieuNaissanceVilleCtrl;
|
||||||
|
late final TextEditingController _lieuNaissancePaysCtrl;
|
||||||
|
late final TextEditingController _agrementCtrl;
|
||||||
|
late final TextEditingController _dateAgrementCtrl;
|
||||||
|
late final TextEditingController _capaciteCtrl;
|
||||||
|
late final TextEditingController _placesCtrl;
|
||||||
|
late final TextEditingController _presentationCtrl;
|
||||||
|
|
||||||
|
Uint8List? _photoBytes;
|
||||||
|
String? _photoFilename;
|
||||||
|
|
||||||
|
bool get _isCreate => widget.isCreate;
|
||||||
|
DossierAM get _dossier => widget.dossier!;
|
||||||
|
bool get _isEnAttente =>
|
||||||
|
!_isCreate && _dossier.user.statut == 'en_attente';
|
||||||
|
|
||||||
|
static String _v(String? s) =>
|
||||||
|
(s != null && s.trim().isNotEmpty) ? s.trim() : '–';
|
||||||
|
|
||||||
|
static String _formatNirForDisplay(String? nir) {
|
||||||
|
final v = _v(nir);
|
||||||
|
if (v == '–') return v;
|
||||||
|
final raw = nirToRaw(v).toUpperCase();
|
||||||
|
return raw.length == 15 ? formatNir(raw) : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_nomCtrl = TextEditingController();
|
||||||
|
_prenomCtrl = TextEditingController();
|
||||||
|
_telCtrl = TextEditingController();
|
||||||
|
_emailCtrl = TextEditingController();
|
||||||
|
_adresseCtrl = TextEditingController();
|
||||||
|
_cpCtrl = TextEditingController();
|
||||||
|
_villeCtrl = TextEditingController();
|
||||||
|
_nirCtrl = TextEditingController();
|
||||||
|
_dateNaissanceCtrl = TextEditingController();
|
||||||
|
_lieuNaissanceVilleCtrl = TextEditingController();
|
||||||
|
_lieuNaissancePaysCtrl = TextEditingController(text: 'France');
|
||||||
|
_agrementCtrl = TextEditingController();
|
||||||
|
_dateAgrementCtrl = TextEditingController();
|
||||||
|
_capaciteCtrl = TextEditingController(text: '1');
|
||||||
|
_placesCtrl = TextEditingController(text: '1');
|
||||||
|
_presentationCtrl = TextEditingController();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nomCtrl.dispose();
|
||||||
|
_prenomCtrl.dispose();
|
||||||
|
_telCtrl.dispose();
|
||||||
|
_emailCtrl.dispose();
|
||||||
|
_adresseCtrl.dispose();
|
||||||
|
_cpCtrl.dispose();
|
||||||
|
_villeCtrl.dispose();
|
||||||
|
_nirCtrl.dispose();
|
||||||
|
_dateNaissanceCtrl.dispose();
|
||||||
|
_lieuNaissanceVilleCtrl.dispose();
|
||||||
|
_lieuNaissancePaysCtrl.dispose();
|
||||||
|
_agrementCtrl.dispose();
|
||||||
|
_dateAgrementCtrl.dispose();
|
||||||
|
_capaciteCtrl.dispose();
|
||||||
|
_placesCtrl.dispose();
|
||||||
|
_presentationCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
|
||||||
|
|
||||||
|
List<AdminDetailField> _photoProFields(DossierAM d) {
|
||||||
|
final u = d.user;
|
||||||
|
return [
|
||||||
|
AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Date de naissance',
|
||||||
|
value: formatIsoDateFr(u.dateNaissance),
|
||||||
|
),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Ville de naissance',
|
||||||
|
value: _v(u.lieuNaissanceVille),
|
||||||
|
),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Pays de naissance',
|
||||||
|
value: _v(u.lieuNaissancePays),
|
||||||
|
),
|
||||||
|
AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Date d’agrément',
|
||||||
|
value: formatIsoDateFr(d.dateAgrement),
|
||||||
|
),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Capacité max (enfants)',
|
||||||
|
value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '–',
|
||||||
|
),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Places disponibles',
|
||||||
|
value: d.placesDisponibles != null
|
||||||
|
? d.placesDisponibles.toString()
|
||||||
|
: '–',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url);
|
||||||
|
|
||||||
|
Widget _buildPhotoSectionReview(AppUser u) {
|
||||||
|
final photoUrl = _fullPhotoUrl(u.photoUrl);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, c) {
|
||||||
|
const uniformFrame = 8.0;
|
||||||
|
final maxPhotoW =
|
||||||
|
(c.maxWidth - 2 * uniformFrame).clamp(0.0, double.infinity);
|
||||||
|
final maxPhotoH =
|
||||||
|
(c.maxHeight - 2 * uniformFrame).clamp(0.0, double.infinity);
|
||||||
|
const ar = _idPhotoAspectRatio;
|
||||||
|
double ph = maxPhotoH;
|
||||||
|
double pw = ph * ar;
|
||||||
|
if (pw > maxPhotoW) {
|
||||||
|
pw = maxPhotoW;
|
||||||
|
ph = pw / ar;
|
||||||
|
}
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(uniformFrame),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: SizedBox(
|
||||||
|
width: pw,
|
||||||
|
height: ph,
|
||||||
|
child: photoUrl.isEmpty
|
||||||
|
? ColoredBox(
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.person_off_outlined,
|
||||||
|
size: 40,
|
||||||
|
color: Colors.grey.shade400),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Aucune photo fournie',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: AuthNetworkImage(
|
||||||
|
url: photoUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
width: pw,
|
||||||
|
height: ph,
|
||||||
|
loadingBuilder: (_, child, progress) {
|
||||||
|
if (progress == null) return child;
|
||||||
|
return ColoredBox(
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
child: Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
value: progress.expectedTotalBytes !=
|
||||||
|
null
|
||||||
|
? progress.cumulativeBytesLoaded /
|
||||||
|
(progress.expectedTotalBytes!)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
errorBuilder: (_, __, ___) => ColoredBox(
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment:
|
||||||
|
MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.broken_image_outlined,
|
||||||
|
size: 40,
|
||||||
|
color: Colors.grey.shade400),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Impossible de charger la photo',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickPhoto() async {
|
||||||
|
try {
|
||||||
|
final picked = await ImagePicker().pickImage(
|
||||||
|
source: ImageSource.gallery,
|
||||||
|
maxWidth: 1200,
|
||||||
|
imageQuality: 85,
|
||||||
|
);
|
||||||
|
if (picked == null) return;
|
||||||
|
final bytes = await picked.readAsBytes();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_photoBytes = bytes;
|
||||||
|
_photoFilename = picked.name;
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('Impossible de charger la photo'),
|
||||||
|
backgroundColor: Colors.red.shade700,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearPhoto() => setState(() {
|
||||||
|
_photoBytes = null;
|
||||||
|
_photoFilename = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
String? _validateStep0() {
|
||||||
|
final nom = _nomCtrl.text.trim();
|
||||||
|
final prenom = _prenomCtrl.text.trim();
|
||||||
|
if (nom.length < 2) return 'Le nom doit contenir au moins 2 caractères.';
|
||||||
|
if (prenom.length < 2) {
|
||||||
|
return 'Le prénom doit contenir au moins 2 caractères.';
|
||||||
|
}
|
||||||
|
final phoneErr = validateFrenchNationalPhone(_telCtrl.text);
|
||||||
|
if (phoneErr != null) return phoneErr;
|
||||||
|
final emailErr = validateEmail(_emailCtrl.text);
|
||||||
|
if (emailErr != null) return emailErr;
|
||||||
|
if (_adresseCtrl.text.trim().isEmpty) {
|
||||||
|
return 'L’adresse est requise.';
|
||||||
|
}
|
||||||
|
final cpErr = validateFrenchPostalCode(_cpCtrl.text);
|
||||||
|
if (cpErr != null) return cpErr;
|
||||||
|
if (_villeCtrl.text.trim().isEmpty) return 'La ville est requise.';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validateStep1() {
|
||||||
|
if (_photoBytes == null || _photoBytes!.isEmpty) {
|
||||||
|
return 'Une photo de profil est requise.';
|
||||||
|
}
|
||||||
|
final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text);
|
||||||
|
if (birthIso == null) {
|
||||||
|
return 'La date de naissance est invalide (jj/mm/aaaa).';
|
||||||
|
}
|
||||||
|
final ville = _lieuNaissanceVilleCtrl.text.trim();
|
||||||
|
final pays = _lieuNaissancePaysCtrl.text.trim();
|
||||||
|
if (ville.length < 2 || pays.length < 2) {
|
||||||
|
return 'La ville et le pays de naissance sont requis (au moins 2 caractères).';
|
||||||
|
}
|
||||||
|
final nirErr = validateNir(_nirCtrl.text);
|
||||||
|
if (nirErr != null) return nirErr;
|
||||||
|
if (_agrementCtrl.text.trim().isEmpty) {
|
||||||
|
return 'Le numéro d’agrément est requis.';
|
||||||
|
}
|
||||||
|
final agrementIso = parseFrDateToIso(_dateAgrementCtrl.text);
|
||||||
|
if (agrementIso == null) {
|
||||||
|
return 'La date d’agrément est invalide (jj/mm/aaaa).';
|
||||||
|
}
|
||||||
|
final capa = int.tryParse(_capaciteCtrl.text.trim());
|
||||||
|
if (capa == null || capa < 1 || capa > kAmCapaciteAccueilMax) {
|
||||||
|
return 'La capacité doit être entre 1 et $kAmCapaciteAccueilMax.';
|
||||||
|
}
|
||||||
|
final places = int.tryParse(_placesCtrl.text.trim());
|
||||||
|
if (places == null || places < 0 || places > capa) {
|
||||||
|
return 'Les places disponibles doivent être entre 0 et la capacité.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validateCurrentStep() {
|
||||||
|
if (!_isCreate) return null;
|
||||||
|
switch (_step) {
|
||||||
|
case 0:
|
||||||
|
return _validateStep0();
|
||||||
|
case 1:
|
||||||
|
return _validateStep1();
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _goNext() {
|
||||||
|
final err = _validateCurrentStep();
|
||||||
|
if (err != null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(err), backgroundColor: Colors.red.shade700),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _step++);
|
||||||
|
_emitStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _imageMimeForBytes(Uint8List bytes) {
|
||||||
|
if (bytes.length >= 8 &&
|
||||||
|
bytes[0] == 0x89 &&
|
||||||
|
bytes[1] == 0x50 &&
|
||||||
|
bytes[2] == 0x4E &&
|
||||||
|
bytes[3] == 0x47) {
|
||||||
|
return 'image/png';
|
||||||
|
}
|
||||||
|
if (bytes.length >= 3 &&
|
||||||
|
bytes[0] == 0xFF &&
|
||||||
|
bytes[1] == 0xD8 &&
|
||||||
|
bytes[2] == 0xFF) {
|
||||||
|
return 'image/jpeg';
|
||||||
|
}
|
||||||
|
if (bytes.length >= 6 &&
|
||||||
|
bytes[0] == 0x47 &&
|
||||||
|
bytes[1] == 0x49 &&
|
||||||
|
bytes[2] == 0x46) {
|
||||||
|
return 'image/gif';
|
||||||
|
}
|
||||||
|
if (bytes.length >= 12 &&
|
||||||
|
bytes[0] == 0x52 &&
|
||||||
|
bytes[1] == 0x49 &&
|
||||||
|
bytes[2] == 0x46 &&
|
||||||
|
bytes[3] == 0x46) {
|
||||||
|
return 'image/webp';
|
||||||
|
}
|
||||||
|
return 'image/jpeg';
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _buildCreateBody() {
|
||||||
|
final bytes = _photoBytes!;
|
||||||
|
final mime = _imageMimeForBytes(bytes);
|
||||||
|
final photoBase64 = 'data:$mime;base64,${base64Encode(bytes)}';
|
||||||
|
final fn = (_photoFilename ?? '').trim();
|
||||||
|
final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text)!;
|
||||||
|
final agrementIso = parseFrDateToIso(_dateAgrementCtrl.text)!;
|
||||||
|
final capa = int.parse(_capaciteCtrl.text.trim());
|
||||||
|
final places = int.parse(_placesCtrl.text.trim());
|
||||||
|
final presentation = _presentationCtrl.text.trim();
|
||||||
|
|
||||||
|
return <String, dynamic>{
|
||||||
|
'email': normalizeEmailText(_emailCtrl.text),
|
||||||
|
'prenom': formatPersonNameCase(_prenomCtrl.text),
|
||||||
|
'nom': formatPersonNameCase(_nomCtrl.text),
|
||||||
|
'telephone': normalizePhone(_telCtrl.text),
|
||||||
|
'adresse':
|
||||||
|
_adresseCtrl.text.trim().isNotEmpty ? _adresseCtrl.text.trim() : null,
|
||||||
|
'code_postal':
|
||||||
|
_cpCtrl.text.trim().isNotEmpty ? _cpCtrl.text.trim() : null,
|
||||||
|
'ville': _villeCtrl.text.trim().isNotEmpty
|
||||||
|
? formatPersonNameCase(_villeCtrl.text)
|
||||||
|
: null,
|
||||||
|
'photo_base64': photoBase64,
|
||||||
|
'photo_filename': fn.isNotEmpty ? fn : 'photo_am.jpg',
|
||||||
|
'consentement_photo': true,
|
||||||
|
'date_naissance': birthIso,
|
||||||
|
'lieu_naissance_ville':
|
||||||
|
formatPersonNameCase(_lieuNaissanceVilleCtrl.text),
|
||||||
|
'lieu_naissance_pays':
|
||||||
|
formatPersonNameCase(_lieuNaissancePaysCtrl.text),
|
||||||
|
'nir': normalizeNir(_nirCtrl.text),
|
||||||
|
'numero_agrement': _agrementCtrl.text.trim(),
|
||||||
|
'date_agrement': agrementIso,
|
||||||
|
'capacite_accueil': capa,
|
||||||
|
'places_disponibles': places,
|
||||||
|
'biographie': presentation.isNotEmpty ? presentation : null,
|
||||||
|
'acceptation_cgu': true,
|
||||||
|
'acceptation_privacy': true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createAndValidate() async {
|
||||||
|
if (_submitting) return;
|
||||||
|
final err0 = _validateStep0();
|
||||||
|
if (err0 != null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(err0), backgroundColor: Colors.red.shade700),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final err1 = _validateStep1();
|
||||||
|
if (err1 != null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(err1), backgroundColor: Colors.red.shade700),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final ok = await showValidationValiderConfirmDialog(
|
||||||
|
context,
|
||||||
|
body:
|
||||||
|
'Créer et activer le dossier de cette assistante maternelle ? Un e-mail de création de mot de passe sera envoyé.',
|
||||||
|
);
|
||||||
|
if (!mounted || !ok) return;
|
||||||
|
|
||||||
|
setState(() => _submitting = true);
|
||||||
|
try {
|
||||||
|
await UserService.createAmDossier(_buildCreateBody());
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Dossier créé et validé. L’assistante maternelle est active.',
|
||||||
|
),
|
||||||
|
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) {
|
||||||
|
return _buildRefusPage();
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Expanded(child: _buildStepContent()),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildNavigation(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStepContent() {
|
||||||
|
switch (_step) {
|
||||||
|
case 0:
|
||||||
|
return _buildStep0();
|
||||||
|
case 1:
|
||||||
|
return _buildStep1();
|
||||||
|
case 2:
|
||||||
|
return _buildStep2();
|
||||||
|
default:
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStep0() {
|
||||||
|
if (_isCreate) {
|
||||||
|
return IdentityBlock.editable(
|
||||||
|
title: 'Identité et coordonnées',
|
||||||
|
nomController: _nomCtrl,
|
||||||
|
prenomController: _prenomCtrl,
|
||||||
|
telephoneController: _telCtrl,
|
||||||
|
emailController: _emailCtrl,
|
||||||
|
adresseController: _adresseCtrl,
|
||||||
|
codePostalController: _cpCtrl,
|
||||||
|
villeController: _villeCtrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final u = _dossier.user;
|
||||||
|
return IdentityBlock.readOnlyFromUser(
|
||||||
|
u,
|
||||||
|
title: 'Identité et coordonnées',
|
||||||
|
emptyLabel: '–',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStep1() {
|
||||||
|
// Modale calée sur les TF ; photo étirée sur toute la hauteur utile
|
||||||
|
// (largeur = [AdminAmPhotoFrame.columnWidthForHeight], cadre inclus).
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, c) {
|
||||||
|
final maxRowW = c.maxWidth;
|
||||||
|
final maxRowH = c.maxHeight.clamp(0.0, double.infinity);
|
||||||
|
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
|
||||||
|
.clamp(0.0, double.infinity);
|
||||||
|
var photoW = AdminAmPhotoFrame.columnWidthForHeight(maxRowH)
|
||||||
|
.clamp(_photoColumnMinWidth, 360.0);
|
||||||
|
if (photoW > maxPhotoW) photoW = maxPhotoW;
|
||||||
|
|
||||||
|
final form = _isCreate
|
||||||
|
? _buildCreateProFields()
|
||||||
|
: ValidationDetailSection(
|
||||||
|
title: 'Dossier professionnel',
|
||||||
|
fields: _photoProFields(_dossier),
|
||||||
|
rowLayout: _photoProRowLayout,
|
||||||
|
);
|
||||||
|
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
const SizedBox(width: _photoProGap),
|
||||||
|
Expanded(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: form,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCreateProFields() {
|
||||||
|
return ValidationFormGrid(
|
||||||
|
title: 'Dossier professionnel',
|
||||||
|
rowLayout: _photoProRowLayout,
|
||||||
|
fields: [
|
||||||
|
ValidationLabeledField(
|
||||||
|
label: 'NIR',
|
||||||
|
field: ValidationNirField(controller: _nirCtrl),
|
||||||
|
),
|
||||||
|
ValidationLabeledField(
|
||||||
|
label: 'Date de naissance',
|
||||||
|
field: ValidationEditableField(
|
||||||
|
controller: _dateNaissanceCtrl,
|
||||||
|
hintText: 'jj / mm / aaaa',
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: const [FrenchDateInputFormatter()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ValidationLabeledField(
|
||||||
|
label: 'Ville de naissance',
|
||||||
|
field: ValidationEditableField(
|
||||||
|
controller: _lieuNaissanceVilleCtrl,
|
||||||
|
inputFormatters: const [PersonNameInputFormatter()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ValidationLabeledField(
|
||||||
|
label: 'Pays de naissance',
|
||||||
|
field: ValidationEditableField(
|
||||||
|
controller: _lieuNaissancePaysCtrl,
|
||||||
|
inputFormatters: const [PersonNameInputFormatter()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ValidationLabeledField(
|
||||||
|
label: 'N° Agrément',
|
||||||
|
field: ValidationEditableField(controller: _agrementCtrl),
|
||||||
|
),
|
||||||
|
ValidationLabeledField(
|
||||||
|
label: 'Date d’agrément',
|
||||||
|
field: ValidationEditableField(
|
||||||
|
controller: _dateAgrementCtrl,
|
||||||
|
hintText: 'jj / mm / aaaa',
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: const [FrenchDateInputFormatter()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ValidationLabeledField(
|
||||||
|
label: 'Capacité max (enfants)',
|
||||||
|
field: ValidationEditableField(
|
||||||
|
controller: _capaciteCtrl,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ValidationLabeledField(
|
||||||
|
label: 'Places disponibles',
|
||||||
|
field: ValidationEditableField(
|
||||||
|
controller: _placesCtrl,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStep2() {
|
||||||
|
if (_isCreate) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Présentation',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _presentationCtrl,
|
||||||
|
maxLines: null,
|
||||||
|
expands: true,
|
||||||
|
maxLength: 2000,
|
||||||
|
textAlignVertical: TextAlignVertical.top,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Présentation (optionnel, max 2000 caractères)',
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade50,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final presentation =
|
||||||
|
(_dossier.presentation != null && _dossier.presentation!.trim().isNotEmpty)
|
||||||
|
? _dossier.presentation!
|
||||||
|
: '–';
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Présentation',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Expanded(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
child: SelectableText(
|
||||||
|
presentation,
|
||||||
|
style: const TextStyle(color: Colors.black87, fontSize: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNavigation() {
|
||||||
|
if (_step == 2) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
|
||||||
|
const Spacer(),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => _step = 1);
|
||||||
|
_emitStep();
|
||||||
|
},
|
||||||
|
child: const Text('Précédent'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (_isCreate) ...[
|
||||||
|
ElevatedButton(
|
||||||
|
style: ValidationModalTheme.primaryElevatedStyle,
|
||||||
|
onPressed: _submitting ? null : _createAndValidate,
|
||||||
|
child: Text(_submitting ? 'Envoi...' : 'Créer et valider'),
|
||||||
|
),
|
||||||
|
] else if (_isEnAttente) ...[
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: _submitting ? null : _refuser,
|
||||||
|
child: const Text('Refuser')),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ValidationModalTheme.primaryElevatedStyle,
|
||||||
|
onPressed: _submitting ? null : _onValiderPressed,
|
||||||
|
child: Text(_submitting ? 'Envoi...' : 'Valider'),
|
||||||
|
),
|
||||||
|
] else
|
||||||
|
ElevatedButton(
|
||||||
|
style: ValidationModalTheme.primaryElevatedStyle,
|
||||||
|
onPressed: widget.onClose,
|
||||||
|
child: const Text('Fermer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
|
||||||
|
const Spacer(),
|
||||||
|
if (_step > 0) ...[
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => _step--);
|
||||||
|
_emitStep();
|
||||||
|
},
|
||||||
|
child: const Text('Précédent'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
ElevatedButton(
|
||||||
|
style: ValidationModalTheme.primaryElevatedStyle,
|
||||||
|
onPressed: _goNext,
|
||||||
|
child: const Text('Suivant'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onValiderPressed() async {
|
||||||
|
if (_submitting) return;
|
||||||
|
final ok = await showValidationValiderConfirmDialog(
|
||||||
|
context,
|
||||||
|
body:
|
||||||
|
'Voulez-vous valider le dossier de cette assistante maternelle ? Cette action confirme le compte.',
|
||||||
|
);
|
||||||
|
if (!mounted || !ok) return;
|
||||||
|
await _valider();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _valider() async {
|
||||||
|
if (_submitting) return;
|
||||||
|
setState(() => _submitting = true);
|
||||||
|
try {
|
||||||
|
await UserService.validateUser(_dossier.user.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _refuser() => setState(() => _showRefusForm = true);
|
||||||
|
|
||||||
|
Widget _buildRefusPage() {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ValidationRefusForm(
|
||||||
|
isSubmitting: _submitting,
|
||||||
|
onCancel: widget.onClose,
|
||||||
|
onPrevious: () => setState(() => _showRefusForm = false),
|
||||||
|
onSubmit: (comment) {
|
||||||
|
if (comment == null || comment.trim().isEmpty) return;
|
||||||
|
_refuserEnvoyer(comment.trim());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refuserEnvoyer(String comment) async {
|
||||||
|
if (_submitting) return;
|
||||||
|
setState(() => _submitting = true);
|
||||||
|
try {
|
||||||
|
await UserService.refuseUser(_dossier.user.id, comment: comment);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.',
|
||||||
|
),
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -446,8 +446,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('Détacher l\'enfant'),
|
title: const Text('Détacher l\'enfant'),
|
||||||
content: Text(
|
content: Text(
|
||||||
'Retirer ${child.fullName} de la fiche de cette assistante ?\n'
|
'Retirer ${child.fullName} de la fiche de cette assistante ?',
|
||||||
'(L\'enfant ne sera pas supprimé.)',
|
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
|
|||||||
@ -1,14 +1,27 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||||
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
||||||
|
|
||||||
/// Cadre photo identité AM (35×45 mm) — même logique que [ValidationAmWizard].
|
/// Cadre photo identité AM / enfant (35×45 mm) — même logique que [ValidationAmWizard].
|
||||||
class AdminAmPhotoFrame extends StatelessWidget {
|
class AdminAmPhotoFrame extends StatelessWidget {
|
||||||
final String? photoUrl;
|
final String? photoUrl;
|
||||||
|
final Uint8List? imageBytes;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final VoidCallback? onClear;
|
||||||
|
final String emptyLabel;
|
||||||
|
|
||||||
static const double idPhotoAspectRatio = 35 / 45;
|
static const double idPhotoAspectRatio = 35 / 45;
|
||||||
|
|
||||||
const AdminAmPhotoFrame({super.key, this.photoUrl});
|
const AdminAmPhotoFrame({
|
||||||
|
super.key,
|
||||||
|
this.photoUrl,
|
||||||
|
this.imageBytes,
|
||||||
|
this.onTap,
|
||||||
|
this.onClear,
|
||||||
|
this.emptyLabel = 'Aucune photo',
|
||||||
|
});
|
||||||
|
|
||||||
/// Largeur colonne photo pour remplir [height] (cadre inclus).
|
/// Largeur colonne photo pour remplir [height] (cadre inclus).
|
||||||
static double columnWidthForHeight(double height) {
|
static double columnWidthForHeight(double height) {
|
||||||
@ -36,9 +49,12 @@ class AdminAmPhotoFrame extends StatelessWidget {
|
|||||||
ph = pw / ar;
|
ph = pw / ar;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final hasLocal = imageBytes != null && imageBytes!.isNotEmpty;
|
||||||
|
final showClear = onClear != null && hasLocal;
|
||||||
|
|
||||||
// Cadre gris = taille photo + padding uniforme ; centré dans la colonne
|
// Cadre gris = taille photo + padding uniforme ; centré dans la colonne
|
||||||
// (évite le vide blanc en bas quand le conteneur parent est plus haut).
|
// (évite le vide blanc en bas quand le conteneur parent est plus haut).
|
||||||
return Align(
|
Widget frame = Align(
|
||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@ -60,22 +76,81 @@ class AdminAmPhotoFrame extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (onTap != null) {
|
||||||
|
frame = MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: frame,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!showClear) return frame;
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
frame,
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: IconButton(
|
||||||
|
tooltip: 'Retirer la photo',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
minWidth: 32,
|
||||||
|
minHeight: 32,
|
||||||
|
),
|
||||||
|
icon: Icon(
|
||||||
|
Icons.cancel,
|
||||||
|
size: 22,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
onPressed: onClear,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _photoContent(String fullUrl, double pw, double ph) {
|
Widget _photoContent(String fullUrl, double pw, double ph) {
|
||||||
|
if (imageBytes != null && imageBytes!.isNotEmpty) {
|
||||||
|
return Image.memory(
|
||||||
|
imageBytes!,
|
||||||
|
width: pw,
|
||||||
|
height: ph,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (fullUrl.isEmpty) {
|
if (fullUrl.isEmpty) {
|
||||||
return ColoredBox(
|
return ColoredBox(
|
||||||
color: Colors.grey.shade200,
|
color: Colors.grey.shade200,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.person_off_outlined, size: 36, color: Colors.grey.shade400),
|
Icon(
|
||||||
|
onTap != null ? Icons.add_a_photo_outlined : Icons.person_off_outlined,
|
||||||
|
size: 36,
|
||||||
|
color: Colors.grey.shade400,
|
||||||
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Padding(
|
||||||
'Aucune photo',
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 11),
|
child: Text(
|
||||||
|
emptyLabel,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.grey.shade600, fontSize: 11),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -108,7 +183,11 @@ class AdminAmPhotoFrame extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
errorBuilder: (_, __, ___) => ColoredBox(
|
errorBuilder: (_, __, ___) => ColoredBox(
|
||||||
color: Colors.grey.shade200,
|
color: Colors.grey.shade200,
|
||||||
child: Icon(Icons.broken_image_outlined, size: 36, color: Colors.grey.shade400),
|
child: Icon(
|
||||||
|
Icons.broken_image_outlined,
|
||||||
|
size: 36,
|
||||||
|
color: Colors.grey.shade400,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
||||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||||
@ -6,26 +8,37 @@ import 'package:p_tits_pas/services/auth_service.dart';
|
|||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
||||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||||
|
import 'package:p_tits_pas/utils/name_format_utils.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_select_am_modal.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_select_am_modal.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_select_famille_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
||||||
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
||||||
|
|
||||||
/// Fiche enfant consultation / édition (ticket #138) — format paysage comme fiche AM.
|
/// Fiche enfant consultation / édition (#138) ou création (#132).
|
||||||
class AdminChildDetailModal extends StatefulWidget {
|
class AdminChildDetailModal extends StatefulWidget {
|
||||||
final EnfantAdminModel enfant;
|
final EnfantAdminModel? enfant;
|
||||||
final VoidCallback? onSaved;
|
final VoidCallback? onSaved;
|
||||||
final VoidCallback? onDeleted;
|
final VoidCallback? onDeleted;
|
||||||
|
final bool isCreating;
|
||||||
|
|
||||||
const AdminChildDetailModal({
|
const AdminChildDetailModal({
|
||||||
super.key,
|
super.key,
|
||||||
required this.enfant,
|
required EnfantAdminModel this.enfant,
|
||||||
this.onSaved,
|
this.onSaved,
|
||||||
this.onDeleted,
|
this.onDeleted,
|
||||||
});
|
}) : isCreating = false;
|
||||||
|
|
||||||
|
/// Création depuis l'onglet Enfants (#132).
|
||||||
|
const AdminChildDetailModal.create({
|
||||||
|
super.key,
|
||||||
|
this.onSaved,
|
||||||
|
}) : enfant = null,
|
||||||
|
onDeleted = null,
|
||||||
|
isCreating = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AdminChildDetailModal> createState() => _AdminChildDetailModalState();
|
State<AdminChildDetailModal> createState() => _AdminChildDetailModalState();
|
||||||
@ -37,7 +50,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
late final TextEditingController _birthCtrl;
|
late final TextEditingController _birthCtrl;
|
||||||
late final TextEditingController _dueCtrl;
|
late final TextEditingController _dueCtrl;
|
||||||
late String _status;
|
late String _status;
|
||||||
late String _gender;
|
late String? _gender;
|
||||||
late bool _consentPhoto;
|
late bool _consentPhoto;
|
||||||
late bool _isMultiple;
|
late bool _isMultiple;
|
||||||
bool _dirty = false;
|
bool _dirty = false;
|
||||||
@ -49,6 +62,16 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
/// AM rattachée au chargement (pour sync différé au Sauvegarder).
|
/// AM rattachée au chargement (pour sync différé au Sauvegarder).
|
||||||
String? _baselineAmUserId;
|
String? _baselineAmUserId;
|
||||||
|
|
||||||
|
/// Famille choisie en mode création (#132).
|
||||||
|
AdminFamilleFoyer? _selectedFamily;
|
||||||
|
|
||||||
|
/// Liens parents locaux (édition) — mis à jour après rattachement foyer (#157).
|
||||||
|
List<EnfantParentLink>? _localParentLinks;
|
||||||
|
|
||||||
|
/// Photo locale (création) — upload multipart `photo`.
|
||||||
|
Uint8List? _photoBytes;
|
||||||
|
String? _photoFilename;
|
||||||
|
|
||||||
static const double _modalWidth = 930;
|
static const double _modalWidth = 930;
|
||||||
static const double _mainRowHeight = 380;
|
static const double _mainRowHeight = 380;
|
||||||
static const double _placementHeight = 96;
|
static const double _placementHeight = 96;
|
||||||
@ -74,44 +97,102 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
_isUnborn ? 'Date prévisionnelle' : 'Date de naissance';
|
_isUnborn ? 'Date prévisionnelle' : 'Date de naissance';
|
||||||
|
|
||||||
bool get _canDelete =>
|
bool get _canDelete =>
|
||||||
|
!widget.isCreating &&
|
||||||
(_currentUserRole ?? '').toLowerCase() == 'super_admin';
|
(_currentUserRole ?? '').toLowerCase() == 'super_admin';
|
||||||
|
|
||||||
bool get _busy => _saving || _deleting;
|
bool get _busy => _saving || _deleting;
|
||||||
|
|
||||||
|
/// Création : tous les champs sauf photo / consentement ; édition : au moins une modif.
|
||||||
|
bool get _canSubmit {
|
||||||
|
if (_busy) return false;
|
||||||
|
if (widget.isCreating) return _isCreateFormComplete;
|
||||||
|
return _dirty;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _isCreateFormComplete {
|
||||||
|
if (_selectedFamily == null) return false;
|
||||||
|
if (formatPersonNameCase(_prenomCtrl.text).isEmpty) return false;
|
||||||
|
if (formatPersonNameCase(_nomCtrl.text).isEmpty) return false;
|
||||||
|
if (_dateToIso(_activeDateCtrl.text) == null) return false;
|
||||||
|
if (_gender == null || !_availableGenders.contains(_gender)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final e = widget.enfant;
|
final e = widget.enfant;
|
||||||
_prenomCtrl = TextEditingController(text: e.firstName ?? '');
|
_prenomCtrl = TextEditingController(text: e?.firstName ?? '');
|
||||||
_nomCtrl = TextEditingController(text: e.lastName ?? '');
|
_nomCtrl = TextEditingController(text: e?.lastName ?? '');
|
||||||
_birthCtrl = TextEditingController(
|
_birthCtrl = TextEditingController(
|
||||||
text: formatIsoDateFr(e.birthDate, ifEmpty: ''),
|
text: formatIsoDateFrInput(e?.birthDate, ifEmpty: ''),
|
||||||
);
|
);
|
||||||
_dueCtrl = TextEditingController(
|
_dueCtrl = TextEditingController(
|
||||||
text: formatIsoDateFr(e.dueDate, ifEmpty: ''),
|
text: formatIsoDateFrInput(e?.dueDate, ifEmpty: ''),
|
||||||
);
|
);
|
||||||
_status = normalizeEnfantStatus(e.status);
|
_status = normalizeEnfantStatus(e?.status);
|
||||||
if (!enfantStatusValues.contains(_status)) {
|
if (!enfantStatusValues.contains(_status)) {
|
||||||
_status = 'sans_garde';
|
_status = 'sans_garde';
|
||||||
}
|
}
|
||||||
_gender = _normalizeGender(e.gender, allowUnknown: _isUnborn);
|
if (widget.isCreating) {
|
||||||
_consentPhoto = e.consentPhoto;
|
_gender = null;
|
||||||
_isMultiple = e.isMultiple;
|
} else {
|
||||||
for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) {
|
_gender = _normalizeGender(e?.gender, allowUnknown: _isUnborn);
|
||||||
c.addListener(_markDirty);
|
}
|
||||||
|
_consentPhoto = e?.consentPhoto ?? false;
|
||||||
|
_isMultiple = e?.isMultiple ?? false;
|
||||||
|
for (final c in [_prenomCtrl, _nomCtrl]) {
|
||||||
|
c.addListener(_onNameChanged);
|
||||||
|
}
|
||||||
|
for (final c in [_birthCtrl, _dueCtrl]) {
|
||||||
|
c.addListener(_onDateChanged);
|
||||||
}
|
}
|
||||||
_prenomCtrl.addListener(_onNameChanged);
|
|
||||||
_nomCtrl.addListener(_onNameChanged);
|
|
||||||
_loadCurrentUserRole();
|
_loadCurrentUserRole();
|
||||||
_loadLinkedAm();
|
if (widget.isCreating) {
|
||||||
|
_loadingAm = false;
|
||||||
|
_dirty = true;
|
||||||
|
} else {
|
||||||
|
_prenomCtrl.addListener(_markDirty);
|
||||||
|
_nomCtrl.addListener(_markDirty);
|
||||||
|
_loadLinkedAm();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onNameChanged() => setState(() {});
|
void _onNameChanged() => setState(() {});
|
||||||
|
|
||||||
|
void _onDateChanged() {
|
||||||
|
setState(() => _dirty = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Date prévisionnelle strictement avant aujourd’hui (statut à naître).
|
||||||
|
bool get _dueDateInPast {
|
||||||
|
if (!_isUnborn) return false;
|
||||||
|
final iso = _dateToIso(_dueCtrl.text);
|
||||||
|
if (iso == null) return false;
|
||||||
|
try {
|
||||||
|
final due = DateTime.parse(iso);
|
||||||
|
final now = DateTime.now();
|
||||||
|
final dueDay = DateTime(due.year, due.month, due.day);
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
return dueDay.isBefore(today);
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadLinkedAm() async {
|
Future<void> _loadLinkedAm() async {
|
||||||
|
if (widget.isCreating) {
|
||||||
|
setState(() => _loadingAm = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final enfantId = widget.enfant?.id;
|
||||||
|
if (enfantId == null || enfantId.isEmpty) {
|
||||||
|
setState(() => _loadingAm = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState(() => _loadingAm = true);
|
setState(() => _loadingAm = true);
|
||||||
try {
|
try {
|
||||||
final am = await UserService.findAmForEnfant(widget.enfant.id);
|
final am = await UserService.findAmForEnfant(enfantId);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_linkedAm = am;
|
_linkedAm = am;
|
||||||
@ -156,8 +237,8 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _coerceGenderForStatus() {
|
void _coerceGenderForStatus() {
|
||||||
if (!_availableGenders.contains(_gender)) {
|
if (_gender != null && !_availableGenders.contains(_gender)) {
|
||||||
_gender = 'H';
|
_gender = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -168,18 +249,29 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
String? _dateToIso(String text) => parseFrDateToIso(text);
|
String? _dateToIso(String text) => parseFrDateToIso(text);
|
||||||
|
|
||||||
String _headerTitle() {
|
String _headerTitle() {
|
||||||
|
if (widget.isCreating) {
|
||||||
|
final fn = _prenomCtrl.text.trim();
|
||||||
|
final ln = _nomCtrl.text.trim();
|
||||||
|
if (fn.isEmpty && ln.isEmpty) return 'Nouvel enfant';
|
||||||
|
return '$fn $ln'.trim();
|
||||||
|
}
|
||||||
final fn = _prenomCtrl.text.trim();
|
final fn = _prenomCtrl.text.trim();
|
||||||
final ln = _nomCtrl.text.trim();
|
final ln = _nomCtrl.text.trim();
|
||||||
if (fn.isEmpty && ln.isEmpty) {
|
if (fn.isEmpty && ln.isEmpty) {
|
||||||
final fallback = widget.enfant.fullName.trim();
|
final fallback = widget.enfant?.fullName.trim() ?? '';
|
||||||
return fallback.isNotEmpty ? fallback : 'Enfant';
|
return fallback.isNotEmpty ? fallback : 'Enfant';
|
||||||
}
|
}
|
||||||
return '$fn $ln'.trim();
|
return '$fn $ln'.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<EnfantParentLink> get _parentLinks => widget.enfant.parentLinks
|
List<EnfantParentLink> get _parentLinks =>
|
||||||
.where((l) => l.parentId.trim().isNotEmpty)
|
(_localParentLinks ??
|
||||||
.toList();
|
widget.enfant?.parentLinks ??
|
||||||
|
const <EnfantParentLink>[])
|
||||||
|
.where((l) => l.parentId.trim().isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
bool get _isOrphan => !widget.isCreating && _parentLinks.isEmpty;
|
||||||
|
|
||||||
Future<void> _openParent(EnfantParentLink link) async {
|
Future<void> _openParent(EnfantParentLink link) async {
|
||||||
if (_busy) return;
|
if (_busy) return;
|
||||||
@ -205,6 +297,17 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget? _parentsSubtitle() {
|
Widget? _parentsSubtitle() {
|
||||||
|
if (widget.isCreating) {
|
||||||
|
final family = _selectedFamily;
|
||||||
|
if (family == null) return null;
|
||||||
|
return Text(
|
||||||
|
family.parentNames.isNotEmpty
|
||||||
|
? 'Famille : ${family.parentNames.join(', ')}'
|
||||||
|
: family.displayTitle,
|
||||||
|
style: const TextStyle(fontSize: 13, color: Colors.black54),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final links = _parentLinks;
|
final links = _parentLinks;
|
||||||
if (links.isEmpty) return null;
|
if (links.isEmpty) return null;
|
||||||
|
|
||||||
@ -252,15 +355,24 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
if (!_dirty) return;
|
if (!_dirty) return;
|
||||||
|
|
||||||
|
if (widget.isCreating) {
|
||||||
|
await _saveCreate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final enfantId = widget.enfant?.id;
|
||||||
|
if (enfantId == null || enfantId.isEmpty) return;
|
||||||
|
|
||||||
setState(() => _saving = true);
|
setState(() => _saving = true);
|
||||||
try {
|
try {
|
||||||
await _syncAmPlacement();
|
await _syncAmPlacement();
|
||||||
|
|
||||||
await UserService.updateEnfant(
|
await UserService.updateEnfant(
|
||||||
enfantId: widget.enfant.id,
|
enfantId: enfantId,
|
||||||
body: {
|
body: {
|
||||||
'first_name': _prenomCtrl.text.trim(),
|
'first_name': formatPersonNameCase(_prenomCtrl.text),
|
||||||
'last_name': _nomCtrl.text.trim(),
|
'last_name': formatPersonNameCase(_nomCtrl.text),
|
||||||
'status': _status,
|
'status': _status,
|
||||||
'gender': _gender,
|
'gender': _gender,
|
||||||
if (_isUnborn)
|
if (_isUnborn)
|
||||||
@ -291,21 +403,101 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _saveCreate() async {
|
||||||
|
final family = _selectedFamily;
|
||||||
|
if (family == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Choisissez une famille / un dossier avant d\'enregistrer'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final prenom = formatPersonNameCase(_prenomCtrl.text);
|
||||||
|
final nom = formatPersonNameCase(_nomCtrl.text);
|
||||||
|
if (prenom.isEmpty || nom.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Prénom et nom sont obligatoires')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_isUnborn && _dateToIso(_birthCtrl.text) == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Date de naissance invalide ou manquante')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final hasPhoto = _photoBytes != null && _photoBytes!.isNotEmpty;
|
||||||
|
if (hasPhoto && !_consentPhoto) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Activez le consentement photo pour enregistrer une photo',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _saving = true);
|
||||||
|
try {
|
||||||
|
await UserService.createEnfant(
|
||||||
|
parentUserId: family.pivotParentUserId,
|
||||||
|
photoBytes: hasPhoto ? _photoBytes : null,
|
||||||
|
photoFilename: _photoFilename,
|
||||||
|
body: {
|
||||||
|
'first_name': prenom,
|
||||||
|
'last_name': nom,
|
||||||
|
'status': _status,
|
||||||
|
'gender': _gender,
|
||||||
|
if (_isUnborn) ...{
|
||||||
|
if (_dateToIso(_dueCtrl.text) != null)
|
||||||
|
'due_date': _dateToIso(_dueCtrl.text),
|
||||||
|
} else ...{
|
||||||
|
if (_dateToIso(_birthCtrl.text) != null)
|
||||||
|
'birth_date': _dateToIso(_birthCtrl.text),
|
||||||
|
},
|
||||||
|
'consent_photo': _consentPhoto,
|
||||||
|
'is_multiple': _isMultiple,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_dirty = false;
|
||||||
|
_saving = false;
|
||||||
|
});
|
||||||
|
widget.onSaved?.call();
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Enfant créé')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _saving = false);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Applique rattachement / détachement AM (différé jusqu'au Sauvegarder).
|
/// Applique rattachement / détachement AM (différé jusqu'au Sauvegarder).
|
||||||
Future<void> _syncAmPlacement() async {
|
Future<void> _syncAmPlacement() async {
|
||||||
|
final enfantId = widget.enfant?.id;
|
||||||
|
if (enfantId == null || enfantId.isEmpty) return;
|
||||||
|
|
||||||
final baselineId = _baselineAmUserId;
|
final baselineId = _baselineAmUserId;
|
||||||
final currentId = _linkedAm?.user.id;
|
final currentId = _linkedAm?.user.id;
|
||||||
|
|
||||||
if (baselineId != null && baselineId != currentId) {
|
if (baselineId != null && baselineId != currentId) {
|
||||||
await UserService.detachEnfantFromAm(
|
await UserService.detachEnfantFromAm(
|
||||||
amUserId: baselineId,
|
amUserId: baselineId,
|
||||||
enfantId: widget.enfant.id,
|
enfantId: enfantId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (currentId != null && currentId != baselineId) {
|
if (currentId != null && currentId != baselineId) {
|
||||||
await UserService.attachEnfantToAm(
|
await UserService.attachEnfantToAm(
|
||||||
amUserId: currentId,
|
amUserId: currentId,
|
||||||
enfantId: widget.enfant.id,
|
enfantId: enfantId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -342,7 +534,9 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
|
|
||||||
setState(() => _deleting = true);
|
setState(() => _deleting = true);
|
||||||
try {
|
try {
|
||||||
await UserService.deleteEnfant(widget.enfant.id);
|
final id = widget.enfant?.id;
|
||||||
|
if (id == null || id.isEmpty) return;
|
||||||
|
await UserService.deleteEnfant(id);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
widget.onDeleted?.call();
|
widget.onDeleted?.call();
|
||||||
widget.onSaved?.call();
|
widget.onSaved?.call();
|
||||||
@ -376,10 +570,12 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
|
|
||||||
/// Recharge AM + statut après une modale externe (ex. fiche AM).
|
/// Recharge AM + statut après une modale externe (ex. fiche AM).
|
||||||
Future<void> _reloadPlacementFromServer() async {
|
Future<void> _reloadPlacementFromServer() async {
|
||||||
|
final id = widget.enfant?.id;
|
||||||
|
if (id == null || id.isEmpty) return;
|
||||||
try {
|
try {
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
UserService.findAmForEnfant(widget.enfant.id),
|
UserService.findAmForEnfant(id),
|
||||||
UserService.getEnfant(widget.enfant.id),
|
UserService.getEnfant(id),
|
||||||
]);
|
]);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final am = results[0] as AssistanteMaternelleModel?;
|
final am = results[0] as AssistanteMaternelleModel?;
|
||||||
@ -391,6 +587,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
if (!enfantStatusValues.contains(_status)) {
|
if (!enfantStatusValues.contains(_status)) {
|
||||||
_status = 'sans_garde';
|
_status = 'sans_garde';
|
||||||
}
|
}
|
||||||
|
_localParentLinks = enfant.parentLinks;
|
||||||
_coerceGenderForStatus();
|
_coerceGenderForStatus();
|
||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@ -476,70 +673,145 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
InputDecoration _inputDecoration({String? hint}) {
|
/// Conserve la date saisie en basculant naissance ↔ prévisionnelle.
|
||||||
return ValidationFieldDecoration.input(hint: hint).copyWith(
|
void _preserveDateOnStatusChange(String from, String to) {
|
||||||
|
final becomingUnborn = to == 'a_naitre' && from != 'a_naitre';
|
||||||
|
final leavingUnborn = from == 'a_naitre' && to != 'a_naitre';
|
||||||
|
if (becomingUnborn) {
|
||||||
|
if (_dueCtrl.text.trim().isEmpty &&
|
||||||
|
_birthCtrl.text.trim().isNotEmpty) {
|
||||||
|
_dueCtrl.text = _birthCtrl.text;
|
||||||
|
}
|
||||||
|
} else if (leavingUnborn) {
|
||||||
|
if (_birthCtrl.text.trim().isEmpty &&
|
||||||
|
_dueCtrl.text.trim().isNotEmpty) {
|
||||||
|
_birthCtrl.text = _dueCtrl.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDecoration _inputDecoration({String? hint, bool error = false}) {
|
||||||
|
final base = ValidationFieldDecoration.input(hint: hint).copyWith(
|
||||||
isDense: false,
|
isDense: false,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||||
);
|
);
|
||||||
|
if (!error) return base;
|
||||||
|
final border = OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
borderSide: BorderSide(color: Colors.red.shade400),
|
||||||
|
);
|
||||||
|
return base.copyWith(
|
||||||
|
fillColor: Colors.red.shade50,
|
||||||
|
enabledBorder: border,
|
||||||
|
focusedBorder: border,
|
||||||
|
border: border,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _dropdownField({
|
Widget _dropdownField({
|
||||||
Key? key,
|
Key? key,
|
||||||
required String value,
|
required String? value,
|
||||||
required List<MapEntry<String, String>> items,
|
required List<MapEntry<String, String>> items,
|
||||||
required ValueChanged<String> onChanged,
|
required ValueChanged<String> onChanged,
|
||||||
|
String? hint,
|
||||||
}) {
|
}) {
|
||||||
final safeValue =
|
final safeValue =
|
||||||
items.any((e) => e.key == value) ? value : items.first.key;
|
value != null && items.any((e) => e.key == value) ? value : null;
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: _fieldHeight,
|
height: _fieldHeight,
|
||||||
child: DropdownButtonFormField<String>(
|
child: DropdownButtonFormField<String>(
|
||||||
key: key,
|
key: key,
|
||||||
value: safeValue,
|
value: safeValue,
|
||||||
|
hint: hint != null
|
||||||
|
? Text(hint, style: TextStyle(color: Colors.grey.shade600))
|
||||||
|
: null,
|
||||||
isExpanded: true,
|
isExpanded: true,
|
||||||
decoration: _inputDecoration(),
|
decoration: _inputDecoration(),
|
||||||
items: items
|
items: items
|
||||||
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
|
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
|
||||||
.toList(),
|
.toList(),
|
||||||
onChanged: _busy ? null : (v) {
|
onChanged: _busy
|
||||||
if (v != null) onChanged(v);
|
? null
|
||||||
},
|
: (v) {
|
||||||
|
if (v != null) onChanged(v);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _textField(TextEditingController controller, {String? hint, Key? key}) {
|
Widget _textField(
|
||||||
|
TextEditingController controller, {
|
||||||
|
String? hint,
|
||||||
|
Key? key,
|
||||||
|
TextInputType? keyboardType,
|
||||||
|
List<TextInputFormatter>? inputFormatters,
|
||||||
|
bool error = false,
|
||||||
|
Widget? suffixIcon,
|
||||||
|
}) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: _fieldHeight,
|
height: _fieldHeight,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
key: key,
|
key: key,
|
||||||
controller: controller,
|
controller: controller,
|
||||||
|
keyboardType: keyboardType,
|
||||||
|
inputFormatters: inputFormatters,
|
||||||
textAlignVertical: TextAlignVertical.center,
|
textAlignVertical: TextAlignVertical.center,
|
||||||
style: const TextStyle(color: Colors.black87, fontSize: 14),
|
style: TextStyle(
|
||||||
decoration: _inputDecoration(hint: hint),
|
color: error ? Colors.red.shade800 : Colors.black87,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
decoration: _inputDecoration(
|
||||||
|
hint: hint,
|
||||||
|
error: error,
|
||||||
|
).copyWith(suffixIcon: suffixIcon),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _dateField() {
|
||||||
|
final past = _dueDateInPast;
|
||||||
|
return _textField(
|
||||||
|
_activeDateCtrl,
|
||||||
|
hint: 'jj / mm / aaaa',
|
||||||
|
key: ValueKey(_status),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: const [FrenchDateInputFormatter()],
|
||||||
|
error: past,
|
||||||
|
suffixIcon: past
|
||||||
|
? Tooltip(
|
||||||
|
message:
|
||||||
|
'Date prévisionnelle antérieure à aujourd’hui',
|
||||||
|
child: Icon(
|
||||||
|
Icons.info_outline,
|
||||||
|
size: 20,
|
||||||
|
color: Colors.red.shade700,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _fieldsGrid() {
|
Widget _fieldsGrid() {
|
||||||
return ValidationEditableSection(
|
return ValidationEditableSection(
|
||||||
rowLayout: _fieldsRowLayout,
|
rowLayout: _fieldsRowLayout,
|
||||||
fields: [
|
fields: [
|
||||||
ValidationLabeledField(
|
ValidationLabeledField(
|
||||||
label: 'Prénom',
|
label: 'Prénom',
|
||||||
field: _textField(_prenomCtrl),
|
field: _textField(
|
||||||
|
_prenomCtrl,
|
||||||
|
inputFormatters: const [PersonNameInputFormatter()],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
ValidationLabeledField(
|
ValidationLabeledField(
|
||||||
label: 'Nom',
|
label: 'Nom',
|
||||||
field: _textField(_nomCtrl),
|
field: _textField(
|
||||||
|
_nomCtrl,
|
||||||
|
inputFormatters: const [PersonNameInputFormatter()],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
ValidationLabeledField(
|
ValidationLabeledField(
|
||||||
label: _dateFieldLabel,
|
label: _dateFieldLabel,
|
||||||
field: _textField(
|
field: _dateField(),
|
||||||
_activeDateCtrl,
|
|
||||||
hint: 'jj/mm/aaaa',
|
|
||||||
key: ValueKey(_status),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
ValidationLabeledField(
|
ValidationLabeledField(
|
||||||
label: 'Statut',
|
label: 'Statut',
|
||||||
@ -555,6 +827,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
.toList(),
|
.toList(),
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
_preserveDateOnStatusChange(_status, v);
|
||||||
_status = v;
|
_status = v;
|
||||||
_coerceGenderForStatus();
|
_coerceGenderForStatus();
|
||||||
_dirty = true;
|
_dirty = true;
|
||||||
@ -570,6 +843,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
field: _dropdownField(
|
field: _dropdownField(
|
||||||
key: ValueKey('genre-$_status'),
|
key: ValueKey('genre-$_status'),
|
||||||
value: _gender,
|
value: _gender,
|
||||||
|
hint: 'Veuillez sélectionner le genre',
|
||||||
items: _availableGenders
|
items: _availableGenders
|
||||||
.map((g) => MapEntry(g, _genderLabel(g)))
|
.map((g) => MapEntry(g, _genderLabel(g)))
|
||||||
.toList(),
|
.toList(),
|
||||||
@ -603,6 +877,43 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _pickPhoto() async {
|
||||||
|
if (_busy || !widget.isCreating) return;
|
||||||
|
try {
|
||||||
|
final picked = await ImagePicker().pickImage(
|
||||||
|
source: ImageSource.gallery,
|
||||||
|
imageQuality: 75,
|
||||||
|
maxWidth: 1200,
|
||||||
|
maxHeight: 1200,
|
||||||
|
);
|
||||||
|
if (picked == null) return;
|
||||||
|
final bytes = await picked.readAsBytes();
|
||||||
|
if (bytes.isEmpty) return;
|
||||||
|
final name = picked.name.trim();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_photoBytes = bytes;
|
||||||
|
_photoFilename = name.isNotEmpty ? name : 'photo.jpg';
|
||||||
|
_consentPhoto = true;
|
||||||
|
_dirty = true;
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Impossible de charger la photo')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearPhoto() {
|
||||||
|
if (_busy || !widget.isCreating) return;
|
||||||
|
setState(() {
|
||||||
|
_photoBytes = null;
|
||||||
|
_photoFilename = null;
|
||||||
|
_dirty = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Widget _mainRow() {
|
Widget _mainRow() {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, c) {
|
builder: (context, c) {
|
||||||
@ -625,7 +936,20 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AdminAmPhotoFrame(
|
child: AdminAmPhotoFrame(
|
||||||
photoUrl: widget.enfant.photoUrl,
|
photoUrl: widget.isCreating
|
||||||
|
? null
|
||||||
|
: widget.enfant?.photoUrl,
|
||||||
|
imageBytes: widget.isCreating ? _photoBytes : null,
|
||||||
|
onTap: widget.isCreating && !_busy ? _pickPhoto : null,
|
||||||
|
onClear: widget.isCreating &&
|
||||||
|
!_busy &&
|
||||||
|
_photoBytes != null &&
|
||||||
|
_photoBytes!.isNotEmpty
|
||||||
|
? _clearPhoto
|
||||||
|
: null,
|
||||||
|
emptyLabel: widget.isCreating
|
||||||
|
? 'Cliquer pour ajouter'
|
||||||
|
: 'Aucune photo',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@ -644,7 +968,9 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
padding: const EdgeInsets.only(top: 16),
|
padding: const EdgeInsets.only(top: 16),
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
child: _placementBlock(),
|
child: SingleChildScrollView(
|
||||||
|
child: _placementBlock(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -893,14 +1219,34 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String get _placementTitle =>
|
String get _placementTitle {
|
||||||
_isScolarise ? 'Scolarisation' : 'Assistante maternelle';
|
if (widget.isCreating) return 'Sélection du dossier de la famille';
|
||||||
|
return _isScolarise ? 'Scolarisation' : 'Assistante maternelle';
|
||||||
|
}
|
||||||
|
|
||||||
Widget _placementBlock() {
|
Widget _placementBlock() {
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
if (_isOrphan) ...[
|
||||||
|
Text(
|
||||||
|
'Sélection du dossier de la famille',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.red.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Aucun responsable rattaché — choisissez un foyer',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.red.shade700),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_familyPlacementSection(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
Text(
|
Text(
|
||||||
_placementTitle,
|
_placementTitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@ -910,11 +1256,194 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_placementSection(),
|
if (widget.isCreating) _familyPlacementSection() else _placementSection(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _pickFamily() async {
|
||||||
|
if (_busy) return;
|
||||||
|
final selected = await AdminSelectFamilleModal.show(
|
||||||
|
context,
|
||||||
|
title: 'Choisir une famille',
|
||||||
|
);
|
||||||
|
if (selected == null || !mounted) return;
|
||||||
|
|
||||||
|
if (widget.isCreating) {
|
||||||
|
setState(() {
|
||||||
|
_selectedFamily = selected;
|
||||||
|
_dirty = true;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Édition orphelin (#157/#158) : un seul attach — le back propage au foyer.
|
||||||
|
final enfantId = widget.enfant?.id;
|
||||||
|
if (enfantId == null || enfantId.isEmpty) return;
|
||||||
|
|
||||||
|
setState(() => _saving = true);
|
||||||
|
try {
|
||||||
|
await UserService.attachEnfantToParent(
|
||||||
|
parentUserId: selected.pivotParentUserId,
|
||||||
|
enfantId: enfantId,
|
||||||
|
);
|
||||||
|
final refreshed = await UserService.getEnfant(enfantId);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_localParentLinks = refreshed.parentLinks;
|
||||||
|
_selectedFamily = selected;
|
||||||
|
_saving = false;
|
||||||
|
});
|
||||||
|
widget.onSaved?.call();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Enfant rattaché au foyer')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _saving = false);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _familyPlacementSection() {
|
||||||
|
final family = _selectedFamily;
|
||||||
|
if (family == null) {
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: _busy ? null : _pickFamily,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: Container(
|
||||||
|
height: _placementHeight,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.grey.shade200),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.family_restroom,
|
||||||
|
size: 30,
|
||||||
|
color: Colors.grey.shade400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Aucune famille sélectionnée',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.grey.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Cliquer pour choisir un dossier / foyer',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(Icons.add_circle_outline, color: Colors.grey.shade500),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: _busy ? null : _pickFamily,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: Container(
|
||||||
|
height: _placementHeight,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF8F5FC),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: const Color(0xFFD8CCE8)),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFEDE5FA),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.family_restroom,
|
||||||
|
size: 30,
|
||||||
|
color: Color(0xFF6B3FA0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
family.displayTitle,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (family.parentNames.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
family.parentNames.join(', '),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Colors.black54,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.swap_horiz, color: Colors.grey.shade700),
|
||||||
|
tooltip: 'Changer de famille',
|
||||||
|
onPressed: _busy ? null : _pickFamily,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _placementSection() {
|
Widget _placementSection() {
|
||||||
if (!_showsAmPlacement) return _scolariseCard();
|
if (!_showsAmPlacement) return _scolariseCard();
|
||||||
|
|
||||||
@ -966,7 +1495,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
style: ValidationModalTheme.primaryElevatedStyle,
|
style: ValidationModalTheme.primaryElevatedStyle,
|
||||||
onPressed: !_dirty || _busy ? null : _save,
|
onPressed: !_canSubmit || _busy ? null : _save,
|
||||||
child: _saving
|
child: _saving
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 18,
|
width: 18,
|
||||||
@ -976,7 +1505,11 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Text(_dirty ? 'Sauvegarder' : 'Aucune modification'),
|
: Text(
|
||||||
|
widget.isCreating
|
||||||
|
? 'Créer'
|
||||||
|
: (_dirty ? 'Sauvegarder' : 'Aucune modification'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
|||||||
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
||||||
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
||||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||||
|
import 'package:p_tits_pas/utils/enfant_vigilance.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||||
|
|
||||||
List<String> enfantAdminSubtitleLines({
|
List<String> enfantAdminSubtitleLines({
|
||||||
@ -36,6 +37,8 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
final VoidCallback? onCardTap;
|
final VoidCallback? onCardTap;
|
||||||
final EdgeInsetsGeometry? margin;
|
final EdgeInsetsGeometry? margin;
|
||||||
final EdgeInsetsGeometry? contentPadding;
|
final EdgeInsetsGeometry? contentPadding;
|
||||||
|
final Color? borderColor;
|
||||||
|
final String? vigilanceTooltip;
|
||||||
|
|
||||||
const AdminEnfantUserCard({
|
const AdminEnfantUserCard({
|
||||||
super.key,
|
super.key,
|
||||||
@ -46,6 +49,8 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
this.onCardTap,
|
this.onCardTap,
|
||||||
this.margin,
|
this.margin,
|
||||||
this.contentPadding,
|
this.contentPadding,
|
||||||
|
this.borderColor,
|
||||||
|
this.vigilanceTooltip,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory AdminEnfantUserCard.fromEnfant(
|
factory AdminEnfantUserCard.fromEnfant(
|
||||||
@ -55,10 +60,13 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
VoidCallback? onCardTap,
|
VoidCallback? onCardTap,
|
||||||
EdgeInsetsGeometry? margin,
|
EdgeInsetsGeometry? margin,
|
||||||
EdgeInsetsGeometry? contentPadding,
|
EdgeInsetsGeometry? contentPadding,
|
||||||
|
Color? borderColor,
|
||||||
|
String? vigilanceTooltip,
|
||||||
}) {
|
}) {
|
||||||
final parents = enfant.parentLinks
|
final parents = enfant.parentLinks
|
||||||
.map((l) => l.parentName ?? 'Parent')
|
.map((l) => l.parentName ?? 'Parent')
|
||||||
.join(', ');
|
.join(', ');
|
||||||
|
final orphan = enfantHasNoResponsable(enfant);
|
||||||
return AdminEnfantUserCard(
|
return AdminEnfantUserCard(
|
||||||
title: enfant.fullName,
|
title: enfant.fullName,
|
||||||
photoUrl: enfant.photoUrl,
|
photoUrl: enfant.photoUrl,
|
||||||
@ -69,6 +77,7 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
gender: enfant.gender,
|
gender: enfant.gender,
|
||||||
extra: [
|
extra: [
|
||||||
if (parents.isNotEmpty) 'Responsables : $parents',
|
if (parents.isNotEmpty) 'Responsables : $parents',
|
||||||
|
if (orphan) 'Aucun responsable rattaché',
|
||||||
...extraSubtitleLines,
|
...extraSubtitleLines,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -76,6 +85,10 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
onCardTap: onCardTap,
|
onCardTap: onCardTap,
|
||||||
margin: margin,
|
margin: margin,
|
||||||
contentPadding: contentPadding,
|
contentPadding: contentPadding,
|
||||||
|
borderColor: borderColor ??
|
||||||
|
(orphan ? Colors.red.shade300 : null),
|
||||||
|
vigilanceTooltip: vigilanceTooltip ??
|
||||||
|
enfantSansResponsableVigilanceMessage(enfant),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,6 +127,8 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
onCardTap: onCardTap,
|
onCardTap: onCardTap,
|
||||||
margin: margin,
|
margin: margin,
|
||||||
contentPadding: contentPadding,
|
contentPadding: contentPadding,
|
||||||
|
borderColor: borderColor,
|
||||||
|
vigilanceTooltip: vigilanceTooltip,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -283,8 +283,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('Détacher l\'enfant'),
|
title: const Text('Détacher l\'enfant'),
|
||||||
content: Text(
|
content: Text(
|
||||||
'Retirer ${child.fullName} de la fiche de ce parent ?\n'
|
'Retirer ${child.fullName} du foyer (tous les responsables) ?',
|
||||||
'(L\'enfant ne sera pas supprimé.)',
|
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
@ -309,8 +308,9 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await _reloadChildren();
|
await _reloadChildren();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
widget.onSaved?.call();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Enfant détaché')),
|
const SnackBar(content: Text('Enfant détaché du foyer')),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@ -337,8 +337,9 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await _reloadChildren();
|
await _reloadChildren();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
widget.onSaved?.call();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Enfant rattaché')),
|
const SnackBar(content: Text('Enfant rattaché au foyer')),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|||||||
@ -0,0 +1,139 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/models/parent_model.dart';
|
||||||
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||||
|
|
||||||
|
/// Foyer / famille sélectionnable pour rattacher un nouvel enfant (#132 / #157).
|
||||||
|
class AdminFamilleFoyer {
|
||||||
|
/// Parent pivot pour `POST /enfants` (`parent_user_id`).
|
||||||
|
final String pivotParentUserId;
|
||||||
|
/// Co-parent éventuel (rattachement foyer #157).
|
||||||
|
final String? coParentUserId;
|
||||||
|
final String? numeroDossier;
|
||||||
|
final String displayTitle;
|
||||||
|
final List<String> parentNames;
|
||||||
|
|
||||||
|
const AdminFamilleFoyer({
|
||||||
|
required this.pivotParentUserId,
|
||||||
|
required this.displayTitle,
|
||||||
|
required this.parentNames,
|
||||||
|
this.coParentUserId,
|
||||||
|
this.numeroDossier,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Parents du foyer à lier à l’enfant (pivot puis co-parent).
|
||||||
|
List<String> get parentUserIds {
|
||||||
|
final ids = <String>[pivotParentUserId];
|
||||||
|
final co = (coParentUserId ?? '').trim();
|
||||||
|
if (co.isNotEmpty && co != pivotParentUserId) ids.add(co);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
String get subtitle {
|
||||||
|
final parts = <String>[];
|
||||||
|
final dossier = (numeroDossier ?? '').trim();
|
||||||
|
if (dossier.isNotEmpty) parts.add('Dossier $dossier');
|
||||||
|
if (parentNames.isNotEmpty) {
|
||||||
|
parts.add('Responsables : ${parentNames.join(', ')}');
|
||||||
|
}
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construit la liste des foyers uniques à partir de `GET /parents`.
|
||||||
|
List<AdminFamilleFoyer> buildFamilleFoyers(List<ParentModel> parents) {
|
||||||
|
final seenDossiers = <String>{};
|
||||||
|
final seenUserIds = <String>{};
|
||||||
|
final foyers = <AdminFamilleFoyer>[];
|
||||||
|
|
||||||
|
for (final p in parents) {
|
||||||
|
final dossier = (p.user.numeroDossier ?? '').trim();
|
||||||
|
if (dossier.isNotEmpty) {
|
||||||
|
if (seenDossiers.contains(dossier)) continue;
|
||||||
|
seenDossiers.add(dossier);
|
||||||
|
} else if (seenUserIds.contains(p.user.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seenUserIds.add(p.user.id);
|
||||||
|
final co = p.coParent;
|
||||||
|
if (co != null) seenUserIds.add(co.id);
|
||||||
|
|
||||||
|
final names = <String>[
|
||||||
|
if (p.user.fullName.trim().isNotEmpty) p.user.fullName.trim(),
|
||||||
|
if (co != null && co.fullName.trim().isNotEmpty) co.fullName.trim(),
|
||||||
|
];
|
||||||
|
|
||||||
|
final title = dossier.isNotEmpty
|
||||||
|
? 'Dossier $dossier'
|
||||||
|
: (names.isNotEmpty ? names.first : 'Famille');
|
||||||
|
|
||||||
|
foyers.add(
|
||||||
|
AdminFamilleFoyer(
|
||||||
|
pivotParentUserId: p.user.id,
|
||||||
|
coParentUserId: co?.id,
|
||||||
|
numeroDossier: dossier.isNotEmpty ? dossier : null,
|
||||||
|
displayTitle: title,
|
||||||
|
parentNames: names,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
foyers.sort(
|
||||||
|
(a, b) => a.displayTitle.toLowerCase().compareTo(b.displayTitle.toLowerCase()),
|
||||||
|
);
|
||||||
|
return foyers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sélection d'une famille / dossier pour créer un enfant — ticket #132.
|
||||||
|
class AdminSelectFamilleModal {
|
||||||
|
AdminSelectFamilleModal._();
|
||||||
|
|
||||||
|
static Future<AdminFamilleFoyer?> show(
|
||||||
|
BuildContext context, {
|
||||||
|
String title = 'Choisir une famille',
|
||||||
|
}) {
|
||||||
|
return AdminSelectListModal.show<AdminFamilleFoyer>(
|
||||||
|
context,
|
||||||
|
title: title,
|
||||||
|
searchHint: 'Rechercher par dossier, nom…',
|
||||||
|
emptyMessage: 'Aucune famille disponible',
|
||||||
|
noResultsMessage: 'Aucun résultat pour cette recherche',
|
||||||
|
loadItems: () async {
|
||||||
|
final parents = await UserService.getParents();
|
||||||
|
return buildFamilleFoyers(parents);
|
||||||
|
},
|
||||||
|
matchesQuery: (f, q) {
|
||||||
|
final dossier = (f.numeroDossier ?? '').toLowerCase();
|
||||||
|
final title = f.displayTitle.toLowerCase();
|
||||||
|
final names = f.parentNames.join(' ').toLowerCase();
|
||||||
|
return dossier.contains(q) || title.contains(q) || names.contains(q);
|
||||||
|
},
|
||||||
|
itemBuilder: (context, f, onSelect) {
|
||||||
|
return AdminUserCard(
|
||||||
|
title: f.displayTitle,
|
||||||
|
fallbackIcon: Icons.family_restroom,
|
||||||
|
subtitleLines: [
|
||||||
|
if (f.parentNames.isNotEmpty) f.parentNames.join(', '),
|
||||||
|
if ((f.numeroDossier ?? '').isNotEmpty)
|
||||||
|
'Dossier ${f.numeroDossier}',
|
||||||
|
],
|
||||||
|
onCardTap: onSelect,
|
||||||
|
margin: const EdgeInsets.only(bottom: 4),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 5,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.check_circle_outline),
|
||||||
|
tooltip: 'Choisir',
|
||||||
|
onPressed: onSelect,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,55 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:p_tits_pas/utils/email_utils.dart';
|
||||||
|
import 'package:p_tits_pas/utils/nir_utils.dart';
|
||||||
|
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||||
|
import 'package:p_tits_pas/utils/postal_utils.dart';
|
||||||
import 'admin_detail_modal.dart';
|
import 'admin_detail_modal.dart';
|
||||||
|
|
||||||
|
/// Réglages des formulaires validation / wizard AM — **jouer sur ces 3 leviers**.
|
||||||
|
class ValidationFormMetrics {
|
||||||
|
ValidationFormMetrics._();
|
||||||
|
|
||||||
|
// --- 1. Titres de section ---
|
||||||
|
static const double sectionTitleFontSize = 16;
|
||||||
|
static const double sectionTitleGapBelow = 12;
|
||||||
|
|
||||||
|
// --- 2. TF : texte intérieur + padding vertical (= hauteur) ---
|
||||||
|
static const double fieldTextFontSize = 14;
|
||||||
|
static const double fieldContentPaddingV = 12;
|
||||||
|
static const double fieldContentPaddingH = 12;
|
||||||
|
/// Hauteur estimée du TF (texte + padding haut/bas + bordure).
|
||||||
|
static const double fieldHeight =
|
||||||
|
fieldTextFontSize + fieldContentPaddingV * 2 + 4;
|
||||||
|
|
||||||
|
// --- 3. Espace entre les lignes de TF ---
|
||||||
|
static const double rowGapBelow = 12;
|
||||||
|
|
||||||
|
// Libellé au-dessus du TF (titre du champ)
|
||||||
|
static const double fieldLabelFontSize = 13;
|
||||||
|
static const double fieldLabelGapBelow = 4;
|
||||||
|
|
||||||
|
static const TextStyle fieldTextStyle = TextStyle(
|
||||||
|
color: Colors.black87,
|
||||||
|
fontSize: fieldTextFontSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
static double get sectionTitleBlockHeight =>
|
||||||
|
sectionTitleFontSize + sectionTitleGapBelow;
|
||||||
|
|
||||||
|
static double get labeledRowHeight =>
|
||||||
|
fieldLabelFontSize + fieldLabelGapBelow + fieldHeight + rowGapBelow;
|
||||||
|
|
||||||
|
/// Corps modale AM : padding wizard + titre + [rows] lignes + nav.
|
||||||
|
static double shellBodyHeightForRows(int rows) =>
|
||||||
|
20 * 2 + // padding wizard
|
||||||
|
4 + // espace haut
|
||||||
|
sectionTitleBlockHeight +
|
||||||
|
rows * labeledRowHeight +
|
||||||
|
24 + // avant nav
|
||||||
|
44; // boutons
|
||||||
|
}
|
||||||
|
|
||||||
/// Bloc type formulaire (titre de section + champs read-only) pour les modales de validation.
|
/// Bloc type formulaire (titre de section + champs read-only) pour les modales de validation.
|
||||||
/// [rowLayout] : même disposition que la création de compte, ex. [2, 2, 1, 2] = ligne de 2, ligne de 2, plein largeur, ligne de 2.
|
/// [rowLayout] : même disposition que la création de compte, ex. [2, 2, 1, 2] = ligne de 2, ligne de 2, plein largeur, ligne de 2.
|
||||||
/// [rowFlex] : flex par index de ligne (optionnel). Ex. {3: [2, 5]} = 4e ligne : code postal étroit (2), ville large (5).
|
/// [rowFlex] : flex par index de ligne (optionnel). Ex. {3: [2, 5]} = 4e ligne : code postal étroit (2), ville large (5).
|
||||||
@ -16,12 +64,16 @@ class ValidationDetailSection extends StatelessWidget {
|
|||||||
/// Flex par ligne (index de ligne -> [flex1, flex2, ...]). Ex. {3: [2, 5]} pour Code postal | Ville.
|
/// Flex par ligne (index de ligne -> [flex1, flex2, ...]). Ex. {3: [2, 5]} pour Code postal | Ville.
|
||||||
final Map<int, List<int>>? rowFlex;
|
final Map<int, List<int>>? rowFlex;
|
||||||
|
|
||||||
|
/// Remplit la hauteur disponible (wizard AM étapes 1–2).
|
||||||
|
final bool expandVertically;
|
||||||
|
|
||||||
const ValidationDetailSection({
|
const ValidationDetailSection({
|
||||||
super.key,
|
super.key,
|
||||||
this.title,
|
this.title,
|
||||||
required this.fields,
|
required this.fields,
|
||||||
this.rowLayout,
|
this.rowLayout,
|
||||||
this.rowFlex,
|
this.rowFlex,
|
||||||
|
this.expandVertically = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -30,6 +82,7 @@ class ValidationDetailSection extends StatelessWidget {
|
|||||||
title: title,
|
title: title,
|
||||||
rowLayout: rowLayout,
|
rowLayout: rowLayout,
|
||||||
rowFlex: rowFlex,
|
rowFlex: rowFlex,
|
||||||
|
expandVertically: expandVertically,
|
||||||
fields: fields
|
fields: fields
|
||||||
.map(
|
.map(
|
||||||
(f) => ValidationLabeledField(
|
(f) => ValidationLabeledField(
|
||||||
@ -49,6 +102,8 @@ class ValidationFormGrid extends StatelessWidget {
|
|||||||
final List<int>? rowLayout;
|
final List<int>? rowLayout;
|
||||||
final Map<int, List<int>>? rowFlex;
|
final Map<int, List<int>>? rowFlex;
|
||||||
final bool compact;
|
final bool compact;
|
||||||
|
/// Répartit la hauteur dispo entre les lignes (remplit le blanc sans scroll).
|
||||||
|
final bool expandVertically;
|
||||||
|
|
||||||
const ValidationFormGrid({
|
const ValidationFormGrid({
|
||||||
super.key,
|
super.key,
|
||||||
@ -57,6 +112,7 @@ class ValidationFormGrid extends StatelessWidget {
|
|||||||
this.rowLayout,
|
this.rowLayout,
|
||||||
this.rowFlex,
|
this.rowFlex,
|
||||||
this.compact = false,
|
this.compact = false,
|
||||||
|
this.expandVertically = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -64,7 +120,7 @@ class ValidationFormGrid extends StatelessWidget {
|
|||||||
final layout = rowLayout ?? List.filled(fields.length, 1);
|
final layout = rowLayout ?? List.filled(fields.length, 1);
|
||||||
int index = 0;
|
int index = 0;
|
||||||
int rowIndex = 0;
|
int rowIndex = 0;
|
||||||
final rows = <Widget>[];
|
final rowWidgets = <Widget>[];
|
||||||
for (final count in layout) {
|
for (final count in layout) {
|
||||||
if (index >= fields.length) break;
|
if (index >= fields.length) break;
|
||||||
final rowFields = fields.skip(index).take(count).toList();
|
final rowFields = fields.skip(index).take(count).toList();
|
||||||
@ -72,48 +128,73 @@ class ValidationFormGrid extends StatelessWidget {
|
|||||||
if (rowFields.isEmpty) continue;
|
if (rowFields.isEmpty) continue;
|
||||||
final flexForRow = rowFlex?[rowIndex];
|
final flexForRow = rowFlex?[rowIndex];
|
||||||
rowIndex++;
|
rowIndex++;
|
||||||
|
final labeled = rowFields
|
||||||
|
.map(
|
||||||
|
(f) => ValidationLabeledField(
|
||||||
|
label: f.label,
|
||||||
|
field: f.field,
|
||||||
|
expand: expandVertically,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
Widget row;
|
||||||
if (count == 1) {
|
if (count == 1) {
|
||||||
rows.add(Padding(
|
row = labeled.first;
|
||||||
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
|
|
||||||
child: rowFields.first,
|
|
||||||
));
|
|
||||||
} else {
|
} else {
|
||||||
rows.add(Padding(
|
row = Row(
|
||||||
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
|
crossAxisAlignment: expandVertically
|
||||||
child: Row(
|
? CrossAxisAlignment.stretch
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
for (int i = 0; i < rowFields.length; i++) ...[
|
for (int i = 0; i < labeled.length; i++) ...[
|
||||||
if (i > 0) SizedBox(width: compact ? 12 : 16),
|
if (i > 0) SizedBox(width: compact ? 12 : 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: (flexForRow != null && i < flexForRow.length)
|
flex: (flexForRow != null && i < flexForRow.length)
|
||||||
? flexForRow[i]
|
? flexForRow[i]
|
||||||
: 1,
|
: 1,
|
||||||
child: rowFields[i],
|
child: labeled[i],
|
||||||
),
|
),
|
||||||
],
|
|
||||||
],
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expandVertically) {
|
||||||
|
rowWidgets.add(Expanded(child: row));
|
||||||
|
} else {
|
||||||
|
rowWidgets.add(Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: compact ? 8 : ValidationFormMetrics.rowGapBelow,
|
||||||
),
|
),
|
||||||
|
child: row,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final showTitle = title != null && title!.trim().isNotEmpty;
|
final showTitle = title != null && title!.trim().isNotEmpty;
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: expandVertically ? MainAxisSize.max : MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (showTitle) ...[
|
if (showTitle) ...[
|
||||||
Text(
|
Text(
|
||||||
title!.trim(),
|
title!.trim(),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: compact ? 15 : 16,
|
fontSize: compact
|
||||||
|
? ValidationFormMetrics.sectionTitleFontSize - 1
|
||||||
|
: ValidationFormMetrics.sectionTitleFontSize,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Colors.black87,
|
color: Colors.black87,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: compact ? 8 : 12),
|
SizedBox(
|
||||||
|
height: compact
|
||||||
|
? 8
|
||||||
|
: ValidationFormMetrics.sectionTitleGapBelow,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
...rows,
|
...rowWidgets,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -130,8 +211,8 @@ class ValidationFieldDecoration {
|
|||||||
fillColor: Colors.grey.shade50,
|
fillColor: Colors.grey.shade50,
|
||||||
hintText: hint,
|
hintText: hint,
|
||||||
contentPadding: EdgeInsets.symmetric(
|
contentPadding: EdgeInsets.symmetric(
|
||||||
horizontal: compact ? 10 : 12,
|
horizontal: compact ? 10 : ValidationFormMetrics.fieldContentPaddingH,
|
||||||
vertical: compact ? 7 : 10,
|
vertical: compact ? 7 : ValidationFormMetrics.fieldContentPaddingV,
|
||||||
),
|
),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
@ -180,29 +261,31 @@ class ValidationFieldDecoration {
|
|||||||
class ValidationLabeledField extends StatelessWidget {
|
class ValidationLabeledField extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final Widget field;
|
final Widget field;
|
||||||
|
final bool expand;
|
||||||
|
|
||||||
const ValidationLabeledField({
|
const ValidationLabeledField({
|
||||||
super.key,
|
super.key,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.field,
|
required this.field,
|
||||||
|
this.expand = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: ValidationFormMetrics.fieldLabelFontSize,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Colors.grey.shade700,
|
color: Colors.grey.shade700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
SizedBox(height: ValidationFormMetrics.fieldLabelGapBelow),
|
||||||
field,
|
if (expand) Expanded(child: field) else field,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -264,13 +347,16 @@ class ValidationEditableField extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!compact) {
|
if (!compact) {
|
||||||
return TextField(
|
return _validationFieldFillHeight(
|
||||||
controller: controller,
|
TextField(
|
||||||
keyboardType: keyboardType,
|
controller: controller,
|
||||||
inputFormatters: inputFormatters,
|
keyboardType: keyboardType,
|
||||||
maxLines: 1,
|
inputFormatters: inputFormatters,
|
||||||
style: const TextStyle(color: Colors.black87, fontSize: 14),
|
maxLines: 1,
|
||||||
decoration: ValidationFieldDecoration.input(hint: hintText),
|
textAlignVertical: TextAlignVertical.center,
|
||||||
|
style: ValidationFormMetrics.fieldTextStyle,
|
||||||
|
decoration: ValidationFieldDecoration.input(hint: hintText),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
@ -295,6 +381,347 @@ class ValidationEditableField extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Étire le champ si le parent impose une hauteur (grille [expandVertically]).
|
||||||
|
Widget _validationFieldFillHeight(Widget field) {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, c) {
|
||||||
|
if (!c.hasBoundedHeight || !c.maxHeight.isFinite) return field;
|
||||||
|
return SizedBox(
|
||||||
|
height: c.maxHeight,
|
||||||
|
width: double.infinity,
|
||||||
|
child: field,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// E-mail style validation — même supervision que login / création de compte :
|
||||||
|
/// [EmailMaxLengthFormatter] + à la perte de focus : trim/minuscules + validation.
|
||||||
|
class ValidationEmailField extends StatefulWidget {
|
||||||
|
final TextEditingController controller;
|
||||||
|
final String? hintText;
|
||||||
|
final bool allowEmpty;
|
||||||
|
|
||||||
|
const ValidationEmailField({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
this.hintText,
|
||||||
|
this.allowEmpty = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ValidationEmailField> createState() => _ValidationEmailFieldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ValidationEmailFieldState extends State<ValidationEmailField> {
|
||||||
|
final GlobalKey<FormFieldState<String>> _fieldKey =
|
||||||
|
GlobalKey<FormFieldState<String>>();
|
||||||
|
late final FocusNode _focusNode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_focusNode = FocusNode();
|
||||||
|
_focusNode.addListener(_onFocusChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFocusChange() {
|
||||||
|
if (_focusNode.hasFocus) return;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || _focusNode.hasFocus) return;
|
||||||
|
final c = widget.controller;
|
||||||
|
final normalized = normalizeEmailText(c.text);
|
||||||
|
if (normalized != c.text) {
|
||||||
|
c.value = TextEditingValue(
|
||||||
|
text: normalized,
|
||||||
|
selection: TextSelection.collapsed(offset: normalized.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_fieldKey.currentState?.validate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_focusNode.removeListener(_onFocusChange);
|
||||||
|
_focusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return _validationFieldFillHeight(
|
||||||
|
TextFormField(
|
||||||
|
key: _fieldKey,
|
||||||
|
controller: widget.controller,
|
||||||
|
focusNode: _focusNode,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
autocorrect: false,
|
||||||
|
enableSuggestions: false,
|
||||||
|
autofillHints: const [AutofillHints.email],
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
textAlignVertical: TextAlignVertical.center,
|
||||||
|
inputFormatters: const [EmailMaxLengthFormatter()],
|
||||||
|
style: ValidationFormMetrics.fieldTextStyle,
|
||||||
|
decoration:
|
||||||
|
ValidationFieldDecoration.input(hint: widget.hintText).copyWith(
|
||||||
|
errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11),
|
||||||
|
errorMaxLines: 2,
|
||||||
|
),
|
||||||
|
validator: (value) =>
|
||||||
|
validateEmail(value, allowEmpty: widget.allowEmpty),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Code postal FR — même supervision que création de compte :
|
||||||
|
/// chiffres uniquement (max 5) + validation à la perte de focus.
|
||||||
|
class ValidationPostalCodeField extends StatefulWidget {
|
||||||
|
final TextEditingController controller;
|
||||||
|
final String? hintText;
|
||||||
|
final bool allowEmpty;
|
||||||
|
|
||||||
|
const ValidationPostalCodeField({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
this.hintText,
|
||||||
|
this.allowEmpty = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ValidationPostalCodeField> createState() =>
|
||||||
|
_ValidationPostalCodeFieldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ValidationPostalCodeFieldState extends State<ValidationPostalCodeField> {
|
||||||
|
final GlobalKey<FormFieldState<String>> _fieldKey =
|
||||||
|
GlobalKey<FormFieldState<String>>();
|
||||||
|
late final FocusNode _focusNode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_focusNode = FocusNode();
|
||||||
|
_focusNode.addListener(_onFocusChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFocusChange() {
|
||||||
|
if (_focusNode.hasFocus) return;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || _focusNode.hasFocus) return;
|
||||||
|
final c = widget.controller;
|
||||||
|
final trimmed = c.text.trim();
|
||||||
|
if (trimmed != c.text) {
|
||||||
|
c.value = TextEditingValue(
|
||||||
|
text: trimmed,
|
||||||
|
selection: TextSelection.collapsed(offset: trimmed.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_fieldKey.currentState?.validate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_focusNode.removeListener(_onFocusChange);
|
||||||
|
_focusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return _validationFieldFillHeight(
|
||||||
|
TextFormField(
|
||||||
|
key: _fieldKey,
|
||||||
|
controller: widget.controller,
|
||||||
|
focusNode: _focusNode,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
textAlignVertical: TextAlignVertical.center,
|
||||||
|
inputFormatters: kFrenchPostalCodeInputFormatters,
|
||||||
|
style: ValidationFormMetrics.fieldTextStyle,
|
||||||
|
decoration: ValidationFieldDecoration.input(
|
||||||
|
hint: widget.hintText ?? '5 chiffres',
|
||||||
|
).copyWith(
|
||||||
|
errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11),
|
||||||
|
errorMaxLines: 2,
|
||||||
|
),
|
||||||
|
validator: (value) =>
|
||||||
|
validateFrenchPostalCode(value, allowEmpty: widget.allowEmpty),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Téléphone FR — formatters live + [validateFrenchNationalPhone] à la perte de focus.
|
||||||
|
class ValidationPhoneField extends StatefulWidget {
|
||||||
|
final TextEditingController controller;
|
||||||
|
final String? hintText;
|
||||||
|
final bool allowEmpty;
|
||||||
|
|
||||||
|
const ValidationPhoneField({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
this.hintText,
|
||||||
|
this.allowEmpty = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ValidationPhoneField> createState() => _ValidationPhoneFieldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ValidationPhoneFieldState extends State<ValidationPhoneField> {
|
||||||
|
final GlobalKey<FormFieldState<String>> _fieldKey =
|
||||||
|
GlobalKey<FormFieldState<String>>();
|
||||||
|
late final FocusNode _focusNode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_focusNode = FocusNode();
|
||||||
|
_focusNode.addListener(_onFocusChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFocusChange() {
|
||||||
|
if (_focusNode.hasFocus) return;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || _focusNode.hasFocus) return;
|
||||||
|
final c = widget.controller;
|
||||||
|
final digits = normalizePhone(c.text);
|
||||||
|
final formatted = digits.isEmpty ? '' : formatPhoneForDisplay(digits);
|
||||||
|
if (formatted != c.text) {
|
||||||
|
c.value = TextEditingValue(
|
||||||
|
text: formatted,
|
||||||
|
selection: TextSelection.collapsed(offset: formatted.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_fieldKey.currentState?.validate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_focusNode.removeListener(_onFocusChange);
|
||||||
|
_focusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return _validationFieldFillHeight(
|
||||||
|
TextFormField(
|
||||||
|
key: _fieldKey,
|
||||||
|
controller: widget.controller,
|
||||||
|
focusNode: _focusNode,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
textAlignVertical: TextAlignVertical.center,
|
||||||
|
inputFormatters: frenchPhoneInputFormatters,
|
||||||
|
style: ValidationFormMetrics.fieldTextStyle,
|
||||||
|
decoration:
|
||||||
|
ValidationFieldDecoration.input(hint: widget.hintText).copyWith(
|
||||||
|
errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11),
|
||||||
|
errorMaxLines: 2,
|
||||||
|
),
|
||||||
|
validator: (value) =>
|
||||||
|
validateFrenchNationalPhone(value, allowEmpty: widget.allowEmpty),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NIR — formatage live ([NirInputFormatter]) + validation au fil de la saisie / blur.
|
||||||
|
class ValidationNirField extends StatefulWidget {
|
||||||
|
final TextEditingController controller;
|
||||||
|
final String? hintText;
|
||||||
|
final bool allowEmpty;
|
||||||
|
|
||||||
|
const ValidationNirField({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
this.hintText,
|
||||||
|
this.allowEmpty = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ValidationNirField> createState() => _ValidationNirFieldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ValidationNirFieldState extends State<ValidationNirField> {
|
||||||
|
final GlobalKey<FormFieldState<String>> _fieldKey =
|
||||||
|
GlobalKey<FormFieldState<String>>();
|
||||||
|
late final FocusNode _focusNode;
|
||||||
|
bool _blurred = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_focusNode = FocusNode();
|
||||||
|
_focusNode.addListener(_onFocusChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFocusChange() {
|
||||||
|
if (_focusNode.hasFocus) return;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || _focusNode.hasFocus) return;
|
||||||
|
setState(() => _blurred = true);
|
||||||
|
final c = widget.controller;
|
||||||
|
final raw = nirToRaw(c.text).toUpperCase();
|
||||||
|
final formatted = raw.isEmpty ? '' : formatNir(raw);
|
||||||
|
if (formatted != c.text) {
|
||||||
|
c.value = TextEditingValue(
|
||||||
|
text: formatted,
|
||||||
|
selection: TextSelection.collapsed(offset: formatted.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_fieldKey.currentState?.validate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_focusNode.removeListener(_onFocusChange);
|
||||||
|
_focusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validator(String? value) {
|
||||||
|
if (_blurred) {
|
||||||
|
if (widget.allowEmpty && (value == null || value.trim().isEmpty)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return validateNir(value);
|
||||||
|
}
|
||||||
|
return validateNirTyping(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return _validationFieldFillHeight(
|
||||||
|
TextFormField(
|
||||||
|
key: _fieldKey,
|
||||||
|
controller: widget.controller,
|
||||||
|
focusNode: _focusNode,
|
||||||
|
keyboardType: TextInputType.text,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
textAlignVertical: TextAlignVertical.center,
|
||||||
|
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||||
|
inputFormatters: const [NirInputFormatter()],
|
||||||
|
style: ValidationFormMetrics.fieldTextStyle,
|
||||||
|
decoration: ValidationFieldDecoration.input(
|
||||||
|
hint: widget.hintText ?? '1 12 34 56 789 012 - 34',
|
||||||
|
).copyWith(
|
||||||
|
errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11),
|
||||||
|
errorMaxLines: 2,
|
||||||
|
),
|
||||||
|
onChanged: (_) => _fieldKey.currentState?.validate(),
|
||||||
|
validator: _validator,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Grille label/champ éditable (délègue à [ValidationFormGrid]).
|
/// Grille label/champ éditable (délègue à [ValidationFormGrid]).
|
||||||
class ValidationEditableSection extends StatelessWidget {
|
class ValidationEditableSection extends StatelessWidget {
|
||||||
final List<ValidationLabeledField> fields;
|
final List<ValidationLabeledField> fields;
|
||||||
@ -368,16 +795,19 @@ class _ValidationReadOnlyFieldState extends State<ValidationReadOnlyField> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (!widget.compact && widget.maxLines == 1) {
|
if (!widget.compact && widget.maxLines == 1) {
|
||||||
return TextField(
|
return _validationFieldFillHeight(
|
||||||
controller: _controller,
|
TextField(
|
||||||
readOnly: true,
|
controller: _controller,
|
||||||
enableInteractiveSelection: false,
|
readOnly: true,
|
||||||
style: TextStyle(
|
enableInteractiveSelection: false,
|
||||||
color: widget.error ? Colors.red.shade800 : Colors.black87,
|
textAlignVertical: TextAlignVertical.center,
|
||||||
fontSize: 14,
|
style: TextStyle(
|
||||||
fontWeight: widget.error ? FontWeight.w600 : null,
|
color: widget.error ? Colors.red.shade800 : Colors.black87,
|
||||||
|
fontSize: ValidationFormMetrics.fieldTextFontSize,
|
||||||
|
fontWeight: widget.error ? FontWeight.w600 : null,
|
||||||
|
),
|
||||||
|
decoration: ValidationFieldDecoration.readOnly(error: widget.error),
|
||||||
),
|
),
|
||||||
decoration: ValidationFieldDecoration.readOnly(error: widget.error),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -72,7 +72,14 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
|
|||||||
normalizeEnfantStatus(e.status) ==
|
normalizeEnfantStatus(e.status) ==
|
||||||
normalizeEnfantStatus(widget.statusFilter);
|
normalizeEnfantStatus(widget.statusFilter);
|
||||||
return matchesName && matchesStatus;
|
return matchesName && matchesStatus;
|
||||||
}).toList();
|
}).toList()
|
||||||
|
..sort((a, b) {
|
||||||
|
// Orphelins (#157) en tête, puis ordre alphabétique.
|
||||||
|
final ao = a.hasNoResponsable ? 0 : 1;
|
||||||
|
final bo = b.hasNoResponsable ? 0 : 1;
|
||||||
|
if (ao != bo) return ao.compareTo(bo);
|
||||||
|
return a.fullName.toLowerCase().compareTo(b.fullName.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
return UserList(
|
return UserList(
|
||||||
isLoading: _isLoading,
|
isLoading: _isLoading,
|
||||||
|
|||||||
@ -80,7 +80,7 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
|||||||
onCardTap: () => _openParentDetails(parent),
|
onCardTap: () => _openParentDetails(parent),
|
||||||
subtitleLines: [
|
subtitleLines: [
|
||||||
parent.user.email,
|
parent.user.email,
|
||||||
'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.children.isNotEmpty ? parent.children.length : parent.childrenCount}',
|
'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${ParentModel.foyerChildrenCount(parent, _parents)}',
|
||||||
],
|
],
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
|
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.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/admin_management_widget.dart';
|
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/am_dossier_create_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
|
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
|
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart';
|
import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
|
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
|
||||||
@ -26,6 +28,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
int _subIndex = 0;
|
int _subIndex = 0;
|
||||||
int _gestionnaireRefreshTick = 0;
|
int _gestionnaireRefreshTick = 0;
|
||||||
int _adminRefreshTick = 0;
|
int _adminRefreshTick = 0;
|
||||||
|
int _enfantRefreshTick = 0;
|
||||||
|
int _amRefreshTick = 0;
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
final TextEditingController _amCapacityController = TextEditingController();
|
final TextEditingController _amCapacityController = TextEditingController();
|
||||||
String? _parentStatus;
|
String? _parentStatus;
|
||||||
@ -264,11 +268,13 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
);
|
);
|
||||||
case 1:
|
case 1:
|
||||||
return EnfantManagementWidget(
|
return EnfantManagementWidget(
|
||||||
|
key: ValueKey('enfants-$_enfantRefreshTick'),
|
||||||
searchQuery: _searchController.text,
|
searchQuery: _searchController.text,
|
||||||
statusFilter: _enfantStatus,
|
statusFilter: _enfantStatus,
|
||||||
);
|
);
|
||||||
case 2:
|
case 2:
|
||||||
return AssistanteMaternelleManagementWidget(
|
return AssistanteMaternelleManagementWidget(
|
||||||
|
key: ValueKey('ams-$_amRefreshTick'),
|
||||||
searchQuery: _searchController.text,
|
searchQuery: _searchController.text,
|
||||||
capacityMin: int.tryParse(_amCapacityController.text),
|
capacityMin: int.tryParse(_amCapacityController.text),
|
||||||
);
|
);
|
||||||
@ -311,6 +317,41 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
|
|
||||||
Future<void> _handleAddPressed() async {
|
Future<void> _handleAddPressed() async {
|
||||||
final contentIndex = _subIndex - _contentIndexOffset;
|
final contentIndex = _subIndex - _contentIndexOffset;
|
||||||
|
|
||||||
|
if (contentIndex == 1) {
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return AdminChildDetailModal.create(
|
||||||
|
onSaved: () {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _enfantRefreshTick++);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentIndex == 2) {
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return AmDossierCreateModal(
|
||||||
|
onClose: () => Navigator.of(dialogContext).pop(),
|
||||||
|
onSuccess: () {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _amRefreshTick++);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (contentIndex == 3) {
|
if (contentIndex == 3) {
|
||||||
final created = await showDialog<bool>(
|
final created = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
@ -354,7 +395,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
'La création parent / enfant / AM sera disponible avec le ticket #129.',
|
'La création parent sera disponible avec le ticket #129.',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,20 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/utils/date_display_utils.dart';
|
import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart';
|
||||||
import 'package:p_tits_pas/utils/nir_utils.dart';
|
|
||||||
import 'package:p_tits_pas/models/user.dart';
|
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
|
||||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/common/identity_block.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
|
||||||
import 'validation_modal_theme.dart';
|
|
||||||
import 'validation_refus_form.dart';
|
|
||||||
import 'validation_valider_confirm_dialog.dart';
|
|
||||||
|
|
||||||
/// Wizard de validation dossier AM : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107.
|
/// Wrapper historique (#107) — délègue à [AmDossierWizard.review].
|
||||||
class ValidationAmWizard extends StatefulWidget {
|
class ValidationAmWizard extends StatelessWidget {
|
||||||
final DossierAM dossier;
|
final DossierAM dossier;
|
||||||
final VoidCallback onClose;
|
final VoidCallback onClose;
|
||||||
final VoidCallback onSuccess;
|
final VoidCallback onSuccess;
|
||||||
@ -28,480 +17,13 @@ class ValidationAmWizard extends StatefulWidget {
|
|||||||
this.onStepChanged,
|
this.onStepChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
|
||||||
State<ValidationAmWizard> createState() => _ValidationAmWizardState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ValidationAmWizardState extends State<ValidationAmWizard> {
|
|
||||||
int _step = 0;
|
|
||||||
bool _showRefusForm = false;
|
|
||||||
bool _submitting = false;
|
|
||||||
|
|
||||||
static const int _stepCount = 3;
|
|
||||||
|
|
||||||
bool get _isEnAttente => widget.dossier.user.statut == 'en_attente';
|
|
||||||
|
|
||||||
static String _v(String? s) =>
|
|
||||||
(s != null && s.trim().isNotEmpty) ? s.trim() : '–';
|
|
||||||
|
|
||||||
/// Présentation lisible : `1 12 34 56 789 012 - 34` (15 caractères utiles requis).
|
|
||||||
static String _formatNirForDisplay(String? nir) {
|
|
||||||
final v = _v(nir);
|
|
||||||
if (v == '–') return v;
|
|
||||||
final raw = nirToRaw(v).toUpperCase();
|
|
||||||
return raw.length == 15 ? formatNir(raw) : v;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
|
|
||||||
}
|
|
||||||
|
|
||||||
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
|
|
||||||
|
|
||||||
/// Panneau photo + grille droite : NIR|naissance, ville|pays, agrément|date, capa|places.
|
|
||||||
List<AdminDetailField> _photoProFields(DossierAM d) {
|
|
||||||
final u = d.user;
|
|
||||||
return [
|
|
||||||
AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)),
|
|
||||||
AdminDetailField(
|
|
||||||
label: 'Date de naissance',
|
|
||||||
value: formatIsoDateFr(u.dateNaissance),
|
|
||||||
),
|
|
||||||
AdminDetailField(
|
|
||||||
label: 'Ville de naissance',
|
|
||||||
value: _v(u.lieuNaissanceVille),
|
|
||||||
),
|
|
||||||
AdminDetailField(
|
|
||||||
label: 'Pays de naissance',
|
|
||||||
value: _v(u.lieuNaissancePays),
|
|
||||||
),
|
|
||||||
AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)),
|
|
||||||
AdminDetailField(
|
|
||||||
label: 'Date d’agrément',
|
|
||||||
value: formatIsoDateFr(d.dateAgrement),
|
|
||||||
),
|
|
||||||
AdminDetailField(
|
|
||||||
label: 'Capacité max (enfants)',
|
|
||||||
value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '–',
|
|
||||||
),
|
|
||||||
AdminDetailField(
|
|
||||||
label: 'Places disponibles',
|
|
||||||
value: d.placesDisponibles != null
|
|
||||||
? d.placesDisponibles.toString()
|
|
||||||
: '–',
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
static const List<int> _photoProRowLayout = [2, 2, 2, 2];
|
|
||||||
|
|
||||||
/// Proportion photo d’identité (35×45 mm).
|
|
||||||
static const double _idPhotoAspectRatio = 35 / 45;
|
|
||||||
|
|
||||||
static const double _photoProGap = 24;
|
|
||||||
/// Largeur mini réservée aux champs (évite une colonne photo trop gourmande).
|
|
||||||
static const double _proColumnMinWidth = 260;
|
|
||||||
static const double _photoColumnMinWidth = 160;
|
|
||||||
|
|
||||||
/// URL complète pour la photo : si relatif, préfixe [ApiConfig.baseUrl] (ex. `/api/v1/uploads/...`).
|
|
||||||
static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url);
|
|
||||||
|
|
||||||
Widget _buildPhotoSection(AppUser u) {
|
|
||||||
final photoUrl = _fullPhotoUrl(u.photoUrl);
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 8),
|
|
||||||
child: LayoutBuilder(
|
|
||||||
builder: (context, c) {
|
|
||||||
// Cadre clair : une seule épaisseur partout (photo + padding identique haut/bas/gauche/droite).
|
|
||||||
const uniformFrame = 8.0;
|
|
||||||
final maxPhotoW =
|
|
||||||
(c.maxWidth - 2 * uniformFrame).clamp(0.0, double.infinity);
|
|
||||||
final maxPhotoH =
|
|
||||||
(c.maxHeight - 2 * uniformFrame).clamp(0.0, double.infinity);
|
|
||||||
const ar = _idPhotoAspectRatio;
|
|
||||||
double ph = maxPhotoH;
|
|
||||||
double pw = ph * ar;
|
|
||||||
if (pw > maxPhotoW) {
|
|
||||||
pw = maxPhotoW;
|
|
||||||
ph = pw / ar;
|
|
||||||
}
|
|
||||||
return Align(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade100,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
|
||||||
),
|
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(uniformFrame),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
child: SizedBox(
|
|
||||||
width: pw,
|
|
||||||
height: ph,
|
|
||||||
child: photoUrl.isEmpty
|
|
||||||
? ColoredBox(
|
|
||||||
color: Colors.grey.shade200,
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.person_off_outlined,
|
|
||||||
size: 40,
|
|
||||||
color: Colors.grey.shade400),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Aucune photo fournie',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.grey.shade600,
|
|
||||||
fontSize: 12),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: AuthNetworkImage(
|
|
||||||
url: photoUrl,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
width: pw,
|
|
||||||
height: ph,
|
|
||||||
loadingBuilder: (_, child, progress) {
|
|
||||||
if (progress == null) return child;
|
|
||||||
return ColoredBox(
|
|
||||||
color: Colors.grey.shade200,
|
|
||||||
child: Center(
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
value: progress.expectedTotalBytes !=
|
|
||||||
null
|
|
||||||
? progress.cumulativeBytesLoaded /
|
|
||||||
(progress.expectedTotalBytes!)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
errorBuilder: (_, __, ___) => ColoredBox(
|
|
||||||
color: Colors.grey.shade200,
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment:
|
|
||||||
MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.broken_image_outlined,
|
|
||||||
size: 40,
|
|
||||||
color: Colors.grey.shade400),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Impossible de charger la photo',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.grey.shade600,
|
|
||||||
fontSize: 12),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_showRefusForm) {
|
return AmDossierWizard.review(
|
||||||
return _buildRefusPage();
|
dossier: dossier,
|
||||||
}
|
onClose: onClose,
|
||||||
return Padding(
|
onSuccess: onSuccess,
|
||||||
padding: const EdgeInsets.all(20),
|
onStepChanged: onStepChanged,
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Expanded(child: _buildStepContent()),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
_buildNavigation(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStepContent() {
|
|
||||||
final d = widget.dossier;
|
|
||||||
final u = d.user;
|
|
||||||
switch (_step) {
|
|
||||||
case 0:
|
|
||||||
return LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
return SingleChildScrollView(
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
|
||||||
child: IdentityBlock.readOnlyFromUser(
|
|
||||||
u,
|
|
||||||
title: 'Identité et coordonnées',
|
|
||||||
emptyLabel: '–',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
case 1:
|
|
||||||
// Pas de SingleChildScrollView sur la Row (hauteur non bornée). Défilement à droite.
|
|
||||||
// Largeur photo ≈ ratio × hauteur utile, plafonnée pour laisser au moins [_proColumnMinWidth] aux champs.
|
|
||||||
return LayoutBuilder(
|
|
||||||
builder: (context, c) {
|
|
||||||
final maxRowW = c.maxWidth;
|
|
||||||
final maxRowH = c.maxHeight;
|
|
||||||
const photoHeaderH = 0.0;
|
|
||||||
final bodyH = (maxRowH - photoHeaderH).clamp(0.0, double.infinity);
|
|
||||||
final idealPhotoW =
|
|
||||||
bodyH * _idPhotoAspectRatio + 16; // marge approx. cadre clair
|
|
||||||
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
|
|
||||||
.clamp(0.0, double.infinity);
|
|
||||||
var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 360.0);
|
|
||||||
if (photoW > maxPhotoW) photoW = maxPhotoW;
|
|
||||||
photoW = photoW.clamp(0.0, maxRowW - _photoProGap);
|
|
||||||
|
|
||||||
return Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: photoW,
|
|
||||||
child: _buildPhotoSection(u),
|
|
||||||
),
|
|
||||||
const SizedBox(width: _photoProGap),
|
|
||||||
Expanded(
|
|
||||||
child: LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
return SingleChildScrollView(
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: BoxConstraints(
|
|
||||||
minWidth: constraints.maxWidth),
|
|
||||||
child: ValidationDetailSection(
|
|
||||||
title: 'Dossier professionnel',
|
|
||||||
fields: _photoProFields(d),
|
|
||||||
rowLayout: _photoProRowLayout,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
case 2:
|
|
||||||
final presentation =
|
|
||||||
(d.presentation != null && d.presentation!.trim().isNotEmpty)
|
|
||||||
? d.presentation!
|
|
||||||
: '–';
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Présentation',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Colors.black87,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Expanded(
|
|
||||||
child: LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
return SingleChildScrollView(
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints:
|
|
||||||
BoxConstraints(minHeight: constraints.maxHeight),
|
|
||||||
child: Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12, vertical: 10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade50,
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
|
||||||
),
|
|
||||||
child: SelectableText(
|
|
||||||
presentation,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.black87, fontSize: 14),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return const SizedBox();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildNavigation() {
|
|
||||||
if (_step == 2) {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
|
|
||||||
const Spacer(),
|
|
||||||
Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
setState(() => _step = 1);
|
|
||||||
_emitStep();
|
|
||||||
},
|
|
||||||
child: const Text('Précédent'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
if (_isEnAttente) ...[
|
|
||||||
OutlinedButton(
|
|
||||||
onPressed: _submitting ? null : _refuser,
|
|
||||||
child: const Text('Refuser')),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
ElevatedButton(
|
|
||||||
style: ValidationModalTheme.primaryElevatedStyle,
|
|
||||||
onPressed: _submitting ? null : _onValiderPressed,
|
|
||||||
child: Text(_submitting ? 'Envoi...' : 'Valider'),
|
|
||||||
),
|
|
||||||
] else
|
|
||||||
ElevatedButton(
|
|
||||||
style: ValidationModalTheme.primaryElevatedStyle,
|
|
||||||
onPressed: widget.onClose,
|
|
||||||
child: const Text('Fermer'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
|
|
||||||
const Spacer(),
|
|
||||||
if (_step > 0) ...[
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
setState(() => _step--);
|
|
||||||
_emitStep();
|
|
||||||
},
|
|
||||||
child: const Text('Précédent'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
],
|
|
||||||
ElevatedButton(
|
|
||||||
style: ValidationModalTheme.primaryElevatedStyle,
|
|
||||||
onPressed: () {
|
|
||||||
setState(() => _step++);
|
|
||||||
_emitStep();
|
|
||||||
},
|
|
||||||
child: const Text('Suivant'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onValiderPressed() async {
|
|
||||||
if (_submitting) return;
|
|
||||||
final ok = await showValidationValiderConfirmDialog(
|
|
||||||
context,
|
|
||||||
body:
|
|
||||||
'Voulez-vous valider le dossier de cette assistante maternelle ? Cette action confirme le compte.',
|
|
||||||
);
|
|
||||||
if (!mounted || !ok) return;
|
|
||||||
await _valider();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _valider() async {
|
|
||||||
if (_submitting) return;
|
|
||||||
setState(() => _submitting = true);
|
|
||||||
try {
|
|
||||||
await UserService.validateUser(widget.dossier.user.id);
|
|
||||||
if (!mounted) return;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _refuser() => setState(() => _showRefusForm = true);
|
|
||||||
|
|
||||||
Widget _buildRefusPage() {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: ValidationRefusForm(
|
|
||||||
isSubmitting: _submitting,
|
|
||||||
onCancel: widget.onClose,
|
|
||||||
onPrevious: () => setState(() => _showRefusForm = false),
|
|
||||||
onSubmit: (comment) {
|
|
||||||
if (comment == null || comment.trim().isEmpty) return;
|
|
||||||
_refuserEnvoyer(comment.trim());
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _refuserEnvoyer(String comment) async {
|
|
||||||
if (_submitting) return;
|
|
||||||
setState(() => _submitting = true);
|
|
||||||
try {
|
|
||||||
await UserService.refuseUser(widget.dossier.user.id, comment: comment);
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: const Text(
|
|
||||||
'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.',
|
|
||||||
),
|
|
||||||
duration: const 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/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';
|
||||||
|
|
||||||
@ -76,8 +77,13 @@ 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
|
||||||
// Hauteur uniforme (ajustée +5px pour éviter l'overflow des étapes parents sans scroll).
|
static const double _familyBodyHeight = 435;
|
||||||
static const double _bodyHeight = 435;
|
|
||||||
|
double get _bodyHeight {
|
||||||
|
final d = _dossier;
|
||||||
|
if (d != null && d.isAm) return AmDossierWizard.shellBodyHeight;
|
||||||
|
return _familyBodyHeight;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
import 'package:p_tits_pas/utils/name_format_utils.dart';
|
||||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
||||||
|
|
||||||
@ -67,7 +68,9 @@ class IdentityValues {
|
|||||||
|
|
||||||
/// Bloc identité : Nom/Prénom, Tél/Email, Adresse, CP/Ville.
|
/// Bloc identité : Nom/Prénom, Tél/Email, Adresse, CP/Ville.
|
||||||
/// Grille partagée (création de compte, validation AM/famille, fiches admin, etc.).
|
/// Grille partagée (création de compte, validation AM/famille, fiches admin, etc.).
|
||||||
class IdentityBlock extends StatelessWidget { final String? title;
|
class IdentityBlock extends StatelessWidget {
|
||||||
|
final String? title;
|
||||||
|
final bool expandVertically;
|
||||||
|
|
||||||
final String? _nom;
|
final String? _nom;
|
||||||
final String? _prenom;
|
final String? _prenom;
|
||||||
@ -91,6 +94,7 @@ class IdentityBlock extends StatelessWidget { final String? title;
|
|||||||
const IdentityBlock.readOnly({
|
const IdentityBlock.readOnly({
|
||||||
super.key,
|
super.key,
|
||||||
this.title,
|
this.title,
|
||||||
|
this.expandVertically = false,
|
||||||
required String nom,
|
required String nom,
|
||||||
required String prenom,
|
required String prenom,
|
||||||
required String telephone,
|
required String telephone,
|
||||||
@ -116,6 +120,7 @@ class IdentityBlock extends StatelessWidget { final String? title;
|
|||||||
const IdentityBlock.editable({
|
const IdentityBlock.editable({
|
||||||
super.key,
|
super.key,
|
||||||
this.title,
|
this.title,
|
||||||
|
this.expandVertically = false,
|
||||||
required TextEditingController nomController,
|
required TextEditingController nomController,
|
||||||
required TextEditingController prenomController,
|
required TextEditingController prenomController,
|
||||||
required TextEditingController telephoneController,
|
required TextEditingController telephoneController,
|
||||||
@ -142,11 +147,13 @@ class IdentityBlock extends StatelessWidget { final String? title;
|
|||||||
factory IdentityBlock.readOnlyValues({
|
factory IdentityBlock.readOnlyValues({
|
||||||
Key? key,
|
Key? key,
|
||||||
String? title,
|
String? title,
|
||||||
|
bool expandVertically = false,
|
||||||
required IdentityValues values,
|
required IdentityValues values,
|
||||||
}) {
|
}) {
|
||||||
return IdentityBlock.readOnly(
|
return IdentityBlock.readOnly(
|
||||||
key: key,
|
key: key,
|
||||||
title: title,
|
title: title,
|
||||||
|
expandVertically: expandVertically,
|
||||||
nom: values.nom,
|
nom: values.nom,
|
||||||
prenom: values.prenom,
|
prenom: values.prenom,
|
||||||
telephone: values.telephone,
|
telephone: values.telephone,
|
||||||
@ -163,10 +170,12 @@ class IdentityBlock extends StatelessWidget { final String? title;
|
|||||||
Key? key,
|
Key? key,
|
||||||
String? title,
|
String? title,
|
||||||
String emptyLabel = 'Non défini',
|
String emptyLabel = 'Non défini',
|
||||||
|
bool expandVertically = false,
|
||||||
}) {
|
}) {
|
||||||
return IdentityBlock.readOnlyValues(
|
return IdentityBlock.readOnlyValues(
|
||||||
key: key,
|
key: key,
|
||||||
title: title,
|
title: title,
|
||||||
|
expandVertically: expandVertically,
|
||||||
values: IdentityValues.fromUser(user, emptyLabel: emptyLabel),
|
values: IdentityValues.fromUser(user, emptyLabel: emptyLabel),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -196,29 +205,29 @@ class IdentityBlock extends StatelessWidget { final String? title;
|
|||||||
title: title,
|
title: title,
|
||||||
rowLayout: rowLayout,
|
rowLayout: rowLayout,
|
||||||
rowFlex: rowFlex,
|
rowFlex: rowFlex,
|
||||||
|
expandVertically: expandVertically,
|
||||||
fields: [
|
fields: [
|
||||||
ValidationLabeledField(
|
ValidationLabeledField(
|
||||||
label: 'Nom',
|
label: 'Nom',
|
||||||
field: ValidationEditableField(controller: _nomCtrl!),
|
field: ValidationEditableField(
|
||||||
|
controller: _nomCtrl!,
|
||||||
|
inputFormatters: const [PersonNameInputFormatter()],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
ValidationLabeledField(
|
ValidationLabeledField(
|
||||||
label: 'Prénom',
|
label: 'Prénom',
|
||||||
field: ValidationEditableField(controller: _prenomCtrl!),
|
field: ValidationEditableField(
|
||||||
|
controller: _prenomCtrl!,
|
||||||
|
inputFormatters: const [PersonNameInputFormatter()],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
ValidationLabeledField(
|
ValidationLabeledField(
|
||||||
label: 'Téléphone',
|
label: 'Téléphone',
|
||||||
field: ValidationEditableField(
|
field: ValidationPhoneField(controller: _telCtrl!),
|
||||||
controller: _telCtrl!,
|
|
||||||
keyboardType: TextInputType.phone,
|
|
||||||
inputFormatters: frenchPhoneInputFormatters,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
ValidationLabeledField(
|
ValidationLabeledField(
|
||||||
label: 'Email',
|
label: 'Email',
|
||||||
field: ValidationEditableField(
|
field: ValidationEmailField(controller: _emailCtrl!),
|
||||||
controller: _emailCtrl!,
|
|
||||||
keyboardType: TextInputType.emailAddress,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
ValidationLabeledField(
|
ValidationLabeledField(
|
||||||
label: 'Adresse (N° et Rue)',
|
label: 'Adresse (N° et Rue)',
|
||||||
@ -226,14 +235,14 @@ class IdentityBlock extends StatelessWidget { final String? title;
|
|||||||
),
|
),
|
||||||
ValidationLabeledField(
|
ValidationLabeledField(
|
||||||
label: 'Code postal',
|
label: 'Code postal',
|
||||||
field: ValidationEditableField(
|
field: ValidationPostalCodeField(controller: _cpCtrl!),
|
||||||
controller: _cpCtrl!,
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
ValidationLabeledField(
|
ValidationLabeledField(
|
||||||
label: 'Ville',
|
label: 'Ville',
|
||||||
field: ValidationEditableField(controller: _villeCtrl!),
|
field: ValidationEditableField(
|
||||||
|
controller: _villeCtrl!,
|
||||||
|
inputFormatters: const [PersonNameInputFormatter()],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@ -243,6 +252,7 @@ class IdentityBlock extends StatelessWidget { final String? title;
|
|||||||
title: title,
|
title: title,
|
||||||
rowLayout: rowLayout,
|
rowLayout: rowLayout,
|
||||||
rowFlex: rowFlex,
|
rowFlex: rowFlex,
|
||||||
|
expandVertically: expandVertically,
|
||||||
fields: [
|
fields: [
|
||||||
ValidationLabeledField(
|
ValidationLabeledField(
|
||||||
label: 'Nom',
|
label: 'Nom',
|
||||||
|
|||||||
@ -54,7 +54,7 @@ class NirTextField extends StatelessWidget {
|
|||||||
inputFontSize: inputFontSize,
|
inputFontSize: inputFontSize,
|
||||||
keyboardType: TextInputType.text,
|
keyboardType: TextInputType.text,
|
||||||
validator: validator ?? validateNir,
|
validator: validator ?? validateNir,
|
||||||
inputFormatters: [NirInputFormatter()],
|
inputFormatters: const [NirInputFormatter()],
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
readOnly: readOnly,
|
readOnly: readOnly,
|
||||||
style: style,
|
style: style,
|
||||||
|
|||||||
@ -206,7 +206,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.2"
|
version: "1.2.2"
|
||||||
http_parser:
|
http_parser:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http_parser
|
name: http_parser
|
||||||
sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
|
sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
|
||||||
|
|||||||
@ -21,6 +21,7 @@ dependencies:
|
|||||||
js: ^0.6.7
|
js: ^0.6.7
|
||||||
url_launcher: ^6.2.4
|
url_launcher: ^6.2.4
|
||||||
http: ^1.2.2
|
http: ^1.2.2
|
||||||
|
http_parser: ^4.0.2
|
||||||
# flutter_secure_storage: ^9.0.0
|
# flutter_secure_storage: ^9.0.0
|
||||||
pdfx: ^2.5.0
|
pdfx: ^2.5.0
|
||||||
universal_platform: ^1.1.0
|
universal_platform: ^1.1.0
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user