Compare commits

..

No commits in common. "8ed68797aa92bca6e66d8e990df2bb1ef537cf58" and "d55f240f56b576432fbf01f6c90efce3ac4ec528" have entirely different histories.

54 changed files with 454 additions and 3280 deletions

View File

@ -1,32 +0,0 @@
import { sanitizeUserForApi } from './sanitize-user-for-api';
import { RoleType, StatutUtilisateurType, Users } from '../../entities/users.entity';
describe('sanitizeUserForApi', () => {
const base: Users = {
id: 'u1',
email: 'a@b.fr',
prenom: 'Paul',
nom: 'Parent',
role: RoleType.PARENT,
statut: StatutUtilisateurType.ACTIF,
password: 'hash',
token_creation_mdp: 'tok',
token_creation_mdp_expire_le: new Date(),
password_reset_token: 'rst',
password_reset_expires: new Date(),
} as Users;
it('retire password et tokens', () => {
const out = sanitizeUserForApi(base)!;
expect(out.prenom).toBe('Paul');
expect(out.nom).toBe('Parent');
expect(out.password).toBeUndefined();
expect(out.token_creation_mdp).toBeUndefined();
expect(out.password_reset_token).toBeUndefined();
});
it('retourne undefined si user absent', () => {
expect(sanitizeUserForApi(null)).toBeUndefined();
expect(sanitizeUserForApi(undefined)).toBeUndefined();
});
});

View File

@ -1,23 +0,0 @@
import { Users } from 'src/entities/users.entity';
/** Champs sensibles exclus des réponses API (ticket #131 — user / co_parent). */
const SENSITIVE_USER_KEYS: (keyof Users)[] = [
'password',
'token_creation_mdp',
'token_creation_mdp_expire_le',
'password_reset_token',
'password_reset_expires',
];
/**
* Retourne une copie utilisateur sans secrets (hash MDP, tokens).
* Utilisé pour `user` et `co_parent` dans les réponses Parents.
*/
export function sanitizeUserForApi(user?: Users | null): Users | undefined {
if (!user) return undefined;
const safe = { ...user } as Users;
for (const key of SENSITIVE_USER_KEYS) {
delete safe[key];
}
return safe;
}

View File

@ -1,47 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
} from 'typeorm';
import { AssistanteMaternelle } from './assistantes_maternelles.entity';
import { Children } from './children.entity';
import { Users } from './users.entity';
@Entity('enfants_assistantes_maternelles', { schema: 'public' })
export class AmChildren {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'id_am', type: 'uuid' })
amId: string;
@Column({ name: 'id_enfant', type: 'uuid' })
enfantId: string;
@Column({ name: 'date_debut', type: 'date' })
date_debut: Date;
@Column({ name: 'date_fin', type: 'date', nullable: true })
date_fin?: Date;
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
cree_le: Date;
@Column({ name: 'cree_par', type: 'uuid', nullable: true })
cree_par?: string;
@ManyToOne(() => AssistanteMaternelle, (am) => am.amChildren, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'id_am', referencedColumnName: 'user_id' })
am: AssistanteMaternelle;
@ManyToOne(() => Children, (c) => c.amLinks, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'id_enfant', referencedColumnName: 'id' })
child: Children;
@ManyToOne(() => Users, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'cree_par', referencedColumnName: 'id' })
createdBy?: Users;
}

View File

@ -1,6 +1,5 @@
import { Entity, PrimaryColumn, Column, OneToOne, OneToMany, JoinColumn } from 'typeorm';
import { Entity, PrimaryColumn, Column, OneToOne, JoinColumn } from 'typeorm';
import { Users } from './users.entity';
import { AmChildren } from './am_children.entity';
@Entity('assistantes_maternelles')
export class AssistanteMaternelle {
@ -52,7 +51,4 @@ export class AssistanteMaternelle {
/** Numéro de dossier (format AAAA-NNNNNN), même valeur que sur utilisateurs (ticket #103) */
@Column({ name: 'numero_dossier', length: 20, nullable: true })
numero_dossier?: string;
@OneToMany(() => AmChildren, (ac) => ac.am)
amChildren: AmChildren[];
}

View File

@ -4,14 +4,12 @@ import {
} from 'typeorm';
import { Parents } from './parents.entity';
import { ParentsChildren } from './parents_children.entity';
import { AmChildren } from './am_children.entity';
import { Dossier } from './dossiers.entity';
export enum StatutEnfantType {
A_NAITRE = 'a_naitre',
ACTIF = 'actif',
SCOLARISE = 'scolarise',
GARDE = 'garde',
SANS_GARDE = 'sans_garde',
}
export enum GenreType {
@ -70,9 +68,6 @@ export class Children {
@OneToMany(() => ParentsChildren, pc => pc.child)
parentLinks: ParentsChildren[];
@OneToMany(() => AmChildren, (ac) => ac.child)
amLinks: AmChildren[];
// Relation avec Dossier
@OneToMany(() => Dossier, d => d.child)
dossiers: Dossier[];

View File

@ -12,14 +12,11 @@ import { AssistantesMaternellesService } from './assistantes_maternelles.service
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { Roles } from 'src/common/decorators/roles.decorator';
import { RoleType, Users } from 'src/entities/users.entity';
import { RoleType } from 'src/entities/users.entity';
import { CreateAssistanteDto } from '../user/dto/create_assistante.dto';
import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto';
import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { User } from 'src/common/decorators/user.decorator';
import { mapAmForApi, mapAmsForApi } from './assistantes_maternelles.mapper';
@ApiTags("Assistantes Maternelles")
@ApiBearerAuth('access-token')
@ -34,74 +31,28 @@ export class AssistantesMaternellesController {
@ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' })
@ApiBody({ type: CreateAssistanteDto })
@Post()
async create(@Body() dto: CreateAssistanteDto): Promise<AssistanteMaternelle> {
const am = await this.assistantesMaternellesService.create(dto);
return mapAmForApi(am);
create(@Body() dto: CreateAssistanteDto): Promise<AssistanteMaternelle> {
return this.assistantesMaternellesService.create(dto);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Get()
@ApiOperation({ summary: 'Récupérer la liste des nounous (inclut amChildren actifs) — ticket #131' })
@ApiOperation({ summary: 'Récupérer la liste des nounous' })
@ApiResponse({ status: 200, description: 'Liste des nounous' })
@ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' })
async getAll(): Promise<AssistanteMaternelle[]> {
const ams = await this.assistantesMaternellesService.findAll();
return mapAmsForApi(ams);
getAll(): Promise<AssistanteMaternelle[]> {
return this.assistantesMaternellesService.findAll();
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
@Get(':id')
@ApiParam({ name: 'id', description: "UUID de la nounou" })
@ApiOperation({ summary: 'Récupérer une nounou par id (inclut amChildren) — ticket #131' })
@ApiOperation({ summary: 'Récupérer une nounou par id' })
@ApiResponse({ status: 200, description: 'Détails de la nounou' })
@ApiResponse({ status: 404, description: 'Nounou non trouvée' })
@ApiResponse({ status: 403, description: 'Accès refusé' })
async getOne(@Param('id') user_id: string): Promise<AssistanteMaternelle> {
const am = await this.assistantesMaternellesService.findOne(user_id);
return mapAmForApi(am);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Patch(':id/fiche')
@ApiBody({ type: UpdateAmFicheAdminDto })
@ApiOperation({ summary: 'Mettre à jour la fiche AM (identité + pro) — ticket #131' })
@ApiParam({ name: 'id', description: "UUID utilisateur de l'AM" })
@ApiResponse({ status: 200, description: 'Fiche AM mise à jour' })
async updateFicheAdmin(
@Param('id') id: string,
@Body() dto: UpdateAmFicheAdminDto,
): Promise<AssistanteMaternelle> {
const am = await this.assistantesMaternellesService.updateFicheAdmin(id, dto);
return mapAmForApi(am);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Post(':id/enfants/:enfantId')
@ApiOperation({ summary: 'Rattacher un enfant à une AM (statut enfant → garde) — ticket #131' })
@ApiParam({ name: 'id', description: "UUID utilisateur de l'AM" })
@ApiParam({ name: 'enfantId', description: "UUID de l'enfant" })
@ApiResponse({ status: 200, description: 'AM avec enfants mis à jour' })
async attachEnfant(
@Param('id') id: string,
@Param('enfantId') enfantId: string,
@User() currentUser: Users,
): Promise<AssistanteMaternelle> {
const am = await this.assistantesMaternellesService.attachEnfant(id, enfantId, currentUser);
return mapAmForApi(am);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Delete(':id/enfants/:enfantId')
@ApiOperation({ summary: "Clôturer le placement d'un enfant chez une AM — ticket #131" })
@ApiParam({ name: 'id', description: "UUID utilisateur de l'AM" })
@ApiParam({ name: 'enfantId', description: "UUID de l'enfant" })
@ApiResponse({ status: 200, description: 'AM avec enfants mis à jour' })
async detachEnfant(
@Param('id') id: string,
@Param('enfantId') enfantId: string,
): Promise<AssistanteMaternelle> {
const am = await this.assistantesMaternellesService.detachEnfant(id, enfantId);
return mapAmForApi(am);
@ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' })
getOne(@Param('id') user_id: string): Promise<AssistanteMaternelle> {
return this.assistantesMaternellesService.findOne(user_id);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
@ -112,15 +63,14 @@ export class AssistantesMaternellesController {
@ApiResponse({ status: 404, description: 'Nounou non trouvée' })
@ApiParam({ name: 'id', description: "UUID de la nounou" })
@Patch(':id')
async update(@Param('id') id: string, @Body() dto: UpdateAssistanteDto): Promise<AssistanteMaternelle> {
const am = await this.assistantesMaternellesService.update(id, dto);
return mapAmForApi(am);
update(@Param('id') id: string, @Body() dto: UpdateAssistanteDto): Promise<AssistanteMaternelle> {
return this.assistantesMaternellesService.update(id, dto);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Supprimer une nounou' })
@ApiResponse({ status: 200, description: 'Nounou supprimée avec succès' })
@ApiResponse({ status: 403, description: 'Accès refusé' })
@ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins, gestionnaires et administrateurs' })
@ApiResponse({ status: 404, description: 'Nounou non trouvée' })
@ApiParam({ name: 'id', description: "UUID de la nounou" })
@Delete(':id')

View File

@ -1,28 +0,0 @@
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { AmChildren } from 'src/entities/am_children.entity';
import { sanitizeUserForApi } from '../../common/utils/sanitize-user-for-api';
/**
* Sérialisation API fiche AM ticket #131.
* Expose `amChildren` actifs (date_fin null) avec enfant imbriqué, sans secrets user.
*/
export function mapAmForApi(am: AssistanteMaternelle): AssistanteMaternelle {
const activeChildren = (am.amChildren ?? []).filter((link) => !link.date_fin);
return {
...am,
user: sanitizeUserForApi(am.user)!,
amChildren: activeChildren.map((link) => ({
...link,
child: link.child,
})),
};
}
export function mapAmsForApi(ams: AssistanteMaternelle[]): AssistanteMaternelle[] {
return ams.map(mapAmForApi);
}
export function filterActiveAmChildren(links: AmChildren[] | undefined): AmChildren[] {
return (links ?? []).filter((link) => !link.date_fin);
}

View File

@ -2,14 +2,12 @@ import { Module } from '@nestjs/common';
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
import { AssistantesMaternellesController } from './assistantes_maternelles.controller';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { AmChildren } from 'src/entities/am_children.entity';
import { Children } from 'src/entities/children.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Users } from 'src/entities/users.entity';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [TypeOrmModule.forFeature([AssistanteMaternelle, AmChildren, Children, Users]),
imports: [TypeOrmModule.forFeature([AssistanteMaternelle, Users]),
AuthModule
],
controllers: [AssistantesMaternellesController],

View File

@ -5,17 +5,11 @@ import {
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { Repository } from 'typeorm';
import { RoleType, Users } from 'src/entities/users.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { AmChildren } from 'src/entities/am_children.entity';
import { Children, StatutEnfantType } from 'src/entities/children.entity';
import { CreateAssistanteDto } from '../user/dto/create_assistante.dto';
import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto';
import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto';
import { validateNir } from 'src/common/utils/nir.util';
const AM_CHILDREN_RELATIONS = ['user', 'amChildren', 'amChildren.child'] as const;
@Injectable()
export class AssistantesMaternellesService {
@ -23,13 +17,10 @@ export class AssistantesMaternellesService {
@InjectRepository(AssistanteMaternelle)
private readonly assistantesMaternelleRepository: Repository<AssistanteMaternelle>,
@InjectRepository(Users)
private readonly usersRepository: Repository<Users>,
@InjectRepository(AmChildren)
private readonly amChildrenRepository: Repository<AmChildren>,
@InjectRepository(Children)
private readonly childrenRepository: Repository<Children>,
private readonly usersRepository: Repository<Users>
) {}
// Création dune assistante maternelle
async create(dto: CreateAssistanteDto): Promise<AssistanteMaternelle> {
const user = await this.usersRepository.findOneBy({ id: dto.user_id });
if (!user) throw new NotFoundException('Utilisateur introuvable');
@ -58,208 +49,30 @@ export class AssistantesMaternellesService {
return this.assistantesMaternelleRepository.save(entity);
}
// Liste des assistantes maternelles
async findAll(): Promise<AssistanteMaternelle[]> {
return this.assistantesMaternelleRepository.find({
relations: [...AM_CHILDREN_RELATIONS],
relations: ['user'],
});
}
// Récupérer une assistante maternelle par user_id
async findOne(user_id: string): Promise<AssistanteMaternelle> {
const assistante = await this.assistantesMaternelleRepository.findOne({
where: { user_id },
relations: [...AM_CHILDREN_RELATIONS],
relations: ['user'],
});
if (!assistante) throw new NotFoundException('Assistante maternelle introuvable');
return assistante;
}
// Mise à jour
async update(id: string, dto: UpdateAssistanteDto): Promise<AssistanteMaternelle> {
await this.assistantesMaternelleRepository.update(id, dto);
return this.findOne(id);
}
/**
* Mise à jour fiche AM (identité + champs pro) par admin/gestionnaire. Ticket #131.
*/
async updateFicheAdmin(amUserId: string, dto: UpdateAmFicheAdminDto): Promise<AssistanteMaternelle> {
const am = await this.findOne(amUserId);
const user = am.user;
if (dto.email && dto.email !== user.email) {
const existing = await this.usersRepository.findOne({ where: { email: dto.email } });
if (existing && existing.id !== user.id) {
throw new ConflictException('Cet email est déjà utilisé');
}
user.email = dto.email;
}
if (dto.nom !== undefined) user.nom = dto.nom;
if (dto.prenom !== undefined) user.prenom = dto.prenom;
if (dto.telephone !== undefined) user.telephone = dto.telephone;
if (dto.adresse !== undefined) user.adresse = dto.adresse;
if (dto.ville !== undefined) user.ville = dto.ville;
if (dto.code_postal !== undefined) user.code_postal = dto.code_postal;
if (dto.statut !== undefined) user.statut = dto.statut;
if (dto.date_naissance !== undefined) {
user.date_naissance = dto.date_naissance ? new Date(dto.date_naissance) : undefined;
}
if (dto.lieu_naissance_ville !== undefined) {
user.lieu_naissance_ville = dto.lieu_naissance_ville || undefined;
}
if (dto.lieu_naissance_pays !== undefined) {
user.lieu_naissance_pays = dto.lieu_naissance_pays || undefined;
}
await this.usersRepository.save(user);
const amPatch: Partial<AssistanteMaternelle> = {};
if (dto.approval_number !== undefined) amPatch.approval_number = dto.approval_number;
if (dto.residence_city !== undefined) amPatch.residence_city = dto.residence_city;
if (dto.max_children !== undefined) amPatch.max_children = dto.max_children;
if (dto.places_available !== undefined) amPatch.places_available = dto.places_available;
if (dto.biography !== undefined) amPatch.biography = dto.biography;
if (dto.available !== undefined) amPatch.available = dto.available;
if (dto.agreement_date !== undefined) {
amPatch.agreement_date = dto.agreement_date ? new Date(dto.agreement_date) : undefined;
}
if (dto.nir !== undefined) {
const nirNormalized = dto.nir.replace(/\s/g, '').toUpperCase();
if (nirNormalized) {
const dateNaissanceForNir =
dto.date_naissance ??
(user.date_naissance instanceof Date
? user.date_naissance.toISOString().slice(0, 10)
: user.date_naissance
? String(user.date_naissance).slice(0, 10)
: undefined);
const nirValidation = validateNir(nirNormalized, {
dateNaissance: dateNaissanceForNir,
});
if (!nirValidation.valid) {
throw new BadRequestException(nirValidation.error || 'NIR invalide');
}
const nirDejaUtilise = await this.assistantesMaternelleRepository.findOne({
where: { nir: nirNormalized },
});
if (nirDejaUtilise && nirDejaUtilise.user_id !== amUserId) {
throw new ConflictException(
'Un compte assistante maternelle avec ce numéro NIR existe déjà.',
);
}
amPatch.nir = nirNormalized;
}
// NIR vide : ne pas effacer (colonne NOT NULL en BDD) — le front renvoie toujours la clé.
}
if (Object.keys(amPatch).length > 0) {
await this.assistantesMaternelleRepository.update(amUserId, amPatch);
}
return this.findOne(amUserId);
}
/**
* Rattacher un enfant à une AM (placement actif). Ticket #131.
* Passe le statut enfant à `garde` (sauf a_naitre / scolarise).
*/
async attachEnfant(amUserId: string, enfantId: string, createdBy?: Users): Promise<AssistanteMaternelle> {
const am = await this.findOne(amUserId);
const existingForAm = await this.amChildrenRepository.findOne({
where: { amId: amUserId, enfantId, date_fin: IsNull() },
});
if (existingForAm) {
throw new ConflictException('Cet enfant est déjà rattaché à cette assistante maternelle');
}
const child = await this.childrenRepository.findOne({ where: { id: enfantId } });
if (!child) {
throw new NotFoundException('Enfant introuvable');
}
const activeForChild = await this.amChildrenRepository.findOne({
where: { enfantId, date_fin: IsNull() },
});
if (activeForChild && activeForChild.amId !== amUserId) {
throw new ConflictException(
'Cet enfant est déjà en garde chez une autre assistante maternelle',
);
}
const activeCount = await this.amChildrenRepository.count({
where: { amId: amUserId, date_fin: IsNull() },
});
if (am.max_children != null && activeCount >= am.max_children) {
throw new BadRequestException(
`Capacité maximale atteinte (${am.max_children} enfant(s))`,
);
}
await this.amChildrenRepository.save(
this.amChildrenRepository.create({
amId: amUserId,
enfantId,
date_debut: new Date(),
cree_par: createdBy?.id,
}),
);
await this.applyGardeStatusOnAttach(child);
return this.findOne(amUserId);
}
/**
* Clôturer le placement AM enfant. Ticket #131.
* Repasse l'enfant en `sans_garde` s'il n'a plus de placement actif.
*/
async detachEnfant(amUserId: string, enfantId: string): Promise<AssistanteMaternelle> {
await this.findOne(amUserId);
const link = await this.amChildrenRepository.findOne({
where: { amId: amUserId, enfantId, date_fin: IsNull() },
relations: ['child'],
});
if (!link) {
throw new NotFoundException('Lien assistante maternelle-enfant introuvable');
}
link.date_fin = new Date();
await this.amChildrenRepository.save(link);
const remaining = await this.amChildrenRepository.count({
where: { enfantId, date_fin: IsNull() },
});
if (remaining === 0 && link.child) {
await this.applySansGardeStatusOnDetach(link.child);
}
return this.findOne(amUserId);
}
private async applyGardeStatusOnAttach(child: Children): Promise<void> {
if (
child.status === StatutEnfantType.A_NAITRE ||
child.status === StatutEnfantType.SCOLARISE
) {
return;
}
child.status = StatutEnfantType.GARDE;
await this.childrenRepository.save(child);
}
private async applySansGardeStatusOnDetach(child: Children): Promise<void> {
if (
child.status === StatutEnfantType.A_NAITRE ||
child.status === StatutEnfantType.SCOLARISE
) {
return;
}
child.status = StatutEnfantType.SANS_GARDE;
await this.childrenRepository.save(child);
}
// Suppression dune assistante maternelle
async remove(id: string): Promise<{ message: string }> {
await this.assistantesMaternelleRepository.delete(id);
return { message: 'Assistante maternelle supprimée' };

View File

@ -1,126 +0,0 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsDateString,
IsEmail,
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Mise à jour fiche AM par admin/gestionnaire (doc 28 §6.1, ticket #131). */
export class UpdateAmFicheAdminDto {
@ApiPropertyOptional({ example: 'MARTIN' })
@IsOptional()
@IsString()
@MaxLength(100)
nom?: string;
@ApiPropertyOptional({ example: 'Claire' })
@IsOptional()
@IsString()
@MaxLength(100)
prenom?: string;
@ApiPropertyOptional({ example: 'claire@example.com' })
@IsOptional()
@IsEmail()
email?: string;
@ApiPropertyOptional({ example: '0612345678' })
@IsOptional()
@IsString()
@MaxLength(20)
telephone?: string;
@ApiPropertyOptional({ example: '5 place Bellecour' })
@IsOptional()
@IsString()
adresse?: string;
@ApiPropertyOptional({ example: 'Lyon' })
@IsOptional()
@IsString()
@MaxLength(150)
ville?: string;
@ApiPropertyOptional({ example: '69002' })
@IsOptional()
@IsString()
@MaxLength(10)
code_postal?: string;
@ApiPropertyOptional({ enum: StatutUtilisateurType })
@IsOptional()
@IsEnum(StatutUtilisateurType)
statut?: StatutUtilisateurType;
@ApiPropertyOptional({ example: '123456789012345' })
@IsOptional()
@IsString()
@MaxLength(15)
nir?: string;
@ApiPropertyOptional({ example: '1985-03-12' })
@IsOptional()
@IsDateString()
date_naissance?: string;
@ApiPropertyOptional({ example: 'Lyon' })
@IsOptional()
@IsString()
@MaxLength(100)
lieu_naissance_ville?: string;
@ApiPropertyOptional({ example: 'France' })
@IsOptional()
@IsString()
@MaxLength(100)
lieu_naissance_pays?: string;
@ApiPropertyOptional({ example: 'AGR-2024-12345' })
@IsOptional()
@IsString()
@MaxLength(50)
approval_number?: string;
@ApiPropertyOptional({ example: '2020-01-15' })
@IsOptional()
@IsDateString()
agreement_date?: string;
@ApiPropertyOptional({ example: 'Lyon' })
@IsOptional()
@IsString()
@MaxLength(100)
residence_city?: string;
@ApiPropertyOptional({ example: 4 })
@IsOptional()
@IsInt()
@Min(1)
@Max(10)
max_children?: number;
@ApiPropertyOptional({ example: 2 })
@IsOptional()
@IsInt()
@Min(0)
@Max(10)
places_available?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
biography?: string;
@ApiPropertyOptional({ example: true })
@IsOptional()
@IsBoolean()
available?: boolean;
}

View File

@ -176,7 +176,7 @@ describe('AuthService (#118 create-password API)', () => {
parentsServiceMock.getDossierFamilleByNumero.mockResolvedValue({
numero_dossier: '2026-000021',
parents: [{ user_id: 'p1', email: 'claire@test.fr', statut: StatutUtilisateurType.REFUSE }],
enfants: [{ id: 'e1', first_name: 'Emma', status: 'sans_garde' }],
enfants: [{ id: 'e1', first_name: 'Emma', status: 'actif' }],
texte_motivation: 'Motivation test',
});

View File

@ -551,7 +551,7 @@ export class AuthService {
? new Date(enfantDto.date_previsionnelle_naissance)
: undefined;
enfant.photo_url = urlPhoto || undefined;
enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE;
enfant.status = enfantDto.date_naissance ? StatutEnfantType.ACTIF : StatutEnfantType.A_NAITRE;
enfant.consent_photo = false;
enfant.is_multiple = enfantDto.grossesse_multiple || false;
@ -1063,7 +1063,7 @@ export class AuthService {
if (enfantDto.genre !== undefined) enfant.gender = enfantDto.genre;
if (enfantDto.date_naissance !== undefined) {
enfant.birth_date = new Date(enfantDto.date_naissance);
enfant.status = StatutEnfantType.SANS_GARDE;
enfant.status = StatutEnfantType.ACTIF;
}
if (enfantDto.date_previsionnelle_naissance !== undefined) {
enfant.due_date = new Date(enfantDto.date_previsionnelle_naissance);

View File

@ -12,7 +12,7 @@ import {
import { GenreType, StatutEnfantType } from 'src/entities/children.entity';
export class CreateEnfantsDto {
@ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE })
@ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.ACTIF })
@IsEnum(StatutEnfantType)
@IsNotEmpty()
status: StatutEnfantType;

View File

@ -34,7 +34,7 @@ export class EnfantsService {
// Vérif métier simple
if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) {
throw new BadRequestException('Un enfant doit avoir une date de naissance');
throw new BadRequestException('Un enfant actif doit avoir une date de naissance');
}
// Vérif doublon éventuel (ex: même prénom + date de naissance pour ce parent)

View File

@ -23,7 +23,6 @@ import { RolesGuard } from 'src/common/guards/roles.guard';
import { User } from 'src/common/decorators/user.decorator';
import { PendingFamilyDto } from './dto/pending-family.dto';
import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto';
import { mapParentForApi, mapParentsForApi } from './parents.mapper';
@ApiTags('Parents')
@Controller('parents')
@ -82,25 +81,21 @@ export class ParentsController {
return validated;
}
@Get()
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Liste des parents (user, co_parent, parentChildren) — ticket #131' })
@Get()
@ApiResponse({ status: 200, type: [Parents], description: 'Liste des parents' })
@ApiResponse({ status: 403, description: 'Accès refusé !' })
async getAll(): Promise<Parents[]> {
const parents = await this.parentsService.findAll();
return mapParentsForApi(parents);
getAll(): Promise<Parents[]> {
return this.parentsService.findAll();
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Get(':id')
@ApiOperation({ summary: 'Détail parent par user_id (inclut co_parent si id_co_parent renseigné) — ticket #131' })
@ApiResponse({ status: 200, type: Parents, description: 'Détails du parent par ID utilisateur' })
@ApiResponse({ status: 404, description: 'Parent non trouvé' })
@ApiResponse({ status: 403, description: 'Accès refusé !' })
async getOne(@Param('id') user_id: string): Promise<Parents> {
const parent = await this.parentsService.findOne(user_id);
return mapParentForApi(parent);
getOne(@Param('id') user_id: string): Promise<Parents> {
return this.parentsService.findOne(user_id);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
@ -108,9 +103,8 @@ export class ParentsController {
@ApiBody({ type: CreateParentDto })
@ApiResponse({ status: 201, type: Parents, description: 'Parent créé avec succès' })
@ApiResponse({ status: 403, description: 'Accès refusé !' })
async create(@Body() dto: CreateParentDto): Promise<Parents> {
const parent = await this.parentsService.create(dto);
return mapParentForApi(parent);
create(@Body() dto: CreateParentDto): Promise<Parents> {
return this.parentsService.create(dto);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@ -119,12 +113,11 @@ export class ParentsController {
@ApiParam({ name: 'id', description: "UUID utilisateur du parent" })
@ApiBody({ type: UpdateParentFicheAdminDto })
@ApiResponse({ status: 200, type: Parents, description: 'Fiche parent mise à jour' })
async updateFicheAdmin(
updateFicheAdmin(
@Param('id') id: string,
@Body() dto: UpdateParentFicheAdminDto,
): Promise<Parents> {
const parent = await this.parentsService.updateFicheAdmin(id, dto);
return mapParentForApi(parent);
return this.parentsService.updateFicheAdmin(id, dto);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@ -133,12 +126,11 @@ export class ParentsController {
@ApiParam({ name: 'id', description: "UUID utilisateur du parent" })
@ApiParam({ name: 'enfantId', description: "UUID de l'enfant" })
@ApiResponse({ status: 200, type: Parents, description: 'Parent avec enfants mis à jour' })
async attachEnfant(
attachEnfant(
@Param('id') id: string,
@Param('enfantId') enfantId: string,
): Promise<Parents> {
const parent = await this.parentsService.attachEnfant(id, enfantId);
return mapParentForApi(parent);
return this.parentsService.attachEnfant(id, enfantId);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@ -147,12 +139,11 @@ export class ParentsController {
@ApiParam({ name: 'id', description: "UUID utilisateur du parent" })
@ApiParam({ name: 'enfantId', description: "UUID de l'enfant" })
@ApiResponse({ status: 200, type: Parents, description: 'Parent avec enfants mis à jour' })
async detachEnfant(
detachEnfant(
@Param('id') id: string,
@Param('enfantId') enfantId: string,
): Promise<Parents> {
const parent = await this.parentsService.detachEnfant(id, enfantId);
return mapParentForApi(parent);
return this.parentsService.detachEnfant(id, enfantId);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
@ -161,8 +152,7 @@ export class ParentsController {
@ApiResponse({ status: 200, type: Parents, description: 'Parent mis à jour avec succès' })
@ApiResponse({ status: 404, description: 'Parent introuvable' })
@ApiResponse({ status: 403, description: 'Accès refusé !' })
async update(@Param('id') id: string, @Body() dto: UpdateParentsDto): Promise<Parents> {
const parent = await this.parentsService.update(id, dto);
return mapParentForApi(parent);
update(@Param('id') id: string, @Body() dto: UpdateParentsDto): Promise<Parents> {
return this.parentsService.update(id, dto);
}
}

View File

@ -1,38 +0,0 @@
import { mapParentForApi } from './parents.mapper';
import { Parents } from '../../entities/parents.entity';
import { RoleType, StatutUtilisateurType, Users } from '../../entities/users.entity';
describe('mapParentForApi', () => {
it('expose co_parent avec prenom/nom sans secrets', () => {
const coParent = {
id: 'cp1',
email: 'co@b.fr',
prenom: 'Clara',
nom: 'Co',
role: RoleType.PARENT,
statut: StatutUtilisateurType.ACTIF,
password: 'secret',
} as Users;
const parent = {
user_id: 'u1',
numero_dossier: '2026-000042',
user: {
id: 'u1',
email: 'p@b.fr',
prenom: 'Paul',
nom: 'Parent',
role: RoleType.PARENT,
password: 'secret',
} as Users,
co_parent: coParent,
parentChildren: [],
} as Parents;
const out = mapParentForApi(parent);
expect(out.co_parent?.prenom).toBe('Clara');
expect(out.co_parent?.nom).toBe('Co');
expect(out.co_parent?.password).toBeUndefined();
expect(out.user.password).toBeUndefined();
});
});

View File

@ -1,18 +0,0 @@
import { Parents } from 'src/entities/parents.entity';
import { sanitizeUserForApi } from '../../common/utils/sanitize-user-for-api';
/**
* Sérialisation API fiche parent ticket #131.
* Garantit `user`, `co_parent` (si présent) et relations sans champs sensibles.
*/
export function mapParentForApi(parent: Parents): Parents {
return {
...parent,
user: sanitizeUserForApi(parent.user)!,
co_parent: sanitizeUserForApi(parent.co_parent),
};
}
export function mapParentsForApi(parents: Parents[]): Parents[] {
return parents.map(mapParentForApi);
}

View File

@ -1,9 +1,3 @@
-- ==========================================================
-- P'titsPas — Schéma PostgreSQL (création from scratch)
-- Fichier canonique : toute nouvelle BDD doit partir d'ici.
-- Migrations dans database/migrations/ : BDD existantes uniquement.
-- ==========================================================
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ==========================================================
@ -11,63 +5,40 @@ CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ==========================================================
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'role_type') THEN
CREATE TYPE role_type AS ENUM (
'parent', 'gestionnaire', 'super_admin', 'administrateur', 'assistante_maternelle'
);
CREATE TYPE role_type AS ENUM ('parent', 'gestionnaire', 'super_admin', 'administrateur', 'assistante_maternelle');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'genre_type') THEN
CREATE TYPE genre_type AS ENUM ('H', 'F', 'Autre');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_utilisateur_type') THEN
CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente', 'actif', 'suspendu', 'refuse');
CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente','actif','suspendu','refuse');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_enfant_type') THEN
CREATE TYPE statut_enfant_type AS ENUM ('a_naitre', 'garde', 'sans_garde', 'scolarise');
CREATE TYPE statut_enfant_type AS ENUM ('a_naitre','actif','scolarise');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_dossier_type') THEN
CREATE TYPE statut_dossier_type AS ENUM ('envoye', 'accepte', 'refuse');
CREATE TYPE statut_dossier_type AS ENUM ('envoye','accepte','refuse');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_contrat_type') THEN
CREATE TYPE statut_contrat_type AS ENUM (
'brouillon', 'en_attente_signature', 'valide', 'resilie'
);
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_contrat_type') THEN
CREATE TYPE statut_contrat_type AS ENUM ('brouillon','en_attente_signature','valide','resilie');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_avenant_type') THEN
CREATE TYPE statut_avenant_type AS ENUM ('propose', 'accepte', 'refuse');
CREATE TYPE statut_avenant_type AS ENUM ('propose','accepte','refuse');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_evenement_type') THEN
CREATE TYPE type_evenement_type AS ENUM (
'absence_enfant', 'conge_am', 'conge_parent', 'arret_maladie_am', 'evenement_rpe'
);
CREATE TYPE type_evenement_type AS ENUM ('absence_enfant','conge_am','conge_parent','arret_maladie_am','evenement_rpe');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_evenement_type') THEN
CREATE TYPE statut_evenement_type AS ENUM ('propose', 'valide', 'refuse');
CREATE TYPE statut_evenement_type AS ENUM ('propose','valide','refuse');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_validation_type') THEN
CREATE TYPE statut_validation_type AS ENUM ('en_attente', 'valide', 'refuse');
CREATE TYPE statut_validation_type AS ENUM ('en_attente','valide','refuse');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'situation_familiale_type') THEN
CREATE TYPE situation_familiale_type AS ENUM (
'celibataire', 'marie', 'concubinage', 'pacse', 'separe', 'divorce', 'veuf', 'parent_isole'
);
CREATE TYPE situation_familiale_type AS ENUM ('celibataire','marie','concubinage','pacse','separe','divorce','veuf','parent_isole');
END IF;
END $$;
-- ==========================================================
-- Table : relais (avant utilisateurs — FK relais_id)
-- ==========================================================
CREATE TABLE relais (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
nom VARCHAR(255) NOT NULL,
adresse TEXT NOT NULL,
horaires_ouverture JSONB,
ligne_fixe VARCHAR(20),
actif BOOLEAN DEFAULT true,
notes TEXT,
cree_le TIMESTAMPTZ DEFAULT now(),
modifie_le TIMESTAMPTZ DEFAULT now()
);
-- ==========================================================
-- Table : utilisateurs
-- ==========================================================
@ -75,33 +46,26 @@ CREATE TABLE utilisateurs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'),
password TEXT,
password TEXT, -- NULL avant création via token
prenom VARCHAR(100),
nom VARCHAR(100),
genre genre_type,
role role_type NOT NULL,
statut statut_utilisateur_type DEFAULT 'en_attente',
telephone VARCHAR(20),
telephone VARCHAR(20), -- Unifié (mobile privilégié)
adresse TEXT,
date_naissance DATE,
lieu_naissance_ville VARCHAR(100),
lieu_naissance_pays VARCHAR(100),
photo_url TEXT,
photo_url TEXT, -- Obligatoire pour AM, non utilisé pour parents
consentement_photo BOOLEAN DEFAULT false,
date_consentement_photo TIMESTAMPTZ,
token_creation_mdp VARCHAR(255),
token_creation_mdp_expire_le TIMESTAMPTZ,
password_reset_token VARCHAR(255),
password_reset_expires TIMESTAMPTZ,
token_reprise VARCHAR(255),
token_reprise_expire_le TIMESTAMPTZ,
token_creation_mdp VARCHAR(255), -- Token pour créer MDP après validation
token_creation_mdp_expire_le TIMESTAMPTZ, -- Expiration 7 jours
-- Ticket #127 : réinitialisation mot de passe oublié (distinct de token_creation_mdp)
password_reset_token VARCHAR(255) NULL,
password_reset_expires TIMESTAMPTZ NULL,
changement_mdp_obligatoire BOOLEAN DEFAULT false,
numero_dossier VARCHAR(20),
cgu_version_acceptee INTEGER,
cgu_acceptee_le TIMESTAMPTZ,
privacy_version_acceptee INTEGER,
privacy_acceptee_le TIMESTAMPTZ,
relais_id UUID REFERENCES relais(id) ON DELETE SET NULL,
cree_le TIMESTAMPTZ DEFAULT now(),
modifie_le TIMESTAMPTZ DEFAULT now(),
ville VARCHAR(150),
@ -110,22 +74,15 @@ CREATE TABLE utilisateurs (
situation_familiale situation_familiale_type
);
CREATE INDEX idx_utilisateurs_token_creation_mdp
ON utilisateurs(token_creation_mdp)
-- Index pour recherche par token
CREATE INDEX idx_utilisateurs_token_creation_mdp
ON utilisateurs(token_creation_mdp)
WHERE token_creation_mdp IS NOT NULL;
CREATE INDEX idx_utilisateurs_password_reset_token
ON utilisateurs(password_reset_token)
WHERE password_reset_token IS NOT NULL;
CREATE INDEX idx_utilisateurs_token_reprise
ON utilisateurs(token_reprise)
WHERE token_reprise IS NOT NULL;
CREATE INDEX idx_utilisateurs_numero_dossier
ON utilisateurs(numero_dossier)
WHERE numero_dossier IS NOT NULL;
-- ==========================================================
-- Table : assistantes_maternelles
-- ==========================================================
@ -140,27 +97,17 @@ CREATE TABLE assistantes_maternelles (
date_agrement DATE,
annee_experience SMALLINT,
specialite VARCHAR(100),
place_disponible INT,
numero_dossier VARCHAR(20)
place_disponible INT
);
CREATE INDEX idx_assistantes_maternelles_numero_dossier
ON assistantes_maternelles(numero_dossier)
WHERE numero_dossier IS NOT NULL;
-- ==========================================================
-- Table : parents
-- ==========================================================
CREATE TABLE parents (
id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE,
id_co_parent UUID REFERENCES utilisateurs(id),
numero_dossier VARCHAR(20)
id_co_parent UUID REFERENCES utilisateurs(id)
);
CREATE INDEX idx_parents_numero_dossier
ON parents(numero_dossier)
WHERE numero_dossier IS NOT NULL;
-- ==========================================================
-- Table : enfants
-- ==========================================================
@ -169,7 +116,7 @@ CREATE TABLE enfants (
statut statut_enfant_type,
prenom VARCHAR(100),
nom VARCHAR(100),
genre genre_type NOT NULL,
genre genre_type NOT NULL, -- Obligatoire selon CDC
date_naissance DATE,
date_prevue_naissance DATE,
photo_url TEXT,
@ -188,27 +135,7 @@ CREATE TABLE enfants_parents (
);
-- ==========================================================
-- Table : enfants_assistantes_maternelles (placement AM ↔ enfant — #131)
-- ==========================================================
CREATE TABLE enfants_assistantes_maternelles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
id_am UUID NOT NULL REFERENCES assistantes_maternelles(id_utilisateur) ON DELETE CASCADE,
id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE,
date_debut DATE NOT NULL DEFAULT CURRENT_DATE,
date_fin DATE NULL,
cree_le TIMESTAMPTZ NOT NULL DEFAULT now(),
cree_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL
);
CREATE INDEX idx_eam_am ON enfants_assistantes_maternelles(id_am);
CREATE INDEX idx_eam_enfant ON enfants_assistantes_maternelles(id_enfant);
CREATE UNIQUE INDEX uq_enfant_garde_active
ON enfants_assistantes_maternelles (id_enfant)
WHERE date_fin IS NULL;
-- ==========================================================
-- Table : dossier_famille (inscription parent — ticket #119)
-- Table : dossier_famille (inscription parent, schéma simplifié — ticket #119)
-- ==========================================================
CREATE TABLE dossier_famille (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@ -349,6 +276,10 @@ CREATE TABLE notifications (
-- ==========================================================
-- Table : validations
-- ==========================================================
-- Historique des décisions (validation / refus / suspension de compte).
-- Colonnes commentaire + valide_par : requises par lAPI Nest (TypeORM).
-- FK en ON DELETE SET NULL : conserver la ligne si lutilisateur référencé
-- est supprimé (voir database/docs/FK_POLICIES.md).
CREATE TABLE validations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE SET NULL,
@ -374,10 +305,13 @@ CREATE TABLE configuration (
modifie_par UUID REFERENCES utilisateurs(id)
);
-- Index pour performance
CREATE INDEX idx_configuration_cle ON configuration(cle);
CREATE INDEX idx_configuration_categorie ON configuration(categorie);
-- Seed initial de configuration
INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES
-- === Configuration Email (SMTP) ===
('smtp_host', 'localhost', 'string', 'email', 'Serveur SMTP (ex: mail.mairie-bezons.fr, smtp.gmail.com)'),
('smtp_port', '25', 'number', 'email', 'Port SMTP (25, 465, 587)'),
('smtp_secure', 'false', 'boolean', 'email', 'Utiliser SSL/TLS (true pour port 465)'),
@ -386,10 +320,14 @@ INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES
('smtp_password', '', 'encrypted', 'email', 'Mot de passe SMTP (chiffré en AES-256)'),
('email_from_name', 'P''titsPas', 'string', 'email', 'Nom de l''expéditeur affiché dans les emails'),
('email_from_address', 'no-reply@ptits-pas.fr', 'string', 'email', 'Adresse email de l''expéditeur'),
-- === Configuration Application ===
('app_name', 'P''titsPas', 'string', 'app', 'Nom de l''application (affiché dans l''interface)'),
('app_url', 'https://app.ptits-pas.fr', 'string', 'app', 'URL publique de l''application (pour les liens dans emails)'),
('app_logo_url', '/assets/logo.png', 'string', 'app', 'URL du logo de l''application'),
('setup_completed', 'false', 'boolean', 'app', 'Configuration initiale terminée'),
-- === Configuration Sécurité ===
('password_reset_token_expiry_days', '7', 'number', 'security', 'Durée de validité des tokens de création/réinitialisation de mot de passe (en jours)'),
('jwt_expiry_hours', '24', 'number', 'security', 'Durée de validité des sessions JWT (en heures)'),
('max_upload_size_mb', '5', 'number', 'security', 'Taille maximale des fichiers uploadés (en MB)'),
@ -400,18 +338,19 @@ INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES
-- ==========================================================
CREATE TABLE documents_legaux (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type VARCHAR(50) NOT NULL,
version INTEGER NOT NULL,
fichier_nom VARCHAR(255) NOT NULL,
fichier_path VARCHAR(500) NOT NULL,
fichier_hash VARCHAR(64) NOT NULL,
actif BOOLEAN DEFAULT false,
televerse_par UUID REFERENCES utilisateurs(id),
televerse_le TIMESTAMPTZ DEFAULT now(),
active_le TIMESTAMPTZ,
UNIQUE(type, version)
type VARCHAR(50) NOT NULL, -- 'cgu' ou 'privacy'
version INTEGER NOT NULL, -- Numéro de version (auto-incrémenté)
fichier_nom VARCHAR(255) NOT NULL, -- Nom original du fichier
fichier_path VARCHAR(500) NOT NULL, -- Chemin de stockage
fichier_hash VARCHAR(64) NOT NULL, -- Hash SHA-256 pour intégrité
actif BOOLEAN DEFAULT false, -- Version actuellement active
televerse_par UUID REFERENCES utilisateurs(id), -- Qui a uploadé
televerse_le TIMESTAMPTZ DEFAULT now(), -- Date d'upload
active_le TIMESTAMPTZ, -- Date d'activation
UNIQUE(type, version) -- Pas de doublon version
);
-- Index pour performance
CREATE INDEX idx_documents_legaux_type_actif ON documents_legaux(type, actif);
CREATE INDEX idx_documents_legaux_version ON documents_legaux(type, version DESC);
@ -422,23 +361,73 @@ CREATE TABLE acceptations_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE CASCADE,
id_document UUID REFERENCES documents_legaux(id),
type_document VARCHAR(50) NOT NULL,
version_document INTEGER NOT NULL,
accepte_le TIMESTAMPTZ DEFAULT now(),
ip_address INET,
user_agent TEXT
type_document VARCHAR(50) NOT NULL, -- 'cgu' ou 'privacy'
version_document INTEGER NOT NULL, -- Version acceptée
accepte_le TIMESTAMPTZ DEFAULT now(), -- Date d'acceptation
ip_address INET, -- IP de l'utilisateur (RGPD)
user_agent TEXT -- Navigateur (preuve)
);
-- Index pour performance
CREATE INDEX idx_acceptations_utilisateur ON acceptations_documents(id_utilisateur);
CREATE INDEX idx_acceptations_document ON acceptations_documents(id_document);
-- ==========================================================
-- Table : relais
-- ==========================================================
CREATE TABLE relais (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
nom VARCHAR(255) NOT NULL,
adresse TEXT NOT NULL,
horaires_ouverture JSONB,
ligne_fixe VARCHAR(20),
actif BOOLEAN DEFAULT true,
notes TEXT,
cree_le TIMESTAMPTZ DEFAULT now(),
modifie_le TIMESTAMPTZ DEFAULT now()
);
-- ==========================================================
-- Modification Table : utilisateurs (ajout colonnes documents et relais)
-- ==========================================================
ALTER TABLE utilisateurs
ADD COLUMN IF NOT EXISTS cgu_version_acceptee INTEGER,
ADD COLUMN IF NOT EXISTS cgu_acceptee_le TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS privacy_version_acceptee INTEGER,
ADD COLUMN IF NOT EXISTS privacy_acceptee_le TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS relais_id UUID REFERENCES relais(id) ON DELETE SET NULL;
-- ==========================================================
-- Ticket #103 : Numéro de dossier (AAAA-NNNNNN, séquence par année)
-- ==========================================================
CREATE TABLE numero_dossier_sequence (
annee INT PRIMARY KEY,
prochain INT NOT NULL DEFAULT 1
CREATE TABLE IF NOT EXISTS numero_dossier_sequence (
annee INT PRIMARY KEY,
prochain INT NOT NULL DEFAULT 1
);
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
ALTER TABLE assistantes_maternelles ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
ALTER TABLE parents ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
CREATE INDEX IF NOT EXISTS idx_utilisateurs_numero_dossier ON utilisateurs(numero_dossier) WHERE numero_dossier IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_assistantes_maternelles_numero_dossier ON assistantes_maternelles(numero_dossier) WHERE numero_dossier IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_parents_numero_dossier ON parents(numero_dossier) WHERE numero_dossier IS NOT NULL;
-- ==========================================================
-- Ticket #110 : Token reprise après refus (lien email)
-- ==========================================================
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise VARCHAR(255) NULL;
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL;
CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise ON utilisateurs(token_reprise) WHERE token_reprise IS NOT NULL;
-- ==========================================================
-- Ticket #127 : Mot de passe oublié (token dédié, ≠ inscription)
-- ==========================================================
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS password_reset_token VARCHAR(255) NULL;
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS password_reset_expires TIMESTAMPTZ NULL;
CREATE INDEX IF NOT EXISTS idx_utilisateurs_password_reset_token ON utilisateurs(password_reset_token) WHERE password_reset_token IS NOT NULL;
-- Lieu de naissance (aligné CREATE TABLE utilisateurs — idempotent si colonnes déjà présentes)
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_ville VARCHAR(100) NULL;
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_pays VARCHAR(100) NULL;
-- ==========================================================
-- Seed : Documents légaux génériques v1
@ -449,7 +438,10 @@ INSERT INTO documents_legaux (type, version, fichier_nom, fichier_path, fichier_
-- ==========================================================
-- Seed : Super Administrateur par défaut
-- Email: admin@ptits-pas.fr | Mot de passe: 4dm1n1strateur
-- ==========================================================
-- Email: admin@ptits-pas.fr
-- Mot de passe: 4dm1n1strateur (hashé bcrypt)
-- IMPORTANT: Changer ce mot de passe en production !
-- ==========================================================
INSERT INTO utilisateurs (
email,

View File

@ -15,13 +15,12 @@ Ce projet contient la **base de données** pour l'application PtitsPas, avec scr
## Structure du projet
- **`BDD.sql`** : schéma canonique (création from scratch — utilisé par `docker-compose.yml` racine)
- `migrations/` : scripts SQL pour **mettre à jour** une BDD déjà en service
- `bdd/data_test/` : fichiers CSV (import legacy)
- `docs/` : documentation métier et technique (`ENUMS.md`, `FK_POLICIES.md`)
- `seed/` : scripts de seed (`03_seed_test_data.sql` — jeu dashboard admin)
- `migrations/` : scripts SQL pour la création et l'import de la base
- `bdd/data_test/` : fichiers CSV pour l'import de données de test
- `docs/` : documentation métier et technique
- `seed/` : scripts de seed
- `tests/` : tests SQL
- `docker-compose.dev.yml` : Postgres standalone (BDD.sql + seed auto)
- `docker-compose.dev.yml` : configuration Docker pour le développement
---

View File

@ -1,10 +1,10 @@
"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo","est_multiple"
"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","sans_garde","Emma","Dupont","F","2020-06-01",,,False,,False
"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","sans_garde",,,,"2020-01-01","2025-01-01",,False,,False
"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","sans_garde","Emma","Martin",,"2023-02-15",,,False,,False
"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","sans_garde","Noah","Martin",,"2023-02-15",,,False,,False
"e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e","sans_garde","Léa","Martin",,"2023-02-15",,,False,,False
"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","sans_garde","Chloé","Rousseau",,"2022-04-20",,,False,,False
"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","sans_garde","Hugo","Rousseau",,"2024-03-10",,,False,,False
"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","sans_garde","Maxime","Lecomte",,"2023-04-15",,,False,,False
"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,,False
"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo","est_multiple"
"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","actif","Emma","Dupont","F","2020-06-01",,,False,,False
"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","actif",,,,"2020-01-01","2025-01-01",,False,,False
"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","actif","Emma","Martin",,"2023-02-15",,,False,,False
"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","actif","Noah","Martin",,"2023-02-15",,,False,,False
"e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e","actif","Léa","Martin",,"2023-02-15",,,False,,False
"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","actif","Chloé","Rousseau",,"2022-04-20",,,False,,False
"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","actif","Hugo","Rousseau",,"2024-03-10",,,False,,False
"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","actif","Maxime","Lecomte",,"2023-04-15",,,False,,False
"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,,False

1 id statut prenom nom genre date_naissance date_prevue_naissance photo_url consentement_photo date_consentement_photo est_multiple
2 5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf sans_garde actif Emma Dupont F 2020-06-01 False False
3 a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d sans_garde actif 2020-01-01 2025-01-01 False False
4 e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c sans_garde actif Emma Martin 2023-02-15 False False
5 e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d sans_garde actif Noah Martin 2023-02-15 False False
6 e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e sans_garde actif Léa Martin 2023-02-15 False False
7 e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f sans_garde actif Chloé Rousseau 2022-04-20 False False
8 e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a sans_garde actif Hugo Rousseau 2024-03-10 False False
9 e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b sans_garde actif Maxime Lecomte 2023-04-15 False False
10 edd19cd1-bb67-4f14-8a37-c66b75c94537 scolarise Lucas Durand H 2018-09-15 False False

View File

@ -14,8 +14,9 @@ services:
ports:
- "5433:5432"
volumes:
- ./BDD.sql:/docker-entrypoint-initdb.d/01_init.sql
- ./seed/03_seed_test_data.sql:/docker-entrypoint-initdb.d/02_seed_test_data.sql
- ./migrations/01_init.sql:/docker-entrypoint-initdb.d/01_init.sql
- ./migrations/07_import.sql:/docker-entrypoint-initdb.d/07_import.sql
- ./bdd/data_test:/bdd/data_test
- postgres_standalone_data:/var/lib/postgresql/data
networks:
- ptitspas_dev

View File

@ -51,17 +51,12 @@ Ce document recense **toutes les valeurs énumérées** utilisées dans la base
| Valeur | Description |
|---|---|
| `a_naitre` | Enfant à naître (date prévue renseignée) |
| `sans_garde` | Enfant né, pas encore placé chez une AM (défaut à l'inscription) |
| `garde` | Enfant placé chez une AM (`enfants_assistantes_maternelles` actif) |
| `actif` | Enfant pris en charge / en cours de garde |
| `scolarise` | Enfant scolarisé, garde potentiellement périscolaire |
**Contraintes associées** :
- `a_naitre`**`date_prevue_naissance` obligatoire**
- `sans_garde` / `garde` / `scolarise`**`date_naissance` obligatoire**
**Transitions** (API admin #131) :
- Rattachement AM ↔ enfant → `garde` (sauf `a_naitre` / `scolarise`)
- Détachement sans autre placement actif → `sans_garde`
- `actif`/`scolarise`**`date_naissance` obligatoire**
---

View File

@ -32,9 +32,6 @@ Documenter, de façon unique et partagée, les règles de suppression/mise à jo
| **parents(id_co_parent)**`utilisateurs(id)` | **SET NULL** | Conserver le parent principal si co-parent disparaît |
| **enfants_parents(id_parent)**`parents(id_utilisateur)` | **CASCADE** | Nettoyage liaisons N:N |
| **enfants_parents(id_enfant)**`enfants(id)` | **CASCADE** | Idem |
| **enfants_assistantes_maternelles(id_am)**`assistantes_maternelles(id_utilisateur)` | **CASCADE** | Placement supprimé avec lAM |
| **enfants_assistantes_maternelles(id_enfant)**`enfants(id)` | **CASCADE** | Idem |
| **enfants_assistantes_maternelles(cree_par)**`utilisateurs(id)` | **SET NULL** | Historique placement conservé |
| **dossiers(id_parent)**`parents(id_utilisateur)` | **CASCADE** | Dossier na pas de sens sans parent |
| **dossiers(id_enfant)**`enfants(id)` | **CASCADE** | Dossier na pas de sens sans enfant |
| **messages(id_dossier)**`dossiers(id)` | **CASCADE** | Messages détruits avec le dossier |

View File

@ -1,36 +0,0 @@
-- Ticket #131 / #141 — Placement AM ↔ enfant + statuts garde/sans_garde
-- ⚠️ BDD EXISTANTES UNIQUEMENT — ne pas exécuter sur une base créée via BDD.sql à jour.
-- Idempotent : safe à rejouer sur recette / prod.
-- 1) Nouveaux statuts enfant
DO $$ BEGIN
ALTER TYPE statut_enfant_type ADD VALUE IF NOT EXISTS 'garde';
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TYPE statut_enfant_type ADD VALUE IF NOT EXISTS 'sans_garde';
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- actif → sans_garde (enfants sans placement AM connu au déploiement)
UPDATE enfants SET statut = 'sans_garde' WHERE statut = 'actif';
-- 2) Table de placement AM ↔ enfant
CREATE TABLE IF NOT EXISTS enfants_assistantes_maternelles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
id_am UUID NOT NULL REFERENCES assistantes_maternelles(id_utilisateur) ON DELETE CASCADE,
id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE,
date_debut DATE NOT NULL DEFAULT CURRENT_DATE,
date_fin DATE NULL,
cree_le TIMESTAMPTZ NOT NULL DEFAULT now(),
cree_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_eam_am ON enfants_assistantes_maternelles(id_am);
CREATE INDEX IF NOT EXISTS idx_eam_enfant ON enfants_assistantes_maternelles(id_enfant);
-- Au plus une garde active par enfant
CREATE UNIQUE INDEX IF NOT EXISTS uq_enfant_garde_active
ON enfants_assistantes_maternelles (id_enfant)
WHERE date_fin IS NULL;

View File

@ -65,12 +65,12 @@ ON CONFLICT (id_utilisateur) DO NOTHING;
-- ------------------------------------------------------------
-- Enfants
-- - child A : déjà né (statut = 'sans_garde' et date_naissance requise)
-- - child A : déjà né (statut = 'actif' et date_naissance requise)
-- - child B : à naître (statut = 'a_naitre' et date_prevue_naissance requise)
-- ------------------------------------------------------------
INSERT INTO enfants (id, prenom, nom, statut, date_naissance, jumeau_multiple)
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'sans_garde', '2022-04-12', false)
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'actif', '2022-04-12', false)
ON CONFLICT (id) DO NOTHING;
INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance, jumeau_multiple)

View File

@ -18,15 +18,15 @@ BEGIN;
INSERT INTO utilisateurs (id, email, password, prenom, nom, role, statut, telephone, adresse, ville, code_postal, profession, situation_familiale, date_naissance, consentement_photo)
VALUES
('a0000001-0001-0001-0001-000000000001', 'sophie.bernard@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Sophie', 'BERNARD', 'administrateur', 'actif', '0678123456', '12 Avenue Gabriel Péri', 'Bezons', '95870', 'Responsable administrative', 'marie', '1978-03-15', false),
('a0000002-0002-0002-0002-000000000002', 'lucas.moreau@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Lucas', 'MOREAU', 'gestionnaire', 'actif', '0687234567', '8 Rue Jean Jaurès', 'Bezons', '95870', 'Gestionnaire des placements', 'celibataire', '1985-09-22', false),
('a0000003-0003-0003-0003-000000000003', 'marie.dubois@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Marie', 'DUBOIS', 'assistante_maternelle', 'actif', '0696345678', '25 Rue de la République', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1980-06-08', true),
('a0000004-0004-0004-0004-000000000004', 'fatima.elmansouri@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Fatima', 'EL MANSOURI', 'assistante_maternelle', 'actif', '0675456789', '17 Boulevard Aristide Briand', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1975-11-12', true),
('a0000005-0005-0005-0005-000000000005', 'claire.martin@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Claire', 'MARTIN', 'parent', 'actif', '0689567890', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Infirmière', 'marie', '1990-04-03', false),
('a0000006-0006-0006-0006-000000000006', 'thomas.martin@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Thomas', 'MARTIN', 'parent', 'actif', '0678456789', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Ingénieur', 'marie', '1988-07-18', false),
('a0000007-0007-0007-0007-000000000007', 'amelie.durand@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Amélie', 'DURAND', 'parent', 'actif', '0667788990', '23 Rue Victor Hugo', 'Bezons', '95870', 'Comptable', 'divorce', '1987-12-14', false),
('a0000008-0008-0008-0008-000000000008', 'julien.rousseau@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Julien', 'ROUSSEAU', 'parent', 'actif', '0656677889', '14 Rue Pasteur', 'Bezons', '95870', 'Commercial', 'divorce', '1985-08-29', false),
('a0000009-0009-0009-0009-000000000009', 'david.lecomte@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'David', 'LECOMTE', 'parent', 'actif', '0645566778', '31 Rue Émile Zola', 'Bezons', '95870', 'Développeur web', 'parent_isole', '1992-10-07', false)
('a0000001-0001-0001-0001-000000000001', 'sophie.bernard@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Sophie', 'BERNARD', 'administrateur', 'actif', '0678123456', '12 Avenue Gabriel Péri', 'Bezons', '95870', 'Responsable administrative', 'marie', '1978-03-15', false),
('a0000002-0002-0002-0002-000000000002', 'lucas.moreau@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Lucas', 'MOREAU', 'gestionnaire', 'actif', '0687234567', '8 Rue Jean Jaurès', 'Bezons', '95870', 'Gestionnaire des placements', 'celibataire', '1985-09-22', false),
('a0000003-0003-0003-0003-000000000003', 'marie.dubois@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Marie', 'DUBOIS', 'assistante_maternelle', 'actif', '0696345678', '25 Rue de la République', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1980-06-08', true),
('a0000004-0004-0004-0004-000000000004', 'fatima.elmansouri@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Fatima', 'EL MANSOURI', 'assistante_maternelle', 'actif', '0675456789', '17 Boulevard Aristide Briand', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1975-11-12', true),
('a0000005-0005-0005-0005-000000000005', 'claire.martin@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Claire', 'MARTIN', 'parent', 'actif', '0689567890', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Infirmière', 'marie', '1990-04-03', false),
('a0000006-0006-0006-0006-000000000006', 'thomas.martin@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Thomas', 'MARTIN', 'parent', 'actif', '0678456789', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Ingénieur', 'marie', '1988-07-18', false),
('a0000007-0007-0007-0007-000000000007', 'amelie.durand@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Amélie', 'DURAND', 'parent', 'actif', '0667788990', '23 Rue Victor Hugo', 'Bezons', '95870', 'Comptable', 'divorce', '1987-12-14', false),
('a0000008-0008-0008-0008-000000000008', 'julien.rousseau@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Julien', 'ROUSSEAU', 'parent', 'actif', '0656677889', '14 Rue Pasteur', 'Bezons', '95870', 'Commercial', 'divorce', '1985-08-29', false),
('a0000009-0009-0009-0009-000000000009', 'david.lecomte@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'David', 'LECOMTE', 'parent', 'actif', '0645566778', '31 Rue Émile Zola', 'Bezons', '95870', 'Développeur web', 'parent_isole', '1992-10-07', false)
ON CONFLICT (email) DO NOTHING;
-- ========== PARENTS (avec co-parent pour le couple Martin) ==========
@ -51,12 +51,12 @@ ON CONFLICT (id_utilisateur) DO NOTHING;
-- ========== ENFANTS ==========
INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut, est_multiple)
VALUES
('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'sans_garde', true),
('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'sans_garde', true),
('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'sans_garde', true),
('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'sans_garde', false),
('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'sans_garde', false),
('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'sans_garde', false)
('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'actif', true),
('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'actif', true),
('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'actif', true),
('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'actif', false),
('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'actif', false),
('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'actif', false)
ON CONFLICT (id) DO NOTHING;
-- ========== ENFANTS_PARENTS (liaison N:N) ==========

View File

@ -1,127 +0,0 @@
# #131 — En-tête fiche parent : co-parent (note front → back)
**Ticket :** #131 (fiche parent dashboard, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1)
**Date :** 2026-06-01
**Statut front :** livré (en-tête dynamique)
**Modif backend demandée :** **aucune fonctionnelle** — ce document fixe le contrat attendu ; le back valide `co_parent` et masque les champs sensibles.
---
## 1. Comportement UI (front)
Dans la modale **fiche parent** (`AdminParentEditModal`) :
| Zone | Contenu |
|------|---------|
| **Titre** | `prenom` + `nom` du parent affiché (plus le libellé fixe « Fiche parent ») |
| **Sous-titre** | `Co-parent : {prenom} {nom}` — affiché **uniquement** si un co-parent est connu |
Le titre se met à jour en direct pendant lédition des champs nom/prénom.
Le sous-titre provient du co-parent **chargé depuis lAPI** (pas saisi à la main dans la modale).
---
## 2. Endpoints consommés
| Méthode | Route | Usage front |
|---------|-------|-------------|
| `GET` | `/api/v1/parents` | Liste parents (onglet Parents) |
| `GET` | `/api/v1/parents/:userId` | Rechargement fiche après rattachement/détachement enfant |
| `PATCH` | `/api/v1/parents/:userId/fiche` | Sauvegarde identité + statut (inchangé) |
Rôles : `super_admin`, `gestionnaire`, `administrateur` (selon route).
---
## 3. Contrat JSON attendu pour `co_parent`
Le front parse `ParentModel.fromJson` avec la clé **`co_parent`** (snake_case), objet utilisateur imbriqué.
### Champs minimum utilisés pour le sous-titre
| Clé JSON | Usage |
|----------|--------|
| `co_parent` | Objet ou absent/`null` |
| `co_parent.id` | Identifiant (futur lien cliquable éventuel) |
| `co_parent.prenom` | Affichage |
| `co_parent.nom` | Affichage |
Affichage front : `'{prenom} {nom}'.trim()` → libellé `Co-parent : …`.
### Exemple de fragment de réponse (`GET /parents/:id`)
```json
{
"user_id": "33333333-3333-3333-3333-333333333333",
"numero_dossier": "2026-000042",
"user": {
"id": "33333333-3333-3333-3333-333333333333",
"email": "parent1@example.com",
"prenom": "Paul",
"nom": "PARENT",
"statut": "actif",
"telephone": "0601020304"
},
"co_parent": {
"id": "44444444-4444-4444-4444-444444444444",
"email": "coparent1@example.com",
"prenom": "Clara",
"nom": "COPARENT",
"role": "parent",
"statut": "actif"
},
"parentChildren": []
}
```
> **Note :** le front lit `user` (pas `utilisateur`). La doc `11_API.md` § Parents mentionne encore `utilisateur` / `id_co_parent` seul — le contrat **effectif** côté Nest/TypeORM est lentité `Parents` sérialisée (`user`, `co_parent`, `parentChildren`, …).
---
## 4. État backend
### Relations (déjà en place)
- `findAll()` et `findOne(user_id)` chargent **`co_parent`** ;
- FK : `parents.id_co_parent``utilisateurs.id` ;
- inscription couple : les deux sens renseignés en principe (`auth.service.ts`).
### Livraison back (#131)
- `mapParentForApi` / `sanitizeUserForApi` : réponses `GET/PATCH/POST/DELETE` parents **sans** `password`, `token_creation_mdp`, `password_reset_*` sur `user` et `co_parent`.
**Checklist validation :**
- [x] `GET /parents/:id` renvoie `co_parent` peuplé quand `id_co_parent` est non null
- [x] `GET /parents` (liste) inclut `co_parent`
- [x] `prenom` / `nom` du co-parent présents
- [x] Pas de fuite `password` / tokens sur `user` ni `co_parent`
---
## 5. Points dattention (hors périmètre immédiat)
| Sujet | Détail |
|-------|--------|
| **Lien inverse** | Si B est co-parent de A (`A.id_co_parent = B`) mais `B.id_co_parent` est `null`, le sous-titre **ne saffichera pas** sur la fiche de B. Pas de résolution inverse côté front. |
| **Familles > 2 adultes** | Sous-titre = co-parent direct (`id_co_parent`) uniquement. |
| **Trou AM ↔ enfants en garde** | Pas de lien AMenfant aujourdhui (à documenter / traiter plus tard). |
---
## 6. Fichiers back concernés
| Fichier | Rôle |
|---------|------|
| `backend/src/routes/parents/parents.service.ts` | `findOne`, `findAll` + relations |
| `backend/src/routes/parents/parents.controller.ts` | `mapParentForApi` sur les réponses |
| `backend/src/routes/parents/parents.mapper.ts` | Sérialisation API |
| `backend/src/common/utils/sanitize-user-for-api.ts` | Masquage secrets |
| `backend/src/entities/parents.entity.ts` | relation `co_parent` |
---
## 7. Références
- `docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1
- Ticket Gitea **#131**

View File

@ -1,132 +0,0 @@
# #131 — Fiche AM éditable + affiliation enfants (note front → back)
**Ticket :** #131 (partie AM, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1)
**Date :** 2026-06-01
**Statut front :** modale livrée (2 onglets) — **API affiliation AM↔enfant à implémenter**
---
## 1. Comportement UI (front)
Modale `AdminAmEditModal` — même shell que la fiche parent (~930 px) :
| Onglet | Contenu |
|--------|---------|
| **Identité & professionnel** | `IdentityBlock` éditable + grille pro (agrément, ville résidence, capacité, places, NIR/agrément date en lecture seule, biographie, switch disponible) + gélule statut |
| **Enfants accueillis** | Liste cartes enfants (réutilise `AdminChildrenAffiliationPanel` / `AdminEnfantUserCard`) + rattacher / détacher |
En-tête : prénom nom · sous-titre `Zone · Agrément · Dossier`.
---
## 2. Endpoints consommés
### Déjà existants (partiels)
| Méthode | Route | Usage |
|---------|-------|-------|
| `GET` | `/api/v1/assistantes-maternelles` | Liste AM |
| `GET` | `/api/v1/assistantes-maternelles/:userId` | Détail (403 possible pour `administrateur` → fallback liste) |
| `PATCH` | `/api/v1/users/:userId` | Identité + statut (admin / super_admin uniquement) |
| `PATCH` | `/api/v1/assistantes-maternelles/:userId` | Champs pro (gestionnaire / super_admin) |
### À créer (recommandé — miroir parent #131 / #115)
| Méthode | Route | Rôle |
|---------|-------|------|
| `PATCH` | `/api/v1/assistantes-maternelles/:userId/fiche` | Mise à jour unifiée identité + pro + statut (`super_admin`, `gestionnaire`, `administrateur`) |
| `POST` | `/api/v1/assistantes-maternelles/:userId/enfants/:enfantId` | Rattacher un enfant |
| `DELETE` | `/api/v1/assistantes-maternelles/:userId/enfants/:enfantId` | Détacher un enfant |
| `GET` | `/api/v1/assistantes-maternelles/:userId` | Inclure `amChildren[]` (relation enfant) |
Le front appelle déjà ces routes ; en labsence de `PATCH …/fiche`, il tente un fallback `PATCH users` + `PATCH assistantes-maternelles` (échoue selon le rôle connecté).
---
## 3. Modèle de données affiliation AM ↔ enfant
**À définir côté BDD** (pas de table dédiée aujourdhui, contrairement à `enfants_parents`) :
Proposition alignée parent :
```sql
-- Piste : enfants_assistantes_maternelles
CREATE TABLE enfants_assistantes_maternelles (
id_am UUID NOT NULL REFERENCES utilisateurs(id) ON DELETE CASCADE,
id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE,
PRIMARY KEY (id_am, id_enfant)
);
```
Réponse API attendue sur `GET /assistantes-maternelles/:id` :
```json
{
"user_id": "uuid-am",
"user": { "id": "…", "prenom": "Claire", "nom": "MARTIN", "statut": "actif" },
"approval_number": "AGR-2024-12345",
"residence_city": "Bezons",
"max_children": 4,
"places_available": 2,
"available": true,
"amChildren": [
{
"child": {
"id": "uuid-enfant",
"first_name": "Emma",
"last_name": "MARTIN",
"status": "actif",
"birth_date": "2023-02-15"
}
}
]
}
```
Le front parse `amChildren` / `am_children` / `assistanteChildren` (même logique que `parentChildren`).
---
## 4. Body `PATCH …/fiche` suggéré
```json
{
"nom": "MARTIN",
"prenom": "Claire",
"email": "claire@example.com",
"telephone": "0612345678",
"adresse": "5 place Bellecour",
"ville": "Lyon",
"code_postal": "69002",
"statut": "actif",
"approval_number": "AGR-2024-12345",
"residence_city": "Lyon",
"max_children": 4,
"places_available": 2,
"biography": "…",
"available": true
}
```
NIR et date dagrément : lecture seule dans la modale (modification hors périmètre admin v1).
---
## 5. Fichiers front concernés
| Fichier | Rôle |
|---------|------|
| `frontend/lib/widgets/admin/common/admin_am_edit_modal.dart` | Modale 2 onglets |
| `frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart` | Liste enfants partagée parent/AM |
| `frontend/lib/widgets/admin/common/admin_status_capsule.dart` | Gélule statut partagée |
| `frontend/lib/models/assistante_maternelle_model.dart` | Parse champs pro + `amChildren` |
| `frontend/lib/services/user_service.dart` | `getAssistanteMaternelle`, `updateAmFiche`, `attachEnfantToAm`, `detachEnfantFromAm` |
| `frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart` | Ouverture modale au clic Modifier |
---
## 6. Références
- Fiche parent : `PATCH /parents/:id/fiche`, `POST|DELETE /parents/:id/enfants/:enfantId`
- Ticket Gitea **#131**, **#115**
- `docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md`

View File

@ -1,124 +0,0 @@
# #131 — En-tête fiche parent : co-parent (note front → back)
**Ticket :** #131 (fiche parent dashboard, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1)
**Date :** 2026-06-01
**Statut front :** livré (en-tête dynamique)
**Modif backend demandée :** **aucune** — ce document fixe le contrat attendu et invite à valider que lexistant le couvre.
---
## 1. Comportement UI (front)
Dans la modale **fiche parent** (`AdminParentEditModal`) :
| Zone | Contenu |
|------|---------|
| **Titre** | `prenom` + `nom` du parent affiché (plus le libellé fixe « Fiche parent ») |
| **Sous-titre** | `Co-parent : {prenom} {nom}` — affiché **uniquement** si un co-parent est connu |
Le titre se met à jour en direct pendant lédition des champs nom/prénom.
Le sous-titre provient du co-parent **chargé depuis lAPI** (pas saisi à la main dans la modale).
---
## 2. Endpoints consommés
| Méthode | Route | Usage front |
|---------|-------|-------------|
| `GET` | `/api/v1/parents` | Liste parents (onglet Parents) |
| `GET` | `/api/v1/parents/:userId` | Rechargement fiche après rattachement/détachement enfant |
| `PATCH` | `/api/v1/parents/:userId/fiche` | Sauvegarde identité + statut (inchangé) |
Rôles : `super_admin`, `gestionnaire`, `administrateur` (selon route).
---
## 3. Contrat JSON attendu pour `co_parent`
Le front parse `ParentModel.fromJson` avec la clé **`co_parent`** (snake_case), objet utilisateur imbriqué.
### Champs minimum utilisés pour le sous-titre
| Clé JSON | Usage |
|----------|--------|
| `co_parent` | Objet ou absent/`null` |
| `co_parent.id` | Identifiant (futur lien cliquable éventuel) |
| `co_parent.prenom` | Affichage |
| `co_parent.nom` | Affichage |
Affichage front : `'{prenom} {nom}'.trim()` → libellé `Co-parent : …`.
### Exemple de fragment de réponse (`GET /parents/:id`)
```json
{
"user_id": "33333333-3333-3333-3333-333333333333",
"numero_dossier": "2026-000042",
"user": {
"id": "33333333-3333-3333-3333-333333333333",
"email": "parent1@example.com",
"prenom": "Paul",
"nom": "PARENT",
"statut": "actif",
"telephone": "0601020304"
},
"co_parent": {
"id": "44444444-4444-4444-4444-444444444444",
"email": "coparent1@example.com",
"prenom": "Clara",
"nom": "COPARENT",
"role": "parent",
"statut": "actif"
},
"parentChildren": []
}
```
> **Note :** le front lit `user` (pas `utilisateur`). La doc `11_API.md` § Parents mentionne encore `utilisateur` / `id_co_parent` seul — le contrat **effectif** côté Nest/TypeORM est lentité `Parents` sérialisée (`user`, `co_parent`, `parentChildren`, …).
---
## 4. État backend (à valider, pas à refaire)
Daprès le code actuel (`parents.service.ts`) :
- `findAll()` et `findOne(user_id)` chargent déjà la relation **`co_parent`** ;
- la FK métier est `parents.id_co_parent``utilisateurs.id` ;
- à linscription couple, les deux sens sont en principe renseignés (`auth.service.ts`).
**Checklist validation back :**
- [ ] `GET /parents/:id` renvoie bien `co_parent` peuplé quand `id_co_parent` est non null
- [ ] `GET /parents` (liste) inclut aussi `co_parent` (sous-titre disponible dès louverture sans re-fetch)
- [ ] Les champs `prenom` / `nom` du co-parent sont présents dans la réponse JSON
Si ces trois points passent en recette, **aucun changement backend nest nécessaire** pour cette fonctionnalité.
---
## 5. Points dattention (hors périmètre immédiat)
| Sujet | Détail |
|-------|--------|
| **Lien inverse** | Si le parent B est le co-parent de A (`A.id_co_parent = B`) mais que `B.id_co_parent` est `null`, le sous-titre **ne saffichera pas** sur la fiche de B. Le front ne fait pas de résolution inverse. À traiter côté back **seulement si** des données legacy ont un lien à sens unique. |
| **Familles > 2 adultes** | Le sous-titre naffiche que le co-parent direct (`id_co_parent`). Les autres responsables liés uniquement via `enfants_parents` ne sont pas listés ici (cf. doc 28 §6). |
| **Données sensibles** | Vérifier que la sérialisation de `co_parent` nexpose pas `password` / tokens (même remarque que pour `user`). |
---
## 6. Fichiers front concernés
| Fichier | Rôle |
|---------|------|
| `frontend/lib/models/parent_model.dart` | Parse `co_parent``AppUser? coParent` |
| `frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart` | Titre + sous-titre |
| `frontend/lib/services/user_service.dart` | `getParents()` / `getParent()` |
---
## 7. Références
- `docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1
- `backend/src/routes/parents/parents.service.ts``findOne`, `findAll`
- `backend/src/entities/parents.entity.ts` — relation `co_parent`
- Ticket Gitea **#131**

View File

@ -1,102 +1,30 @@
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/models/user.dart';
class AssistanteMaternelleModel {
final AppUser user;
final String? approvalNumber;
final String? nir;
final String? residenceCity;
final int? maxChildren;
final int? placesAvailable;
final String? biography;
final bool? available;
final String? agreementDate;
final List<ParentChildSummary> children;
AssistanteMaternelleModel({
required this.user,
this.approvalNumber,
this.nir,
this.residenceCity,
this.maxChildren,
this.placesAvailable,
this.biography,
this.available,
this.agreementDate,
this.children = const [],
});
factory AssistanteMaternelleModel.fromJson(Map<String, dynamic> json) {
final root = Map<String, dynamic>.from(json);
final userJson = Map<String, dynamic>.from(root['user'] ?? root);
if (root['numero_dossier'] != null && userJson['numero_dossier'] == null) {
userJson['numero_dossier'] = root['numero_dossier'];
}
final userJson = json['user'] ?? json;
final user = AppUser.fromJson(userJson);
final children = _parseChildren(root);
return AssistanteMaternelleModel(
user: user,
approvalNumber: _str(
root['approval_number'] ?? root['numero_agrement'],
),
nir: _str(root['nir'] ?? root['nir_chiffre']),
residenceCity: _str(
root['residence_city'] ?? root['ville_residence'],
),
maxChildren: _int(root['max_children'] ?? root['nb_max_enfants']),
placesAvailable: _int(
root['places_available'] ?? root['place_disponible'],
),
biography: _str(root['biography'] ?? root['biographie']),
available: root['available'] as bool? ?? root['disponible'] as bool?,
agreementDate: _dateString(
root['agreement_date'] ?? root['date_agrement'],
),
children: children,
approvalNumber: json['numero_agrement'] as String?,
residenceCity: json['ville_residence'] as String?,
maxChildren: json['nb_max_enfants'] as int?,
placesAvailable: json['place_disponible'] as int?,
);
}
static String? _str(dynamic v) {
if (v == null) return null;
final s = v.toString().trim();
return s.isEmpty ? null : s;
}
static int? _int(dynamic v) {
if (v == null) return null;
if (v is int) return v;
return int.tryParse(v.toString());
}
static String? _dateString(dynamic v) {
if (v == null) return null;
return v.toString().split('T').first;
}
static List<ParentChildSummary> _parseChildren(Map<String, dynamic> json) {
final children = <ParentChildSummary>[];
final seen = <String>{};
void add(ParentChildSummary? child) {
if (child == null || child.id.isEmpty || seen.contains(child.id)) return;
seen.add(child.id);
children.add(child);
}
final links = json['amChildren'] ??
json['am_children'] ??
json['assistanteChildren'] ??
json['assistante_children'];
if (links is List) {
for (final link in links) {
if (link is! Map) continue;
add(ParentChildSummary.fromParentChildLink(
Map<String, dynamic>.from(link),
));
}
}
return children;
}
}

View File

@ -1,5 +1,4 @@
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
/// Réponse unifiée GET /dossiers/:numeroDossier. Ticket #119, #107.
class DossierUnifie {
@ -221,7 +220,7 @@ class EnfantDossier {
lastName: (json['last_name'] ?? json['nom'])?.toString(),
birthDate: json['birth_date']?.toString(),
gender: (json['gender'] ?? json['genre'])?.toString(),
status: normalizeEnfantStatus(json['status']?.toString()),
status: json['status']?.toString(),
dueDate: json['due_date']?.toString(),
photoUrl: resolvedPhoto,
consentPhoto:

View File

@ -1,5 +1,4 @@
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
/// Enfant tel que renvoyé par `GET /enfants` (dashboard admin).
class EnfantAdminModel {
@ -55,7 +54,7 @@ class EnfantAdminModel {
gender: json['gender'] as String?,
birthDate: _dateString(json['birth_date']),
dueDate: _dateString(json['due_date']),
status: normalizeEnfantStatus(json['status']?.toString()),
status: (json['status'] ?? '').toString(),
photoUrl: json['photo_url'] as String?,
consentPhoto: json['consent_photo'] == true,
isMultiple: json['is_multiple'] == true,

View File

@ -1,5 +1,4 @@
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
/// Résumé enfant affiché dans la fiche parent (dashboard admin).
class ParentChildSummary {
@ -34,9 +33,7 @@ class ParentChildSummary {
id: (json['id'] ?? '').toString(),
firstName: (json['first_name'] ?? json['prenom'])?.toString(),
lastName: (json['last_name'] ?? json['nom'])?.toString(),
status: normalizeEnfantStatus(
(json['status'] ?? json['statut'])?.toString(),
),
status: (json['status'] ?? json['statut'] ?? '').toString(),
photoUrl: json['photo_url']?.toString(),
birthDate: _dateString(json['birth_date']),
dueDate: _dateString(json['due_date']),

View File

@ -3,13 +3,11 @@ import 'package:p_tits_pas/models/user.dart';
class ParentModel {
final AppUser user;
final AppUser? coParent;
final int childrenCount;
final List<ParentChildSummary> children;
ParentModel({
required this.user,
this.coParent,
this.childrenCount = 0,
this.children = const [],
});
@ -22,19 +20,12 @@ class ParentModel {
}
final user = AppUser.fromJson(userJson);
AppUser? coParent;
final coParentRaw = root['co_parent'];
if (coParentRaw is Map) {
coParent = AppUser.fromJson(Map<String, dynamic>.from(coParentRaw));
}
final children = _parseChildren(root);
final links = root['parentChildren'] ?? root['parent_children'];
final linkCount = links is List ? links.length : 0;
return ParentModel(
user: user,
coParent: coParent,
childrenCount: children.isNotEmpty ? children.length : linkCount,
children: children,
);

View File

@ -513,153 +513,7 @@ class UserService {
}
final List<dynamic> data = jsonDecode(response.body);
return data
.map((e) => AssistanteMaternelleModel.fromJson(
Map<String, dynamic>.from(e as Map),
))
.toList();
}
static Future<AssistanteMaternelleModel> getAssistanteMaternelle(
String userId,
) async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$userId'),
headers: await _headers(),
);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body);
return AssistanteMaternelleModel.fromJson(
Map<String, dynamic>.from(decoded is Map ? decoded : {}),
);
}
if (response.statusCode == 403 || response.statusCode == 404) {
final all = await getAssistantesMaternelles();
return all.firstWhere(
(a) => a.user.id == userId,
orElse: () => throw Exception('Assistante maternelle introuvable'),
);
}
final err = jsonDecode(response.body) as Map<String, dynamic>?;
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement AM');
}
/// Mise à jour fiche AM (identité + champs pro). Ticket #131.
static Future<AssistanteMaternelleModel> updateAmFiche({
required String amUserId,
required Map<String, dynamic> body,
}) async {
final ficheResponse = await http.patch(
Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId/fiche',
),
headers: await _headers(),
body: jsonEncode(body),
);
if (ficheResponse.statusCode == 200) {
return _amModelFromBody(ficheResponse.body);
}
if (ficheResponse.statusCode != 404) {
throw Exception(
_extractErrorMessage(ficheResponse.body, 'Erreur mise à jour AM'),
);
}
final userFields = <String, dynamic>{};
for (final k in [
'nom',
'prenom',
'email',
'telephone',
'adresse',
'ville',
'code_postal',
'statut',
'date_naissance',
'lieu_naissance_ville',
'lieu_naissance_pays',
]) {
if (body.containsKey(k)) userFields[k] = body[k];
}
final proFields = <String, dynamic>{};
for (final entry in body.entries) {
if (!userFields.containsKey(entry.key)) {
proFields[entry.key] = entry.value;
}
}
if (userFields.isNotEmpty) {
final userResponse = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$amUserId'),
headers: await _headers(),
body: jsonEncode(userFields),
);
if (userResponse.statusCode != 200) {
throw Exception(
_extractErrorMessage(userResponse.body, 'Erreur mise à jour identité'),
);
}
}
if (proFields.isNotEmpty) {
final proResponse = await http.patch(
Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId',
),
headers: await _headers(),
body: jsonEncode(proFields),
);
if (proResponse.statusCode != 200) {
throw Exception(
_extractErrorMessage(proResponse.body, 'Erreur mise à jour fiche pro'),
);
}
}
return getAssistanteMaternelle(amUserId);
}
static Future<AssistanteMaternelleModel> attachEnfantToAm({
required String amUserId,
required String enfantId,
}) async {
final response = await http.post(
Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId/enfants/$enfantId',
),
headers: await _headers(),
);
if (response.statusCode != 200 && response.statusCode != 201) {
throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant'));
}
return _amModelFromBody(response.body);
}
static Future<AssistanteMaternelleModel> detachEnfantFromAm({
required String amUserId,
required String enfantId,
}) async {
final response = await http.delete(
Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}/$amUserId/enfants/$enfantId',
),
headers: await _headers(),
);
if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception(_extractErrorMessage(response.body, 'Erreur détachement enfant'));
}
if (response.body.isEmpty) {
return getAssistanteMaternelle(amUserId);
}
return _amModelFromBody(response.body);
}
static AssistanteMaternelleModel _amModelFromBody(String body) {
final decoded = jsonDecode(body);
return AssistanteMaternelleModel.fromJson(
Map<String, dynamic>.from(decoded is Map ? decoded : {}),
);
return data.map((e) => AssistanteMaternelleModel.fromJson(e)).toList();
}
// Récupérer la liste des administrateurs (via /users filtré ou autre)

View File

@ -1,45 +0,0 @@
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
/// Places disponibles attendues : capacité max enfants rattachés.
int? amExpectedPlacesAvailable({
required int? maxChildren,
required int childrenCount,
}) {
if (maxChildren == null) return null;
return (maxChildren - childrenCount).clamp(0, maxChildren);
}
/// True si la valeur déclarée par l'AM ne correspond pas au calcul métier.
bool amHasPlacesInconsistency({
required int? maxChildren,
required int? placesAvailable,
required int childrenCount,
}) {
final expected = amExpectedPlacesAvailable(
maxChildren: maxChildren,
childrenCount: childrenCount,
);
if (expected == null) return false;
if (placesAvailable == null) return childrenCount > (maxChildren ?? 0);
return placesAvailable != expected;
}
String? amPlacesVigilanceMessage(AssistanteMaternelleModel am) {
if (!amHasPlacesInconsistency(
maxChildren: am.maxChildren,
placesAvailable: am.placesAvailable,
childrenCount: am.children.length,
)) {
return null;
}
final stored = am.placesAvailable;
final expected = amExpectedPlacesAvailable(
maxChildren: am.maxChildren,
childrenCount: am.children.length,
);
final storedLabel = stored?.toString() ?? 'non renseigné';
final expectedLabel = expected?.toString() ?? '';
return 'Point de vigilance : l\'AM déclare $storedLabel place(s) disponible(s), '
'alors que capacité ${am.maxChildren ?? ''} '
'${am.children.length} enfant(s) rattaché(s) = $expectedLabel.';
}

View File

@ -1,5 +1,4 @@
import 'package:intl/intl.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
/// Affiche une date ISO / parseable en `dd/MM/yyyy`, avec repli sur la chaîne ou [ifEmpty].
String formatIsoDateFr(String? s, {String ifEmpty = ''}) {
@ -11,25 +10,7 @@ String formatIsoDateFr(String? s, {String ifEmpty = ''}) {
}
}
/// Convertit `dd/MM/yyyy` (ou ISO) en `yyyy-MM-dd` pour l'API.
String? parseFrDateToIso(String text) {
final t = text.trim();
if (t.isEmpty) return null;
try {
return DateFormat('dd/MM/yyyy')
.parseStrict(t)
.toIso8601String()
.split('T')
.first;
} catch (_) {
try {
return DateTime.parse(t).toIso8601String().split('T').first;
} catch (_) {
return null;
}
}
}
/// Parties d'âge calendaire entre [birth] et [reference] (date-only).
({int years, int months, int days}) computeChildAgeParts(
DateTime birth,
DateTime reference,
@ -64,7 +45,7 @@ String formatChildAgeLabel({
String? dueDate,
String? status,
}) {
if (status == 'a_naitre' || normalizeEnfantStatus(status) == 'a_naitre') {
if (status == 'a_naitre') {
final due = formatIsoDateFr(dueDate, ifEmpty: '');
if (due.isNotEmpty) return 'Naissance prévue : $due';
return 'À naître';

View File

@ -1,51 +0,0 @@
/// Statuts enfant alignés sur `statut_enfant_type` (BDD / back #131).
const enfantStatusValues = [
'a_naitre',
'sans_garde',
'garde',
'scolarise',
];
/// Normalise une valeur API (legacy `actif` `sans_garde`).
String normalizeEnfantStatus(String? raw) {
final s = (raw ?? '').trim().toLowerCase();
if (s.isEmpty) return 'sans_garde';
if (s == 'actif') return 'sans_garde';
return s;
}
String _scolariseAccordeAuGenre(String? gender) {
final g = (gender ?? '').trim().toUpperCase();
if (g == 'F') return 'Scolarisée';
return 'Scolarisé';
}
/// Libellé affiché pour un statut enfant.
String enfantStatusLabel(String? status, {String? gender}) {
switch (normalizeEnfantStatus(status)) {
case 'a_naitre':
return 'À naître';
case 'sans_garde':
return 'Sans garde';
case 'garde':
return 'En garde';
case 'scolarise':
return _scolariseAccordeAuGenre(gender);
default:
return status?.trim().isNotEmpty == true ? status!.trim() : '';
}
}
/// Libellé court pour colonnes validation (null = pas de bandeau).
String? enfantColumnStatusLabel({String? status, String? gender}) {
final s = normalizeEnfantStatus(status);
switch (s) {
case 'a_naitre':
case 'sans_garde':
case 'garde':
case 'scolarise':
return enfantStatusLabel(s, gender: gender);
default:
return null;
}
}

View File

@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/utils/am_vigilance.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
@ -77,12 +77,10 @@ class _AssistanteMaternelleManagementWidgetState
itemCount: filteredAssistantes.length,
itemBuilder: (context, index) {
final assistante = filteredAssistantes[index];
final vigilance = amPlacesVigilanceMessage(assistante);
return AdminUserCard(
title: assistante.user.fullName,
avatarUrl: assistante.user.photoUrl,
fallbackIcon: Icons.face,
vigilanceTooltip: vigilance,
subtitleLines: [
assistante.user.email,
'Zone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}',
@ -104,10 +102,55 @@ class _AssistanteMaternelleManagementWidgetState
void _openAssistanteDetails(AssistanteMaternelleModel assistante) {
showDialog<void>(
context: context,
builder: (context) => AdminAmEditModal(
assistante: assistante,
onSaved: _loadAssistantes,
builder: (context) => AdminDetailModal(
title: assistante.user.fullName.isEmpty
? 'Assistante maternelle'
: assistante.user.fullName,
subtitle: assistante.user.email,
fields: [
AdminDetailField(label: 'ID', value: _v(assistante.user.id)),
AdminDetailField(
label: 'Numero agrement',
value: _v(assistante.approvalNumber),
),
AdminDetailField(
label: 'Ville residence',
value: _v(assistante.residenceCity),
),
AdminDetailField(
label: 'Capacite max',
value: assistante.maxChildren?.toString() ?? '-',
),
AdminDetailField(
label: 'Places disponibles',
value: assistante.placesAvailable?.toString() ?? '-',
),
AdminDetailField(
label: 'Telephone',
value: _v(assistante.user.telephone) != '' ? formatPhoneForDisplay(_v(assistante.user.telephone)) : '',
),
AdminDetailField(label: 'Adresse', value: _v(assistante.user.adresse)),
AdminDetailField(label: 'Ville', value: _v(assistante.user.ville)),
AdminDetailField(
label: 'Code postal',
value: _v(assistante.user.codePostal),
),
],
onEdit: () {
Navigator.of(context).pop();
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Action Modifier a implementer')),
);
},
onDelete: () {
Navigator.of(context).pop();
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Action Supprimer a implementer')),
);
},
),
);
}
String _v(String? value) => (value == null || value.isEmpty) ? '-' : value;
}

View File

@ -1,301 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
/// Grille 2×2 des places d'accueil AM (max 4, limitée à [capacity]).
class AdminAmChildrenCapacityGrid extends StatelessWidget {
static const int _gridSlots = 4;
static const double _slotHeight = 44;
static const double _gridPadding = 10;
static const double _gridGap = 8;
static const double _borderWidth = 1;
static const double fixedHeight = _borderWidth * 2 +
_gridPadding * 2 +
_slotHeight * 2 +
_gridGap;
final List<ParentChildSummary> children;
final int capacity;
final void Function(ParentChildSummary child) onOpen;
final void Function(ParentChildSummary child) onDetach;
const AdminAmChildrenCapacityGrid({
super.key,
required this.children,
required this.capacity,
required this.onOpen,
required this.onDetach,
});
@override
Widget build(BuildContext context) {
final maxSlots = capacity.clamp(0, _gridSlots);
return Container(
height: fixedHeight,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300, width: _borderWidth),
),
padding: const EdgeInsets.all(_gridPadding),
child: Column(
children: [
_buildRow(0, 1, maxSlots),
const SizedBox(height: _gridGap),
_buildRow(2, 3, maxSlots),
],
),
);
}
Widget _buildRow(int leftIndex, int rightIndex, int maxSlots) {
return Row(
children: [
Expanded(child: _buildSlot(leftIndex, maxSlots)),
const SizedBox(width: _gridGap),
Expanded(child: _buildSlot(rightIndex, maxSlots)),
],
);
}
Widget _buildSlot(int index, int maxSlots) {
return SizedBox(
height: _slotHeight,
child: _buildSlotContent(index, maxSlots),
);
}
Widget _buildSlotContent(int index, int maxSlots) {
if (index < children.length) {
return _OccupiedSlot(
child: children[index],
overCapacity: index >= maxSlots,
onOpen: () => onOpen(children[index]),
onDetach: () => onDetach(children[index]),
);
}
if (index < maxSlots) {
return const _EmptySlot();
}
return const _UnavailableSlot();
}
}
class _SlotShell extends StatelessWidget {
final Color backgroundColor;
final Color borderColor;
final Widget child;
const _SlotShell({
required this.backgroundColor,
required this.borderColor,
required this.child,
});
@override
Widget build(BuildContext context) {
return SizedBox.expand(
child: DecoratedBox(
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: borderColor),
),
child: child,
),
);
}
}
class _UnavailableSlot extends StatelessWidget {
const _UnavailableSlot();
@override
Widget build(BuildContext context) {
return _SlotShell(
backgroundColor: Colors.grey.shade100,
borderColor: Colors.grey.shade200,
child: Center(
child: Icon(
Icons.block_outlined,
size: 20,
color: Colors.grey.shade400,
),
),
);
}
}
class _EmptySlot extends StatelessWidget {
const _EmptySlot();
@override
Widget build(BuildContext context) {
return _SlotShell(
backgroundColor: Colors.grey.shade50,
borderColor: Colors.grey.shade300,
child: Center(
child: Text(
'Place libre',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
),
),
);
}
}
class _OccupiedSlot extends StatefulWidget {
final ParentChildSummary child;
final bool overCapacity;
final VoidCallback onOpen;
final VoidCallback onDetach;
const _OccupiedSlot({
required this.child,
required this.overCapacity,
required this.onOpen,
required this.onDetach,
});
@override
State<_OccupiedSlot> createState() => _OccupiedSlotState();
}
class _OccupiedSlotState extends State<_OccupiedSlot> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final age = formatChildAgeLabel(
birthDate: widget.child.birthDate,
dueDate: widget.child.dueDate,
status: widget.child.status,
);
final avatarUrl = ApiConfig.absoluteMediaUrl(widget.child.photoUrl ?? '');
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: _SlotShell(
backgroundColor: widget.overCapacity
? Colors.red.shade50
: const Color(0xFFF8F5FC),
borderColor: widget.overCapacity
? Colors.red.shade300
: const Color(0xFFD8CCE8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildAvatar(avatarUrl),
const SizedBox(width: 8),
Expanded(
child: Row(
children: [
Flexible(
fit: FlexFit.loose,
child: Text(
widget.child.fullName,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (age.isNotEmpty) ...[
const SizedBox(width: 8),
Expanded(
child: Text(
age,
style: const TextStyle(
fontSize: 11,
color: Colors.black54,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
],
),
),
AnimatedOpacity(
duration: const Duration(milliseconds: 120),
opacity: _hovered ? 1 : 0,
child: IgnorePointer(
ignoring: !_hovered,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Voir / modifier',
icon: const Icon(Icons.visibility_outlined, size: 20),
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
onPressed: widget.onOpen,
),
IconButton(
tooltip: 'Détacher',
icon: Icon(
Icons.link_off,
size: 20,
color: Colors.orange.shade800,
),
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
onPressed: widget.onDetach,
),
],
),
),
),
],
),
),
),
);
}
Widget _buildAvatar(String url) {
const size = 28.0;
const bg = Color(0xFFEDE5FA);
const iconColor = Color(0xFF6B3FA0);
if (url.isEmpty) {
return CircleAvatar(
radius: 14,
backgroundColor: bg,
child: const Icon(Icons.child_care_outlined, size: 15, color: iconColor),
);
}
return ClipOval(
child: SizedBox(
width: size,
height: size,
child: AuthNetworkImage(
url: url,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => ColoredBox(
color: bg,
child: const Icon(Icons.child_care_outlined, size: 15, color: iconColor),
),
),
),
);
}
}

View File

@ -1,841 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/utils/am_vigilance.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/nir_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_children_capacity_grid.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
/// Fiche AM éditable (ticket #131) identité | fiche pro (photo) | enfants.
class AdminAmEditModal extends StatefulWidget {
final AssistanteMaternelleModel assistante;
final VoidCallback? onSaved;
const AdminAmEditModal({
super.key,
required this.assistante,
this.onSaved,
});
@override
State<AdminAmEditModal> createState() => _AdminAmEditModalState();
}
class _AdminAmEditModalState extends State<AdminAmEditModal>
with SingleTickerProviderStateMixin {
late final TabController _tabCtrl;
late final TextEditingController _nomCtrl;
late final TextEditingController _prenomCtrl;
late final TextEditingController _emailCtrl;
late final TextEditingController _telCtrl;
late final TextEditingController _adresseCtrl;
late final TextEditingController _villeCtrl;
late final TextEditingController _cpCtrl;
late final TextEditingController _agrementCtrl;
late final TextEditingController _nirCtrl;
late final TextEditingController _dateNaissanceCtrl;
late final TextEditingController _lieuNaissanceVilleCtrl;
late final TextEditingController _lieuNaissancePaysCtrl;
late final TextEditingController _dateAgrementCtrl;
late final TextEditingController _capaciteCtrl;
late String _statut;
late bool _disponible;
late int? _placesAvailable;
late List<ParentChildSummary> _children;
late Set<String> _baselineChildIds;
bool _saving = false;
bool _dirty = false;
static const double _modalWidth = 930;
static const double _photoProGap = 24;
static const double _proColumnMinWidth = 260;
static const double _photoColumnMinWidth = 160;
static const double _proTabHeight = 300;
static const List<int> _photoProRowLayout = [2, 2, 2];
/// Hauteur onglet enfants : champs + titre + grille 2×2 (+ alerte places si besoin).
double _childrenTabHeight() {
const capacityFields = 72.0;
const titleSection = 40.0;
const inconsistencyExtra = 46.0;
var h = capacityFields +
titleSection +
AdminAmChildrenCapacityGrid.fixedHeight;
if (_placesInconsistent()) h += inconsistencyExtra;
return h + 4;
}
double _tabViewHeight(int index) {
switch (index) {
case 1:
return _proTabHeight;
case 2:
return _childrenTabHeight();
default:
return 292;
}
}
@override
void initState() {
super.initState();
_tabCtrl = TabController(length: 3, vsync: this);
final u = widget.assistante.user;
final am = widget.assistante;
_nomCtrl = TextEditingController(text: u.nom ?? '');
_prenomCtrl = TextEditingController(text: u.prenom ?? '');
_emailCtrl = TextEditingController(text: u.email);
_telCtrl = TextEditingController(
text: formatPhoneForDisplay(u.telephone ?? ''),
);
_adresseCtrl = TextEditingController(text: u.adresse ?? '');
_villeCtrl = TextEditingController(text: u.ville ?? '');
_cpCtrl = TextEditingController(text: u.codePostal ?? '');
_agrementCtrl = TextEditingController(text: am.approvalNumber ?? '');
_nirCtrl = TextEditingController(text: _formatNirDisplay());
_dateNaissanceCtrl = TextEditingController(
text: formatIsoDateFr(u.dateNaissance, ifEmpty: ''),
);
_lieuNaissanceVilleCtrl = TextEditingController(
text: u.lieuNaissanceVille ?? '',
);
_lieuNaissancePaysCtrl = TextEditingController(
text: u.lieuNaissancePays ?? '',
);
_dateAgrementCtrl = TextEditingController(
text: formatIsoDateFr(am.agreementDate, ifEmpty: ''),
);
_capaciteCtrl = TextEditingController(
text: am.maxChildren?.toString() ?? '',
);
_statut = u.statut ?? 'en_attente';
_disponible = am.available ?? true;
_placesAvailable = am.placesAvailable;
_children = List.of(am.children);
_baselineChildIds = _children.map((c) => c.id).toSet();
for (final c in [
_nomCtrl,
_prenomCtrl,
_emailCtrl,
_telCtrl,
_adresseCtrl,
_villeCtrl,
_cpCtrl,
_agrementCtrl,
_nirCtrl,
_dateNaissanceCtrl,
_lieuNaissanceVilleCtrl,
_lieuNaissancePaysCtrl,
_dateAgrementCtrl,
_capaciteCtrl,
]) {
c.addListener(_markDirty);
}
_capaciteCtrl.addListener(_onCapacityChanged);
_nomCtrl.addListener(_onNameFieldChanged);
_prenomCtrl.addListener(_onNameFieldChanged);
_tabCtrl.addListener(_onTabChanged);
_fetchChildrenFromServer();
}
bool _childrenChanged() {
final current = _children.map((c) => c.id).toSet();
return current.length != _baselineChildIds.length ||
!current.containsAll(_baselineChildIds);
}
void _syncPlacesAfterChildrenChange() {
final expected = _computedPlacesAvailable();
if (expected != null) _placesAvailable = expected;
}
void _onTabChanged() {
if (!_tabCtrl.indexIsChanging) setState(() {});
}
void _onNameFieldChanged() => setState(() {});
void _onCapacityChanged() => setState(() {});
@override
void dispose() {
_tabCtrl.dispose();
for (final c in [
_nomCtrl,
_prenomCtrl,
_emailCtrl,
_telCtrl,
_adresseCtrl,
_villeCtrl,
_cpCtrl,
_agrementCtrl,
_nirCtrl,
_dateNaissanceCtrl,
_lieuNaissanceVilleCtrl,
_lieuNaissancePaysCtrl,
_dateAgrementCtrl,
_capaciteCtrl,
]) {
c.dispose();
}
super.dispose();
}
void _markDirty() {
if (!_dirty) setState(() => _dirty = true);
}
String _headerTitle() {
final fn = _prenomCtrl.text.trim();
final ln = _nomCtrl.text.trim();
if (fn.isEmpty && ln.isEmpty) {
final fallback = widget.assistante.user.fullName.trim();
return fallback.isNotEmpty ? fallback : 'Assistante maternelle';
}
return '$fn $ln'.trim();
}
String? _headerSubtitle() {
final parts = <String>[];
final zone = (widget.assistante.residenceCity ?? '').trim();
if (zone.isNotEmpty) parts.add('Zone : $zone');
final agrement = _agrementCtrl.text.trim();
if (agrement.isNotEmpty) parts.add('Agrément : $agrement');
final dossier = widget.assistante.user.numeroDossier?.trim();
if (dossier != null && dossier.isNotEmpty) {
parts.add('Dossier $dossier');
}
if (parts.isEmpty) return null;
return parts.join(' · ');
}
String _formatNirDisplay() {
final raw = widget.assistante.nir?.trim() ?? '';
if (raw.isEmpty) return '';
final digits = nirToRaw(raw).toUpperCase();
return digits.length == 15 ? formatNir(digits) : raw;
}
String? _frDateToIso(String text) => parseFrDateToIso(text);
int? _capaciteMax() => _parseIntField(_capaciteCtrl);
int? _computedPlacesAvailable() => amExpectedPlacesAvailable(
maxChildren: _capaciteMax(),
childrenCount: _children.length,
);
bool _placesInconsistent() => amHasPlacesInconsistency(
maxChildren: _capaciteMax(),
placesAvailable: _placesAvailable,
childrenCount: _children.length,
);
String _placesDisplayValue() {
if (_placesAvailable != null) return _placesAvailable.toString();
return '';
}
void _applyPlacesCorrection() {
final expected = _computedPlacesAvailable();
if (expected == null) return;
setState(() {
_placesAvailable = expected;
_dirty = true;
});
}
String? _placesInconsistencyMessage() {
if (!_placesInconsistent()) return null;
final stored = _placesAvailable;
final expected = _computedPlacesAvailable();
final storedLabel = stored?.toString() ?? 'non renseigné';
final expectedLabel = expected?.toString() ?? '';
return 'Incohérence : l\'AM déclare $storedLabel place(s) disponible(s), '
'le calcul (capacité ${_capaciteMax() ?? ''} ${_children.length} '
'enfant(s) rattaché(s)) donne $expectedLabel.';
}
int? _parseIntField(TextEditingController c) {
final t = c.text.trim();
if (t.isEmpty) return null;
return int.tryParse(t);
}
Future<void> _save() async {
if (!_dirty) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Confirmer'),
content: const Text(
'Enregistrer les modifications de la fiche assistante maternelle ?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Sauvegarder'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _saving = true);
try {
if (_childrenChanged()) _syncPlacesAfterChildrenChange();
final currentIds = _children.map((c) => c.id).toSet();
for (final id in _baselineChildIds.difference(currentIds)) {
await UserService.detachEnfantFromAm(
amUserId: widget.assistante.user.id,
enfantId: id,
);
}
for (final id in currentIds.difference(_baselineChildIds)) {
await UserService.attachEnfantToAm(
amUserId: widget.assistante.user.id,
enfantId: id,
);
}
await UserService.updateAmFiche(
amUserId: widget.assistante.user.id,
body: {
'nom': _nomCtrl.text.trim(),
'prenom': _prenomCtrl.text.trim(),
'email': _emailCtrl.text.trim(),
'telephone': normalizePhone(_telCtrl.text),
'adresse': _adresseCtrl.text.trim(),
'ville': _villeCtrl.text.trim(),
'code_postal': _cpCtrl.text.trim(),
'statut': _statut,
'approval_number': _agrementCtrl.text.trim(),
'nir': nirToRaw(_nirCtrl.text),
if (_frDateToIso(_dateNaissanceCtrl.text) != null)
'date_naissance': _frDateToIso(_dateNaissanceCtrl.text),
'lieu_naissance_ville': _lieuNaissanceVilleCtrl.text.trim(),
'lieu_naissance_pays': _lieuNaissancePaysCtrl.text.trim(),
if (_frDateToIso(_dateAgrementCtrl.text) != null)
'agreement_date': _frDateToIso(_dateAgrementCtrl.text),
if (_capaciteMax() != null) 'max_children': _capaciteMax(),
if (_placesAvailable != null) 'places_available': _placesAvailable,
'available': _disponible,
},
);
if (!mounted) return;
setState(() {
_dirty = false;
_saving = false;
_baselineChildIds = currentIds;
});
widget.onSaved?.call();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Fiche assistante maternelle enregistrée')),
);
} catch (e) {
if (!mounted) return;
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
Future<void> _fetchChildrenFromServer() async {
try {
final refreshed =
await UserService.getAssistanteMaternelle(widget.assistante.user.id);
final all = await UserService.getEnfants();
final byId = {for (final e in all) e.id: e};
final kids = refreshed.children.map((c) {
final full = byId[c.id];
if (full != null) return ParentChildSummary.fromEnfant(full);
return c;
}).toList();
if (!mounted) return;
setState(() {
_children = kids;
_baselineChildIds = kids.map((c) => c.id).toSet();
});
} catch (_) {}
}
Future<void> _refreshChildrenDetails() async {
try {
final all = await UserService.getEnfants();
final byId = {for (final e in all) e.id: e};
if (!mounted) return;
setState(() {
_children = _children.map((c) {
final full = byId[c.id];
if (full != null) return ParentChildSummary.fromEnfant(full);
return c;
}).toList();
});
} catch (_) {}
}
Future<void> _openChild(ParentChildSummary child) async {
try {
final enfant = await UserService.getEnfant(child.id);
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AdminChildDetailModal(
enfant: enfant,
onSaved: _refreshChildrenDetails,
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
Future<void> _detachChild(ParentChildSummary child) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Détacher l\'enfant'),
content: Text(
'Retirer ${child.fullName} de la fiche de cette assistante ?\n'
'(L\'enfant ne sera pas supprimé.)',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ValidationModalTheme.primaryElevatedStyle,
child: const Text('Détacher'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() {
_children = _children.where((c) => c.id != child.id).toList();
_syncPlacesAfterChildrenChange();
_dirty = true;
});
}
Future<void> _attachChild() async {
List<EnfantAdminModel> all;
try {
all = await UserService.getEnfants();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
return;
}
final linkedIds = _children.map((c) => c.id).toSet();
final candidates = all.where((e) => !linkedIds.contains(e.id)).toList();
if (candidates.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Aucun enfant disponible à rattacher')),
);
return;
}
if (!mounted) return;
final selected = await showDialog<EnfantAdminModel>(
context: context,
builder: (ctx) => SimpleDialog(
title: const Text('Rattacher un enfant'),
children: candidates
.map(
(e) => SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, e),
child: Text(e.fullName),
),
)
.toList(),
),
);
if (selected == null || !mounted) return;
setState(() {
_children = [
..._children,
ParentChildSummary.fromEnfant(selected),
];
_syncPlacesAfterChildrenChange();
_dirty = true;
});
}
Widget _identityTab() {
return SingleChildScrollView(
child: IdentityBlock.editable(
title: 'Identité et coordonnées',
nomController: _nomCtrl,
prenomController: _prenomCtrl,
telephoneController: _telCtrl,
emailController: _emailCtrl,
adresseController: _adresseCtrl,
codePostalController: _cpCtrl,
villeController: _villeCtrl,
),
);
}
Widget _proFieldsGrid() {
return ValidationFormGrid(
title: 'Dossier professionnel',
rowLayout: _photoProRowLayout,
fields: [
ValidationLabeledField(
label: 'NIR',
field: ValidationEditableField(
controller: _nirCtrl,
hintText: '15 chiffres',
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[\d\s]')),
],
),
),
ValidationLabeledField(
label: 'Date de naissance',
field: ValidationEditableField(
controller: _dateNaissanceCtrl,
hintText: 'jj/mm/aaaa',
),
),
ValidationLabeledField(
label: 'Ville de naissance',
field: ValidationEditableField(
controller: _lieuNaissanceVilleCtrl,
),
),
ValidationLabeledField(
label: 'Pays de naissance',
field: ValidationEditableField(
controller: _lieuNaissancePaysCtrl,
),
),
ValidationLabeledField(
label: 'N° Agrément',
field: ValidationEditableField(
controller: _agrementCtrl,
),
),
ValidationLabeledField(
label: 'Date d\'agrément',
field: ValidationEditableField(
controller: _dateAgrementCtrl,
hintText: 'jj/mm/aaaa',
),
),
],
);
}
Widget _proTab() {
return LayoutBuilder(
builder: (context, c) {
final maxRowW = c.maxWidth;
final maxRowH = c.maxHeight;
final bodyH = maxRowH;
final idealPhotoW = bodyH * AdminAmPhotoFrame.idPhotoAspectRatio + 16;
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
.clamp(0.0, double.infinity);
var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 220.0);
if (photoW > maxPhotoW) photoW = maxPhotoW;
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: photoW,
child: AdminAmPhotoFrame(
photoUrl: widget.assistante.user.photoUrl,
),
),
const SizedBox(width: _photoProGap),
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_proFieldsGrid(),
SwitchListTile(
contentPadding: EdgeInsets.zero,
dense: true,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
title: const Text(
'Disponible pour accueillir',
style: TextStyle(fontSize: 13),
),
value: _disponible,
onChanged: (v) => setState(() {
_disponible = v;
_dirty = true;
}),
),
],
),
),
),
],
);
},
);
}
Widget _childrenCapacityFields() {
final inconsistent = _placesInconsistent();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ValidationEditableSection(
rowLayout: const [2],
fields: [
ValidationLabeledField(
label: 'Capacité max (enfants)',
field: ValidationEditableField(
controller: _capaciteCtrl,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
),
ValidationLabeledField(
label: 'Places disponibles',
field: ValidationReadOnlyField(
value: _placesDisplayValue(),
error: inconsistent,
),
),
],
),
if (inconsistent) ...[
const SizedBox(height: 6),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 6,
runSpacing: 4,
children: [
Text(
_placesInconsistencyMessage()!,
style: TextStyle(
fontSize: 12,
color: Colors.red.shade700,
height: 1.3,
),
),
OutlinedButton(
onPressed: _saving ? null : _applyPlacesCorrection,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red.shade800,
disabledForegroundColor: Colors.red.shade300,
side: BorderSide(color: Colors.red.shade400),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
minimumSize: const Size(0, 28),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
textStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
child: const Text('Mettre à jour'),
),
],
),
],
],
);
}
Widget _childrenTab() {
final capacity = (_capaciteMax() ?? 4).clamp(1, 4);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_childrenCapacityFields(),
const SizedBox(height: 10),
Text(
'Enfants accueillis : ${_children.length}',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 8),
AdminAmChildrenCapacityGrid(
children: _children,
capacity: capacity,
onOpen: _openChild,
onDetach: _detachChild,
),
],
);
}
Widget _buildFooter() {
final isChildrenTab = _tabCtrl.index == 2;
return Row(
children: [
TextButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
const Spacer(),
if (isChildrenTab)
TextButton.icon(
onPressed: _attachChild,
icon: const Icon(Icons.link, size: 18),
label: const Text('Rattacher un enfant'),
),
if (isChildrenTab) const SizedBox(width: 12),
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: !_dirty || _saving ? null : _save,
child: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(_dirty ? 'Sauvegarder' : 'Aucune modification'),
),
],
);
}
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _modalWidth),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 4, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_headerTitle(),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
if (_headerSubtitle() != null) ...[
const SizedBox(height: 4),
Text(
_headerSubtitle()!,
style: const TextStyle(
fontSize: 13,
color: Colors.black54,
),
),
],
],
),
),
const SizedBox(width: 12),
SizedBox(
width: 160,
child: AdminStatusCapsule(
statut: _statut,
onChanged: (v) => setState(() {
_statut = v;
_dirty = true;
}),
),
),
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 40,
minHeight: 40,
),
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
tooltip: 'Fermer',
),
],
),
),
TabBar(
controller: _tabCtrl,
onTap: (_) => setState(() {}),
labelColor: ValidationModalTheme.primaryActionBackground,
unselectedLabelColor: Colors.black54,
indicatorColor: ValidationModalTheme.primaryActionBackground,
tabs: const [
Tab(text: 'Identité'),
Tab(text: 'Fiche professionnelle'),
Tab(text: 'Enfants accueillis'),
],
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
child: SizedBox(
height: _tabViewHeight(_tabCtrl.index),
child: TabBarView(
controller: _tabCtrl,
children: [
_identityTab(),
_proTab(),
_childrenTab(),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
child: _buildFooter(),
),
],
),
),
);
}
}

View File

@ -1,115 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
/// Cadre photo identité AM (35×45 mm) même logique que [ValidationAmWizard].
class AdminAmPhotoFrame extends StatelessWidget {
final String? photoUrl;
static const double idPhotoAspectRatio = 35 / 45;
const AdminAmPhotoFrame({super.key, this.photoUrl});
/// Largeur colonne photo pour remplir [height] (cadre inclus).
static double columnWidthForHeight(double height) {
const frame = 16.0;
final innerH = (height - frame).clamp(0.0, double.infinity);
return innerH * idPhotoAspectRatio + frame;
}
@override
Widget build(BuildContext context) {
final fullUrl = ApiConfig.absoluteMediaUrl(photoUrl);
return LayoutBuilder(
builder: (context, c) {
const uniformFrame = 8.0;
final maxPhotoW =
(c.maxWidth - 2 * uniformFrame).clamp(0.0, double.infinity);
final maxPhotoH =
(c.maxHeight - 2 * uniformFrame).clamp(0.0, double.infinity);
const ar = idPhotoAspectRatio;
double ph = maxPhotoH;
double pw = ph * ar;
if (pw > maxPhotoW) {
pw = maxPhotoW;
ph = pw / ar;
}
// Cadre gris = taille photo + padding uniforme ; centré dans la colonne
// (évite le vide blanc en bas quand le conteneur parent est plus haut).
return Align(
alignment: Alignment.topCenter,
child: Container(
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(uniformFrame),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: pw,
height: ph,
child: _photoContent(fullUrl, pw, ph),
),
),
),
),
);
},
);
}
Widget _photoContent(String fullUrl, double pw, double ph) {
if (fullUrl.isEmpty) {
return ColoredBox(
color: Colors.grey.shade200,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.person_off_outlined, size: 36, color: Colors.grey.shade400),
const SizedBox(height: 6),
Text(
'Aucune photo',
style: TextStyle(color: Colors.grey.shade600, fontSize: 11),
),
],
),
);
}
return AuthNetworkImage(
url: fullUrl,
width: pw,
height: ph,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
loadingBuilder: (_, child, progress) {
if (progress == null) return child;
return ColoredBox(
color: Colors.grey.shade200,
child: Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
value: progress.expectedTotalBytes != null
? progress.cumulativeBytesLoaded /
(progress.expectedTotalBytes!)
: null,
),
),
),
);
},
errorBuilder: (_, __, ___) => ColoredBox(
color: Colors.grey.shade200,
child: Icon(Icons.broken_image_outlined, size: 36, color: Colors.grey.shade400),
),
);
}
}

View File

@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
/// Fiche enfant consultation / édition (ticket #138).
class AdminChildDetailModal extends StatefulWidget {
@ -30,6 +29,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
bool _dirty = false;
bool _saving = false;
static const _statuses = ['a_naitre', 'actif', 'scolarise'];
static const _genders = ['H', 'F', 'Autre'];
static const _labelStyle = TextStyle(
@ -46,10 +46,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
_nomCtrl = TextEditingController(text: e.lastName ?? '');
_birthCtrl = TextEditingController(text: e.birthDate ?? '');
_dueCtrl = TextEditingController(text: e.dueDate ?? '');
_status = normalizeEnfantStatus(e.status);
if (!enfantStatusValues.contains(_status)) {
_status = 'sans_garde';
}
_status = _statuses.contains(e.status) ? e.status : 'actif';
_gender = _normalizeGender(e.gender);
_consentPhoto = e.consentPhoto;
_isMultiple = e.isMultiple;
@ -216,13 +213,8 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
child: _labeledDropdown(
'Statut',
_status,
enfantStatusValues
.map(
(s) => MapEntry(
s,
enfantStatusLabel(s, gender: _gender),
),
)
_statuses
.map((s) => MapEntry(s, _statusLabel(s)))
.toList(),
(v) => setState(() {
_status = v;
@ -396,6 +388,19 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
);
}
String _statusLabel(String status) {
switch (status) {
case 'a_naitre':
return 'À naître';
case 'actif':
return 'Actif';
case 'scolarise':
return 'Scolarisé';
default:
return status;
}
}
String _genderLabel(String gender) {
switch (gender) {
case 'H':

View File

@ -1,84 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart';
/// Liste scrollable d'enfants rattachés (fiche parent / fiche AM).
class AdminChildrenAffiliationPanel extends StatelessWidget {
final List<ParentChildSummary> children;
final ScrollController scrollController;
final void Function(ParentChildSummary child) onOpen;
final void Function(ParentChildSummary child) onDetach;
final String emptyMessage;
final double? height;
static const double _itemHeight = 58;
static const double defaultViewportHeight = _itemHeight * 2.5 + 8;
const AdminChildrenAffiliationPanel({
super.key,
required this.children,
required this.scrollController,
required this.onOpen,
required this.onDetach,
this.emptyMessage = 'Aucun enfant rattaché',
this.height,
});
@override
Widget build(BuildContext context) {
if (height != null) {
return _panel(height!);
}
return LayoutBuilder(
builder: (context, constraints) {
final h = constraints.maxHeight.isFinite && constraints.maxHeight > 0
? constraints.maxHeight
: defaultViewportHeight;
return _panel(h);
},
);
}
Widget _panel(double panelHeight) {
return Container(
height: panelHeight,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
clipBehavior: Clip.antiAlias,
child: children.isEmpty
? Center(
child: Text(
emptyMessage,
style: const TextStyle(fontSize: 14, color: Colors.black54),
),
)
: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
itemExtent: _itemHeight,
itemCount: children.length,
itemBuilder: (_, i) {
final c = children[i];
return AdminEnfantUserCard.fromSummary(
c,
actions: [
IconButton(
icon: const Icon(Icons.visibility_outlined),
tooltip: 'Voir / modifier',
onPressed: () => onOpen(c),
),
IconButton(
tooltip: 'Détacher',
icon: Icon(Icons.link_off, color: Colors.orange.shade800),
onPressed: () => onDetach(c),
),
],
);
},
),
);
}
}

View File

@ -2,26 +2,36 @@ import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
String enfantStatusLabel(String status) {
switch (status) {
case 'a_naitre':
return 'À naître';
case 'actif':
return 'Actif';
case 'scolarise':
return 'Scolarisé';
default:
return status;
}
}
List<String> enfantAdminSubtitleLines({
required String status,
String? birthDate,
String? dueDate,
String? gender,
List<String> extra = const [],
}) {
final lines = <String>[];
final age = formatChildAgeLabel(
birthDate: birthDate,
dueDate: dueDate,
status: normalizeEnfantStatus(status),
status: status,
);
if (age.isNotEmpty) lines.add(age);
final normalized = normalizeEnfantStatus(status);
if (normalized.isNotEmpty) {
lines.add('Statut : ${enfantStatusLabel(normalized, gender: gender)}');
if (status.isNotEmpty) {
lines.add('Statut : ${enfantStatusLabel(status)}');
}
lines.addAll(extra);
return lines;
@ -57,7 +67,6 @@ class AdminEnfantUserCard extends StatelessWidget {
status: enfant.status,
birthDate: enfant.birthDate,
dueDate: enfant.dueDate,
gender: enfant.gender,
extra: [
if (parents.isNotEmpty) 'Responsables : $parents',
...extraSubtitleLines,

View File

@ -3,11 +3,9 @@ import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
@ -38,12 +36,17 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
late String _statut;
late List<ParentChildSummary> _children;
AppUser? _coParent;
late final ScrollController _childrenScrollCtrl;
bool _saving = false;
bool _dirty = false;
static const _statuts = ['actif', 'en_attente', 'suspendu', 'refuse'];
static const double _modalWidth = 930;
/// Hauteur d'une ligne enfant (carte + marge) — viewport = 2,5 lignes visibles.
static const double _childListItemHeight = 58;
static const double _childrenListViewportHeight =
_childListItemHeight * 2.5 + 8;
@override
void initState() {
@ -59,7 +62,6 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
_villeCtrl = TextEditingController(text: u.ville ?? '');
_cpCtrl = TextEditingController(text: u.codePostal ?? '');
_statut = u.statut ?? 'en_attente';
_coParent = widget.parent.coParent;
_children = List.of(widget.parent.children);
_childrenScrollCtrl = ScrollController();
for (final c in [
@ -73,15 +75,9 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
]) {
c.addListener(_markDirty);
}
_nomCtrl.addListener(_onNameFieldChanged);
_prenomCtrl.addListener(_onNameFieldChanged);
_reloadChildren();
}
void _onNameFieldChanged() {
setState(() {});
}
@override
void dispose() {
for (final c in [
@ -103,20 +99,19 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
if (!_dirty) setState(() => _dirty = true);
}
String _headerTitle() {
final fn = _prenomCtrl.text.trim();
final ln = _nomCtrl.text.trim();
if (fn.isEmpty && ln.isEmpty) {
final fallback = widget.parent.user.fullName.trim();
return fallback.isNotEmpty ? fallback : 'Parent';
String _displayStatus(String status) {
switch (status) {
case 'actif':
return 'Actif';
case 'en_attente':
return 'En attente';
case 'suspendu':
return 'Suspendu';
case 'refuse':
return 'Refusé';
default:
return status;
}
return '$fn $ln'.trim();
}
String? _coParentSubtitle() {
final name = _coParent?.fullName.trim() ?? '';
if (name.isEmpty) return null;
return 'Co-parent : $name';
}
Future<void> _save() async {
@ -207,10 +202,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
}).toList();
if (!mounted) return;
setState(() {
_children = kids;
_coParent = refreshed.coParent;
});
setState(() => _children = kids);
} catch (_) {}
}
@ -230,7 +222,9 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ValidationModalTheme.primaryElevatedStyle,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade700,
),
child: const Text('Détacher'),
),
],
@ -315,12 +309,39 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
}
}
Widget _childrenPanel() {
return AdminChildrenAffiliationPanel(
children: _children,
scrollController: _childrenScrollCtrl,
onOpen: _openChild,
onDetach: _detachChild,
Widget _statusDropdown() {
return Container(
height: 34,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: Colors.black26),
),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _statuts.contains(_statut) ? _statut : _statuts.first,
isExpanded: true,
isDense: true,
style: const TextStyle(fontSize: 13, color: Colors.black87),
icon: const Icon(Icons.arrow_drop_down, size: 20),
items: _statuts
.map(
(s) => DropdownMenuItem(
value: s,
child: Text(_displayStatus(s)),
),
)
.toList(),
onChanged: (v) {
if (v == null) return;
setState(() {
_statut = v;
_dirty = true;
});
},
),
),
);
}
@ -336,6 +357,49 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
);
}
Widget _childrenPanel() {
return Container(
height: _childrenListViewportHeight,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
clipBehavior: Clip.antiAlias,
child: _children.isEmpty
? const Center(
child: Text(
'Aucun enfant rattaché',
style: TextStyle(fontSize: 14, color: Colors.black54),
),
)
: ListView.builder(
controller: _childrenScrollCtrl,
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
itemExtent: _childListItemHeight,
itemCount: _children.length,
itemBuilder: (_, i) {
final c = _children[i];
return AdminEnfantUserCard.fromSummary(
c,
actions: [
IconButton(
icon: const Icon(Icons.visibility_outlined),
tooltip: 'Voir / modifier',
onPressed: () => _openChild(c),
),
IconButton(
tooltip: 'Détacher',
icon: Icon(Icons.link_off, color: Colors.orange.shade800),
onPressed: () => _detachChild(c),
),
],
);
},
),
);
}
Widget _buildFooter() {
return Row(
children: [
@ -373,72 +437,41 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _modalWidth),
constraints: BoxConstraints(
maxWidth: _modalWidth,
maxHeight: MediaQuery.of(context).size.height * 0.85,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 4, 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_headerTitle(),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
if (_coParentSubtitle() != null) ...[
const SizedBox(height: 4),
Text(
_coParentSubtitle()!,
style: const TextStyle(
fontSize: 13,
color: Colors.black54,
),
),
],
],
),
Row(
children: [
const Padding(
padding: EdgeInsets.fromLTRB(18, 16, 0, 10),
child: Text(
'Fiche parent',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
),
const SizedBox(width: 12),
SizedBox(
width: 160,
child: AdminStatusCapsule(
statut: _statut,
onChanged: (v) => setState(() {
_statut = v;
_dirty = true;
}),
),
),
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 40,
minHeight: 40,
),
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
tooltip: 'Fermer',
),
],
),
),
const Spacer(),
SizedBox(width: 160, child: _statusDropdown()),
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
tooltip: 'Fermer',
),
],
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_identityFields(),
const SizedBox(height: 10),
const SizedBox(height: 12),
Text(
'Nombre d\'enfants : ${_children.length}',
style: const TextStyle(
@ -447,9 +480,9 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
color: Colors.black87,
),
),
const SizedBox(height: 6),
const SizedBox(height: 8),
_childrenPanel(),
const SizedBox(height: 12),
const SizedBox(height: 16),
_buildFooter(),
],
),

View File

@ -1,66 +0,0 @@
import 'package:flutter/material.dart';
/// Gélule de sélection du statut utilisateur (fiches admin parent / AM).
class AdminStatusCapsule extends StatelessWidget {
final String statut;
final ValueChanged<String>? onChanged;
static const statuts = ['actif', 'en_attente', 'suspendu', 'refuse'];
const AdminStatusCapsule({
super.key,
required this.statut,
this.onChanged,
});
static String displayStatus(String status) {
switch (status) {
case 'actif':
return 'Actif';
case 'en_attente':
return 'En attente';
case 'suspendu':
return 'Suspendu';
case 'refuse':
return 'Refusé';
default:
return status;
}
}
@override
Widget build(BuildContext context) {
final value = statuts.contains(statut) ? statut : statuts.first;
return Container(
height: 34,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: Colors.black26),
),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: value,
isExpanded: true,
isDense: true,
style: const TextStyle(fontSize: 13, color: Colors.black87),
icon: const Icon(Icons.arrow_drop_down, size: 20),
items: statuts
.map(
(s) => DropdownMenuItem(
value: s,
child: Text(displayStatus(s)),
),
)
.toList(),
onChanged: onChanged == null
? null
: (v) {
if (v != null) onChanged!(v);
},
),
),
);
}
}

View File

@ -12,7 +12,6 @@ class AdminUserCard extends StatefulWidget {
final Color? backgroundColor;
final Color? titleColor;
final Color? infoColor;
final String? vigilanceTooltip;
const AdminUserCard({
super.key,
@ -25,7 +24,6 @@ class AdminUserCard extends StatefulWidget {
this.backgroundColor,
this.titleColor,
this.infoColor,
this.vigilanceTooltip,
});
@override
@ -67,17 +65,6 @@ class _AdminUserCardState extends State<AdminUserCard> {
children: [
_buildAvatar(avatarUrl),
const SizedBox(width: 10),
if (widget.vigilanceTooltip != null) ...[
Tooltip(
message: widget.vigilanceTooltip!,
child: Icon(
Icons.error_outline,
size: 20,
color: Colors.orange.shade800,
),
),
const SizedBox(width: 6),
],
Expanded(
child: Row(
children: [

View File

@ -48,7 +48,6 @@ class ValidationFormGrid extends StatelessWidget {
final List<ValidationLabeledField> fields;
final List<int>? rowLayout;
final Map<int, List<int>>? rowFlex;
final bool compact;
const ValidationFormGrid({
super.key,
@ -56,7 +55,6 @@ class ValidationFormGrid extends StatelessWidget {
required this.fields,
this.rowLayout,
this.rowFlex,
this.compact = false,
});
@override
@ -74,17 +72,17 @@ class ValidationFormGrid extends StatelessWidget {
rowIndex++;
if (count == 1) {
rows.add(Padding(
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
padding: const EdgeInsets.only(bottom: 12),
child: rowFields.first,
));
} else {
rows.add(Padding(
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (int i = 0; i < rowFields.length; i++) ...[
if (i > 0) SizedBox(width: compact ? 12 : 16),
if (i > 0) const SizedBox(width: 16),
Expanded(
flex: (flexForRow != null && i < flexForRow.length)
? flexForRow[i]
@ -105,13 +103,13 @@ class ValidationFormGrid extends StatelessWidget {
if (showTitle) ...[
Text(
title!.trim(),
style: TextStyle(
fontSize: compact ? 15 : 16,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
SizedBox(height: compact ? 8 : 12),
const SizedBox(height: 12),
],
...rows,
],
@ -123,16 +121,13 @@ class ValidationFormGrid extends StatelessWidget {
class ValidationFieldDecoration {
ValidationFieldDecoration._();
static InputDecoration input({String? hint, bool compact = false}) {
static InputDecoration input({String? hint}) {
return InputDecoration(
isDense: true,
filled: true,
fillColor: Colors.grey.shade50,
hintText: hint,
contentPadding: EdgeInsets.symmetric(
horizontal: compact ? 10 : 12,
vertical: compact ? 7 : 10,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: Colors.grey.shade300),
@ -148,30 +143,11 @@ class ValidationFieldDecoration {
);
}
static InputDecoration readOnly({bool error = false, bool compact = false}) {
final borderColor = error ? Colors.red.shade400 : Colors.grey.shade300;
final fillColor = error ? Colors.red.shade50 : Colors.grey.shade50;
return input(compact: compact).copyWith(
filled: true,
fillColor: fillColor,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: borderColor),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: borderColor),
),
);
}
static BoxDecoration container({bool error = false}) {
static BoxDecoration container() {
return BoxDecoration(
color: error ? Colors.red.shade50 : Colors.grey.shade50,
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: error ? Colors.red.shade400 : Colors.grey.shade300,
),
border: Border.all(color: Colors.grey.shade300),
);
}
}
@ -215,7 +191,6 @@ class ValidationEditableField extends StatelessWidget {
final List<TextInputFormatter>? inputFormatters;
final String? hintText;
final int maxLines;
final bool compact;
const ValidationEditableField({
super.key,
@ -224,73 +199,17 @@ class ValidationEditableField extends StatelessWidget {
this.inputFormatters,
this.hintText,
this.maxLines = 1,
this.compact = false,
});
static const double _compactFieldHeight = 34;
static BoxDecoration _compactDecoration({bool error = false}) {
return BoxDecoration(
color: error ? Colors.red.shade50 : Colors.grey.shade50,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: error ? Colors.red.shade400 : Colors.grey.shade300,
),
);
}
static InputDecoration _compactInputDecoration({String? hint}) {
return InputDecoration(
isDense: true,
filled: false,
hintText: hint,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
);
}
@override
Widget build(BuildContext context) {
if (maxLines > 1) {
return TextField(
controller: controller,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
maxLines: maxLines,
style: const TextStyle(color: Colors.black87, fontSize: 14),
decoration: ValidationFieldDecoration.input(hint: hintText),
);
}
if (!compact) {
return TextField(
controller: controller,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
maxLines: 1,
style: const TextStyle(color: Colors.black87, fontSize: 14),
decoration: ValidationFieldDecoration.input(hint: hintText),
);
}
return SizedBox(
height: _compactFieldHeight,
child: DecoratedBox(
decoration: _compactDecoration(),
child: TextField(
controller: controller,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
maxLines: 1,
textAlignVertical: TextAlignVertical.center,
style: const TextStyle(
color: Colors.black87,
fontSize: 13,
height: 1.0,
),
decoration: _compactInputDecoration(hint: hintText),
),
),
return TextField(
controller: controller,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
maxLines: maxLines,
style: const TextStyle(color: Colors.black87, fontSize: 14),
decoration: ValidationFieldDecoration.input(hint: hintText),
);
}
}
@ -300,14 +219,12 @@ class ValidationEditableSection extends StatelessWidget {
final List<ValidationLabeledField> fields;
final List<int>? rowLayout;
final Map<int, List<int>>? rowFlex;
final bool compact;
const ValidationEditableSection({
super.key,
required this.fields,
this.rowLayout,
this.rowFlex,
this.compact = false,
});
@override
@ -315,90 +232,32 @@ class ValidationEditableSection extends StatelessWidget {
return ValidationFormGrid(
rowLayout: rowLayout,
rowFlex: rowFlex,
compact: compact,
fields: fields,
);
}
}
/// Champ texte en lecture seule, même coque [TextField] que [ValidationEditableField].
class ValidationReadOnlyField extends StatefulWidget {
/// Champ texte en lecture seule, style formulaire (fond gris léger, bordure).
class ValidationReadOnlyField extends StatelessWidget {
final String value;
final int? maxLines;
final bool compact;
final bool error;
const ValidationReadOnlyField({
super.key,
required this.value,
this.maxLines = 1,
this.compact = false,
this.error = false,
});
@override
State<ValidationReadOnlyField> createState() => _ValidationReadOnlyFieldState();
}
class _ValidationReadOnlyFieldState extends State<ValidationReadOnlyField> {
late final TextEditingController _controller;
static const double _compactFieldHeight = 34;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.value);
}
@override
void didUpdateWidget(ValidationReadOnlyField oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.value != widget.value) {
_controller.text = widget.value;
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!widget.compact && widget.maxLines == 1) {
return TextField(
controller: _controller,
readOnly: true,
enableInteractiveSelection: false,
style: TextStyle(
color: widget.error ? Colors.red.shade800 : Colors.black87,
fontSize: 14,
fontWeight: widget.error ? FontWeight.w600 : null,
),
decoration: ValidationFieldDecoration.readOnly(error: widget.error),
);
}
return Container(
width: double.infinity,
height: widget.compact && widget.maxLines == 1 ? _compactFieldHeight : null,
alignment: widget.compact ? Alignment.centerLeft : null,
padding: EdgeInsets.symmetric(
horizontal: widget.compact ? 10 : 12,
vertical: widget.compact ? 7 : 10,
),
decoration: ValidationFieldDecoration.container(error: widget.error),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: ValidationFieldDecoration.container(),
child: Text(
widget.value,
style: TextStyle(
color: widget.error ? Colors.red.shade800 : Colors.black87,
fontSize: widget.compact ? 13 : 14,
height: widget.compact ? 1.0 : null,
fontWeight: widget.error ? FontWeight.w600 : null,
),
maxLines: widget.maxLines,
value,
style: const TextStyle(color: Colors.black87, fontSize: 14),
maxLines: maxLines,
overflow: TextOverflow.ellipsis,
),
);

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
@ -68,9 +67,8 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
final query = widget.searchQuery.toLowerCase();
final filtered = _enfants.where((e) {
final matchesName = e.fullName.toLowerCase().contains(query);
final matchesStatus = widget.statusFilter == null ||
normalizeEnfantStatus(e.status) ==
normalizeEnfantStatus(widget.statusFilter);
final matchesStatus =
widget.statusFilter == null || e.status == widget.statusFilter;
return matchesName && matchesStatus;
}).toList();

View File

@ -205,17 +205,10 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
),
),
DropdownMenuItem<String?>(
value: 'sans_garde',
value: 'actif',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Sans garde', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(
value: 'garde',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('En garde', style: TextStyle(fontSize: 12)),
child: Text('Actif', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(

View File

@ -3,7 +3,6 @@ import 'package:flutter/gestures.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
@ -398,9 +397,20 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
);
}
/// Statut dans la colonne 2/3 (scolarisé·e, à naître, sans garde, en garde).
/// « Scolarisé » / « Scolarisée » selon le genre enfant (`F` / sinon masculin par défaut).
static String _scolariseAccordeAuGenre(String? gender) {
final g = (gender ?? '').trim().toUpperCase();
if (g == 'F') return 'Scolarisée';
return 'Scolarisé';
}
/// Statut dans la colonne 2/3 uniquement (pas de [ValidationReadOnlyField]) : scolarisé·e ou « À naître ».
/// `actif` : pas de ligne statut.
String? _enfantColumnStatusLabel(EnfantDossier e) {
return enfantColumnStatusLabel(status: e.status, gender: e.gender);
final s = (e.status ?? '').trim().toLowerCase();
if (s == 'a_naitre') return 'À naître';
if (s == 'scolarise') return _scolariseAccordeAuGenre(e.gender);
return null;
}
/// Nom ; date de naissance et genre sur une ligne (prénom au-dessus, pleine largeur).

View File

@ -13,7 +13,6 @@ class AuthNetworkImage extends StatefulWidget {
this.width,
this.height,
this.fit = BoxFit.cover,
this.alignment = Alignment.center,
this.loadingBuilder,
this.errorBuilder,
});
@ -22,7 +21,6 @@ class AuthNetworkImage extends StatefulWidget {
final double? width;
final double? height;
final BoxFit fit;
final AlignmentGeometry alignment;
final ImageLoadingBuilder? loadingBuilder;
final ImageErrorWidgetBuilder? errorBuilder;
@ -77,7 +75,6 @@ class _AuthNetworkImageState extends State<AuthNetworkImage> {
width: widget.width,
height: widget.height,
fit: widget.fit,
alignment: widget.alignment,
loadingBuilder: widget.loadingBuilder,
errorBuilder: err,
);
@ -108,7 +105,6 @@ class _AuthNetworkImageState extends State<AuthNetworkImage> {
width: widget.width,
height: widget.height,
fit: widget.fit,
alignment: widget.alignment,
headers: headers,
loadingBuilder: widget.loadingBuilder,
errorBuilder: err,