Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
946d8edcd2 | ||
|
|
c8cb82dd24 | ||
|
|
9cd180bf6a | ||
|
|
ca07d2e111 | ||
|
|
67336c64fe | ||
|
|
97afbbcf9a | ||
|
|
e235b30140 | ||
|
|
7f23356273 | ||
|
|
c8c9cfbc4d | ||
|
|
530e896b66 | ||
|
|
0029c5ab86 | ||
|
|
2ce9e9215f | ||
|
|
3fdd913367 | ||
|
|
6708f73b06 | ||
|
|
f596f062a6 | ||
|
|
86701731e3 | ||
|
|
b4abb7d6de | ||
|
|
cb5c1a5518 | ||
|
|
30ca99fb65 | ||
|
|
8ee2ca8ea6 | ||
|
|
e3552667bc | ||
|
|
4ae334b247 | ||
|
|
471a62ddb7 | ||
|
|
59afeb0a8d | ||
|
|
d2172eafdb | ||
|
|
d247867fa0 | ||
|
|
93912d1374 | ||
|
|
925b6d5cd4 | ||
|
|
b0dddd6695 | ||
|
|
ef7512dc1e | ||
|
|
2ececa711b | ||
|
|
7a3d997ff9 | ||
|
|
6b89d7b405 | ||
|
|
f7ac628445 | ||
|
|
26e9738ec7 | ||
|
|
0ec3410457 | ||
|
|
c3acf1970d | ||
|
|
6fe2b89a61 | ||
|
|
d6a9b3fd66 | ||
|
|
134b9781c8 |
@@ -89,6 +89,9 @@
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"^src/(.*)$": "<rootDir>/$1"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
|
||||
@@ -33,7 +33,6 @@ model Child {
|
||||
dateOfBirth DateTime
|
||||
photoUrl String?
|
||||
photoConsent Boolean @default(false)
|
||||
isMultiple Boolean @default(false)
|
||||
isUnborn Boolean @default(false)
|
||||
parentId String
|
||||
parent Parent @relation(fields: [parentId], references: [id])
|
||||
|
||||
@@ -18,6 +18,7 @@ import { AppConfigModule } from './modules/config/config.module';
|
||||
import { DocumentsLegauxModule } from './modules/documents-legaux';
|
||||
import { RelaisModule } from './routes/relais/relais.module';
|
||||
import { DossiersModule } from './routes/dossiers/dossiers.module';
|
||||
import { SuppressionsModule } from './routes/suppressions/suppressions.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -57,6 +58,7 @@ import { DossiersModule } from './routes/dossiers/dossiers.module';
|
||||
DocumentsLegauxModule,
|
||||
RelaisModule,
|
||||
DossiersModule,
|
||||
SuppressionsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
@@ -63,9 +63,6 @@ export class Children {
|
||||
@Column({ type: 'timestamptz', nullable: true, name: 'date_consentement_photo' })
|
||||
consent_photo_at?: Date;
|
||||
|
||||
@Column({ default: false, name: 'est_multiple', type: 'boolean' })
|
||||
is_multiple: boolean;
|
||||
|
||||
// Lien via table de jointure enfants_parents
|
||||
@OneToMany(() => ParentsChildren, pc => pc.child)
|
||||
parentLinks: ParentsChildren[];
|
||||
|
||||
+51
-2
@@ -1,20 +1,69 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AssistantesMaternellesController } from './assistantes_maternelles.controller';
|
||||
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', () => {
|
||||
let controller: AssistantesMaternellesController;
|
||||
const authServiceMock = {
|
||||
createAmDossierStaff: jest.fn(),
|
||||
};
|
||||
const amServiceMock = {};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AssistantesMaternellesController],
|
||||
providers: [AssistantesMaternellesService],
|
||||
}).compile();
|
||||
providers: [
|
||||
{ provide: AssistantesMaternellesService, useValue: amServiceMock },
|
||||
{ provide: AuthService, useValue: authServiceMock },
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.overrideGuard(RolesGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<AssistantesMaternellesController>(AssistantesMaternellesController);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
it('createDossier delegates to authService.createAmDossierStaff with CGU accepted', async () => {
|
||||
authServiceMock.createAmDossierStaff.mockResolvedValue({
|
||||
message: 'ok',
|
||||
user_id: 'u1',
|
||||
statut: 'actif',
|
||||
numero_dossier: '2026-000001',
|
||||
});
|
||||
|
||||
const body = {
|
||||
email: 'am.staff@test.fr',
|
||||
prenom: 'Marie',
|
||||
nom: 'TEST',
|
||||
telephone: '0689567890',
|
||||
consentement_photo: false,
|
||||
lieu_naissance_ville: 'Paris',
|
||||
lieu_naissance_pays: 'France',
|
||||
nir: '285017512345678',
|
||||
numero_agrement: 'AGR-TEST-001',
|
||||
capacite_accueil: 3,
|
||||
places_disponibles: 2,
|
||||
};
|
||||
|
||||
const res = await controller.createDossier(body as any);
|
||||
expect(authServiceMock.createAmDossierStaff).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email: body.email,
|
||||
acceptation_cgu: true,
|
||||
acceptation_privacy: true,
|
||||
}),
|
||||
);
|
||||
expect(res.numero_dossier).toBe('2026-000001');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
Param,
|
||||
Delete,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
@@ -16,17 +18,49 @@ import { RoleType, Users } from 'src/entities/users.entity';
|
||||
import { CreateAssistanteDto } from '../user/dto/create_assistante.dto';
|
||||
import { UpdateAssistanteDto } from '../user/dto/update_assistante.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 { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { User } from 'src/common/decorators/user.decorator';
|
||||
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")
|
||||
@ApiBearerAuth('access-token')
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
@Controller('assistantes-maternelles')
|
||||
export class AssistantesMaternellesController {
|
||||
constructor(private readonly assistantesMaternellesService: AssistantesMaternellesService) { }
|
||||
constructor(
|
||||
private readonly assistantesMaternellesService: AssistantesMaternellesService,
|
||||
private readonly authService: AuthService,
|
||||
) { }
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@Post('dossier')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: 'Créer un dossier AM complet (staff) — ticket #156',
|
||||
description:
|
||||
'Crée user + fiche AM avec statut actif, n° dossier, et envoie l’e-mail de création de mot de passe. ' +
|
||||
'Ne pas utiliser POST /auth/register/am depuis le dashboard.',
|
||||
})
|
||||
@ApiBody({ type: StaffCreateAmDossierDto })
|
||||
@ApiResponse({ status: 201, type: StaffCreateAmDossierResponseDto })
|
||||
@ApiResponse({ status: 400, description: 'Validation métier / NIR' })
|
||||
@ApiResponse({ status: 403, description: 'Rôle non autorisé' })
|
||||
@ApiResponse({ status: 409, description: 'Email / NIR / agrément déjà pris' })
|
||||
async createDossier(
|
||||
@Body() dto: StaffCreateAmDossierDto,
|
||||
): Promise<StaffCreateAmDossierResponseDto> {
|
||||
const registerDto = {
|
||||
...dto,
|
||||
acceptation_cgu: true,
|
||||
acceptation_privacy: true,
|
||||
} as RegisterAMCompletDto;
|
||||
return this.authService.createAmDossierStaff(registerDto);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({ summary: 'Créer nounou' })
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
|
||||
/** Réponse 201 POST /assistantes-maternelles/dossier (#156). */
|
||||
export class StaffCreateAmDossierResponseDto {
|
||||
@ApiProperty()
|
||||
message: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
user_id: string;
|
||||
|
||||
@ApiProperty({
|
||||
enum: StatutUtilisateurType,
|
||||
example: StatutUtilisateurType.ACTIF,
|
||||
})
|
||||
statut: StatutUtilisateurType;
|
||||
|
||||
@ApiProperty({
|
||||
example: '2026-000042',
|
||||
description: 'Numéro de dossier attribué',
|
||||
})
|
||||
numero_dossier: string;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ApiPropertyOptional, OmitType } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
import { RegisterAMCompletDto } from 'src/routes/auth/dto/register-am-complet.dto';
|
||||
|
||||
/**
|
||||
* Création dossier AM par staff (#156).
|
||||
* Mêmes champs que l'inscription publique, sans CGU/privacy obligatoires
|
||||
* (acceptées côté serveur pour le compte du gestionnaire).
|
||||
*/
|
||||
export class StaffCreateAmDossierDto extends OmitType(RegisterAMCompletDto, [
|
||||
'acceptation_cgu',
|
||||
'acceptation_privacy',
|
||||
] as const) {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Ignoré côté staff (CGU acceptées serveur). Conservé pour compat éventuelle.',
|
||||
default: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
acceptation_cgu?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Ignoré côté staff (privacy acceptée serveur).',
|
||||
default: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
acceptation_privacy?: boolean;
|
||||
}
|
||||
@@ -29,7 +29,7 @@ import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||
ParentsChildren,
|
||||
]),
|
||||
forwardRef(() => UserModule),
|
||||
ParentsModule,
|
||||
forwardRef(() => ParentsModule),
|
||||
DossiersModule,
|
||||
AppConfigModule,
|
||||
MailModule,
|
||||
|
||||
@@ -36,6 +36,7 @@ import { MailService } from 'src/modules/mail/mail.service';
|
||||
import { ParentsService } from '../parents/parents.service';
|
||||
import { DossiersService } from '../dossiers/dossiers.service';
|
||||
import { DossierAmCompletDto } from '../dossiers/dto/dossier-am-complet.dto';
|
||||
import { StaffAddCoParentDto } from '../parents/dto/staff-add-co-parent.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -416,11 +417,20 @@ export class AuthService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Inscription Parent COMPLÈTE - Workflow CDC 6 étapes en 1 transaction
|
||||
* Gère : Parent 1 + Parent 2 (opt) + Enfants + Présentation + CGU
|
||||
* Cœur partagé création dossier parent (#129).
|
||||
* - public : statut en_attente + mails pending
|
||||
* - staff : statut actif + mails création MDP (pas de mail « dossier en attente »)
|
||||
*/
|
||||
async inscrireParentComplet(dto: RegisterParentCompletDto) {
|
||||
if (!dto.acceptation_cgu || !dto.acceptation_privacy) {
|
||||
async createParentDossier(
|
||||
dto: RegisterParentCompletDto,
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -471,7 +481,7 @@ export class AuthService {
|
||||
prenom: dto.prenom,
|
||||
nom: dto.nom,
|
||||
role: RoleType.PARENT,
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
statut: options.statut,
|
||||
telephone: dto.telephone,
|
||||
adresse: dto.adresse,
|
||||
code_postal: dto.code_postal,
|
||||
@@ -496,7 +506,7 @@ export class AuthService {
|
||||
prenom: dto.co_parent_prenom,
|
||||
nom: dto.co_parent_nom,
|
||||
role: RoleType.PARENT,
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
statut: options.statut,
|
||||
telephone: dto.co_parent_telephone,
|
||||
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,
|
||||
@@ -554,7 +564,6 @@ export class AuthService {
|
||||
enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE;
|
||||
enfant.consent_photo = !!enfantDto.consent_photo;
|
||||
enfant.consent_photo_at = enfant.consent_photo ? new Date() : null!;
|
||||
enfant.is_multiple = enfantDto.grossesse_multiple || false;
|
||||
|
||||
const enfantEnregistre = await manager.save(Children, enfant);
|
||||
enfantsEnregistres.push(enfantEnregistre);
|
||||
@@ -612,44 +621,287 @@ export class AuthService {
|
||||
|
||||
const numeroDossier = resultat.parent1.numero_dossier ?? '';
|
||||
|
||||
try {
|
||||
await this.mailService.sendRegistrationPendingEmail(
|
||||
resultat.parent1.email,
|
||||
resultat.parent1.prenom ?? '',
|
||||
resultat.parent1.nom ?? '',
|
||||
numeroDossier,
|
||||
);
|
||||
if (resultat.parent2) {
|
||||
if (options.sendPendingEmail) {
|
||||
try {
|
||||
await this.mailService.sendRegistrationPendingEmail(
|
||||
resultat.parent2.email,
|
||||
resultat.parent2.prenom ?? '',
|
||||
resultat.parent2.nom ?? '',
|
||||
resultat.parent1.email,
|
||||
resultat.parent1.prenom ?? '',
|
||||
resultat.parent1.nom ?? '',
|
||||
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),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
"[inscrireParentComplet] É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: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
|
||||
message,
|
||||
parent_id: resultat.parent1.id,
|
||||
co_parent_id: resultat.parent2?.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),
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
enfant_ids: resultat.enfants.map(e => e.id),
|
||||
statut: options.statut,
|
||||
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
|
||||
* Inscription Parent publique — CDC (statut en_attente + mail pending).
|
||||
*/
|
||||
async inscrireAMComplet(dto: RegisterAMCompletDto) {
|
||||
if (!dto.acceptation_cgu || !dto.acceptation_privacy) {
|
||||
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) {
|
||||
this.logger.error(
|
||||
'[addCoParentStaff] Échec envoi email création MDP (co-parent conservé)',
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
message:
|
||||
'Co-parent ajouté au foyer. Un e-mail de création de mot de passe a été envoyé.',
|
||||
numero_dossier: numeroDossier,
|
||||
parent_user_id: pivotUserId,
|
||||
co_parent_user_id: coParent.id,
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cœur partagé création dossier AM (#156).
|
||||
* - public : statut en_attente + mail pending
|
||||
* - staff : statut actif + mail création MDP
|
||||
*/
|
||||
async createAmDossier(
|
||||
dto: RegisterAMCompletDto,
|
||||
options: {
|
||||
statut: StatutUtilisateurType;
|
||||
sendPendingEmail: boolean;
|
||||
sendPasswordSetupEmail: boolean;
|
||||
requireCgu: boolean;
|
||||
logContext?: string;
|
||||
},
|
||||
): Promise<{
|
||||
message: string;
|
||||
user_id: string;
|
||||
statut: StatutUtilisateurType;
|
||||
numero_dossier: string;
|
||||
}> {
|
||||
const logCtx = options.logContext ?? 'createAmDossier';
|
||||
|
||||
if (options.requireCgu && (!dto.acceptation_cgu || !dto.acceptation_privacy)) {
|
||||
throw new BadRequestException(
|
||||
"L'acceptation des CGU et de la politique de confidentialité est obligatoire",
|
||||
);
|
||||
@@ -669,8 +921,7 @@ export class AuthService {
|
||||
throw new BadRequestException(nirValidation.error || 'NIR invalide');
|
||||
}
|
||||
if (nirValidation.warning) {
|
||||
// Warning uniquement : on ne bloque pas (AM souvent étrangères, DOM-TOM, Corse)
|
||||
console.warn('[inscrireAMComplet] NIR warning:', nirValidation.warning, 'email=', dto.email);
|
||||
console.warn(`[${logCtx}] NIR warning:`, nirValidation.warning, 'email=', dto.email);
|
||||
}
|
||||
|
||||
const existe = await this.usersService.findByEmailOrNull(dto.email);
|
||||
@@ -721,79 +972,142 @@ export class AuthService {
|
||||
let resultat: { user: Users };
|
||||
try {
|
||||
resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
||||
const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager);
|
||||
const { numero: numeroDossier } =
|
||||
await this.numeroDossierService.getNextNumeroDossier(manager);
|
||||
|
||||
const user = manager.create(Users, {
|
||||
email: dto.email,
|
||||
prenom: dto.prenom,
|
||||
nom: dto.nom,
|
||||
role: RoleType.ASSISTANTE_MATERNELLE,
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
telephone: dto.telephone,
|
||||
adresse: dto.adresse,
|
||||
code_postal: dto.code_postal,
|
||||
ville: dto.ville,
|
||||
token_creation_mdp: tokenCreationMdp,
|
||||
token_creation_mdp_expire_le: dateExpiration,
|
||||
photo_url: urlPhoto ?? undefined,
|
||||
consentement_photo: dto.consentement_photo,
|
||||
date_consentement_photo: dateConsentementPhoto,
|
||||
date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined,
|
||||
lieu_naissance_ville: dto.lieu_naissance_ville,
|
||||
lieu_naissance_pays: dto.lieu_naissance_pays,
|
||||
numero_dossier: numeroDossier,
|
||||
const user = manager.create(Users, {
|
||||
email: dto.email,
|
||||
prenom: dto.prenom,
|
||||
nom: dto.nom,
|
||||
role: RoleType.ASSISTANTE_MATERNELLE,
|
||||
statut: options.statut,
|
||||
telephone: dto.telephone,
|
||||
adresse: dto.adresse,
|
||||
code_postal: dto.code_postal,
|
||||
ville: dto.ville,
|
||||
token_creation_mdp: tokenCreationMdp,
|
||||
token_creation_mdp_expire_le: dateExpiration,
|
||||
photo_url: urlPhoto ?? undefined,
|
||||
consentement_photo: dto.consentement_photo,
|
||||
date_consentement_photo: dateConsentementPhoto,
|
||||
date_naissance: dto.date_naissance
|
||||
? new Date(dto.date_naissance)
|
||||
: undefined,
|
||||
lieu_naissance_ville: dto.lieu_naissance_ville,
|
||||
lieu_naissance_pays: dto.lieu_naissance_pays,
|
||||
numero_dossier: numeroDossier,
|
||||
});
|
||||
const userEnregistre = await manager.save(Users, user);
|
||||
|
||||
const amRepo = manager.getRepository(AssistanteMaternelle);
|
||||
const am = amRepo.create({
|
||||
user_id: userEnregistre.id,
|
||||
approval_number: dto.numero_agrement,
|
||||
nir: nirNormalized,
|
||||
max_children: dto.capacite_accueil,
|
||||
places_available: dto.places_disponibles,
|
||||
biography: dto.biographie,
|
||||
residence_city: dto.ville ?? undefined,
|
||||
agreement_date: dto.date_agrement
|
||||
? new Date(dto.date_agrement)
|
||||
: undefined,
|
||||
available: true,
|
||||
numero_dossier: numeroDossier,
|
||||
});
|
||||
await amRepo.save(am);
|
||||
|
||||
return { user: userEnregistre };
|
||||
});
|
||||
const userEnregistre = await manager.save(Users, user);
|
||||
|
||||
const amRepo = manager.getRepository(AssistanteMaternelle);
|
||||
const am = amRepo.create({
|
||||
user_id: userEnregistre.id,
|
||||
approval_number: dto.numero_agrement,
|
||||
nir: nirNormalized,
|
||||
max_children: dto.capacite_accueil,
|
||||
places_available: dto.places_disponibles,
|
||||
biography: dto.biographie,
|
||||
residence_city: dto.ville ?? undefined,
|
||||
agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined,
|
||||
available: true,
|
||||
numero_dossier: numeroDossier,
|
||||
});
|
||||
await amRepo.save(am);
|
||||
|
||||
return { user: userEnregistre };
|
||||
});
|
||||
} catch (err) {
|
||||
if (this.isPostgresUniqueViolation(err)) {
|
||||
throw new ConflictException('Un compte avec cet email existe déjà (contrainte unique en base).');
|
||||
throw new ConflictException(
|
||||
'Un compte avec cet email existe déjà (contrainte unique en base).',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const numeroDossier = resultat.user.numero_dossier ?? '';
|
||||
|
||||
try {
|
||||
await this.mailService.sendRegistrationPendingEmail(
|
||||
resultat.user.email,
|
||||
resultat.user.prenom ?? '',
|
||||
resultat.user.nom ?? '',
|
||||
numeroDossier,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
"[inscrireAMComplet] Échec envoi email d'accusé de réception (inscription conservée)",
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
if (options.sendPendingEmail) {
|
||||
try {
|
||||
await this.mailService.sendRegistrationPendingEmail(
|
||||
resultat.user.email,
|
||||
resultat.user.prenom ?? '',
|
||||
resultat.user.nom ?? '',
|
||||
numeroDossier,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`[${logCtx}] Échec envoi email d'accusé de réception (inscription conservée)`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.sendPasswordSetupEmail) {
|
||||
try {
|
||||
await this.mailService.sendValidatedAccountPasswordSetupEmail(
|
||||
{
|
||||
email: resultat.user.email,
|
||||
prenom: resultat.user.prenom ?? '',
|
||||
nom: resultat.user.nom ?? '',
|
||||
token: tokenCreationMdp,
|
||||
numeroDossier,
|
||||
},
|
||||
'am',
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`[${logCtx}] Échec envoi email création MDP (dossier conservé)`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const message =
|
||||
options.statut === StatutUtilisateurType.ACTIF
|
||||
? 'Dossier AM créé et validé. Un e-mail de création de mot de passe a été envoyé.'
|
||||
: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.';
|
||||
|
||||
return {
|
||||
message:
|
||||
'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
|
||||
message,
|
||||
user_id: resultat.user.id,
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
statut: options.statut,
|
||||
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
|
||||
*/
|
||||
@@ -1072,9 +1386,6 @@ export class AuthService {
|
||||
enfant.status = StatutEnfantType.A_NAITRE;
|
||||
}
|
||||
}
|
||||
if (enfantDto.grossesse_multiple !== undefined) {
|
||||
enfant.is_multiple = enfantDto.grossesse_multiple;
|
||||
}
|
||||
if (enfantDto.consent_photo !== undefined) {
|
||||
enfant.consent_photo = !!enfantDto.consent_photo;
|
||||
enfant.consent_photo_at = enfant.consent_photo
|
||||
|
||||
@@ -55,11 +55,6 @@ export class EnfantInscriptionDto {
|
||||
@IsString()
|
||||
photo_filename?: string;
|
||||
|
||||
@ApiProperty({ example: false, required: false, description: 'Grossesse multiple (jumeaux, triplés, etc.)' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
grossesse_multiple?: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
example: true,
|
||||
required: false,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
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,17 +1,58 @@
|
||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
Controller,
|
||||
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 { RoleType } from 'src/entities/users.entity';
|
||||
import { RoleType, Users } from 'src/entities/users.entity';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
import { User } from 'src/common/decorators/user.decorator';
|
||||
import { DossiersService } from './dossiers.service';
|
||||
import { SuppressionService } from '../suppressions/suppression.service';
|
||||
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
||||
import { DossierListItemDto } from './dto/dossier-list-item.dto';
|
||||
|
||||
@ApiTags('Dossiers')
|
||||
@ApiBearerAuth('access-token')
|
||||
@Controller('dossiers')
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
export class DossiersController {
|
||||
constructor(private readonly dossiersService: DossiersService) {}
|
||||
constructor(
|
||||
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 d’abord, 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')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@@ -23,4 +64,21 @@ export class DossiersController {
|
||||
getDossier(@Param('numeroDossier') numeroDossier: string): Promise<DossierUnifieDto> {
|
||||
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,6 +5,7 @@ import { JwtModule } from '@nestjs/jwt';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||
import { ParentsModule } from '../parents/parents.module';
|
||||
import { SuppressionsModule } from '../suppressions/suppressions.module';
|
||||
import { DossiersController } from './dossiers.controller';
|
||||
import { DossiersService } from './dossiers.service';
|
||||
|
||||
@@ -12,6 +13,7 @@ import { DossiersService } from './dossiers.service';
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Parents, AssistanteMaternelle]),
|
||||
ParentsModule,
|
||||
SuppressionsModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,15 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||
import { StatutUtilisateurType, Users } from 'src/entities/users.entity';
|
||||
import { ParentsService } from '../parents/parents.service';
|
||||
import { SuppressionService } from '../suppressions/suppression.service';
|
||||
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
||||
import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto';
|
||||
import { DossierListItemDto } from './dto/dossier-list-item.dto';
|
||||
|
||||
/**
|
||||
* Endpoint unifié GET /dossiers/:numeroDossier – AM ou famille. Ticket #119.
|
||||
* Dossiers unifiés — détail (#119) + liste (#153) + sans_enfant (#159).
|
||||
*/
|
||||
@Injectable()
|
||||
export class DossiersService {
|
||||
@@ -18,8 +21,173 @@ export class DossiersService {
|
||||
@InjectRepository(AssistanteMaternelle)
|
||||
private readonly amRepository: Repository<AssistanteMaternelle>,
|
||||
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> {
|
||||
const num = numeroDossier?.trim();
|
||||
if (!num) {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
@@ -6,11 +7,23 @@ import {
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { GenreType, StatutEnfantType } from 'src/entities/children.entity';
|
||||
|
||||
/** Multipart envoie des strings ("true"/"false") — JSON envoie déjà des booleans. */
|
||||
function toBoolean({ value }: { value: unknown }): boolean | unknown {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'string') {
|
||||
const v = value.trim().toLowerCase();
|
||||
if (v === 'true' || v === '1') return true;
|
||||
if (v === 'false' || v === '0' || v === '') return false;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export class CreateEnfantsDto {
|
||||
@ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE })
|
||||
@IsEnum(StatutEnfantType)
|
||||
@@ -52,6 +65,7 @@ export class CreateEnfantsDto {
|
||||
photo_url?: string;
|
||||
|
||||
@ApiProperty({ default: false })
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
consent_photo: boolean;
|
||||
|
||||
@@ -60,7 +74,16 @@ export class CreateEnfantsDto {
|
||||
@IsDateString()
|
||||
consent_photo_at?: string;
|
||||
|
||||
@ApiProperty({ default: false })
|
||||
@IsBoolean()
|
||||
is_multiple: boolean;
|
||||
/**
|
||||
* Parent pivot du foyer — obligatoire pour staff (gestionnaire/admin).
|
||||
* Ignoré / interdit en externe pour un PARENT (ticket #132).
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'UUID du parent pivot (staff only). Obligatoire pour GESTIONNAIRE / ADMIN / SUPER_ADMIN.',
|
||||
format: 'uuid',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
parent_user_id?: string;
|
||||
}
|
||||
|
||||
@@ -29,9 +29,6 @@ export class EnfantResponseDto {
|
||||
@ApiProperty({ example: false })
|
||||
consent_photo: boolean;
|
||||
|
||||
@ApiProperty({ example: false })
|
||||
is_multiple: boolean;
|
||||
|
||||
@ApiProperty({ example: 'UUID-parent' })
|
||||
parent_id: string;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,35 @@
|
||||
import {
|
||||
Body,
|
||||
CallHandler,
|
||||
Controller,
|
||||
Delete,
|
||||
ExecutionContext,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBearerAuth, ApiTags, ApiConsumes } from '@nestjs/swagger';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { diskStorage } from 'multer';
|
||||
import { extname } from 'path';
|
||||
import { Observable } from 'rxjs';
|
||||
import { EnfantsService } from './enfants.service';
|
||||
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
||||
import { UpdateEnfantsDto } from './dto/update_enfants.dto';
|
||||
@@ -23,38 +38,80 @@ import { User } from 'src/common/decorators/user.decorator';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
import { SuppressionService } from '../suppressions/suppression.service';
|
||||
|
||||
const photoMulterOptions = {
|
||||
storage: diskStorage({
|
||||
destination: './uploads/photos',
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const ext = extname(file.originalname);
|
||||
cb(null, `enfant-${uniqueSuffix}${ext}`);
|
||||
},
|
||||
}),
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) {
|
||||
return cb(new Error('Seules les images sont autorisées'), false);
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Multer uniquement si Content-Type multipart (parent ou staff + photo).
|
||||
* JSON sans photo (#132) passe sans interceptor fichier.
|
||||
*/
|
||||
@Injectable()
|
||||
class OptionalEnfantPhotoInterceptor implements NestInterceptor {
|
||||
private readonly multipart = new (FileInterceptor(
|
||||
'photo',
|
||||
photoMulterOptions,
|
||||
))();
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> | Promise<Observable<unknown>> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const ct = String(req.headers['content-type'] ?? '');
|
||||
if (!ct.includes('multipart/form-data')) {
|
||||
return next.handle();
|
||||
}
|
||||
return this.multipart.intercept(context, next);
|
||||
}
|
||||
}
|
||||
|
||||
@ApiBearerAuth('access-token')
|
||||
@ApiTags('Enfants')
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
@Controller('enfants')
|
||||
export class EnfantsController {
|
||||
constructor(private readonly enfantsService: EnfantsService) { }
|
||||
constructor(
|
||||
private readonly enfantsService: EnfantsService,
|
||||
private readonly suppressionService: SuppressionService,
|
||||
) { }
|
||||
|
||||
@Roles(RoleType.PARENT)
|
||||
@Post()
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('photo', {
|
||||
storage: diskStorage({
|
||||
destination: './uploads/photos',
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const ext = extname(file.originalname);
|
||||
cb(null, `enfant-${uniqueSuffix}${ext}`);
|
||||
},
|
||||
}),
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) {
|
||||
return cb(new Error('Seules les images sont autorisées'), false);
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024,
|
||||
},
|
||||
}),
|
||||
@Roles(
|
||||
RoleType.PARENT,
|
||||
RoleType.GESTIONNAIRE,
|
||||
RoleType.ADMINISTRATEUR,
|
||||
RoleType.SUPER_ADMIN,
|
||||
)
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: 'Créer un enfant',
|
||||
description:
|
||||
'PARENT : multipart éventuel, rattache au compte connecté. ' +
|
||||
'Staff : parent_user_id obligatoire ; JSON sans photo OK ; avec photo → multipart (champ fichier `photo`, max 5 Mo). Ticket #132.',
|
||||
})
|
||||
@ApiConsumes('application/json', 'multipart/form-data')
|
||||
@ApiBody({
|
||||
description:
|
||||
'Champs métier (+ parent_user_id côté staff). Fichier optionnel `photo` en multipart.',
|
||||
type: CreateEnfantsDto,
|
||||
})
|
||||
@UseInterceptors(OptionalEnfantPhotoInterceptor)
|
||||
create(
|
||||
@Body() dto: CreateEnfantsDto,
|
||||
@UploadedFile() photo: Express.Multer.File,
|
||||
@@ -90,17 +147,47 @@ export class EnfantsController {
|
||||
RoleType.GESTIONNAIRE,
|
||||
)
|
||||
@Patch(':id')
|
||||
@ApiOperation({
|
||||
summary: 'Mettre à jour un enfant',
|
||||
description:
|
||||
'JSON sans photo OK ; avec nouvelle photo → multipart (champ fichier `photo`, max 5 Mo).',
|
||||
})
|
||||
@ApiConsumes('application/json', 'multipart/form-data')
|
||||
@UseInterceptors(OptionalEnfantPhotoInterceptor)
|
||||
update(
|
||||
@Param('id', new ParseUUIDPipe()) id: string,
|
||||
@Body() dto: UpdateEnfantsDto,
|
||||
@UploadedFile() photo: Express.Multer.File,
|
||||
@User() currentUser: Users,
|
||||
) {
|
||||
return this.enfantsService.update(id, dto, currentUser);
|
||||
return this.enfantsService.update(id, dto, currentUser, photo);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN)
|
||||
@Roles(
|
||||
RoleType.SUPER_ADMIN,
|
||||
RoleType.ADMINISTRATEUR,
|
||||
RoleType.GESTIONNAIRE,
|
||||
)
|
||||
@Delete(':id')
|
||||
remove(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||
return this.enfantsService.remove(id);
|
||||
@ApiOperation({
|
||||
summary: 'Supprimer un enfant (#159)',
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,16 @@ import { Children } from 'src/entities/children.entity';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { SuppressionsModule } from '../suppressions/suppressions.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Children, Parents, ParentsChildren]),
|
||||
AuthModule
|
||||
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Children, Parents, ParentsChildren]),
|
||||
AuthModule,
|
||||
SuppressionsModule,
|
||||
],
|
||||
controllers: [EnfantsController],
|
||||
providers: [EnfantsService]
|
||||
providers: [EnfantsService],
|
||||
exports: [EnfantsService],
|
||||
})
|
||||
export class EnfantsModule { }
|
||||
|
||||
@@ -13,6 +13,12 @@ import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||
import { RoleType, Users } from 'src/entities/users.entity';
|
||||
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
||||
|
||||
const STAFF_ROLES: RoleType[] = [
|
||||
RoleType.GESTIONNAIRE,
|
||||
RoleType.ADMINISTRATEUR,
|
||||
RoleType.SUPER_ADMIN,
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class EnfantsService {
|
||||
constructor(
|
||||
@@ -24,20 +30,35 @@ export class EnfantsService {
|
||||
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
||||
) { }
|
||||
|
||||
// Création d'un enfant
|
||||
async create(dto: CreateEnfantsDto, currentUser: Users, photoFile?: Express.Multer.File): Promise<Children> {
|
||||
private isStaff(user: Users): boolean {
|
||||
return STAFF_ROLES.includes(user.role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Création d'un enfant.
|
||||
* - PARENT : rattache au parent connecté (multipart photo optionnel).
|
||||
* - Staff : `parent_user_id` obligatoire ; JSON sans photo OK ;
|
||||
* avec photo → multipart (même stockage `/uploads/photos/...`). Ticket #132.
|
||||
*/
|
||||
async create(
|
||||
dto: CreateEnfantsDto,
|
||||
currentUser: Users,
|
||||
photoFile?: Express.Multer.File,
|
||||
): Promise<Children> {
|
||||
const pivotUserId = this.resolvePivotParentUserId(dto, currentUser);
|
||||
|
||||
const parent = await this.parentsRepository.findOne({
|
||||
where: { user_id: currentUser.id },
|
||||
where: { user_id: pivotUserId },
|
||||
relations: ['co_parent'],
|
||||
});
|
||||
if (!parent) throw new NotFoundException('Parent introuvable');
|
||||
|
||||
// Vérif métier simple
|
||||
// Vérif métier simple (aligné comportement historique parent)
|
||||
if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) {
|
||||
throw new BadRequestException('Un enfant né doit avoir une date de naissance');
|
||||
}
|
||||
|
||||
// Vérif doublon éventuel (ex: même prénom + date de naissance pour ce parent)
|
||||
// Vérif doublon éventuel (ex: même prénom + date de naissance)
|
||||
const exist = await this.childrenRepository.findOne({
|
||||
where: {
|
||||
first_name: dto.first_name,
|
||||
@@ -47,50 +68,107 @@ export class EnfantsService {
|
||||
});
|
||||
if (exist) throw new ConflictException('Cet enfant existe déjà');
|
||||
|
||||
// Gestion de la photo uploadée
|
||||
// Gestion de la photo uploadée (multipart parent ou staff)
|
||||
let photoUrl = dto.photo_url;
|
||||
let consentAt: Date | undefined;
|
||||
if (photoFile) {
|
||||
dto.photo_url = `/uploads/photos/${photoFile.filename}`;
|
||||
photoUrl = `/uploads/photos/${photoFile.filename}`;
|
||||
if (dto.consent_photo) {
|
||||
dto.consent_photo_at = new Date().toISOString();
|
||||
consentAt = new Date();
|
||||
}
|
||||
} else if (dto.consent_photo) {
|
||||
consentAt = dto.consent_photo_at
|
||||
? new Date(dto.consent_photo_at)
|
||||
: new Date();
|
||||
}
|
||||
|
||||
// Création
|
||||
const child = this.childrenRepository.create(dto);
|
||||
const child = this.childrenRepository.create({
|
||||
status: dto.status,
|
||||
first_name: dto.first_name,
|
||||
last_name: dto.last_name,
|
||||
gender: dto.gender,
|
||||
birth_date: dto.birth_date ? new Date(dto.birth_date) : undefined,
|
||||
due_date: dto.due_date ? new Date(dto.due_date) : undefined,
|
||||
photo_url: photoUrl,
|
||||
consent_photo: !!dto.consent_photo,
|
||||
consent_photo_at: consentAt,
|
||||
});
|
||||
await this.childrenRepository.save(child);
|
||||
|
||||
// Lien parent-enfant (Parent 1)
|
||||
const parentLink = this.parentsChildrenRepository.create({
|
||||
parentId: parent.user_id,
|
||||
enfantId: child.id,
|
||||
});
|
||||
await this.parentsChildrenRepository.save(parentLink);
|
||||
// Lien parent-enfant (pivot)
|
||||
await this.parentsChildrenRepository.save(
|
||||
this.parentsChildrenRepository.create({
|
||||
parentId: parent.user_id,
|
||||
enfantId: child.id,
|
||||
}),
|
||||
);
|
||||
|
||||
// Rattachement automatique au co-parent s'il existe
|
||||
if (parent.co_parent) {
|
||||
const coParentLink = this.parentsChildrenRepository.create({
|
||||
parentId: parent.co_parent.id,
|
||||
enfantId: child.id,
|
||||
});
|
||||
await this.parentsChildrenRepository.save(coParentLink);
|
||||
await this.parentsChildrenRepository.save(
|
||||
this.parentsChildrenRepository.create({
|
||||
parentId: parent.co_parent.id,
|
||||
enfantId: child.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return this.findOne(child.id, currentUser);
|
||||
}
|
||||
|
||||
// Liste des enfants (admin/gestionnaire)
|
||||
async findAll(): Promise<Children[]> {
|
||||
return this.childrenRepository.find({
|
||||
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
||||
order: { last_name: 'ASC', first_name: 'ASC' },
|
||||
private resolvePivotParentUserId(
|
||||
dto: CreateEnfantsDto,
|
||||
currentUser: Users,
|
||||
): string {
|
||||
if (this.isStaff(currentUser)) {
|
||||
const id = dto.parent_user_id?.trim();
|
||||
if (!id) {
|
||||
throw new BadRequestException(
|
||||
'parent_user_id est obligatoire pour créer un enfant (staff)',
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
if (currentUser.role === RoleType.PARENT) {
|
||||
if (
|
||||
dto.parent_user_id &&
|
||||
dto.parent_user_id.trim() !== currentUser.id
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
'Un parent ne peut pas créer un enfant pour un autre compte',
|
||||
);
|
||||
}
|
||||
return currentUser.id;
|
||||
}
|
||||
|
||||
throw new ForbiddenException('Accès interdit');
|
||||
}
|
||||
|
||||
/** Flag API #157 — true si aucun lien enfants_parents. */
|
||||
private withSansResponsable(child: Children): Children & { sans_responsable: boolean } {
|
||||
return Object.assign(child, {
|
||||
sans_responsable: !child.parentLinks || child.parentLinks.length === 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Liste des enfants (admin/gestionnaire) — inclut les orphelins (parentLinks: [])
|
||||
async findAll(): Promise<Array<Children & { sans_responsable: boolean }>> {
|
||||
const children = await this.childrenRepository.find({
|
||||
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
||||
order: { last_name: 'ASC', first_name: 'ASC' },
|
||||
});
|
||||
return children.map((c) => this.withSansResponsable(c));
|
||||
}
|
||||
|
||||
// Récupérer un enfant par id
|
||||
async findOne(id: string, currentUser: Users): Promise<Children> {
|
||||
async findOne(
|
||||
id: string,
|
||||
currentUser: Users,
|
||||
): Promise<Children & { sans_responsable: boolean }> {
|
||||
const child = await this.childrenRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['parentLinks'],
|
||||
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
||||
});
|
||||
if (!child) throw new NotFoundException('Enfant introuvable');
|
||||
|
||||
@@ -104,27 +182,40 @@ export class EnfantsService {
|
||||
case RoleType.ADMINISTRATEUR:
|
||||
case RoleType.SUPER_ADMIN:
|
||||
case RoleType.GESTIONNAIRE:
|
||||
// accès complet
|
||||
// accès complet (y compris orphelins)
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ForbiddenException('Accès interdit');
|
||||
}
|
||||
|
||||
return child;
|
||||
return this.withSansResponsable(child);
|
||||
}
|
||||
|
||||
|
||||
// Mise à jour
|
||||
async update(id: string, dto: Partial<CreateEnfantsDto>, currentUser: Users): Promise<Children> {
|
||||
async update(
|
||||
id: string,
|
||||
dto: Partial<CreateEnfantsDto>,
|
||||
currentUser: Users,
|
||||
photoFile?: Express.Multer.File,
|
||||
): Promise<Children> {
|
||||
const child = await this.childrenRepository.findOne({ where: { id } });
|
||||
if (!child) throw new NotFoundException('Enfant introuvable');
|
||||
|
||||
const patch: Partial<Children> = { ...dto } as Partial<Children>;
|
||||
const { parent_user_id: _ignored, ...rest } = dto;
|
||||
const patch: Partial<Children> = { ...rest } as Partial<Children>;
|
||||
if (dto.consent_photo !== undefined) {
|
||||
patch.consent_photo = dto.consent_photo;
|
||||
patch.consent_photo_at = dto.consent_photo ? new Date() : null!;
|
||||
}
|
||||
if (photoFile) {
|
||||
patch.photo_url = `/uploads/photos/${photoFile.filename}`;
|
||||
if (dto.consent_photo !== false) {
|
||||
patch.consent_photo = true;
|
||||
patch.consent_photo_at = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
await this.childrenRepository.update(id, patch);
|
||||
return this.findOne(id, currentUser);
|
||||
|
||||
@@ -54,9 +54,6 @@ export class DossierFamilleEnfantDto {
|
||||
description: 'Consentement affichage photo (colonne consentement_photo)',
|
||||
})
|
||||
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 */
|
||||
|
||||
@@ -7,6 +7,12 @@ export class ParentPendingSummaryDto {
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
nom?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
prenom?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
telephone?: string | null;
|
||||
|
||||
@@ -18,7 +24,10 @@ export class ParentPendingSummaryDto {
|
||||
}
|
||||
|
||||
export class PendingFamilyDto {
|
||||
@ApiProperty({ example: 'Famille Dupont', description: 'Libellé affiché pour la famille' })
|
||||
@ApiProperty({
|
||||
example: 'MARTIN Claire - MARTIN Thomas',
|
||||
description: 'Libellé affiché : NOM Prénom (séparés par « - » si co-parent)',
|
||||
})
|
||||
libelle: string;
|
||||
|
||||
@ApiProperty({
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
|
||||
/** Réponse 201 POST /parents/:id/co-parent (#135). */
|
||||
export class StaffAddCoParentResponseDto {
|
||||
@ApiProperty()
|
||||
message: string;
|
||||
|
||||
@ApiProperty({ example: '2026-000043' })
|
||||
numero_dossier: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid', description: 'UUID du parent pivot' })
|
||||
parent_user_id: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid', description: 'UUID du co-parent créé' })
|
||||
co_parent_user_id: string;
|
||||
|
||||
@ApiProperty({
|
||||
enum: StatutUtilisateurType,
|
||||
example: StatutUtilisateurType.ACTIF,
|
||||
})
|
||||
statut: StatutUtilisateurType;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* Ajout d’un co-parent sur un foyer existant (staff) — ticket #135.
|
||||
* Corps sans préfixe `co_parent_*` (l’URL cible déjà le pivot).
|
||||
*/
|
||||
export class StaffAddCoParentDto {
|
||||
@ApiProperty({ example: 'thomas.martin@ptits-pas.fr' })
|
||||
@IsEmail({}, { message: 'Email invalide' })
|
||||
@IsNotEmpty({ message: "L'email est requis" })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'Thomas' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'Le prénom est requis' })
|
||||
@MinLength(2)
|
||||
@MaxLength(100)
|
||||
prenom: string;
|
||||
|
||||
@ApiProperty({ example: 'MARTIN' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'Le nom est requis' })
|
||||
@MinLength(2)
|
||||
@MaxLength(100)
|
||||
nom: string;
|
||||
|
||||
@ApiProperty({ example: '0678456789' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'Le téléphone est requis' })
|
||||
@Matches(/^(\+33|0)[1-9](\d{2}){4}$/, {
|
||||
message: 'Le numéro de téléphone doit être valide (ex: 0689567890 ou +33689567890)',
|
||||
})
|
||||
telephone: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: true,
|
||||
description: 'Si true, copie l’adresse du parent pivot',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
meme_adresse?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
adresse?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
code_postal?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(150)
|
||||
ville?: string;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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[];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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,18 +1,104 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
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', () => {
|
||||
let controller: ParentsController;
|
||||
const authServiceMock = {
|
||||
createParentDossierStaff: jest.fn(),
|
||||
addCoParentStaff: jest.fn(),
|
||||
};
|
||||
const parentsServiceMock = {};
|
||||
const userServiceMock = {};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [ParentsController],
|
||||
}).compile();
|
||||
providers: [
|
||||
{ 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);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
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,6 +3,8 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
@@ -10,14 +12,27 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ParentsService } from './parents.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { Users } from 'src/entities/users.entity';
|
||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||
import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { CreateParentDto } from '../user/dto/create_parent.dto';
|
||||
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
|
||||
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
|
||||
import { StaffCreateParentDossierDto } from './dto/staff-create-parent-dossier.dto';
|
||||
import { StaffCreateParentDossierResponseDto } from './dto/staff-create-parent-dossier-response.dto';
|
||||
import { StaffAddCoParentDto } from './dto/staff-add-co-parent.dto';
|
||||
import { StaffAddCoParentResponseDto } from './dto/staff-add-co-parent-response.dto';
|
||||
import { RegisterParentCompletDto } from '../auth/dto/register-parent-complet.dto';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
import { User } from 'src/common/decorators/user.decorator';
|
||||
@@ -26,14 +41,50 @@ import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto';
|
||||
import { mapParentForApi, mapParentsForApi } from './parents.mapper';
|
||||
|
||||
@ApiTags('Parents')
|
||||
@ApiBearerAuth('access-token')
|
||||
@Controller('parents')
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
export class ParentsController {
|
||||
constructor(
|
||||
private readonly parentsService: ParentsService,
|
||||
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 l’e-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')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' })
|
||||
@@ -127,6 +178,28 @@ export class ParentsController {
|
||||
return mapParentForApi(parent);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@Post(':id/co-parent')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: 'Ajouter un co-parent à un foyer existant (staff) — ticket #135',
|
||||
description:
|
||||
'Foyer mono-parent uniquement. Crée le co-parent actif, liens foyer + enfants, ' +
|
||||
'e-mail de création de mot de passe. Ne pas utiliser POST /auth/register/parent.',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'UUID utilisateur du parent pivot' })
|
||||
@ApiBody({ type: StaffAddCoParentDto })
|
||||
@ApiResponse({ status: 201, type: StaffAddCoParentResponseDto })
|
||||
@ApiResponse({ status: 400, description: 'Foyer déjà à 2 parents / validation' })
|
||||
@ApiResponse({ status: 404, description: 'Parent introuvable' })
|
||||
@ApiResponse({ status: 409, description: 'Email déjà pris' })
|
||||
async addCoParent(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: StaffAddCoParentDto,
|
||||
): Promise<StaffAddCoParentResponseDto> {
|
||||
return this.authService.addCoParentStaff(id, dto);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@Post(':id/enfants/:enfantId')
|
||||
@ApiOperation({ summary: 'Rattacher un enfant à un parent — ticket #115' })
|
||||
|
||||
@@ -9,11 +9,13 @@ import { ParentsController } from './parents.controller';
|
||||
import { ParentsService } from './parents.service';
|
||||
import { Users } from 'src/entities/users.entity';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant, ParentsChildren]),
|
||||
forwardRef(() => UserModule),
|
||||
forwardRef(() => AuthModule),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
|
||||
@@ -114,34 +114,86 @@ export class ParentsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rattacher un enfant existant à un parent (enfants_parents). Ticket #115 / doc 28 §6.2.
|
||||
* Membres du foyer (user ids) pour affiliation enfant.
|
||||
* Pivot + co-parent (A→B et B→A) + même numero_dossier. Ticket #158.
|
||||
*/
|
||||
async attachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
||||
await this.findOne(parentUserId);
|
||||
private async resolveFoyerParentUserIds(parent: Parents): Promise<string[]> {
|
||||
const ids = new Set<string>([parent.user_id]);
|
||||
|
||||
const existing = await this.parentsChildrenRepository.findOne({
|
||||
where: { parentId: parentUserId, enfantId },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('Cet enfant est déjà rattaché à ce parent');
|
||||
if (parent.co_parent?.id) {
|
||||
ids.add(parent.co_parent.id);
|
||||
}
|
||||
|
||||
const child = await this.parentsRepository.manager.findOne(Children, { where: { id: enfantId } });
|
||||
// Sens inverse : parents qui déclarent ce user comme co-parent
|
||||
const reverseLinks = await this.parentsRepository.find({
|
||||
where: { co_parent: { id: parent.user_id } },
|
||||
relations: ['co_parent'],
|
||||
});
|
||||
for (const p of reverseLinks) {
|
||||
ids.add(p.user_id);
|
||||
if (p.co_parent?.id) ids.add(p.co_parent.id);
|
||||
}
|
||||
|
||||
const dossier = parent.numero_dossier?.trim();
|
||||
if (dossier) {
|
||||
const sameDossier = await this.parentsRepository.find({
|
||||
where: { numero_dossier: dossier },
|
||||
relations: ['co_parent'],
|
||||
});
|
||||
for (const p of sameDossier) {
|
||||
ids.add(p.user_id);
|
||||
if (p.co_parent?.id) ids.add(p.co_parent.id);
|
||||
}
|
||||
}
|
||||
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rattacher un enfant au foyer du parent (tous les responsables). Ticket #158.
|
||||
* Un seul POST suffit : liens créés pour pivot + co-parent / même dossier.
|
||||
*/
|
||||
async attachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
||||
const parent = await this.findOne(parentUserId);
|
||||
|
||||
const child = await this.parentsRepository.manager.findOne(Children, {
|
||||
where: { id: enfantId },
|
||||
});
|
||||
if (!child) {
|
||||
throw new NotFoundException('Enfant introuvable');
|
||||
}
|
||||
|
||||
await this.parentsChildrenRepository.save(
|
||||
this.parentsChildrenRepository.create({ parentId: parentUserId, enfantId }),
|
||||
);
|
||||
const foyerIds = await this.resolveFoyerParentUserIds(parent);
|
||||
let created = 0;
|
||||
|
||||
for (const memberId of foyerIds) {
|
||||
const existing = await this.parentsChildrenRepository.findOne({
|
||||
where: { parentId: memberId, enfantId },
|
||||
});
|
||||
if (existing) continue;
|
||||
|
||||
await this.parentsChildrenRepository.save(
|
||||
this.parentsChildrenRepository.create({
|
||||
parentId: memberId,
|
||||
enfantId,
|
||||
}),
|
||||
);
|
||||
created += 1;
|
||||
}
|
||||
|
||||
if (created === 0) {
|
||||
throw new ConflictException('Cet enfant est déjà rattaché à ce foyer');
|
||||
}
|
||||
|
||||
return this.findOne(parentUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Détacher un enfant d'un parent sans supprimer l'enfant. Ticket #115 / doc 28 §6.2.
|
||||
* Détacher un enfant du foyer du parent (tous les responsables). Ticket #158.
|
||||
* Si plus aucun lien ensuite → enfant orphelin (#157).
|
||||
*/
|
||||
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
||||
await this.findOne(parentUserId);
|
||||
const parent = await this.findOne(parentUserId);
|
||||
|
||||
const link = await this.parentsChildrenRepository.findOne({
|
||||
where: { parentId: parentUserId, enfantId },
|
||||
@@ -150,12 +202,12 @@ export class ParentsService {
|
||||
throw new NotFoundException('Lien parent-enfant introuvable');
|
||||
}
|
||||
|
||||
const totalLinks = await this.parentsChildrenRepository.count({ where: { enfantId } });
|
||||
if (totalLinks <= 1) {
|
||||
throw new BadRequestException('Un enfant doit rester rattaché à au moins un responsable');
|
||||
}
|
||||
const foyerIds = await this.resolveFoyerParentUserIds(parent);
|
||||
await this.parentsChildrenRepository.delete({
|
||||
parentId: In(foyerIds),
|
||||
enfantId,
|
||||
});
|
||||
|
||||
await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId });
|
||||
return this.findOne(parentUserId);
|
||||
}
|
||||
|
||||
@@ -199,7 +251,15 @@ export class ParentsService {
|
||||
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
|
||||
)
|
||||
SELECT
|
||||
'Famille ' || string_agg(u.nom, ' - ' ORDER BY u.nom, u.prenom) AS libelle,
|
||||
string_agg(
|
||||
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.numero_dossier))[1] AS numero_dossier,
|
||||
MIN(u.cree_le) AS date_soumission,
|
||||
@@ -215,6 +275,8 @@ export class ParentsService {
|
||||
json_build_object(
|
||||
'id', u.id::text,
|
||||
'email', u.email,
|
||||
'nom', u.nom,
|
||||
'prenom', u.prenom,
|
||||
'telephone', u.telephone,
|
||||
'code_postal', u.code_postal,
|
||||
'ville', u.ville
|
||||
@@ -265,11 +327,21 @@ export class ParentsService {
|
||||
return [];
|
||||
}
|
||||
|
||||
private normalizeParents(parents: unknown): { id: string; email: string; telephone: string | null; code_postal: string | null; ville: string | null }[] {
|
||||
private normalizeParents(parents: unknown): {
|
||||
id: string;
|
||||
email: string;
|
||||
nom: string | null;
|
||||
prenom: string | null;
|
||||
telephone: string | null;
|
||||
code_postal: string | null;
|
||||
ville: string | null;
|
||||
}[] {
|
||||
if (Array.isArray(parents)) {
|
||||
return parents.map((p: any) => ({
|
||||
id: String(p?.id ?? ''),
|
||||
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,
|
||||
code_postal: p?.code_postal != null ? String(p.code_postal) : null,
|
||||
ville: p?.ville != null ? String(p.ville) : null,
|
||||
@@ -298,7 +370,6 @@ export class ParentsService {
|
||||
status: child.status,
|
||||
photo_url: child.photo_url ?? undefined,
|
||||
consent_photo: child.consent_photo,
|
||||
est_multiple: child.is_multiple,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -24,15 +24,19 @@ export class RelaisController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||
@ApiOperation({ summary: 'Lister tous les relais' })
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({
|
||||
summary: 'Lister tous les relais',
|
||||
description:
|
||||
'Lecture ouverte aux gestionnaires (combobox fiches). CRUD write reste admin-only. Ticket #151.',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Liste des relais.' })
|
||||
findAll() {
|
||||
return this.relaisService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({ summary: 'Récupérer un relais par ID' })
|
||||
@ApiResponse({ status: 200, description: 'Le relais trouvé.' })
|
||||
findOne(@Param('id') id: string) {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
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 d’enfants 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,420 @@
|
||||
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 d’utilisateur 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))];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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,20 +1,21 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import 'reflect-metadata';
|
||||
import { GestionnairesController } from './gestionnaires.controller';
|
||||
import { GestionnairesService } from './gestionnaires.service';
|
||||
import { RoleType } from 'src/entities/users.entity';
|
||||
|
||||
describe('GestionnairesController', () => {
|
||||
let controller: GestionnairesController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [GestionnairesController],
|
||||
providers: [GestionnairesService],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<GestionnairesController>(GestionnairesController);
|
||||
describe('GestionnairesController roles (#161)', () => {
|
||||
it('POST /gestionnaires autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
|
||||
const roles = Reflect.getMetadata('roles', GestionnairesController.prototype.create);
|
||||
expect(roles).toEqual(
|
||||
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
|
||||
);
|
||||
expect(roles).not.toContain(RoleType.GESTIONNAIRE);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
it('PATCH /gestionnaires/:id autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
|
||||
const roles = Reflect.getMetadata('roles', GestionnairesController.prototype.update);
|
||||
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 {
|
||||
constructor(private readonly gestionnairesService: GestionnairesService) { }
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN)
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||
@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é.' })
|
||||
@ApiOperation({ summary: 'Création d\'un gestionnaire' })
|
||||
@ApiOperation({ summary: 'Création d\'un gestionnaire (admin / super admin)' })
|
||||
@ApiBody({ type: CreateGestionnaireDto })
|
||||
@Post()
|
||||
create(@Body() dto: CreateGestionnaireDto): Promise<Users> {
|
||||
@@ -43,7 +43,7 @@ export class GestionnairesController {
|
||||
return this.gestionnairesService.findAll();
|
||||
}
|
||||
|
||||
@Roles(RoleType.GESTIONNAIRE, RoleType.SUPER_ADMIN)
|
||||
@Roles(RoleType.GESTIONNAIRE, RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||
@ApiOperation({ summary: 'Récupérer un gestionnaire par ID' })
|
||||
@ApiResponse({ status: 400, description: 'ID invalide' })
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||
@@ -56,8 +56,8 @@ export class GestionnairesController {
|
||||
return this.gestionnairesService.findOne(id);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN)
|
||||
@ApiOperation({ summary: 'Mettre à jour un gestionnaire' })
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||
@ApiOperation({ summary: 'Mettre à jour un gestionnaire (admin / super admin)' })
|
||||
@ApiResponse({ status: 200, description: 'Le gestionnaire a été mis à jour avec succès.', type: Users })
|
||||
@ApiResponse({ status: 404, description: 'Gestionnaire non trouvé' })
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import 'reflect-metadata';
|
||||
import { UserController } from './user.controller';
|
||||
import { UserService } from './user.service';
|
||||
import { RoleType } from 'src/entities/users.entity';
|
||||
|
||||
describe('UserController', () => {
|
||||
let controller: UserController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [UserController],
|
||||
providers: [UserService],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<UserController>(UserController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
describe('UserController roles (#161)', () => {
|
||||
it('POST /users/admin autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
|
||||
const roles = Reflect.getMetadata('roles', UserController.prototype.createAdmin);
|
||||
expect(roles).toEqual(
|
||||
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
|
||||
);
|
||||
expect(roles).not.toContain(RoleType.GESTIONNAIRE);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,18 +10,22 @@ import { CreateUserDto } from './dto/create_user.dto';
|
||||
import { CreateAdminDto } from './dto/create_admin.dto';
|
||||
import { UpdateUserDto } from './dto/update_user.dto';
|
||||
import { AffecterNumeroDossierDto } from './dto/affecter-numero-dossier.dto';
|
||||
import { SuppressionService } from '../suppressions/suppression.service';
|
||||
|
||||
@ApiTags('Utilisateurs')
|
||||
@ApiBearerAuth('access-token')
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
@Controller('users')
|
||||
export class UserController {
|
||||
constructor(private readonly userService: UserService) { }
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly suppressionService: SuppressionService,
|
||||
) { }
|
||||
|
||||
// Création d'un administrateur (réservée aux super admins)
|
||||
// Création d'un administrateur (admin + super admin) — #161
|
||||
@Post('admin')
|
||||
@Roles(RoleType.SUPER_ADMIN)
|
||||
@ApiOperation({ summary: 'Créer un nouvel administrateur (super admin seulement)' })
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||
@ApiOperation({ summary: 'Créer un nouvel administrateur (admin / super admin)' })
|
||||
createAdmin(
|
||||
@Body() dto: CreateAdminDto,
|
||||
@User() currentUser: Users
|
||||
@@ -146,12 +150,20 @@ export class UserController {
|
||||
return this.userService.suspendUser(id, currentUser, comment);
|
||||
}
|
||||
|
||||
// Supprimer un utilisateur (super_admin uniquement)
|
||||
// Supprimer un utilisateur — cascades métier #159
|
||||
@Delete(':id')
|
||||
@Roles(RoleType.SUPER_ADMIN)
|
||||
@ApiOperation({ summary: 'Supprimer un utilisateur' })
|
||||
@Roles(
|
||||
RoleType.SUPER_ADMIN,
|
||||
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" })
|
||||
remove(@Param('id') id: string, @User() currentUser: Users) {
|
||||
return this.userService.remove(id, currentUser);
|
||||
return this.suppressionService.deleteUser(id, currentUser);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Parents } from 'src/entities/parents.entity';
|
||||
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
|
||||
import { MailModule } from 'src/modules/mail/mail.module';
|
||||
import { AppConfigModule } from 'src/modules/config/config.module';
|
||||
import { SuppressionsModule } from '../suppressions/suppressions.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature(
|
||||
@@ -26,6 +27,7 @@ import { AppConfigModule } from 'src/modules/config/config.module';
|
||||
GestionnairesModule,
|
||||
MailModule,
|
||||
AppConfigModule,
|
||||
SuppressionsModule,
|
||||
],
|
||||
controllers: [UserController],
|
||||
providers: [UserService],
|
||||
|
||||
@@ -1,18 +1,87 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
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;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [UserService],
|
||||
}).compile();
|
||||
const dto = {
|
||||
email: 'nouveau.admin@ptits-pas.fr',
|
||||
password: 'Password1!',
|
||||
prenom: 'Nina',
|
||||
nom: 'Admin',
|
||||
telephone: '0601020304',
|
||||
};
|
||||
|
||||
service = module.get<UserService>(UserService);
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new UserService(
|
||||
usersRepository as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
it('autorise un administrateur à créer un admin', async () => {
|
||||
usersRepository.findOneBy.mockResolvedValue(null);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,8 +117,14 @@ export class UserService {
|
||||
}
|
||||
|
||||
async createAdmin(dto: CreateAdminDto, currentUser: Users): Promise<Users> {
|
||||
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
||||
throw new ForbiddenException('Seuls les super administrateurs peuvent créer un administrateur');
|
||||
// #161 — admin et super_admin peuvent créer un administrateur
|
||||
if (
|
||||
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 });
|
||||
@@ -520,6 +526,7 @@ export class UserService {
|
||||
}
|
||||
|
||||
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) {
|
||||
throw new ForbiddenException('Accès réservé aux super admins');
|
||||
}
|
||||
|
||||
+1
-2
@@ -174,8 +174,7 @@ CREATE TABLE enfants (
|
||||
date_prevue_naissance DATE,
|
||||
photo_url TEXT,
|
||||
consentement_photo BOOLEAN DEFAULT false,
|
||||
date_consentement_photo TIMESTAMPTZ,
|
||||
est_multiple BOOLEAN DEFAULT false
|
||||
date_consentement_photo TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- ==========================================================
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo","est_multiple"
|
||||
"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","sans_garde","Emma","Dupont","F","2020-06-01",,,False,,False
|
||||
"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","sans_garde",,,,"2020-01-01","2025-01-01",,False,,False
|
||||
"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","sans_garde","Emma","Martin",,"2023-02-15",,,False,,False
|
||||
"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","sans_garde","Noah","Martin",,"2023-02-15",,,False,,False
|
||||
"e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e","sans_garde","Léa","Martin",,"2023-02-15",,,False,,False
|
||||
"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","sans_garde","Chloé","Rousseau",,"2022-04-20",,,False,,False
|
||||
"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","sans_garde","Hugo","Rousseau",,"2024-03-10",,,False,,False
|
||||
"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","sans_garde","Maxime","Lecomte",,"2023-04-15",,,False,,False
|
||||
"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,,False
|
||||
"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo"
|
||||
"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","sans_garde","Emma","Dupont","F","2020-06-01",,,False,
|
||||
"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","sans_garde",,,,"2020-01-01","2025-01-01",,False,
|
||||
"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","sans_garde","Emma","Martin",,"2023-02-15",,,False,
|
||||
"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","sans_garde","Noah","Martin",,"2023-02-15",,,False,
|
||||
"e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e","sans_garde","Léa","Martin",,"2023-02-15",,,False,
|
||||
"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","sans_garde","Chloé","Rousseau",,"2022-04-20",,,False,
|
||||
"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","sans_garde","Hugo","Rousseau",,"2024-03-10",,,False,
|
||||
"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","sans_garde","Maxime","Lecomte",,"2023-04-15",,,False,
|
||||
"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,
|
||||
|
||||
|
@@ -0,0 +1,2 @@
|
||||
-- #152 — Suppression grossesse multiple / est_multiple
|
||||
ALTER TABLE enfants DROP COLUMN IF EXISTS est_multiple;
|
||||
@@ -69,12 +69,12 @@ ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||
-- - child B : à naître (statut = 'a_naitre' et date_prevue_naissance requise)
|
||||
-- ------------------------------------------------------------
|
||||
|
||||
INSERT INTO enfants (id, prenom, nom, statut, date_naissance, jumeau_multiple)
|
||||
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'sans_garde', '2022-04-12', false)
|
||||
INSERT INTO enfants (id, prenom, nom, statut, date_naissance)
|
||||
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'sans_garde', '2022-04-12')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
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', false)
|
||||
INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance)
|
||||
VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Mila', 'Parent', 'a_naitre', '2026-02-15')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
|
||||
@@ -49,14 +49,14 @@ VALUES
|
||||
ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||
|
||||
-- ========== ENFANTS ==========
|
||||
INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut, est_multiple)
|
||||
INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut)
|
||||
VALUES
|
||||
('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'sans_garde', true),
|
||||
('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'sans_garde', true),
|
||||
('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'sans_garde', true),
|
||||
('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'sans_garde', false),
|
||||
('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'sans_garde', false),
|
||||
('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'sans_garde', false)
|
||||
('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'sans_garde'),
|
||||
('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'sans_garde'),
|
||||
('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'sans_garde'),
|
||||
('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'sans_garde'),
|
||||
('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'sans_garde'),
|
||||
('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'sans_garde')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ========== ENFANTS_PARENTS (liaison N:N) ==========
|
||||
|
||||
@@ -117,7 +117,6 @@ Table des enfants pris en charge.
|
||||
| `photo_url` | TEXT | | URL de la photo |
|
||||
| `consentement_photo` | BOOLEAN | DEFAULT false | Consentement photo |
|
||||
| `date_consentement_photo` | TIMESTAMPTZ | | Date du consentement |
|
||||
| `est_multiple` | BOOLEAN | DEFAULT false | Indique si grossesse multiple |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -276,9 +276,6 @@ export class Enfants {
|
||||
@Column({ name: 'consentement_photo', type: 'boolean', default: false })
|
||||
consentementPhoto: boolean;
|
||||
|
||||
@Column({ name: 'est_multiple', type: 'boolean', default: false })
|
||||
estMultiple: boolean;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: StatutEnfantType,
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# Analyse d’empreinte — P'titsPas
|
||||
|
||||
**Date :** 2026-07-22
|
||||
**Contexte :** snapshot pour mémoire (après alerte disque plein + purge cache Gitea)
|
||||
**Périmètre :** application `jmartin/petitspas` déployée sur le VPS (`/home/deploy/dev/ptitspas-app`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Lignes de code
|
||||
|
||||
Comptage brut (`wc -l`), hors `node_modules` / builds / `.dart_tool`.
|
||||
|
||||
| Zone | Lignes | Fichiers |
|
||||
|------|--------|----------|
|
||||
| **Frontend** (Dart `frontend/lib/`) | ~29 800 | 141 |
|
||||
| **Backend** (TypeScript `backend/src/`) | ~10 000 | 141 |
|
||||
| **BDD** (SQL sous `database/`) | ~1 250 | ~15 |
|
||||
| **Total** | **~41 000** | |
|
||||
|
||||
### Frontend (détail)
|
||||
|
||||
| Dossier | Lignes |
|
||||
|---------|--------|
|
||||
| `widgets/` | ~18 400 |
|
||||
| `screens/` | ~5 000 |
|
||||
| `services/` | ~2 300 |
|
||||
| `models/` | ~2 200 |
|
||||
| `utils/` | ~1 400 |
|
||||
| reste | ~500 |
|
||||
|
||||
### Backend (détail)
|
||||
|
||||
| Dossier | Lignes |
|
||||
|---------|--------|
|
||||
| `routes/` | ~6 500 |
|
||||
| `modules/` | ~1 700 |
|
||||
| `entities/` | ~1 000 |
|
||||
| `common/` + `config/` | ~400 |
|
||||
|
||||
### BDD (détail)
|
||||
|
||||
| Élément | Lignes |
|
||||
|---------|--------|
|
||||
| `BDD.sql` (schéma) | ~470 |
|
||||
| seeds | ~300 |
|
||||
| migrations / patches | ~280 |
|
||||
| tests SQL | ~205 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Empreinte disque — runtime (Docker)
|
||||
|
||||
| Composant | Taille | Notes |
|
||||
|-----------|--------|--------|
|
||||
| Image `ptitspas-app-backend` | ~300 Mo | NestJS |
|
||||
| Image `ptitspas-app-frontend` | ~122 Mo | Flutter web + Nginx |
|
||||
| Image `postgres:17` | ~454 Mo | |
|
||||
| Image `dpage/pgadmin4` | ~534 Mo | optionnel |
|
||||
| Volume `postgres_data` | ~49 Mo | données BDD live |
|
||||
| Volume `backend_uploads` | ~20 Mo | photos |
|
||||
| Volume `backend_documents_legaux` | ~0 | |
|
||||
| **Stack complète (avec pgAdmin)** | **~1,5 Go** | |
|
||||
| **Stack prod sans pgAdmin** | **~945 Mo** | |
|
||||
|
||||
---
|
||||
|
||||
## 3. Empreinte disque — code source
|
||||
|
||||
| Élément | Taille |
|
||||
|---------|--------|
|
||||
| Clone `/home/deploy/dev/ptitspas-app` | ~701 Mo |
|
||||
| dont `backend/node_modules` | ~354 Mo |
|
||||
| dont `frontend` (+ `.dart_tool`) | ~114 Mo |
|
||||
| **Code utile** (hors deps / `.git` / builds) | **~51 Mo** |
|
||||
| Repo Gitea `petitspas.git` | ~109 Mo |
|
||||
| Dump `database/BDD.sql` | ~19 Ko |
|
||||
|
||||
---
|
||||
|
||||
## 4. Pour (re)déployer
|
||||
|
||||
Minimum requis :
|
||||
|
||||
1. Images custom backend + frontend (~422 Mo), ou code + build Docker
|
||||
2. Image `postgres:17` (~454 Mo)
|
||||
3. Volumes persistants (~70 Mo au snapshot)
|
||||
4. Optionnel : pgAdmin (~534 Mo)
|
||||
|
||||
**Ordre de grandeur :** ~1 Go pour faire tourner l’app en prod (sans pgAdmin).
|
||||
|
||||
---
|
||||
|
||||
## 5. Notes infra du jour (lié)
|
||||
|
||||
- Disque VPS passé de **100 %** à ~**44 %** après :
|
||||
- purge cache Docker / journals
|
||||
- purge officielle Gitea `delete_repo_archives` (~24 Go de zip/bundle `petitspas`)
|
||||
- Crons Gitea activés :
|
||||
- `archive_cleanup` @midnight (`OLDER_THAN = 24h`)
|
||||
- `delete_repo_archives` @weekly
|
||||
|
||||
---
|
||||
|
||||
*Fichier généré pour historique projet — ne pas considérer comme métrique CI automatisée.*
|
||||
@@ -0,0 +1,89 @@
|
||||
# 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 d’accusé « 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`
|
||||
@@ -0,0 +1,73 @@
|
||||
# Mini-spec API — POST /parents/:id/co-parent (#135)
|
||||
|
||||
Contrat back pour l’ajout d’un **2ᵉ parent** sur un foyer mono-parent (staff).
|
||||
|
||||
## Endpoint
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **Méthode** | `POST` |
|
||||
| **URL** | `{base}/api/v1/parents/{parentUserId}/co-parent` |
|
||||
| **Auth** | Bearer JWT |
|
||||
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
|
||||
| **Succès** | **201** |
|
||||
|
||||
`parentUserId` = UUID du **parent pivot** (déjà dans le dossier).
|
||||
|
||||
Ne **pas** appeler `POST /auth/register/parent` ni `POST /parents/dossier`.
|
||||
|
||||
---
|
||||
|
||||
## Body (JSON)
|
||||
|
||||
| Champ | Type | Obligatoire | Notes |
|
||||
|-------|------|-------------|--------|
|
||||
| `email` | string | oui | unique |
|
||||
| `prenom` | string | oui | |
|
||||
| `nom` | string | oui | |
|
||||
| `telephone` | string | oui | `0X…` ou `+33…` |
|
||||
| `meme_adresse` | bool | non | défaut **true** → copie adresse du pivot |
|
||||
| `adresse` | string | si `meme_adresse=false` | |
|
||||
| `code_postal` | string | si `meme_adresse=false` | |
|
||||
| `ville` | string | si `meme_adresse=false` | |
|
||||
|
||||
---
|
||||
|
||||
## Comportement 201
|
||||
|
||||
- User co-parent **actif** + token création MDP
|
||||
- Fiche `parents` + liens pivot ↔ co-parent + même `numero_dossier`
|
||||
- Enfants du foyer rattachés au co-parent
|
||||
- E-mail **création MDP** (pas mail « en attente »)
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Co-parent ajouté au foyer. Un e-mail de création de mot de passe a été envoyé.",
|
||||
"numero_dossier": "2026-000043",
|
||||
"parent_user_id": "uuid-pivot",
|
||||
"co_parent_user_id": "uuid-co",
|
||||
"statut": "actif"
|
||||
}
|
||||
```
|
||||
|
||||
## Erreurs
|
||||
|
||||
| Code | Cas |
|
||||
|------|-----|
|
||||
| 400 | Déjà un co-parent / 2 responsables / validation adresse |
|
||||
| 401 | Token invalide |
|
||||
| 403 | Rôle non staff |
|
||||
| 404 | Pivot introuvable |
|
||||
| 409 | Email déjà pris |
|
||||
|
||||
## Réemploi édition identité
|
||||
|
||||
| Endpoint | Usage |
|
||||
|----------|--------|
|
||||
| `GET /dossiers/:numero` | Préremplir wizard edit |
|
||||
| `PATCH /parents/:id/fiche` | Sauver identité pivot / co-parent existant |
|
||||
| `PATCH /assistantes-maternelles/:id/fiche` | Édition AM |
|
||||
|
||||
## Branche
|
||||
|
||||
`feature/135-edition-dossier`
|
||||
@@ -0,0 +1,83 @@
|
||||
# Mini-spec front — Mode édition dossier + ajout 2ᵉ parent (#135)
|
||||
|
||||
Branche : `feature/135-edition-dossier`
|
||||
Ticket : **#135** (full-stack)
|
||||
|
||||
Prérequis : **#153** (liste Dossiers) livré.
|
||||
|
||||
---
|
||||
|
||||
## Objectif
|
||||
|
||||
1. Clic sur un dossier (liste #153) → ouvrir le wizard en mode **`edit`**
|
||||
2. Foyer **mono-parent** : page co-parent → **switch** ajouter un 2ᵉ parent
|
||||
3. Sauvegarder les champs via APIs existantes + nouvel endpoint co-parent
|
||||
|
||||
---
|
||||
|
||||
## Modes wizard
|
||||
|
||||
| Mode | Famille | AM |
|
||||
|------|---------|-----|
|
||||
| `review` | déjà | déjà |
|
||||
| `create` | déjà (#129) | déjà (#156) |
|
||||
| **`edit`** | **à faire** | **à faire** |
|
||||
|
||||
Factories : `ParentDossierWizard.edit(...)` / `AmDossierWizard.edit(...)`
|
||||
Préremplir via `UserService.getDossierByNumero(numero)`.
|
||||
|
||||
---
|
||||
|
||||
## APIs
|
||||
|
||||
| Action | Endpoint |
|
||||
|--------|----------|
|
||||
| Charger | `GET /dossiers/:numero` |
|
||||
| Sauver parent | `PATCH /parents/:id/fiche` |
|
||||
| Sauver AM | `PATCH /assistantes-maternelles/:id/fiche` |
|
||||
| **Ajouter co-parent** | **`POST /parents/:pivotUserId/co-parent`** — voir `docs/tmp/135-contrat-api-ajout-co-parent.md` |
|
||||
|
||||
Body co-parent :
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "thomas@…",
|
||||
"prenom": "Thomas",
|
||||
"nom": "MARTIN",
|
||||
"telephone": "0678456789",
|
||||
"meme_adresse": true
|
||||
}
|
||||
```
|
||||
|
||||
`UserService.addCoParent(pivotUserId, body)` → cet endpoint.
|
||||
|
||||
---
|
||||
|
||||
## UX
|
||||
|
||||
- Depuis `DossiersManagementWidget` / carte liste : clic → edit (plus seulement review pending)
|
||||
- Pending : garder validation (review) ; dossiers actifs → edit
|
||||
- Mono-parent : switch « Ajouter un co-parent » (comme create) → au save, `POST …/co-parent` si nouveau
|
||||
- Déjà 2 parents : éditer les deux fiches ; pas de 3ᵉ
|
||||
- Pas de bouton créer dans l’onglet Dossiers
|
||||
|
||||
---
|
||||
|
||||
## Hors scope
|
||||
|
||||
- Famille N responsables (#139)
|
||||
- Suppressions (#154)
|
||||
- Création dossier initial (#129 / #156)
|
||||
|
||||
---
|
||||
|
||||
## Critères d’acceptation
|
||||
|
||||
- [ ] Clic dossier actif → wizard edit prérempli
|
||||
- [ ] PATCH fiche enregistre les modifs
|
||||
- [ ] Mono-parent + switch → co-parent créé (actif + mail MDP)
|
||||
- [ ] review / create inchangés
|
||||
|
||||
## Branche
|
||||
|
||||
`feature/135-edition-dossier`
|
||||
@@ -0,0 +1,65 @@
|
||||
# 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
|
||||
@@ -0,0 +1,77 @@
|
||||
# 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é d’unité |
|
||||
| `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` d’abord, 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`
|
||||
@@ -0,0 +1,167 @@
|
||||
# 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 l’onglet 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 (aujourd’hui 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 l’ouverture « review » d’un 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 d’endpoint `GET /dossiers` liste** aujourd’hui. 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 d’acceptation front
|
||||
|
||||
- [ ] Onglet **Dossiers** toujours visible (même 0 pending)
|
||||
- [ ] Plus d’onglet 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`)
|
||||
@@ -0,0 +1,35 @@
|
||||
# Matrice suppression — #154 / back **#159** / front **#160**
|
||||
|
||||
**Statut** : cadrage PO validé (sept. 2026)
|
||||
**Milestone** : 0.1.0
|
||||
**Email** : pas d’email 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 d’entré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).
|
||||
@@ -0,0 +1,169 @@
|
||||
# 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 d’impact) → DELETE → **refresh** liste
|
||||
- Pending = **mêmes** règles que validés
|
||||
- **Pas** d’email
|
||||
|
||||
| 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 : « L’enfant sera supprimé du dossier de {famille} ({n°}). »
|
||||
- Dernier : proposer **deux actions** :
|
||||
1. Supprimer l’enfant 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)
|
||||
@@ -0,0 +1,75 @@
|
||||
# Mini-spec API — POST /assistantes-maternelles/dossier (#156)
|
||||
|
||||
Contrat pour le **plan front** (wizard création AM staff).
|
||||
|
||||
## Endpoint
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **Méthode** | `POST` |
|
||||
| **URL** | `{base}/assistantes-maternelles/dossier` |
|
||||
| **Auth** | Bearer JWT |
|
||||
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
|
||||
| **Content-Type** | `application/json` |
|
||||
|
||||
Ne **pas** appeler `POST /auth/register/am` depuis le dashboard.
|
||||
|
||||
## Body (JSON)
|
||||
|
||||
Aligné inscription AM publique, **sans** CGU/privacy obligatoires (acceptées serveur).
|
||||
|
||||
| Champ | Type | Obligatoire | Notes |
|
||||
|-------|------|-------------|--------|
|
||||
| `email` | string | oui | unique |
|
||||
| `prenom` | string | oui | |
|
||||
| `nom` | string | oui | |
|
||||
| `telephone` | string | oui | `0X…` ou `+33…` |
|
||||
| `adresse` | string | non | |
|
||||
| `code_postal` | string | non | |
|
||||
| `ville` | string | non | |
|
||||
| `photo_base64` | string | non | data-URL `data:image/…;base64,…` |
|
||||
| `photo_filename` | string | non | hint nom fichier |
|
||||
| `consentement_photo` | bool | oui | |
|
||||
| `date_naissance` | date ISO | non | `YYYY-MM-DD` |
|
||||
| `lieu_naissance_ville` | string | oui | |
|
||||
| `lieu_naissance_pays` | string | oui | |
|
||||
| `nir` | string | oui | 15 car. (Corse 2A/2B OK) |
|
||||
| `numero_agrement` | string | oui | unique |
|
||||
| `date_agrement` | date ISO | non | |
|
||||
| `capacite_accueil` | int | oui | 1–10 |
|
||||
| `places_disponibles` | int | oui | 0–10, ≤ capacité |
|
||||
| `biographie` | string | non | max 2000 |
|
||||
|
||||
## Réponses
|
||||
|
||||
### 201 Created
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Dossier AM créé et validé. Un e-mail de création de mot de passe a été envoyé.",
|
||||
"user_id": "uuid",
|
||||
"statut": "actif",
|
||||
"numero_dossier": "2026-000042"
|
||||
}
|
||||
```
|
||||
|
||||
Effets serveur : user AM **actif**, fiche `assistantes_maternelles`, n° dossier, **e-mail création MDP** (pas d’accusé « en attente »).
|
||||
|
||||
### Erreurs
|
||||
|
||||
| Code | Cas |
|
||||
|------|-----|
|
||||
| 400 | Validation / NIR / places > capacité |
|
||||
| 403 | Rôle non staff |
|
||||
| 409 | Email, NIR ou agrément déjà pris |
|
||||
| 401 | Token manquant / invalide |
|
||||
|
||||
## Front
|
||||
|
||||
- `UserService.createAmDossier(body)` → cet endpoint
|
||||
- Après 201 : refresh liste AM ; snackbar OK
|
||||
- Wizard create : ne pas envoyer `acceptation_cgu` / `acceptation_privacy` (optionnels)
|
||||
|
||||
## Branche
|
||||
|
||||
`feature/156-creation-dossier-am`
|
||||
@@ -0,0 +1,239 @@
|
||||
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 l’icône si présente.
|
||||
final String? photoUrl;
|
||||
/// Dossier famille sans enfant lié (#159 / #160).
|
||||
final bool sansEnfant;
|
||||
/// Nombre d’enfants 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';
|
||||
}
|
||||
@@ -185,7 +185,6 @@ class EnfantDossier {
|
||||
final String? dueDate;
|
||||
final String? photoUrl;
|
||||
final bool consentPhoto;
|
||||
final bool estMultiple;
|
||||
|
||||
EnfantDossier({
|
||||
required this.id,
|
||||
@@ -197,7 +196,6 @@ class EnfantDossier {
|
||||
this.dueDate,
|
||||
this.photoUrl,
|
||||
this.consentPhoto = false,
|
||||
this.estMultiple = false,
|
||||
});
|
||||
|
||||
String get fullName => '${firstName ?? ''} ${lastName ?? ''}'.trim();
|
||||
@@ -215,8 +213,13 @@ class EnfantDossier {
|
||||
factory EnfantDossier.fromJson(Map<String, dynamic> json) {
|
||||
final rawPhoto = json['photo_url'] ?? json['photoUrl'];
|
||||
final resolvedPhoto = _optionalPhotoUrl(rawPhoto);
|
||||
final rawId = json['id'] ??
|
||||
json['enfant_id'] ??
|
||||
json['enfantId'] ??
|
||||
json['child_id'] ??
|
||||
json['childId'];
|
||||
return EnfantDossier(
|
||||
id: json['id']?.toString() ?? '',
|
||||
id: rawId?.toString().trim() ?? '',
|
||||
firstName: (json['first_name'] ?? json['prenom'])?.toString(),
|
||||
lastName: (json['last_name'] ?? json['nom'])?.toString(),
|
||||
birthDate: json['birth_date']?.toString(),
|
||||
@@ -226,8 +229,6 @@ class EnfantDossier {
|
||||
photoUrl: resolvedPhoto,
|
||||
consentPhoto:
|
||||
json['consent_photo'] == true || json['consentPhoto'] == true,
|
||||
estMultiple:
|
||||
json['est_multiple'] == true || json['estMultiple'] == true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,9 @@ class EnfantAdminModel {
|
||||
final String status;
|
||||
final String? photoUrl;
|
||||
final bool consentPhoto;
|
||||
final bool isMultiple;
|
||||
final List<EnfantParentLink> parentLinks;
|
||||
/// Flag API #157 (sinon déduit de [parentLinks]).
|
||||
final bool? sansResponsable;
|
||||
|
||||
EnfantAdminModel({
|
||||
required this.id,
|
||||
@@ -25,8 +26,8 @@ class EnfantAdminModel {
|
||||
required this.status,
|
||||
this.photoUrl,
|
||||
this.consentPhoto = false,
|
||||
this.isMultiple = false,
|
||||
this.parentLinks = const [],
|
||||
this.sansResponsable,
|
||||
});
|
||||
|
||||
String get fullName {
|
||||
@@ -37,6 +38,12 @@ class EnfantAdminModel {
|
||||
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({
|
||||
String? id,
|
||||
String? firstName,
|
||||
@@ -47,8 +54,8 @@ class EnfantAdminModel {
|
||||
String? status,
|
||||
String? photoUrl,
|
||||
bool? consentPhoto,
|
||||
bool? isMultiple,
|
||||
List<EnfantParentLink>? parentLinks,
|
||||
bool? sansResponsable,
|
||||
}) {
|
||||
return EnfantAdminModel(
|
||||
id: id ?? this.id,
|
||||
@@ -60,8 +67,8 @@ class EnfantAdminModel {
|
||||
status: status ?? this.status,
|
||||
photoUrl: photoUrl ?? this.photoUrl,
|
||||
consentPhoto: consentPhoto ?? this.consentPhoto,
|
||||
isMultiple: isMultiple ?? this.isMultiple,
|
||||
parentLinks: parentLinks ?? this.parentLinks,
|
||||
sansResponsable: sansResponsable ?? this.sansResponsable,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,6 +88,13 @@ class EnfantAdminModel {
|
||||
_parseBool(json['consentement_photo']) ||
|
||||
_parseBool(json['consentPhoto']);
|
||||
|
||||
bool? sansResponsable;
|
||||
if (json.containsKey('sans_responsable') ||
|
||||
json.containsKey('sansResponsable')) {
|
||||
sansResponsable = _parseBool(json['sans_responsable']) ||
|
||||
_parseBool(json['sansResponsable']);
|
||||
}
|
||||
|
||||
return EnfantAdminModel(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
firstName: json['first_name'] as String? ?? json['prenom'] as String?,
|
||||
@@ -93,9 +107,8 @@ class EnfantAdminModel {
|
||||
),
|
||||
photoUrl: photoUrl,
|
||||
consentPhoto: consentPhoto,
|
||||
isMultiple: _parseBool(json['is_multiple']) ||
|
||||
_parseBool(json['est_multiple']),
|
||||
parentLinks: links,
|
||||
sansResponsable: sansResponsable,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,7 +121,6 @@ class EnfantAdminModel {
|
||||
if (birthDate != null && birthDate!.isNotEmpty) 'birth_date': birthDate,
|
||||
if (dueDate != null && dueDate!.isNotEmpty) 'due_date': dueDate,
|
||||
'consent_photo': consentPhoto,
|
||||
'is_multiple': isMultiple,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -62,4 +62,56 @@ class ParentModel {
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
/// Nombre d’enfants distincts du foyer (ce parent + co-parent / même dossier).
|
||||
/// Évite Claire=7 / Thomas=6 quand un lien n’est que sur un des deux (#157).
|
||||
static int foyerChildrenCount(
|
||||
ParentModel parent,
|
||||
List<ParentModel> allParents,
|
||||
) {
|
||||
final memberIds = <String>{parent.user.id};
|
||||
final coId = parent.coParent?.id.trim();
|
||||
if (coId != null && coId.isNotEmpty) memberIds.add(coId);
|
||||
|
||||
final dossier = (parent.user.numeroDossier ?? '').trim();
|
||||
|
||||
for (final other in allParents) {
|
||||
if (memberIds.contains(other.user.id)) continue;
|
||||
final otherCo = other.coParent?.id.trim();
|
||||
if (otherCo != null && memberIds.contains(otherCo)) {
|
||||
memberIds.add(other.user.id);
|
||||
continue;
|
||||
}
|
||||
if (dossier.isNotEmpty &&
|
||||
(other.user.numeroDossier ?? '').trim() == dossier) {
|
||||
memberIds.add(other.user.id);
|
||||
final oc = other.coParent?.id.trim();
|
||||
if (oc != null && oc.isNotEmpty) memberIds.add(oc);
|
||||
}
|
||||
}
|
||||
|
||||
final childIds = <String>{};
|
||||
for (final p in allParents) {
|
||||
if (!memberIds.contains(p.user.id)) continue;
|
||||
for (final c in p.children) {
|
||||
final id = c.id.trim();
|
||||
if (id.isNotEmpty) childIds.add(id);
|
||||
}
|
||||
// Repli si la liste enfants n’est pas hydratée.
|
||||
if (p.children.isEmpty && p.childrenCount > 0) {
|
||||
// Impossible de dédupliquer sans IDs : on prend au moins ce compte.
|
||||
// (évite d’afficher 0 si l’API n’envoie que childrenCount)
|
||||
}
|
||||
}
|
||||
|
||||
if (childIds.isNotEmpty) return childIds.length;
|
||||
|
||||
var maxCount = 0;
|
||||
for (final p in allParents) {
|
||||
if (!memberIds.contains(p.user.id)) continue;
|
||||
final n = p.children.isNotEmpty ? p.children.length : p.childrenCount;
|
||||
if (n > maxCount) maxCount = n;
|
||||
}
|
||||
return maxCount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ class ChildData {
|
||||
String lastName;
|
||||
String dob; // Date de naissance ou prévisionnelle
|
||||
bool photoConsent;
|
||||
bool multipleBirth;
|
||||
bool isUnbornChild;
|
||||
File? imageFile;
|
||||
CardColorVertical cardColor; // Nouveau champ pour la couleur de la carte
|
||||
@@ -40,7 +39,6 @@ class ChildData {
|
||||
this.lastName = '',
|
||||
this.dob = '',
|
||||
this.photoConsent = false,
|
||||
this.multipleBirth = false,
|
||||
this.isUnbornChild = false,
|
||||
this.imageFile,
|
||||
required this.cardColor, // Rendre requis dans le constructeur
|
||||
|
||||
@@ -38,7 +38,6 @@ class ChildData {
|
||||
/// Valeurs API : `H`, `F`, `Autre` (GenreType backend). Vide tant que non choisi.
|
||||
String genre;
|
||||
bool photoConsent;
|
||||
bool multipleBirth;
|
||||
bool isUnbornChild;
|
||||
File? imageFile;
|
||||
/// Octets de la photo (fiable à l’envoi API ; [imageFile] peut être absent sur le web).
|
||||
@@ -55,7 +54,6 @@ class ChildData {
|
||||
this.dob = '',
|
||||
this.genre = '',
|
||||
this.photoConsent = false,
|
||||
this.multipleBirth = false,
|
||||
this.isUnbornChild = false,
|
||||
this.imageFile,
|
||||
this.imageBytes,
|
||||
@@ -70,7 +68,6 @@ class ChildData {
|
||||
String? dob,
|
||||
String? genre,
|
||||
bool? photoConsent,
|
||||
bool? multipleBirth,
|
||||
bool? isUnbornChild,
|
||||
Object? imageFile = _unsetImage,
|
||||
Object? imageBytes = _unsetImageBytes,
|
||||
@@ -84,7 +81,6 @@ class ChildData {
|
||||
dob: dob ?? this.dob,
|
||||
genre: genre ?? this.genre,
|
||||
photoConsent: photoConsent ?? this.photoConsent,
|
||||
multipleBirth: multipleBirth ?? this.multipleBirth,
|
||||
isUnbornChild: isUnbornChild ?? this.isUnbornChild,
|
||||
imageFile: identical(imageFile, _unsetImage) ? this.imageFile : imageFile as File?,
|
||||
imageBytes:
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/utils/email_utils.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||
import 'package:p_tits_pas/widgets/email_text_field.dart';
|
||||
import 'package:p_tits_pas/widgets/french_phone_field.dart';
|
||||
|
||||
@@ -151,30 +152,19 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
|
||||
Future<void> _delete() async {
|
||||
if (!_isEditMode || _isSubmitting) return;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
return AlertDialog(
|
||||
title: const Text('Confirmer la suppression'),
|
||||
content: Text(
|
||||
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
|
||||
),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
final name = widget.initialUser!.fullName.isEmpty
|
||||
? widget.initialUser!.email
|
||||
: widget.initialUser!.fullName;
|
||||
final confirmed = await showSuppressionConfirmDialog(
|
||||
context,
|
||||
title: 'Supprimer l\'administrateur',
|
||||
people: [SuppressionPersonLine.administrateur(name)],
|
||||
footnotes: const [
|
||||
'Le compte sera définitivement supprimé.',
|
||||
],
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
if (!confirmed) return;
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
|
||||
@@ -8,6 +8,8 @@ import 'package:p_tits_pas/models/user.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/user_service.dart';
|
||||
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||
|
||||
class AdminUserFormDialog extends StatefulWidget {
|
||||
final AppUser? initialUser;
|
||||
@@ -43,6 +45,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
List<RelaisModel> _relais = [];
|
||||
String? _selectedRelaisId;
|
||||
String? _currentUserId;
|
||||
String? _currentUserRole;
|
||||
bool get _isEditMode => widget.initialUser != null;
|
||||
bool get _isSuperAdminTarget =>
|
||||
(widget.initialUser?.role ?? '').toLowerCase() == 'super_admin';
|
||||
@@ -50,7 +53,12 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
_isEditMode &&
|
||||
_currentUserId != null &&
|
||||
widget.initialUser!.id == _currentUserId;
|
||||
bool get _canDeleteTarget => !_isSuperAdminTarget && !_isSelfTarget;
|
||||
/// Gestionnaire : pas de delete staff. Admin+ seulement (#154 / #160).
|
||||
bool get _canDeleteTarget {
|
||||
if (!_isEditMode || widget.readOnly) return false;
|
||||
if (_isSelfTarget || _isSuperAdminTarget) return false;
|
||||
return canDeleteGestionnaire(_currentUserRole);
|
||||
}
|
||||
bool get _isLockedAdminIdentity =>
|
||||
_isEditMode && widget.adminMode && _isSuperAdminTarget;
|
||||
String get _targetRoleKey {
|
||||
@@ -125,6 +133,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
if (cached != null) {
|
||||
setState(() {
|
||||
_currentUserId = cached.id;
|
||||
_currentUserRole = cached.role;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -132,6 +141,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
if (!mounted || refreshed == null) return;
|
||||
setState(() {
|
||||
_currentUserId = refreshed.id;
|
||||
_currentUserRole = refreshed.role;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -146,6 +156,21 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Fallback si GET /relais échoue : conserve le relais déjà connu sur l'utilisateur.
|
||||
List<RelaisModel> _fallbackRelaisFromUser() {
|
||||
final id = _selectedRelaisId?.trim();
|
||||
if (id == null || id.isEmpty) return const [];
|
||||
final nom = (widget.initialUser?.relaisNom ?? '').trim();
|
||||
return [
|
||||
RelaisModel(
|
||||
id: id,
|
||||
nom: nom.isNotEmpty ? nom : 'Relais actuel',
|
||||
adresse: '',
|
||||
actif: true,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _loadRelais() async {
|
||||
try {
|
||||
final list = await RelaisService.getRelais();
|
||||
@@ -162,7 +187,8 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
if (selected != null) {
|
||||
filtered.add(selected);
|
||||
} else {
|
||||
_selectedRelaisId = null;
|
||||
// Garder l'id sélectionné et afficher un item de secours (nom carte).
|
||||
filtered.addAll(_fallbackRelaisFromUser());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,9 +198,9 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
// Ne pas nullifier _selectedRelaisId (#151) — la carte a déjà le bon libellé.
|
||||
setState(() {
|
||||
_selectedRelaisId = null;
|
||||
_relais = [];
|
||||
_relais = _fallbackRelaisFromUser();
|
||||
_isLoadingRelais = false;
|
||||
});
|
||||
}
|
||||
@@ -362,30 +388,25 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
if (!_canDeleteTarget) return;
|
||||
if (!_isEditMode || _isSubmitting) return;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
return AlertDialog(
|
||||
title: const Text('Confirmer la suppression'),
|
||||
content: Text(
|
||||
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
|
||||
),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
final name = widget.initialUser!.fullName.isEmpty
|
||||
? widget.initialUser!.email
|
||||
: widget.initialUser!.fullName;
|
||||
final confirmed = await showSuppressionConfirmDialog(
|
||||
context,
|
||||
title: widget.adminMode
|
||||
? 'Supprimer l\'administrateur'
|
||||
: 'Supprimer le gestionnaire',
|
||||
people: [
|
||||
widget.adminMode
|
||||
? SuppressionPersonLine.administrateur(name)
|
||||
: SuppressionPersonLine.gestionnaire(name),
|
||||
],
|
||||
footnotes: const [
|
||||
'Le compte sera définitivement supprimé.',
|
||||
],
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
if (!confirmed) return;
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
|
||||
@@ -121,7 +121,6 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
|
||||
dob: '',
|
||||
isUnbornChild: false,
|
||||
photoConsent: false,
|
||||
multipleBirth: false,
|
||||
cardColor: cardColor,
|
||||
);
|
||||
registrationData.addChild(newChild);
|
||||
|
||||
@@ -62,7 +62,10 @@ class _GestionnaireDashboardScreenState extends State<GestionnaireDashboardScree
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: UserManagementPanel(showAdministrateursTab: false),
|
||||
child: UserManagementPanel(
|
||||
showAdministrateursTab: false,
|
||||
allowStaffAccountCreation: false,
|
||||
),
|
||||
),
|
||||
const AppFooter(),
|
||||
],
|
||||
|
||||
@@ -60,7 +60,12 @@ class ApiConfig {
|
||||
static const String userChildren = '/users/children';
|
||||
static const String gestionnaires = '/gestionnaires';
|
||||
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';
|
||||
/// 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 relais = '/relais';
|
||||
static const String dossiers = '/dossiers';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||
import 'package:p_tits_pas/models/parent_model.dart';
|
||||
@@ -57,6 +58,33 @@ class UserService {
|
||||
return v.toString();
|
||||
}
|
||||
|
||||
/// MIME pour upload photo enfant (filtre Nest : jpg/jpeg/png/gif).
|
||||
static MediaType _imageMediaType(String filename, List<int> bytes) {
|
||||
if (bytes.length >= 3 &&
|
||||
bytes[0] == 0xFF &&
|
||||
bytes[1] == 0xD8 &&
|
||||
bytes[2] == 0xFF) {
|
||||
return MediaType('image', 'jpeg');
|
||||
}
|
||||
if (bytes.length >= 8 &&
|
||||
bytes[0] == 0x89 &&
|
||||
bytes[1] == 0x50 &&
|
||||
bytes[2] == 0x4E &&
|
||||
bytes[3] == 0x47) {
|
||||
return MediaType('image', 'png');
|
||||
}
|
||||
if (bytes.length >= 6 &&
|
||||
bytes[0] == 0x47 &&
|
||||
bytes[1] == 0x49 &&
|
||||
bytes[2] == 0x46) {
|
||||
return MediaType('image', 'gif');
|
||||
}
|
||||
final lower = filename.toLowerCase();
|
||||
if (lower.endsWith('.png')) return MediaType('image', 'png');
|
||||
if (lower.endsWith('.gif')) return MediaType('image', 'gif');
|
||||
return MediaType('image', 'jpeg');
|
||||
}
|
||||
|
||||
static String _errMessage(dynamic err) {
|
||||
if (err == null) return 'Erreur inconnue';
|
||||
if (err is String) return err;
|
||||
@@ -497,17 +525,58 @@ class UserService {
|
||||
return enfant.copyWith(parentLinks: enriched);
|
||||
}
|
||||
|
||||
/// Mise à jour enfant. Avec [photoBytes] : multipart (champ `photo`), sinon JSON.
|
||||
static Future<EnfantAdminModel> updateEnfant({
|
||||
required String enfantId,
|
||||
required Map<String, dynamic> body,
|
||||
List<int>? photoBytes,
|
||||
String? photoFilename,
|
||||
}) async {
|
||||
final response = await http.patch(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
|
||||
headers: await _headers(),
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
final id = enfantId.trim();
|
||||
if (id.isEmpty) {
|
||||
throw Exception('Identifiant enfant manquant.');
|
||||
}
|
||||
final hasPhoto = photoBytes != null && photoBytes.isNotEmpty;
|
||||
final http.Response response;
|
||||
if (hasPhoto) {
|
||||
final token = await TokenService.getToken();
|
||||
final req = http.MultipartRequest(
|
||||
'PATCH',
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$id'),
|
||||
);
|
||||
req.headers['Accept'] = 'application/json';
|
||||
if (token != null) {
|
||||
req.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
body.forEach((key, value) {
|
||||
if (value == null) return;
|
||||
req.fields[key] = value is bool
|
||||
? (value ? 'true' : 'false')
|
||||
: value.toString();
|
||||
});
|
||||
final name = (photoFilename ?? '').trim();
|
||||
final filename = name.isNotEmpty ? name : 'photo.jpg';
|
||||
req.files.add(
|
||||
http.MultipartFile.fromBytes(
|
||||
'photo',
|
||||
photoBytes,
|
||||
filename: filename,
|
||||
contentType: _imageMediaType(filename, photoBytes),
|
||||
),
|
||||
);
|
||||
final streamed = await req.send();
|
||||
response = await http.Response.fromStream(streamed);
|
||||
} else {
|
||||
response = await http.patch(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$id'),
|
||||
headers: await _headers(),
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
}
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant'));
|
||||
throw Exception(
|
||||
_extractErrorMessage(response.body, 'Erreur mise à jour enfant'),
|
||||
);
|
||||
}
|
||||
final enfant = EnfantAdminModel.fromJson(
|
||||
jsonDecode(response.body) as Map<String, dynamic>,
|
||||
@@ -515,14 +584,155 @@ class UserService {
|
||||
return enrichEnfantParentNames(enfant);
|
||||
}
|
||||
|
||||
static Future<void> deleteEnfant(String enfantId) async {
|
||||
/// Création enfant côté staff (#132) — nécessite `POST /enfants` étendu (voir mini-spec).
|
||||
/// [parentUserId] : parent pivot du foyer (`parent_user_id`).
|
||||
/// Avec [photoBytes] : `multipart/form-data` (champ fichier `photo`), sinon JSON.
|
||||
static Future<EnfantAdminModel> createEnfant({
|
||||
required String parentUserId,
|
||||
required Map<String, dynamic> body,
|
||||
List<int>? photoBytes,
|
||||
String? photoFilename,
|
||||
}) async {
|
||||
final hasPhoto = photoBytes != null && photoBytes.isNotEmpty;
|
||||
final http.Response response;
|
||||
if (hasPhoto) {
|
||||
final token = await TokenService.getToken();
|
||||
final req = http.MultipartRequest(
|
||||
'POST',
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
|
||||
);
|
||||
req.headers['Accept'] = 'application/json';
|
||||
if (token != null) {
|
||||
req.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
body.forEach((key, value) {
|
||||
if (value == null) return;
|
||||
req.fields[key] = value is bool
|
||||
? (value ? 'true' : 'false')
|
||||
: value.toString();
|
||||
});
|
||||
req.fields['parent_user_id'] = parentUserId;
|
||||
final name = (photoFilename ?? '').trim();
|
||||
final filename = name.isNotEmpty ? name : 'photo.jpg';
|
||||
req.files.add(
|
||||
http.MultipartFile.fromBytes(
|
||||
'photo',
|
||||
photoBytes,
|
||||
filename: filename,
|
||||
contentType: _imageMediaType(filename, photoBytes),
|
||||
),
|
||||
);
|
||||
final streamed = await req.send();
|
||||
response = await http.Response.fromStream(streamed);
|
||||
} else {
|
||||
final payload = <String, dynamic>{
|
||||
...body,
|
||||
'parent_user_id': parentUserId,
|
||||
};
|
||||
response = await http.post(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
|
||||
headers: await _headers(),
|
||||
body: jsonEncode(payload),
|
||||
);
|
||||
}
|
||||
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||
throw Exception(
|
||||
_extractErrorMessage(response.body, 'Erreur création enfant'),
|
||||
);
|
||||
}
|
||||
final enfant = EnfantAdminModel.fromJson(
|
||||
jsonDecode(response.body) as Map<String, dynamic>,
|
||||
);
|
||||
return enrichEnfantParentNames(enfant);
|
||||
}
|
||||
|
||||
/// DELETE /enfants/:id?deleteDossier= — ticket #159 / #160.
|
||||
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(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'),
|
||||
headers: await _headers(),
|
||||
);
|
||||
if (response.statusCode != 200 && response.statusCode != 204) {
|
||||
throw Exception(_extractErrorMessage(response.body, 'Erreur suppression enfant'));
|
||||
throw Exception(
|
||||
_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).
|
||||
@@ -555,6 +765,155 @@ class UserService {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/// Ajout d’un co-parent sur foyer mono-parent (staff, #135).
|
||||
/// `POST /parents/:pivotUserId/co-parent` — succès 201.
|
||||
static Future<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
|
||||
static Future<List<AssistanteMaternelleModel>>
|
||||
getAssistantesMaternelles() async {
|
||||
@@ -944,22 +1303,18 @@ class UserService {
|
||||
return AppUser.fromJson(data);
|
||||
}
|
||||
|
||||
static Future<void> deleteUser(String userId) async {
|
||||
/// DELETE /users/:id — cascades métier #159 / #160.
|
||||
static Future<Map<String, dynamic>> deleteUser(String userId) async {
|
||||
final response = await http.delete(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'),
|
||||
headers: await _headers(),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200 && response.statusCode != 204) {
|
||||
final decoded = jsonDecode(response.body);
|
||||
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');
|
||||
throw Exception(
|
||||
_extractErrorMessage(response.body, 'Erreur suppression utilisateur'),
|
||||
);
|
||||
}
|
||||
return _parseSuppressionBody(response.body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||
|
||||
@@ -11,10 +12,50 @@ String formatIsoDateFr(String? s, {String ifEmpty = '–'}) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit `dd/MM/yyyy` (ou ISO) en `yyyy-MM-dd` pour l'API.
|
||||
/// Affiche une date ISO en saisie guidée `jj / mm / aaaa`.
|
||||
String formatIsoDateFrInput(String? s, {String ifEmpty = ''}) {
|
||||
if (s == null || s.trim().isEmpty) return ifEmpty;
|
||||
try {
|
||||
final dt = DateTime.parse(s.trim());
|
||||
return formatFrenchDateDigits(
|
||||
DateFormat('ddMMyyyy').format(dt),
|
||||
);
|
||||
} catch (_) {
|
||||
return formatFrenchDateDigits(s.replaceAll(RegExp(r'\D'), ''));
|
||||
}
|
||||
}
|
||||
|
||||
/// Formate jusqu’à 8 chiffres en `jj / mm / aaaa`.
|
||||
String formatFrenchDateDigits(String digits) {
|
||||
final d = digits.replaceAll(RegExp(r'\D'), '');
|
||||
final limited = d.length > 8 ? d.substring(0, 8) : d;
|
||||
final buf = StringBuffer();
|
||||
for (var i = 0; i < limited.length; i++) {
|
||||
if (i == 2 || i == 4) buf.write(' / ');
|
||||
buf.write(limited[i]);
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
/// Convertit `dd/MM/yyyy`, `dd / MM / yyyy`, 8 chiffres ou ISO en `yyyy-MM-dd`.
|
||||
String? parseFrDateToIso(String text) {
|
||||
final t = text.trim();
|
||||
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 {
|
||||
return DateFormat('dd/MM/yyyy')
|
||||
.parseStrict(t)
|
||||
@@ -96,3 +137,43 @@ String formatChildAgeLabel({
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||
|
||||
/// Enfant sans lien parent (orphelins d’affiliation) — ticket #157.
|
||||
bool enfantHasNoResponsable(EnfantAdminModel enfant) => enfant.hasNoResponsable;
|
||||
|
||||
/// Message vigilance liste Enfants (même usage que [amPlacesVigilanceMessage]).
|
||||
String? enfantSansResponsableVigilanceMessage(EnfantAdminModel enfant) {
|
||||
if (!enfantHasNoResponsable(enfant)) return null;
|
||||
return 'Aucun responsable rattaché — à rattacher à un foyer';
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Formatage affichage prénom / nom (capitalisation par mot et segments après `-` ou `'`).
|
||||
|
||||
String formatPersonNameCase(String raw) {
|
||||
@@ -9,6 +11,22 @@ String formatPersonNameCase(String raw) {
|
||||
return words.map(_capitalizeComposedWord).join(' ');
|
||||
}
|
||||
|
||||
/// Variante saisie live : pas de majuscule tant que le mot n’a qu’une lettre ;
|
||||
/// dès la 2ᵉ lettre, capitalisation comme [formatPersonNameCase].
|
||||
String formatPersonNameCaseTyping(String raw) {
|
||||
if (raw.isEmpty) return raw;
|
||||
final trailingMatch = RegExp(r'(\s*)$').firstMatch(raw);
|
||||
final trailing = trailingMatch?.group(1) ?? '';
|
||||
final core = raw.substring(0, raw.length - trailing.length);
|
||||
if (core.isEmpty) return raw;
|
||||
final words = core.split(RegExp(r'\s+'));
|
||||
final formatted = words.map((w) {
|
||||
if (w.length < 2) return w;
|
||||
return _capitalizeComposedWord(w);
|
||||
}).join(' ');
|
||||
return formatted + trailing;
|
||||
}
|
||||
|
||||
String _capitalizeComposedWord(String word) {
|
||||
if (word.isEmpty) {
|
||||
return word;
|
||||
@@ -30,3 +48,26 @@ String _capitalizeComposedWord(String word) {
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// Formateur de saisie prénom / nom (capitalisation progressive).
|
||||
class PersonNameInputFormatter extends TextInputFormatter {
|
||||
const PersonNameInputFormatter();
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
final formatted = formatPersonNameCaseTyping(newValue.text);
|
||||
if (formatted == newValue.text) return newValue;
|
||||
|
||||
final sel = newValue.selection;
|
||||
final offset = sel.isValid
|
||||
? sel.baseOffset.clamp(0, formatted.length)
|
||||
: formatted.length;
|
||||
return TextEditingValue(
|
||||
text: formatted,
|
||||
selection: TextSelection.collapsed(offset: offset),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,12 +49,21 @@ String nirToRaw(String normalized) {
|
||||
return s;
|
||||
}
|
||||
|
||||
/// Formate pour affichage : 1 12 34 56 789 012 - 34 ou 1 12 34 2A 789 012 - 34 (Corse).
|
||||
/// Formate pour affichage (complet ou en cours de saisie) :
|
||||
/// `1 12 34 56 789 012 - 34` ou `1 12 34 2A 789 012 - 34` (Corse).
|
||||
String formatNir(String raw) {
|
||||
final r = nirToRaw(raw);
|
||||
if (r.length < 15) return r;
|
||||
// Même structure pour tous : sexe + année + mois + département + commune + ordre-clé.
|
||||
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)}';
|
||||
final r = nirToRaw(raw).toUpperCase();
|
||||
if (r.isEmpty) return '';
|
||||
final buf = StringBuffer();
|
||||
for (var i = 0; i < r.length && i < 15; i++) {
|
||||
if (i == 1 || i == 3 || i == 5 || i == 7 || i == 10) {
|
||||
buf.write(' ');
|
||||
} else if (i == 13) {
|
||||
buf.write(' - ');
|
||||
}
|
||||
buf.write(r[i]);
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
/// Aligné sur le backend (NIR côté API / nir.util.ts) : sexe 1–3, département 2A ou 2B pour la Corse.
|
||||
@@ -92,20 +101,39 @@ String? validateNir(String? value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Formateur de saisie : affiche le NIR formaté (1 12 34 56 789 012 - 34) et limite à 15 caractères utiles.
|
||||
/// Validation pendant la saisie : pas d’erreur si incomplet encore plausible ;
|
||||
/// dès 15 caractères, même contrôles que [validateNir].
|
||||
String? validateNirTyping(String? value) {
|
||||
if (value == null || value.trim().isEmpty) return null;
|
||||
final raw = nirToRaw(value).toUpperCase();
|
||||
if (raw.isEmpty) return null;
|
||||
if (raw[0] != '1' && raw[0] != '2' && raw[0] != '3') {
|
||||
return 'Format NIR invalide (ex. 1 12 34 56 789 012 - 34 ou 2A pour la Corse)';
|
||||
}
|
||||
if (raw.length < 15) return null;
|
||||
return validateNir(value);
|
||||
}
|
||||
|
||||
/// Formateur de saisie : affiche le NIR formaté au fil de la frappe et limite à 15 caractères utiles.
|
||||
class NirInputFormatter extends TextInputFormatter {
|
||||
const NirInputFormatter();
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
final raw = normalizeNir(newValue.text);
|
||||
if (raw.isEmpty) return newValue;
|
||||
if (raw.isEmpty) {
|
||||
return const TextEditingValue(
|
||||
text: '',
|
||||
selection: TextSelection.collapsed(offset: 0),
|
||||
);
|
||||
}
|
||||
final formatted = formatNir(raw);
|
||||
final offset = formatted.length;
|
||||
return TextEditingValue(
|
||||
text: formatted,
|
||||
selection: TextSelection.collapsed(offset: offset),
|
||||
selection: TextSelection.collapsed(offset: formatted.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +166,6 @@ class ParentRegistrationPayload {
|
||||
static Map<String, dynamic> _childToJson(ChildData c, int index, String parentNom) {
|
||||
final map = <String, dynamic>{
|
||||
'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre',
|
||||
'grossesse_multiple': c.multipleBirth,
|
||||
'consent_photo': c.photoConsent,
|
||||
};
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ class RepriseMapper {
|
||||
dob: dob,
|
||||
genre: e.gender ?? '',
|
||||
photoConsent: e.consentPhoto,
|
||||
multipleBirth: e.estMultiple,
|
||||
isUnbornChild: isUnborn,
|
||||
cardColor: _childCardColors[index % _childCardColors.length],
|
||||
repriseChildId: e.id,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/// Création comptes staff (gestionnaire / admin) — ticket #161.
|
||||
bool canCreateStaffAccounts(String? role) {
|
||||
final r = (role ?? '').trim().toLowerCase();
|
||||
return r == 'administrateur' || r == 'super_admin';
|
||||
}
|
||||
|
||||
/// Droits d’affichage 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,7 +4,9 @@ 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/services/auth_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/admin/common/suppression_confirm_dialog.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||
|
||||
class AdminManagementWidget extends StatefulWidget {
|
||||
@@ -24,6 +26,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
||||
String? _error;
|
||||
List<AppUser> _admins = [];
|
||||
String? _currentUserRole;
|
||||
String? _currentUserId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -62,6 +65,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
||||
if (cached != null) {
|
||||
setState(() {
|
||||
_currentUserRole = (cached.role).toLowerCase();
|
||||
_currentUserId = cached.id;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -69,6 +73,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
||||
if (!mounted || refreshed == null) return;
|
||||
setState(() {
|
||||
_currentUserRole = (refreshed.role).toLowerCase();
|
||||
_currentUserId = refreshed.id;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -79,6 +84,16 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
||||
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 {
|
||||
final canEdit = _canEditAdmin(user);
|
||||
final changed = await showDialog<bool>(
|
||||
@@ -98,6 +113,56 @@ 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 : c’est 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
|
||||
Widget build(BuildContext context) {
|
||||
final query = widget.searchQuery.toLowerCase();
|
||||
@@ -117,6 +182,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
||||
final user = filteredAdmins[index];
|
||||
final isSuperAdmin = _isSuperAdmin(user);
|
||||
final canEdit = _canEditAdmin(user);
|
||||
final canDelete = _canDeleteAdmin(user);
|
||||
return AdminUserCard(
|
||||
title: user.fullName,
|
||||
fallbackIcon: isSuperAdmin
|
||||
@@ -148,6 +214,8 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
||||
_openAdminEditDialog(user);
|
||||
},
|
||||
),
|
||||
if (canDelete)
|
||||
suppressionIconButton(onPressed: () => _confirmDelete(user)),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart';
|
||||
|
||||
/// Modale de création dossier AM (#156) — même shell que [ValidationDossierModal].
|
||||
class AmDossierCreateModal extends StatefulWidget {
|
||||
final VoidCallback onClose;
|
||||
final VoidCallback? onSuccess;
|
||||
|
||||
const AmDossierCreateModal({
|
||||
super.key,
|
||||
required this.onClose,
|
||||
this.onSuccess,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AmDossierCreateModal> createState() => _AmDossierCreateModalState();
|
||||
}
|
||||
|
||||
class _AmDossierCreateModalState extends State<AmDossierCreateModal> {
|
||||
int? _stepIndex;
|
||||
int? _stepTotal;
|
||||
|
||||
void _onStepChanged(int step, int total) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_stepIndex = step;
|
||||
_stepTotal = total;
|
||||
});
|
||||
}
|
||||
|
||||
void _onSuccess() {
|
||||
widget.onSuccess?.call();
|
||||
}
|
||||
|
||||
static const double _modalWidth = 930;
|
||||
/// Hauteur calculée depuis 4 lignes de TF (voir [AmDossierWizard.shellBodyHeight]).
|
||||
static double get _bodyHeight => AmDossierWizard.shellBodyHeight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maxH = MediaQuery.of(context).size.height * 0.85;
|
||||
final showStep =
|
||||
_stepIndex != null && _stepTotal != null && (_stepTotal ?? 0) > 0;
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: _modalWidth, maxHeight: maxH),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(18, 18, 0, 12),
|
||||
child: Text(
|
||||
'Nouvelle assistante maternelle',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (showStep) ...[
|
||||
Text(
|
||||
'Étape ${(_stepIndex ?? 0) + 1}/${_stepTotal ?? 1}',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Colors.black54,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: widget.onClose,
|
||||
tooltip: 'Fermer',
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SizedBox(
|
||||
height: _bodyHeight,
|
||||
child: AmDossierWizard.create(
|
||||
onClose: widget.onClose,
|
||||
onSuccess: _onSuccess,
|
||||
onStepChanged: _onStepChanged,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,13 @@
|
||||
import 'package:flutter/material.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/services/auth_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/admin/common/admin_user_card.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||
|
||||
class AssistanteMaternelleManagementWidget extends StatefulWidget {
|
||||
@@ -26,16 +30,24 @@ class _AssistanteMaternelleManagementWidgetState
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
List<AssistanteMaternelleModel> _assistantes = [];
|
||||
bool _canDelete = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadRights();
|
||||
_loadAssistantes();
|
||||
}
|
||||
|
||||
@override
|
||||
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 {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
@@ -57,6 +69,46 @@ 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
|
||||
Widget build(BuildContext context) {
|
||||
final query = widget.searchQuery.toLowerCase();
|
||||
@@ -96,6 +148,10 @@ class _AssistanteMaternelleManagementWidgetState
|
||||
_openAssistanteDetails(assistante);
|
||||
},
|
||||
),
|
||||
if (_canDelete)
|
||||
suppressionIconButton(
|
||||
onPressed: () => _confirmDelete(assistante),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -20,6 +20,8 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
|
||||
final int capacity;
|
||||
final void Function(ParentChildSummary child) onOpen;
|
||||
final void Function(ParentChildSummary child) onDetach;
|
||||
/// Clic sur une case libre → même flux que « Rattacher un enfant » (#149).
|
||||
final VoidCallback? onAttachEmpty;
|
||||
|
||||
const AdminAmChildrenCapacityGrid({
|
||||
super.key,
|
||||
@@ -27,6 +29,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
|
||||
required this.capacity,
|
||||
required this.onOpen,
|
||||
required this.onDetach,
|
||||
this.onAttachEmpty,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -78,7 +81,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
if (index < maxSlots) {
|
||||
return const _EmptySlot();
|
||||
return _EmptySlot(onTap: onAttachEmpty);
|
||||
}
|
||||
return const _UnavailableSlot();
|
||||
}
|
||||
@@ -129,18 +132,52 @@ class _UnavailableSlot extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptySlot extends StatelessWidget {
|
||||
const _EmptySlot();
|
||||
class _EmptySlot extends StatefulWidget {
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _EmptySlot({this.onTap});
|
||||
|
||||
@override
|
||||
State<_EmptySlot> createState() => _EmptySlotState();
|
||||
}
|
||||
|
||||
class _EmptySlotState extends State<_EmptySlot> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _SlotShell(
|
||||
backgroundColor: Colors.grey.shade50,
|
||||
borderColor: Colors.grey.shade300,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Place libre',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
|
||||
final clickable = widget.onTap != null;
|
||||
return MouseRegion(
|
||||
onEnter: clickable ? (_) => setState(() => _hovered = true) : null,
|
||||
onExit: clickable ? (_) => setState(() => _hovered = false) : null,
|
||||
cursor: clickable ? SystemMouseCursors.click : MouseCursor.defer,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
hoverColor: const Color(0x149CC5C0),
|
||||
child: _SlotShell(
|
||||
backgroundColor: _hovered
|
||||
? const Color(0xFFF3F0FA)
|
||||
: Colors.grey.shade50,
|
||||
borderColor: _hovered
|
||||
? const Color(0xFFB8A4D4)
|
||||
: Colors.grey.shade300,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Place libre',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _hovered
|
||||
? const Color(0xFF6B3FA0)
|
||||
: Colors.grey.shade500,
|
||||
fontWeight: _hovered ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -69,29 +69,6 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
||||
static const double _proTabHeight = 300;
|
||||
static const List<int> _photoProRowLayout = [2, 2, 2];
|
||||
|
||||
/// Hauteur onglet enfants : champs + titre + grille 2×2 (+ alerte places si besoin).
|
||||
double _childrenTabHeight() {
|
||||
const capacityFields = 72.0;
|
||||
const titleSection = 40.0;
|
||||
const inconsistencyExtra = 46.0;
|
||||
var h = capacityFields +
|
||||
titleSection +
|
||||
AdminAmChildrenCapacityGrid.fixedHeight;
|
||||
if (_placesInconsistent()) h += inconsistencyExtra;
|
||||
return h + 4;
|
||||
}
|
||||
|
||||
double _tabViewHeight(int index) {
|
||||
switch (index) {
|
||||
case 1:
|
||||
return _proTabHeight;
|
||||
case 2:
|
||||
return _childrenTabHeight();
|
||||
default:
|
||||
return 292;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -446,8 +423,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Détacher l\'enfant'),
|
||||
content: Text(
|
||||
'Retirer ${child.fullName} de la fiche de cette assistante ?\n'
|
||||
'(L\'enfant ne sera pas supprimé.)',
|
||||
'Retirer ${child.fullName} de la fiche de cette assistante ?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -542,17 +518,15 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
||||
|
||||
|
||||
Widget _identityTab() {
|
||||
return SingleChildScrollView(
|
||||
child: IdentityBlock.editable(
|
||||
title: 'Identité et coordonnées',
|
||||
nomController: _nomCtrl,
|
||||
prenomController: _prenomCtrl,
|
||||
telephoneController: _telCtrl,
|
||||
emailController: _emailCtrl,
|
||||
adresseController: _adresseCtrl,
|
||||
codePostalController: _cpCtrl,
|
||||
villeController: _villeCtrl,
|
||||
),
|
||||
return IdentityBlock.editable(
|
||||
title: 'Identité et coordonnées',
|
||||
nomController: _nomCtrl,
|
||||
prenomController: _prenomCtrl,
|
||||
telephoneController: _telCtrl,
|
||||
emailController: _emailCtrl,
|
||||
adresseController: _adresseCtrl,
|
||||
codePostalController: _cpCtrl,
|
||||
villeController: _villeCtrl,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -611,7 +585,9 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
||||
return LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
final maxRowW = c.maxWidth;
|
||||
final maxRowH = c.maxHeight;
|
||||
// Hauteur bornée : l’onglet pro a une hauteur fixe (photo ID).
|
||||
final maxRowH =
|
||||
c.maxHeight.isFinite ? c.maxHeight : _proTabHeight;
|
||||
final bodyH = maxRowH;
|
||||
final idealPhotoW = bodyH * AdminAmPhotoFrame.idPhotoAspectRatio + 16;
|
||||
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
|
||||
@@ -660,6 +636,18 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
||||
);
|
||||
}
|
||||
|
||||
/// Corps de l’onglet 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() {
|
||||
final inconsistent = _placesInconsistent();
|
||||
return Column(
|
||||
@@ -745,6 +733,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
||||
capacity: capacity,
|
||||
onOpen: _openChild,
|
||||
onDetach: _detachChild,
|
||||
onAttachEmpty: _capacityFull ? null : _attachChild,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -867,15 +856,13 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
|
||||
child: SizedBox(
|
||||
height: _tabViewHeight(_tabCtrl.index),
|
||||
child: TabBarView(
|
||||
controller: _tabCtrl,
|
||||
children: [
|
||||
_identityTab(),
|
||||
_proTab(),
|
||||
_childrenTab(),
|
||||
],
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeInOut,
|
||||
alignment: Alignment.topCenter,
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey<int>(_tabCtrl.index),
|
||||
child: _buildActiveTabBody(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
||||
|
||||
/// Cadre photo identité AM (35×45 mm) — même logique que [ValidationAmWizard].
|
||||
/// Cadre photo identité AM / enfant (35×45 mm) — même logique que [ValidationAmWizard].
|
||||
class AdminAmPhotoFrame extends StatelessWidget {
|
||||
final String? photoUrl;
|
||||
final Uint8List? imageBytes;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onClear;
|
||||
final String emptyLabel;
|
||||
|
||||
static const double idPhotoAspectRatio = 35 / 45;
|
||||
|
||||
const AdminAmPhotoFrame({super.key, this.photoUrl});
|
||||
const AdminAmPhotoFrame({
|
||||
super.key,
|
||||
this.photoUrl,
|
||||
this.imageBytes,
|
||||
this.onTap,
|
||||
this.onClear,
|
||||
this.emptyLabel = 'Aucune photo',
|
||||
});
|
||||
|
||||
/// Largeur colonne photo pour remplir [height] (cadre inclus).
|
||||
static double columnWidthForHeight(double height) {
|
||||
@@ -36,9 +49,12 @@ class AdminAmPhotoFrame extends StatelessWidget {
|
||||
ph = pw / ar;
|
||||
}
|
||||
|
||||
final hasLocal = imageBytes != null && imageBytes!.isNotEmpty;
|
||||
final showClear = onClear != null && hasLocal;
|
||||
|
||||
// Cadre gris = taille photo + padding uniforme ; centré dans la colonne
|
||||
// (évite le vide blanc en bas quand le conteneur parent est plus haut).
|
||||
return Align(
|
||||
Widget frame = Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
@@ -60,22 +76,81 @@ class AdminAmPhotoFrame extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (onTap != null) {
|
||||
frame = MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: frame,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!showClear) return frame;
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
frame,
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: IconButton(
|
||||
tooltip: 'Retirer la photo',
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 32,
|
||||
minHeight: 32,
|
||||
),
|
||||
icon: Icon(
|
||||
Icons.cancel,
|
||||
size: 22,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
onPressed: onClear,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _photoContent(String fullUrl, double pw, double ph) {
|
||||
if (imageBytes != null && imageBytes!.isNotEmpty) {
|
||||
return Image.memory(
|
||||
imageBytes!,
|
||||
width: pw,
|
||||
height: ph,
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.topCenter,
|
||||
);
|
||||
}
|
||||
if (fullUrl.isEmpty) {
|
||||
return ColoredBox(
|
||||
color: Colors.grey.shade200,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.person_off_outlined, size: 36, color: Colors.grey.shade400),
|
||||
Icon(
|
||||
onTap != null ? Icons.add_a_photo_outlined : Icons.person_off_outlined,
|
||||
size: 36,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Aucune photo',
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 11),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Text(
|
||||
emptyLabel,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 11),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -108,7 +183,11 @@ class AdminAmPhotoFrame extends StatelessWidget {
|
||||
},
|
||||
errorBuilder: (_, __, ___) => ColoredBox(
|
||||
color: Colors.grey.shade200,
|
||||
child: Icon(Icons.broken_image_outlined, size: 36, color: Colors.grey.shade400),
|
||||
child: Icon(
|
||||
Icons.broken_image_outlined,
|
||||
size: 36,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
||||
import 'package:p_tits_pas/utils/date_display_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';
|
||||
|
||||
List<String> enfantAdminSubtitleLines({
|
||||
@@ -36,6 +37,8 @@ class AdminEnfantUserCard extends StatelessWidget {
|
||||
final VoidCallback? onCardTap;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final EdgeInsetsGeometry? contentPadding;
|
||||
final Color? borderColor;
|
||||
final String? vigilanceTooltip;
|
||||
|
||||
const AdminEnfantUserCard({
|
||||
super.key,
|
||||
@@ -46,6 +49,8 @@ class AdminEnfantUserCard extends StatelessWidget {
|
||||
this.onCardTap,
|
||||
this.margin,
|
||||
this.contentPadding,
|
||||
this.borderColor,
|
||||
this.vigilanceTooltip,
|
||||
});
|
||||
|
||||
factory AdminEnfantUserCard.fromEnfant(
|
||||
@@ -55,10 +60,13 @@ class AdminEnfantUserCard extends StatelessWidget {
|
||||
VoidCallback? onCardTap,
|
||||
EdgeInsetsGeometry? margin,
|
||||
EdgeInsetsGeometry? contentPadding,
|
||||
Color? borderColor,
|
||||
String? vigilanceTooltip,
|
||||
}) {
|
||||
final parents = enfant.parentLinks
|
||||
.map((l) => l.parentName ?? 'Parent')
|
||||
.join(', ');
|
||||
final orphan = enfantHasNoResponsable(enfant);
|
||||
return AdminEnfantUserCard(
|
||||
title: enfant.fullName,
|
||||
photoUrl: enfant.photoUrl,
|
||||
@@ -69,6 +77,7 @@ class AdminEnfantUserCard extends StatelessWidget {
|
||||
gender: enfant.gender,
|
||||
extra: [
|
||||
if (parents.isNotEmpty) 'Responsables : $parents',
|
||||
if (orphan) 'Aucun responsable rattaché',
|
||||
...extraSubtitleLines,
|
||||
],
|
||||
),
|
||||
@@ -76,6 +85,10 @@ class AdminEnfantUserCard extends StatelessWidget {
|
||||
onCardTap: onCardTap,
|
||||
margin: margin,
|
||||
contentPadding: contentPadding,
|
||||
borderColor: borderColor ??
|
||||
(orphan ? Colors.red.shade300 : null),
|
||||
vigilanceTooltip: vigilanceTooltip ??
|
||||
enfantSansResponsableVigilanceMessage(enfant),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,6 +127,8 @@ class AdminEnfantUserCard extends StatelessWidget {
|
||||
onCardTap: onCardTap,
|
||||
margin: margin,
|
||||
contentPadding: contentPadding,
|
||||
borderColor: borderColor,
|
||||
vigilanceTooltip: vigilanceTooltip,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,8 +283,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Détacher l\'enfant'),
|
||||
content: Text(
|
||||
'Retirer ${child.fullName} de la fiche de ce parent ?\n'
|
||||
'(L\'enfant ne sera pas supprimé.)',
|
||||
'Retirer ${child.fullName} du foyer (tous les responsables) ?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -309,8 +308,9 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
||||
if (!mounted) return;
|
||||
await _reloadChildren();
|
||||
if (!mounted) return;
|
||||
widget.onSaved?.call();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Enfant détaché')),
|
||||
const SnackBar(content: Text('Enfant détaché du foyer')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
@@ -337,8 +337,9 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
||||
if (!mounted) return;
|
||||
await _reloadChildren();
|
||||
if (!mounted) return;
|
||||
widget.onSaved?.call();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Enfant rattaché')),
|
||||
const SnackBar(content: Text('Enfant rattaché au foyer')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/parent_model.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||
|
||||
/// Foyer / famille sélectionnable pour rattacher un nouvel enfant (#132 / #157).
|
||||
class AdminFamilleFoyer {
|
||||
/// Parent pivot pour `POST /enfants` (`parent_user_id`).
|
||||
final String pivotParentUserId;
|
||||
/// Co-parent éventuel (rattachement foyer #157).
|
||||
final String? coParentUserId;
|
||||
final String? numeroDossier;
|
||||
final String displayTitle;
|
||||
final List<String> parentNames;
|
||||
|
||||
const AdminFamilleFoyer({
|
||||
required this.pivotParentUserId,
|
||||
required this.displayTitle,
|
||||
required this.parentNames,
|
||||
this.coParentUserId,
|
||||
this.numeroDossier,
|
||||
});
|
||||
|
||||
/// Parents du foyer à lier à l’enfant (pivot puis co-parent).
|
||||
List<String> get parentUserIds {
|
||||
final ids = <String>[pivotParentUserId];
|
||||
final co = (coParentUserId ?? '').trim();
|
||||
if (co.isNotEmpty && co != pivotParentUserId) ids.add(co);
|
||||
return ids;
|
||||
}
|
||||
|
||||
String get subtitle {
|
||||
final parts = <String>[];
|
||||
final dossier = (numeroDossier ?? '').trim();
|
||||
if (dossier.isNotEmpty) parts.add('Dossier $dossier');
|
||||
if (parentNames.isNotEmpty) {
|
||||
parts.add('Responsables : ${parentNames.join(', ')}');
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
/// Construit la liste des foyers uniques à partir de `GET /parents`.
|
||||
List<AdminFamilleFoyer> buildFamilleFoyers(List<ParentModel> parents) {
|
||||
final seenDossiers = <String>{};
|
||||
final seenUserIds = <String>{};
|
||||
final foyers = <AdminFamilleFoyer>[];
|
||||
|
||||
for (final p in parents) {
|
||||
final dossier = (p.user.numeroDossier ?? '').trim();
|
||||
if (dossier.isNotEmpty) {
|
||||
if (seenDossiers.contains(dossier)) continue;
|
||||
seenDossiers.add(dossier);
|
||||
} else if (seenUserIds.contains(p.user.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenUserIds.add(p.user.id);
|
||||
final co = p.coParent;
|
||||
if (co != null) seenUserIds.add(co.id);
|
||||
|
||||
final names = <String>[
|
||||
if (p.user.fullName.trim().isNotEmpty) p.user.fullName.trim(),
|
||||
if (co != null && co.fullName.trim().isNotEmpty) co.fullName.trim(),
|
||||
];
|
||||
|
||||
final title = dossier.isNotEmpty
|
||||
? 'Dossier $dossier'
|
||||
: (names.isNotEmpty ? names.first : 'Famille');
|
||||
|
||||
foyers.add(
|
||||
AdminFamilleFoyer(
|
||||
pivotParentUserId: p.user.id,
|
||||
coParentUserId: co?.id,
|
||||
numeroDossier: dossier.isNotEmpty ? dossier : null,
|
||||
displayTitle: title,
|
||||
parentNames: names,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
foyers.sort(
|
||||
(a, b) => a.displayTitle.toLowerCase().compareTo(b.displayTitle.toLowerCase()),
|
||||
);
|
||||
return foyers;
|
||||
}
|
||||
|
||||
/// Sélection d'une famille / dossier pour créer un enfant — ticket #132.
|
||||
class AdminSelectFamilleModal {
|
||||
AdminSelectFamilleModal._();
|
||||
|
||||
static Future<AdminFamilleFoyer?> show(
|
||||
BuildContext context, {
|
||||
String title = 'Choisir une famille',
|
||||
}) {
|
||||
return AdminSelectListModal.show<AdminFamilleFoyer>(
|
||||
context,
|
||||
title: title,
|
||||
searchHint: 'Rechercher par dossier, nom…',
|
||||
emptyMessage: 'Aucune famille disponible',
|
||||
noResultsMessage: 'Aucun résultat pour cette recherche',
|
||||
loadItems: () async {
|
||||
final parents = await UserService.getParents();
|
||||
return buildFamilleFoyers(parents);
|
||||
},
|
||||
matchesQuery: (f, q) {
|
||||
final dossier = (f.numeroDossier ?? '').toLowerCase();
|
||||
final title = f.displayTitle.toLowerCase();
|
||||
final names = f.parentNames.join(' ').toLowerCase();
|
||||
return dossier.contains(q) || title.contains(q) || names.contains(q);
|
||||
},
|
||||
itemBuilder: (context, f, onSelect) {
|
||||
return AdminUserCard(
|
||||
title: f.displayTitle,
|
||||
fallbackIcon: Icons.family_restroom,
|
||||
subtitleLines: [
|
||||
if (f.parentNames.isNotEmpty) f.parentNames.join(', '),
|
||||
if ((f.numeroDossier ?? '').isNotEmpty)
|
||||
'Dossier ${f.numeroDossier}',
|
||||
],
|
||||
onCardTap: onSelect,
|
||||
margin: const EdgeInsets.only(bottom: 4),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 5,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.check_circle_outline),
|
||||
tooltip: 'Choisir',
|
||||
onPressed: onSelect,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@ class AdminUserCard extends StatefulWidget {
|
||||
final Color? backgroundColor;
|
||||
final Color? titleColor;
|
||||
final Color? infoColor;
|
||||
/// Fond du cercle avatar / icône (défaut lavande admin).
|
||||
final Color? avatarBackgroundColor;
|
||||
/// Couleur de l’icône fallback (défaut violet admin).
|
||||
final Color? avatarIconColor;
|
||||
final String? vigilanceTooltip;
|
||||
final VoidCallback? onCardTap;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
@@ -28,6 +32,8 @@ class AdminUserCard extends StatefulWidget {
|
||||
this.backgroundColor,
|
||||
this.titleColor,
|
||||
this.infoColor,
|
||||
this.avatarBackgroundColor,
|
||||
this.avatarIconColor,
|
||||
this.vigilanceTooltip,
|
||||
this.onCardTap,
|
||||
this.margin,
|
||||
@@ -159,8 +165,8 @@ class _AdminUserCardState extends State<AdminUserCard> {
|
||||
|
||||
Widget _buildAvatar(String url) {
|
||||
const size = 28.0;
|
||||
const bg = Color(0xFFEDE5FA);
|
||||
const iconColor = Color(0xFF6B3FA0);
|
||||
final bg = widget.avatarBackgroundColor ?? const Color(0xFFEDE5FA);
|
||||
final iconColor = widget.avatarIconColor ?? const Color(0xFF6B3FA0);
|
||||
|
||||
if (url.isEmpty) {
|
||||
return CircleAvatar(
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/dossier_list_item.dart';
|
||||
|
||||
/// Choix pour le dernier enfant d’un dossier famille (#160).
|
||||
enum DernierEnfantSuppressionChoice {
|
||||
enfantSeul,
|
||||
dossierAussi,
|
||||
}
|
||||
|
||||
/// Ligne d’impact (une personne / une fiche).
|
||||
class SuppressionPersonLine {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final String? role;
|
||||
|
||||
const SuppressionPersonLine({
|
||||
required this.label,
|
||||
this.icon = Icons.person_outline,
|
||||
this.role,
|
||||
});
|
||||
|
||||
factory SuppressionPersonLine.parent(String label) => SuppressionPersonLine(
|
||||
label: label,
|
||||
icon: Icons.supervisor_account_outlined,
|
||||
role: 'Parent',
|
||||
);
|
||||
|
||||
factory SuppressionPersonLine.enfant(String label) => SuppressionPersonLine(
|
||||
label: label,
|
||||
icon: Icons.child_care_outlined,
|
||||
role: 'Enfant',
|
||||
);
|
||||
|
||||
factory SuppressionPersonLine.am(String label) => SuppressionPersonLine(
|
||||
label: label,
|
||||
icon: Icons.face,
|
||||
role: 'AM',
|
||||
);
|
||||
|
||||
factory SuppressionPersonLine.gestionnaire(String label) =>
|
||||
SuppressionPersonLine(
|
||||
label: label,
|
||||
icon: Icons.assignment_ind_outlined,
|
||||
role: 'Gestionnaire',
|
||||
);
|
||||
|
||||
factory SuppressionPersonLine.administrateur(String label) =>
|
||||
SuppressionPersonLine(
|
||||
label: label,
|
||||
icon: Icons.manage_accounts_outlined,
|
||||
role: 'Admin',
|
||||
);
|
||||
|
||||
factory SuppressionPersonLine.relais(String label) => SuppressionPersonLine(
|
||||
label: label,
|
||||
icon: Icons.apartment_outlined,
|
||||
role: 'Relais',
|
||||
);
|
||||
}
|
||||
|
||||
/// Widget unique pour toutes les boîtes de confirmation de suppression (#160).
|
||||
class SuppressionConfirmDialog extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final String? message;
|
||||
final List<SuppressionPersonLine> people;
|
||||
final List<String> footnotes;
|
||||
final List<Widget> actions;
|
||||
|
||||
const SuppressionConfirmDialog({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.message,
|
||||
this.people = const [],
|
||||
this.footnotes = const [],
|
||||
required this.actions,
|
||||
});
|
||||
|
||||
/// Variante oui/non standard (Annuler / Supprimer).
|
||||
static SuppressionConfirmDialog yesNo({
|
||||
required String title,
|
||||
String? subtitle,
|
||||
String? message,
|
||||
List<SuppressionPersonLine> people = const [],
|
||||
List<String> footnotes = const [],
|
||||
String confirmLabel = 'Supprimer',
|
||||
required VoidCallback onCancel,
|
||||
required VoidCallback onConfirm,
|
||||
}) {
|
||||
return SuppressionConfirmDialog(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
message: message,
|
||||
people: people,
|
||||
footnotes: footnotes,
|
||||
actions: [
|
||||
TextButton(onPressed: onCancel, child: const Text('Annuler')),
|
||||
FilledButton(
|
||||
onPressed: onConfirm,
|
||||
style: _dangerButtonStyle,
|
||||
child: Text(confirmLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static ButtonStyle get _dangerButtonStyle => FilledButton.styleFrom(
|
||||
backgroundColor: Colors.red.shade700,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return AlertDialog(
|
||||
backgroundColor: const Color(0xFFF7F2FB),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
titlePadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
|
||||
contentPadding: const EdgeInsets.fromLTRB(24, 12, 24, 8),
|
||||
actionsPadding: const EdgeInsets.fromLTRB(16, 4, 16, 14),
|
||||
title: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.delete_outline,
|
||||
color: Colors.red.shade700,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFF2F2F2F),
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
if ((subtitle ?? '').trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle!.trim(),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: const Color(0xFF6D4EA1),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if ((message ?? '').trim().isNotEmpty) ...[
|
||||
Text(
|
||||
message!.trim(),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.black87,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
if (people.isNotEmpty || footnotes.isNotEmpty)
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (people.isNotEmpty) ...[
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFFE5D8F2)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < people.length; i++) ...[
|
||||
if (i > 0)
|
||||
Divider(
|
||||
height: 1,
|
||||
indent: 44,
|
||||
endIndent: 12,
|
||||
color: Colors.grey.shade200,
|
||||
),
|
||||
_SuppressionPersonRow(line: people[i]),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (footnotes.isNotEmpty) const SizedBox(height: 12),
|
||||
],
|
||||
for (final note in footnotes)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: Colors.orange.shade800,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
note,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: Colors.black54,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: actions,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SuppressionPersonRow extends StatelessWidget {
|
||||
final SuppressionPersonLine line;
|
||||
|
||||
const _SuppressionPersonRow({required this.line});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(line.icon, size: 18, color: const Color(0xFF6D4EA1)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
line.label,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
color: Color(0xFF2F2F2F),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if ((line.role ?? '').trim().isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEDE5FA),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
line.role!.trim(),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF6D4EA1),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Affiche [SuppressionConfirmDialog] et renvoie `true` si confirmé.
|
||||
Future<bool> showSuppressionConfirmDialog(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
String? subtitle,
|
||||
String? message,
|
||||
List<SuppressionPersonLine> people = const [],
|
||||
List<String> footnotes = const [],
|
||||
String confirmLabel = 'Supprimer',
|
||||
}) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => SuppressionConfirmDialog.yesNo(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
message: message,
|
||||
people: people,
|
||||
footnotes: footnotes,
|
||||
confirmLabel: confirmLabel,
|
||||
onCancel: () => Navigator.of(ctx).pop(false),
|
||||
onConfirm: () => Navigator.of(ctx).pop(true),
|
||||
),
|
||||
);
|
||||
return result == true;
|
||||
}
|
||||
|
||||
/// Confirmation delete dossier famille / AM avec liste nominative.
|
||||
Future<bool> showDossierSuppressionConfirmDialog(
|
||||
BuildContext context, {
|
||||
required String numeroDossier,
|
||||
required bool isFamille,
|
||||
required List<SuppressionPersonLine> people,
|
||||
String? fallbackSummary,
|
||||
}) {
|
||||
final num = numeroDossier.trim();
|
||||
if (isFamille) {
|
||||
return showSuppressionConfirmDialog(
|
||||
context,
|
||||
title: 'Supprimer le dossier',
|
||||
subtitle: 'Dossier famille $num',
|
||||
people: people,
|
||||
footnotes: people.isEmpty && (fallbackSummary ?? '').isNotEmpty
|
||||
? [fallbackSummary!]
|
||||
: const [
|
||||
'Tous les comptes et fiches listés seront définitivement '
|
||||
'supprimés.',
|
||||
'Les placements AM des enfants seront clos.',
|
||||
],
|
||||
);
|
||||
}
|
||||
return showSuppressionConfirmDialog(
|
||||
context,
|
||||
title: 'Supprimer le dossier',
|
||||
subtitle: 'Dossier AM $num',
|
||||
people: people,
|
||||
footnotes: const [
|
||||
'Le compte et le dossier AM seront supprimés.',
|
||||
'Les enfants accueillis ne seront pas supprimés '
|
||||
'(placements clos).',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Dialog dernier enfant : deux actions métier.
|
||||
Future<DernierEnfantSuppressionChoice?> showDernierEnfantSuppressionDialog(
|
||||
BuildContext context, {
|
||||
required String enfantName,
|
||||
required String familleLabel,
|
||||
required String numeroDossier,
|
||||
String? amLabel,
|
||||
}) {
|
||||
final am = (amLabel ?? '').trim();
|
||||
return showDialog<DernierEnfantSuppressionChoice>(
|
||||
context: context,
|
||||
builder: (ctx) => SuppressionConfirmDialog(
|
||||
title: 'Dernier enfant du dossier',
|
||||
subtitle: 'Dossier $numeroDossier'
|
||||
'${familleLabel.isEmpty ? '' : ' · $familleLabel'}',
|
||||
people: [SuppressionPersonLine.enfant(enfantName)],
|
||||
footnotes: [
|
||||
'« Enfant seulement » : le dossier reste sans enfant.',
|
||||
'« Dossier aussi » : parents et dossier sont également '
|
||||
'supprimés.',
|
||||
if (am.isNotEmpty) 'Le placement chez $am sera clos.',
|
||||
],
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(
|
||||
DernierEnfantSuppressionChoice.enfantSeul,
|
||||
),
|
||||
child: const Text('Enfant seulement'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(
|
||||
DernierEnfantSuppressionChoice.dossierAussi,
|
||||
),
|
||||
style: SuppressionConfirmDialog._dangerButtonStyle,
|
||||
child: const Text('Dossier aussi'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Notes d’impact pour suppression d’un enfant (AM optionnelle).
|
||||
List<String> enfantSuppressionFootnotes({
|
||||
required String? numeroDossier,
|
||||
required String familleLabel,
|
||||
String? amLabel,
|
||||
}) {
|
||||
final notes = <String>[];
|
||||
final num = (numeroDossier ?? '').trim();
|
||||
final famille = familleLabel.trim();
|
||||
final am = (amLabel ?? '').trim();
|
||||
if (num.isEmpty) {
|
||||
notes.add('Supprimer définitivement cette fiche enfant.');
|
||||
} else {
|
||||
notes.add(
|
||||
'L’enfant sera retiré du dossier de '
|
||||
'${famille.isEmpty ? 'la famille' : famille}.',
|
||||
);
|
||||
}
|
||||
if (am.isNotEmpty) {
|
||||
notes.add('Le placement chez $am sera clos.');
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
/// Bouton poubelle compact pour les cartes liste.
|
||||
Widget suppressionIconButton({
|
||||
required VoidCallback? onPressed,
|
||||
String tooltip = 'Supprimer',
|
||||
}) {
|
||||
return IconButton(
|
||||
icon: Icon(Icons.delete_outline, color: Colors.red.shade700),
|
||||
tooltip: tooltip,
|
||||
onPressed: onPressed,
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit les lignes parents / enfants depuis un dossier unifié.
|
||||
List<SuppressionPersonLine> suppressionPeopleFromDossier({
|
||||
required bool isFamille,
|
||||
required List<({String nom, String prenom, String email})> parents,
|
||||
required List<({String nom, String prenom})> enfants,
|
||||
String? amName,
|
||||
}) {
|
||||
final lines = <SuppressionPersonLine>[];
|
||||
if (isFamille) {
|
||||
for (final p in parents) {
|
||||
final label = formatDossierPersonLabel(
|
||||
nom: p.nom,
|
||||
prenom: p.prenom,
|
||||
email: p.email,
|
||||
);
|
||||
if (label.isEmpty) continue;
|
||||
lines.add(SuppressionPersonLine.parent(label));
|
||||
}
|
||||
for (final e in enfants) {
|
||||
final label = formatDossierPersonLabel(nom: e.nom, prenom: e.prenom);
|
||||
if (label.isEmpty) continue;
|
||||
lines.add(SuppressionPersonLine.enfant(label));
|
||||
}
|
||||
} else if ((amName ?? '').trim().isNotEmpty) {
|
||||
lines.add(SuppressionPersonLine.am(amName!.trim()));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
@@ -1,7 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:p_tits_pas/utils/email_utils.dart';
|
||||
import 'package:p_tits_pas/utils/nir_utils.dart';
|
||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||
import 'package:p_tits_pas/utils/postal_utils.dart';
|
||||
import 'admin_detail_modal.dart';
|
||||
|
||||
/// Réglages des formulaires validation / wizard AM — **jouer sur ces 3 leviers**.
|
||||
class ValidationFormMetrics {
|
||||
ValidationFormMetrics._();
|
||||
|
||||
// --- 1. Titres de section ---
|
||||
static const double sectionTitleFontSize = 16;
|
||||
static const double sectionTitleGapBelow = 12;
|
||||
|
||||
// --- 2. TF : texte intérieur + padding vertical (= hauteur) ---
|
||||
static const double fieldTextFontSize = 14;
|
||||
static const double fieldContentPaddingV = 12;
|
||||
static const double fieldContentPaddingH = 12;
|
||||
/// Hauteur estimée du TF (texte + padding haut/bas + bordure).
|
||||
static const double fieldHeight =
|
||||
fieldTextFontSize + fieldContentPaddingV * 2 + 4;
|
||||
|
||||
// --- 3. Espace entre les lignes de TF ---
|
||||
static const double rowGapBelow = 12;
|
||||
|
||||
// Libellé au-dessus du TF (titre du champ)
|
||||
static const double fieldLabelFontSize = 13;
|
||||
static const double fieldLabelGapBelow = 4;
|
||||
|
||||
static const TextStyle fieldTextStyle = TextStyle(
|
||||
color: Colors.black87,
|
||||
fontSize: fieldTextFontSize,
|
||||
);
|
||||
|
||||
static double get sectionTitleBlockHeight =>
|
||||
sectionTitleFontSize * 1.25 + sectionTitleGapBelow;
|
||||
|
||||
/// Libellé : marge au-dessus de [fieldLabelFontSize] (métriques police).
|
||||
static double get labeledRowHeight =>
|
||||
fieldLabelFontSize * 1.25 +
|
||||
fieldLabelGapBelow +
|
||||
fieldHeight +
|
||||
rowGapBelow;
|
||||
|
||||
/// Corps modale AM / famille : padding wizard + titre + [rows] lignes + nav.
|
||||
static double shellBodyHeightForRows(int rows) =>
|
||||
20 * 2 + // padding wizard
|
||||
4 + // espace haut
|
||||
sectionTitleBlockHeight +
|
||||
rows * labeledRowHeight +
|
||||
24 + // avant nav
|
||||
48; // boutons (+ marge anti-overflow)
|
||||
}
|
||||
|
||||
/// 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).
|
||||
@@ -16,12 +68,16 @@ class ValidationDetailSection extends StatelessWidget {
|
||||
/// Flex par ligne (index de ligne -> [flex1, flex2, ...]). Ex. {3: [2, 5]} pour Code postal | Ville.
|
||||
final Map<int, List<int>>? rowFlex;
|
||||
|
||||
/// Remplit la hauteur disponible (wizard AM étapes 1–2).
|
||||
final bool expandVertically;
|
||||
|
||||
const ValidationDetailSection({
|
||||
super.key,
|
||||
this.title,
|
||||
required this.fields,
|
||||
this.rowLayout,
|
||||
this.rowFlex,
|
||||
this.expandVertically = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -30,6 +86,7 @@ class ValidationDetailSection extends StatelessWidget {
|
||||
title: title,
|
||||
rowLayout: rowLayout,
|
||||
rowFlex: rowFlex,
|
||||
expandVertically: expandVertically,
|
||||
fields: fields
|
||||
.map(
|
||||
(f) => ValidationLabeledField(
|
||||
@@ -49,6 +106,8 @@ class ValidationFormGrid extends StatelessWidget {
|
||||
final List<int>? rowLayout;
|
||||
final Map<int, List<int>>? rowFlex;
|
||||
final bool compact;
|
||||
/// Répartit la hauteur dispo entre les lignes (remplit le blanc sans scroll).
|
||||
final bool expandVertically;
|
||||
|
||||
const ValidationFormGrid({
|
||||
super.key,
|
||||
@@ -57,6 +116,7 @@ class ValidationFormGrid extends StatelessWidget {
|
||||
this.rowLayout,
|
||||
this.rowFlex,
|
||||
this.compact = false,
|
||||
this.expandVertically = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -64,7 +124,7 @@ class ValidationFormGrid extends StatelessWidget {
|
||||
final layout = rowLayout ?? List.filled(fields.length, 1);
|
||||
int index = 0;
|
||||
int rowIndex = 0;
|
||||
final rows = <Widget>[];
|
||||
final rowWidgets = <Widget>[];
|
||||
for (final count in layout) {
|
||||
if (index >= fields.length) break;
|
||||
final rowFields = fields.skip(index).take(count).toList();
|
||||
@@ -72,48 +132,74 @@ class ValidationFormGrid extends StatelessWidget {
|
||||
if (rowFields.isEmpty) continue;
|
||||
final flexForRow = rowFlex?[rowIndex];
|
||||
rowIndex++;
|
||||
final labeled = rowFields
|
||||
.map(
|
||||
(f) => ValidationLabeledField(
|
||||
label: f.label,
|
||||
field: f.field,
|
||||
expand: expandVertically,
|
||||
labelTrailing: f.labelTrailing,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
Widget row;
|
||||
if (count == 1) {
|
||||
rows.add(Padding(
|
||||
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
|
||||
child: rowFields.first,
|
||||
));
|
||||
row = labeled.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],
|
||||
),
|
||||
],
|
||||
row = Row(
|
||||
crossAxisAlignment: expandVertically
|
||||
? CrossAxisAlignment.stretch
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (int i = 0; i < labeled.length; i++) ...[
|
||||
if (i > 0) SizedBox(width: compact ? 12 : 16),
|
||||
Expanded(
|
||||
flex: (flexForRow != null && i < flexForRow.length)
|
||||
? flexForRow[i]
|
||||
: 1,
|
||||
child: labeled[i],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (expandVertically) {
|
||||
rowWidgets.add(Expanded(child: row));
|
||||
} else {
|
||||
rowWidgets.add(Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: compact ? 8 : ValidationFormMetrics.rowGapBelow,
|
||||
),
|
||||
child: row,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
final showTitle = title != null && title!.trim().isNotEmpty;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisSize: expandVertically ? MainAxisSize.max : MainAxisSize.min,
|
||||
children: [
|
||||
if (showTitle) ...[
|
||||
Text(
|
||||
title!.trim(),
|
||||
style: TextStyle(
|
||||
fontSize: compact ? 15 : 16,
|
||||
fontSize: compact
|
||||
? ValidationFormMetrics.sectionTitleFontSize - 1
|
||||
: ValidationFormMetrics.sectionTitleFontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
SizedBox(height: compact ? 8 : 12),
|
||||
SizedBox(
|
||||
height: compact
|
||||
? 8
|
||||
: ValidationFormMetrics.sectionTitleGapBelow,
|
||||
),
|
||||
],
|
||||
...rows,
|
||||
...rowWidgets,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -130,8 +216,8 @@ class ValidationFieldDecoration {
|
||||
fillColor: Colors.grey.shade50,
|
||||
hintText: hint,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: compact ? 10 : 12,
|
||||
vertical: compact ? 7 : 10,
|
||||
horizontal: compact ? 10 : ValidationFormMetrics.fieldContentPaddingH,
|
||||
vertical: compact ? 7 : ValidationFormMetrics.fieldContentPaddingV,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
@@ -180,29 +266,40 @@ class ValidationFieldDecoration {
|
||||
class ValidationLabeledField extends StatelessWidget {
|
||||
final String label;
|
||||
final Widget field;
|
||||
final bool expand;
|
||||
/// Widget aligné à droite sur la ligne du libellé (ex. switch « Même adresse »).
|
||||
final Widget? labelTrailing;
|
||||
|
||||
const ValidationLabeledField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.field,
|
||||
this.expand = false,
|
||||
this.labelTrailing,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final labelStyle = TextStyle(
|
||||
fontSize: ValidationFormMetrics.fieldLabelFontSize,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey.shade700,
|
||||
);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey.shade700,
|
||||
if (labelTrailing == null)
|
||||
Text(label, style: labelStyle)
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text(label, style: labelStyle)),
|
||||
labelTrailing!,
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
field,
|
||||
SizedBox(height: ValidationFormMetrics.fieldLabelGapBelow),
|
||||
if (expand) Expanded(child: field) else field,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -216,6 +313,7 @@ class ValidationEditableField extends StatelessWidget {
|
||||
final String? hintText;
|
||||
final int maxLines;
|
||||
final bool compact;
|
||||
final bool enabled;
|
||||
|
||||
const ValidationEditableField({
|
||||
super.key,
|
||||
@@ -225,6 +323,7 @@ class ValidationEditableField extends StatelessWidget {
|
||||
this.hintText,
|
||||
this.maxLines = 1,
|
||||
this.compact = false,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
static const double _compactFieldHeight = 34;
|
||||
@@ -256,6 +355,7 @@ class ValidationEditableField extends StatelessWidget {
|
||||
if (maxLines > 1) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
enabled: enabled,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLines: maxLines,
|
||||
@@ -264,13 +364,17 @@ class ValidationEditableField extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
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 _validationFieldFillHeight(
|
||||
TextField(
|
||||
controller: controller,
|
||||
enabled: enabled,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLines: 1,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
style: ValidationFormMetrics.fieldTextStyle,
|
||||
decoration: ValidationFieldDecoration.input(hint: hintText),
|
||||
),
|
||||
);
|
||||
}
|
||||
return SizedBox(
|
||||
@@ -279,6 +383,7 @@ class ValidationEditableField extends StatelessWidget {
|
||||
decoration: _compactDecoration(),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
enabled: enabled,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLines: 1,
|
||||
@@ -295,6 +400,352 @@ class ValidationEditableField extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hauteur TF fixe ; en grille [expandVertically], étire jusqu’à la hauteur dispo.
|
||||
Widget _validationFieldFillHeight(Widget field) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
final h = (c.hasBoundedHeight && c.maxHeight.isFinite)
|
||||
? c.maxHeight
|
||||
: ValidationFormMetrics.fieldHeight;
|
||||
return SizedBox(
|
||||
height: h,
|
||||
width: double.infinity,
|
||||
child: field,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// E-mail style validation — même supervision que login / création de compte :
|
||||
/// [EmailMaxLengthFormatter] + à la perte de focus : trim/minuscules + validation.
|
||||
class ValidationEmailField extends StatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final String? hintText;
|
||||
final bool allowEmpty;
|
||||
|
||||
const ValidationEmailField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.hintText,
|
||||
this.allowEmpty = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ValidationEmailField> createState() => _ValidationEmailFieldState();
|
||||
}
|
||||
|
||||
class _ValidationEmailFieldState extends State<ValidationEmailField> {
|
||||
final GlobalKey<FormFieldState<String>> _fieldKey =
|
||||
GlobalKey<FormFieldState<String>>();
|
||||
late final FocusNode _focusNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode = FocusNode();
|
||||
_focusNode.addListener(_onFocusChange);
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
if (_focusNode.hasFocus) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _focusNode.hasFocus) return;
|
||||
final c = widget.controller;
|
||||
final normalized = normalizeEmailText(c.text);
|
||||
if (normalized != c.text) {
|
||||
c.value = TextEditingValue(
|
||||
text: normalized,
|
||||
selection: TextSelection.collapsed(offset: normalized.length),
|
||||
);
|
||||
}
|
||||
_fieldKey.currentState?.validate();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.removeListener(_onFocusChange);
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _validationFieldFillHeight(
|
||||
TextFormField(
|
||||
key: _fieldKey,
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
textInputAction: TextInputAction.next,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
inputFormatters: const [EmailMaxLengthFormatter()],
|
||||
style: ValidationFormMetrics.fieldTextStyle,
|
||||
decoration:
|
||||
ValidationFieldDecoration.input(hint: widget.hintText).copyWith(
|
||||
errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11),
|
||||
errorMaxLines: 2,
|
||||
),
|
||||
validator: (value) =>
|
||||
validateEmail(value, allowEmpty: widget.allowEmpty),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Code postal FR — même supervision que création de compte :
|
||||
/// chiffres uniquement (max 5) + validation à la perte de focus.
|
||||
class ValidationPostalCodeField extends StatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final String? hintText;
|
||||
final bool allowEmpty;
|
||||
final bool enabled;
|
||||
|
||||
const ValidationPostalCodeField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.hintText,
|
||||
this.allowEmpty = false,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ValidationPostalCodeField> createState() =>
|
||||
_ValidationPostalCodeFieldState();
|
||||
}
|
||||
|
||||
class _ValidationPostalCodeFieldState extends State<ValidationPostalCodeField> {
|
||||
final GlobalKey<FormFieldState<String>> _fieldKey =
|
||||
GlobalKey<FormFieldState<String>>();
|
||||
late final FocusNode _focusNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode = FocusNode();
|
||||
_focusNode.addListener(_onFocusChange);
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
if (_focusNode.hasFocus) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _focusNode.hasFocus) return;
|
||||
final c = widget.controller;
|
||||
final trimmed = c.text.trim();
|
||||
if (trimmed != c.text) {
|
||||
c.value = TextEditingValue(
|
||||
text: trimmed,
|
||||
selection: TextSelection.collapsed(offset: trimmed.length),
|
||||
);
|
||||
}
|
||||
_fieldKey.currentState?.validate();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.removeListener(_onFocusChange);
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _validationFieldFillHeight(
|
||||
TextFormField(
|
||||
key: _fieldKey,
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
enabled: widget.enabled,
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.next,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
inputFormatters: kFrenchPostalCodeInputFormatters,
|
||||
style: ValidationFormMetrics.fieldTextStyle,
|
||||
decoration: ValidationFieldDecoration.input(
|
||||
hint: widget.hintText ?? '5 chiffres',
|
||||
).copyWith(
|
||||
errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11),
|
||||
errorMaxLines: 2,
|
||||
),
|
||||
validator: (value) =>
|
||||
validateFrenchPostalCode(value, allowEmpty: widget.allowEmpty),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Téléphone FR — formatters live + [validateFrenchNationalPhone] à la perte de focus.
|
||||
class ValidationPhoneField extends StatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final String? hintText;
|
||||
final bool allowEmpty;
|
||||
|
||||
const ValidationPhoneField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.hintText,
|
||||
this.allowEmpty = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ValidationPhoneField> createState() => _ValidationPhoneFieldState();
|
||||
}
|
||||
|
||||
class _ValidationPhoneFieldState extends State<ValidationPhoneField> {
|
||||
final GlobalKey<FormFieldState<String>> _fieldKey =
|
||||
GlobalKey<FormFieldState<String>>();
|
||||
late final FocusNode _focusNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode = FocusNode();
|
||||
_focusNode.addListener(_onFocusChange);
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
if (_focusNode.hasFocus) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _focusNode.hasFocus) return;
|
||||
final c = widget.controller;
|
||||
final digits = normalizePhone(c.text);
|
||||
final formatted = digits.isEmpty ? '' : formatPhoneForDisplay(digits);
|
||||
if (formatted != c.text) {
|
||||
c.value = TextEditingValue(
|
||||
text: formatted,
|
||||
selection: TextSelection.collapsed(offset: formatted.length),
|
||||
);
|
||||
}
|
||||
_fieldKey.currentState?.validate();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.removeListener(_onFocusChange);
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _validationFieldFillHeight(
|
||||
TextFormField(
|
||||
key: _fieldKey,
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
keyboardType: TextInputType.phone,
|
||||
textInputAction: TextInputAction.next,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
inputFormatters: frenchPhoneInputFormatters,
|
||||
style: ValidationFormMetrics.fieldTextStyle,
|
||||
decoration:
|
||||
ValidationFieldDecoration.input(hint: widget.hintText).copyWith(
|
||||
errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11),
|
||||
errorMaxLines: 2,
|
||||
),
|
||||
validator: (value) =>
|
||||
validateFrenchNationalPhone(value, allowEmpty: widget.allowEmpty),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// NIR — formatage live ([NirInputFormatter]) + validation au fil de la saisie / blur.
|
||||
class ValidationNirField extends StatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final String? hintText;
|
||||
final bool allowEmpty;
|
||||
|
||||
const ValidationNirField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.hintText,
|
||||
this.allowEmpty = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ValidationNirField> createState() => _ValidationNirFieldState();
|
||||
}
|
||||
|
||||
class _ValidationNirFieldState extends State<ValidationNirField> {
|
||||
final GlobalKey<FormFieldState<String>> _fieldKey =
|
||||
GlobalKey<FormFieldState<String>>();
|
||||
late final FocusNode _focusNode;
|
||||
bool _blurred = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode = FocusNode();
|
||||
_focusNode.addListener(_onFocusChange);
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
if (_focusNode.hasFocus) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _focusNode.hasFocus) return;
|
||||
setState(() => _blurred = true);
|
||||
final c = widget.controller;
|
||||
final raw = nirToRaw(c.text).toUpperCase();
|
||||
final formatted = raw.isEmpty ? '' : formatNir(raw);
|
||||
if (formatted != c.text) {
|
||||
c.value = TextEditingValue(
|
||||
text: formatted,
|
||||
selection: TextSelection.collapsed(offset: formatted.length),
|
||||
);
|
||||
}
|
||||
_fieldKey.currentState?.validate();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.removeListener(_onFocusChange);
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String? _validator(String? value) {
|
||||
if (_blurred) {
|
||||
if (widget.allowEmpty && (value == null || value.trim().isEmpty)) {
|
||||
return null;
|
||||
}
|
||||
return validateNir(value);
|
||||
}
|
||||
return validateNirTyping(value);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _validationFieldFillHeight(
|
||||
TextFormField(
|
||||
key: _fieldKey,
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
keyboardType: TextInputType.text,
|
||||
textInputAction: TextInputAction.next,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
inputFormatters: const [NirInputFormatter()],
|
||||
style: ValidationFormMetrics.fieldTextStyle,
|
||||
decoration: ValidationFieldDecoration.input(
|
||||
hint: widget.hintText ?? '1 12 34 56 789 012 - 34',
|
||||
).copyWith(
|
||||
errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11),
|
||||
errorMaxLines: 2,
|
||||
),
|
||||
onChanged: (_) => _fieldKey.currentState?.validate(),
|
||||
validator: _validator,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Grille label/champ éditable (délègue à [ValidationFormGrid]).
|
||||
class ValidationEditableSection extends StatelessWidget {
|
||||
final List<ValidationLabeledField> fields;
|
||||
@@ -368,16 +819,19 @@ class _ValidationReadOnlyFieldState extends State<ValidationReadOnlyField> {
|
||||
@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,
|
||||
return _validationFieldFillHeight(
|
||||
TextField(
|
||||
controller: _controller,
|
||||
readOnly: true,
|
||||
enableInteractiveSelection: false,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
style: TextStyle(
|
||||
color: widget.error ? Colors.red.shade800 : Colors.black87,
|
||||
fontSize: ValidationFormMetrics.fieldTextFontSize,
|
||||
fontWeight: widget.error ? FontWeight.w600 : null,
|
||||
),
|
||||
decoration: ValidationFieldDecoration.readOnly(error: widget.error),
|
||||
),
|
||||
decoration: ValidationFieldDecoration.readOnly(error: widget.error),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
||||
final ValueChanged<int> onSubTabChange;
|
||||
final TextEditingController searchController;
|
||||
final String searchHint;
|
||||
/// Infobulle au survol de la barre de recherche (ex. critères de recherche).
|
||||
final String? searchTooltip;
|
||||
final Widget? filterControl;
|
||||
final VoidCallback? onAddPressed;
|
||||
final String addLabel;
|
||||
@@ -22,12 +24,16 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
||||
'Administrateurs',
|
||||
];
|
||||
|
||||
/// Aligné sur la taille des libellés d’onglets.
|
||||
static const double _searchFontSize = 13;
|
||||
|
||||
const DashboardUserManagementSubBar({
|
||||
Key? key,
|
||||
required this.selectedSubIndex,
|
||||
required this.onSubTabChange,
|
||||
required this.searchController,
|
||||
required this.searchHint,
|
||||
this.searchTooltip,
|
||||
this.filterControl,
|
||||
this.onAddPressed,
|
||||
this.addLabel = '+ Ajouter',
|
||||
@@ -54,22 +60,7 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
||||
_buildSubNavItem(context, labels[i], i),
|
||||
],
|
||||
const SizedBox(width: 36),
|
||||
_pillField(
|
||||
width: 320,
|
||||
child: TextField(
|
||||
controller: searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: searchHint,
|
||||
prefixIcon: const Icon(Icons.search, size: 18),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildSearchField(),
|
||||
if (filterControl != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
_pillField(width: 150, child: filterControl!),
|
||||
@@ -81,6 +72,40 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchField() {
|
||||
final field = _pillField(
|
||||
width: 320,
|
||||
child: TextField(
|
||||
controller: searchController,
|
||||
style: const TextStyle(fontSize: _searchFontSize),
|
||||
decoration: InputDecoration(
|
||||
hintText: searchHint,
|
||||
hintStyle: TextStyle(
|
||||
fontSize: _searchFontSize,
|
||||
fontStyle: FontStyle.normal,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: Colors.black45,
|
||||
),
|
||||
prefixIcon: const Icon(Icons.search, size: 18),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final tip = (searchTooltip ?? '').trim();
|
||||
if (tip.isEmpty) return field;
|
||||
return Tooltip(
|
||||
message: tip,
|
||||
preferBelow: false,
|
||||
waitDuration: const Duration(milliseconds: 400),
|
||||
child: field,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _pillField({required double width, required Widget child}) {
|
||||
return Container(
|
||||
width: width,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||
|
||||
/// Carte dossier unifiée (#153) — fond neutre, accent couleur sur l’icône.
|
||||
class DossierListCard extends StatelessWidget {
|
||||
final String numeroDossier;
|
||||
final String namesLine;
|
||||
final bool isFamille;
|
||||
final VoidCallback onOpen;
|
||||
/// Photo AM (si absente → icône fallback).
|
||||
final String? photoUrl;
|
||||
final VoidCallback? onDelete;
|
||||
/// Warning vigilance (ex. dossier sans enfant #160).
|
||||
final String? vigilanceTooltip;
|
||||
/// Nombre d’enfants (famille) — affiché à côté des noms.
|
||||
final int? enfantsCount;
|
||||
final bool sansEnfant;
|
||||
|
||||
/// Lavande — Famille / Parents.
|
||||
static const Color familleAccent = Color(0xFFB289C9);
|
||||
|
||||
/// Menthe logo — AM.
|
||||
static const Color amAccent = Color(0xFF5A9D94);
|
||||
|
||||
const DossierListCard({
|
||||
super.key,
|
||||
required this.numeroDossier,
|
||||
required this.namesLine,
|
||||
required this.isFamille,
|
||||
required this.onOpen,
|
||||
this.photoUrl,
|
||||
this.onDelete,
|
||||
this.vigilanceTooltip,
|
||||
this.enfantsCount,
|
||||
this.sansEnfant = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = isFamille ? familleAccent : amAccent;
|
||||
final num = numeroDossier.trim().isEmpty ? '–' : numeroDossier.trim();
|
||||
final names = namesLine.trim();
|
||||
final avatar = (photoUrl ?? '').trim();
|
||||
final emptyKids = isFamille && (sansEnfant || enfantsCount == 0);
|
||||
final count = enfantsCount;
|
||||
final countLabel = (!isFamille || count == null)
|
||||
? null
|
||||
: (count <= 1 ? '$count enfant' : '$count enfants');
|
||||
|
||||
final subtitle = <String>[
|
||||
if (names.isNotEmpty) names,
|
||||
if (emptyKids) 'Sans enfant',
|
||||
if (!emptyKids && countLabel != null) countLabel,
|
||||
];
|
||||
|
||||
return AdminUserCard(
|
||||
title: num,
|
||||
subtitleLines: subtitle,
|
||||
avatarUrl: !isFamille && avatar.isNotEmpty ? avatar : null,
|
||||
fallbackIcon:
|
||||
isFamille ? Icons.family_restroom_outlined : Icons.face,
|
||||
// N° = titre neutre (comme Parents / AM) ; accent = icône seule.
|
||||
avatarIconColor: accent,
|
||||
infoColor: emptyKids ? Colors.red.shade700 : Colors.black87,
|
||||
onCardTap: onOpen,
|
||||
vigilanceTooltip: emptyKids
|
||||
? (vigilanceTooltip ??
|
||||
'Aucun enfant rattaché à ce dossier famille')
|
||||
: vigilanceTooltip,
|
||||
borderColor: emptyKids ? Colors.red.shade300 : null,
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Ouvrir',
|
||||
icon: Icon(Icons.open_in_new, size: 20, color: accent),
|
||||
onPressed: onOpen,
|
||||
),
|
||||
if (onDelete != null)
|
||||
suppressionIconButton(onPressed: onDelete),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/dossier_list_item.dart';
|
||||
import 'package:p_tits_pas/services/auth_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/dossier_list_card.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart';
|
||||
|
||||
/// Onglet permanent « Dossiers » (#153) : pending en haut + liste unifiée en bas.
|
||||
class DossiersManagementWidget extends StatefulWidget {
|
||||
final String searchQuery;
|
||||
final VoidCallback? onRefresh;
|
||||
|
||||
const DossiersManagementWidget({
|
||||
super.key,
|
||||
this.searchQuery = '',
|
||||
this.onRefresh,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DossiersManagementWidget> createState() =>
|
||||
_DossiersManagementWidgetState();
|
||||
}
|
||||
|
||||
class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
List<DossierListItem> _all = [];
|
||||
Set<String> _pendingNumeros = {};
|
||||
int _pendingRefreshTick = 0;
|
||||
bool _canDelete = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadRights();
|
||||
_loadAll();
|
||||
}
|
||||
|
||||
Future<void> _loadRights() async {
|
||||
final user = await AuthService.getCurrentUser();
|
||||
if (!mounted) return;
|
||||
setState(() => _canDelete = canDeleteMetier(user?.role));
|
||||
}
|
||||
|
||||
Future<void> _loadAll() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final parents = await UserService.getParents();
|
||||
final ams = await UserService.getAssistantesMaternelles();
|
||||
Map<String, bool> sansEnfant = {};
|
||||
try {
|
||||
sansEnfant = await UserService.getSansEnfantByNumero();
|
||||
} catch (_) {
|
||||
// Flag optionnel : ne bloque pas la liste.
|
||||
}
|
||||
if (!mounted) return;
|
||||
final items = <DossierListItem>[
|
||||
...DossierListItem.fromParents(parents),
|
||||
...DossierListItem.fromAssistantes(ams),
|
||||
].map((item) {
|
||||
if (!item.isFamille) return item;
|
||||
final apiFlag = sansEnfant[item.numeroDossier] == true;
|
||||
final localEmpty = (item.enfantsCount ?? 0) == 0;
|
||||
return item.copyWith(
|
||||
sansEnfant: apiFlag || localEmpty || item.sansEnfant,
|
||||
);
|
||||
}).toList();
|
||||
items.sort((a, b) {
|
||||
// Dossiers sans enfant en tête (#160), comme orphelins #157.
|
||||
final ae = a.sansEnfant ? 0 : 1;
|
||||
final be = b.sansEnfant ? 0 : 1;
|
||||
if (ae != be) return ae.compareTo(be);
|
||||
final byNum = a.numeroDossier.compareTo(b.numeroDossier);
|
||||
if (byNum != 0) return byNum;
|
||||
return a.typeLabel.compareTo(b.typeLabel);
|
||||
});
|
||||
setState(() {
|
||||
_all = items;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e is Exception
|
||||
? e.toString().replaceFirst('Exception: ', '')
|
||||
: 'Erreur inconnue';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshEverything() async {
|
||||
setState(() => _pendingRefreshTick++);
|
||||
await _loadAll();
|
||||
widget.onRefresh?.call();
|
||||
}
|
||||
|
||||
void _openDossier(String numeroDossier) {
|
||||
final num = numeroDossier.trim();
|
||||
if (num.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Numéro de dossier manquant.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => ValidationDossierModal(
|
||||
numeroDossier: num,
|
||||
openAsEdit: true,
|
||||
onClose: () => Navigator.of(context).pop(),
|
||||
onSuccess: () {
|
||||
Navigator.of(context).pop();
|
||||
_refreshEverything();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmDeleteDossier(DossierListItem item) async {
|
||||
final num = item.numeroDossier.trim();
|
||||
if (num.isEmpty) return;
|
||||
|
||||
var people = <SuppressionPersonLine>[];
|
||||
String? fallbackSummary;
|
||||
try {
|
||||
final dossier = await UserService.getDossier(num);
|
||||
if (dossier.isFamily) {
|
||||
final f = dossier.asFamily;
|
||||
people = suppressionPeopleFromDossier(
|
||||
isFamille: true,
|
||||
parents: f.parents
|
||||
.map((p) => (
|
||||
nom: p.nom ?? '',
|
||||
prenom: p.prenom ?? '',
|
||||
email: p.email,
|
||||
))
|
||||
.toList(),
|
||||
enfants: f.enfants
|
||||
.map((e) => (
|
||||
nom: e.lastName ?? '',
|
||||
prenom: e.firstName ?? '',
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
} else {
|
||||
final am = dossier.asAm.user;
|
||||
people = suppressionPeopleFromDossier(
|
||||
isFamille: false,
|
||||
parents: const [],
|
||||
enfants: const [],
|
||||
amName: formatDossierPersonLabel(
|
||||
nom: am.nom,
|
||||
prenom: am.prenom,
|
||||
email: am.email,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
fallbackSummary = item.isFamille
|
||||
? 'Tous les parents et enfants rattachés seront supprimés.'
|
||||
: 'Le compte AM sera supprimé ; les enfants accueillis '
|
||||
'seront conservés.';
|
||||
if (item.namesLine.trim().isNotEmpty) {
|
||||
for (final part in item.namesLine.split(' - ')) {
|
||||
final label = part.trim();
|
||||
if (label.isEmpty) continue;
|
||||
people.add(SuppressionPersonLine(
|
||||
label: label,
|
||||
icon: item.isFamille
|
||||
? Icons.supervisor_account_outlined
|
||||
: Icons.face,
|
||||
role: item.isFamille ? 'Parent' : 'AM',
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
final confirmed = await showDossierSuppressionConfirmDialog(
|
||||
context,
|
||||
numeroDossier: num,
|
||||
isFamille: item.isFamille,
|
||||
people: people,
|
||||
fallbackSummary: fallbackSummary,
|
||||
);
|
||||
if (!confirmed || !mounted) return;
|
||||
|
||||
try {
|
||||
final result = await UserService.deleteDossier(num);
|
||||
if (!mounted) return;
|
||||
final msg = (result['message'] ?? 'Dossier supprimé.').toString();
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||
await _refreshEverything();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
e is Exception
|
||||
? e.toString().replaceFirst('Exception: ', '')
|
||||
: 'Erreur suppression',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final query = widget.searchQuery;
|
||||
// Pending uniquement en haut — exclus de « Tous les dossiers ».
|
||||
final filtered = _all
|
||||
.where((d) => !_pendingNumeros.contains(d.numeroDossier))
|
||||
.where((d) => (d.statut ?? '').toLowerCase() != 'en_attente')
|
||||
.where((d) => d.matchesQuery(query))
|
||||
.toList(growable: false);
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _refreshEverything,
|
||||
child: CustomScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: PendingValidationWidget(
|
||||
key: ValueKey('pending-$_pendingRefreshTick'),
|
||||
searchQuery: query,
|
||||
compactWhenEmpty: true,
|
||||
canDelete: _canDelete,
|
||||
onPendingNumerosChanged: (nums) {
|
||||
if (!mounted) return;
|
||||
setState(() => _pendingNumeros = nums);
|
||||
},
|
||||
onRefresh: () {
|
||||
_loadAll();
|
||||
widget.onRefresh?.call();
|
||||
},
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(
|
||||
'Tous les dossiers',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_loading)
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (_error != null && _error!.isNotEmpty)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_error!, style: const TextStyle(color: Colors.red)),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadAll,
|
||||
child: const Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (filtered.isEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 32),
|
||||
child: Text(
|
||||
query.trim().isEmpty
|
||||
? 'Aucun dossier pour le moment.\n'
|
||||
'Pour créer un dossier → onglet Parents (+ Parents) '
|
||||
'ou Assistantes maternelles (+ Asmat).'
|
||||
: 'Aucun dossier ne correspond à la recherche.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey.shade600, height: 1.4),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final item = filtered[index];
|
||||
return DossierListCard(
|
||||
numeroDossier: item.numeroDossier,
|
||||
namesLine: item.namesLine,
|
||||
isFamille: item.isFamille,
|
||||
photoUrl: item.photoUrl,
|
||||
sansEnfant: item.sansEnfant,
|
||||
enfantsCount: item.enfantsCount,
|
||||
vigilanceTooltip: item.sansEnfant
|
||||
? 'Aucun enfant rattaché à ce dossier famille'
|
||||
: null,
|
||||
onOpen: () => _openDossier(item.numeroDossier),
|
||||
onDelete: _canDelete
|
||||
? () => _confirmDeleteDossier(item)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
childCount: filtered.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/dossier_list_item.dart';
|
||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||
import 'package:p_tits_pas/services/auth_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_child_detail_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||
|
||||
/// Onglet liste globale des enfants (doc 28 §6.2, ticket #137).
|
||||
@@ -25,13 +29,21 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
List<EnfantAdminModel> _enfants = [];
|
||||
bool _canDelete = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadRights();
|
||||
_loadEnfants();
|
||||
}
|
||||
|
||||
Future<void> _loadRights() async {
|
||||
final user = await AuthService.getCurrentUser();
|
||||
if (!mounted) return;
|
||||
setState(() => _canDelete = canDeleteMetier(user?.role));
|
||||
}
|
||||
|
||||
Future<void> _loadEnfants() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
@@ -59,10 +71,133 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
|
||||
builder: (ctx) => AdminChildDetailModal(
|
||||
enfant: enfant,
|
||||
onSaved: _loadEnfants,
|
||||
onDeleted: _loadEnfants,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<({String? numero, String famille, bool isLast, String? amLabel})>
|
||||
_resolveContext(
|
||||
EnfantAdminModel enfant,
|
||||
) async {
|
||||
String? amLabel;
|
||||
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.isEmpty) {
|
||||
return (numero: null, famille: '', isLast: true, amLabel: amLabel);
|
||||
}
|
||||
try {
|
||||
final parent = await UserService.getParent(parentId);
|
||||
final num = (parent.user.numeroDossier ?? '').trim();
|
||||
final famille = parent.user.fullName.isNotEmpty
|
||||
? parent.user.fullName
|
||||
: parent.user.email;
|
||||
if (num.isEmpty) {
|
||||
return (
|
||||
numero: null,
|
||||
famille: famille,
|
||||
isLast: true,
|
||||
amLabel: amLabel,
|
||||
);
|
||||
}
|
||||
final dossier = await UserService.getDossier(num);
|
||||
if (!dossier.isFamily) {
|
||||
return (
|
||||
numero: num,
|
||||
famille: famille,
|
||||
isLast: true,
|
||||
amLabel: amLabel,
|
||||
);
|
||||
}
|
||||
final n = dossier.asFamily.enfants.length;
|
||||
final names = dossier.asFamily.parents
|
||||
.map((p) => formatDossierPersonLabel(
|
||||
nom: p.nom,
|
||||
prenom: p.prenom,
|
||||
email: p.email,
|
||||
))
|
||||
.where((s) => s.isNotEmpty)
|
||||
.join(' - ');
|
||||
return (
|
||||
numero: num,
|
||||
famille: names.isNotEmpty ? names : famille,
|
||||
isLast: n <= 1,
|
||||
amLabel: amLabel,
|
||||
);
|
||||
} catch (_) {
|
||||
return (numero: null, famille: '', isLast: false, amLabel: amLabel);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(EnfantAdminModel enfant) async {
|
||||
final ctx = await _resolveContext(enfant);
|
||||
if (!mounted) return;
|
||||
|
||||
bool deleteDossier = false;
|
||||
if (ctx.isLast && (ctx.numero ?? '').isNotEmpty) {
|
||||
final choice = await showDernierEnfantSuppressionDialog(
|
||||
context,
|
||||
enfantName: enfant.fullName,
|
||||
familleLabel: ctx.famille,
|
||||
numeroDossier: ctx.numero!,
|
||||
amLabel: ctx.amLabel,
|
||||
);
|
||||
if (choice == null || !mounted) return;
|
||||
deleteDossier =
|
||||
choice == DernierEnfantSuppressionChoice.dossierAussi;
|
||||
} else {
|
||||
final confirmed = await showSuppressionConfirmDialog(
|
||||
context,
|
||||
title: 'Supprimer l\'enfant',
|
||||
subtitle: (ctx.numero ?? '').isEmpty
|
||||
? null
|
||||
: 'Dossier ${ctx.numero}',
|
||||
people: [SuppressionPersonLine.enfant(enfant.fullName)],
|
||||
footnotes: enfantSuppressionFootnotes(
|
||||
numeroDossier: ctx.numero,
|
||||
familleLabel: ctx.famille,
|
||||
amLabel: ctx.amLabel,
|
||||
),
|
||||
);
|
||||
if (!confirmed || !mounted) return;
|
||||
}
|
||||
|
||||
try {
|
||||
final result = await UserService.deleteEnfant(
|
||||
enfant.id,
|
||||
deleteDossier: deleteDossier,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final msg = (result['message'] ?? 'Enfant supprimé.').toString();
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||
await _loadEnfants();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
e is Exception
|
||||
? e.toString().replaceFirst('Exception: ', '')
|
||||
: 'Erreur suppression',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final query = widget.searchQuery.toLowerCase();
|
||||
@@ -72,7 +207,14 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
|
||||
normalizeEnfantStatus(e.status) ==
|
||||
normalizeEnfantStatus(widget.statusFilter);
|
||||
return matchesName && matchesStatus;
|
||||
}).toList();
|
||||
}).toList()
|
||||
..sort((a, b) {
|
||||
// Orphelins (#157) en tête, puis ordre alphabétique.
|
||||
final ao = a.hasNoResponsable ? 0 : 1;
|
||||
final bo = b.hasNoResponsable ? 0 : 1;
|
||||
if (ao != bo) return ao.compareTo(bo);
|
||||
return a.fullName.toLowerCase().compareTo(b.fullName.toLowerCase());
|
||||
});
|
||||
|
||||
return UserList(
|
||||
isLoading: _isLoading,
|
||||
@@ -91,6 +233,10 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
|
||||
tooltip: 'Voir / modifier',
|
||||
onPressed: () => _openEnfant(enfant),
|
||||
),
|
||||
if (_canDelete)
|
||||
suppressionIconButton(
|
||||
onPressed: () => _confirmDelete(enfant),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/user.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/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/admin/common/suppression_confirm_dialog.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||
|
||||
class GestionnaireManagementWidget extends StatefulWidget {
|
||||
@@ -23,16 +26,28 @@ class _GestionnaireManagementWidgetState
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
List<AppUser> _gestionnaires = [];
|
||||
bool _canDelete = false;
|
||||
String? _currentUserId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadRights();
|
||||
_loadGestionnaires();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() => super.dispose();
|
||||
|
||||
Future<void> _loadRights() async {
|
||||
final user = await AuthService.getCurrentUser();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_canDelete = canDeleteGestionnaire(user?.role);
|
||||
_currentUserId = user?.id;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadGestionnaires() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
@@ -67,6 +82,46 @@ class _GestionnaireManagementWidgetState
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(AppUser user) async {
|
||||
if (_currentUserId != null && _currentUserId == user.id) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Vous ne pouvez pas supprimer votre propre compte.'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final name = user.fullName.isNotEmpty ? user.fullName : user.email;
|
||||
final confirmed = await showSuppressionConfirmDialog(
|
||||
context,
|
||||
title: 'Supprimer le gestionnaire',
|
||||
people: [SuppressionPersonLine.gestionnaire(name)],
|
||||
footnotes: const [
|
||||
'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'] ?? 'Gestionnaire supprimé.').toString();
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||
await _loadGestionnaires();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
e is Exception
|
||||
? e.toString().replaceFirst('Exception: ', '')
|
||||
: 'Erreur suppression',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final query = widget.searchQuery.toLowerCase();
|
||||
@@ -84,6 +139,8 @@ class _GestionnaireManagementWidgetState
|
||||
itemCount: filteredGestionnaires.length,
|
||||
itemBuilder: (context, index) {
|
||||
final user = filteredGestionnaires[index];
|
||||
final isSelf =
|
||||
_currentUserId != null && _currentUserId == user.id;
|
||||
return AdminUserCard(
|
||||
title: user.fullName,
|
||||
fallbackIcon: Icons.assignment_ind_outlined,
|
||||
@@ -102,6 +159,8 @@ class _GestionnaireManagementWidgetState
|
||||
_openGestionnaireEditDialog(user);
|
||||
},
|
||||
),
|
||||
if (_canDelete && !isSelf)
|
||||
suppressionIconButton(onPressed: () => _confirmDelete(user)),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart';
|
||||
|
||||
/// Modale de création dossier famille (#129) — même shell que [AmDossierCreateModal].
|
||||
class ParentDossierCreateModal extends StatefulWidget {
|
||||
final VoidCallback onClose;
|
||||
final VoidCallback? onSuccess;
|
||||
|
||||
const ParentDossierCreateModal({
|
||||
super.key,
|
||||
required this.onClose,
|
||||
this.onSuccess,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ParentDossierCreateModal> createState() =>
|
||||
_ParentDossierCreateModalState();
|
||||
}
|
||||
|
||||
class _ParentDossierCreateModalState extends State<ParentDossierCreateModal> {
|
||||
int? _stepIndex;
|
||||
int? _stepTotal;
|
||||
|
||||
void _onStepChanged(int step, int total) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_stepIndex = step;
|
||||
_stepTotal = total;
|
||||
});
|
||||
}
|
||||
|
||||
void _onSuccess() {
|
||||
widget.onSuccess?.call();
|
||||
}
|
||||
|
||||
static const double _modalWidth = 930;
|
||||
/// Hauteur calculée depuis 4 lignes de TF (voir [ParentDossierWizard.shellBodyHeight]).
|
||||
static double get _bodyHeight => ParentDossierWizard.shellBodyHeight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maxH = MediaQuery.of(context).size.height * 0.85;
|
||||
final showStep =
|
||||
_stepIndex != null && _stepTotal != null && (_stepTotal ?? 0) > 0;
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: _modalWidth, maxHeight: maxH),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(18, 18, 0, 12),
|
||||
child: Text(
|
||||
'Nouveau dossier famille',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (showStep) ...[
|
||||
Text(
|
||||
'Étape ${(_stepIndex ?? 0) + 1}/${_stepTotal ?? 1}',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Colors.black54,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: widget.onClose,
|
||||
tooltip: 'Fermer',
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SizedBox(
|
||||
height: _bodyHeight,
|
||||
child: ParentDossierWizard.create(
|
||||
onClose: widget.onClose,
|
||||
onSuccess: _onSuccess,
|
||||
onStepChanged: _onStepChanged,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user