Compare commits

..
Author SHA1 Message Date
jmartin 86ceb558a4 sync(develop): #132 création enfant staff 2026-07-17 18:29:45 +02:00
jmartinandCursor cc3b52db16 feat(#132): création enfant staff (onglet Enfants) — front + back.
POST /enfants pour gestionnaire/admin avec parent_user_id, liens foyer,
multipart photo optionnel ; UI modale création + sélection famille.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 18:29:43 +02:00
jmartinandCursor 3c7f4f6e16 fix(#151): autoriser GET /relais pour gestionnaire + robustesse combo
Permet au gestionnaire de peupler la liste Relais (création/édition).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:54:17 +02:00
jmartinandCursor dcd407a3da feat(#146–#149): sélection enfant/AM, capacité max et case libre.
Modales de rattachement partagées, filtre Libre/Sans garde, recalcul des places à l'attach, désactivation si capacité pleine, clic sur case libre.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:25:42 +02:00
jmartinandCursor fde63f8e72 feat(#145): lien co-parent cliquable dans la fiche parent.
Squash merge develop → master.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 14:56:45 +02:00
jmartinandCursor 1f8f1b9507 feat(#138,#143,#144): fiche enfant admin, consentement photo et fix gestionnaire.
Squash merge develop → master.
- #138 : modale enfant paysage, zone AM/scolarisation, liens responsables
- #144 : persistance consent_photo à l'inscription enfant
- #143 : masquer Supprimer sur la propre fiche gestionnaire

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 13:21:43 +02:00
132 changed files with 3020 additions and 11371 deletions
-3
View File
@@ -89,9 +89,6 @@
"transform": { "transform": {
"^.+\\.(t|j)s$": "ts-jest" "^.+\\.(t|j)s$": "ts-jest"
}, },
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/$1"
},
"collectCoverageFrom": [ "collectCoverageFrom": [
"**/*.(t|j)s" "**/*.(t|j)s"
], ],
+1
View File
@@ -33,6 +33,7 @@ model Child {
dateOfBirth DateTime dateOfBirth DateTime
photoUrl String? photoUrl String?
photoConsent Boolean @default(false) photoConsent Boolean @default(false)
isMultiple Boolean @default(false)
isUnborn Boolean @default(false) isUnborn Boolean @default(false)
parentId String parentId String
parent Parent @relation(fields: [parentId], references: [id]) parent Parent @relation(fields: [parentId], references: [id])
-2
View File
@@ -18,7 +18,6 @@ import { AppConfigModule } from './modules/config/config.module';
import { DocumentsLegauxModule } from './modules/documents-legaux'; import { DocumentsLegauxModule } from './modules/documents-legaux';
import { RelaisModule } from './routes/relais/relais.module'; import { RelaisModule } from './routes/relais/relais.module';
import { DossiersModule } from './routes/dossiers/dossiers.module'; import { DossiersModule } from './routes/dossiers/dossiers.module';
import { SuppressionsModule } from './routes/suppressions/suppressions.module';
@Module({ @Module({
imports: [ imports: [
@@ -58,7 +57,6 @@ import { SuppressionsModule } from './routes/suppressions/suppressions.module';
DocumentsLegauxModule, DocumentsLegauxModule,
RelaisModule, RelaisModule,
DossiersModule, DossiersModule,
SuppressionsModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [
+3
View File
@@ -63,6 +63,9 @@ export class Children {
@Column({ type: 'timestamptz', nullable: true, name: 'date_consentement_photo' }) @Column({ type: 'timestamptz', nullable: true, name: 'date_consentement_photo' })
consent_photo_at?: Date; consent_photo_at?: Date;
@Column({ default: false, name: 'est_multiple', type: 'boolean' })
is_multiple: boolean;
// Lien via table de jointure enfants_parents // Lien via table de jointure enfants_parents
@OneToMany(() => ParentsChildren, pc => pc.child) @OneToMany(() => ParentsChildren, pc => pc.child)
parentLinks: ParentsChildren[]; parentLinks: ParentsChildren[];
@@ -1,69 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { AssistantesMaternellesController } from './assistantes_maternelles.controller'; import { AssistantesMaternellesController } from './assistantes_maternelles.controller';
import { AssistantesMaternellesService } from './assistantes_maternelles.service'; import { AssistantesMaternellesService } from './assistantes_maternelles.service';
import { AuthService } from '../auth/auth.service';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
describe('AssistantesMaternellesController', () => { describe('AssistantesMaternellesController', () => {
let controller: AssistantesMaternellesController; let controller: AssistantesMaternellesController;
const authServiceMock = {
createAmDossierStaff: jest.fn(),
};
const amServiceMock = {};
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [AssistantesMaternellesController], controllers: [AssistantesMaternellesController],
providers: [ providers: [AssistantesMaternellesService],
{ provide: AssistantesMaternellesService, useValue: amServiceMock }, }).compile();
{ provide: AuthService, useValue: authServiceMock },
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<AssistantesMaternellesController>(AssistantesMaternellesController); controller = module.get<AssistantesMaternellesController>(AssistantesMaternellesController);
jest.clearAllMocks();
}); });
it('should be defined', () => { it('should be defined', () => {
expect(controller).toBeDefined(); expect(controller).toBeDefined();
}); });
it('createDossier delegates to authService.createAmDossierStaff with CGU accepted', async () => {
authServiceMock.createAmDossierStaff.mockResolvedValue({
message: 'ok',
user_id: 'u1',
statut: 'actif',
numero_dossier: '2026-000001',
});
const body = {
email: 'am.staff@test.fr',
prenom: 'Marie',
nom: 'TEST',
telephone: '0689567890',
consentement_photo: false,
lieu_naissance_ville: 'Paris',
lieu_naissance_pays: 'France',
nir: '285017512345678',
numero_agrement: 'AGR-TEST-001',
capacite_accueil: 3,
places_disponibles: 2,
};
const res = await controller.createDossier(body as any);
expect(authServiceMock.createAmDossierStaff).toHaveBeenCalledWith(
expect.objectContaining({
email: body.email,
acceptation_cgu: true,
acceptation_privacy: true,
}),
);
expect(res.numero_dossier).toBe('2026-000001');
});
}); });
@@ -7,8 +7,6 @@ import {
Param, Param,
Delete, Delete,
UseGuards, UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common'; } from '@nestjs/common';
import { AssistantesMaternellesService } from './assistantes_maternelles.service'; import { AssistantesMaternellesService } from './assistantes_maternelles.service';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
@@ -18,49 +16,17 @@ import { RoleType, Users } from 'src/entities/users.entity';
import { CreateAssistanteDto } from '../user/dto/create_assistante.dto'; import { CreateAssistanteDto } from '../user/dto/create_assistante.dto';
import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto'; import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto';
import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto'; import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto';
import { StaffCreateAmDossierDto } from './dto/staff-create-am-dossier.dto';
import { StaffCreateAmDossierResponseDto } from './dto/staff-create-am-dossier-response.dto';
import { RolesGuard } from 'src/common/guards/roles.guard'; import { RolesGuard } from 'src/common/guards/roles.guard';
import { AuthGuard } from 'src/common/guards/auth.guard'; import { AuthGuard } from 'src/common/guards/auth.guard';
import { User } from 'src/common/decorators/user.decorator'; import { User } from 'src/common/decorators/user.decorator';
import { mapAmForApi, mapAmsForApi } from './assistantes_maternelles.mapper'; import { mapAmForApi, mapAmsForApi } from './assistantes_maternelles.mapper';
import { AuthService } from '../auth/auth.service';
import { RegisterAMCompletDto } from '../auth/dto/register-am-complet.dto';
@ApiTags("Assistantes Maternelles") @ApiTags("Assistantes Maternelles")
@ApiBearerAuth('access-token') @ApiBearerAuth('access-token')
@UseGuards(AuthGuard, RolesGuard) @UseGuards(AuthGuard, RolesGuard)
@Controller('assistantes-maternelles') @Controller('assistantes-maternelles')
export class AssistantesMaternellesController { export class AssistantesMaternellesController {
constructor( constructor(private readonly assistantesMaternellesService: AssistantesMaternellesService) { }
private readonly assistantesMaternellesService: AssistantesMaternellesService,
private readonly authService: AuthService,
) { }
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Post('dossier')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Créer un dossier AM complet (staff) — ticket #156',
description:
'Crée user + fiche AM avec statut actif, n° dossier, et envoie le-mail de création de mot de passe. ' +
'Ne pas utiliser POST /auth/register/am depuis le dashboard.',
})
@ApiBody({ type: StaffCreateAmDossierDto })
@ApiResponse({ status: 201, type: StaffCreateAmDossierResponseDto })
@ApiResponse({ status: 400, description: 'Validation métier / NIR' })
@ApiResponse({ status: 403, description: 'Rôle non autorisé' })
@ApiResponse({ status: 409, description: 'Email / NIR / agrément déjà pris' })
async createDossier(
@Body() dto: StaffCreateAmDossierDto,
): Promise<StaffCreateAmDossierResponseDto> {
const registerDto = {
...dto,
acceptation_cgu: true,
acceptation_privacy: true,
} as RegisterAMCompletDto;
return this.authService.createAmDossierStaff(registerDto);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Créer nounou' }) @ApiOperation({ summary: 'Créer nounou' })
@@ -1,23 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Réponse 201 POST /assistantes-maternelles/dossier (#156). */
export class StaffCreateAmDossierResponseDto {
@ApiProperty()
message: string;
@ApiProperty({ format: 'uuid' })
user_id: string;
@ApiProperty({
enum: StatutUtilisateurType,
example: StatutUtilisateurType.ACTIF,
})
statut: StatutUtilisateurType;
@ApiProperty({
example: '2026-000042',
description: 'Numéro de dossier attribué',
})
numero_dossier: string;
}
@@ -1,29 +0,0 @@
import { ApiPropertyOptional, OmitType } from '@nestjs/swagger';
import { IsBoolean, IsOptional } from 'class-validator';
import { RegisterAMCompletDto } from 'src/routes/auth/dto/register-am-complet.dto';
/**
* Création dossier AM par staff (#156).
* Mêmes champs que l'inscription publique, sans CGU/privacy obligatoires
* (acceptées côté serveur pour le compte du gestionnaire).
*/
export class StaffCreateAmDossierDto extends OmitType(RegisterAMCompletDto, [
'acceptation_cgu',
'acceptation_privacy',
] as const) {
@ApiPropertyOptional({
description: 'Ignoré côté staff (CGU acceptées serveur). Conservé pour compat éventuelle.',
default: true,
})
@IsOptional()
@IsBoolean()
acceptation_cgu?: boolean;
@ApiPropertyOptional({
description: 'Ignoré côté staff (privacy acceptée serveur).',
default: true,
})
@IsOptional()
@IsBoolean()
acceptation_privacy?: boolean;
}
+1 -1
View File
@@ -29,7 +29,7 @@ import { ParentsChildren } from 'src/entities/parents_children.entity';
ParentsChildren, ParentsChildren,
]), ]),
forwardRef(() => UserModule), forwardRef(() => UserModule),
forwardRef(() => ParentsModule), ParentsModule,
DossiersModule, DossiersModule,
AppConfigModule, AppConfigModule,
MailModule, MailModule,
+88 -399
View File
@@ -36,7 +36,6 @@ import { MailService } from 'src/modules/mail/mail.service';
import { ParentsService } from '../parents/parents.service'; import { ParentsService } from '../parents/parents.service';
import { DossiersService } from '../dossiers/dossiers.service'; import { DossiersService } from '../dossiers/dossiers.service';
import { DossierAmCompletDto } from '../dossiers/dto/dossier-am-complet.dto'; import { DossierAmCompletDto } from '../dossiers/dto/dossier-am-complet.dto';
import { StaffAddCoParentDto } from '../parents/dto/staff-add-co-parent.dto';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@@ -417,20 +416,11 @@ export class AuthService {
} }
/** /**
* Cœur partagé création dossier parent (#129). * Inscription Parent COMPLÈTE - Workflow CDC 6 étapes en 1 transaction
* - public : statut en_attente + mails pending * Gère : Parent 1 + Parent 2 (opt) + Enfants + Présentation + CGU
* - staff : statut actif + mails création MDP (pas de mail « dossier en attente »)
*/ */
async createParentDossier( async inscrireParentComplet(dto: RegisterParentCompletDto) {
dto: RegisterParentCompletDto, if (!dto.acceptation_cgu || !dto.acceptation_privacy) {
options: {
statut: StatutUtilisateurType;
sendPendingEmail: boolean;
sendPasswordSetupEmail: boolean;
requireCgu: boolean;
},
) {
if (options.requireCgu && (!dto.acceptation_cgu || !dto.acceptation_privacy)) {
throw new BadRequestException('L\'acceptation des CGU et de la politique de confidentialité est obligatoire'); throw new BadRequestException('L\'acceptation des CGU et de la politique de confidentialité est obligatoire');
} }
@@ -481,7 +471,7 @@ export class AuthService {
prenom: dto.prenom, prenom: dto.prenom,
nom: dto.nom, nom: dto.nom,
role: RoleType.PARENT, role: RoleType.PARENT,
statut: options.statut, statut: StatutUtilisateurType.EN_ATTENTE,
telephone: dto.telephone, telephone: dto.telephone,
adresse: dto.adresse, adresse: dto.adresse,
code_postal: dto.code_postal, code_postal: dto.code_postal,
@@ -506,7 +496,7 @@ export class AuthService {
prenom: dto.co_parent_prenom, prenom: dto.co_parent_prenom,
nom: dto.co_parent_nom, nom: dto.co_parent_nom,
role: RoleType.PARENT, role: RoleType.PARENT,
statut: options.statut, statut: StatutUtilisateurType.EN_ATTENTE,
telephone: dto.co_parent_telephone, telephone: dto.co_parent_telephone,
adresse: dto.co_parent_meme_adresse ? dto.adresse : dto.co_parent_adresse, adresse: dto.co_parent_meme_adresse ? dto.adresse : dto.co_parent_adresse,
code_postal: dto.co_parent_meme_adresse ? dto.code_postal : dto.co_parent_code_postal, code_postal: dto.co_parent_meme_adresse ? dto.code_postal : dto.co_parent_code_postal,
@@ -564,6 +554,7 @@ export class AuthService {
enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE; enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE;
enfant.consent_photo = !!enfantDto.consent_photo; enfant.consent_photo = !!enfantDto.consent_photo;
enfant.consent_photo_at = enfant.consent_photo ? new Date() : null!; enfant.consent_photo_at = enfant.consent_photo ? new Date() : null!;
enfant.is_multiple = enfantDto.grossesse_multiple || false;
const enfantEnregistre = await manager.save(Children, enfant); const enfantEnregistre = await manager.save(Children, enfant);
enfantsEnregistres.push(enfantEnregistre); enfantsEnregistres.push(enfantEnregistre);
@@ -621,287 +612,44 @@ export class AuthService {
const numeroDossier = resultat.parent1.numero_dossier ?? ''; const numeroDossier = resultat.parent1.numero_dossier ?? '';
if (options.sendPendingEmail) { try {
try { await this.mailService.sendRegistrationPendingEmail(
resultat.parent1.email,
resultat.parent1.prenom ?? '',
resultat.parent1.nom ?? '',
numeroDossier,
);
if (resultat.parent2) {
await this.mailService.sendRegistrationPendingEmail( await this.mailService.sendRegistrationPendingEmail(
resultat.parent1.email, resultat.parent2.email,
resultat.parent1.prenom ?? '', resultat.parent2.prenom ?? '',
resultat.parent1.nom ?? '', resultat.parent2.nom ?? '',
numeroDossier, numeroDossier,
); );
if (resultat.parent2) {
await this.mailService.sendRegistrationPendingEmail(
resultat.parent2.email,
resultat.parent2.prenom ?? '',
resultat.parent2.nom ?? '',
numeroDossier,
);
}
} catch (err) {
this.logger.error(
"[createParentDossier] Échec envoi email d'accusé de réception (inscription conservée)",
err instanceof Error ? err.stack : String(err),
);
} }
}
if (options.sendPasswordSetupEmail) {
try {
await this.mailService.sendValidatedAccountPasswordSetupEmail(
{
email: resultat.parent1.email,
prenom: resultat.parent1.prenom ?? '',
nom: resultat.parent1.nom ?? '',
token: resultat.tokenCreationMdp,
numeroDossier,
},
'parent',
);
if (resultat.parent2 && resultat.tokenCoParent) {
await this.mailService.sendValidatedAccountPasswordSetupEmail(
{
email: resultat.parent2.email,
prenom: resultat.parent2.prenom ?? '',
nom: resultat.parent2.nom ?? '',
token: resultat.tokenCoParent,
numeroDossier,
},
'parent',
);
}
} catch (err) {
this.logger.error(
'[createParentDossier] Échec envoi email création MDP (dossier conservé)',
err instanceof Error ? err.stack : String(err),
);
}
}
const message = options.sendPasswordSetupEmail
? 'Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.'
: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.';
return {
message,
parent_id: resultat.parent1.id,
parent_user_id: resultat.parent1.id,
co_parent_id: resultat.parent2?.id ?? null,
co_parent_user_id: resultat.parent2?.id ?? null,
enfants_ids: resultat.enfants.map(e => e.id),
enfant_ids: resultat.enfants.map(e => e.id),
statut: options.statut,
numero_dossier: numeroDossier,
};
}
/**
* Inscription Parent publique — CDC (statut en_attente + mail pending).
*/
async inscrireParentComplet(dto: RegisterParentCompletDto) {
return this.createParentDossier(dto, {
statut: StatutUtilisateurType.EN_ATTENTE,
sendPendingEmail: true,
sendPasswordSetupEmail: false,
requireCgu: true,
});
}
/**
* Création dossier parent par le staff (#129) — actif + mail création MDP.
*/
async createParentDossierStaff(dto: RegisterParentCompletDto) {
return this.createParentDossier(dto, {
statut: StatutUtilisateurType.ACTIF,
sendPendingEmail: false,
sendPasswordSetupEmail: true,
requireCgu: false,
});
}
/**
* Ajoute un co-parent à un foyer existant (mono-parent) — ticket #135.
* Compte actif + mail création MDP + liens Parents bidirectionnels + enfants du foyer.
*/
async addCoParentStaff(pivotUserId: string, dto: StaffAddCoParentDto) {
const pivotParent = await this.parentsRepo.findOne({
where: { user_id: pivotUserId },
relations: ['user', 'co_parent', 'parentChildren'],
});
if (!pivotParent?.user) {
throw new NotFoundException('Parent introuvable');
}
if (pivotParent.co_parent) {
throw new BadRequestException('Ce foyer a déjà un co-parent.');
}
const numeroDossier = pivotParent.numero_dossier?.trim() || pivotParent.user.numero_dossier?.trim();
if (!numeroDossier) {
throw new BadRequestException("Ce parent n'a pas de numéro de dossier.");
}
const sameDossierCount = await this.parentsRepo.count({
where: { numero_dossier: numeroDossier },
});
if (sameDossierCount >= 2) {
throw new BadRequestException('Ce dossier a déjà deux responsables.');
}
const email = dto.email.trim().toLowerCase();
if (pivotParent.user.email.trim().toLowerCase() === email) {
throw new BadRequestException(
"L'email du co-parent doit être différent de celui du parent principal.",
);
}
const emailExiste = await this.usersService.findByEmailOrNull(dto.email);
if (emailExiste) {
throw new ConflictException("L'email du co-parent est déjà utilisé");
}
const memeAdresse = dto.meme_adresse ?? true;
if (!memeAdresse) {
if (!dto.adresse?.trim() || !dto.ville?.trim() || !dto.code_postal?.trim()) {
throw new BadRequestException(
"Adresse, code postal et ville du co-parent sont requis si meme_adresse est faux.",
);
}
}
const joursExpirationToken = await this.appConfigService.get<number>(
'password_reset_token_expiry_days',
7,
);
const tokenCreationMdp = crypto.randomUUID();
const dateExpiration = new Date();
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken);
let coParent: Users;
try {
coParent = await this.usersRepo.manager.transaction(async (manager) => {
const pivotUser = await manager.findOne(Users, {
where: { id: pivotUserId },
});
if (!pivotUser) {
throw new NotFoundException('Parent introuvable');
}
const pivotEntite = await manager.findOne(Parents, {
where: { user_id: pivotUserId },
relations: ['parentChildren'],
});
if (!pivotEntite) {
throw new NotFoundException('Parent introuvable');
}
const coUser = manager.create(Users, {
email: dto.email.trim(),
prenom: dto.prenom,
nom: dto.nom,
role: RoleType.PARENT,
statut: StatutUtilisateurType.ACTIF,
telephone: dto.telephone,
adresse: memeAdresse ? pivotUser.adresse : dto.adresse,
code_postal: memeAdresse ? pivotUser.code_postal : dto.code_postal,
ville: memeAdresse ? pivotUser.ville : dto.ville,
token_creation_mdp: tokenCreationMdp,
token_creation_mdp_expire_le: dateExpiration,
numero_dossier: numeroDossier,
});
const coUserSaved = await manager.save(Users, coUser);
pivotEntite.co_parent = coUserSaved;
pivotEntite.numero_dossier = numeroDossier;
await manager.save(Parents, pivotEntite);
const coEntite = manager.create(Parents, {
user_id: coUserSaved.id,
numero_dossier: numeroDossier,
});
coEntite.user = coUserSaved;
coEntite.co_parent = pivotUser;
await manager.save(Parents, coEntite);
const enfantIds = (pivotEntite.parentChildren ?? [])
.map((pc) => pc.enfantId)
.filter(Boolean);
for (const enfantId of enfantIds) {
const existing = await manager.findOne(ParentsChildren, {
where: { parentId: coUserSaved.id, enfantId },
});
if (existing) continue;
await manager.save(
ParentsChildren,
manager.create(ParentsChildren, {
parentId: coUserSaved.id,
enfantId,
}),
);
}
return coUserSaved;
});
} catch (err) {
if (this.isPostgresUniqueViolation(err)) {
throw new ConflictException(
'Un compte avec cet email existe déjà (contrainte unique en base).',
);
}
throw err;
}
try {
await this.mailService.sendValidatedAccountPasswordSetupEmail(
{
email: coParent.email,
prenom: coParent.prenom ?? '',
nom: coParent.nom ?? '',
token: tokenCreationMdp,
numeroDossier,
},
'parent',
);
} catch (err) { } catch (err) {
this.logger.error( this.logger.error(
'[addCoParentStaff] Échec envoi email création MDP (co-parent conservé)', "[inscrireParentComplet] Échec envoi email d'accusé de réception (inscription conservée)",
err instanceof Error ? err.stack : String(err), err instanceof Error ? err.stack : String(err),
); );
} }
return { return {
message: message: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
'Co-parent ajouté au foyer. Un e-mail de création de mot de passe a été envoyé.', parent_id: resultat.parent1.id,
co_parent_id: resultat.parent2?.id,
enfants_ids: resultat.enfants.map(e => e.id),
statut: StatutUtilisateurType.EN_ATTENTE,
numero_dossier: numeroDossier, numero_dossier: numeroDossier,
parent_user_id: pivotUserId,
co_parent_user_id: coParent.id,
statut: StatutUtilisateurType.ACTIF,
}; };
} }
/** /**
* Cœur partagé création dossier AM (#156). * Inscription Assistante Maternelle COMPLÈTE - Un seul endpoint (identité + pro + photo + CGU)
* - public : statut en_attente + mail pending * Crée User (role AM) + entrée assistantes_maternelles, token création MDP
* - staff : statut actif + mail création MDP
*/ */
async createAmDossier( async inscrireAMComplet(dto: RegisterAMCompletDto) {
dto: RegisterAMCompletDto, if (!dto.acceptation_cgu || !dto.acceptation_privacy) {
options: {
statut: StatutUtilisateurType;
sendPendingEmail: boolean;
sendPasswordSetupEmail: boolean;
requireCgu: boolean;
logContext?: string;
},
): Promise<{
message: string;
user_id: string;
statut: StatutUtilisateurType;
numero_dossier: string;
}> {
const logCtx = options.logContext ?? 'createAmDossier';
if (options.requireCgu && (!dto.acceptation_cgu || !dto.acceptation_privacy)) {
throw new BadRequestException( throw new BadRequestException(
"L'acceptation des CGU et de la politique de confidentialité est obligatoire", "L'acceptation des CGU et de la politique de confidentialité est obligatoire",
); );
@@ -921,7 +669,8 @@ export class AuthService {
throw new BadRequestException(nirValidation.error || 'NIR invalide'); throw new BadRequestException(nirValidation.error || 'NIR invalide');
} }
if (nirValidation.warning) { if (nirValidation.warning) {
console.warn(`[${logCtx}] NIR warning:`, nirValidation.warning, 'email=', dto.email); // Warning uniquement : on ne bloque pas (AM souvent étrangères, DOM-TOM, Corse)
console.warn('[inscrireAMComplet] NIR warning:', nirValidation.warning, 'email=', dto.email);
} }
const existe = await this.usersService.findByEmailOrNull(dto.email); const existe = await this.usersService.findByEmailOrNull(dto.email);
@@ -972,142 +721,79 @@ export class AuthService {
let resultat: { user: Users }; let resultat: { user: Users };
try { try {
resultat = await this.usersRepo.manager.transaction(async (manager) => { resultat = await this.usersRepo.manager.transaction(async (manager) => {
const { numero: numeroDossier } = const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager);
await this.numeroDossierService.getNextNumeroDossier(manager);
const user = manager.create(Users, { const user = manager.create(Users, {
email: dto.email, email: dto.email,
prenom: dto.prenom, prenom: dto.prenom,
nom: dto.nom, nom: dto.nom,
role: RoleType.ASSISTANTE_MATERNELLE, role: RoleType.ASSISTANTE_MATERNELLE,
statut: options.statut, statut: StatutUtilisateurType.EN_ATTENTE,
telephone: dto.telephone, telephone: dto.telephone,
adresse: dto.adresse, adresse: dto.adresse,
code_postal: dto.code_postal, code_postal: dto.code_postal,
ville: dto.ville, ville: dto.ville,
token_creation_mdp: tokenCreationMdp, token_creation_mdp: tokenCreationMdp,
token_creation_mdp_expire_le: dateExpiration, token_creation_mdp_expire_le: dateExpiration,
photo_url: urlPhoto ?? undefined, photo_url: urlPhoto ?? undefined,
consentement_photo: dto.consentement_photo, consentement_photo: dto.consentement_photo,
date_consentement_photo: dateConsentementPhoto, date_consentement_photo: dateConsentementPhoto,
date_naissance: dto.date_naissance date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined,
? new Date(dto.date_naissance) lieu_naissance_ville: dto.lieu_naissance_ville,
: undefined, lieu_naissance_pays: dto.lieu_naissance_pays,
lieu_naissance_ville: dto.lieu_naissance_ville, numero_dossier: numeroDossier,
lieu_naissance_pays: dto.lieu_naissance_pays,
numero_dossier: numeroDossier,
});
const userEnregistre = await manager.save(Users, user);
const amRepo = manager.getRepository(AssistanteMaternelle);
const am = amRepo.create({
user_id: userEnregistre.id,
approval_number: dto.numero_agrement,
nir: nirNormalized,
max_children: dto.capacite_accueil,
places_available: dto.places_disponibles,
biography: dto.biographie,
residence_city: dto.ville ?? undefined,
agreement_date: dto.date_agrement
? new Date(dto.date_agrement)
: undefined,
available: true,
numero_dossier: numeroDossier,
});
await amRepo.save(am);
return { user: userEnregistre };
}); });
const userEnregistre = await manager.save(Users, user);
const amRepo = manager.getRepository(AssistanteMaternelle);
const am = amRepo.create({
user_id: userEnregistre.id,
approval_number: dto.numero_agrement,
nir: nirNormalized,
max_children: dto.capacite_accueil,
places_available: dto.places_disponibles,
biography: dto.biographie,
residence_city: dto.ville ?? undefined,
agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined,
available: true,
numero_dossier: numeroDossier,
});
await amRepo.save(am);
return { user: userEnregistre };
});
} catch (err) { } catch (err) {
if (this.isPostgresUniqueViolation(err)) { if (this.isPostgresUniqueViolation(err)) {
throw new ConflictException( throw new ConflictException('Un compte avec cet email existe déjà (contrainte unique en base).');
'Un compte avec cet email existe déjà (contrainte unique en base).',
);
} }
throw err; throw err;
} }
const numeroDossier = resultat.user.numero_dossier ?? ''; const numeroDossier = resultat.user.numero_dossier ?? '';
if (options.sendPendingEmail) { try {
try { await this.mailService.sendRegistrationPendingEmail(
await this.mailService.sendRegistrationPendingEmail( resultat.user.email,
resultat.user.email, resultat.user.prenom ?? '',
resultat.user.prenom ?? '', resultat.user.nom ?? '',
resultat.user.nom ?? '', numeroDossier,
numeroDossier, );
); } catch (err) {
} catch (err) { this.logger.error(
this.logger.error( "[inscrireAMComplet] Échec envoi email d'accusé de réception (inscription conservée)",
`[${logCtx}] Échec envoi email d'accusé de réception (inscription conservée)`, err instanceof Error ? err.stack : String(err),
err instanceof Error ? err.stack : String(err), );
);
}
} }
if (options.sendPasswordSetupEmail) {
try {
await this.mailService.sendValidatedAccountPasswordSetupEmail(
{
email: resultat.user.email,
prenom: resultat.user.prenom ?? '',
nom: resultat.user.nom ?? '',
token: tokenCreationMdp,
numeroDossier,
},
'am',
);
} catch (err) {
this.logger.error(
`[${logCtx}] Échec envoi email création MDP (dossier conservé)`,
err instanceof Error ? err.stack : String(err),
);
}
}
const message =
options.statut === StatutUtilisateurType.ACTIF
? 'Dossier AM créé et validé. Un e-mail de création de mot de passe a été envoyé.'
: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.';
return { return {
message, message:
'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
user_id: resultat.user.id, user_id: resultat.user.id,
statut: options.statut, statut: StatutUtilisateurType.EN_ATTENTE,
numero_dossier: numeroDossier, numero_dossier: numeroDossier,
}; };
} }
/**
* Inscription Assistante Maternelle COMPLÈTE - Un seul endpoint (identité + pro + photo + CGU)
* Crée User (role AM) + entrée assistantes_maternelles, token création MDP
*/
async inscrireAMComplet(dto: RegisterAMCompletDto) {
return this.createAmDossier(dto, {
statut: StatutUtilisateurType.EN_ATTENTE,
sendPendingEmail: true,
sendPasswordSetupEmail: false,
requireCgu: true,
logContext: 'inscrireAMComplet',
});
}
/**
* Création dossier AM par staff (#156) — statut actif + e-mail création MDP.
*/
async createAmDossierStaff(dto: RegisterAMCompletDto) {
return this.createAmDossier(dto, {
statut: StatutUtilisateurType.ACTIF,
sendPendingEmail: false,
sendPasswordSetupEmail: true,
requireCgu: false,
logContext: 'createAmDossierStaff',
});
}
/**
* Sauvegarde une photo depuis base64 vers le système de fichiers
*/
/** /**
* Sauvegarde une photo depuis base64 vers le système de fichiers * Sauvegarde une photo depuis base64 vers le système de fichiers
*/ */
@@ -1386,6 +1072,9 @@ export class AuthService {
enfant.status = StatutEnfantType.A_NAITRE; enfant.status = StatutEnfantType.A_NAITRE;
} }
} }
if (enfantDto.grossesse_multiple !== undefined) {
enfant.is_multiple = enfantDto.grossesse_multiple;
}
if (enfantDto.consent_photo !== undefined) { if (enfantDto.consent_photo !== undefined) {
enfant.consent_photo = !!enfantDto.consent_photo; enfant.consent_photo = !!enfantDto.consent_photo;
enfant.consent_photo_at = enfant.consent_photo enfant.consent_photo_at = enfant.consent_photo
@@ -55,6 +55,11 @@ export class EnfantInscriptionDto {
@IsString() @IsString()
photo_filename?: string; photo_filename?: string;
@ApiProperty({ example: false, required: false, description: 'Grossesse multiple (jumeaux, triplés, etc.)' })
@IsOptional()
@IsBoolean()
grossesse_multiple?: boolean;
@ApiProperty({ @ApiProperty({
example: true, example: true,
required: false, required: false,
@@ -1,85 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DossiersController } from './dossiers.controller';
import { DossiersService } from './dossiers.service';
import { SuppressionService } from '../suppressions/suppression.service';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { StatutUtilisateurType } from 'src/entities/users.entity';
describe('DossiersController', () => {
let controller: DossiersController;
const dossiersServiceMock = {
listDossiers: jest.fn(),
getDossierByNumero: jest.fn(),
};
const suppressionServiceMock = {
deleteDossier: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [DossiersController],
providers: [
{ provide: DossiersService, useValue: dossiersServiceMock },
{ provide: SuppressionService, useValue: suppressionServiceMock },
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<DossiersController>(DossiersController);
jest.clearAllMocks();
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('list delegates to dossiersService.listDossiers with q', async () => {
dossiersServiceMock.listDossiers.mockResolvedValue([
{
type: 'famille',
numero_dossier: '2026-000043',
libelle: 'Claire MARTIN',
emails: ['claire@test.fr'],
user_ids: ['u1'],
statut: StatutUtilisateurType.ACTIF,
a_valider: false,
date_reference: null,
},
]);
const res = await controller.list('martin');
expect(dossiersServiceMock.listDossiers).toHaveBeenCalledWith('martin');
expect(res).toHaveLength(1);
expect(res[0].numero_dossier).toBe('2026-000043');
});
it('getDossier delegates to getDossierByNumero', async () => {
dossiersServiceMock.getDossierByNumero.mockResolvedValue({
type: 'family',
dossier: { numero_dossier: '2026-000001' },
});
const res = await controller.getDossier('2026-000001');
expect(dossiersServiceMock.getDossierByNumero).toHaveBeenCalledWith('2026-000001');
expect(res.type).toBe('family');
});
it('remove delegates to suppressionService.deleteDossier', async () => {
const user = { id: 'u1', role: 'gestionnaire' } as never;
suppressionServiceMock.deleteDossier.mockResolvedValue({
type: 'famille',
deleted_user_ids: [],
deleted_enfant_ids: [],
message: 'ok',
});
await controller.remove('2026-000001', user);
expect(suppressionServiceMock.deleteDossier).toHaveBeenCalledWith(
'2026-000001',
user,
);
});
});
@@ -1,58 +1,17 @@
import { import { Controller, Get, Param, UseGuards } from '@nestjs/common';
Controller, import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
Delete,
Get,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiQuery,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { Roles } from 'src/common/decorators/roles.decorator'; import { Roles } from 'src/common/decorators/roles.decorator';
import { RoleType, Users } from 'src/entities/users.entity'; import { RoleType } from 'src/entities/users.entity';
import { AuthGuard } from 'src/common/guards/auth.guard'; import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard'; import { RolesGuard } from 'src/common/guards/roles.guard';
import { User } from 'src/common/decorators/user.decorator';
import { DossiersService } from './dossiers.service'; import { DossiersService } from './dossiers.service';
import { SuppressionService } from '../suppressions/suppression.service';
import { DossierUnifieDto } from './dto/dossier-unifie.dto'; import { DossierUnifieDto } from './dto/dossier-unifie.dto';
import { DossierListItemDto } from './dto/dossier-list-item.dto';
@ApiTags('Dossiers') @ApiTags('Dossiers')
@ApiBearerAuth('access-token')
@Controller('dossiers') @Controller('dossiers')
@UseGuards(AuthGuard, RolesGuard) @UseGuards(AuthGuard, RolesGuard)
export class DossiersController { export class DossiersController {
constructor( constructor(private readonly dossiersService: DossiersService) {}
private readonly dossiersService: DossiersService,
private readonly suppressionService: SuppressionService,
) {}
@Get()
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({
summary: 'Liste unifiée des dossiers (familles + AM) — ticket #153',
description:
'1 entrée = 1 numero_dossier. Types `famille` | `assistante_maternelle`. ' +
'Filtre optionnel `q` (n°, nom, email). Tri : à valider dabord, puis n° décroissant. ' +
'`sans_enfant` (#159) pour dossiers famille sans enfant.',
})
@ApiQuery({
name: 'q',
required: false,
description: 'Recherche libre : n° dossier, libellé, email…',
})
@ApiResponse({ status: 200, type: [DossierListItemDto] })
@ApiResponse({ status: 403, description: 'Accès refusé' })
list(@Query('q') q?: string): Promise<DossierListItemDto[]> {
return this.dossiersService.listDossiers(q);
}
@Get(':numeroDossier') @Get(':numeroDossier')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@@ -64,21 +23,4 @@ export class DossiersController {
getDossier(@Param('numeroDossier') numeroDossier: string): Promise<DossierUnifieDto> { getDossier(@Param('numeroDossier') numeroDossier: string): Promise<DossierUnifieDto> {
return this.dossiersService.getDossierByNumero(numeroDossier); return this.dossiersService.getDossierByNumero(numeroDossier);
} }
@Delete(':numeroDossier')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({
summary: 'Supprimer un dossier (famille ou AM) — #159',
description:
'Famille : parents + enfants. AM : compte AM + dossier AM (enfants conservés, placements clos).',
})
@ApiParam({ name: 'numeroDossier', description: 'Numéro de dossier' })
@ApiResponse({ status: 200, description: 'Résultat de suppression' })
@ApiResponse({ status: 404, description: 'Dossier introuvable' })
remove(
@Param('numeroDossier') numeroDossier: string,
@User() currentUser: Users,
) {
return this.suppressionService.deleteDossier(numeroDossier, currentUser);
}
} }
@@ -5,7 +5,6 @@ import { JwtModule } from '@nestjs/jwt';
import { Parents } from 'src/entities/parents.entity'; import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { ParentsModule } from '../parents/parents.module'; import { ParentsModule } from '../parents/parents.module';
import { SuppressionsModule } from '../suppressions/suppressions.module';
import { DossiersController } from './dossiers.controller'; import { DossiersController } from './dossiers.controller';
import { DossiersService } from './dossiers.service'; import { DossiersService } from './dossiers.service';
@@ -13,7 +12,6 @@ import { DossiersService } from './dossiers.service';
imports: [ imports: [
TypeOrmModule.forFeature([Parents, AssistanteMaternelle]), TypeOrmModule.forFeature([Parents, AssistanteMaternelle]),
ParentsModule, ParentsModule,
SuppressionsModule,
JwtModule.registerAsync({ JwtModule.registerAsync({
imports: [ConfigModule], imports: [ConfigModule],
useFactory: (config: ConfigService) => ({ useFactory: (config: ConfigService) => ({
@@ -1,148 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DossiersService } from './dossiers.service';
import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { ParentsService } from '../parents/parents.service';
import { SuppressionService } from '../suppressions/suppression.service';
import { StatutUtilisateurType } from 'src/entities/users.entity';
describe('DossiersService.listDossiers', () => {
let service: DossiersService;
const parentsQb = {
innerJoinAndSelect: jest.fn().mockReturnThis(),
leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn(),
};
const amQb = {
innerJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn(),
};
const parentsRepo = {
createQueryBuilder: jest.fn(() => parentsQb),
findOne: jest.fn(),
};
const amRepo = {
createQueryBuilder: jest.fn(() => amQb),
findOne: jest.fn(),
};
const parentsService = {
getDossierFamilleByNumero: jest.fn(),
};
const suppressionService = {
countEnfantsForNumero: jest.fn().mockResolvedValue(1),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
DossiersService,
{ provide: getRepositoryToken(Parents), useValue: parentsRepo },
{ provide: getRepositoryToken(AssistanteMaternelle), useValue: amRepo },
{ provide: ParentsService, useValue: parentsService },
{ provide: SuppressionService, useValue: suppressionService },
],
}).compile();
service = module.get(DossiersService);
jest.clearAllMocks();
parentsRepo.createQueryBuilder.mockReturnValue(parentsQb);
amRepo.createQueryBuilder.mockReturnValue(amQb);
suppressionService.countEnfantsForNumero.mockResolvedValue(1);
});
it('aggregates famille (pivot+co-parent) and AM, sorts a_valider first', async () => {
parentsQb.getMany.mockResolvedValue([
{
user_id: 'p1',
numero_dossier: '2026-000010',
user: {
id: 'p1',
email: 'claire@test.fr',
prenom: 'Claire',
nom: 'Martin',
statut: StatutUtilisateurType.ACTIF,
cree_le: new Date('2026-01-01'),
},
co_parent: {
id: 'p2',
email: 'thomas@test.fr',
prenom: 'Thomas',
nom: 'Martin',
statut: StatutUtilisateurType.ACTIF,
cree_le: new Date('2026-01-02'),
},
},
{
user_id: 'p3',
numero_dossier: '2026-000020',
user: {
id: 'p3',
email: 'pending@test.fr',
prenom: 'Paul',
nom: 'Pending',
statut: StatutUtilisateurType.EN_ATTENTE,
cree_le: new Date('2026-02-01'),
},
co_parent: undefined,
},
]);
amQb.getMany.mockResolvedValue([
{
user_id: 'am1',
numero_dossier: '2026-000015',
user: {
id: 'am1',
email: 'am@test.fr',
prenom: 'Marie',
nom: 'Dupont',
statut: StatutUtilisateurType.ACTIF,
cree_le: new Date('2026-01-15'),
},
},
]);
const list = await service.listDossiers();
expect(list).toHaveLength(3);
expect(list[0].a_valider).toBe(true);
expect(list[0].type).toBe('famille');
expect(list[0].numero_dossier).toBe('2026-000020');
const famille = list.find((i) => i.numero_dossier === '2026-000010')!;
expect(famille.type).toBe('famille');
expect(famille.user_ids).toEqual(expect.arrayContaining(['p1', 'p2']));
expect(famille.emails).toHaveLength(2);
expect(famille.libelle).toContain('MARTIN');
const am = list.find((i) => i.type === 'assistante_maternelle')!;
expect(am.numero_dossier).toBe('2026-000015');
expect(am.libelle).toContain('Marie');
});
it('filters with q', async () => {
parentsQb.getMany.mockResolvedValue([]);
amQb.getMany.mockResolvedValue([
{
user_id: 'am1',
numero_dossier: '2026-000015',
user: {
id: 'am1',
email: 'am@test.fr',
prenom: 'Marie',
nom: 'Dupont',
statut: StatutUtilisateurType.ACTIF,
cree_le: new Date('2026-01-15'),
},
},
]);
const hit = await service.listDossiers('dupont');
expect(hit).toHaveLength(1);
const miss = await service.listDossiers('zzz');
expect(miss).toHaveLength(0);
});
});
+1 -169
View File
@@ -3,15 +3,12 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { Parents } from 'src/entities/parents.entity'; import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { StatutUtilisateurType, Users } from 'src/entities/users.entity';
import { ParentsService } from '../parents/parents.service'; import { ParentsService } from '../parents/parents.service';
import { SuppressionService } from '../suppressions/suppression.service';
import { DossierUnifieDto } from './dto/dossier-unifie.dto'; import { DossierUnifieDto } from './dto/dossier-unifie.dto';
import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto'; import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto';
import { DossierListItemDto } from './dto/dossier-list-item.dto';
/** /**
* Dossiers unifiés — détail (#119) + liste (#153) + sans_enfant (#159). * Endpoint unifié GET /dossiers/:numeroDossier AM ou famille. Ticket #119.
*/ */
@Injectable() @Injectable()
export class DossiersService { export class DossiersService {
@@ -21,173 +18,8 @@ export class DossiersService {
@InjectRepository(AssistanteMaternelle) @InjectRepository(AssistanteMaternelle)
private readonly amRepository: Repository<AssistanteMaternelle>, private readonly amRepository: Repository<AssistanteMaternelle>,
private readonly parentsService: ParentsService, private readonly parentsService: ParentsService,
private readonly suppressionService: SuppressionService,
) {} ) {}
/**
* Liste unifiée tous dossiers (familles + AM) ayant un numero_dossier.
* Ticket #153 — optionnel `q` filtre n° / nom / prénom / email (côté serveur).
*/
async listDossiers(q?: string): Promise<DossierListItemDto[]> {
const items: DossierListItemDto[] = [
...(await this.listFamilleItems()),
...(await this.listAmItems()),
];
const needle = (q ?? '').trim().toLowerCase();
const filtered = needle
? items.filter((item) => this.matchesQuery(item, needle))
: items;
filtered.sort((a, b) => {
// À valider d'abord, puis n° dossier décroissant
if (a.a_valider !== b.a_valider) return a.a_valider ? -1 : 1;
return b.numero_dossier.localeCompare(a.numero_dossier, 'fr');
});
for (const item of filtered) {
if (item.type === 'famille') {
const n = await this.suppressionService.countEnfantsForNumero(
item.numero_dossier,
);
item.sans_enfant = n === 0;
} else {
item.sans_enfant = false;
}
}
return filtered;
}
private async listFamilleItems(): Promise<DossierListItemDto[]> {
const parents = await this.parentsRepository
.createQueryBuilder('p')
.innerJoinAndSelect('p.user', 'u')
.leftJoinAndSelect('p.co_parent', 'cp')
.where('p.numero_dossier IS NOT NULL')
.andWhere("TRIM(p.numero_dossier) <> ''")
.getMany();
const byNum = new Map<string, Parents[]>();
for (const p of parents) {
const num = (p.numero_dossier ?? '').trim();
if (!num) continue;
const group = byNum.get(num) ?? [];
group.push(p);
byNum.set(num, group);
}
const items: DossierListItemDto[] = [];
for (const [numero_dossier, group] of byNum) {
const usersMap = new Map<string, Users>();
for (const p of group) {
if (p.user) usersMap.set(p.user.id, p.user);
if (p.co_parent) usersMap.set(p.co_parent.id, p.co_parent);
}
const users = [...usersMap.values()].sort((a, b) => {
const an = `${a.nom ?? ''} ${a.prenom ?? ''}`.toLowerCase();
const bn = `${b.nom ?? ''} ${b.prenom ?? ''}`.toLowerCase();
return an.localeCompare(bn, 'fr') || a.id.localeCompare(b.id);
});
if (users.length === 0) continue;
const names = users.map((u) => this.formatPersonName(u)).filter(Boolean);
const libelle =
names.length === 0
? `Dossier ${numero_dossier}`
: names.length === 1
? names[0]
: names.join(' & ');
const emails = users.map((u) => u.email).filter(Boolean);
const user_ids = users.map((u) => u.id);
const a_valider = users.some((u) => u.statut === StatutUtilisateurType.EN_ATTENTE);
const statut = a_valider
? StatutUtilisateurType.EN_ATTENTE
: (users[0].statut ?? StatutUtilisateurType.ACTIF);
const date_reference = this.minCreeLeIso(users);
items.push({
type: 'famille',
numero_dossier,
libelle,
emails,
user_ids,
statut,
a_valider,
date_reference,
});
}
return items;
}
private async listAmItems(): Promise<DossierListItemDto[]> {
const ams = await this.amRepository
.createQueryBuilder('am')
.innerJoinAndSelect('am.user', 'u')
.where('am.numero_dossier IS NOT NULL')
.andWhere("TRIM(am.numero_dossier) <> ''")
.getMany();
const byNum = new Map<string, AssistanteMaternelle>();
for (const am of ams) {
const num = (am.numero_dossier ?? '').trim();
if (!num || !am.user) continue;
// Un n° = une AM ; garder le premier
if (!byNum.has(num)) byNum.set(num, am);
}
const items: DossierListItemDto[] = [];
for (const [numero_dossier, am] of byNum) {
const u = am.user!;
const libelle = this.formatPersonName(u) || `AM ${numero_dossier}`;
const a_valider = u.statut === StatutUtilisateurType.EN_ATTENTE;
items.push({
type: 'assistante_maternelle',
numero_dossier,
libelle,
emails: u.email ? [u.email] : [],
user_ids: [u.id],
statut: u.statut ?? StatutUtilisateurType.ACTIF,
a_valider,
date_reference: this.minCreeLeIso([u]),
});
}
return items;
}
private matchesQuery(item: DossierListItemDto, needle: string): boolean {
const hay = [
item.numero_dossier,
item.libelle,
...item.emails,
item.statut,
item.type,
]
.join(' ')
.toLowerCase();
return hay.includes(needle);
}
private formatPersonName(u: Users): string {
const prenom = (u.prenom ?? '').trim();
const nom = (u.nom ?? '').trim();
const nomFmt = nom ? nom.toUpperCase() : '';
return [prenom, nomFmt].filter(Boolean).join(' ');
}
private minCreeLeIso(users: Users[]): string | null {
let min: Date | null = null;
for (const u of users) {
const d = u.cree_le;
if (!d) continue;
const date = d instanceof Date ? d : new Date(d);
if (Number.isNaN(date.getTime())) continue;
if (!min || date < min) min = date;
}
return min ? min.toISOString() : null;
}
async getDossierByNumero(numeroDossier: string): Promise<DossierUnifieDto> { async getDossierByNumero(numeroDossier: string): Promise<DossierUnifieDto> {
const num = numeroDossier?.trim(); const num = numeroDossier?.trim();
if (!num) { if (!num) {
@@ -1,58 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Ligne de liste GET /dossiers (#153). */
export class DossierListItemDto {
@ApiProperty({
enum: ['famille', 'assistante_maternelle'],
description: 'Type de dossier',
})
type: 'famille' | 'assistante_maternelle';
@ApiProperty({ example: '2026-000043' })
numero_dossier: string;
@ApiProperty({
example: 'Claire MARTIN & Thomas MARTIN',
description: 'Libellé affiché (noms)',
})
libelle: string;
@ApiProperty({
type: [String],
example: ['claire@example.com', 'thomas@example.com'],
})
emails: string[];
@ApiProperty({
type: [String],
format: 'uuid',
description: 'IDs utilisateur liés au dossier (parents du foyer ou AM)',
})
user_ids: string[];
@ApiProperty({
enum: StatutUtilisateurType,
description:
'Statut agrégé : en_attente si au moins un user en_attente, sinon statut du premier',
})
statut: StatutUtilisateurType;
@ApiProperty({
description: 'True si le dossier est en attente de validation (section haute UI)',
})
a_valider: boolean;
@ApiPropertyOptional({
nullable: true,
example: '2026-01-12T10:00:00.000Z',
description: 'Date de référence (MIN cree_le des users du dossier)',
})
date_reference: string | null;
@ApiPropertyOptional({
description:
'True si dossier famille sans enfant lié (#159). Omis ou false pour AM.',
})
sans_enfant?: boolean;
}
@@ -74,6 +74,11 @@ export class CreateEnfantsDto {
@IsDateString() @IsDateString()
consent_photo_at?: string; consent_photo_at?: string;
@ApiProperty({ default: false })
@Transform(toBoolean)
@IsBoolean()
is_multiple: boolean;
/** /**
* Parent pivot du foyer — obligatoire pour staff (gestionnaire/admin). * Parent pivot du foyer — obligatoire pour staff (gestionnaire/admin).
* Ignoré / interdit en externe pour un PARENT (ticket #132). * Ignoré / interdit en externe pour un PARENT (ticket #132).
@@ -29,6 +29,9 @@ export class EnfantResponseDto {
@ApiProperty({ example: false }) @ApiProperty({ example: false })
consent_photo: boolean; consent_photo: boolean;
@ApiProperty({ example: false })
is_multiple: boolean;
@ApiProperty({ example: 'UUID-parent' }) @ApiProperty({ example: 'UUID-parent' })
parent_id: string; parent_id: string;
} }
@@ -13,7 +13,6 @@ import {
ParseUUIDPipe, ParseUUIDPipe,
Patch, Patch,
Post, Post,
Query,
UploadedFile, UploadedFile,
UseGuards, UseGuards,
UseInterceptors, UseInterceptors,
@@ -24,7 +23,6 @@ import {
ApiBody, ApiBody,
ApiConsumes, ApiConsumes,
ApiOperation, ApiOperation,
ApiQuery,
ApiTags, ApiTags,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { diskStorage } from 'multer'; import { diskStorage } from 'multer';
@@ -38,7 +36,6 @@ import { User } from 'src/common/decorators/user.decorator';
import { AuthGuard } from 'src/common/guards/auth.guard'; import { AuthGuard } from 'src/common/guards/auth.guard';
import { Roles } from 'src/common/decorators/roles.decorator'; import { Roles } from 'src/common/decorators/roles.decorator';
import { RolesGuard } from 'src/common/guards/roles.guard'; import { RolesGuard } from 'src/common/guards/roles.guard';
import { SuppressionService } from '../suppressions/suppression.service';
const photoMulterOptions = { const photoMulterOptions = {
storage: diskStorage({ storage: diskStorage({
@@ -86,10 +83,7 @@ class OptionalEnfantPhotoInterceptor implements NestInterceptor {
@UseGuards(AuthGuard, RolesGuard) @UseGuards(AuthGuard, RolesGuard)
@Controller('enfants') @Controller('enfants')
export class EnfantsController { export class EnfantsController {
constructor( constructor(private readonly enfantsService: EnfantsService) { }
private readonly enfantsService: EnfantsService,
private readonly suppressionService: SuppressionService,
) { }
@Roles( @Roles(
RoleType.PARENT, RoleType.PARENT,
@@ -147,47 +141,17 @@ export class EnfantsController {
RoleType.GESTIONNAIRE, RoleType.GESTIONNAIRE,
) )
@Patch(':id') @Patch(':id')
@ApiOperation({
summary: 'Mettre à jour un enfant',
description:
'JSON sans photo OK ; avec nouvelle photo → multipart (champ fichier `photo`, max 5 Mo).',
})
@ApiConsumes('application/json', 'multipart/form-data')
@UseInterceptors(OptionalEnfantPhotoInterceptor)
update( update(
@Param('id', new ParseUUIDPipe()) id: string, @Param('id', new ParseUUIDPipe()) id: string,
@Body() dto: UpdateEnfantsDto, @Body() dto: UpdateEnfantsDto,
@UploadedFile() photo: Express.Multer.File,
@User() currentUser: Users, @User() currentUser: Users,
) { ) {
return this.enfantsService.update(id, dto, currentUser, photo); return this.enfantsService.update(id, dto, currentUser);
} }
@Roles( @Roles(RoleType.SUPER_ADMIN)
RoleType.SUPER_ADMIN,
RoleType.ADMINISTRATEUR,
RoleType.GESTIONNAIRE,
)
@Delete(':id') @Delete(':id')
@ApiOperation({ remove(@Param('id', new ParseUUIDPipe()) id: string) {
summary: 'Supprimer un enfant (#159)', return this.enfantsService.remove(id);
description:
'Query `deleteDossier=true` si dernier enfant et suppression du dossier famille souhaitée.',
})
@ApiQuery({
name: 'deleteDossier',
required: false,
description: 'Si true et dernier enfant : cascade dossier famille',
})
remove(
@Param('id', new ParseUUIDPipe()) id: string,
@Query('deleteDossier') deleteDossier: string | undefined,
@User() currentUser: Users,
) {
const flag =
deleteDossier === 'true' ||
deleteDossier === '1' ||
deleteDossier === 'yes';
return this.suppressionService.deleteEnfant(id, flag, currentUser);
} }
} }
+4 -7
View File
@@ -6,16 +6,13 @@ import { Children } from 'src/entities/children.entity';
import { Parents } from 'src/entities/parents.entity'; import { Parents } from 'src/entities/parents.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity'; import { ParentsChildren } from 'src/entities/parents_children.entity';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { SuppressionsModule } from '../suppressions/suppressions.module';
@Module({ @Module({
imports: [ imports: [TypeOrmModule.forFeature([Children, Parents, ParentsChildren]),
TypeOrmModule.forFeature([Children, Parents, ParentsChildren]), AuthModule
AuthModule,
SuppressionsModule,
], ],
controllers: [EnfantsController], controllers: [EnfantsController],
providers: [EnfantsService], providers: [EnfantsService]
exports: [EnfantsService],
}) })
export class EnfantsModule { } export class EnfantsModule { }
+8 -30
View File
@@ -92,6 +92,7 @@ export class EnfantsService {
photo_url: photoUrl, photo_url: photoUrl,
consent_photo: !!dto.consent_photo, consent_photo: !!dto.consent_photo,
consent_photo_at: consentAt, consent_photo_at: consentAt,
is_multiple: !!dto.is_multiple,
}); });
await this.childrenRepository.save(child); await this.childrenRepository.save(child);
@@ -145,27 +146,16 @@ export class EnfantsService {
throw new ForbiddenException('Accès interdit'); throw new ForbiddenException('Accès interdit');
} }
/** Flag API #157 — true si aucun lien enfants_parents. */ // Liste des enfants (admin/gestionnaire)
private withSansResponsable(child: Children): Children & { sans_responsable: boolean } { async findAll(): Promise<Children[]> {
return Object.assign(child, { return this.childrenRepository.find({
sans_responsable: !child.parentLinks || child.parentLinks.length === 0,
});
}
// Liste des enfants (admin/gestionnaire) — inclut les orphelins (parentLinks: [])
async findAll(): Promise<Array<Children & { sans_responsable: boolean }>> {
const children = await this.childrenRepository.find({
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'], relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
order: { last_name: 'ASC', first_name: 'ASC' }, order: { last_name: 'ASC', first_name: 'ASC' },
}); });
return children.map((c) => this.withSansResponsable(c));
} }
// Récupérer un enfant par id // Récupérer un enfant par id
async findOne( async findOne(id: string, currentUser: Users): Promise<Children> {
id: string,
currentUser: Users,
): Promise<Children & { sans_responsable: boolean }> {
const child = await this.childrenRepository.findOne({ const child = await this.childrenRepository.findOne({
where: { id }, where: { id },
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'], relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
@@ -182,24 +172,19 @@ export class EnfantsService {
case RoleType.ADMINISTRATEUR: case RoleType.ADMINISTRATEUR:
case RoleType.SUPER_ADMIN: case RoleType.SUPER_ADMIN:
case RoleType.GESTIONNAIRE: case RoleType.GESTIONNAIRE:
// accès complet (y compris orphelins) // accès complet
break; break;
default: default:
throw new ForbiddenException('Accès interdit'); throw new ForbiddenException('Accès interdit');
} }
return this.withSansResponsable(child); return child;
} }
// Mise à jour // Mise à jour
async update( async update(id: string, dto: Partial<CreateEnfantsDto>, currentUser: Users): Promise<Children> {
id: string,
dto: Partial<CreateEnfantsDto>,
currentUser: Users,
photoFile?: Express.Multer.File,
): Promise<Children> {
const child = await this.childrenRepository.findOne({ where: { id } }); const child = await this.childrenRepository.findOne({ where: { id } });
if (!child) throw new NotFoundException('Enfant introuvable'); if (!child) throw new NotFoundException('Enfant introuvable');
@@ -209,13 +194,6 @@ export class EnfantsService {
patch.consent_photo = dto.consent_photo; patch.consent_photo = dto.consent_photo;
patch.consent_photo_at = dto.consent_photo ? new Date() : null!; patch.consent_photo_at = dto.consent_photo ? new Date() : null!;
} }
if (photoFile) {
patch.photo_url = `/uploads/photos/${photoFile.filename}`;
if (dto.consent_photo !== false) {
patch.consent_photo = true;
patch.consent_photo_at = new Date();
}
}
await this.childrenRepository.update(id, patch); await this.childrenRepository.update(id, patch);
return this.findOne(id, currentUser); return this.findOne(id, currentUser);
@@ -54,6 +54,9 @@ export class DossierFamilleEnfantDto {
description: 'Consentement affichage photo (colonne consentement_photo)', description: 'Consentement affichage photo (colonne consentement_photo)',
}) })
consent_photo?: boolean; consent_photo?: boolean;
@ApiProperty({ required: false, description: 'Grossesse multiple (est_multiple)' })
est_multiple?: boolean;
} }
/** Réponse GET /parents/dossier-famille/:numeroDossier dossier famille complet. Ticket #119 */ /** Réponse GET /parents/dossier-famille/:numeroDossier dossier famille complet. Ticket #119 */
@@ -7,12 +7,6 @@ export class ParentPendingSummaryDto {
@ApiProperty() @ApiProperty()
email: string; email: string;
@ApiPropertyOptional({ nullable: true })
nom?: string | null;
@ApiPropertyOptional({ nullable: true })
prenom?: string | null;
@ApiPropertyOptional({ nullable: true }) @ApiPropertyOptional({ nullable: true })
telephone?: string | null; telephone?: string | null;
@@ -24,10 +18,7 @@ export class ParentPendingSummaryDto {
} }
export class PendingFamilyDto { export class PendingFamilyDto {
@ApiProperty({ @ApiProperty({ example: 'Famille Dupont', description: 'Libellé affiché pour la famille' })
example: 'MARTIN Claire - MARTIN Thomas',
description: 'Libellé affiché : NOM Prénom (séparés par « - » si co-parent)',
})
libelle: string; libelle: string;
@ApiProperty({ @ApiProperty({
@@ -1,23 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Réponse 201 POST /parents/:id/co-parent (#135). */
export class StaffAddCoParentResponseDto {
@ApiProperty()
message: string;
@ApiProperty({ example: '2026-000043' })
numero_dossier: string;
@ApiProperty({ format: 'uuid', description: 'UUID du parent pivot' })
parent_user_id: string;
@ApiProperty({ format: 'uuid', description: 'UUID du co-parent créé' })
co_parent_user_id: string;
@ApiProperty({
enum: StatutUtilisateurType,
example: StatutUtilisateurType.ACTIF,
})
statut: StatutUtilisateurType;
}
@@ -1,69 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
/**
* Ajout dun co-parent sur un foyer existant (staff) — ticket #135.
* Corps sans préfixe `co_parent_*` (lURL cible déjà le pivot).
*/
export class StaffAddCoParentDto {
@ApiProperty({ example: 'thomas.martin@ptits-pas.fr' })
@IsEmail({}, { message: 'Email invalide' })
@IsNotEmpty({ message: "L'email est requis" })
email: string;
@ApiProperty({ example: 'Thomas' })
@IsString()
@IsNotEmpty({ message: 'Le prénom est requis' })
@MinLength(2)
@MaxLength(100)
prenom: string;
@ApiProperty({ example: 'MARTIN' })
@IsString()
@IsNotEmpty({ message: 'Le nom est requis' })
@MinLength(2)
@MaxLength(100)
nom: string;
@ApiProperty({ example: '0678456789' })
@IsString()
@IsNotEmpty({ message: 'Le téléphone est requis' })
@Matches(/^(\+33|0)[1-9](\d{2}){4}$/, {
message: 'Le numéro de téléphone doit être valide (ex: 0689567890 ou +33689567890)',
})
telephone: string;
@ApiPropertyOptional({
example: true,
description: 'Si true, copie ladresse du parent pivot',
})
@IsOptional()
@IsBoolean()
meme_adresse?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
adresse?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(10)
code_postal?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(150)
ville?: string;
}
@@ -1,37 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Réponse 201 POST /parents/dossier (#129). */
export class StaffCreateParentDossierResponseDto {
@ApiProperty()
message: string;
@ApiProperty({
example: '2026-000043',
description: 'Numéro de dossier famille attribué',
})
numero_dossier: string;
@ApiProperty({ format: 'uuid', description: 'UUID user du parent pivot' })
parent_user_id: string;
@ApiPropertyOptional({
format: 'uuid',
nullable: true,
description: 'UUID user du co-parent, ou null',
})
co_parent_user_id: string | null;
@ApiProperty({
enum: StatutUtilisateurType,
example: StatutUtilisateurType.ACTIF,
})
statut: StatutUtilisateurType;
@ApiProperty({
type: [String],
format: 'uuid',
description: 'IDs des enfants créés',
})
enfant_ids: string[];
}
@@ -1,29 +0,0 @@
import { ApiPropertyOptional, OmitType } from '@nestjs/swagger';
import { IsBoolean, IsOptional } from 'class-validator';
import { RegisterParentCompletDto } from 'src/routes/auth/dto/register-parent-complet.dto';
/**
* Création dossier parent/famille par staff (#129).
* Mêmes champs que l'inscription publique, sans CGU/privacy obligatoires
* (acceptées côté serveur pour le compte du gestionnaire).
*/
export class StaffCreateParentDossierDto extends OmitType(RegisterParentCompletDto, [
'acceptation_cgu',
'acceptation_privacy',
] as const) {
@ApiPropertyOptional({
description: 'Ignoré côté staff (CGU acceptées serveur). Conservé pour compat éventuelle.',
default: true,
})
@IsOptional()
@IsBoolean()
acceptation_cgu?: boolean;
@ApiPropertyOptional({
description: 'Ignoré côté staff (privacy acceptée serveur).',
default: true,
})
@IsOptional()
@IsBoolean()
acceptation_privacy?: boolean;
}
@@ -1,104 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { ParentsController } from './parents.controller'; import { ParentsController } from './parents.controller';
import { ParentsService } from './parents.service';
import { UserService } from '../user/user.service';
import { AuthService } from '../auth/auth.service';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { StatutUtilisateurType } from 'src/entities/users.entity';
describe('ParentsController', () => { describe('ParentsController', () => {
let controller: ParentsController; let controller: ParentsController;
const authServiceMock = {
createParentDossierStaff: jest.fn(),
addCoParentStaff: jest.fn(),
};
const parentsServiceMock = {};
const userServiceMock = {};
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [ParentsController], controllers: [ParentsController],
providers: [ }).compile();
{ provide: ParentsService, useValue: parentsServiceMock },
{ provide: UserService, useValue: userServiceMock },
{ provide: AuthService, useValue: authServiceMock },
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<ParentsController>(ParentsController); controller = module.get<ParentsController>(ParentsController);
jest.clearAllMocks();
}); });
it('should be defined', () => { it('should be defined', () => {
expect(controller).toBeDefined(); expect(controller).toBeDefined();
}); });
it('createDossier delegates to authService.createParentDossierStaff with CGU accepted', async () => {
authServiceMock.createParentDossierStaff.mockResolvedValue({
message: 'Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.',
parent_user_id: 'p1',
co_parent_user_id: 'p2',
enfant_ids: ['e1'],
statut: StatutUtilisateurType.ACTIF,
numero_dossier: '2026-000043',
});
const body = {
email: 'parent.staff@test.fr',
prenom: 'Claire',
nom: 'MARTIN',
telephone: '0689567890',
enfants: [
{
prenom: 'Emma',
nom: 'MARTIN',
date_naissance: '2023-02-15',
genre: 'F',
},
],
};
const res = await controller.createDossier(body as any);
expect(authServiceMock.createParentDossierStaff).toHaveBeenCalledWith(
expect.objectContaining({
email: body.email,
acceptation_cgu: true,
acceptation_privacy: true,
}),
);
expect(res.numero_dossier).toBe('2026-000043');
expect(res.parent_user_id).toBe('p1');
expect(res.co_parent_user_id).toBe('p2');
expect(res.enfant_ids).toEqual(['e1']);
expect(res.statut).toBe(StatutUtilisateurType.ACTIF);
});
it('addCoParent delegates to authService.addCoParentStaff', async () => {
authServiceMock.addCoParentStaff.mockResolvedValue({
message: 'ok',
numero_dossier: '2026-000043',
parent_user_id: 'p1',
co_parent_user_id: 'p2',
statut: StatutUtilisateurType.ACTIF,
});
const body = {
email: 'coparent@test.fr',
prenom: 'Thomas',
nom: 'MARTIN',
telephone: '0678456789',
meme_adresse: true,
};
const res = await controller.addCoParent('p1', body as any);
expect(authServiceMock.addCoParentStaff).toHaveBeenCalledWith('p1', body);
expect(res.co_parent_user_id).toBe('p2');
expect(res.statut).toBe(StatutUtilisateurType.ACTIF);
});
}); });
@@ -3,8 +3,6 @@ import {
Controller, Controller,
Delete, Delete,
Get, Get,
HttpCode,
HttpStatus,
Param, Param,
Patch, Patch,
Post, Post,
@@ -12,27 +10,14 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { ParentsService } from './parents.service'; import { ParentsService } from './parents.service';
import { UserService } from '../user/user.service'; import { UserService } from '../user/user.service';
import { AuthService } from '../auth/auth.service';
import { Parents } from 'src/entities/parents.entity'; import { Parents } from 'src/entities/parents.entity';
import { Users } from 'src/entities/users.entity'; import { Users } from 'src/entities/users.entity';
import { Roles } from 'src/common/decorators/roles.decorator'; import { Roles } from 'src/common/decorators/roles.decorator';
import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity'; import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
import { import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { CreateParentDto } from '../user/dto/create_parent.dto'; import { CreateParentDto } from '../user/dto/create_parent.dto';
import { UpdateParentsDto } from '../user/dto/update_parent.dto'; import { UpdateParentsDto } from '../user/dto/update_parent.dto';
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto'; import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
import { StaffCreateParentDossierDto } from './dto/staff-create-parent-dossier.dto';
import { StaffCreateParentDossierResponseDto } from './dto/staff-create-parent-dossier-response.dto';
import { StaffAddCoParentDto } from './dto/staff-add-co-parent.dto';
import { StaffAddCoParentResponseDto } from './dto/staff-add-co-parent-response.dto';
import { RegisterParentCompletDto } from '../auth/dto/register-parent-complet.dto';
import { AuthGuard } from 'src/common/guards/auth.guard'; import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard'; import { RolesGuard } from 'src/common/guards/roles.guard';
import { User } from 'src/common/decorators/user.decorator'; import { User } from 'src/common/decorators/user.decorator';
@@ -41,50 +26,14 @@ import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto';
import { mapParentForApi, mapParentsForApi } from './parents.mapper'; import { mapParentForApi, mapParentsForApi } from './parents.mapper';
@ApiTags('Parents') @ApiTags('Parents')
@ApiBearerAuth('access-token')
@Controller('parents') @Controller('parents')
@UseGuards(AuthGuard, RolesGuard) @UseGuards(AuthGuard, RolesGuard)
export class ParentsController { export class ParentsController {
constructor( constructor(
private readonly parentsService: ParentsService, private readonly parentsService: ParentsService,
private readonly userService: UserService, private readonly userService: UserService,
private readonly authService: AuthService,
) {} ) {}
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@Post('dossier')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Créer un dossier famille/parent complet (staff) — ticket #129',
description:
'Crée parent (+ co-parent optionnel) + enfants + n° dossier avec statut actif, ' +
'et envoie le-mail de création de mot de passe. ' +
'Ne pas utiliser POST /auth/register/parent depuis le dashboard.',
})
@ApiBody({ type: StaffCreateParentDossierDto })
@ApiResponse({ status: 201, type: StaffCreateParentDossierResponseDto })
@ApiResponse({ status: 400, description: 'Validation DTO / métier' })
@ApiResponse({ status: 403, description: 'Rôle non autorisé' })
@ApiResponse({ status: 409, description: 'Email pivot et/ou co-parent déjà pris' })
async createDossier(
@Body() dto: StaffCreateParentDossierDto,
): Promise<StaffCreateParentDossierResponseDto> {
const registerDto = {
...dto,
acceptation_cgu: true,
acceptation_privacy: true,
} as RegisterParentCompletDto;
const result = await this.authService.createParentDossierStaff(registerDto);
return {
message: result.message,
numero_dossier: result.numero_dossier,
parent_user_id: result.parent_user_id,
co_parent_user_id: result.co_parent_user_id ?? null,
statut: result.statut,
enfant_ids: result.enfant_ids,
};
}
@Get('pending-families') @Get('pending-families')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' }) @ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' })
@@ -178,28 +127,6 @@ export class ParentsController {
return mapParentForApi(parent); return mapParentForApi(parent);
} }
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@Post(':id/co-parent')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Ajouter un co-parent à un foyer existant (staff) — ticket #135',
description:
'Foyer mono-parent uniquement. Crée le co-parent actif, liens foyer + enfants, ' +
'e-mail de création de mot de passe. Ne pas utiliser POST /auth/register/parent.',
})
@ApiParam({ name: 'id', description: 'UUID utilisateur du parent pivot' })
@ApiBody({ type: StaffAddCoParentDto })
@ApiResponse({ status: 201, type: StaffAddCoParentResponseDto })
@ApiResponse({ status: 400, description: 'Foyer déjà à 2 parents / validation' })
@ApiResponse({ status: 404, description: 'Parent introuvable' })
@ApiResponse({ status: 409, description: 'Email déjà pris' })
async addCoParent(
@Param('id') id: string,
@Body() dto: StaffAddCoParentDto,
): Promise<StaffAddCoParentResponseDto> {
return this.authService.addCoParentStaff(id, dto);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Post(':id/enfants/:enfantId') @Post(':id/enfants/:enfantId')
@ApiOperation({ summary: 'Rattacher un enfant à un parent — ticket #115' }) @ApiOperation({ summary: 'Rattacher un enfant à un parent — ticket #115' })
@@ -9,13 +9,11 @@ import { ParentsController } from './parents.controller';
import { ParentsService } from './parents.service'; import { ParentsService } from './parents.service';
import { Users } from 'src/entities/users.entity'; import { Users } from 'src/entities/users.entity';
import { UserModule } from '../user/user.module'; import { UserModule } from '../user/user.module';
import { AuthModule } from '../auth/auth.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant, ParentsChildren]), TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant, ParentsChildren]),
forwardRef(() => UserModule), forwardRef(() => UserModule),
forwardRef(() => AuthModule),
JwtModule.registerAsync({ JwtModule.registerAsync({
imports: [ConfigModule], imports: [ConfigModule],
useFactory: (config: ConfigService) => ({ useFactory: (config: ConfigService) => ({
+22 -93
View File
@@ -114,86 +114,34 @@ export class ParentsService {
} }
/** /**
* Membres du foyer (user ids) pour affiliation enfant. * Rattacher un enfant existant à un parent (enfants_parents). Ticket #115 / doc 28 §6.2.
* Pivot + co-parent (A→B et B→A) + même numero_dossier. Ticket #158.
*/
private async resolveFoyerParentUserIds(parent: Parents): Promise<string[]> {
const ids = new Set<string>([parent.user_id]);
if (parent.co_parent?.id) {
ids.add(parent.co_parent.id);
}
// Sens inverse : parents qui déclarent ce user comme co-parent
const reverseLinks = await this.parentsRepository.find({
where: { co_parent: { id: parent.user_id } },
relations: ['co_parent'],
});
for (const p of reverseLinks) {
ids.add(p.user_id);
if (p.co_parent?.id) ids.add(p.co_parent.id);
}
const dossier = parent.numero_dossier?.trim();
if (dossier) {
const sameDossier = await this.parentsRepository.find({
where: { numero_dossier: dossier },
relations: ['co_parent'],
});
for (const p of sameDossier) {
ids.add(p.user_id);
if (p.co_parent?.id) ids.add(p.co_parent.id);
}
}
return [...ids];
}
/**
* Rattacher un enfant au foyer du parent (tous les responsables). Ticket #158.
* Un seul POST suffit : liens créés pour pivot + co-parent / même dossier.
*/ */
async attachEnfant(parentUserId: string, enfantId: string): Promise<Parents> { async attachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
const parent = await this.findOne(parentUserId); await this.findOne(parentUserId);
const child = await this.parentsRepository.manager.findOne(Children, { const existing = await this.parentsChildrenRepository.findOne({
where: { id: enfantId }, where: { parentId: parentUserId, enfantId },
}); });
if (existing) {
throw new ConflictException('Cet enfant est déjà rattaché à ce parent');
}
const child = await this.parentsRepository.manager.findOne(Children, { where: { id: enfantId } });
if (!child) { if (!child) {
throw new NotFoundException('Enfant introuvable'); throw new NotFoundException('Enfant introuvable');
} }
const foyerIds = await this.resolveFoyerParentUserIds(parent); await this.parentsChildrenRepository.save(
let created = 0; this.parentsChildrenRepository.create({ parentId: parentUserId, enfantId }),
);
for (const memberId of foyerIds) {
const existing = await this.parentsChildrenRepository.findOne({
where: { parentId: memberId, enfantId },
});
if (existing) continue;
await this.parentsChildrenRepository.save(
this.parentsChildrenRepository.create({
parentId: memberId,
enfantId,
}),
);
created += 1;
}
if (created === 0) {
throw new ConflictException('Cet enfant est déjà rattaché à ce foyer');
}
return this.findOne(parentUserId); return this.findOne(parentUserId);
} }
/** /**
* Détacher un enfant du foyer du parent (tous les responsables). Ticket #158. * Détacher un enfant d'un parent sans supprimer l'enfant. Ticket #115 / doc 28 §6.2.
* Si plus aucun lien ensuite → enfant orphelin (#157).
*/ */
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> { async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
const parent = await this.findOne(parentUserId); await this.findOne(parentUserId);
const link = await this.parentsChildrenRepository.findOne({ const link = await this.parentsChildrenRepository.findOne({
where: { parentId: parentUserId, enfantId }, where: { parentId: parentUserId, enfantId },
@@ -202,12 +150,12 @@ export class ParentsService {
throw new NotFoundException('Lien parent-enfant introuvable'); throw new NotFoundException('Lien parent-enfant introuvable');
} }
const foyerIds = await this.resolveFoyerParentUserIds(parent); const totalLinks = await this.parentsChildrenRepository.count({ where: { enfantId } });
await this.parentsChildrenRepository.delete({ if (totalLinks <= 1) {
parentId: In(foyerIds), throw new BadRequestException('Un enfant doit rester rattaché à au moins un responsable');
enfantId, }
});
await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId });
return this.findOne(parentUserId); return this.findOne(parentUserId);
} }
@@ -251,15 +199,7 @@ export class ParentsService {
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
) )
SELECT SELECT
string_agg( 'Famille ' || string_agg(u.nom, ' - ' ORDER BY u.nom, u.prenom) AS libelle,
UPPER(TRIM(u.nom))
|| CASE
WHEN u.prenom IS NOT NULL AND TRIM(u.prenom) <> ''
THEN ' ' || INITCAP(TRIM(u.prenom))
ELSE ''
END,
' - ' ORDER BY u.nom, u.prenom, u.id
) AS libelle,
array_agg(p.id_utilisateur ORDER BY u.nom, u.prenom, u.id) AS "parentIds", array_agg(p.id_utilisateur ORDER BY u.nom, u.prenom, u.id) AS "parentIds",
(array_agg(p.numero_dossier))[1] AS numero_dossier, (array_agg(p.numero_dossier))[1] AS numero_dossier,
MIN(u.cree_le) AS date_soumission, MIN(u.cree_le) AS date_soumission,
@@ -275,8 +215,6 @@ export class ParentsService {
json_build_object( json_build_object(
'id', u.id::text, 'id', u.id::text,
'email', u.email, 'email', u.email,
'nom', u.nom,
'prenom', u.prenom,
'telephone', u.telephone, 'telephone', u.telephone,
'code_postal', u.code_postal, 'code_postal', u.code_postal,
'ville', u.ville 'ville', u.ville
@@ -327,21 +265,11 @@ export class ParentsService {
return []; return [];
} }
private normalizeParents(parents: unknown): { private normalizeParents(parents: unknown): { id: string; email: string; telephone: string | null; code_postal: string | null; ville: string | null }[] {
id: string;
email: string;
nom: string | null;
prenom: string | null;
telephone: string | null;
code_postal: string | null;
ville: string | null;
}[] {
if (Array.isArray(parents)) { if (Array.isArray(parents)) {
return parents.map((p: any) => ({ return parents.map((p: any) => ({
id: String(p?.id ?? ''), id: String(p?.id ?? ''),
email: String(p?.email ?? ''), email: String(p?.email ?? ''),
nom: p?.nom != null ? String(p.nom) : null,
prenom: p?.prenom != null ? String(p.prenom) : null,
telephone: p?.telephone != null ? String(p.telephone) : null, telephone: p?.telephone != null ? String(p.telephone) : null,
code_postal: p?.code_postal != null ? String(p.code_postal) : null, code_postal: p?.code_postal != null ? String(p.code_postal) : null,
ville: p?.ville != null ? String(p.ville) : null, ville: p?.ville != null ? String(p.ville) : null,
@@ -370,6 +298,7 @@ export class ParentsService {
status: child.status, status: child.status,
photo_url: child.photo_url ?? undefined, photo_url: child.photo_url ?? undefined,
consent_photo: child.consent_photo, consent_photo: child.consent_photo,
est_multiple: child.is_multiple,
}; };
} }
@@ -1,175 +0,0 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { SuppressionService } from './suppression.service';
import { RoleType } from 'src/entities/users.entity';
describe('SuppressionService (#159)', () => {
const dataSource = {
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) =>
cb({
delete: jest.fn(),
query: jest.fn(),
}),
),
};
const usersRepository = {
findOne: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
};
const parentsRepository = {
findOne: jest.fn(),
find: jest.fn(),
query: jest.fn(),
};
const amRepository = {
findOne: jest.fn(),
};
const childrenRepository = {
findOne: jest.fn(),
delete: jest.fn(),
save: jest.fn(),
};
const parentsChildrenRepository = {
find: jest.fn(),
};
const amChildrenRepository = {
find: jest.fn(),
save: jest.fn(),
count: jest.fn(),
};
let service: SuppressionService;
const staff = {
id: 'staff-1',
role: RoleType.GESTIONNAIRE,
} as never;
const admin = {
id: 'admin-1',
role: RoleType.ADMINISTRATEUR,
} as never;
const superAdmin = {
id: 'sa-1',
role: RoleType.SUPER_ADMIN,
} as never;
beforeEach(() => {
jest.clearAllMocks();
service = new SuppressionService(
dataSource as never,
usersRepository as never,
parentsRepository as never,
amRepository as never,
childrenRepository as never,
parentsChildrenRepository as never,
amChildrenRepository as never,
);
});
it('refuse self-delete', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'admin-1',
role: RoleType.ADMINISTRATEUR,
});
await expect(service.deleteUser('admin-1', admin)).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('refuse gestionnaire deleting another gestionnaire', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'g2',
role: RoleType.GESTIONNAIRE,
});
await expect(service.deleteUser('g2', staff)).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('dernier admin : refus si pas super_admin', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'admin-2',
role: RoleType.ADMINISTRATEUR,
});
usersRepository.count.mockResolvedValue(1);
await expect(service.deleteUser('admin-2', admin)).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('dernier admin : OK pour super_admin', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'admin-2',
role: RoleType.ADMINISTRATEUR,
});
usersRepository.count.mockResolvedValue(1);
usersRepository.delete.mockResolvedValue({ affected: 1 });
const res = await service.deleteUser('admin-2', superAdmin);
expect(res.deleted_user_ids).toEqual(['admin-2']);
});
it('delete AM : clos placements, pas denfants deleted', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'am-1',
role: RoleType.ASSISTANTE_MATERNELLE,
});
amRepository.findOne.mockResolvedValue({
user_id: 'am-1',
numero_dossier: '2026-000015',
});
amChildrenRepository.find.mockResolvedValue([
{
amId: 'am-1',
enfantId: 'e1',
child: { id: 'e1', status: 'garde' },
},
]);
amChildrenRepository.count.mockResolvedValue(0);
amChildrenRepository.save.mockImplementation(async (x) => x);
childrenRepository.save.mockResolvedValue({});
usersRepository.delete.mockResolvedValue({ affected: 1 });
const res = await service.deleteUser('am-1', staff);
expect(res.deleted_enfant_ids).toEqual([]);
expect(res.deleted_user_ids).toEqual(['am-1']);
expect(res.type).toBe('assistante_maternelle');
});
it('delete dossier famille introuvable', async () => {
parentsRepository.findOne.mockResolvedValue(null);
amRepository.findOne.mockResolvedValue(null);
await expect(
service.deleteDossier('2026-999999', staff),
).rejects.toBeInstanceOf(NotFoundException);
});
it('co-parent : delete user seul', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'p2',
role: RoleType.PARENT,
});
parentsRepository.findOne.mockResolvedValue({
user_id: 'p2',
numero_dossier: '2026-000010',
co_parent: { id: 'p1' },
});
parentsRepository.query.mockResolvedValue([
{ id: 'p1' },
{ id: 'p2' },
]);
dataSource.transaction.mockImplementation(async (cb) =>
cb({
delete: jest.fn(),
query: jest.fn(),
}),
);
const res = await service.deleteUser('p2', staff);
expect(res.deleted_enfant_ids).toEqual([]);
expect(res.deleted_user_ids).toEqual(['p2']);
expect(res.message).toMatch(/co-parent/i);
});
});
@@ -1,420 +0,0 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, IsNull, Repository } from 'typeorm';
import { RoleType, Users } from 'src/entities/users.entity';
import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { Children, StatutEnfantType } from 'src/entities/children.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { AmChildren } from 'src/entities/am_children.entity';
export type SuppressionResult = {
type?: 'famille' | 'assistante_maternelle';
numero_dossier?: string;
deleted_user_ids: string[];
deleted_enfant_ids: string[];
dossier_supprime?: boolean;
message: string;
};
const STAFF_METIER: RoleType[] = [
RoleType.GESTIONNAIRE,
RoleType.ADMINISTRATEUR,
RoleType.SUPER_ADMIN,
];
/**
* Cascades de suppression métier — tickets #154 / #159.
*/
@Injectable()
export class SuppressionService {
constructor(
private readonly dataSource: DataSource,
@InjectRepository(Users)
private readonly usersRepository: Repository<Users>,
@InjectRepository(Parents)
private readonly parentsRepository: Repository<Parents>,
@InjectRepository(AssistanteMaternelle)
private readonly amRepository: Repository<AssistanteMaternelle>,
@InjectRepository(Children)
private readonly childrenRepository: Repository<Children>,
@InjectRepository(ParentsChildren)
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
@InjectRepository(AmChildren)
private readonly amChildrenRepository: Repository<AmChildren>,
) {}
assertStaffMetier(currentUser: Users): void {
if (!STAFF_METIER.includes(currentUser.role)) {
throw new ForbiddenException('Accès refusé');
}
}
async deleteDossier(
numeroDossier: string,
currentUser: Users,
): Promise<SuppressionResult> {
this.assertStaffMetier(currentUser);
const num = numeroDossier?.trim();
if (!num) {
throw new BadRequestException('Numéro de dossier requis.');
}
const parentHit = await this.parentsRepository.findOne({
where: { numero_dossier: num },
});
if (parentHit) {
return this.deleteFamilleByNumero(num);
}
const amHit = await this.amRepository.findOne({
where: { numero_dossier: num },
relations: ['user'],
});
if (amHit?.user) {
return this.deleteAmUser(amHit.user.id);
}
throw new NotFoundException('Aucun dossier trouvé pour ce numéro.');
}
async deleteUser(
id: string,
currentUser: Users,
): Promise<SuppressionResult> {
const target = await this.usersRepository.findOne({ where: { id } });
if (!target) {
throw new NotFoundException('Utilisateur introuvable');
}
if (target.id === currentUser.id) {
throw new ForbiddenException('Vous ne pouvez pas supprimer votre propre compte.');
}
if (target.role === RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Le super administrateur ne peut pas être supprimé.');
}
if (target.role === RoleType.PARENT) {
this.assertStaffMetier(currentUser);
return this.deleteParentUser(target.id);
}
if (target.role === RoleType.ASSISTANTE_MATERNELLE) {
this.assertStaffMetier(currentUser);
return this.deleteAmUser(target.id);
}
if (target.role === RoleType.GESTIONNAIRE) {
if (
currentUser.role !== RoleType.ADMINISTRATEUR &&
currentUser.role !== RoleType.SUPER_ADMIN
) {
throw new ForbiddenException(
'Seul un administrateur peut supprimer un gestionnaire.',
);
}
await this.usersRepository.delete(target.id);
return {
deleted_user_ids: [target.id],
deleted_enfant_ids: [],
message: 'Gestionnaire supprimé.',
};
}
if (target.role === RoleType.ADMINISTRATEUR) {
await this.assertCanDeleteAdministrateur(target, currentUser);
await this.usersRepository.delete(target.id);
return {
deleted_user_ids: [target.id],
deleted_enfant_ids: [],
message: 'Administrateur supprimé.',
};
}
throw new BadRequestException('Type dutilisateur non supprimable via cet endpoint.');
}
async deleteEnfant(
enfantId: string,
deleteDossier: boolean,
currentUser: Users,
): Promise<SuppressionResult> {
this.assertStaffMetier(currentUser);
const child = await this.childrenRepository.findOne({
where: { id: enfantId },
relations: ['parentLinks', 'parentLinks.parent'],
});
if (!child) {
throw new NotFoundException('Enfant introuvable');
}
const parentIds = (child.parentLinks ?? [])
.map((l) => l.parentId ?? l.parent?.user_id)
.filter(Boolean) as string[];
let numero: string | undefined;
if (parentIds.length > 0) {
const parents = await this.parentsRepository.find({
where: { user_id: In(parentIds) },
});
numero = parents.map((p) => p.numero_dossier?.trim()).find((n) => !!n);
}
if (!numero) {
await this.closePlacementsForEnfants([enfantId]);
await this.childrenRepository.delete(enfantId);
return {
deleted_user_ids: [],
deleted_enfant_ids: [enfantId],
dossier_supprime: false,
message: 'Enfant supprimé.',
};
}
const siblingIds = await this.listEnfantIdsForNumero(numero);
const isLast = siblingIds.length <= 1;
if (isLast && deleteDossier) {
const result = await this.deleteFamilleByNumero(numero);
return {
...result,
dossier_supprime: true,
message: 'Dernier enfant et dossier famille supprimés.',
};
}
await this.closePlacementsForEnfants([enfantId]);
await this.childrenRepository.delete(enfantId);
return {
deleted_user_ids: [],
deleted_enfant_ids: [enfantId],
dossier_supprime: false,
numero_dossier: numero,
type: 'famille',
message: isLast
? 'Dernier enfant supprimé. Le dossier famille reste sans enfant.'
: 'Enfant supprimé du dossier famille.',
};
}
/** Compte enfants liés à un numero_dossier famille (pour flag sans_enfant). */
async countEnfantsForNumero(numeroDossier: string): Promise<number> {
const ids = await this.listEnfantIdsForNumero(numeroDossier);
return ids.length;
}
private async assertCanDeleteAdministrateur(
target: Users,
currentUser: Users,
): Promise<void> {
if (
currentUser.role !== RoleType.ADMINISTRATEUR &&
currentUser.role !== RoleType.SUPER_ADMIN
) {
throw new ForbiddenException(
'Seul un administrateur peut supprimer un administrateur.',
);
}
const adminCount = await this.usersRepository.count({
where: { role: RoleType.ADMINISTRATEUR },
});
if (adminCount <= 1) {
if (currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException(
'Seul le super administrateur peut supprimer le dernier administrateur.',
);
}
}
}
private async deleteParentUser(userId: string): Promise<SuppressionResult> {
const parent = await this.parentsRepository.findOne({
where: { user_id: userId },
relations: ['co_parent'],
});
if (!parent) {
// Compte parent sans fiche — hard delete user
await this.usersRepository.delete(userId);
return {
deleted_user_ids: [userId],
deleted_enfant_ids: [],
message: 'Parent supprimé.',
};
}
const numero = parent.numero_dossier?.trim();
const foyerIds = numero
? await this.listParentUserIdsForNumero(numero)
: [userId];
const isLast = foyerIds.filter((id) => id !== userId).length === 0;
if (!isLast) {
// Co-parent : retirer liens enfants de ce parent, clear co_parent refs, delete user
await this.dataSource.transaction(async (manager) => {
await manager.delete(ParentsChildren, { parentId: userId });
await manager.query(
`UPDATE parents SET id_co_parent = NULL WHERE id_co_parent = $1 OR id_utilisateur = $1`,
[userId],
);
await manager.delete(Users, { id: userId });
});
return {
deleted_user_ids: [userId],
deleted_enfant_ids: [],
numero_dossier: numero,
type: 'famille',
message: 'Parent retiré du dossier (co-parent).',
};
}
// Dernier parent : + enfants
const enfantIds = numero
? await this.listEnfantIdsForNumero(numero)
: await this.listEnfantIdsForParent(userId);
await this.closePlacementsForEnfants(enfantIds);
await this.dataSource.transaction(async (manager) => {
if (enfantIds.length) {
await manager.delete(Children, { id: In(enfantIds) });
}
await manager.query(
`UPDATE parents SET id_co_parent = NULL WHERE id_utilisateur = $1 OR id_co_parent = $1`,
[userId],
);
await manager.delete(Users, { id: userId });
});
return {
deleted_user_ids: [userId],
deleted_enfant_ids: enfantIds,
numero_dossier: numero,
type: 'famille',
message: 'Dernier parent et enfants rattachés supprimés.',
};
}
private async deleteFamilleByNumero(numero: string): Promise<SuppressionResult> {
const parentIds = await this.listParentUserIdsForNumero(numero);
if (parentIds.length === 0) {
throw new NotFoundException('Aucun parent pour ce dossier.');
}
const enfantIds = await this.listEnfantIdsForNumero(numero);
await this.closePlacementsForEnfants(enfantIds);
await this.dataSource.transaction(async (manager) => {
if (enfantIds.length) {
await manager.delete(Children, { id: In(enfantIds) });
}
await manager.query(
`UPDATE parents SET id_co_parent = NULL WHERE id_utilisateur = ANY($1::uuid[]) OR id_co_parent = ANY($1::uuid[])`,
[parentIds],
);
await manager.delete(Users, { id: In(parentIds) });
});
return {
type: 'famille',
numero_dossier: numero,
deleted_user_ids: parentIds,
deleted_enfant_ids: enfantIds,
message: 'Dossier famille supprimé.',
};
}
private async deleteAmUser(userId: string): Promise<SuppressionResult> {
const am = await this.amRepository.findOne({ where: { user_id: userId } });
const numero = am?.numero_dossier?.trim();
const active = await this.amChildrenRepository.find({
where: { amId: userId, date_fin: IsNull() },
relations: ['child'],
});
const now = new Date();
for (const link of active) {
link.date_fin = now;
await this.amChildrenRepository.save(link);
if (link.child) {
await this.applySansGarde(link.child);
}
}
await this.usersRepository.delete(userId);
return {
type: 'assistante_maternelle',
numero_dossier: numero,
deleted_user_ids: [userId],
deleted_enfant_ids: [],
message: 'Dossier assistante maternelle supprimé.',
};
}
private async applySansGarde(child: Children): Promise<void> {
if (
child.status === StatutEnfantType.A_NAITRE ||
child.status === StatutEnfantType.SCOLARISE
) {
return;
}
const remaining = await this.amChildrenRepository.count({
where: { enfantId: child.id, date_fin: IsNull() },
});
if (remaining === 0) {
child.status = StatutEnfantType.SANS_GARDE;
await this.childrenRepository.save(child);
}
}
private async closePlacementsForEnfants(enfantIds: string[]): Promise<void> {
if (!enfantIds.length) return;
const links = await this.amChildrenRepository.find({
where: { enfantId: In(enfantIds), date_fin: IsNull() },
relations: ['child'],
});
const now = new Date();
for (const link of links) {
link.date_fin = now;
await this.amChildrenRepository.save(link);
if (link.child) {
await this.applySansGarde(link.child);
}
}
}
private async listParentUserIdsForNumero(numero: string): Promise<string[]> {
const rows: Array<{ id: string }> = await this.parentsRepository.query(
`
SELECT DISTINCT x.id::text AS id FROM (
SELECT id_utilisateur AS id FROM parents WHERE TRIM(numero_dossier) = $1
UNION
SELECT id_co_parent AS id FROM parents
WHERE TRIM(numero_dossier) = $1 AND id_co_parent IS NOT NULL
UNION
SELECT p2.id_utilisateur AS id FROM parents p1
JOIN parents p2 ON p2.id_utilisateur = p1.id_co_parent
WHERE TRIM(p1.numero_dossier) = $1
) x WHERE x.id IS NOT NULL
`,
[numero],
);
return rows.map((r) => r.id);
}
private async listEnfantIdsForNumero(numero: string): Promise<string[]> {
const parentIds = await this.listParentUserIdsForNumero(numero);
if (!parentIds.length) return [];
return this.listEnfantIdsForParents(parentIds);
}
private async listEnfantIdsForParent(parentId: string): Promise<string[]> {
return this.listEnfantIdsForParents([parentId]);
}
private async listEnfantIdsForParents(parentIds: string[]): Promise<string[]> {
const links = await this.parentsChildrenRepository.find({
where: { parentId: In(parentIds) },
});
return [...new Set(links.map((l) => l.enfantId))];
}
}
@@ -1,25 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Users } from 'src/entities/users.entity';
import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { Children } from 'src/entities/children.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { AmChildren } from 'src/entities/am_children.entity';
import { SuppressionService } from './suppression.service';
@Module({
imports: [
TypeOrmModule.forFeature([
Users,
Parents,
AssistanteMaternelle,
Children,
ParentsChildren,
AmChildren,
]),
],
providers: [SuppressionService],
exports: [SuppressionService],
})
export class SuppressionsModule {}
@@ -1,21 +1,20 @@
import 'reflect-metadata'; import { Test, TestingModule } from '@nestjs/testing';
import { GestionnairesController } from './gestionnaires.controller'; import { GestionnairesController } from './gestionnaires.controller';
import { RoleType } from 'src/entities/users.entity'; import { GestionnairesService } from './gestionnaires.service';
describe('GestionnairesController roles (#161)', () => { describe('GestionnairesController', () => {
it('POST /gestionnaires autorise SUPER_ADMIN et ADMINISTRATEUR', () => { let controller: GestionnairesController;
const roles = Reflect.getMetadata('roles', GestionnairesController.prototype.create);
expect(roles).toEqual( beforeEach(async () => {
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]), const module: TestingModule = await Test.createTestingModule({
); controllers: [GestionnairesController],
expect(roles).not.toContain(RoleType.GESTIONNAIRE); providers: [GestionnairesService],
}).compile();
controller = module.get<GestionnairesController>(GestionnairesController);
}); });
it('PATCH /gestionnaires/:id autorise SUPER_ADMIN et ADMINISTRATEUR', () => { it('should be defined', () => {
const roles = Reflect.getMetadata('roles', GestionnairesController.prototype.update); expect(controller).toBeDefined();
expect(roles).toEqual(
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
);
expect(roles).not.toContain(RoleType.GESTIONNAIRE);
}); });
}); });
@@ -25,10 +25,10 @@ import { AuthGuard } from 'src/common/guards/auth.guard';
export class GestionnairesController { export class GestionnairesController {
constructor(private readonly gestionnairesService: GestionnairesService) { } constructor(private readonly gestionnairesService: GestionnairesService) { }
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) @Roles(RoleType.SUPER_ADMIN)
@ApiResponse({ status: 201, description: 'Le gestionnaire a été créé avec succès.', type: Users }) @ApiResponse({ status: 201, description: 'Le gestionnaire a été créé avec succès.', type: Users })
@ApiResponse({ status: 409, description: 'Conflit. L\'email est déjà utilisé.' }) @ApiResponse({ status: 409, description: 'Conflit. L\'email est déjà utilisé.' })
@ApiOperation({ summary: 'Création d\'un gestionnaire (admin / super admin)' }) @ApiOperation({ summary: 'Création d\'un gestionnaire' })
@ApiBody({ type: CreateGestionnaireDto }) @ApiBody({ type: CreateGestionnaireDto })
@Post() @Post()
create(@Body() dto: CreateGestionnaireDto): Promise<Users> { create(@Body() dto: CreateGestionnaireDto): Promise<Users> {
@@ -43,7 +43,7 @@ export class GestionnairesController {
return this.gestionnairesService.findAll(); return this.gestionnairesService.findAll();
} }
@Roles(RoleType.GESTIONNAIRE, RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) @Roles(RoleType.GESTIONNAIRE, RoleType.SUPER_ADMIN)
@ApiOperation({ summary: 'Récupérer un gestionnaire par ID' }) @ApiOperation({ summary: 'Récupérer un gestionnaire par ID' })
@ApiResponse({ status: 400, description: 'ID invalide' }) @ApiResponse({ status: 400, description: 'ID invalide' })
@ApiResponse({ status: 403, description: 'Accès refusé' }) @ApiResponse({ status: 403, description: 'Accès refusé' })
@@ -56,8 +56,8 @@ export class GestionnairesController {
return this.gestionnairesService.findOne(id); return this.gestionnairesService.findOne(id);
} }
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) @Roles(RoleType.SUPER_ADMIN)
@ApiOperation({ summary: 'Mettre à jour un gestionnaire (admin / super admin)' }) @ApiOperation({ summary: 'Mettre à jour un gestionnaire' })
@ApiResponse({ status: 200, description: 'Le gestionnaire a été mis à jour avec succès.', type: Users }) @ApiResponse({ status: 200, description: 'Le gestionnaire a été mis à jour avec succès.', type: Users })
@ApiResponse({ status: 404, description: 'Gestionnaire non trouvé' }) @ApiResponse({ status: 404, description: 'Gestionnaire non trouvé' })
@ApiResponse({ status: 403, description: 'Accès refusé' }) @ApiResponse({ status: 403, description: 'Accès refusé' })
@@ -1,13 +1,20 @@
import 'reflect-metadata'; import { Test, TestingModule } from '@nestjs/testing';
import { UserController } from './user.controller'; import { UserController } from './user.controller';
import { RoleType } from 'src/entities/users.entity'; import { UserService } from './user.service';
describe('UserController roles (#161)', () => { describe('UserController', () => {
it('POST /users/admin autorise SUPER_ADMIN et ADMINISTRATEUR', () => { let controller: UserController;
const roles = Reflect.getMetadata('roles', UserController.prototype.createAdmin);
expect(roles).toEqual( beforeEach(async () => {
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]), const module: TestingModule = await Test.createTestingModule({
); controllers: [UserController],
expect(roles).not.toContain(RoleType.GESTIONNAIRE); providers: [UserService],
}).compile();
controller = module.get<UserController>(UserController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
}); });
}); });
+8 -20
View File
@@ -10,22 +10,18 @@ import { CreateUserDto } from './dto/create_user.dto';
import { CreateAdminDto } from './dto/create_admin.dto'; import { CreateAdminDto } from './dto/create_admin.dto';
import { UpdateUserDto } from './dto/update_user.dto'; import { UpdateUserDto } from './dto/update_user.dto';
import { AffecterNumeroDossierDto } from './dto/affecter-numero-dossier.dto'; import { AffecterNumeroDossierDto } from './dto/affecter-numero-dossier.dto';
import { SuppressionService } from '../suppressions/suppression.service';
@ApiTags('Utilisateurs') @ApiTags('Utilisateurs')
@ApiBearerAuth('access-token') @ApiBearerAuth('access-token')
@UseGuards(AuthGuard, RolesGuard) @UseGuards(AuthGuard, RolesGuard)
@Controller('users') @Controller('users')
export class UserController { export class UserController {
constructor( constructor(private readonly userService: UserService) { }
private readonly userService: UserService,
private readonly suppressionService: SuppressionService,
) { }
// Création d'un administrateur (admin + super admin) — #161 // Création d'un administrateur (réservée aux super admins)
@Post('admin') @Post('admin')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) @Roles(RoleType.SUPER_ADMIN)
@ApiOperation({ summary: 'Créer un nouvel administrateur (admin / super admin)' }) @ApiOperation({ summary: 'Créer un nouvel administrateur (super admin seulement)' })
createAdmin( createAdmin(
@Body() dto: CreateAdminDto, @Body() dto: CreateAdminDto,
@User() currentUser: Users @User() currentUser: Users
@@ -150,20 +146,12 @@ export class UserController {
return this.userService.suspendUser(id, currentUser, comment); return this.userService.suspendUser(id, currentUser, comment);
} }
// Supprimer un utilisateur — cascades métier #159 // Supprimer un utilisateur (super_admin uniquement)
@Delete(':id') @Delete(':id')
@Roles( @Roles(RoleType.SUPER_ADMIN)
RoleType.SUPER_ADMIN, @ApiOperation({ summary: 'Supprimer un utilisateur' })
RoleType.ADMINISTRATEUR,
RoleType.GESTIONNAIRE,
)
@ApiOperation({
summary: 'Supprimer un utilisateur (cascades métier #159)',
description:
'Parent / AM / staff selon matrice. Gestionnaire ne peut pas supprimer un autre gestionnaire. Self interdit.',
})
@ApiParam({ name: 'id', description: "UUID de l'utilisateur" }) @ApiParam({ name: 'id', description: "UUID de l'utilisateur" })
remove(@Param('id') id: string, @User() currentUser: Users) { remove(@Param('id') id: string, @User() currentUser: Users) {
return this.suppressionService.deleteUser(id, currentUser); return this.userService.remove(id, currentUser);
} }
} }
-2
View File
@@ -12,7 +12,6 @@ import { Parents } from 'src/entities/parents.entity';
import { GestionnairesModule } from './gestionnaires/gestionnaires.module'; import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
import { MailModule } from 'src/modules/mail/mail.module'; import { MailModule } from 'src/modules/mail/mail.module';
import { AppConfigModule } from 'src/modules/config/config.module'; import { AppConfigModule } from 'src/modules/config/config.module';
import { SuppressionsModule } from '../suppressions/suppressions.module';
@Module({ @Module({
imports: [TypeOrmModule.forFeature( imports: [TypeOrmModule.forFeature(
@@ -27,7 +26,6 @@ import { SuppressionsModule } from '../suppressions/suppressions.module';
GestionnairesModule, GestionnairesModule,
MailModule, MailModule,
AppConfigModule, AppConfigModule,
SuppressionsModule,
], ],
controllers: [UserController], controllers: [UserController],
providers: [UserService], providers: [UserService],
+9 -78
View File
@@ -1,87 +1,18 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing';
import { UserService } from './user.service'; import { UserService } from './user.service';
import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
describe('UserService.createAdmin (#161)', () => {
const usersRepository = {
findOneBy: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};
describe('UserService', () => {
let service: UserService; let service: UserService;
const dto = { beforeEach(async () => {
email: 'nouveau.admin@ptits-pas.fr', const module: TestingModule = await Test.createTestingModule({
password: 'Password1!', providers: [UserService],
prenom: 'Nina', }).compile();
nom: 'Admin',
telephone: '0601020304',
};
beforeEach(() => { service = module.get<UserService>(UserService);
jest.clearAllMocks();
service = new UserService(
usersRepository as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
}); });
it('autorise un administrateur à créer un admin', async () => { it('should be defined', () => {
usersRepository.findOneBy.mockResolvedValue(null); expect(service).toBeDefined();
usersRepository.create.mockImplementation((data) => data);
usersRepository.save.mockImplementation(async (entity) => ({
id: 'new-admin',
...entity,
}));
const result = await service.createAdmin(dto as never, {
id: 'admin-1',
role: RoleType.ADMINISTRATEUR,
} as never);
expect(result.role).toBe(RoleType.ADMINISTRATEUR);
expect(result.statut).toBe(StatutUtilisateurType.ACTIF);
expect(usersRepository.save).toHaveBeenCalled();
});
it('autorise un super_admin à créer un admin', async () => {
usersRepository.findOneBy.mockResolvedValue(null);
usersRepository.create.mockImplementation((data) => data);
usersRepository.save.mockImplementation(async (entity) => ({
id: 'new-admin',
...entity,
}));
await expect(
service.createAdmin(dto as never, {
id: 'sa-1',
role: RoleType.SUPER_ADMIN,
} as never),
).resolves.toMatchObject({ role: RoleType.ADMINISTRATEUR });
});
it('refuse un gestionnaire (403 métier)', async () => {
await expect(
service.createAdmin(dto as never, {
id: 'gest-1',
role: RoleType.GESTIONNAIRE,
} as never),
).rejects.toBeInstanceOf(ForbiddenException);
expect(usersRepository.save).not.toHaveBeenCalled();
});
it('refuse un email déjà utilisé', async () => {
usersRepository.findOneBy.mockResolvedValue({ id: 'exists' });
await expect(
service.createAdmin(dto as never, {
id: 'admin-1',
role: RoleType.ADMINISTRATEUR,
} as never),
).rejects.toBeInstanceOf(BadRequestException);
}); });
}); });
+2 -9
View File
@@ -117,14 +117,8 @@ export class UserService {
} }
async createAdmin(dto: CreateAdminDto, currentUser: Users): Promise<Users> { async createAdmin(dto: CreateAdminDto, currentUser: Users): Promise<Users> {
// #161 — admin et super_admin peuvent créer un administrateur if (currentUser.role !== RoleType.SUPER_ADMIN) {
if ( throw new ForbiddenException('Seuls les super administrateurs peuvent créer un administrateur');
currentUser.role !== RoleType.SUPER_ADMIN &&
currentUser.role !== RoleType.ADMINISTRATEUR
) {
throw new ForbiddenException(
'Seuls les administrateurs et super administrateurs peuvent créer un administrateur',
);
} }
const exist = await this.usersRepository.findOneBy({ email: dto.email }); const exist = await this.usersRepository.findOneBy({ email: dto.email });
@@ -526,7 +520,6 @@ export class UserService {
} }
async remove(id: string, currentUser: Users): Promise<void> { async remove(id: string, currentUser: Users): Promise<void> {
// Délégué historiquement ; préférer SuppressionService via controller (#159).
if (currentUser.role !== RoleType.SUPER_ADMIN) { if (currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Accès réservé aux super admins'); throw new ForbiddenException('Accès réservé aux super admins');
} }
+2 -1
View File
@@ -174,7 +174,8 @@ CREATE TABLE enfants (
date_prevue_naissance DATE, date_prevue_naissance DATE,
photo_url TEXT, photo_url TEXT,
consentement_photo BOOLEAN DEFAULT false, consentement_photo BOOLEAN DEFAULT false,
date_consentement_photo TIMESTAMPTZ date_consentement_photo TIMESTAMPTZ,
est_multiple BOOLEAN DEFAULT false
); );
-- ========================================================== -- ==========================================================
+10 -10
View File
@@ -1,10 +1,10 @@
"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo" "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, "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, "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, "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, "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, "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, "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, "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, "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, "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 Emma Dupont F 2020-06-01 False False
3 a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d sans_garde 2020-01-01 2025-01-01 False False
4 e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c sans_garde Emma Martin 2023-02-15 False False
5 e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d sans_garde Noah Martin 2023-02-15 False False
6 e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e sans_garde Léa Martin 2023-02-15 False False
7 e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f sans_garde Chloé Rousseau 2022-04-20 False False
8 e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a sans_garde Hugo Rousseau 2024-03-10 False False
9 e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b sans_garde Maxime Lecomte 2023-04-15 False False
10 edd19cd1-bb67-4f14-8a37-c66b75c94537 scolarise Lucas Durand H 2018-09-15 False False
@@ -1,2 +0,0 @@
-- #152 — Suppression grossesse multiple / est_multiple
ALTER TABLE enfants DROP COLUMN IF EXISTS est_multiple;
+4 -4
View File
@@ -69,12 +69,12 @@ ON CONFLICT (id_utilisateur) DO NOTHING;
-- - child B : à naître (statut = 'a_naitre' et date_prevue_naissance requise) -- - child B : à naître (statut = 'a_naitre' et date_prevue_naissance requise)
-- ------------------------------------------------------------ -- ------------------------------------------------------------
INSERT INTO enfants (id, prenom, nom, statut, date_naissance) 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') VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'sans_garde', '2022-04-12', false)
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance) INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance, jumeau_multiple)
VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Mila', 'Parent', 'a_naitre', '2026-02-15') VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Mila', 'Parent', 'a_naitre', '2026-02-15', false)
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
-- ------------------------------------------------------------ -- ------------------------------------------------------------
+7 -7
View File
@@ -49,14 +49,14 @@ VALUES
ON CONFLICT (id_utilisateur) DO NOTHING; ON CONFLICT (id_utilisateur) DO NOTHING;
-- ========== ENFANTS ========== -- ========== ENFANTS ==========
INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut) INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut, est_multiple)
VALUES VALUES
('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'sans_garde'), ('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'), ('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'), ('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'), ('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'), ('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') ('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'sans_garde', false)
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
-- ========== ENFANTS_PARENTS (liaison N:N) ========== -- ========== ENFANTS_PARENTS (liaison N:N) ==========
+1
View File
@@ -117,6 +117,7 @@ Table des enfants pris en charge.
| `photo_url` | TEXT | | URL de la photo | | `photo_url` | TEXT | | URL de la photo |
| `consentement_photo` | BOOLEAN | DEFAULT false | Consentement photo | | `consentement_photo` | BOOLEAN | DEFAULT false | Consentement photo |
| `date_consentement_photo` | TIMESTAMPTZ | | Date du consentement | | `date_consentement_photo` | TIMESTAMPTZ | | Date du consentement |
| `est_multiple` | BOOLEAN | DEFAULT false | Indique si grossesse multiple |
--- ---
+3
View File
@@ -276,6 +276,9 @@ export class Enfants {
@Column({ name: 'consentement_photo', type: 'boolean', default: false }) @Column({ name: 'consentement_photo', type: 'boolean', default: false })
consentementPhoto: boolean; consentementPhoto: boolean;
@Column({ name: 'est_multiple', type: 'boolean', default: false })
estMultiple: boolean;
@Column({ @Column({
type: 'enum', type: 'enum',
enum: StatutEnfantType, enum: StatutEnfantType,
@@ -1,104 +0,0 @@
# Analyse dempreinte — P'titsPas
**Date :** 2026-07-22
**Contexte :** snapshot pour mémoire (après alerte disque plein + purge cache Gitea)
**Périmètre :** application `jmartin/petitspas` déployée sur le VPS (`/home/deploy/dev/ptitspas-app`)
---
## 1. Lignes de code
Comptage brut (`wc -l`), hors `node_modules` / builds / `.dart_tool`.
| Zone | Lignes | Fichiers |
|------|--------|----------|
| **Frontend** (Dart `frontend/lib/`) | ~29 800 | 141 |
| **Backend** (TypeScript `backend/src/`) | ~10 000 | 141 |
| **BDD** (SQL sous `database/`) | ~1 250 | ~15 |
| **Total** | **~41 000** | |
### Frontend (détail)
| Dossier | Lignes |
|---------|--------|
| `widgets/` | ~18 400 |
| `screens/` | ~5 000 |
| `services/` | ~2 300 |
| `models/` | ~2 200 |
| `utils/` | ~1 400 |
| reste | ~500 |
### Backend (détail)
| Dossier | Lignes |
|---------|--------|
| `routes/` | ~6 500 |
| `modules/` | ~1 700 |
| `entities/` | ~1 000 |
| `common/` + `config/` | ~400 |
### BDD (détail)
| Élément | Lignes |
|---------|--------|
| `BDD.sql` (schéma) | ~470 |
| seeds | ~300 |
| migrations / patches | ~280 |
| tests SQL | ~205 |
---
## 2. Empreinte disque — runtime (Docker)
| Composant | Taille | Notes |
|-----------|--------|--------|
| Image `ptitspas-app-backend` | ~300 Mo | NestJS |
| Image `ptitspas-app-frontend` | ~122 Mo | Flutter web + Nginx |
| Image `postgres:17` | ~454 Mo | |
| Image `dpage/pgadmin4` | ~534 Mo | optionnel |
| Volume `postgres_data` | ~49 Mo | données BDD live |
| Volume `backend_uploads` | ~20 Mo | photos |
| Volume `backend_documents_legaux` | ~0 | |
| **Stack complète (avec pgAdmin)** | **~1,5 Go** | |
| **Stack prod sans pgAdmin** | **~945 Mo** | |
---
## 3. Empreinte disque — code source
| Élément | Taille |
|---------|--------|
| Clone `/home/deploy/dev/ptitspas-app` | ~701 Mo |
| dont `backend/node_modules` | ~354 Mo |
| dont `frontend` (+ `.dart_tool`) | ~114 Mo |
| **Code utile** (hors deps / `.git` / builds) | **~51 Mo** |
| Repo Gitea `petitspas.git` | ~109 Mo |
| Dump `database/BDD.sql` | ~19 Ko |
---
## 4. Pour (re)déployer
Minimum requis :
1. Images custom backend + frontend (~422 Mo), ou code + build Docker
2. Image `postgres:17` (~454 Mo)
3. Volumes persistants (~70 Mo au snapshot)
4. Optionnel : pgAdmin (~534 Mo)
**Ordre de grandeur :** ~1 Go pour faire tourner lapp en prod (sans pgAdmin).
---
## 5. Notes infra du jour (lié)
- Disque VPS passé de **100 %** à ~**44 %** après :
- purge cache Docker / journals
- purge officielle Gitea `delete_repo_archives` (~24 Go de zip/bundle `petitspas`)
- Crons Gitea activés :
- `archive_cleanup` @midnight (`OLDER_THAN = 24h`)
- `delete_repo_archives` @weekly
---
*Fichier généré pour historique projet — ne pas considérer comme métrique CI automatisée.*
-89
View File
@@ -1,89 +0,0 @@
# Mini-spec API — POST /parents/dossier (#129)
Contrat pour le **plan front** (wizard création dossier famille staff).
Miroir de **#156** (`POST /assistantes-maternelles/dossier`).
## Endpoint
| | |
|--|--|
| **Méthode** | `POST` |
| **URL** | `{base}/parents/dossier` |
| **Auth** | Bearer JWT |
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
| **Content-Type** | `application/json` |
Ne **pas** appeler `POST /auth/register/parent` depuis le dashboard.
## Body (JSON)
Aligné `RegisterParentCompletDto`, **sans** CGU/privacy obligatoires (acceptées serveur).
### Parent 1 (obligatoire)
| Champ | Type | Obligatoire | Notes |
|-------|------|-------------|--------|
| `email` | string | oui | unique |
| `prenom` | string | oui | |
| `nom` | string | oui | |
| `telephone` | string | oui | `0X…` ou `+33…` |
| `adresse` | string | non | |
| `code_postal` | string | non | |
| `ville` | string | non | |
### Co-parent (optionnel)
`co_parent_email`, `co_parent_prenom`, `co_parent_nom`, `co_parent_telephone`,
`co_parent_meme_adresse`, `co_parent_adresse`, `co_parent_code_postal`, `co_parent_ville`.
Si co-parent fourni : e-mail distinct ; mêmes règles téléphone / adresse que register.
### Enfants (≥ 1)
| Champ | Type | Notes |
|-------|------|--------|
| `enfants` | `EnfantInscriptionDto[]` | `prenom`, `nom`, `date_naissance` / `date_previsionnelle_naissance`, `genre`, `photo_base64`, `photo_filename`, etc. |
### Présentation
| Champ | Type | Obligatoire |
|-------|------|-------------|
| `presentation_dossier` | string | non (max 2000) |
## Réponses
### 201 Created
```json
{
"message": "Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.",
"numero_dossier": "2026-000043",
"parent_user_id": "uuid-pivot",
"co_parent_user_id": "uuid-ou-null",
"statut": "actif",
"enfant_ids": ["uuid", "..."]
}
```
Effets serveur : user(s) parent **actif**, fiches `parents`, enfants + foyer, n° dossier,
**e-mail création MDP** pour chaque compte sans MDP (pas daccusé « en attente »).
### Erreurs
| Code | Cas |
|------|-----|
| 400 | Validation DTO / métier (enfants vides, dates, etc.) |
| 401 | Token manquant / invalide |
| 403 | Rôle non staff |
| 409 | Conflit e-mail (pivot et/ou co-parent) |
## Front
- `UserService.createParentDossier(body)` → cet endpoint
- Wizard create basé sur `ValidationFamilyWizard`
- Ne pas envoyer `acceptation_cgu` / `acceptation_privacy` (optionnels)
## Branche
`feature/129-creation-dossier-parent`
@@ -1,73 +0,0 @@
# Mini-spec API — POST /parents/:id/co-parent (#135)
Contrat back pour lajout dun **2ᵉ parent** sur un foyer mono-parent (staff).
## Endpoint
| | |
|--|--|
| **Méthode** | `POST` |
| **URL** | `{base}/api/v1/parents/{parentUserId}/co-parent` |
| **Auth** | Bearer JWT |
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
| **Succès** | **201** |
`parentUserId` = UUID du **parent pivot** (déjà dans le dossier).
Ne **pas** appeler `POST /auth/register/parent` ni `POST /parents/dossier`.
---
## Body (JSON)
| Champ | Type | Obligatoire | Notes |
|-------|------|-------------|--------|
| `email` | string | oui | unique |
| `prenom` | string | oui | |
| `nom` | string | oui | |
| `telephone` | string | oui | `0X…` ou `+33…` |
| `meme_adresse` | bool | non | défaut **true** → copie adresse du pivot |
| `adresse` | string | si `meme_adresse=false` | |
| `code_postal` | string | si `meme_adresse=false` | |
| `ville` | string | si `meme_adresse=false` | |
---
## Comportement 201
- User co-parent **actif** + token création MDP
- Fiche `parents` + liens pivot ↔ co-parent + même `numero_dossier`
- Enfants du foyer rattachés au co-parent
- E-mail **création MDP** (pas mail « en attente »)
```json
{
"message": "Co-parent ajouté au foyer. Un e-mail de création de mot de passe a été envoyé.",
"numero_dossier": "2026-000043",
"parent_user_id": "uuid-pivot",
"co_parent_user_id": "uuid-co",
"statut": "actif"
}
```
## Erreurs
| Code | Cas |
|------|-----|
| 400 | Déjà un co-parent / 2 responsables / validation adresse |
| 401 | Token invalide |
| 403 | Rôle non staff |
| 404 | Pivot introuvable |
| 409 | Email déjà pris |
## Réemploi édition identité
| Endpoint | Usage |
|----------|--------|
| `GET /dossiers/:numero` | Préremplir wizard edit |
| `PATCH /parents/:id/fiche` | Sauver identité pivot / co-parent existant |
| `PATCH /assistantes-maternelles/:id/fiche` | Édition AM |
## Branche
`feature/135-edition-dossier`
@@ -1,83 +0,0 @@
# Mini-spec front — Mode édition dossier + ajout 2ᵉ parent (#135)
Branche : `feature/135-edition-dossier`
Ticket : **#135** (full-stack)
Prérequis : **#153** (liste Dossiers) livré.
---
## Objectif
1. Clic sur un dossier (liste #153) → ouvrir le wizard en mode **`edit`**
2. Foyer **mono-parent** : page co-parent → **switch** ajouter un 2ᵉ parent
3. Sauvegarder les champs via APIs existantes + nouvel endpoint co-parent
---
## Modes wizard
| Mode | Famille | AM |
|------|---------|-----|
| `review` | déjà | déjà |
| `create` | déjà (#129) | déjà (#156) |
| **`edit`** | **à faire** | **à faire** |
Factories : `ParentDossierWizard.edit(...)` / `AmDossierWizard.edit(...)`
Préremplir via `UserService.getDossierByNumero(numero)`.
---
## APIs
| Action | Endpoint |
|--------|----------|
| Charger | `GET /dossiers/:numero` |
| Sauver parent | `PATCH /parents/:id/fiche` |
| Sauver AM | `PATCH /assistantes-maternelles/:id/fiche` |
| **Ajouter co-parent** | **`POST /parents/:pivotUserId/co-parent`** — voir `docs/tmp/135-contrat-api-ajout-co-parent.md` |
Body co-parent :
```json
{
"email": "thomas@…",
"prenom": "Thomas",
"nom": "MARTIN",
"telephone": "0678456789",
"meme_adresse": true
}
```
`UserService.addCoParent(pivotUserId, body)` → cet endpoint.
---
## UX
- Depuis `DossiersManagementWidget` / carte liste : clic → edit (plus seulement review pending)
- Pending : garder validation (review) ; dossiers actifs → edit
- Mono-parent : switch « Ajouter un co-parent » (comme create) → au save, `POST …/co-parent` si nouveau
- Déjà 2 parents : éditer les deux fiches ; pas de 3ᵉ
- Pas de bouton créer dans longlet Dossiers
---
## Hors scope
- Famille N responsables (#139)
- Suppressions (#154)
- Création dossier initial (#129 / #156)
---
## Critères dacceptation
- [ ] Clic dossier actif → wizard edit prérempli
- [ ] PATCH fiche enregistre les modifs
- [ ] Mono-parent + switch → co-parent créé (actif + mail MDP)
- [ ] review / create inchangés
## Branche
`feature/135-edition-dossier`
@@ -1,65 +0,0 @@
# Mini-spec — Suppression complète grossesse multiple / `est_multiple`
**Ticket** : **#152** — https://git.ptits-pas.fr/jmartin/petitspas/issues/152
**Branche** : `feature/152-remove-est-multiple` (depuis `develop`)
**Périmètre** : **full stack** — BDD + back + front + scripts + docs. **Aucun fantôme.**
---
## Décision
On **supprime tout**. Pas de DTO « ignorés », pas de compat payload.
`forbidNonWhitelisted: true` ⇒ back et front **partent ensemble** (même feature / même déploiement).
---
## Alias retirés
`est_multiple` · `is_multiple` · `grossesse_multiple` · `multipleBirth` · `estMultiple` · `isMultiple` · `jumeau_multiple`
---
## Back / BDD (fait sur la branche)
- [x] Migration `database/migrations/2026_drop_enfants_est_multiple.sql`
- [x] `BDD.sql`, seeds, CSV test
- [x] Entity `Children` sans colonne
- [x] DTO create/inscription/réponse/dossier famille **sans** le champ
- [x] Services auth / enfants / parents : plus de mapping
- [x] Prisma legacy `isMultiple` retiré
## Front (fait sur la branche)
- [x] Modèles admin / dossier / inscription
- [x] Payloads inscription + reprise
- [x] Modale enfant + wizard dossier famille
- [x] Step3 inscription parent
## Scripts / docs
- [x] `tests/scripts/register-parent-*.mjs`
- [x] `docs/10_DATABASE.md`, `docs/99_REGLES-CODAGE.md`
- Docs tmp/archive #112 : mentions historiques OK (archive)
---
## Déploiement
1. Appliquer la migration SQL sur la BDD vivante
2. Deploy back **et** front de cette branche
3. Smoke : création enfant staff, inscription parent, reprise, wizard famille
## Vérif
```bash
rg -n 'est_multiple|is_multiple|grossesse_multiple|multipleBirth|estMultiple|isMultiple|jumeau_multiple' \
backend/src frontend/lib database tests/scripts docs/10_DATABASE.md docs/99_REGLES-CODAGE.md
```
**0** hit (hors ce fichier mini-spec / archives).
## Hors scope
- Métier futur « fratrie / jumeaux » → nouveau ticket
- #155 rename Admin*
- Ticket modales staff
@@ -1,77 +0,0 @@
# Mini-spec API — GET /dossiers (#153)
Contrat pour le **plan front** (onglet permanent Dossiers).
## Endpoint
| | |
|--|--|
| **Méthode** | `GET` |
| **URL** | `{base}/api/v1/dossiers` |
| **Auth** | Bearer JWT |
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
| **Query** | `q` (optionnel) — recherche n° / libellé / email |
Complète `GET /dossiers/:numeroDossier` (#119) déjà existant.
---
## Réponse 200
Tableau de lignes (1 entrée = 1 `numero_dossier`) :
```json
[
{
"type": "famille",
"numero_dossier": "2026-000043",
"libelle": "Claire MARTIN & Thomas MARTIN",
"emails": ["claire@test.fr", "thomas@test.fr"],
"user_ids": ["uuid-pivot", "uuid-co"],
"statut": "actif",
"a_valider": false,
"date_reference": "2026-01-12T10:00:00.000Z"
},
{
"type": "assistante_maternelle",
"numero_dossier": "2026-000042",
"libelle": "Marie DUPONT",
"emails": ["marie@test.fr"],
"user_ids": ["uuid-am"],
"statut": "en_attente",
"a_valider": true,
"date_reference": "2026-02-01T08:00:00.000Z"
}
]
```
### Champs
| Champ | Notes |
|-------|--------|
| `type` | `famille` \| `assistante_maternelle` |
| `numero_dossier` | Clé dunité |
| `libelle` | Noms formatés (foyer : `A & B`) |
| `emails` / `user_ids` | Membres du foyer ou AM |
| `statut` | Agrégé : `en_attente` si au moins un user pending |
| `a_valider` | `true` si pending → section haute UI |
| `date_reference` | `MIN(cree_le)` des users |
**Tri** : `a_valider` dabord, puis `numero_dossier` décroissant.
**Famille** : dédupliquée par `numero_dossier` (pivot + co-parent = 1 ligne).
---
## Front
- `UserService.getDossiers({ q? })` → cet endpoint
- Section haute : filtrer `a_valider == true` **ou** continuer pending APIs existantes
- Section basse : liste complète (ou hors pending selon règle UX)
- Clic → `GET /dossiers/:numero` (détail) / validation review
Composition client `getParents`+`getAM` **plus nécessaire** si cet endpoint est déployé.
## Branche
`feature/153-onglet-dossiers`
-167
View File
@@ -1,167 +0,0 @@
# Mini-spec front — Onglet permanent « Dossiers » (#153)
Branche Git (front + back) : `feature/153-onglet-dossiers`
Ticket Gitea : **#153** (ticket normal, plus epic)
> Suite prévue : **#135** = au clic, mode **édition** wizard + ajout 2ᵉ parent.
> **#153** = onglet + listes + navigation / validation pending. **Pas** de création, **pas** d’édition complète.
---
## Contexte / objectif
Remplacer longlet conditionnel **« À valider »** (apparaît/disparaît selon pending) par un onglet **permanent « Dossiers »** dans le dashboard admin/gestionnaire.
Quand on ouvre **Dossiers** :
1. **En haut** — section **Dossiers à valider** (AM + familles pending)
2. **En dessous** — liste de **tous les dossiers** (familles **et** AM), 1 ligne = 1 `numero_dossier`
3. Différenciation visuelle famille vs AM : **couleur + icône**
4. **Barre de recherche** (n° dossier, nom, email…)
**Pas** de bouton « Créer un dossier » ici (création via **+ Parents** #129 / **+ Asmat** #156).
---
## UX cible
### Onglets dashboard (`UserManagementPanel`)
| Avant (#107) | Après (#153) |
|--------------|--------------|
| « À valider » **conditionnel** si pending | **« Dossiers » toujours visible** (admin + gestionnaire) |
| Contenu = seulement pending | Pending **en haut** + liste complète **en bas** |
Ordre suggéré des onglets :
`Dossiers` | `Parents` | `Enfants` | `Assistantes maternelles` | `Gestionnaires` | (`Administrateurs`)
### Section haute — À valider
- Réutiliser / adapter `PendingValidationWidget` (ou extraire la liste dans un sous-widget).
- Sources déjà branchées :
- `UserService.getPendingUsers(role: 'assistante_maternelle')`
- `UserService.getPendingFamilies()`
- Clic ligne pending → **`ValidationDossierModal`** / wizards `.review` (inchangé).
- Si section vide : ne pas afficher de gros vide ; masquer la section ou message court « Aucun dossier en attente ».
### Section basse — Tous les dossiers
1 ligne = **1 dossier** (`numero_dossier`), type :
| Type | Libellé UI | Couleur (suggestion) |
|------|------------|----------------------|
| `famille` | Famille / Parents | teinte existante parents (ex. violet / rose dashboard) |
| `assistante_maternelle` | AM | teinte existante AM (ex. teal / bleu) |
Colonnes / infos utiles (cartes style `AdminUserCard` ou lignes type pending) :
- n° dossier
- type (pastille couleur + icône)
- libellé (noms parents ou AM)
- email(s) principal(aux)
- statut user / dossier si dispo (`actif`, `en_attente`, …)
- date utile si dispo
**Déduplication** : un foyer (pivot + co-parent) = **une** ligne famille (même `numero_dossier`). Idem AM.
### Recherche
- La search bar du panel (aujourdhui désactivée / hint « pas de recherche » sur À valider) doit **filtrer la liste unifiée** (et idéalement aussi le pending affiché).
- Critères **minimum** : `numero_dossier`, nom, prénom, email.
- Harmoniser le hint : `Rechercher un dossier (n°, nom, email)…`
### État vide liste complète
Aide optionnelle : *« Pour créer un dossier → onglet Parents (+ Parents) ou Assistantes maternelles (+ Asmat) »*.
### Clic sur un dossier de la liste complète (#153)
| Cas | Comportement #153 |
|-----|-------------------|
| Pending | Ouvrir validation (review) — déjà en place |
| Dossier **actif** / non pending | Ouvrir consultation via `GET /dossiers/:numeroDossier` (`UserService.getDossierByNumero`) en **lecture / review** si possible **sans** save édition |
**Ne pas** implémenter le mode `edit` ni le switch 2ᵉ parent → **#135**.
Si louverture « review » dun dossier actif est trop lourde pour ce ticket : clic peut temporairement no-op / snackbar *« Édition dossier : prochainement (#135) »***à éviter** si `getDossierByNumero` + wizard review marche déjà pour les deux types.
---
## Données / APIs (front)
### Déjà disponibles (préférer composer côté front pour #153)
| Besoin | API / service |
|--------|----------------|
| Pending AM | `getPendingUsers(role: assistante_maternelle)` |
| Pending familles | `getPendingFamilies()` |
| Parents (avec `numero_dossier`) | `getParents()` |
| AM (avec `numero_dossier`) | `getAssistantesMaternelles()` |
| Détail unifié | `getDossierByNumero(numero)``GET /dossiers/:numeroDossier` |
**Pas dendpoint `GET /dossiers` liste** aujourdhui. Pour #153 :
- Construire la liste unifiée **côté client** à partir de `getParents()` + `getAssistantesMaternelles()` (group by `numero_dossier`).
- Exclure ou marquer les pending déjà dans la section haute (éviter doublons visuels, ou les laisser dans les deux avec badge « à valider » — **préférence** : pending **uniquement** en haut ; liste basse = tous **hors** pending **ou** tous avec badge ; choisir une règle claire et documenter dans le PR).
**Règle recommandée** :
- Haut = pending only
- Bas = **tous** les dossiers ayant un `numero_dossier` (y compris pending) **OU** bas = non-pending only
**Recommandation produit** : bas = **tous** (vision complète), pending aussi en haut pour action rapide. Si doublon gênant : bas = non-pending only.
### Si le back ajoute plus tard `GET /dossiers`
Brancher `UserService.getDossiers()` — hors scope bloquant #153 front si composition client OK.
---
## Fichiers front probables
| Fichier | Rôle |
|---------|------|
| `frontend/lib/widgets/admin/user_management_panel.dart` | Onglet permanent **Dossiers** ; retirer logique conditionnelle À valider ; search sur cet onglet |
| `frontend/lib/widgets/admin/pending_validation_widget.dart` | Réemploi section haute (ou refactor léger) |
| **Nouveau** `…/dossiers_management_widget.dart` (nom libre) | Shell onglet : pending + liste unifiée + refresh |
| **Nouveau** modèle léger `DossierListItem` (type, numero, libelle, emails, statut…) | Mapping parents/AM → ligne |
| `user_service.dart` / `api_config.dart` | Seulement si helper `getDossiersUnified()` côté client (pas forcément nouvel endpoint) |
| `validation_dossier_modal.dart` | Réemploi ouverture pending / détail |
Réutiliser look & feel cartes / hover « Ouvrir » de `_PendingValidationRow` / `AdminUserCard`.
---
## Hors scope (#153)
- Bouton créer dossier
- Mode `edit` wizard + ajout 2ᵉ parent → **#135**
- Suppressions → **#154**
- Famille N responsables → **#139**
- Changer les onglets Parents / AM / Enfants (restent)
---
## Critères dacceptation front
- [ ] Onglet **Dossiers** toujours visible (même 0 pending)
- [ ] Plus donglet conditionnel **« À valider »**
- [ ] Section haute pending si non vide ; validation au clic OK
- [ ] Liste unifiée familles + AM en dessous ; 1 ligne / `numero_dossier`
- [ ] Couleur + icône différencient famille / AM
- [ ] Recherche filtre (n° + nom + email minimum)
- [ ] **Aucun** bouton créer dans cet onglet
- [ ] Pas de régression validation pending (valider / refuser)
---
## Back (info — Cursor back séparé si besoin)
- Liste unifiée : **pas bloquante** si composition front
- Optionnel : `GET /api/v1/dossiers` (liste) pour perf / pagination plus tard
- `GET /dossiers/:numero` déjà là (#119)
---
## Branche
`feature/153-onglet-dossiers` (depuis `develop`)
-35
View File
@@ -1,35 +0,0 @@
# Matrice suppression — #154 / back **#159** / front **#160**
**Statut** : cadrage PO validé (sept. 2026)
**Milestone** : 0.1.0
**Email** : pas demail de suppression (cas rare)
## Droits
| Cible | Qui peut supprimer |
|-------|-------------------|
| Dossier / parent / enfant / AM | `GESTIONNAIRE`, `ADMINISTRATEUR`, `SUPER_ADMIN` |
| Gestionnaire (user) | `ADMINISTRATEUR`, `SUPER_ADMIN` |
| Administrateur (user) | Autre admin OK ; **self interdit** ; **dernier admin** = `SUPER_ADMIN` only ; `SUPER_ADMIN` non supprimable |
## Matrice métier
| Point dentrée | Action | Effet |
|----------------|--------|--------|
| Dossiers | Delete dossier **famille** | Tous **parents** + tous **enfants** ; clore placements AM des enfants |
| Dossiers / AM | Delete dossier **AM** ou compte AM | **Compte AM + dossier AM** ; enfants **conservés** ; placements **clos** |
| Parents | Co-parent (autre parent reste) | Compte parent seul ; dossier + enfants restent |
| Parents | Dernier parent | Parent + **enfants** rattachés |
| Enfants | Pas dernier | Enfant seul (retiré du dossier) |
| Enfants | Dernier + `deleteDossier=true` | Cascade dossier famille (parents + enfants) |
| Enfants | Dernier + `deleteDossier=false` | Enfant seul ; dossier peut apparaître **`sans_enfant`** |
| Pending / validé | — | **Mêmes règles** (pas de différenciation) |
## Warning
- `sans_enfant` sur liste `GET /dossiers` (dossier famille sans enfant lié).
- Miroir de `sans_responsable` (#157) côté enfants.
## Hors scope
Soft-delete RGPD, audit (#128), famille N (#139), restriction admin-only métier (plus tard).
-169
View File
@@ -1,169 +0,0 @@
# Mini-spec front — Suppressions dashboard
**Ticket front** : **#160** — https://git.ptits-pas.fr/jmartin/petitspas/issues/160
**Ticket back** : **#159** — https://git.ptits-pas.fr/jmartin/petitspas/issues/159
**Epic** : #154 (complète #133)
**Branche back** : `feature/159-suppressions-backend`
**Doc matrice** : [154-matrice-suppression.md](./154-matrice-suppression.md)
Travail **en parallèle** : ce contrat est la source de vérité UI ↔ API.
---
## UX commune
Sur chaque ligne / carte des listes :
- Icône **poubelle** en bout de ligne
- Clic → **dialog de confirmation** (texte dimpact) → DELETE → **refresh** liste
- Pending = **mêmes** règles que validés
- **Pas** demail
| Liste | Poubelle visible si |
|-------|---------------------|
| Dossiers, Parents, Enfants, AM | gestionnaire **ou** admin |
| Gestionnaires | **admin** only |
| Administrateurs | admin+ ; **pas** sur sa propre ligne ; dernier admin : UI warning + réservé super_admin |
---
## Contrat API
Base : auth Bearer. Erreurs : `400` / `403` / `404` / `409` avec `message` FR.
### 1. `DELETE /dossiers/:numeroDossier`
- **Famille** → supprime tous parents + enfants du n° ; clos placements AM des enfants.
- **AM** → compte AM + dossier AM ; enfants gardés ; placements clos.
**Réponse 200** (exemple) :
```json
{
"type": "famille",
"numero_dossier": "2026-000043",
"deleted_user_ids": ["…"],
"deleted_enfant_ids": ["…"],
"message": "Dossier famille supprimé."
}
```
ou
```json
{
"type": "assistante_maternelle",
"numero_dossier": "2026-000015",
"deleted_user_ids": ["…"],
"deleted_enfant_ids": [],
"message": "Dossier assistante maternelle supprimé."
}
```
**Dialog UI** : lister libellé + n° + « X parent(s), Y enfant(s) » (ou « compte AM, enfants conservés »).
---
### 2. `DELETE /users/:id`
Comportement selon le **rôle** de la cible :
| Cible | Effet |
|-------|--------|
| Parent **co-parent** | Delete ce user seul |
| Parent **dernier** du dossier | Delete user + enfants du foyer |
| AM | Delete user AM + dossier AM ; enfants conservés ; placements clos |
| Gestionnaire | Admin only ; self → 403 |
| Administrateur | Self → 403 ; dernier admin → super_admin only sinon 403 ; super_admin → 403 |
**Réponse 200** :
```json
{
"deleted_user_ids": ["…"],
"deleted_enfant_ids": ["…"],
"message": "…"
}
```
**Dialogs** :
- Co-parent : « Ce parent sera retiré / supprimé du dossier {n°}. Les enfants restent avec le co-parent. »
- Dernier parent : « Dernier parent du dossier {n°}. Les enfants rattachés seront aussi supprimés. »
- AM : « Le compte et le dossier AM seront supprimés. Les enfants accueillis ne seront pas supprimés. »
Optionnel (si exposé) : `GET /users/:id/suppression-impact` — sinon calculer depuis données déjà en liste / détail dossier.
---
### 3. `DELETE /enfants/:id?deleteDossier=true|false`
- Pas dernier enfant → delete enfant (`deleteDossier` ignoré ou false).
- Dernier enfant + `deleteDossier=false` → delete enfant ; dossier famille peut passer `sans_enfant`.
- Dernier enfant + `deleteDossier=true` → cascade dossier famille (parents + enfants).
**Réponse 200** :
```json
{
"deleted_enfant_ids": ["…"],
"deleted_user_ids": ["…"],
"dossier_supprime": false,
"message": "…"
}
```
**Dialog** :
- Standard : « Lenfant sera supprimé du dossier de {famille} ({n°}). »
- Dernier : proposer **deux actions** :
1. Supprimer lenfant seulement (`deleteDossier=false`)
2. Supprimer aussi le dossier / parents (`deleteDossier=true`)
Pour savoir si dernier : compter enfants du `numero_dossier` (détail dossier ou champ impact API).
---
### 4. `GET /dossiers` — flag `sans_enfant`
Chaque item famille peut exposer :
```json
"sans_enfant": true
```
- `true` si dossier **famille** sans enfant lié.
- AM : `false` ou omis.
**UI** : badge / warning vigilance (comme `sans_responsable` / alertes AM).
---
## UserService (Flutter) — signatures cibles
```dart
Future<void> deleteDossier(String numeroDossier);
Future<Map<String, dynamic>> deleteUser(String userId);
Future<Map<String, dynamic>> deleteEnfant(String enfantId, {bool deleteDossier = false});
```
(Adapter le parsing au JSON réel une fois le back mergé ; en parallèle, stubber sur ce contrat.)
---
## Fichiers front probables
- Cartes listes : `admin_user_card.dart`, `admin_enfant_user_card.dart`, cartes dossiers
- Listes : `dossiers_management_widget.dart`, `parent_managmant_widget.dart`, `enfant_management_widget.dart`, `assistante_maternelle_management_widget.dart`, `gestionnaire_management_widget.dart`, `admin_management_widget.dart`
- `user_service.dart`
---
## Critères front (#160)
- [ ] Poubelle selon droits
- [ ] Confirmations avec impact
- [ ] Refresh après succès
- [ ] Dernier enfant : choix dossier oui/non
- [ ] Warning `sans_enfant`
- [ ] Self-admin / dernier admin gérés côté UI (masquer ou message 403)
@@ -1,54 +0,0 @@
# Mini-spec — Rename préfixe `Admin*` dashboard partagé (#155)
**Ticket** : **#155**
**Branche** : `feature/155-rename-admin-prefix-dashboard` (depuis `develop`)
**Décision naming** : **option C** — dossier neutre `widgets/dashboard/` + noms **sans** préfixe `Admin`.
---
## Principe
Les widgets partagés **admin + gestionnaire** ne doivent plus sappeler `Admin*`.
On garde `Admin*` seulement là où cest vraiment le rôle administrateur.
## Gardé `Admin*` (hors rename)
| Élément | Raison |
|---------|--------|
| `AdminManagementWidget` | Onglet **Administrateurs** |
| `screens/administrateurs/*` | `AdminDashboardScreen`, `AdminCreateDialog`, `AdminUserFormDialog` |
| `EnfantAdminModel` | Modèle API (pas un widget) — hors scope ticket |
## Renames faits
| Avant | Après |
|-------|--------|
| `widgets/admin/common/admin_child_detail_modal.dart``AdminChildDetailModal` | `widgets/dashboard/child_detail_modal.dart``ChildDetailModal` |
| `admin_am_edit_modal``AdminAmEditModal` | `am_edit_modal``AmEditModal` |
| `admin_parent_edit_modal``AdminParentEditModal` | `parent_edit_modal``ParentEditModal` |
| `admin_user_card``AdminUserCard` | `user_card``UserCard` |
| `admin_enfant_user_card``AdminEnfantUserCard` | `enfant_user_card``EnfantUserCard` |
| `admin_am_photo_frame``AdminAmPhotoFrame` | `am_photo_frame``AmPhotoFrame` |
| `admin_am_children_capacity_grid` | `am_children_capacity_grid``AmChildrenCapacityGrid` |
| `admin_children_affiliation_panel` | `children_affiliation_panel``ChildrenAffiliationPanel` |
| `admin_select_*` / `AdminSelect*` / `AdminFamilleFoyer` | `select_*` / `Select*` / `FamilleFoyer` |
| `admin_status_capsule` | `status_capsule``StatusCapsule` |
| `admin_list_state``AdminListState` | `user_list_state``UserListState` |
| `admin_detail_modal``AdminDetailModal` / `AdminDetailField` | `detail_modal``DetailModal` / `DetailField` |
| `dashboard_admin.dart` | `user_management_sub_bar.dart` (`DashboardUserManagementSubBar` inchangé) |
Dossier `widgets/admin/` conserve encore les panels métier (`user_management_panel`, wizards, etc.) + `AdminManagementWidget`.
**Phase 2** (même ticket #155) : déplacer ces panels → `widgets/dashboard/` — voir [155-suite-move-admin-panels-to-dashboard.md](./155-suite-move-admin-panels-to-dashboard.md).
## Hors scope
- Refonte UX modales (ticket dédié)
- Rename API / back
- Déplacer tout `widgets/admin/``widgets/dashboard/` (panels) — possible follow-up
## Critères
- [x] Plus de préfixe `Admin` sur les composants **partagés** listés
- [ ] Build Flutter / recette dashboard admin + gestionnaire OK
- [x] Pas de changement comportemental (rename mécanique)
@@ -1,203 +0,0 @@
# Mini-spec — Déplacer les panels `widgets/admin/``widgets/dashboard/`
**Ticket** : **#155** (phase 2 — même ticket que le rename `Admin*`)
**Phase 1** : widgets `Admin*``widgets/dashboard/` (déjà sur `feature/155-rename-admin-prefix-dashboard`)
**Branche** : poursuivre / rebaser `feature/155-rename-admin-prefix-dashboard` (ou nouvelle branche depuis `develop` après merge phase 1)
**Nature** : rename / move mécanique — **zéro** changement UX / métier
---
## Contexte
Après la phase 1 (#155), la situation est **hybride** :
| Emplacement | Contenu |
|-------------|---------|
| `widgets/dashboard/` | Composants partagés sans préfixe `Admin*` (modales, cartes, selects, sub-bar…) |
| `widgets/admin/` | **Panels** du dashboard staff (listes, wizards, validation, shell `UserManagementPanel`…) + `AdminManagementWidget` |
Le dossier `admin/` laisse encore croire « réservé administrateur », alors que **gestionnaire** consomme les mêmes panels (`GestionnaireDashboardScreen``UserManagementPanel`).
Ce ticket **termine loption C** au niveau dossier : tout le dashboard staff vit sous `widgets/dashboard/`, sauf ce qui est **vraiment** rôle admin.
---
## Objectif
```
frontend/lib/widgets/admin/<panels & common partagés>
↓ git mv + update imports
frontend/lib/widgets/dashboard/…
```
Critère : un nouveau dev ne doit plus ouvrir `widgets/admin/` pour du code partagé admin+gestionnaire.
---
## Cible darborescence (proposée)
```
widgets/dashboard/
├── (déjà là #155) child_detail_modal.dart, am_edit_modal.dart, user_card.dart, …
├── user_management_panel.dart ← shell onglets
├── user_management_sub_bar.dart ← déjà déplacé #155
├── dossiers_management_widget.dart
├── dossier_list_card.dart
├── parent_management_widget.dart ← corriger le typo managmant au passage ?
├── enfant_management_widget.dart
├── assistante_maternelle_management_widget.dart
├── gestionnaire_management_widget.dart
├── pending_validation_widget.dart
├── parent_dossier_create_modal.dart
├── parent_dossier_wizard.dart
├── am_dossier_create_modal.dart
├── am_dossier_wizard.dart
├── validation_*.dart ← family/am wizards, refus, theme, confirm
├── parametres_panel.dart ← utilisé par écran admin (OK dans dashboard)
├── relais_management_panel.dart
├── common/ ← sous-dossier optionnel
│ ├── suppression_confirm_dialog.dart
│ ├── user_list.dart
│ └── validation_detail_section.dart
└── …
widgets/admin/ ← mince, rôle admin seulement
└── admin_management_widget.dart ← onglet Administrateurs
```
### Variante B (plus stricte)
`AdminManagementWidget` + éventuels helpers purement admin →
`screens/administrateurs/widgets/`
et **suppression** du dossier `widgets/admin/`.
**Reco** : **variante A** (garder `widgets/admin/` minimal avec seulement `AdminManagementWidget`) — moins de churn screens, clair.
---
## Inventaire à déplacer (état actuel)
### Racine `widgets/admin/``widgets/dashboard/`
| Fichier actuel | Notes |
|----------------|--------|
| `user_management_panel.dart` | Shell partagé admin + gestionnaire |
| `dossiers_management_widget.dart` | |
| `dossier_list_card.dart` | |
| `parent_managmant_widget.dart` | Typo historique `managmant`**option** : renommer → `parent_management_widget.dart` dans le même ticket ou ticket typo séparé |
| `enfant_management_widget.dart` | |
| `assistante_maternelle_management_widget.dart` | |
| `gestionnaire_management_widget.dart` | |
| `pending_validation_widget.dart` | |
| `parent_dossier_create_modal.dart` | |
| `parent_dossier_wizard.dart` | |
| `am_dossier_create_modal.dart` | |
| `am_dossier_wizard.dart` | |
| `validation_am_wizard.dart` | |
| `validation_family_wizard.dart` | |
| `validation_dossier_modal.dart` | |
| `validation_modal_theme.dart` | |
| `validation_refus_form.dart` | |
| `validation_valider_confirm_dialog.dart` | |
| `parametres_panel.dart` | Écran admin seulement, mais pas préfixé Admin — OK dashboard |
| `relais_management_panel.dart` | |
### `widgets/admin/common/``widgets/dashboard/common/` (ou plat)
| Fichier | Notes |
|---------|--------|
| `suppression_confirm_dialog.dart` | Partagé (y compris `screens/administrateurs/creation/*`) |
| `user_list.dart` | |
| `validation_detail_section.dart` | |
### **Ne pas** déplacer
| Fichier | Destination |
|---------|-------------|
| `admin_management_widget.dart` | Reste `widgets/admin/` (ou variante B → screens) |
### Déjà fait (#155) — ne pas retraiter
Tout ce qui est déjà sous `widgets/dashboard/` (`child_detail_modal`, `am_edit_modal`, `user_card`, `select_*`, `user_management_sub_bar`, …).
---
## Consommateurs dimports (à mettre à jour)
### Screens
- `screens/administrateurs/admin_dashboardScreen.dart``UserManagementPanel`, `ParametresPanel`
- `screens/gestionnaire/gestionnaire_dashboard_screen.dart``UserManagementPanel`
- `screens/administrateurs/creation/admin_create.dart``suppression_confirm_dialog`
- `screens/administrateurs/creation/gestionnaires_create.dart` — idem
### Widgets déjà en `dashboard/`
- `am_edit_modal`, `child_detail_modal`, `parent_edit_modal`, `select_*` — imports vers `widgets/admin/common/*` ou panels
### Divers
- `widgets/common/identity_block.dart` (si import admin)
- Tous les fichiers **déplacés** entre eux (imports relatifs / package)
### Hors scope rename classes
Sauf décision explicite sur le typo `parent_managmant_widget` → pas de rename de **classes** métier dans ce ticket (seulement chemins de fichiers + imports).
`AdminManagementWidget` **conserve** son nom.
---
## Plan dexécution
1. Partir de `feature/155-rename-admin-prefix-dashboard` (phase 1) **ou** `develop` si phase 1 déjà mergée
2. `git mv` fichiers selon inventaire
3. Remplacer globalement
`package:p_tits_pas/widgets/admin/``package:p_tits_pas/widgets/dashboard/`
**sauf** `…/widgets/admin/admin_management_widget.dart`
4. Corriger imports relatifs cassés
5. Grep de contrôle (ci-dessous)
6. Build Flutter web (Docker) + smoke dashboard admin **et** gestionnaire
7. Merge → squash master si flux habituel
---
## Vérifs
```bash
# Plus de panels partagés sous admin (seul AdminManagement attendu)
find frontend/lib/widgets/admin -name '*.dart'
# Plus dimports panels vers lancien chemin (sauf AdminManagement)
rg -n "widgets/admin/(user_management|dossiers_|parent_|enfant_|assistante|gestionnaire|pending|validation_|parametres|relais|am_dossier|parent_dossier|dossier_list|common/)" frontend/lib
# Screens OK
rg -n "widgets/admin/" frontend/lib/screens
```
Attendu screens : **0** hit vers panels ; éventuellement plus aucun hit `widgets/admin/` sauf si import explicite `AdminManagementWidget` depuis `user_management_panel` (chemin `widgets/admin/admin_management_widget.dart`).
---
## Hors scope
- Refonte UX des modales / panels (ticket dédié annoncé)
- Rename `EnfantAdminModel`
- Rename `screens/administrateurs/`
- Rename `AdminUserFormDialog` / `AdminCreateDialog`
- Changement API / back
- #152 (`est_multiple`) — autre branche
---
## Critères dacceptation
- [ ] Inventaire déplacé selon tableau
- [ ] `widgets/admin/` ne contient plus que `admin_management_widget.dart` (variante A)
- [ ] Imports screens + widgets à jour
- [ ] Build Flutter OK
- [ ] Recette : dashboard **administrateur** et **gestionnaire** (listes, ouverture fiches, validation, création dossier) sans régression
- [ ] Aucun changement comportemental volontaire
---
## Risques / notes
- **Conflits de merge** si dautres features touchent les panels → faire ce ticket quand la surface dashboard est calme (fin 0.1.0 OK)
- Typo `parent_managmant_widget` : soit inclus (bonus), soit ticket cleanup 1-ligne séparé
- Docs darchive citant `widgets/admin/…` : pas obligatoire de mettre à jour ; `docs/27_BRIEFING-FRONTEND.md` oui si encore listé
-75
View File
@@ -1,75 +0,0 @@
# Mini-spec API — POST /assistantes-maternelles/dossier (#156)
Contrat pour le **plan front** (wizard création AM staff).
## Endpoint
| | |
|--|--|
| **Méthode** | `POST` |
| **URL** | `{base}/assistantes-maternelles/dossier` |
| **Auth** | Bearer JWT |
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
| **Content-Type** | `application/json` |
Ne **pas** appeler `POST /auth/register/am` depuis le dashboard.
## Body (JSON)
Aligné inscription AM publique, **sans** CGU/privacy obligatoires (acceptées serveur).
| Champ | Type | Obligatoire | Notes |
|-------|------|-------------|--------|
| `email` | string | oui | unique |
| `prenom` | string | oui | |
| `nom` | string | oui | |
| `telephone` | string | oui | `0X…` ou `+33…` |
| `adresse` | string | non | |
| `code_postal` | string | non | |
| `ville` | string | non | |
| `photo_base64` | string | non | data-URL `data:image/…;base64,…` |
| `photo_filename` | string | non | hint nom fichier |
| `consentement_photo` | bool | oui | |
| `date_naissance` | date ISO | non | `YYYY-MM-DD` |
| `lieu_naissance_ville` | string | oui | |
| `lieu_naissance_pays` | string | oui | |
| `nir` | string | oui | 15 car. (Corse 2A/2B OK) |
| `numero_agrement` | string | oui | unique |
| `date_agrement` | date ISO | non | |
| `capacite_accueil` | int | oui | 110 |
| `places_disponibles` | int | oui | 010, ≤ capacité |
| `biographie` | string | non | max 2000 |
## Réponses
### 201 Created
```json
{
"message": "Dossier AM créé et validé. Un e-mail de création de mot de passe a été envoyé.",
"user_id": "uuid",
"statut": "actif",
"numero_dossier": "2026-000042"
}
```
Effets serveur : user AM **actif**, fiche `assistantes_maternelles`, n° dossier, **e-mail création MDP** (pas daccusé « en attente »).
### Erreurs
| Code | Cas |
|------|-----|
| 400 | Validation / NIR / places > capacité |
| 403 | Rôle non staff |
| 409 | Email, NIR ou agrément déjà pris |
| 401 | Token manquant / invalide |
## Front
- `UserService.createAmDossier(body)` → cet endpoint
- Après 201 : refresh liste AM ; snackbar OK
- Wizard create : ne pas envoyer `acceptation_cgu` / `acceptation_privacy` (optionnels)
## Branche
`feature/156-creation-dossier-am`
-239
View File
@@ -1,239 +0,0 @@
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart';
/// Ligne de liste unifiée dossiers (famille ou AM) — ticket #153.
enum DossierListType { famille, assistanteMaternelle }
class DossierListItem {
final DossierListType type;
final String numeroDossier;
final String libelle;
final List<String> emails;
final String? statut;
/// Photo profil (AM) — affichée à la place de licône si présente.
final String? photoUrl;
/// Dossier famille sans enfant lié (#159 / #160).
final bool sansEnfant;
/// Nombre denfants du foyer (famille uniquement ; null pour AM).
final int? enfantsCount;
const DossierListItem({
required this.type,
required this.numeroDossier,
required this.libelle,
this.emails = const [],
this.statut,
this.photoUrl,
this.sansEnfant = false,
this.enfantsCount,
});
DossierListItem copyWith({
DossierListType? type,
String? numeroDossier,
String? libelle,
List<String>? emails,
String? statut,
String? photoUrl,
bool? sansEnfant,
int? enfantsCount,
}) {
return DossierListItem(
type: type ?? this.type,
numeroDossier: numeroDossier ?? this.numeroDossier,
libelle: libelle ?? this.libelle,
emails: emails ?? this.emails,
statut: statut ?? this.statut,
photoUrl: photoUrl ?? this.photoUrl,
sansEnfant: sansEnfant ?? this.sansEnfant,
enfantsCount: enfantsCount ?? this.enfantsCount,
);
}
bool get isFamille => type == DossierListType.famille;
bool get isAm => type == DossierListType.assistanteMaternelle;
String get typeLabel => isFamille ? 'Famille' : 'AM';
/// Sous-titre carte : `NOM Prénom` ou `NOM Prénom - NOM Prénom`.
String get namesLine => libelle;
String get emailsLine => emails.where((e) => e.trim().isNotEmpty).join(' · ');
/// Titre carte : numéro de dossier seul.
String get titleLine => numeroDossier;
bool matchesQuery(String query) {
final q = query.trim().toLowerCase();
if (q.isEmpty) return true;
if (numeroDossier.toLowerCase().contains(q)) return true;
if (libelle.toLowerCase().contains(q)) return true;
for (final e in emails) {
if (e.toLowerCase().contains(q)) return true;
}
return false;
}
/// Une ligne par `numero_dossier` (foyer dédupliqué).
static List<DossierListItem> fromParents(List<ParentModel> parents) {
final byDossier = <String, List<ParentModel>>{};
for (final p in parents) {
final num = (p.user.numeroDossier ?? '').trim();
if (num.isEmpty) continue;
byDossier.putIfAbsent(num, () => []).add(p);
}
final items = <DossierListItem>[];
for (final entry in byDossier.entries) {
final seenIds = <String>{};
final names = <String>[];
final emails = <String>[];
final statuts = <String>[];
void consider(
String? id,
String? nom,
String? prenom,
String? email,
String? statut,
) {
final uid = (id ?? '').trim();
if (uid.isEmpty || !seenIds.add(uid)) return;
final label = formatDossierPersonLabel(
nom: nom,
prenom: prenom,
email: email,
);
if (label.isNotEmpty) names.add(label);
final e = (email ?? '').trim();
if (e.isNotEmpty) emails.add(e);
final s = (statut ?? '').trim();
if (s.isNotEmpty) statuts.add(s);
}
for (final p in entry.value) {
consider(
p.user.id,
p.user.nom,
p.user.prenom,
p.user.email,
p.user.statut,
);
final co = p.coParent;
if (co != null) {
consider(co.id, co.nom, co.prenom, co.email, co.statut);
}
}
final childIds = <String>{};
var maxCountFallback = 0;
for (final p in entry.value) {
for (final c in p.children) {
final id = c.id.trim();
if (id.isNotEmpty) childIds.add(id);
}
final n = p.children.isNotEmpty ? p.children.length : p.childrenCount;
if (n > maxCountFallback) maxCountFallback = n;
}
final enfantsCount =
childIds.isNotEmpty ? childIds.length : maxCountFallback;
items.add(
DossierListItem(
type: DossierListType.famille,
numeroDossier: entry.key,
libelle: names.isNotEmpty ? names.join(' - ') : 'Famille',
emails: emails,
statut: _preferStatut(statuts),
enfantsCount: enfantsCount,
sansEnfant: enfantsCount == 0,
),
);
}
return items;
}
static List<DossierListItem> fromAssistantes(
List<AssistanteMaternelleModel> ams,
) {
final byDossier = <String, AssistanteMaternelleModel>{};
for (final am in ams) {
final num = (am.user.numeroDossier ?? '').trim();
if (num.isEmpty) continue;
byDossier.putIfAbsent(num, () => am);
}
return byDossier.entries.map((e) {
final u = e.value.user;
final name = formatDossierPersonLabel(
nom: u.nom,
prenom: u.prenom,
email: u.email,
);
final photo = (u.photoUrl ?? '').trim();
return DossierListItem(
type: DossierListType.assistanteMaternelle,
numeroDossier: e.key,
libelle: name.isNotEmpty ? name : 'AM',
emails: u.email.trim().isEmpty ? const [] : [u.email.trim()],
statut: u.statut?.trim(),
photoUrl: photo.isEmpty ? null : photo,
);
}).toList();
}
/// Priorité affichage : en_attente > suspendu > refuse > actif > autre.
static String? _preferStatut(List<String> raw) {
if (raw.isEmpty) return null;
const order = ['en_attente', 'suspendu', 'refuse', 'actif'];
for (final wanted in order) {
for (final s in raw) {
if (s.toLowerCase() == wanted) return s;
}
}
return raw.first;
}
}
/// Affichage carte dossier : `NOM Prénom` (repli email).
String formatDossierPersonLabel({
String? nom,
String? prenom,
String? email,
}) {
final n = (nom ?? '').trim().toUpperCase();
final p = formatPersonNameCase(prenom ?? '');
if (n.isNotEmpty && p.isNotEmpty) return '$n $p';
if (n.isNotEmpty) return n;
if (p.isNotEmpty) return p;
return (email ?? '').trim();
}
/// Reformate un libellé famille API (`A & B` / `Famille …`) en `NOM Prénom - …`.
String formatDossierFamilyNamesLine(String libelle) {
var raw = libelle.trim();
if (raw.isEmpty) return '';
raw = raw.replaceFirst(RegExp(r'^famille\s+', caseSensitive: false), '');
raw = raw
.replaceAll(RegExp(r'\s+&\s+'), ' - ')
.replaceAll(RegExp(r'\s+et\s+', caseSensitive: false), ' - ');
final parts = raw
.split(RegExp(r'\s+-\s+'))
.map((part) => _formatLoosePersonSegment(part.trim()))
.where((s) => s.isNotEmpty)
.toList();
return parts.join(' - ');
}
/// Segment libre type « martin sophie » ou « DURAND Amélie » → `NOM Prénom`.
String _formatLoosePersonSegment(String segment) {
final words =
segment.split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList();
if (words.isEmpty) return '';
if (words.length == 1) return words.first.toUpperCase();
// Convention affichage : premier mot = NOM, reste = prénom(s).
final nom = words.first.toUpperCase();
final prenom = formatPersonNameCase(words.sublist(1).join(' '));
return '$nom $prenom';
}
+5 -6
View File
@@ -185,6 +185,7 @@ class EnfantDossier {
final String? dueDate; final String? dueDate;
final String? photoUrl; final String? photoUrl;
final bool consentPhoto; final bool consentPhoto;
final bool estMultiple;
EnfantDossier({ EnfantDossier({
required this.id, required this.id,
@@ -196,6 +197,7 @@ class EnfantDossier {
this.dueDate, this.dueDate,
this.photoUrl, this.photoUrl,
this.consentPhoto = false, this.consentPhoto = false,
this.estMultiple = false,
}); });
String get fullName => '${firstName ?? ''} ${lastName ?? ''}'.trim(); String get fullName => '${firstName ?? ''} ${lastName ?? ''}'.trim();
@@ -213,13 +215,8 @@ class EnfantDossier {
factory EnfantDossier.fromJson(Map<String, dynamic> json) { factory EnfantDossier.fromJson(Map<String, dynamic> json) {
final rawPhoto = json['photo_url'] ?? json['photoUrl']; final rawPhoto = json['photo_url'] ?? json['photoUrl'];
final resolvedPhoto = _optionalPhotoUrl(rawPhoto); final resolvedPhoto = _optionalPhotoUrl(rawPhoto);
final rawId = json['id'] ??
json['enfant_id'] ??
json['enfantId'] ??
json['child_id'] ??
json['childId'];
return EnfantDossier( return EnfantDossier(
id: rawId?.toString().trim() ?? '', id: json['id']?.toString() ?? '',
firstName: (json['first_name'] ?? json['prenom'])?.toString(), firstName: (json['first_name'] ?? json['prenom'])?.toString(),
lastName: (json['last_name'] ?? json['nom'])?.toString(), lastName: (json['last_name'] ?? json['nom'])?.toString(),
birthDate: json['birth_date']?.toString(), birthDate: json['birth_date']?.toString(),
@@ -229,6 +226,8 @@ class EnfantDossier {
photoUrl: resolvedPhoto, photoUrl: resolvedPhoto,
consentPhoto: consentPhoto:
json['consent_photo'] == true || json['consentPhoto'] == true, json['consent_photo'] == true || json['consentPhoto'] == true,
estMultiple:
json['est_multiple'] == true || json['estMultiple'] == true,
); );
} }
} }
+7 -19
View File
@@ -12,9 +12,8 @@ class EnfantAdminModel {
final String status; final String status;
final String? photoUrl; final String? photoUrl;
final bool consentPhoto; final bool consentPhoto;
final bool isMultiple;
final List<EnfantParentLink> parentLinks; final List<EnfantParentLink> parentLinks;
/// Flag API #157 (sinon déduit de [parentLinks]).
final bool? sansResponsable;
EnfantAdminModel({ EnfantAdminModel({
required this.id, required this.id,
@@ -26,8 +25,8 @@ class EnfantAdminModel {
required this.status, required this.status,
this.photoUrl, this.photoUrl,
this.consentPhoto = false, this.consentPhoto = false,
this.isMultiple = false,
this.parentLinks = const [], this.parentLinks = const [],
this.sansResponsable,
}); });
String get fullName { String get fullName {
@@ -38,12 +37,6 @@ class EnfantAdminModel {
return '$fn $ln'; return '$fn $ln';
} }
/// Aucun lien parent valide — ticket #157.
bool get hasNoResponsable {
if (sansResponsable != null) return sansResponsable!;
return !parentLinks.any((l) => l.parentId.trim().isNotEmpty);
}
EnfantAdminModel copyWith({ EnfantAdminModel copyWith({
String? id, String? id,
String? firstName, String? firstName,
@@ -54,8 +47,8 @@ class EnfantAdminModel {
String? status, String? status,
String? photoUrl, String? photoUrl,
bool? consentPhoto, bool? consentPhoto,
bool? isMultiple,
List<EnfantParentLink>? parentLinks, List<EnfantParentLink>? parentLinks,
bool? sansResponsable,
}) { }) {
return EnfantAdminModel( return EnfantAdminModel(
id: id ?? this.id, id: id ?? this.id,
@@ -67,8 +60,8 @@ class EnfantAdminModel {
status: status ?? this.status, status: status ?? this.status,
photoUrl: photoUrl ?? this.photoUrl, photoUrl: photoUrl ?? this.photoUrl,
consentPhoto: consentPhoto ?? this.consentPhoto, consentPhoto: consentPhoto ?? this.consentPhoto,
isMultiple: isMultiple ?? this.isMultiple,
parentLinks: parentLinks ?? this.parentLinks, parentLinks: parentLinks ?? this.parentLinks,
sansResponsable: sansResponsable ?? this.sansResponsable,
); );
} }
@@ -88,13 +81,6 @@ class EnfantAdminModel {
_parseBool(json['consentement_photo']) || _parseBool(json['consentement_photo']) ||
_parseBool(json['consentPhoto']); _parseBool(json['consentPhoto']);
bool? sansResponsable;
if (json.containsKey('sans_responsable') ||
json.containsKey('sansResponsable')) {
sansResponsable = _parseBool(json['sans_responsable']) ||
_parseBool(json['sansResponsable']);
}
return EnfantAdminModel( return EnfantAdminModel(
id: (json['id'] ?? '').toString(), id: (json['id'] ?? '').toString(),
firstName: json['first_name'] as String? ?? json['prenom'] as String?, firstName: json['first_name'] as String? ?? json['prenom'] as String?,
@@ -107,8 +93,9 @@ class EnfantAdminModel {
), ),
photoUrl: photoUrl, photoUrl: photoUrl,
consentPhoto: consentPhoto, consentPhoto: consentPhoto,
isMultiple: _parseBool(json['is_multiple']) ||
_parseBool(json['est_multiple']),
parentLinks: links, parentLinks: links,
sansResponsable: sansResponsable,
); );
} }
@@ -121,6 +108,7 @@ class EnfantAdminModel {
if (birthDate != null && birthDate!.isNotEmpty) 'birth_date': birthDate, if (birthDate != null && birthDate!.isNotEmpty) 'birth_date': birthDate,
if (dueDate != null && dueDate!.isNotEmpty) 'due_date': dueDate, if (dueDate != null && dueDate!.isNotEmpty) 'due_date': dueDate,
'consent_photo': consentPhoto, 'consent_photo': consentPhoto,
'is_multiple': isMultiple,
}; };
} }
-52
View File
@@ -62,56 +62,4 @@ class ParentModel {
return children; return children;
} }
/// Nombre denfants distincts du foyer (ce parent + co-parent / même dossier).
/// Évite Claire=7 / Thomas=6 quand un lien nest que sur un des deux (#157).
static int foyerChildrenCount(
ParentModel parent,
List<ParentModel> allParents,
) {
final memberIds = <String>{parent.user.id};
final coId = parent.coParent?.id.trim();
if (coId != null && coId.isNotEmpty) memberIds.add(coId);
final dossier = (parent.user.numeroDossier ?? '').trim();
for (final other in allParents) {
if (memberIds.contains(other.user.id)) continue;
final otherCo = other.coParent?.id.trim();
if (otherCo != null && memberIds.contains(otherCo)) {
memberIds.add(other.user.id);
continue;
}
if (dossier.isNotEmpty &&
(other.user.numeroDossier ?? '').trim() == dossier) {
memberIds.add(other.user.id);
final oc = other.coParent?.id.trim();
if (oc != null && oc.isNotEmpty) memberIds.add(oc);
}
}
final childIds = <String>{};
for (final p in allParents) {
if (!memberIds.contains(p.user.id)) continue;
for (final c in p.children) {
final id = c.id.trim();
if (id.isNotEmpty) childIds.add(id);
}
// Repli si la liste enfants nest pas hydratée.
if (p.children.isEmpty && p.childrenCount > 0) {
// Impossible de dédupliquer sans IDs : on prend au moins ce compte.
// (évite dafficher 0 si lAPI nenvoie que childrenCount)
}
}
if (childIds.isNotEmpty) return childIds.length;
var maxCount = 0;
for (final p in allParents) {
if (!memberIds.contains(p.user.id)) continue;
final n = p.children.isNotEmpty ? p.children.length : p.childrenCount;
if (n > maxCount) maxCount = n;
}
return maxCount;
}
} }
@@ -30,6 +30,7 @@ class ChildData {
String lastName; String lastName;
String dob; // Date de naissance ou prévisionnelle String dob; // Date de naissance ou prévisionnelle
bool photoConsent; bool photoConsent;
bool multipleBirth;
bool isUnbornChild; bool isUnbornChild;
File? imageFile; File? imageFile;
CardColorVertical cardColor; // Nouveau champ pour la couleur de la carte CardColorVertical cardColor; // Nouveau champ pour la couleur de la carte
@@ -39,6 +40,7 @@ class ChildData {
this.lastName = '', this.lastName = '',
this.dob = '', this.dob = '',
this.photoConsent = false, this.photoConsent = false,
this.multipleBirth = false,
this.isUnbornChild = false, this.isUnbornChild = false,
this.imageFile, this.imageFile,
required this.cardColor, // Rendre requis dans le constructeur required this.cardColor, // Rendre requis dans le constructeur
@@ -38,6 +38,7 @@ class ChildData {
/// Valeurs API : `H`, `F`, `Autre` (GenreType backend). Vide tant que non choisi. /// Valeurs API : `H`, `F`, `Autre` (GenreType backend). Vide tant que non choisi.
String genre; String genre;
bool photoConsent; bool photoConsent;
bool multipleBirth;
bool isUnbornChild; bool isUnbornChild;
File? imageFile; File? imageFile;
/// Octets de la photo (fiable à lenvoi API ; [imageFile] peut être absent sur le web). /// Octets de la photo (fiable à lenvoi API ; [imageFile] peut être absent sur le web).
@@ -54,6 +55,7 @@ class ChildData {
this.dob = '', this.dob = '',
this.genre = '', this.genre = '',
this.photoConsent = false, this.photoConsent = false,
this.multipleBirth = false,
this.isUnbornChild = false, this.isUnbornChild = false,
this.imageFile, this.imageFile,
this.imageBytes, this.imageBytes,
@@ -68,6 +70,7 @@ class ChildData {
String? dob, String? dob,
String? genre, String? genre,
bool? photoConsent, bool? photoConsent,
bool? multipleBirth,
bool? isUnbornChild, bool? isUnbornChild,
Object? imageFile = _unsetImage, Object? imageFile = _unsetImage,
Object? imageBytes = _unsetImageBytes, Object? imageBytes = _unsetImageBytes,
@@ -81,6 +84,7 @@ class ChildData {
dob: dob ?? this.dob, dob: dob ?? this.dob,
genre: genre ?? this.genre, genre: genre ?? this.genre,
photoConsent: photoConsent ?? this.photoConsent, photoConsent: photoConsent ?? this.photoConsent,
multipleBirth: multipleBirth ?? this.multipleBirth,
isUnbornChild: isUnbornChild ?? this.isUnbornChild, isUnbornChild: isUnbornChild ?? this.isUnbornChild,
imageFile: identical(imageFile, _unsetImage) ? this.imageFile : imageFile as File?, imageFile: identical(imageFile, _unsetImage) ? this.imageFile : imageFile as File?,
imageBytes: imageBytes:
@@ -2,9 +2,9 @@ import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/configuration_service.dart'; import 'package:p_tits_pas/services/configuration_service.dart';
import 'package:p_tits_pas/widgets/dashboard/user_management_sub_bar.dart'; import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
import 'package:p_tits_pas/widgets/dashboard/parametres_panel.dart'; import 'package:p_tits_pas/widgets/admin/parametres_panel.dart';
import 'package:p_tits_pas/widgets/dashboard/user_management_panel.dart'; import 'package:p_tits_pas/widgets/admin/user_management_panel.dart';
import 'package:p_tits_pas/widgets/app_footer.dart'; import 'package:p_tits_pas/widgets/app_footer.dart';
import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart'; import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart';
@@ -3,7 +3,6 @@ import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/email_utils.dart'; import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/email_text_field.dart'; import 'package:p_tits_pas/widgets/email_text_field.dart';
import 'package:p_tits_pas/widgets/french_phone_field.dart'; import 'package:p_tits_pas/widgets/french_phone_field.dart';
@@ -152,19 +151,30 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
Future<void> _delete() async { Future<void> _delete() async {
if (!_isEditMode || _isSubmitting) return; if (!_isEditMode || _isSubmitting) return;
final name = widget.initialUser!.fullName.isEmpty final confirmed = await showDialog<bool>(
? widget.initialUser!.email context: context,
: widget.initialUser!.fullName; builder: (ctx) {
final confirmed = await showSuppressionConfirmDialog( return AlertDialog(
context, title: const Text('Confirmer la suppression'),
title: 'Supprimer l\'administrateur', content: Text(
people: [SuppressionPersonLine.administrateur(name)], 'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
footnotes: const [ ),
'Le compte sera définitivement supprimé.', actions: [
], TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
child: const Text('Supprimer'),
),
],
);
},
); );
if (!confirmed) return; if (confirmed != true) return;
setState(() { setState(() {
_isSubmitting = true; _isSubmitting = true;
@@ -8,8 +8,6 @@ import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/relais_service.dart'; import 'package:p_tits_pas/services/relais_service.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
class AdminUserFormDialog extends StatefulWidget { class AdminUserFormDialog extends StatefulWidget {
final AppUser? initialUser; final AppUser? initialUser;
@@ -45,7 +43,6 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
List<RelaisModel> _relais = []; List<RelaisModel> _relais = [];
String? _selectedRelaisId; String? _selectedRelaisId;
String? _currentUserId; String? _currentUserId;
String? _currentUserRole;
bool get _isEditMode => widget.initialUser != null; bool get _isEditMode => widget.initialUser != null;
bool get _isSuperAdminTarget => bool get _isSuperAdminTarget =>
(widget.initialUser?.role ?? '').toLowerCase() == 'super_admin'; (widget.initialUser?.role ?? '').toLowerCase() == 'super_admin';
@@ -53,12 +50,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
_isEditMode && _isEditMode &&
_currentUserId != null && _currentUserId != null &&
widget.initialUser!.id == _currentUserId; widget.initialUser!.id == _currentUserId;
/// Gestionnaire : pas de delete staff. Admin+ seulement (#154 / #160). bool get _canDeleteTarget => !_isSuperAdminTarget && !_isSelfTarget;
bool get _canDeleteTarget {
if (!_isEditMode || widget.readOnly) return false;
if (_isSelfTarget || _isSuperAdminTarget) return false;
return canDeleteGestionnaire(_currentUserRole);
}
bool get _isLockedAdminIdentity => bool get _isLockedAdminIdentity =>
_isEditMode && widget.adminMode && _isSuperAdminTarget; _isEditMode && widget.adminMode && _isSuperAdminTarget;
String get _targetRoleKey { String get _targetRoleKey {
@@ -133,7 +125,6 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
if (cached != null) { if (cached != null) {
setState(() { setState(() {
_currentUserId = cached.id; _currentUserId = cached.id;
_currentUserRole = cached.role;
}); });
return; return;
} }
@@ -141,7 +132,6 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
if (!mounted || refreshed == null) return; if (!mounted || refreshed == null) return;
setState(() { setState(() {
_currentUserId = refreshed.id; _currentUserId = refreshed.id;
_currentUserRole = refreshed.role;
}); });
} }
@@ -388,25 +378,30 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
if (!_canDeleteTarget) return; if (!_canDeleteTarget) return;
if (!_isEditMode || _isSubmitting) return; if (!_isEditMode || _isSubmitting) return;
final name = widget.initialUser!.fullName.isEmpty final confirmed = await showDialog<bool>(
? widget.initialUser!.email context: context,
: widget.initialUser!.fullName; builder: (ctx) {
final confirmed = await showSuppressionConfirmDialog( return AlertDialog(
context, title: const Text('Confirmer la suppression'),
title: widget.adminMode content: Text(
? 'Supprimer l\'administrateur' 'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
: 'Supprimer le gestionnaire', ),
people: [ actions: [
widget.adminMode TextButton(
? SuppressionPersonLine.administrateur(name) onPressed: () => Navigator.of(ctx).pop(false),
: SuppressionPersonLine.gestionnaire(name), child: const Text('Annuler'),
], ),
footnotes: const [ FilledButton(
'Le compte sera définitivement supprimé.', onPressed: () => Navigator.of(ctx).pop(true),
], style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
child: const Text('Supprimer'),
),
],
);
},
); );
if (!confirmed) return; if (confirmed != true) return;
setState(() { setState(() {
_isSubmitting = true; _isSubmitting = true;
@@ -121,6 +121,7 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
dob: '', dob: '',
isUnbornChild: false, isUnbornChild: false,
photoConsent: false, photoConsent: false,
multipleBirth: false,
cardColor: cardColor, cardColor: cardColor,
); );
registrationData.addChild(newChild); registrationData.addChild(newChild);
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/widgets/dashboard/user_management_panel.dart'; import 'package:p_tits_pas/widgets/admin/user_management_panel.dart';
import 'package:p_tits_pas/widgets/app_footer.dart'; import 'package:p_tits_pas/widgets/app_footer.dart';
import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart'; import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart';
@@ -62,10 +62,7 @@ class _GestionnaireDashboardScreenState extends State<GestionnaireDashboardScree
body: Column( body: Column(
children: [ children: [
Expanded( Expanded(
child: UserManagementPanel( child: UserManagementPanel(showAdministrateursTab: false),
showAdministrateursTab: false,
allowStaffAccountCreation: false,
),
), ),
const AppFooter(), const AppFooter(),
], ],
@@ -60,12 +60,7 @@ class ApiConfig {
static const String userChildren = '/users/children'; static const String userChildren = '/users/children';
static const String gestionnaires = '/gestionnaires'; static const String gestionnaires = '/gestionnaires';
static const String parents = '/parents'; static const String parents = '/parents';
/// Création dossier famille actif par le staff (#129) — body type register parent.
static const String parentsDossier = '/parents/dossier';
static const String assistantesMaternelles = '/assistantes-maternelles'; static const String assistantesMaternelles = '/assistantes-maternelles';
/// Création dossier AM actif par le staff (#156) — body type register AM.
static const String assistantesMaternellesDossier =
'/assistantes-maternelles/dossier';
static const String enfants = '/enfants'; static const String enfants = '/enfants';
static const String relais = '/relais'; static const String relais = '/relais';
static const String dossiers = '/dossiers'; static const String dossiers = '/dossiers';
+19 -284
View File
@@ -525,58 +525,17 @@ class UserService {
return enfant.copyWith(parentLinks: enriched); return enfant.copyWith(parentLinks: enriched);
} }
/// Mise à jour enfant. Avec [photoBytes] : multipart (champ `photo`), sinon JSON.
static Future<EnfantAdminModel> updateEnfant({ static Future<EnfantAdminModel> updateEnfant({
required String enfantId, required String enfantId,
required Map<String, dynamic> body, required Map<String, dynamic> body,
List<int>? photoBytes,
String? photoFilename,
}) async { }) async {
final id = enfantId.trim(); final response = await http.patch(
if (id.isEmpty) { Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
throw Exception('Identifiant enfant manquant.'); headers: await _headers(),
} body: jsonEncode(body),
final hasPhoto = photoBytes != null && photoBytes.isNotEmpty; );
final http.Response response;
if (hasPhoto) {
final token = await TokenService.getToken();
final req = http.MultipartRequest(
'PATCH',
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$id'),
);
req.headers['Accept'] = 'application/json';
if (token != null) {
req.headers['Authorization'] = 'Bearer $token';
}
body.forEach((key, value) {
if (value == null) return;
req.fields[key] = value is bool
? (value ? 'true' : 'false')
: value.toString();
});
final name = (photoFilename ?? '').trim();
final filename = name.isNotEmpty ? name : 'photo.jpg';
req.files.add(
http.MultipartFile.fromBytes(
'photo',
photoBytes,
filename: filename,
contentType: _imageMediaType(filename, photoBytes),
),
);
final streamed = await req.send();
response = await http.Response.fromStream(streamed);
} else {
response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$id'),
headers: await _headers(),
body: jsonEncode(body),
);
}
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception( throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant'));
_extractErrorMessage(response.body, 'Erreur mise à jour enfant'),
);
} }
final enfant = EnfantAdminModel.fromJson( final enfant = EnfantAdminModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>, jsonDecode(response.body) as Map<String, dynamic>,
@@ -646,93 +605,14 @@ class UserService {
return enrichEnfantParentNames(enfant); return enrichEnfantParentNames(enfant);
} }
/// DELETE /enfants/:id?deleteDossier= — ticket #159 / #160. static Future<void> deleteEnfant(String enfantId) async {
static Future<Map<String, dynamic>> deleteEnfant(
String enfantId, {
bool deleteDossier = false,
}) async {
final uri = Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId',
).replace(
queryParameters: {
'deleteDossier': deleteDossier ? 'true' : 'false',
},
);
final response = await http.delete(uri, headers: await _headers());
if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception(
_extractErrorMessage(response.body, 'Erreur suppression enfant'),
);
}
return _parseSuppressionBody(response.body);
}
/// DELETE /dossiers/:numeroDossier — ticket #159 / #160.
static Future<Map<String, dynamic>> deleteDossier(
String numeroDossier,
) async {
final num = numeroDossier.trim();
if (num.isEmpty) {
throw Exception('Numéro de dossier manquant.');
}
final encoded = Uri.encodeComponent(num);
final response = await http.delete( final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'), Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
headers: await _headers(), headers: await _headers(),
); );
if (response.statusCode != 200 && response.statusCode != 204) { if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception( throw Exception(_extractErrorMessage(response.body, 'Erreur suppression enfant'));
_extractErrorMessage(response.body, 'Erreur suppression dossier'),
);
} }
return _parseSuppressionBody(response.body);
}
/// Liste unifiée GET /dossiers — flags `sans_enfant` (#159).
static Future<List<Map<String, dynamic>>> listDossiers({String? q}) async {
final uri = Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}')
.replace(
queryParameters: (q != null && q.trim().isNotEmpty)
? {'q': q.trim()}
: null,
);
final response = await http.get(uri, headers: await _headers());
if (response.statusCode != 200) {
throw Exception(
_extractErrorMessage(response.body, 'Erreur chargement dossiers'),
);
}
final decoded = jsonDecode(response.body);
if (decoded is! List) return const [];
return decoded
.whereType<Map>()
.map((e) => Map<String, dynamic>.from(e))
.toList();
}
/// Map `numero_dossier` → `sans_enfant` (familles uniquement).
static Future<Map<String, bool>> getSansEnfantByNumero() async {
final rows = await listDossiers();
final out = <String, bool>{};
for (final row in rows) {
final num = (row['numero_dossier'] ?? '').toString().trim();
if (num.isEmpty) continue;
final type = (row['type'] ?? '').toString().toLowerCase();
if (type != 'famille') continue;
out[num] = row['sans_enfant'] == true;
}
return out;
}
static Map<String, dynamic> _parseSuppressionBody(String body) {
if (body.trim().isEmpty) return <String, dynamic>{};
try {
final decoded = jsonDecode(body);
if (decoded is Map) {
return Map<String, dynamic>.from(decoded);
}
} catch (_) {}
return <String, dynamic>{};
} }
/// AM dont la liste d'enfants actifs contient [enfantId] (API actuelle). /// AM dont la liste d'enfants actifs contient [enfantId] (API actuelle).
@@ -765,155 +645,6 @@ class UserService {
return fallback; return fallback;
} }
/// Ajout dun co-parent sur foyer mono-parent (staff, #135).
/// `POST /parents/:pivotUserId/co-parent` — succès 201.
static Future<Map<String, dynamic>> addCoParent(
String pivotUserId, {
required Map<String, dynamic> body,
}) async {
final id = pivotUserId.trim();
if (id.isEmpty) {
throw Exception('Identifiant du parent pivot manquant.');
}
final http.Response response;
try {
response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$id/co-parent'),
headers: await _headers(),
body: jsonEncode(body),
);
} on http.ClientException {
throw Exception(
'Connexion au serveur impossible. Vérifiez votre réseau puis réessayez.',
);
}
if (response.statusCode == 200 || response.statusCode == 201) {
if (response.body.trim().isEmpty) return <String, dynamic>{};
try {
final decoded = jsonDecode(response.body);
if (decoded is Map) {
return Map<String, dynamic>.from(decoded);
}
} catch (_) {}
return <String, dynamic>{};
}
final message = _extractErrorMessage(
response.body,
'Erreur ajout co-parent',
);
if (response.statusCode == 409) {
throw Exception(
message.isNotEmpty ? message : 'Conflit : e-mail déjà utilisé.',
);
}
if (response.statusCode == 400) {
throw Exception(
message.isNotEmpty ? message : 'Données invalides (400).',
);
}
throw Exception(message);
}
/// Création dossier famille actif côté staff (#129).
/// `POST /parents/dossier` — body aligné sur register parent complet.
/// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur, par parent créé).
/// Ne pas utiliser `POST /auth/register/parent` (public / en_attente).
static Future<Map<String, dynamic>> createParentDossier(
Map<String, dynamic> body,
) async {
final http.Response response;
try {
response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parentsDossier}'),
headers: await _headers(),
body: jsonEncode(body),
);
} on http.ClientException {
throw Exception(
'Connexion au serveur impossible. Vérifiez votre réseau puis réessayez.',
);
}
if (response.statusCode == 200 || response.statusCode == 201) {
if (response.body.trim().isEmpty) return <String, dynamic>{};
try {
final decoded = jsonDecode(response.body);
if (decoded is Map) {
return Map<String, dynamic>.from(decoded);
}
} catch (_) {}
return <String, dynamic>{};
}
final message = _extractErrorMessage(
response.body,
'Erreur création dossier famille',
);
if (response.statusCode == 409) {
throw Exception(
message.isNotEmpty ? message : 'Conflit : e-mail déjà utilisé.',
);
}
if (response.statusCode == 400) {
throw Exception(
message.isNotEmpty ? message : 'Données invalides (400).',
);
}
throw Exception(message);
}
/// Création dossier AM actif côté staff (#156).
/// `POST /assistantes-maternelles/dossier` — body aligné sur register AM.
/// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur).
/// Ne pas utiliser `POST /auth/register/am` (public / en_attente).
static Future<Map<String, dynamic>> createAmDossier(
Map<String, dynamic> body,
) async {
final http.Response response;
try {
response = await http.post(
Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.assistantesMaternellesDossier}',
),
headers: await _headers(),
body: jsonEncode(body),
);
} on http.ClientException {
throw Exception(
'Connexion au serveur impossible. Vérifiez votre réseau puis réessayez.',
);
}
if (response.statusCode == 200 || response.statusCode == 201) {
if (response.body.trim().isEmpty) return <String, dynamic>{};
try {
final decoded = jsonDecode(response.body);
if (decoded is Map) {
return Map<String, dynamic>.from(decoded);
}
} catch (_) {}
return <String, dynamic>{};
}
final message = _extractErrorMessage(
response.body,
'Erreur création dossier AM',
);
if (response.statusCode == 409) {
throw Exception(message.isNotEmpty
? message
: 'Conflit : e-mail, NIR ou agrément déjà utilisé.');
}
if (response.statusCode == 400) {
throw Exception(
message.isNotEmpty ? message : 'Données invalides (400).',
);
}
throw Exception(message);
}
// Récupérer la liste des assistantes maternelles // Récupérer la liste des assistantes maternelles
static Future<List<AssistanteMaternelleModel>> static Future<List<AssistanteMaternelleModel>>
getAssistantesMaternelles() async { getAssistantesMaternelles() async {
@@ -1303,18 +1034,22 @@ class UserService {
return AppUser.fromJson(data); return AppUser.fromJson(data);
} }
/// DELETE /users/:id — cascades métier #159 / #160. static Future<void> deleteUser(String userId) async {
static Future<Map<String, dynamic>> deleteUser(String userId) async {
final response = await http.delete( final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'), Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'),
headers: await _headers(), headers: await _headers(),
); );
if (response.statusCode != 200 && response.statusCode != 204) { if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception( final decoded = jsonDecode(response.body);
_extractErrorMessage(response.body, 'Erreur suppression utilisateur'), if (decoded is Map<String, dynamic>) {
); final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur suppression utilisateur');
}
throw Exception('Erreur suppression utilisateur');
} }
return _parseSuppressionBody(response.body);
} }
} }
+1 -82
View File
@@ -1,4 +1,3 @@
import 'package:flutter/services.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart';
@@ -12,50 +11,10 @@ String formatIsoDateFr(String? s, {String ifEmpty = ''}) {
} }
} }
/// Affiche une date ISO en saisie guidée `jj / mm / aaaa`. /// Convertit `dd/MM/yyyy` (ou ISO) en `yyyy-MM-dd` pour l'API.
String formatIsoDateFrInput(String? s, {String ifEmpty = ''}) {
if (s == null || s.trim().isEmpty) return ifEmpty;
try {
final dt = DateTime.parse(s.trim());
return formatFrenchDateDigits(
DateFormat('ddMMyyyy').format(dt),
);
} catch (_) {
return formatFrenchDateDigits(s.replaceAll(RegExp(r'\D'), ''));
}
}
/// Formate jusqu’à 8 chiffres en `jj / mm / aaaa`.
String formatFrenchDateDigits(String digits) {
final d = digits.replaceAll(RegExp(r'\D'), '');
final limited = d.length > 8 ? d.substring(0, 8) : d;
final buf = StringBuffer();
for (var i = 0; i < limited.length; i++) {
if (i == 2 || i == 4) buf.write(' / ');
buf.write(limited[i]);
}
return buf.toString();
}
/// Convertit `dd/MM/yyyy`, `dd / MM / yyyy`, 8 chiffres ou ISO en `yyyy-MM-dd`.
String? parseFrDateToIso(String text) { String? parseFrDateToIso(String text) {
final t = text.trim(); final t = text.trim();
if (t.isEmpty) return null; if (t.isEmpty) return null;
final digits = t.replaceAll(RegExp(r'\D'), '');
if (digits.length == 8) {
final dd = digits.substring(0, 2);
final mm = digits.substring(2, 4);
final yyyy = digits.substring(4, 8);
try {
return DateFormat('dd/MM/yyyy')
.parseStrict('$dd/$mm/$yyyy')
.toIso8601String()
.split('T')
.first;
} catch (_) {}
}
try { try {
return DateFormat('dd/MM/yyyy') return DateFormat('dd/MM/yyyy')
.parseStrict(t) .parseStrict(t)
@@ -137,43 +96,3 @@ String formatChildAgeLabel({
return 'Né le ${formatIsoDateFr(birthDate)}'; return 'Né le ${formatIsoDateFr(birthDate)}';
} }
} }
/// Saisie date FR : 8 chiffres → `jj / mm / aaaa`.
class FrenchDateInputFormatter extends TextInputFormatter {
const FrenchDateInputFormatter();
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
final limited = digits.length > 8 ? digits.substring(0, 8) : digits;
final formatted = formatFrenchDateDigits(limited);
final digitsBeforeCursor = newValue.text
.substring(0, newValue.selection.start.clamp(0, newValue.text.length))
.replaceAll(RegExp(r'\D'), '')
.length
.clamp(0, limited.length);
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(
offset: _cursorOffset(formatted, digitsBeforeCursor),
),
);
}
static int _cursorOffset(String formatted, int digitsBeforeCursor) {
if (digitsBeforeCursor <= 0) return 0;
var seen = 0;
for (var i = 0; i < formatted.length; i++) {
if (RegExp(r'\d').hasMatch(formatted[i])) {
seen++;
if (seen >= digitsBeforeCursor) return i + 1;
}
}
return formatted.length;
}
}
-10
View File
@@ -1,10 +0,0 @@
import 'package:p_tits_pas/models/enfant_admin_model.dart';
/// Enfant sans lien parent (orphelins daffiliation) — ticket #157.
bool enfantHasNoResponsable(EnfantAdminModel enfant) => enfant.hasNoResponsable;
/// Message vigilance liste Enfants (même usage que [amPlacesVigilanceMessage]).
String? enfantSansResponsableVigilanceMessage(EnfantAdminModel enfant) {
if (!enfantHasNoResponsable(enfant)) return null;
return 'Aucun responsable rattaché — à rattacher à un foyer';
}
-41
View File
@@ -1,5 +1,3 @@
import 'package:flutter/services.dart';
/// Formatage affichage prénom / nom (capitalisation par mot et segments après `-` ou `'`). /// Formatage affichage prénom / nom (capitalisation par mot et segments après `-` ou `'`).
String formatPersonNameCase(String raw) { String formatPersonNameCase(String raw) {
@@ -11,22 +9,6 @@ String formatPersonNameCase(String raw) {
return words.map(_capitalizeComposedWord).join(' '); return words.map(_capitalizeComposedWord).join(' ');
} }
/// Variante saisie live : pas de majuscule tant que le mot na quune lettre ;
/// dès la 2ᵉ lettre, capitalisation comme [formatPersonNameCase].
String formatPersonNameCaseTyping(String raw) {
if (raw.isEmpty) return raw;
final trailingMatch = RegExp(r'(\s*)$').firstMatch(raw);
final trailing = trailingMatch?.group(1) ?? '';
final core = raw.substring(0, raw.length - trailing.length);
if (core.isEmpty) return raw;
final words = core.split(RegExp(r'\s+'));
final formatted = words.map((w) {
if (w.length < 2) return w;
return _capitalizeComposedWord(w);
}).join(' ');
return formatted + trailing;
}
String _capitalizeComposedWord(String word) { String _capitalizeComposedWord(String word) {
if (word.isEmpty) { if (word.isEmpty) {
return word; return word;
@@ -48,26 +30,3 @@ String _capitalizeComposedWord(String word) {
} }
return buffer.toString(); return buffer.toString();
} }
/// Formateur de saisie prénom / nom (capitalisation progressive).
class PersonNameInputFormatter extends TextInputFormatter {
const PersonNameInputFormatter();
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final formatted = formatPersonNameCaseTyping(newValue.text);
if (formatted == newValue.text) return newValue;
final sel = newValue.selection;
final offset = sel.isValid
? sel.baseOffset.clamp(0, formatted.length)
: formatted.length;
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: offset),
);
}
}
+9 -37
View File
@@ -49,21 +49,12 @@ String nirToRaw(String normalized) {
return s; return s;
} }
/// Formate pour affichage (complet ou en cours de saisie) : /// Formate pour affichage : 1 12 34 56 789 012 - 34 ou 1 12 34 2A 789 012 - 34 (Corse).
/// `1 12 34 56 789 012 - 34` ou `1 12 34 2A 789 012 - 34` (Corse).
String formatNir(String raw) { String formatNir(String raw) {
final r = nirToRaw(raw).toUpperCase(); final r = nirToRaw(raw);
if (r.isEmpty) return ''; if (r.length < 15) return r;
final buf = StringBuffer(); // Même structure pour tous : sexe + année + mois + département + commune + ordre-clé.
for (var i = 0; i < r.length && i < 15; i++) { return '${r.substring(0, 1)} ${r.substring(1, 3)} ${r.substring(3, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)} - ${r.substring(13, 15)}';
if (i == 1 || i == 3 || i == 5 || i == 7 || i == 10) {
buf.write(' ');
} else if (i == 13) {
buf.write(' - ');
}
buf.write(r[i]);
}
return buf.toString();
} }
/// Aligné sur le backend (NIR côté API / nir.util.ts) : sexe 13, département 2A ou 2B pour la Corse. /// Aligné sur le backend (NIR côté API / nir.util.ts) : sexe 13, département 2A ou 2B pour la Corse.
@@ -101,39 +92,20 @@ String? validateNir(String? value) {
return null; return null;
} }
/// Validation pendant la saisie : pas derreur si incomplet encore plausible ; /// Formateur de saisie : affiche le NIR formaté (1 12 34 56 789 012 - 34) et limite à 15 caractères utiles.
/// dès 15 caractères, même contrôles que [validateNir].
String? validateNirTyping(String? value) {
if (value == null || value.trim().isEmpty) return null;
final raw = nirToRaw(value).toUpperCase();
if (raw.isEmpty) return null;
if (raw[0] != '1' && raw[0] != '2' && raw[0] != '3') {
return 'Format NIR invalide (ex. 1 12 34 56 789 012 - 34 ou 2A pour la Corse)';
}
if (raw.length < 15) return null;
return validateNir(value);
}
/// Formateur de saisie : affiche le NIR formaté au fil de la frappe et limite à 15 caractères utiles.
class NirInputFormatter extends TextInputFormatter { class NirInputFormatter extends TextInputFormatter {
const NirInputFormatter();
@override @override
TextEditingValue formatEditUpdate( TextEditingValue formatEditUpdate(
TextEditingValue oldValue, TextEditingValue oldValue,
TextEditingValue newValue, TextEditingValue newValue,
) { ) {
final raw = normalizeNir(newValue.text); final raw = normalizeNir(newValue.text);
if (raw.isEmpty) { if (raw.isEmpty) return newValue;
return const TextEditingValue(
text: '',
selection: TextSelection.collapsed(offset: 0),
);
}
final formatted = formatNir(raw); final formatted = formatNir(raw);
final offset = formatted.length;
return TextEditingValue( return TextEditingValue(
text: formatted, text: formatted,
selection: TextSelection.collapsed(offset: formatted.length), selection: TextSelection.collapsed(offset: offset),
); );
} }
} }
@@ -166,6 +166,7 @@ class ParentRegistrationPayload {
static Map<String, dynamic> _childToJson(ChildData c, int index, String parentNom) { static Map<String, dynamic> _childToJson(ChildData c, int index, String parentNom) {
final map = <String, dynamic>{ final map = <String, dynamic>{
'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre', 'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre',
'grossesse_multiple': c.multipleBirth,
'consent_photo': c.photoConsent, 'consent_photo': c.photoConsent,
}; };
+1
View File
@@ -44,6 +44,7 @@ class RepriseMapper {
dob: dob, dob: dob,
genre: e.gender ?? '', genre: e.gender ?? '',
photoConsent: e.consentPhoto, photoConsent: e.consentPhoto,
multipleBirth: e.estMultiple,
isUnbornChild: isUnborn, isUnbornChild: isUnborn,
cardColor: _childCardColors[index % _childCardColors.length], cardColor: _childCardColors[index % _childCardColors.length],
repriseChildId: e.id, repriseChildId: e.id,
@@ -1,59 +0,0 @@
/// Création comptes staff (gestionnaire / admin) — ticket #161.
bool canCreateStaffAccounts(String? role) {
final r = (role ?? '').trim().toLowerCase();
return r == 'administrateur' || r == 'super_admin';
}
/// Droits daffichage poubelle — tickets #154 / #160.
bool canDeleteMetier(String? role) {
final r = (role ?? '').trim().toLowerCase();
return r == 'gestionnaire' ||
r == 'administrateur' ||
r == 'super_admin';
}
bool canDeleteGestionnaire(String? role) {
final r = (role ?? '').trim().toLowerCase();
return r == 'administrateur' || r == 'super_admin';
}
bool canDeleteAdministrateur({
required String? currentRole,
required String? currentUserId,
required String targetUserId,
required String targetRole,
required int adminCount,
}) {
final me = (currentRole ?? '').trim().toLowerCase();
if (me != 'administrateur' && me != 'super_admin') return false;
if (targetUserId.trim().isEmpty) return false;
if (currentUserId != null &&
currentUserId.trim() == targetUserId.trim()) {
return false;
}
final target = targetRole.trim().toLowerCase();
if (target == 'super_admin') return false;
if (adminCount <= 1 && me != 'super_admin') return false;
return true;
}
String? adminDeleteBlockedReason({
required String? currentRole,
required String? currentUserId,
required String targetUserId,
required String targetRole,
required int adminCount,
}) {
if (currentUserId != null &&
currentUserId.trim() == targetUserId.trim()) {
return 'Vous ne pouvez pas supprimer votre propre compte.';
}
if (targetRole.trim().toLowerCase() == 'super_admin') {
return 'Le super administrateur ne peut pas être supprimé.';
}
if (adminCount <= 1 &&
(currentRole ?? '').trim().toLowerCase() != 'super_admin') {
return 'Seul un super administrateur peut supprimer le dernier administrateur.';
}
return null;
}
@@ -4,10 +4,8 @@ import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart'; import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/dashboard/user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/dashboard/common/user_list.dart';
class AdminManagementWidget extends StatefulWidget { class AdminManagementWidget extends StatefulWidget {
final String searchQuery; final String searchQuery;
@@ -26,7 +24,6 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
String? _error; String? _error;
List<AppUser> _admins = []; List<AppUser> _admins = [];
String? _currentUserRole; String? _currentUserRole;
String? _currentUserId;
@override @override
void initState() { void initState() {
@@ -65,7 +62,6 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
if (cached != null) { if (cached != null) {
setState(() { setState(() {
_currentUserRole = (cached.role).toLowerCase(); _currentUserRole = (cached.role).toLowerCase();
_currentUserId = cached.id;
}); });
return; return;
} }
@@ -73,7 +69,6 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
if (!mounted || refreshed == null) return; if (!mounted || refreshed == null) return;
setState(() { setState(() {
_currentUserRole = (refreshed.role).toLowerCase(); _currentUserRole = (refreshed.role).toLowerCase();
_currentUserId = refreshed.id;
}); });
} }
@@ -84,16 +79,6 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
return _currentUserRole == 'super_admin'; return _currentUserRole == 'super_admin';
} }
bool _canDeleteAdmin(AppUser target) {
return canDeleteAdministrateur(
currentRole: _currentUserRole,
currentUserId: _currentUserId,
targetUserId: target.id,
targetRole: target.role,
adminCount: _admins.length,
);
}
Future<void> _openAdminEditDialog(AppUser user) async { Future<void> _openAdminEditDialog(AppUser user) async {
final canEdit = _canEditAdmin(user); final canEdit = _canEditAdmin(user);
final changed = await showDialog<bool>( final changed = await showDialog<bool>(
@@ -113,56 +98,6 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
} }
} }
Future<void> _confirmDelete(AppUser user) async {
final blocked = adminDeleteBlockedReason(
currentRole: _currentUserRole,
currentUserId: _currentUserId,
targetUserId: user.id,
targetRole: user.role,
adminCount: _admins.length,
);
if (blocked != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(blocked)),
);
return;
}
final name = user.fullName.isNotEmpty ? user.fullName : user.email;
final isLast = _admins.length <= 1;
final confirmed = await showSuppressionConfirmDialog(
context,
title: 'Supprimer l\'administrateur',
people: [SuppressionPersonLine.administrateur(name)],
footnotes: [
if (isLast)
'Attention : cest le dernier administrateur.',
'Le compte sera définitivement supprimé.',
],
);
if (!confirmed || !mounted) return;
try {
final result = await UserService.deleteUser(user.id);
if (!mounted) return;
final msg =
(result['message'] ?? 'Administrateur supprimé.').toString();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
await _loadAdmins();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur suppression',
),
),
);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase(); final query = widget.searchQuery.toLowerCase();
@@ -182,8 +117,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
final user = filteredAdmins[index]; final user = filteredAdmins[index];
final isSuperAdmin = _isSuperAdmin(user); final isSuperAdmin = _isSuperAdmin(user);
final canEdit = _canEditAdmin(user); final canEdit = _canEditAdmin(user);
final canDelete = _canDeleteAdmin(user); return AdminUserCard(
return UserCard(
title: user.fullName, title: user.fullName,
fallbackIcon: isSuperAdmin fallbackIcon: isSuperAdmin
? Icons.verified_user_outlined ? Icons.verified_user_outlined
@@ -214,8 +148,6 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
_openAdminEditDialog(user); _openAdminEditDialog(user);
}, },
), ),
if (canDelete)
suppressionIconButton(onPressed: () => _confirmDelete(user)),
], ],
); );
}, },
@@ -1,14 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/models/dossier_list_item.dart';
import 'package:p_tits_pas/utils/am_vigilance.dart'; import 'package:p_tits_pas/utils/am_vigilance.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/am_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/dashboard/user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/dashboard/common/user_list.dart';
class AssistanteMaternelleManagementWidget extends StatefulWidget { class AssistanteMaternelleManagementWidget extends StatefulWidget {
final String searchQuery; final String searchQuery;
@@ -30,24 +26,16 @@ class _AssistanteMaternelleManagementWidgetState
bool _isLoading = false; bool _isLoading = false;
String? _error; String? _error;
List<AssistanteMaternelleModel> _assistantes = []; List<AssistanteMaternelleModel> _assistantes = [];
bool _canDelete = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadRights();
_loadAssistantes(); _loadAssistantes();
} }
@override @override
void dispose() => super.dispose(); void dispose() => super.dispose();
Future<void> _loadRights() async {
final user = await AuthService.getCurrentUser();
if (!mounted) return;
setState(() => _canDelete = canDeleteMetier(user?.role));
}
Future<void> _loadAssistantes() async { Future<void> _loadAssistantes() async {
setState(() { setState(() {
_isLoading = true; _isLoading = true;
@@ -69,46 +57,6 @@ class _AssistanteMaternelleManagementWidgetState
} }
} }
Future<void> _confirmDelete(AssistanteMaternelleModel am) async {
final num = (am.user.numeroDossier ?? '').trim();
final name = formatDossierPersonLabel(
nom: am.user.nom,
prenom: am.user.prenom,
email: am.user.email,
);
final confirmed = await showSuppressionConfirmDialog(
context,
title: 'Supprimer l\'assistante maternelle',
subtitle: num.isEmpty ? null : 'Dossier $num',
people: [SuppressionPersonLine.am(name)],
footnotes: const [
'Le compte et le dossier AM seront supprimés.',
'Les enfants accueillis ne seront pas supprimés '
'(placements clos).',
],
);
if (!confirmed || !mounted) return;
try {
final result = await UserService.deleteUser(am.user.id);
if (!mounted) return;
final msg = (result['message'] ?? 'AM supprimée.').toString();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
await _loadAssistantes();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur suppression',
),
),
);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase(); final query = widget.searchQuery.toLowerCase();
@@ -130,7 +78,7 @@ class _AssistanteMaternelleManagementWidgetState
itemBuilder: (context, index) { itemBuilder: (context, index) {
final assistante = filteredAssistantes[index]; final assistante = filteredAssistantes[index];
final vigilance = amPlacesVigilanceMessage(assistante); final vigilance = amPlacesVigilanceMessage(assistante);
return UserCard( return AdminUserCard(
title: assistante.user.fullName, title: assistante.user.fullName,
avatarUrl: assistante.user.photoUrl, avatarUrl: assistante.user.photoUrl,
fallbackIcon: Icons.face, fallbackIcon: Icons.face,
@@ -148,10 +96,6 @@ class _AssistanteMaternelleManagementWidgetState
_openAssistanteDetails(assistante); _openAssistanteDetails(assistante);
}, },
), ),
if (_canDelete)
suppressionIconButton(
onPressed: () => _confirmDelete(assistante),
),
], ],
); );
}, },
@@ -161,7 +105,7 @@ class _AssistanteMaternelleManagementWidgetState
void _openAssistanteDetails(AssistanteMaternelleModel assistante) { void _openAssistanteDetails(AssistanteMaternelleModel assistante) {
showDialog<void>( showDialog<void>(
context: context, context: context,
builder: (context) => AmEditModal( builder: (context) => AdminAmEditModal(
assistante: assistante, assistante: assistante,
onSaved: _loadAssistantes, onSaved: _loadAssistantes,
), ),
@@ -5,7 +5,7 @@ import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.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]). /// Grille 2×2 des places d'accueil AM (max 4, limitée à [capacity]).
class AmChildrenCapacityGrid extends StatelessWidget { class AdminAmChildrenCapacityGrid extends StatelessWidget {
static const int _gridSlots = 4; static const int _gridSlots = 4;
static const double _slotHeight = 44; static const double _slotHeight = 44;
static const double _gridPadding = 10; static const double _gridPadding = 10;
@@ -23,7 +23,7 @@ class AmChildrenCapacityGrid extends StatelessWidget {
/// Clic sur une case libre → même flux que « Rattacher un enfant » (#149). /// Clic sur une case libre → même flux que « Rattacher un enfant » (#149).
final VoidCallback? onAttachEmpty; final VoidCallback? onAttachEmpty;
const AmChildrenCapacityGrid({ const AdminAmChildrenCapacityGrid({
super.key, super.key,
required this.children, required this.children,
required this.capacity, required this.capacity,
@@ -7,31 +7,31 @@ 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/nir_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/dashboard/am_children_capacity_grid.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_children_capacity_grid.dart';
import 'package:p_tits_pas/widgets/dashboard/am_photo_frame.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
import 'package:p_tits_pas/widgets/dashboard/child_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/select_enfant_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/status_capsule.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
import 'package:p_tits_pas/widgets/dashboard/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
/// Fiche AM éditable (ticket #131) — identité | fiche pro (photo) | enfants. /// Fiche AM éditable (ticket #131) — identité | fiche pro (photo) | enfants.
class AmEditModal extends StatefulWidget { class AdminAmEditModal extends StatefulWidget {
final AssistanteMaternelleModel assistante; final AssistanteMaternelleModel assistante;
final VoidCallback? onSaved; final VoidCallback? onSaved;
const AmEditModal({ const AdminAmEditModal({
super.key, super.key,
required this.assistante, required this.assistante,
this.onSaved, this.onSaved,
}); });
@override @override
State<AmEditModal> createState() => _AmEditModalState(); State<AdminAmEditModal> createState() => _AdminAmEditModalState();
} }
class _AmEditModalState extends State<AmEditModal> class _AdminAmEditModalState extends State<AdminAmEditModal>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late final TabController _tabCtrl; late final TabController _tabCtrl;
late final TextEditingController _nomCtrl; late final TextEditingController _nomCtrl;
@@ -69,6 +69,29 @@ class _AmEditModalState extends State<AmEditModal>
static const double _proTabHeight = 300; static const double _proTabHeight = 300;
static const List<int> _photoProRowLayout = [2, 2, 2]; 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 @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -404,7 +427,7 @@ class _AmEditModalState extends State<AmEditModal>
if (!mounted) return; if (!mounted) return;
await showDialog<void>( await showDialog<void>(
context: context, context: context,
builder: (ctx) => ChildDetailModal( builder: (ctx) => AdminChildDetailModal(
enfant: enfant, enfant: enfant,
onSaved: _refreshChildrenDetails, onSaved: _refreshChildrenDetails,
), ),
@@ -423,7 +446,8 @@ class _AmEditModalState extends State<AmEditModal>
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Détacher l\'enfant'), title: const Text('Détacher l\'enfant'),
content: Text( content: Text(
'Retirer ${child.fullName} de la fiche de cette assistante ?', 'Retirer ${child.fullName} de la fiche de cette assistante ?\n'
'(L\'enfant ne sera pas supprimé.)',
), ),
actions: [ actions: [
TextButton( TextButton(
@@ -450,7 +474,7 @@ class _AmEditModalState extends State<AmEditModal>
Future<void> _attachChild() async { Future<void> _attachChild() async {
if (_capacityFull || !mounted) return; if (_capacityFull || !mounted) return;
final selected = await SelectEnfantModal.show( final selected = await AdminSelectEnfantModal.show(
context, context,
excludeIds: _children.map((c) => c.id).toSet(), excludeIds: _children.map((c) => c.id).toSet(),
title: 'Rattacher un enfant', title: 'Rattacher un enfant',
@@ -518,15 +542,17 @@ class _AmEditModalState extends State<AmEditModal>
Widget _identityTab() { Widget _identityTab() {
return IdentityBlock.editable( return SingleChildScrollView(
title: 'Identité et coordonnées', child: IdentityBlock.editable(
nomController: _nomCtrl, title: 'Identité et coordonnées',
prenomController: _prenomCtrl, nomController: _nomCtrl,
telephoneController: _telCtrl, prenomController: _prenomCtrl,
emailController: _emailCtrl, telephoneController: _telCtrl,
adresseController: _adresseCtrl, emailController: _emailCtrl,
codePostalController: _cpCtrl, adresseController: _adresseCtrl,
villeController: _villeCtrl, codePostalController: _cpCtrl,
villeController: _villeCtrl,
),
); );
} }
@@ -585,11 +611,9 @@ class _AmEditModalState extends State<AmEditModal>
return LayoutBuilder( return LayoutBuilder(
builder: (context, c) { builder: (context, c) {
final maxRowW = c.maxWidth; final maxRowW = c.maxWidth;
// Hauteur bornée : longlet pro a une hauteur fixe (photo ID). final maxRowH = c.maxHeight;
final maxRowH =
c.maxHeight.isFinite ? c.maxHeight : _proTabHeight;
final bodyH = maxRowH; final bodyH = maxRowH;
final idealPhotoW = bodyH * AmPhotoFrame.idPhotoAspectRatio + 16; final idealPhotoW = bodyH * AdminAmPhotoFrame.idPhotoAspectRatio + 16;
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth) final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
.clamp(0.0, double.infinity); .clamp(0.0, double.infinity);
var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 220.0); var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 220.0);
@@ -600,7 +624,7 @@ class _AmEditModalState extends State<AmEditModal>
children: [ children: [
SizedBox( SizedBox(
width: photoW, width: photoW,
child: AmPhotoFrame( child: AdminAmPhotoFrame(
photoUrl: widget.assistante.user.photoUrl, photoUrl: widget.assistante.user.photoUrl,
), ),
), ),
@@ -636,18 +660,6 @@ class _AmEditModalState extends State<AmEditModal>
); );
} }
/// Corps de longlet actif — hauteur naturelle (pas de TabBarView).
Widget _buildActiveTabBody() {
switch (_tabCtrl.index) {
case 1:
return SizedBox(height: _proTabHeight, child: _proTab());
case 2:
return _childrenTab();
default:
return _identityTab();
}
}
Widget _childrenCapacityFields() { Widget _childrenCapacityFields() {
final inconsistent = _placesInconsistent(); final inconsistent = _placesInconsistent();
return Column( return Column(
@@ -728,7 +740,7 @@ class _AmEditModalState extends State<AmEditModal>
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
AmChildrenCapacityGrid( AdminAmChildrenCapacityGrid(
children: _children, children: _children,
capacity: capacity, capacity: capacity,
onOpen: _openChild, onOpen: _openChild,
@@ -820,7 +832,7 @@ class _AmEditModalState extends State<AmEditModal>
const SizedBox(width: 12), const SizedBox(width: 12),
SizedBox( SizedBox(
width: 160, width: 160,
child: StatusCapsule( child: AdminStatusCapsule(
statut: _statut, statut: _statut,
onChanged: (v) => setState(() { onChanged: (v) => setState(() {
_statut = v; _statut = v;
@@ -856,13 +868,15 @@ class _AmEditModalState extends State<AmEditModal>
const Divider(height: 1), const Divider(height: 1),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
child: AnimatedSize( child: SizedBox(
duration: const Duration(milliseconds: 180), height: _tabViewHeight(_tabCtrl.index),
curve: Curves.easeInOut, child: TabBarView(
alignment: Alignment.topCenter, controller: _tabCtrl,
child: KeyedSubtree( children: [
key: ValueKey<int>(_tabCtrl.index), _identityTab(),
child: _buildActiveTabBody(), _proTab(),
_childrenTab(),
],
), ),
), ),
), ),
@@ -5,7 +5,7 @@ import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
/// Cadre photo identité AM / enfant (35×45 mm) — même logique que [ValidationAmWizard]. /// Cadre photo identité AM / enfant (35×45 mm) — même logique que [ValidationAmWizard].
class AmPhotoFrame extends StatelessWidget { class AdminAmPhotoFrame extends StatelessWidget {
final String? photoUrl; final String? photoUrl;
final Uint8List? imageBytes; final Uint8List? imageBytes;
final VoidCallback? onTap; final VoidCallback? onTap;
@@ -14,7 +14,7 @@ class AmPhotoFrame extends StatelessWidget {
static const double idPhotoAspectRatio = 35 / 45; static const double idPhotoAspectRatio = 35 / 45;
const AmPhotoFrame({ const AdminAmPhotoFrame({
super.key, super.key,
this.photoUrl, this.photoUrl,
this.imageBytes, this.imageBytes,
@@ -1,34 +1,31 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/models/dossier_list_item.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
import 'package:p_tits_pas/widgets/dashboard/am_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/am_photo_frame.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_select_am_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/parent_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_select_famille_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/select_am_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/dashboard/select_famille_modal.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/dashboard/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
/// Fiche enfant consultation / édition (#138) ou création (#132). /// Fiche enfant consultation / édition (#138) ou création (#132).
class ChildDetailModal extends StatefulWidget { class AdminChildDetailModal extends StatefulWidget {
final EnfantAdminModel? enfant; final EnfantAdminModel? enfant;
final VoidCallback? onSaved; final VoidCallback? onSaved;
final VoidCallback? onDeleted; final VoidCallback? onDeleted;
final bool isCreating; final bool isCreating;
const ChildDetailModal({ const AdminChildDetailModal({
super.key, super.key,
required EnfantAdminModel this.enfant, required EnfantAdminModel this.enfant,
this.onSaved, this.onSaved,
@@ -36,7 +33,7 @@ class ChildDetailModal extends StatefulWidget {
}) : isCreating = false; }) : isCreating = false;
/// Création depuis l'onglet Enfants (#132). /// Création depuis l'onglet Enfants (#132).
const ChildDetailModal.create({ const AdminChildDetailModal.create({
super.key, super.key,
this.onSaved, this.onSaved,
}) : enfant = null, }) : enfant = null,
@@ -44,17 +41,18 @@ class ChildDetailModal extends StatefulWidget {
isCreating = true; isCreating = true;
@override @override
State<ChildDetailModal> createState() => _ChildDetailModalState(); State<AdminChildDetailModal> createState() => _AdminChildDetailModalState();
} }
class _ChildDetailModalState extends State<ChildDetailModal> { class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
late final TextEditingController _prenomCtrl; late final TextEditingController _prenomCtrl;
late final TextEditingController _nomCtrl; late final TextEditingController _nomCtrl;
late final TextEditingController _birthCtrl; late final TextEditingController _birthCtrl;
late final TextEditingController _dueCtrl; late final TextEditingController _dueCtrl;
late String _status; late String _status;
late String? _gender; late String _gender;
late bool _consentPhoto; late bool _consentPhoto;
late bool _isMultiple;
bool _dirty = false; bool _dirty = false;
bool _saving = false; bool _saving = false;
bool _deleting = false; bool _deleting = false;
@@ -65,10 +63,7 @@ class _ChildDetailModalState extends State<ChildDetailModal> {
String? _baselineAmUserId; String? _baselineAmUserId;
/// Famille choisie en mode création (#132). /// Famille choisie en mode création (#132).
FamilleFoyer? _selectedFamily; AdminFamilleFoyer? _selectedFamily;
/// Liens parents locaux (édition) — mis à jour après rattachement foyer (#157).
List<EnfantParentLink>? _localParentLinks;
/// Photo locale (création) — upload multipart `photo`. /// Photo locale (création) — upload multipart `photo`.
Uint8List? _photoBytes; Uint8List? _photoBytes;
@@ -99,26 +94,11 @@ class _ChildDetailModalState extends State<ChildDetailModal> {
_isUnborn ? 'Date prévisionnelle' : 'Date de naissance'; _isUnborn ? 'Date prévisionnelle' : 'Date de naissance';
bool get _canDelete => bool get _canDelete =>
!widget.isCreating && canDeleteMetier(_currentUserRole); !widget.isCreating &&
(_currentUserRole ?? '').toLowerCase() == 'super_admin';
bool get _busy => _saving || _deleting; bool get _busy => _saving || _deleting;
/// Création : tous les champs sauf photo / consentement ; édition : au moins une modif.
bool get _canSubmit {
if (_busy) return false;
if (widget.isCreating) return _isCreateFormComplete;
return _dirty;
}
bool get _isCreateFormComplete {
if (_selectedFamily == null) return false;
if (formatPersonNameCase(_prenomCtrl.text).isEmpty) return false;
if (formatPersonNameCase(_nomCtrl.text).isEmpty) return false;
if (_dateToIso(_activeDateCtrl.text) == null) return false;
if (_gender == null || !_availableGenders.contains(_gender)) return false;
return true;
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -126,60 +106,34 @@ class _ChildDetailModalState extends State<ChildDetailModal> {
_prenomCtrl = TextEditingController(text: e?.firstName ?? ''); _prenomCtrl = TextEditingController(text: e?.firstName ?? '');
_nomCtrl = TextEditingController(text: e?.lastName ?? ''); _nomCtrl = TextEditingController(text: e?.lastName ?? '');
_birthCtrl = TextEditingController( _birthCtrl = TextEditingController(
text: formatIsoDateFrInput(e?.birthDate, ifEmpty: ''), text: formatIsoDateFr(e?.birthDate, ifEmpty: ''),
); );
_dueCtrl = TextEditingController( _dueCtrl = TextEditingController(
text: formatIsoDateFrInput(e?.dueDate, ifEmpty: ''), text: formatIsoDateFr(e?.dueDate, ifEmpty: ''),
); );
_status = normalizeEnfantStatus(e?.status); _status = normalizeEnfantStatus(e?.status);
if (!enfantStatusValues.contains(_status)) { if (!enfantStatusValues.contains(_status)) {
_status = 'sans_garde'; _status = 'sans_garde';
} }
if (widget.isCreating) { _gender = _normalizeGender(e?.gender, allowUnknown: _isUnborn);
_gender = null;
} else {
_gender = _normalizeGender(e?.gender, allowUnknown: _isUnborn);
}
_consentPhoto = e?.consentPhoto ?? false; _consentPhoto = e?.consentPhoto ?? false;
for (final c in [_prenomCtrl, _nomCtrl]) { _isMultiple = e?.isMultiple ?? false;
c.addListener(_onNameChanged); for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) {
} c.addListener(_markDirty);
for (final c in [_birthCtrl, _dueCtrl]) {
c.addListener(_onDateChanged);
} }
_prenomCtrl.addListener(_onNameChanged);
_nomCtrl.addListener(_onNameChanged);
_loadCurrentUserRole(); _loadCurrentUserRole();
if (widget.isCreating) { if (widget.isCreating) {
_loadingAm = false; _loadingAm = false;
_dirty = true; _dirty = true;
} else { } else {
_prenomCtrl.addListener(_markDirty);
_nomCtrl.addListener(_markDirty);
_loadLinkedAm(); _loadLinkedAm();
} }
} }
void _onNameChanged() => setState(() {}); void _onNameChanged() => setState(() {});
void _onDateChanged() {
setState(() => _dirty = true);
}
/// Date prévisionnelle strictement avant aujourdhui (statut à naître).
bool get _dueDateInPast {
if (!_isUnborn) return false;
final iso = _dateToIso(_dueCtrl.text);
if (iso == null) return false;
try {
final due = DateTime.parse(iso);
final now = DateTime.now();
final dueDay = DateTime(due.year, due.month, due.day);
final today = DateTime(now.year, now.month, now.day);
return dueDay.isBefore(today);
} catch (_) {
return false;
}
}
Future<void> _loadLinkedAm() async { Future<void> _loadLinkedAm() async {
if (widget.isCreating) { if (widget.isCreating) {
setState(() => _loadingAm = false); setState(() => _loadingAm = false);
@@ -237,8 +191,8 @@ class _ChildDetailModalState extends State<ChildDetailModal> {
} }
void _coerceGenderForStatus() { void _coerceGenderForStatus() {
if (_gender != null && !_availableGenders.contains(_gender)) { if (!_availableGenders.contains(_gender)) {
_gender = null; _gender = 'H';
} }
} }
@@ -265,14 +219,10 @@ class _ChildDetailModalState extends State<ChildDetailModal> {
} }
List<EnfantParentLink> get _parentLinks => List<EnfantParentLink> get _parentLinks =>
(_localParentLinks ?? (widget.enfant?.parentLinks ?? const <EnfantParentLink>[])
widget.enfant?.parentLinks ??
const <EnfantParentLink>[])
.where((l) => l.parentId.trim().isNotEmpty) .where((l) => l.parentId.trim().isNotEmpty)
.toList(); .toList();
bool get _isOrphan => !widget.isCreating && _parentLinks.isEmpty;
Future<void> _openParent(EnfantParentLink link) async { Future<void> _openParent(EnfantParentLink link) async {
if (_busy) return; if (_busy) return;
final id = link.parentId.trim(); final id = link.parentId.trim();
@@ -283,7 +233,7 @@ class _ChildDetailModalState extends State<ChildDetailModal> {
if (!mounted) return; if (!mounted) return;
await showDialog<void>( await showDialog<void>(
context: context, context: context,
builder: (ctx) => ParentEditModal( builder: (ctx) => AdminParentEditModal(
parent: parent, parent: parent,
onSaved: () => widget.onSaved?.call(), onSaved: () => widget.onSaved?.call(),
), ),
@@ -371,8 +321,8 @@ class _ChildDetailModalState extends State<ChildDetailModal> {
await UserService.updateEnfant( await UserService.updateEnfant(
enfantId: enfantId, enfantId: enfantId,
body: { body: {
'first_name': formatPersonNameCase(_prenomCtrl.text), 'first_name': _prenomCtrl.text.trim(),
'last_name': formatPersonNameCase(_nomCtrl.text), 'last_name': _nomCtrl.text.trim(),
'status': _status, 'status': _status,
'gender': _gender, 'gender': _gender,
if (_isUnborn) if (_isUnborn)
@@ -381,6 +331,7 @@ class _ChildDetailModalState extends State<ChildDetailModal> {
else if (_dateToIso(_birthCtrl.text) != null) else if (_dateToIso(_birthCtrl.text) != null)
'birth_date': _dateToIso(_birthCtrl.text), 'birth_date': _dateToIso(_birthCtrl.text),
'consent_photo': _consentPhoto, 'consent_photo': _consentPhoto,
'is_multiple': _isMultiple,
}, },
); );
if (!mounted) return; if (!mounted) return;
@@ -412,8 +363,8 @@ class _ChildDetailModalState extends State<ChildDetailModal> {
); );
return; return;
} }
final prenom = formatPersonNameCase(_prenomCtrl.text); final prenom = _prenomCtrl.text.trim();
final nom = formatPersonNameCase(_nomCtrl.text); final nom = _nomCtrl.text.trim();
if (prenom.isEmpty || nom.isEmpty) { if (prenom.isEmpty || nom.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Prénom et nom sont obligatoires')), const SnackBar(content: Text('Prénom et nom sont obligatoires')),
@@ -449,14 +400,13 @@ class _ChildDetailModalState extends State<ChildDetailModal> {
'last_name': nom, 'last_name': nom,
'status': _status, 'status': _status,
'gender': _gender, 'gender': _gender,
if (_isUnborn) ...{ if (_isUnborn)
if (_dateToIso(_dueCtrl.text) != null) if (_dateToIso(_dueCtrl.text) != null)
'due_date': _dateToIso(_dueCtrl.text), 'due_date': _dateToIso(_dueCtrl.text)
} else ...{ else if (_dateToIso(_birthCtrl.text) != null)
if (_dateToIso(_birthCtrl.text) != null)
'birth_date': _dateToIso(_birthCtrl.text), 'birth_date': _dateToIso(_birthCtrl.text),
},
'consent_photo': _consentPhoto, 'consent_photo': _consentPhoto,
'is_multiple': _isMultiple,
}, },
); );
if (!mounted) return; if (!mounted) return;
@@ -504,99 +454,37 @@ class _ChildDetailModalState extends State<ChildDetailModal> {
if (!_canDelete || _deleting) return; if (!_canDelete || _deleting) return;
final name = _headerTitle(); final name = _headerTitle();
final enfant = widget.enfant; final confirmed = await showDialog<bool>(
if (enfant == null) return; context: context,
builder: (ctx) => AlertDialog(
bool deleteDossierFlag = false; title: const Text('Supprimer l\'enfant'),
String? numero; content: Text(
String familleLabel = ''; 'Supprimer définitivement la fiche de $name ?\n'
bool isLast = false; 'Cette action est irréversible.',
String? amLabel;
final linked = _linkedAm;
if (linked != null) {
final label = formatDossierPersonLabel(
nom: linked.user.nom,
prenom: linked.user.prenom,
email: linked.user.email,
);
if (label.isNotEmpty) amLabel = label;
} else {
try {
final am = await UserService.findAmForEnfant(enfant.id);
if (am != null) {
final label = formatDossierPersonLabel(
nom: am.user.nom,
prenom: am.user.prenom,
email: am.user.email,
);
if (label.isNotEmpty) amLabel = label;
}
} catch (_) {}
}
final parentId = enfant.parentLinks
.map((l) => l.parentId.trim())
.firstWhere((id) => id.isNotEmpty, orElse: () => '');
if (parentId.isNotEmpty) {
try {
final parent = await UserService.getParent(parentId);
final num = (parent.user.numeroDossier ?? '').trim();
numero = num;
familleLabel = parent.user.fullName.isNotEmpty
? parent.user.fullName
: parent.user.email;
if (num.isNotEmpty) {
final dossier = await UserService.getDossier(num);
if (dossier.isFamily) {
isLast = dossier.asFamily.enfants.length <= 1;
final names = dossier.asFamily.parents
.map((p) => formatDossierPersonLabel(
nom: p.nom,
prenom: p.prenom,
email: p.email,
))
.where((s) => s.isNotEmpty)
.join(' - ');
if (names.isNotEmpty) familleLabel = names;
}
}
} catch (_) {}
}
if (!mounted) return;
if (isLast && (numero ?? '').isNotEmpty) {
final choice = await showDernierEnfantSuppressionDialog(
context,
enfantName: name,
familleLabel: familleLabel,
numeroDossier: numero!,
amLabel: amLabel,
);
if (choice == null || !mounted) return;
deleteDossierFlag =
choice == DernierEnfantSuppressionChoice.dossierAussi;
} else {
final confirmed = await showSuppressionConfirmDialog(
context,
title: 'Supprimer l\'enfant',
subtitle: (numero ?? '').isEmpty ? null : 'Dossier $numero',
people: [SuppressionPersonLine.enfant(name)],
footnotes: enfantSuppressionFootnotes(
numeroDossier: numero,
familleLabel: familleLabel,
amLabel: amLabel,
), ),
); actions: [
if (!confirmed || !mounted) return; TextButton(
} onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade700,
foregroundColor: Colors.white,
),
child: const Text('Supprimer'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _deleting = true); setState(() => _deleting = true);
try { try {
final id = widget.enfant?.id; final id = widget.enfant?.id;
if (id == null || id.isEmpty) return; if (id == null || id.isEmpty) return;
await UserService.deleteEnfant(id, deleteDossier: deleteDossierFlag); await UserService.deleteEnfant(id);
if (!mounted) return; if (!mounted) return;
widget.onDeleted?.call(); widget.onDeleted?.call();
widget.onSaved?.call(); widget.onSaved?.call();
@@ -608,13 +496,7 @@ class _ChildDetailModalState extends State<ChildDetailModal> {
if (!mounted) return; if (!mounted) return;
setState(() => _deleting = false); setState(() => _deleting = false);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
content: Text(
e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur suppression',
),
),
); );
} }
} }
@@ -624,7 +506,7 @@ class _ChildDetailModalState extends State<ChildDetailModal> {
if (am == null) return; if (am == null) return;
await showDialog<void>( await showDialog<void>(
context: context, context: context,
builder: (ctx) => AmEditModal( builder: (ctx) => AdminAmEditModal(
assistante: am, assistante: am,
onSaved: () async { onSaved: () async {
await _reloadPlacementFromServer(); await _reloadPlacementFromServer();
@@ -653,7 +535,6 @@ class _ChildDetailModalState extends State<ChildDetailModal> {
if (!enfantStatusValues.contains(_status)) { if (!enfantStatusValues.contains(_status)) {
_status = 'sans_garde'; _status = 'sans_garde';
} }
_localParentLinks = enfant.parentLinks;
_coerceGenderForStatus(); _coerceGenderForStatus();
}); });
} catch (_) { } catch (_) {
@@ -676,7 +557,7 @@ class _ChildDetailModalState extends State<ChildDetailModal> {
} }
if (!mounted) return; if (!mounted) return;
final selected = await SelectAmModal.show( final selected = await AdminSelectAmModal.show(
context, context,
excludeIds: { excludeIds: {
if (_linkedAm != null) _linkedAm!.user.id, if (_linkedAm != null) _linkedAm!.user.id,
@@ -739,145 +620,70 @@ class _ChildDetailModalState extends State<ChildDetailModal> {
}); });
} }
/// Conserve la date saisie en basculant naissance ↔ prévisionnelle. InputDecoration _inputDecoration({String? hint}) {
void _preserveDateOnStatusChange(String from, String to) { return ValidationFieldDecoration.input(hint: hint).copyWith(
final becomingUnborn = to == 'a_naitre' && from != 'a_naitre';
final leavingUnborn = from == 'a_naitre' && to != 'a_naitre';
if (becomingUnborn) {
if (_dueCtrl.text.trim().isEmpty &&
_birthCtrl.text.trim().isNotEmpty) {
_dueCtrl.text = _birthCtrl.text;
}
} else if (leavingUnborn) {
if (_birthCtrl.text.trim().isEmpty &&
_dueCtrl.text.trim().isNotEmpty) {
_birthCtrl.text = _dueCtrl.text;
}
}
}
InputDecoration _inputDecoration({String? hint, bool error = false}) {
final base = ValidationFieldDecoration.input(hint: hint).copyWith(
isDense: false, isDense: false,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
); );
if (!error) return base;
final border = OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: Colors.red.shade400),
);
return base.copyWith(
fillColor: Colors.red.shade50,
enabledBorder: border,
focusedBorder: border,
border: border,
);
} }
Widget _dropdownField({ Widget _dropdownField({
Key? key, Key? key,
required String? value, required String value,
required List<MapEntry<String, String>> items, required List<MapEntry<String, String>> items,
required ValueChanged<String> onChanged, required ValueChanged<String> onChanged,
String? hint,
}) { }) {
final safeValue = final safeValue =
value != null && items.any((e) => e.key == value) ? value : null; items.any((e) => e.key == value) ? value : items.first.key;
return SizedBox( return SizedBox(
height: _fieldHeight, height: _fieldHeight,
child: DropdownButtonFormField<String>( child: DropdownButtonFormField<String>(
key: key, key: key,
value: safeValue, value: safeValue,
hint: hint != null
? Text(hint, style: TextStyle(color: Colors.grey.shade600))
: null,
isExpanded: true, isExpanded: true,
decoration: _inputDecoration(), decoration: _inputDecoration(),
items: items items: items
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
.toList(), .toList(),
onChanged: _busy onChanged: _busy ? null : (v) {
? null if (v != null) onChanged(v);
: (v) { },
if (v != null) onChanged(v);
},
), ),
); );
} }
Widget _textField( Widget _textField(TextEditingController controller, {String? hint, Key? key}) {
TextEditingController controller, {
String? hint,
Key? key,
TextInputType? keyboardType,
List<TextInputFormatter>? inputFormatters,
bool error = false,
Widget? suffixIcon,
}) {
return SizedBox( return SizedBox(
height: _fieldHeight, height: _fieldHeight,
child: TextField( child: TextField(
key: key, key: key,
controller: controller, controller: controller,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
textAlignVertical: TextAlignVertical.center, textAlignVertical: TextAlignVertical.center,
style: TextStyle( style: const TextStyle(color: Colors.black87, fontSize: 14),
color: error ? Colors.red.shade800 : Colors.black87, decoration: _inputDecoration(hint: hint),
fontSize: 14,
),
decoration: _inputDecoration(
hint: hint,
error: error,
).copyWith(suffixIcon: suffixIcon),
), ),
); );
} }
Widget _dateField() {
final past = _dueDateInPast;
return _textField(
_activeDateCtrl,
hint: 'jj / mm / aaaa',
key: ValueKey(_status),
keyboardType: TextInputType.number,
inputFormatters: const [FrenchDateInputFormatter()],
error: past,
suffixIcon: past
? Tooltip(
message:
'Date prévisionnelle antérieure à aujourdhui',
child: Icon(
Icons.info_outline,
size: 20,
color: Colors.red.shade700,
),
)
: null,
);
}
Widget _fieldsGrid() { Widget _fieldsGrid() {
return ValidationEditableSection( return ValidationEditableSection(
rowLayout: _fieldsRowLayout, rowLayout: _fieldsRowLayout,
fields: [ fields: [
ValidationLabeledField( ValidationLabeledField(
label: 'Prénom', label: 'Prénom',
field: _textField( field: _textField(_prenomCtrl),
_prenomCtrl,
inputFormatters: const [PersonNameInputFormatter()],
),
), ),
ValidationLabeledField( ValidationLabeledField(
label: 'Nom', label: 'Nom',
field: _textField( field: _textField(_nomCtrl),
_nomCtrl,
inputFormatters: const [PersonNameInputFormatter()],
),
), ),
ValidationLabeledField( ValidationLabeledField(
label: _dateFieldLabel, label: _dateFieldLabel,
field: _dateField(), field: _textField(
_activeDateCtrl,
hint: 'jj/mm/aaaa',
key: ValueKey(_status),
),
), ),
ValidationLabeledField( ValidationLabeledField(
label: 'Statut', label: 'Statut',
@@ -893,7 +699,6 @@ class _ChildDetailModalState extends State<ChildDetailModal> {
.toList(), .toList(),
onChanged: (v) { onChanged: (v) {
setState(() { setState(() {
_preserveDateOnStatusChange(_status, v);
_status = v; _status = v;
_coerceGenderForStatus(); _coerceGenderForStatus();
_dirty = true; _dirty = true;
@@ -909,7 +714,6 @@ class _ChildDetailModalState extends State<ChildDetailModal> {
field: _dropdownField( field: _dropdownField(
key: ValueKey('genre-$_status'), key: ValueKey('genre-$_status'),
value: _gender, value: _gender,
hint: 'Veuillez sélectionner le genre',
items: _availableGenders items: _availableGenders
.map((g) => MapEntry(g, _genderLabel(g))) .map((g) => MapEntry(g, _genderLabel(g)))
.toList(), .toList(),
@@ -986,7 +790,7 @@ class _ChildDetailModalState extends State<ChildDetailModal> {
final maxRowW = c.maxWidth; final maxRowW = c.maxWidth;
final maxRowH = c.maxHeight; final maxRowH = c.maxHeight;
final idealPhotoW = final idealPhotoW =
maxRowH * AmPhotoFrame.idPhotoAspectRatio + 16; maxRowH * AdminAmPhotoFrame.idPhotoAspectRatio + 16;
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth) final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
.clamp(0.0, double.infinity); .clamp(0.0, double.infinity);
var photoW = idealPhotoW.clamp(_photoColumnMinWidth, _photoColumnMaxWidth); var photoW = idealPhotoW.clamp(_photoColumnMinWidth, _photoColumnMaxWidth);
@@ -1001,7 +805,7 @@ class _ChildDetailModalState extends State<ChildDetailModal> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Expanded( Expanded(
child: AmPhotoFrame( child: AdminAmPhotoFrame(
photoUrl: widget.isCreating photoUrl: widget.isCreating
? null ? null
: widget.enfant?.photoUrl, : widget.enfant?.photoUrl,
@@ -1034,9 +838,7 @@ class _ChildDetailModalState extends State<ChildDetailModal> {
padding: const EdgeInsets.only(top: 16), padding: const EdgeInsets.only(top: 16),
child: Align( child: Align(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
child: SingleChildScrollView( child: _placementBlock(),
child: _placementBlock(),
),
), ),
), ),
), ),
@@ -1286,7 +1088,7 @@ class _ChildDetailModalState extends State<ChildDetailModal> {
} }
String get _placementTitle { String get _placementTitle {
if (widget.isCreating) return 'Sélection du dossier de la famille'; if (widget.isCreating) return 'Famille / dossier';
return _isScolarise ? 'Scolarisation' : 'Assistante maternelle'; return _isScolarise ? 'Scolarisation' : 'Assistante maternelle';
} }
@@ -1295,24 +1097,6 @@ class _ChildDetailModalState extends State<ChildDetailModal> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (_isOrphan) ...[
Text(
'Sélection du dossier de la famille',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.red.shade800,
),
),
const SizedBox(height: 4),
Text(
'Aucun responsable rattaché — choisissez un foyer',
style: TextStyle(fontSize: 12, color: Colors.red.shade700),
),
const SizedBox(height: 8),
_familyPlacementSection(),
const SizedBox(height: 16),
],
Text( Text(
_placementTitle, _placementTitle,
style: TextStyle( style: TextStyle(
@@ -1329,48 +1113,15 @@ class _ChildDetailModalState extends State<ChildDetailModal> {
Future<void> _pickFamily() async { Future<void> _pickFamily() async {
if (_busy) return; if (_busy) return;
final selected = await SelectFamilleModal.show( final selected = await AdminSelectFamilleModal.show(
context, context,
title: 'Choisir une famille', title: 'Choisir une famille',
); );
if (selected == null || !mounted) return; if (selected == null || !mounted) return;
setState(() {
if (widget.isCreating) { _selectedFamily = selected;
setState(() { _dirty = true;
_selectedFamily = selected; });
_dirty = true;
});
return;
}
// Édition orphelin (#157/#158) : un seul attach — le back propage au foyer.
final enfantId = widget.enfant?.id;
if (enfantId == null || enfantId.isEmpty) return;
setState(() => _saving = true);
try {
await UserService.attachEnfantToParent(
parentUserId: selected.pivotParentUserId,
enfantId: enfantId,
);
final refreshed = await UserService.getEnfant(enfantId);
if (!mounted) return;
setState(() {
_localParentLinks = refreshed.parentLinks;
_selectedFamily = selected;
_saving = false;
});
widget.onSaved?.call();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enfant rattaché au foyer')),
);
} catch (e) {
if (!mounted) return;
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
} }
Widget _familyPlacementSection() { Widget _familyPlacementSection() {
@@ -1561,7 +1312,7 @@ class _ChildDetailModalState extends State<ChildDetailModal> {
const SizedBox(width: 12), const SizedBox(width: 12),
ElevatedButton( ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle, style: ValidationModalTheme.primaryElevatedStyle,
onPressed: !_canSubmit || _busy ? null : _save, onPressed: !_dirty || _busy ? null : _save,
child: _saving child: _saving
? const SizedBox( ? const SizedBox(
width: 18, width: 18,
@@ -1,9 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/widgets/dashboard/enfant_user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart';
/// Liste scrollable d'enfants rattachés (fiche parent / fiche AM). /// Liste scrollable d'enfants rattachés (fiche parent / fiche AM).
class ChildrenAffiliationPanel extends StatelessWidget { class AdminChildrenAffiliationPanel extends StatelessWidget {
final List<ParentChildSummary> children; final List<ParentChildSummary> children;
final ScrollController scrollController; final ScrollController scrollController;
final void Function(ParentChildSummary child) onOpen; final void Function(ParentChildSummary child) onOpen;
@@ -14,7 +14,7 @@ class ChildrenAffiliationPanel extends StatelessWidget {
static const double _itemHeight = 58; static const double _itemHeight = 58;
static const double defaultViewportHeight = _itemHeight * 2.5 + 8; static const double defaultViewportHeight = _itemHeight * 2.5 + 8;
const ChildrenAffiliationPanel({ const AdminChildrenAffiliationPanel({
super.key, super.key,
required this.children, required this.children,
required this.scrollController, required this.scrollController,
@@ -62,7 +62,7 @@ class ChildrenAffiliationPanel extends StatelessWidget {
itemCount: children.length, itemCount: children.length,
itemBuilder: (_, i) { itemBuilder: (_, i) {
final c = children[i]; final c = children[i];
return EnfantUserCard.fromSummary( return AdminEnfantUserCard.fromSummary(
c, c,
onCardTap: () => onOpen(c), onCardTap: () => onOpen(c),
actions: [ actions: [
@@ -1,23 +1,23 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class DetailField { class AdminDetailField {
final String label; final String label;
final String value; final String value;
const DetailField({ const AdminDetailField({
required this.label, required this.label,
required this.value, required this.value,
}); });
} }
class DetailModal extends StatelessWidget { class AdminDetailModal extends StatelessWidget {
final String title; final String title;
final String? subtitle; final String? subtitle;
final List<DetailField> fields; final List<AdminDetailField> fields;
final VoidCallback onEdit; final VoidCallback onEdit;
final VoidCallback onDelete; final VoidCallback onDelete;
const DetailModal({ const AdminDetailModal({
super.key, super.key,
required this.title, required this.title,
this.subtitle, this.subtitle,
@@ -3,8 +3,7 @@ import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart';
import 'package:p_tits_pas/utils/enfant_vigilance.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/dashboard/user_card.dart';
List<String> enfantAdminSubtitleLines({ List<String> enfantAdminSubtitleLines({
required String status, required String status,
@@ -29,7 +28,7 @@ List<String> enfantAdminSubtitleLines({
} }
/// Carte enfant admin (photo, nom, âge) — même rendu onglet Enfants / fiche parent. /// Carte enfant admin (photo, nom, âge) — même rendu onglet Enfants / fiche parent.
class EnfantUserCard extends StatelessWidget { class AdminEnfantUserCard extends StatelessWidget {
final String title; final String title;
final String? photoUrl; final String? photoUrl;
final List<String> subtitleLines; final List<String> subtitleLines;
@@ -37,10 +36,8 @@ class EnfantUserCard extends StatelessWidget {
final VoidCallback? onCardTap; final VoidCallback? onCardTap;
final EdgeInsetsGeometry? margin; final EdgeInsetsGeometry? margin;
final EdgeInsetsGeometry? contentPadding; final EdgeInsetsGeometry? contentPadding;
final Color? borderColor;
final String? vigilanceTooltip;
const EnfantUserCard({ const AdminEnfantUserCard({
super.key, super.key,
required this.title, required this.title,
this.photoUrl, this.photoUrl,
@@ -49,25 +46,20 @@ class EnfantUserCard extends StatelessWidget {
this.onCardTap, this.onCardTap,
this.margin, this.margin,
this.contentPadding, this.contentPadding,
this.borderColor,
this.vigilanceTooltip,
}); });
factory EnfantUserCard.fromEnfant( factory AdminEnfantUserCard.fromEnfant(
EnfantAdminModel enfant, { EnfantAdminModel enfant, {
List<String> extraSubtitleLines = const [], List<String> extraSubtitleLines = const [],
List<Widget> actions = const [], List<Widget> actions = const [],
VoidCallback? onCardTap, VoidCallback? onCardTap,
EdgeInsetsGeometry? margin, EdgeInsetsGeometry? margin,
EdgeInsetsGeometry? contentPadding, EdgeInsetsGeometry? contentPadding,
Color? borderColor,
String? vigilanceTooltip,
}) { }) {
final parents = enfant.parentLinks final parents = enfant.parentLinks
.map((l) => l.parentName ?? 'Parent') .map((l) => l.parentName ?? 'Parent')
.join(', '); .join(', ');
final orphan = enfantHasNoResponsable(enfant); return AdminEnfantUserCard(
return EnfantUserCard(
title: enfant.fullName, title: enfant.fullName,
photoUrl: enfant.photoUrl, photoUrl: enfant.photoUrl,
subtitleLines: enfantAdminSubtitleLines( subtitleLines: enfantAdminSubtitleLines(
@@ -77,7 +69,6 @@ class EnfantUserCard extends StatelessWidget {
gender: enfant.gender, gender: enfant.gender,
extra: [ extra: [
if (parents.isNotEmpty) 'Responsables : $parents', if (parents.isNotEmpty) 'Responsables : $parents',
if (orphan) 'Aucun responsable rattaché',
...extraSubtitleLines, ...extraSubtitleLines,
], ],
), ),
@@ -85,14 +76,10 @@ class EnfantUserCard extends StatelessWidget {
onCardTap: onCardTap, onCardTap: onCardTap,
margin: margin, margin: margin,
contentPadding: contentPadding, contentPadding: contentPadding,
borderColor: borderColor ??
(orphan ? Colors.red.shade300 : null),
vigilanceTooltip: vigilanceTooltip ??
enfantSansResponsableVigilanceMessage(enfant),
); );
} }
factory EnfantUserCard.fromSummary( factory AdminEnfantUserCard.fromSummary(
ParentChildSummary child, { ParentChildSummary child, {
List<String> extraSubtitleLines = const [], List<String> extraSubtitleLines = const [],
List<Widget> actions = const [], List<Widget> actions = const [],
@@ -100,7 +87,7 @@ class EnfantUserCard extends StatelessWidget {
EdgeInsetsGeometry? margin, EdgeInsetsGeometry? margin,
EdgeInsetsGeometry? contentPadding, EdgeInsetsGeometry? contentPadding,
}) { }) {
return EnfantUserCard( return AdminEnfantUserCard(
title: child.fullName, title: child.fullName,
photoUrl: child.photoUrl, photoUrl: child.photoUrl,
subtitleLines: enfantAdminSubtitleLines( subtitleLines: enfantAdminSubtitleLines(
@@ -118,7 +105,7 @@ class EnfantUserCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return UserCard( return AdminUserCard(
title: title, title: title,
fallbackIcon: Icons.child_care_outlined, fallbackIcon: Icons.child_care_outlined,
avatarUrl: photoUrl, avatarUrl: photoUrl,
@@ -127,8 +114,6 @@ class EnfantUserCard extends StatelessWidget {
onCardTap: onCardTap, onCardTap: onCardTap,
margin: margin, margin: margin,
contentPadding: contentPadding, contentPadding: contentPadding,
borderColor: borderColor,
vigilanceTooltip: vigilanceTooltip,
); );
} }
} }
@@ -1,13 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class UserListState extends StatelessWidget { class AdminListState extends StatelessWidget {
final bool isLoading; final bool isLoading;
final String? error; final String? error;
final bool isEmpty; final bool isEmpty;
final String emptyMessage; final String emptyMessage;
final Widget list; final Widget list;
const UserListState({ const AdminListState({
super.key, super.key,
required this.isLoading, required this.isLoading,
required this.error, required this.error,
@@ -4,30 +4,30 @@ 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/parent_model.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/dashboard/child_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/children_affiliation_panel.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart';
import 'package:p_tits_pas/widgets/dashboard/select_enfant_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/status_capsule.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
/// Fiche parent éditable (doc 28 §6.1, tickets #131 / #138). /// Fiche parent éditable (doc 28 §6.1, tickets #131 / #138).
/// Shell et typo alignés sur [ValidationDossierModal] / wizards validation. /// Shell et typo alignés sur [ValidationDossierModal] / wizards validation.
class ParentEditModal extends StatefulWidget { class AdminParentEditModal extends StatefulWidget {
final ParentModel parent; final ParentModel parent;
final VoidCallback? onSaved; final VoidCallback? onSaved;
const ParentEditModal({ const AdminParentEditModal({
super.key, super.key,
required this.parent, required this.parent,
this.onSaved, this.onSaved,
}); });
@override @override
State<ParentEditModal> createState() => _ParentEditModalState(); State<AdminParentEditModal> createState() => _AdminParentEditModalState();
} }
class _ParentEditModalState extends State<ParentEditModal> { class _AdminParentEditModalState extends State<AdminParentEditModal> {
late final TextEditingController _nomCtrl; late final TextEditingController _nomCtrl;
late final TextEditingController _prenomCtrl; late final TextEditingController _prenomCtrl;
late final TextEditingController _emailCtrl; late final TextEditingController _emailCtrl;
@@ -129,7 +129,7 @@ class _ParentEditModalState extends State<ParentEditModal> {
if (!mounted) return; if (!mounted) return;
await showDialog<void>( await showDialog<void>(
context: context, context: context,
builder: (ctx) => ParentEditModal( builder: (ctx) => AdminParentEditModal(
parent: parent, parent: parent,
onSaved: () async { onSaved: () async {
try { try {
@@ -244,7 +244,7 @@ class _ParentEditModalState extends State<ParentEditModal> {
if (!mounted) return; if (!mounted) return;
await showDialog<void>( await showDialog<void>(
context: context, context: context,
builder: (ctx) => ChildDetailModal( builder: (ctx) => AdminChildDetailModal(
enfant: enfant, enfant: enfant,
onSaved: _reloadChildren, onSaved: _reloadChildren,
), ),
@@ -283,7 +283,8 @@ class _ParentEditModalState extends State<ParentEditModal> {
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Détacher l\'enfant'), title: const Text('Détacher l\'enfant'),
content: Text( content: Text(
'Retirer ${child.fullName} du foyer (tous les responsables) ?', 'Retirer ${child.fullName} de la fiche de ce parent ?\n'
'(L\'enfant ne sera pas supprimé.)',
), ),
actions: [ actions: [
TextButton( TextButton(
@@ -308,9 +309,8 @@ class _ParentEditModalState extends State<ParentEditModal> {
if (!mounted) return; if (!mounted) return;
await _reloadChildren(); await _reloadChildren();
if (!mounted) return; if (!mounted) return;
widget.onSaved?.call();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enfant détaché du foyer')), const SnackBar(content: Text('Enfant détaché')),
); );
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
@@ -322,7 +322,7 @@ class _ParentEditModalState extends State<ParentEditModal> {
Future<void> _attachChild() async { Future<void> _attachChild() async {
if (!mounted) return; if (!mounted) return;
final selected = await SelectEnfantModal.show( final selected = await AdminSelectEnfantModal.show(
context, context,
excludeIds: _children.map((c) => c.id).toSet(), excludeIds: _children.map((c) => c.id).toSet(),
title: 'Rattacher un enfant', title: 'Rattacher un enfant',
@@ -337,9 +337,8 @@ class _ParentEditModalState extends State<ParentEditModal> {
if (!mounted) return; if (!mounted) return;
await _reloadChildren(); await _reloadChildren();
if (!mounted) return; if (!mounted) return;
widget.onSaved?.call();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enfant rattaché au foyer')), const SnackBar(content: Text('Enfant rattaché')),
); );
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
@@ -350,7 +349,7 @@ class _ParentEditModalState extends State<ParentEditModal> {
} }
Widget _childrenPanel() { Widget _childrenPanel() {
return ChildrenAffiliationPanel( return AdminChildrenAffiliationPanel(
children: _children, children: _children,
scrollController: _childrenScrollCtrl, scrollController: _childrenScrollCtrl,
onOpen: _openChild, onOpen: _openChild,
@@ -437,7 +436,7 @@ class _ParentEditModalState extends State<ParentEditModal> {
const SizedBox(width: 12), const SizedBox(width: 12),
SizedBox( SizedBox(
width: 160, width: 160,
child: StatusCapsule( child: AdminStatusCapsule(
statut: _statut, statut: _statut,
onChanged: (v) => setState(() { onChanged: (v) => setState(() {
_statut = v; _statut = v;
@@ -3,10 +3,10 @@ import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/am_vigilance.dart'; import 'package:p_tits_pas/utils/am_vigilance.dart';
import 'package:p_tits_pas/widgets/dashboard/am_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/select_list_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
List<String> _amSelectSubtitleLines(AssistanteMaternelleModel am) { List<String> _amSelectSubtitleLines(AssistanteMaternelleModel am) {
final lines = <String>[]; final lines = <String>[];
@@ -28,22 +28,22 @@ List<String> _amSelectSubtitleLines(AssistanteMaternelleModel am) {
} }
/// Sélection d'une AM à rattacher (fiche enfant) — ticket #147. /// Sélection d'une AM à rattacher (fiche enfant) — ticket #147.
/// S'appuie sur [SelectListModal] (shell partagé avec #146). /// S'appuie sur [AdminSelectListModal] (shell partagé avec #146).
class SelectAmModal { class AdminSelectAmModal {
SelectAmModal._(); AdminSelectAmModal._();
static Future<AssistanteMaternelleModel?> show( static Future<AssistanteMaternelleModel?> show(
BuildContext context, { BuildContext context, {
Set<String> excludeIds = const {}, Set<String> excludeIds = const {},
String title = 'Choisir une assistante maternelle', String title = 'Choisir une assistante maternelle',
}) { }) {
return SelectListModal.show<AssistanteMaternelleModel>( return AdminSelectListModal.show<AssistanteMaternelleModel>(
context, context,
title: title, title: title,
searchHint: 'Rechercher par nom, prénom ou zone…', searchHint: 'Rechercher par nom, prénom ou zone…',
emptyMessage: 'Aucune assistante maternelle disponible', emptyMessage: 'Aucune assistante maternelle disponible',
noResultsMessage: 'Aucune AM avec place libre pour cette recherche', noResultsMessage: 'Aucune AM avec place libre pour cette recherche',
toggleFilter: const SelectToggleFilter<AssistanteMaternelleModel>( toggleFilter: const AdminSelectToggleFilter<AssistanteMaternelleModel>(
label: 'Libre', label: 'Libre',
initialValue: true, initialValue: true,
whenEnabled: amHasFreePlace, whenEnabled: amHasFreePlace,
@@ -76,7 +76,7 @@ class SelectAmModal {
_resolveAmSelection(ctx, am, reload), _resolveAmSelection(ctx, am, reload),
itemBuilder: (context, am, onSelect) { itemBuilder: (context, am, onSelect) {
final full = !amHasFreePlace(am); final full = !amHasFreePlace(am);
return UserCard( return AdminUserCard(
title: am.user.fullName, title: am.user.fullName,
avatarUrl: am.user.photoUrl, avatarUrl: am.user.photoUrl,
fallbackIcon: Icons.face, fallbackIcon: Icons.face,
@@ -150,7 +150,7 @@ class _AmNoPlaceWarningDialogState extends State<_AmNoPlaceWarningDialog> {
if (!mounted) return; if (!mounted) return;
await showDialog<void>( await showDialog<void>(
context: context, context: context,
builder: (ctx) => AmEditModal( builder: (ctx) => AdminAmEditModal(
assistante: _am, assistante: _am,
onSaved: () async { onSaved: () async {
try { try {
@@ -2,13 +2,13 @@ import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart';
import 'package:p_tits_pas/widgets/dashboard/enfant_user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart';
import 'package:p_tits_pas/widgets/dashboard/select_list_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
/// Sélection d'un enfant à rattacher (fiche AM / fiche parent) — ticket #146. /// Sélection d'un enfant à rattacher (fiche AM / fiche parent) — ticket #146.
/// S'appuie sur [SelectListModal] (shell partagé avec #147). /// S'appuie sur [AdminSelectListModal] (shell partagé avec #147).
class SelectEnfantModal { class AdminSelectEnfantModal {
SelectEnfantModal._(); AdminSelectEnfantModal._();
static Future<EnfantAdminModel?> show( static Future<EnfantAdminModel?> show(
BuildContext context, { BuildContext context, {
@@ -17,7 +17,7 @@ class SelectEnfantModal {
/// Affiché uniquement depuis la fiche AM : filtre les enfants déjà en garde. /// Affiché uniquement depuis la fiche AM : filtre les enfants déjà en garde.
bool showSansGardeFilter = false, bool showSansGardeFilter = false,
}) { }) {
return SelectListModal.show<EnfantAdminModel>( return AdminSelectListModal.show<EnfantAdminModel>(
context, context,
title: title, title: title,
searchHint: 'Rechercher par nom ou prénom…', searchHint: 'Rechercher par nom ou prénom…',
@@ -26,7 +26,7 @@ class SelectEnfantModal {
? 'Aucun enfant sans garde pour cette recherche' ? 'Aucun enfant sans garde pour cette recherche'
: 'Aucun résultat pour cette recherche', : 'Aucun résultat pour cette recherche',
toggleFilter: showSansGardeFilter toggleFilter: showSansGardeFilter
? SelectToggleFilter<EnfantAdminModel>( ? AdminSelectToggleFilter<EnfantAdminModel>(
label: 'Sans garde', label: 'Sans garde',
initialValue: true, initialValue: true,
whenEnabled: (e) => whenEnabled: (e) =>
@@ -50,7 +50,7 @@ class SelectEnfantModal {
return name.contains(q) || fn.contains(q) || ln.contains(q); return name.contains(q) || fn.contains(q) || ln.contains(q);
}, },
itemBuilder: (context, e, onSelect) { itemBuilder: (context, e, onSelect) {
return EnfantUserCard.fromEnfant( return AdminEnfantUserCard.fromEnfant(
e, e,
onCardTap: onSelect, onCardTap: onSelect,
margin: const EdgeInsets.only(bottom: 4), margin: const EdgeInsets.only(bottom: 4),
@@ -1,35 +1,24 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/parent_model.dart'; import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/dashboard/select_list_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
/// Foyer / famille sélectionnable pour rattacher un nouvel enfant (#132 / #157). /// Foyer / famille sélectionnable pour rattacher un nouvel enfant (#132).
class FamilleFoyer { class AdminFamilleFoyer {
/// Parent pivot pour `POST /enfants` (`parent_user_id`). /// Parent pivot pour `POST /enfants` (`parent_user_id`).
final String pivotParentUserId; final String pivotParentUserId;
/// Co-parent éventuel (rattachement foyer #157).
final String? coParentUserId;
final String? numeroDossier; final String? numeroDossier;
final String displayTitle; final String displayTitle;
final List<String> parentNames; final List<String> parentNames;
const FamilleFoyer({ const AdminFamilleFoyer({
required this.pivotParentUserId, required this.pivotParentUserId,
required this.displayTitle, required this.displayTitle,
required this.parentNames, required this.parentNames,
this.coParentUserId,
this.numeroDossier, this.numeroDossier,
}); });
/// Parents du foyer à lier à lenfant (pivot puis co-parent).
List<String> get parentUserIds {
final ids = <String>[pivotParentUserId];
final co = (coParentUserId ?? '').trim();
if (co.isNotEmpty && co != pivotParentUserId) ids.add(co);
return ids;
}
String get subtitle { String get subtitle {
final parts = <String>[]; final parts = <String>[];
final dossier = (numeroDossier ?? '').trim(); final dossier = (numeroDossier ?? '').trim();
@@ -42,10 +31,10 @@ class FamilleFoyer {
} }
/// Construit la liste des foyers uniques à partir de `GET /parents`. /// Construit la liste des foyers uniques à partir de `GET /parents`.
List<FamilleFoyer> buildFamilleFoyers(List<ParentModel> parents) { List<AdminFamilleFoyer> buildFamilleFoyers(List<ParentModel> parents) {
final seenDossiers = <String>{}; final seenDossiers = <String>{};
final seenUserIds = <String>{}; final seenUserIds = <String>{};
final foyers = <FamilleFoyer>[]; final foyers = <AdminFamilleFoyer>[];
for (final p in parents) { for (final p in parents) {
final dossier = (p.user.numeroDossier ?? '').trim(); final dossier = (p.user.numeroDossier ?? '').trim();
@@ -70,9 +59,8 @@ List<FamilleFoyer> buildFamilleFoyers(List<ParentModel> parents) {
: (names.isNotEmpty ? names.first : 'Famille'); : (names.isNotEmpty ? names.first : 'Famille');
foyers.add( foyers.add(
FamilleFoyer( AdminFamilleFoyer(
pivotParentUserId: p.user.id, pivotParentUserId: p.user.id,
coParentUserId: co?.id,
numeroDossier: dossier.isNotEmpty ? dossier : null, numeroDossier: dossier.isNotEmpty ? dossier : null,
displayTitle: title, displayTitle: title,
parentNames: names, parentNames: names,
@@ -87,14 +75,14 @@ List<FamilleFoyer> buildFamilleFoyers(List<ParentModel> parents) {
} }
/// Sélection d'une famille / dossier pour créer un enfant — ticket #132. /// Sélection d'une famille / dossier pour créer un enfant — ticket #132.
class SelectFamilleModal { class AdminSelectFamilleModal {
SelectFamilleModal._(); AdminSelectFamilleModal._();
static Future<FamilleFoyer?> show( static Future<AdminFamilleFoyer?> show(
BuildContext context, { BuildContext context, {
String title = 'Choisir une famille', String title = 'Choisir une famille',
}) { }) {
return SelectListModal.show<FamilleFoyer>( return AdminSelectListModal.show<AdminFamilleFoyer>(
context, context,
title: title, title: title,
searchHint: 'Rechercher par dossier, nom…', searchHint: 'Rechercher par dossier, nom…',
@@ -111,7 +99,7 @@ class SelectFamilleModal {
return dossier.contains(q) || title.contains(q) || names.contains(q); return dossier.contains(q) || title.contains(q) || names.contains(q);
}, },
itemBuilder: (context, f, onSelect) { itemBuilder: (context, f, onSelect) {
return UserCard( return AdminUserCard(
title: f.displayTitle, title: f.displayTitle,
fallbackIcon: Icons.family_restroom, fallbackIcon: Icons.family_restroom,
subtitleLines: [ subtitleLines: [
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
/// Filtre optionnel (switch) sur la même ligne que la barre de recherche. /// Filtre optionnel (switch) sur la même ligne que la barre de recherche.
class SelectToggleFilter<T> { class AdminSelectToggleFilter<T> {
final String label; final String label;
final bool initialValue; final bool initialValue;
@@ -10,7 +10,7 @@ class SelectToggleFilter<T> {
/// [whenEnabled] renvoie `true`. /// [whenEnabled] renvoie `true`.
final bool Function(T item) whenEnabled; final bool Function(T item) whenEnabled;
const SelectToggleFilter({ const AdminSelectToggleFilter({
required this.label, required this.label,
required this.whenEnabled, required this.whenEnabled,
this.initialValue = true, this.initialValue = true,
@@ -19,7 +19,7 @@ class SelectToggleFilter<T> {
/// Shell générique « rechercher + liste + sélection » pour les modales admin. /// Shell générique « rechercher + liste + sélection » pour les modales admin.
/// Utilisé par la sélection d'enfant (#146) et la sélection d'AM (#147). /// Utilisé par la sélection d'enfant (#146) et la sélection d'AM (#147).
class SelectListModal<T> extends StatefulWidget { class AdminSelectListModal<T> extends StatefulWidget {
final String title; final String title;
final String searchHint; final String searchHint;
final Future<List<T>> Function() loadItems; final Future<List<T>> Function() loadItems;
@@ -40,7 +40,7 @@ class SelectListModal<T> extends StatefulWidget {
final int minVisibleCards; final int minVisibleCards;
/// Switch optionnel à droite du champ de recherche (ex. « Sans garde », « Libre »). /// Switch optionnel à droite du champ de recherche (ex. « Sans garde », « Libre »).
final SelectToggleFilter<T>? toggleFilter; final AdminSelectToggleFilter<T>? toggleFilter;
/// Si fourni, appelé avant de valider la sélection. /// Si fourni, appelé avant de valider la sélection.
/// Retourne l'élément à pop (éventuellement rafraîchi), ou `null` pour annuler. /// Retourne l'élément à pop (éventuellement rafraîchi), ou `null` pour annuler.
@@ -50,7 +50,7 @@ class SelectListModal<T> extends StatefulWidget {
Future<void> Function() reload, Future<void> Function() reload,
)? resolveSelect; )? resolveSelect;
const SelectListModal({ const AdminSelectListModal({
super.key, super.key,
required this.title, required this.title,
required this.loadItems, required this.loadItems,
@@ -82,7 +82,7 @@ class SelectListModal<T> extends StatefulWidget {
double modalWidth = 930, double modalWidth = 930,
double cardExtent = 52, double cardExtent = 52,
int minVisibleCards = 8, int minVisibleCards = 8,
SelectToggleFilter<T>? toggleFilter, AdminSelectToggleFilter<T>? toggleFilter,
Future<T?> Function( Future<T?> Function(
BuildContext context, BuildContext context,
T item, T item,
@@ -91,7 +91,7 @@ class SelectListModal<T> extends StatefulWidget {
}) { }) {
return showDialog<T>( return showDialog<T>(
context: context, context: context,
builder: (ctx) => SelectListModal<T>( builder: (ctx) => AdminSelectListModal<T>(
title: title, title: title,
loadItems: loadItems, loadItems: loadItems,
matchesQuery: matchesQuery, matchesQuery: matchesQuery,
@@ -109,11 +109,11 @@ class SelectListModal<T> extends StatefulWidget {
} }
@override @override
State<SelectListModal<T>> createState() => State<AdminSelectListModal<T>> createState() =>
_SelectListModalState<T>(); _AdminSelectListModalState<T>();
} }
class _SelectListModalState<T> extends State<SelectListModal<T>> { class _AdminSelectListModalState<T> extends State<AdminSelectListModal<T>> {
final _searchCtrl = TextEditingController(); final _searchCtrl = TextEditingController();
List<T> _all = []; List<T> _all = [];
bool _loading = true; bool _loading = true;
@@ -1,13 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Gélule de sélection du statut utilisateur (fiches admin parent / AM). /// Gélule de sélection du statut utilisateur (fiches admin parent / AM).
class StatusCapsule extends StatelessWidget { class AdminStatusCapsule extends StatelessWidget {
final String statut; final String statut;
final ValueChanged<String>? onChanged; final ValueChanged<String>? onChanged;
static const statuts = ['actif', 'en_attente', 'suspendu', 'refuse']; static const statuts = ['actif', 'en_attente', 'suspendu', 'refuse'];
const StatusCapsule({ const AdminStatusCapsule({
super.key, super.key,
required this.statut, required this.statut,
this.onChanged, this.onChanged,
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
class UserCard extends StatefulWidget { class AdminUserCard extends StatefulWidget {
final String title; final String title;
final List<String> subtitleLines; final List<String> subtitleLines;
final String? avatarUrl; final String? avatarUrl;
@@ -12,16 +12,12 @@ class UserCard extends StatefulWidget {
final Color? backgroundColor; final Color? backgroundColor;
final Color? titleColor; final Color? titleColor;
final Color? infoColor; final Color? infoColor;
/// Fond du cercle avatar / icône (défaut lavande admin).
final Color? avatarBackgroundColor;
/// Couleur de licône fallback (défaut violet admin).
final Color? avatarIconColor;
final String? vigilanceTooltip; final String? vigilanceTooltip;
final VoidCallback? onCardTap; final VoidCallback? onCardTap;
final EdgeInsetsGeometry? margin; final EdgeInsetsGeometry? margin;
final EdgeInsetsGeometry? contentPadding; final EdgeInsetsGeometry? contentPadding;
const UserCard({ const AdminUserCard({
super.key, super.key,
required this.title, required this.title,
required this.subtitleLines, required this.subtitleLines,
@@ -32,8 +28,6 @@ class UserCard extends StatefulWidget {
this.backgroundColor, this.backgroundColor,
this.titleColor, this.titleColor,
this.infoColor, this.infoColor,
this.avatarBackgroundColor,
this.avatarIconColor,
this.vigilanceTooltip, this.vigilanceTooltip,
this.onCardTap, this.onCardTap,
this.margin, this.margin,
@@ -41,10 +35,10 @@ class UserCard extends StatefulWidget {
}); });
@override @override
State<UserCard> createState() => _UserCardState(); State<AdminUserCard> createState() => _AdminUserCardState();
} }
class _UserCardState extends State<UserCard> { class _AdminUserCardState extends State<AdminUserCard> {
bool _isHovered = false; bool _isHovered = false;
@override @override
@@ -165,8 +159,8 @@ class _UserCardState extends State<UserCard> {
Widget _buildAvatar(String url) { Widget _buildAvatar(String url) {
const size = 28.0; const size = 28.0;
final bg = widget.avatarBackgroundColor ?? const Color(0xFFEDE5FA); const bg = Color(0xFFEDE5FA);
final iconColor = widget.avatarIconColor ?? const Color(0xFF6B3FA0); const iconColor = Color(0xFF6B3FA0);
if (url.isEmpty) { if (url.isEmpty) {
return CircleAvatar( return CircleAvatar(
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/widgets/dashboard/user_list_state.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_list_state.dart';
class UserList extends StatelessWidget { class UserList extends StatelessWidget {
final bool isLoading; final bool isLoading;
@@ -28,7 +28,7 @@ class UserList extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
UserListState( AdminListState(
isLoading: isLoading, isLoading: isLoading,
error: error, error: error,
isEmpty: isEmpty, isEmpty: isEmpty,
@@ -0,0 +1,406 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'admin_detail_modal.dart';
/// Bloc type formulaire (titre de section + champs read-only) pour les modales de validation.
/// [rowLayout] : même disposition que la création de compte, ex. [2, 2, 1, 2] = ligne de 2, ligne de 2, plein largeur, ligne de 2.
/// [rowFlex] : flex par index de ligne (optionnel). Ex. {3: [2, 5]} = 4e ligne : code postal étroit (2), ville large (5).
class ValidationDetailSection extends StatelessWidget {
/// Si null ou vide, pas de bandeau titre (gain de place vertical, ex. wizard AM).
final String? title;
final List<AdminDetailField> fields;
/// Nombre de champs par ligne (1 = plein largeur, 2 = deux côte à côte). Ex. [2, 2, 1, 2] pour identité.
final List<int>? rowLayout;
/// Flex par ligne (index de ligne -> [flex1, flex2, ...]). Ex. {3: [2, 5]} pour Code postal | Ville.
final Map<int, List<int>>? rowFlex;
const ValidationDetailSection({
super.key,
this.title,
required this.fields,
this.rowLayout,
this.rowFlex,
});
@override
Widget build(BuildContext context) {
return ValidationFormGrid(
title: title,
rowLayout: rowLayout,
rowFlex: rowFlex,
fields: fields
.map(
(f) => ValidationLabeledField(
label: f.label,
field: ValidationReadOnlyField(value: f.value),
),
)
.toList(),
);
}
}
/// Grille label/champ réutilisable (validation, fiches admin).
class ValidationFormGrid extends StatelessWidget {
final String? title;
final List<ValidationLabeledField> fields;
final List<int>? rowLayout;
final Map<int, List<int>>? rowFlex;
final bool compact;
const ValidationFormGrid({
super.key,
this.title,
required this.fields,
this.rowLayout,
this.rowFlex,
this.compact = false,
});
@override
Widget build(BuildContext context) {
final layout = rowLayout ?? List.filled(fields.length, 1);
int index = 0;
int rowIndex = 0;
final rows = <Widget>[];
for (final count in layout) {
if (index >= fields.length) break;
final rowFields = fields.skip(index).take(count).toList();
index += count;
if (rowFields.isEmpty) continue;
final flexForRow = rowFlex?[rowIndex];
rowIndex++;
if (count == 1) {
rows.add(Padding(
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
child: rowFields.first,
));
} else {
rows.add(Padding(
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (int i = 0; i < rowFields.length; i++) ...[
if (i > 0) SizedBox(width: compact ? 12 : 16),
Expanded(
flex: (flexForRow != null && i < flexForRow.length)
? flexForRow[i]
: 1,
child: rowFields[i],
),
],
],
),
));
}
}
final showTitle = title != null && title!.trim().isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (showTitle) ...[
Text(
title!.trim(),
style: TextStyle(
fontSize: compact ? 15 : 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
SizedBox(height: compact ? 8 : 12),
],
...rows,
],
);
}
}
/// Décoration commune lecture seule / édition (modales validation, fiches admin).
class ValidationFieldDecoration {
ValidationFieldDecoration._();
static InputDecoration input({String? hint, bool compact = false}) {
return InputDecoration(
isDense: true,
filled: true,
fillColor: Colors.grey.shade50,
hintText: hint,
contentPadding: EdgeInsets.symmetric(
horizontal: compact ? 10 : 12,
vertical: compact ? 7 : 10,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: Colors.grey.shade500),
),
);
}
static InputDecoration readOnly({bool error = false, bool compact = false}) {
final borderColor = error ? Colors.red.shade400 : Colors.grey.shade300;
final fillColor = error ? Colors.red.shade50 : Colors.grey.shade50;
return input(compact: compact).copyWith(
filled: true,
fillColor: fillColor,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: borderColor),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: borderColor),
),
);
}
static BoxDecoration container({bool error = false}) {
return BoxDecoration(
color: error ? Colors.red.shade50 : Colors.grey.shade50,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: error ? Colors.red.shade400 : Colors.grey.shade300,
),
);
}
}
/// Libellé au-dessus dun champ (même typo que [ValidationDetailSection]).
class ValidationLabeledField extends StatelessWidget {
final String label;
final Widget field;
const ValidationLabeledField({
super.key,
required this.label,
required this.field,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
),
),
const SizedBox(height: 4),
field,
],
);
}
}
/// Champ texte éditable, même rendu que [ValidationReadOnlyField].
class ValidationEditableField extends StatelessWidget {
final TextEditingController controller;
final TextInputType keyboardType;
final List<TextInputFormatter>? inputFormatters;
final String? hintText;
final int maxLines;
final bool compact;
const ValidationEditableField({
super.key,
required this.controller,
this.keyboardType = TextInputType.text,
this.inputFormatters,
this.hintText,
this.maxLines = 1,
this.compact = false,
});
static const double _compactFieldHeight = 34;
static BoxDecoration _compactDecoration({bool error = false}) {
return BoxDecoration(
color: error ? Colors.red.shade50 : Colors.grey.shade50,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: error ? Colors.red.shade400 : Colors.grey.shade300,
),
);
}
static InputDecoration _compactInputDecoration({String? hint}) {
return InputDecoration(
isDense: true,
filled: false,
hintText: hint,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
);
}
@override
Widget build(BuildContext context) {
if (maxLines > 1) {
return TextField(
controller: controller,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
maxLines: maxLines,
style: const TextStyle(color: Colors.black87, fontSize: 14),
decoration: ValidationFieldDecoration.input(hint: hintText),
);
}
if (!compact) {
return TextField(
controller: controller,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
maxLines: 1,
style: const TextStyle(color: Colors.black87, fontSize: 14),
decoration: ValidationFieldDecoration.input(hint: hintText),
);
}
return SizedBox(
height: _compactFieldHeight,
child: DecoratedBox(
decoration: _compactDecoration(),
child: TextField(
controller: controller,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
maxLines: 1,
textAlignVertical: TextAlignVertical.center,
style: const TextStyle(
color: Colors.black87,
fontSize: 13,
height: 1.0,
),
decoration: _compactInputDecoration(hint: hintText),
),
),
);
}
}
/// Grille label/champ éditable (délègue à [ValidationFormGrid]).
class ValidationEditableSection extends StatelessWidget {
final List<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
Widget build(BuildContext context) {
return ValidationFormGrid(
rowLayout: rowLayout,
rowFlex: rowFlex,
compact: compact,
fields: fields,
);
}
}
/// Champ texte en lecture seule, même coque [TextField] que [ValidationEditableField].
class ValidationReadOnlyField extends StatefulWidget {
final String value;
final int? maxLines;
final bool compact;
final bool error;
const ValidationReadOnlyField({
super.key,
required this.value,
this.maxLines = 1,
this.compact = false,
this.error = false,
});
@override
State<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),
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,
overflow: TextOverflow.ellipsis,
),
);
}
}

Some files were not shown because too many files have changed in this diff Show More