From d6d8b299dd71dc3e4a2c60c21a2c77851d3cbd20 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 10 Sep 2026 22:43:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(#159/#160):=20suppressions=20m=C3=A9tier?= =?UTF-8?q?=20dashboard=20=E2=80=94=20API=20+=20UI=20(squash=20develop).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matrice PO #154 : supprimer dossiers famille/AM, parents, enfants et comptes staff avec règles métier (placements clôturés, sans_enfant, cascade foyer, droits gestionnaire/admin). Poubelle en liste, dialogues de confirmation, garde-fou gestionnaire→gestionnaire. Inclut polish hauteur modale fiche AM. --- backend/src/app.module.ts | 2 + .../dossiers/dossiers.controller.spec.ts | 24 +- .../routes/dossiers/dossiers.controller.ts | 38 +- .../src/routes/dossiers/dossiers.module.ts | 2 + .../routes/dossiers/dossiers.service.spec.ts | 6 + .../src/routes/dossiers/dossiers.service.ts | 15 +- .../dossiers/dto/dossier-list-item.dto.ts | 6 + .../src/routes/enfants/enfants.controller.ts | 36 +- backend/src/routes/enfants/enfants.module.ts | 11 +- .../suppressions/suppression.service.spec.ts | 175 +++++++ .../suppressions/suppression.service.ts | 420 ++++++++++++++++ .../suppressions/suppressions.module.ts | 25 + backend/src/routes/user/user.controller.ts | 22 +- backend/src/routes/user/user.module.ts | 2 + backend/src/routes/user/user.service.ts | 1 + docs/tmp/154-matrice-suppression.md | 35 ++ docs/tmp/154-mini-spec-front-suppression.md | 169 +++++++ frontend/lib/models/dossier_list_item.dart | 43 ++ .../creation/admin_create.dart | 34 +- .../creation/gestionnaires_create.dart | 51 +- frontend/lib/services/user_service.dart | 101 +++- frontend/lib/utils/staff_deletion_rights.dart | 53 ++ .../admin/admin_management_widget.dart | 68 +++ ...sistante_maternelle_management_widget.dart | 56 +++ .../admin/common/admin_am_edit_modal.dart | 75 ++- .../common/admin_child_detail_modal.dart | 126 +++-- .../common/suppression_confirm_dialog.dart | 467 ++++++++++++++++++ .../lib/widgets/admin/dossier_list_card.dart | 33 +- .../admin/dossiers_management_widget.dart | 128 ++++- .../admin/enfant_management_widget.dart | 139 ++++++ .../admin/gestionnaire_management_widget.dart | 59 +++ .../admin/parent_managmant_widget.dart | 75 +++ .../admin/pending_validation_widget.dart | 115 ++++- .../admin/relais_management_panel.dart | 29 +- 34 files changed, 2467 insertions(+), 174 deletions(-) create mode 100644 backend/src/routes/suppressions/suppression.service.spec.ts create mode 100644 backend/src/routes/suppressions/suppression.service.ts create mode 100644 backend/src/routes/suppressions/suppressions.module.ts create mode 100644 docs/tmp/154-matrice-suppression.md create mode 100644 docs/tmp/154-mini-spec-front-suppression.md create mode 100644 frontend/lib/utils/staff_deletion_rights.dart create mode 100644 frontend/lib/widgets/admin/common/suppression_confirm_dialog.dart diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index ce8dbc2..715f83c 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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: [ diff --git a/backend/src/routes/dossiers/dossiers.controller.spec.ts b/backend/src/routes/dossiers/dossiers.controller.spec.ts index 165e1e3..8cab3f5 100644 --- a/backend/src/routes/dossiers/dossiers.controller.spec.ts +++ b/backend/src/routes/dossiers/dossiers.controller.spec.ts @@ -1,6 +1,7 @@ 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'; @@ -11,11 +12,17 @@ describe('DossiersController', () => { 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 }], + providers: [ + { provide: DossiersService, useValue: dossiersServiceMock }, + { provide: SuppressionService, useValue: suppressionServiceMock }, + ], }) .overrideGuard(AuthGuard) .useValue({ canActivate: () => true }) @@ -60,4 +67,19 @@ describe('DossiersController', () => { 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, + ); + }); }); diff --git a/backend/src/routes/dossiers/dossiers.controller.ts b/backend/src/routes/dossiers/dossiers.controller.ts index 4849f9b..3009a0e 100644 --- a/backend/src/routes/dossiers/dossiers.controller.ts +++ b/backend/src/routes/dossiers/dossiers.controller.ts @@ -1,4 +1,11 @@ -import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { + Controller, + Delete, + Get, + Param, + Query, + UseGuards, +} from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, @@ -8,10 +15,12 @@ import { 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'; @@ -20,7 +29,10 @@ import { DossierListItemDto } from './dto/dossier-list-item.dto'; @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) @@ -28,7 +40,8 @@ export class DossiersController { 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.', + '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', @@ -51,4 +64,21 @@ export class DossiersController { getDossier(@Param('numeroDossier') numeroDossier: string): Promise { 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); + } } diff --git a/backend/src/routes/dossiers/dossiers.module.ts b/backend/src/routes/dossiers/dossiers.module.ts index e6ba196..d130bb2 100644 --- a/backend/src/routes/dossiers/dossiers.module.ts +++ b/backend/src/routes/dossiers/dossiers.module.ts @@ -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) => ({ diff --git a/backend/src/routes/dossiers/dossiers.service.spec.ts b/backend/src/routes/dossiers/dossiers.service.spec.ts index ecdf772..5519c26 100644 --- a/backend/src/routes/dossiers/dossiers.service.spec.ts +++ b/backend/src/routes/dossiers/dossiers.service.spec.ts @@ -4,6 +4,7 @@ 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', () => { @@ -32,6 +33,9 @@ describe('DossiersService.listDossiers', () => { const parentsService = { getDossierFamilleByNumero: jest.fn(), }; + const suppressionService = { + countEnfantsForNumero: jest.fn().mockResolvedValue(1), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -40,6 +44,7 @@ describe('DossiersService.listDossiers', () => { { provide: getRepositoryToken(Parents), useValue: parentsRepo }, { provide: getRepositoryToken(AssistanteMaternelle), useValue: amRepo }, { provide: ParentsService, useValue: parentsService }, + { provide: SuppressionService, useValue: suppressionService }, ], }).compile(); @@ -47,6 +52,7 @@ describe('DossiersService.listDossiers', () => { 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 () => { diff --git a/backend/src/routes/dossiers/dossiers.service.ts b/backend/src/routes/dossiers/dossiers.service.ts index cfc4711..75ce244 100644 --- a/backend/src/routes/dossiers/dossiers.service.ts +++ b/backend/src/routes/dossiers/dossiers.service.ts @@ -5,12 +5,13 @@ 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'; /** - * Dossiers unifiés — détail (#119) + liste (#153). + * Dossiers unifiés — détail (#119) + liste (#153) + sans_enfant (#159). */ @Injectable() export class DossiersService { @@ -20,6 +21,7 @@ export class DossiersService { @InjectRepository(AssistanteMaternelle) private readonly amRepository: Repository, private readonly parentsService: ParentsService, + private readonly suppressionService: SuppressionService, ) {} /** @@ -43,6 +45,17 @@ export class DossiersService { 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; } diff --git a/backend/src/routes/dossiers/dto/dossier-list-item.dto.ts b/backend/src/routes/dossiers/dto/dossier-list-item.dto.ts index c510f7e..fb38384 100644 --- a/backend/src/routes/dossiers/dto/dossier-list-item.dto.ts +++ b/backend/src/routes/dossiers/dto/dossier-list-item.dto.ts @@ -49,4 +49,10 @@ export class DossierListItemDto { 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; } diff --git a/backend/src/routes/enfants/enfants.controller.ts b/backend/src/routes/enfants/enfants.controller.ts index 8b44228..b6b1e8e 100644 --- a/backend/src/routes/enfants/enfants.controller.ts +++ b/backend/src/routes/enfants/enfants.controller.ts @@ -13,6 +13,7 @@ import { ParseUUIDPipe, Patch, Post, + Query, UploadedFile, UseGuards, UseInterceptors, @@ -23,6 +24,7 @@ import { ApiBody, ApiConsumes, ApiOperation, + ApiQuery, ApiTags, } from '@nestjs/swagger'; import { diskStorage } from 'multer'; @@ -36,6 +38,7 @@ 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({ @@ -83,7 +86,10 @@ class OptionalEnfantPhotoInterceptor implements NestInterceptor { @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, @@ -157,9 +163,31 @@ export class EnfantsController { 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); } } diff --git a/backend/src/routes/enfants/enfants.module.ts b/backend/src/routes/enfants/enfants.module.ts index 2b9385b..4565cb7 100644 --- a/backend/src/routes/enfants/enfants.module.ts +++ b/backend/src/routes/enfants/enfants.module.ts @@ -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 { } diff --git a/backend/src/routes/suppressions/suppression.service.spec.ts b/backend/src/routes/suppressions/suppression.service.spec.ts new file mode 100644 index 0000000..0e38008 --- /dev/null +++ b/backend/src/routes/suppressions/suppression.service.spec.ts @@ -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) => + 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); + }); +}); diff --git a/backend/src/routes/suppressions/suppression.service.ts b/backend/src/routes/suppressions/suppression.service.ts new file mode 100644 index 0000000..f401bfc --- /dev/null +++ b/backend/src/routes/suppressions/suppression.service.ts @@ -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, + @InjectRepository(Parents) + private readonly parentsRepository: Repository, + @InjectRepository(AssistanteMaternelle) + private readonly amRepository: Repository, + @InjectRepository(Children) + private readonly childrenRepository: Repository, + @InjectRepository(ParentsChildren) + private readonly parentsChildrenRepository: Repository, + @InjectRepository(AmChildren) + private readonly amChildrenRepository: Repository, + ) {} + + assertStaffMetier(currentUser: Users): void { + if (!STAFF_METIER.includes(currentUser.role)) { + throw new ForbiddenException('Accès refusé'); + } + } + + async deleteDossier( + numeroDossier: string, + currentUser: Users, + ): Promise { + 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 { + 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 { + 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 { + const ids = await this.listEnfantIdsForNumero(numeroDossier); + return ids.length; + } + + private async assertCanDeleteAdministrateur( + target: Users, + currentUser: Users, + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const parentIds = await this.listParentUserIdsForNumero(numero); + if (!parentIds.length) return []; + return this.listEnfantIdsForParents(parentIds); + } + + private async listEnfantIdsForParent(parentId: string): Promise { + return this.listEnfantIdsForParents([parentId]); + } + + private async listEnfantIdsForParents(parentIds: string[]): Promise { + const links = await this.parentsChildrenRepository.find({ + where: { parentId: In(parentIds) }, + }); + return [...new Set(links.map((l) => l.enfantId))]; + } +} diff --git a/backend/src/routes/suppressions/suppressions.module.ts b/backend/src/routes/suppressions/suppressions.module.ts new file mode 100644 index 0000000..4b210eb --- /dev/null +++ b/backend/src/routes/suppressions/suppressions.module.ts @@ -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 {} diff --git a/backend/src/routes/user/user.controller.ts b/backend/src/routes/user/user.controller.ts index 90b8535..c419ac2 100644 --- a/backend/src/routes/user/user.controller.ts +++ b/backend/src/routes/user/user.controller.ts @@ -10,13 +10,17 @@ 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) @Post('admin') @@ -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); } } diff --git a/backend/src/routes/user/user.module.ts b/backend/src/routes/user/user.module.ts index 62598f8..839763f 100644 --- a/backend/src/routes/user/user.module.ts +++ b/backend/src/routes/user/user.module.ts @@ -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], diff --git a/backend/src/routes/user/user.service.ts b/backend/src/routes/user/user.service.ts index be700a3..50a0c52 100644 --- a/backend/src/routes/user/user.service.ts +++ b/backend/src/routes/user/user.service.ts @@ -520,6 +520,7 @@ export class UserService { } async remove(id: string, currentUser: Users): Promise { + // 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'); } diff --git a/docs/tmp/154-matrice-suppression.md b/docs/tmp/154-matrice-suppression.md new file mode 100644 index 0000000..fc1da2f --- /dev/null +++ b/docs/tmp/154-matrice-suppression.md @@ -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). diff --git a/docs/tmp/154-mini-spec-front-suppression.md b/docs/tmp/154-mini-spec-front-suppression.md new file mode 100644 index 0000000..271b26d --- /dev/null +++ b/docs/tmp/154-mini-spec-front-suppression.md @@ -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 deleteDossier(String numeroDossier); +Future> deleteUser(String userId); +Future> 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) diff --git a/frontend/lib/models/dossier_list_item.dart b/frontend/lib/models/dossier_list_item.dart index 25ec22f..4d35c35 100644 --- a/frontend/lib/models/dossier_list_item.dart +++ b/frontend/lib/models/dossier_list_item.dart @@ -13,6 +13,10 @@ class DossierListItem { 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, @@ -21,8 +25,32 @@ class DossierListItem { this.emails = const [], this.statut, this.photoUrl, + this.sansEnfant = false, + this.enfantsCount, }); + DossierListItem copyWith({ + DossierListType? type, + String? numeroDossier, + String? libelle, + List? 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; @@ -98,6 +126,19 @@ class DossierListItem { } } + final childIds = {}; + 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, @@ -105,6 +146,8 @@ class DossierListItem { libelle: names.isNotEmpty ? names.join(' - ') : 'Famille', emails: emails, statut: _preferStatut(statuts), + enfantsCount: enfantsCount, + sansEnfant: enfantsCount == 0, ), ); } diff --git a/frontend/lib/screens/administrateurs/creation/admin_create.dart b/frontend/lib/screens/administrateurs/creation/admin_create.dart index 1db28b2..1334519 100644 --- a/frontend/lib/screens/administrateurs/creation/admin_create.dart +++ b/frontend/lib/screens/administrateurs/creation/admin_create.dart @@ -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 { Future _delete() async { if (!_isEditMode || _isSubmitting) return; - final confirmed = await showDialog( - 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; diff --git a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart index 9a24de6..38b67e2 100644 --- a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart +++ b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart @@ -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 { List _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 { _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 { if (cached != null) { setState(() { _currentUserId = cached.id; + _currentUserRole = cached.role; }); return; } @@ -132,6 +141,7 @@ class _AdminUserFormDialogState extends State { if (!mounted || refreshed == null) return; setState(() { _currentUserId = refreshed.id; + _currentUserRole = refreshed.role; }); } @@ -378,30 +388,25 @@ class _AdminUserFormDialogState extends State { if (!_canDeleteTarget) return; if (!_isEditMode || _isSubmitting) return; - final confirmed = await showDialog( - 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; diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index 4c078aa..95e5ede 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -646,14 +646,93 @@ class UserService { return enrichEnfantParentNames(enfant); } - static Future deleteEnfant(String enfantId) async { + /// DELETE /enfants/:id?deleteDossier= — ticket #159 / #160. + static Future> 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> 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>> 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((e) => Map.from(e)) + .toList(); + } + + /// Map `numero_dossier` → `sans_enfant` (familles uniquement). + static Future> getSansEnfantByNumero() async { + final rows = await listDossiers(); + final out = {}; + 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 _parseSuppressionBody(String body) { + if (body.trim().isEmpty) return {}; + try { + final decoded = jsonDecode(body); + if (decoded is Map) { + return Map.from(decoded); + } + } catch (_) {} + return {}; } /// AM dont la liste d'enfants actifs contient [enfantId] (API actuelle). @@ -1224,22 +1303,18 @@ class UserService { return AppUser.fromJson(data); } - static Future deleteUser(String userId) async { + /// DELETE /users/:id — cascades métier #159 / #160. + static Future> 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) { - 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); } } diff --git a/frontend/lib/utils/staff_deletion_rights.dart b/frontend/lib/utils/staff_deletion_rights.dart new file mode 100644 index 0000000..eae8a11 --- /dev/null +++ b/frontend/lib/utils/staff_deletion_rights.dart @@ -0,0 +1,53 @@ +/// 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; +} diff --git a/frontend/lib/widgets/admin/admin_management_widget.dart b/frontend/lib/widgets/admin/admin_management_widget.dart index d0a2bed..08d40a3 100644 --- a/frontend/lib/widgets/admin/admin_management_widget.dart +++ b/frontend/lib/widgets/admin/admin_management_widget.dart @@ -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 { String? _error; List _admins = []; String? _currentUserRole; + String? _currentUserId; @override void initState() { @@ -62,6 +65,7 @@ class _AdminManagementWidgetState extends State { if (cached != null) { setState(() { _currentUserRole = (cached.role).toLowerCase(); + _currentUserId = cached.id; }); return; } @@ -69,6 +73,7 @@ class _AdminManagementWidgetState extends State { if (!mounted || refreshed == null) return; setState(() { _currentUserRole = (refreshed.role).toLowerCase(); + _currentUserId = refreshed.id; }); } @@ -79,6 +84,16 @@ class _AdminManagementWidgetState extends State { return _currentUserRole == 'super_admin'; } + bool _canDeleteAdmin(AppUser target) { + return canDeleteAdministrateur( + currentRole: _currentUserRole, + currentUserId: _currentUserId, + targetUserId: target.id, + targetRole: target.role, + adminCount: _admins.length, + ); + } + Future _openAdminEditDialog(AppUser user) async { final canEdit = _canEditAdmin(user); final changed = await showDialog( @@ -98,6 +113,56 @@ class _AdminManagementWidgetState extends State { } } + Future _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 { 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 { _openAdminEditDialog(user); }, ), + if (canDelete) + suppressionIconButton(onPressed: () => _confirmDelete(user)), ], ); }, diff --git a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart index dc28b11..b48212b 100644 --- a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart +++ b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart @@ -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 _assistantes = []; + bool _canDelete = false; @override void initState() { super.initState(); + _loadRights(); _loadAssistantes(); } @override void dispose() => super.dispose(); + Future _loadRights() async { + final user = await AuthService.getCurrentUser(); + if (!mounted) return; + setState(() => _canDelete = canDeleteMetier(user?.role)); + } + Future _loadAssistantes() async { setState(() { _isLoading = true; @@ -57,6 +69,46 @@ class _AssistanteMaternelleManagementWidgetState } } + Future _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), + ), ], ); }, diff --git a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart index 7059c2f..5855e69 100644 --- a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart @@ -69,29 +69,6 @@ class _AdminAmEditModalState extends State static const double _proTabHeight = 300; static const List _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(); @@ -541,17 +518,15 @@ class _AdminAmEditModalState extends State 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, ); } @@ -610,7 +585,9 @@ class _AdminAmEditModalState extends State 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) @@ -659,6 +636,18 @@ class _AdminAmEditModalState extends State ); } + /// 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( @@ -867,15 +856,13 @@ class _AdminAmEditModalState extends State 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(_tabCtrl.index), + child: _buildActiveTabBody(), ), ), ), diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart index eae7d4d..28f3374 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; +import 'package:p_tits_pas/models/dossier_list_item.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/services/auth_service.dart'; @@ -9,11 +10,13 @@ import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/utils/name_format_utils.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_am_photo_frame.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_select_am_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_select_famille_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; @@ -97,8 +100,7 @@ class _AdminChildDetailModalState extends State { _isUnborn ? 'Date prévisionnelle' : 'Date de naissance'; bool get _canDelete => - !widget.isCreating && - (_currentUserRole ?? '').toLowerCase() == 'super_admin'; + !widget.isCreating && canDeleteMetier(_currentUserRole); bool get _busy => _saving || _deleting; @@ -506,37 +508,99 @@ class _AdminChildDetailModalState extends State { if (!_canDelete || _deleting) return; final name = _headerTitle(); - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Supprimer l\'enfant'), - content: Text( - 'Supprimer définitivement la fiche de $name ?\n' - 'Cette action est irréversible.', + final enfant = widget.enfant; + if (enfant == null) return; + + bool deleteDossierFlag = false; + String? numero; + String familleLabel = ''; + bool isLast = false; + + String? amLabel; + final linked = _linkedAm; + if (linked != null) { + final label = formatDossierPersonLabel( + nom: linked.user.nom, + prenom: linked.user.prenom, + email: linked.user.email, + ); + if (label.isNotEmpty) amLabel = label; + } else { + try { + final am = await UserService.findAmForEnfant(enfant.id); + if (am != null) { + final label = formatDossierPersonLabel( + nom: am.user.nom, + prenom: am.user.prenom, + email: am.user.email, + ); + if (label.isNotEmpty) amLabel = label; + } + } catch (_) {} + } + + final parentId = enfant.parentLinks + .map((l) => l.parentId.trim()) + .firstWhere((id) => id.isNotEmpty, orElse: () => ''); + if (parentId.isNotEmpty) { + try { + final parent = await UserService.getParent(parentId); + final num = (parent.user.numeroDossier ?? '').trim(); + numero = num; + familleLabel = parent.user.fullName.isNotEmpty + ? parent.user.fullName + : parent.user.email; + if (num.isNotEmpty) { + final dossier = await UserService.getDossier(num); + if (dossier.isFamily) { + isLast = dossier.asFamily.enfants.length <= 1; + final names = dossier.asFamily.parents + .map((p) => formatDossierPersonLabel( + nom: p.nom, + prenom: p.prenom, + email: p.email, + )) + .where((s) => s.isNotEmpty) + .join(' - '); + if (names.isNotEmpty) familleLabel = names; + } + } + } catch (_) {} + } + + if (!mounted) return; + + if (isLast && (numero ?? '').isNotEmpty) { + final choice = await showDernierEnfantSuppressionDialog( + context, + enfantName: name, + familleLabel: familleLabel, + numeroDossier: numero!, + amLabel: amLabel, + ); + if (choice == null || !mounted) return; + deleteDossierFlag = + choice == DernierEnfantSuppressionChoice.dossierAussi; + } else { + final confirmed = await showSuppressionConfirmDialog( + context, + title: 'Supprimer l\'enfant', + subtitle: (numero ?? '').isEmpty ? null : 'Dossier $numero', + people: [SuppressionPersonLine.enfant(name)], + footnotes: enfantSuppressionFootnotes( + numeroDossier: numero, + familleLabel: familleLabel, + amLabel: amLabel, ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Annuler'), - ), - ElevatedButton( - onPressed: () => Navigator.pop(ctx, true), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red.shade700, - foregroundColor: Colors.white, - ), - child: const Text('Supprimer'), - ), - ], - ), - ); - if (confirmed != true || !mounted) return; + ); + if (!confirmed || !mounted) return; + } setState(() => _deleting = true); try { final id = widget.enfant?.id; if (id == null || id.isEmpty) return; - await UserService.deleteEnfant(id); + await UserService.deleteEnfant(id, deleteDossier: deleteDossierFlag); if (!mounted) return; widget.onDeleted?.call(); widget.onSaved?.call(); @@ -548,7 +612,13 @@ class _AdminChildDetailModalState extends State { if (!mounted) return; setState(() => _deleting = false); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + SnackBar( + content: Text( + e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur suppression', + ), + ), ); } } diff --git a/frontend/lib/widgets/admin/common/suppression_confirm_dialog.dart b/frontend/lib/widgets/admin/common/suppression_confirm_dialog.dart new file mode 100644 index 0000000..38bf785 --- /dev/null +++ b/frontend/lib/widgets/admin/common/suppression_confirm_dialog.dart @@ -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 people; + final List footnotes; + final List 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 people = const [], + List 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 showSuppressionConfirmDialog( + BuildContext context, { + required String title, + String? subtitle, + String? message, + List people = const [], + List footnotes = const [], + String confirmLabel = 'Supprimer', +}) async { + final result = await showDialog( + 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 showDossierSuppressionConfirmDialog( + BuildContext context, { + required String numeroDossier, + required bool isFamille, + required List 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 showDernierEnfantSuppressionDialog( + BuildContext context, { + required String enfantName, + required String familleLabel, + required String numeroDossier, + String? amLabel, +}) { + final am = (amLabel ?? '').trim(); + return showDialog( + 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 enfantSuppressionFootnotes({ + required String? numeroDossier, + required String familleLabel, + String? amLabel, +}) { + final notes = []; + 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 suppressionPeopleFromDossier({ + required bool isFamille, + required List<({String nom, String prenom, String email})> parents, + required List<({String nom, String prenom})> enfants, + String? amName, +}) { + final lines = []; + 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; +} diff --git a/frontend/lib/widgets/admin/dossier_list_card.dart b/frontend/lib/widgets/admin/dossier_list_card.dart index b824eae..7608df2 100644 --- a/frontend/lib/widgets/admin/dossier_list_card.dart +++ b/frontend/lib/widgets/admin/dossier_list_card.dart @@ -1,5 +1,6 @@ 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 { @@ -9,6 +10,12 @@ class DossierListCard extends StatelessWidget { 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); @@ -23,6 +30,10 @@ class DossierListCard extends StatelessWidget { required this.isFamille, required this.onOpen, this.photoUrl, + this.onDelete, + this.vigilanceTooltip, + this.enfantsCount, + this.sansEnfant = false, }); @override @@ -31,23 +42,41 @@ class DossierListCard extends StatelessWidget { 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 = [ + if (names.isNotEmpty) names, + if (emptyKids) 'Sans enfant', + if (!emptyKids && countLabel != null) countLabel, + ]; return AdminUserCard( title: num, - subtitleLines: names.isEmpty ? const [] : [names], + 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: Colors.black87, + 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), ], ); } diff --git a/frontend/lib/widgets/admin/dossiers_management_widget.dart b/frontend/lib/widgets/admin/dossiers_management_widget.dart index 4cafa2f..a616525 100644 --- a/frontend/lib/widgets/admin/dossiers_management_widget.dart +++ b/frontend/lib/widgets/admin/dossiers_management_widget.dart @@ -1,8 +1,11 @@ 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. @@ -27,13 +30,21 @@ class _DossiersManagementWidgetState extends State { List _all = []; Set _pendingNumeros = {}; int _pendingRefreshTick = 0; + bool _canDelete = false; @override void initState() { super.initState(); + _loadRights(); _loadAll(); } + Future _loadRights() async { + final user = await AuthService.getCurrentUser(); + if (!mounted) return; + setState(() => _canDelete = canDeleteMetier(user?.role)); + } + Future _loadAll() async { setState(() { _loading = true; @@ -42,12 +53,29 @@ class _DossiersManagementWidgetState extends State { try { final parents = await UserService.getParents(); final ams = await UserService.getAssistantesMaternelles(); + Map sansEnfant = {}; + try { + sansEnfant = await UserService.getSansEnfantByNumero(); + } catch (_) { + // Flag optionnel : ne bloque pas la liste. + } if (!mounted) return; final items = [ ...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); @@ -95,6 +123,95 @@ class _DossiersManagementWidgetState extends State { ); } + Future _confirmDeleteDossier(DossierListItem item) async { + final num = item.numeroDossier.trim(); + if (num.isEmpty) return; + + var people = []; + 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; @@ -115,6 +232,7 @@ class _DossiersManagementWidgetState extends State { key: ValueKey('pending-$_pendingRefreshTick'), searchQuery: query, compactWhenEmpty: true, + canDelete: _canDelete, onPendingNumerosChanged: (nums) { if (!mounted) return; setState(() => _pendingNumeros = nums); @@ -189,7 +307,15 @@ class _DossiersManagementWidgetState extends State { 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, diff --git a/frontend/lib/widgets/admin/enfant_management_widget.dart b/frontend/lib/widgets/admin/enfant_management_widget.dart index 5ef03f1..6731ff7 100644 --- a/frontend/lib/widgets/admin/enfant_management_widget.dart +++ b/frontend/lib/widgets/admin/enfant_management_widget.dart @@ -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 { bool _isLoading = false; String? _error; List _enfants = []; + bool _canDelete = false; @override void initState() { super.initState(); + _loadRights(); _loadEnfants(); } + Future _loadRights() async { + final user = await AuthService.getCurrentUser(); + if (!mounted) return; + setState(() => _canDelete = canDeleteMetier(user?.role)); + } + Future _loadEnfants() async { setState(() { _isLoading = true; @@ -59,10 +71,133 @@ class _EnfantManagementWidgetState extends State { 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 _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(); @@ -98,6 +233,10 @@ class _EnfantManagementWidgetState extends State { tooltip: 'Voir / modifier', onPressed: () => _openEnfant(enfant), ), + if (_canDelete) + suppressionIconButton( + onPressed: () => _confirmDelete(enfant), + ), ], ); }, diff --git a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart index 1ce89d6..f5cda69 100644 --- a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart +++ b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart @@ -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 _gestionnaires = []; + bool _canDelete = false; + String? _currentUserId; @override void initState() { super.initState(); + _loadRights(); _loadGestionnaires(); } @override void dispose() => super.dispose(); + Future _loadRights() async { + final user = await AuthService.getCurrentUser(); + if (!mounted) return; + setState(() { + _canDelete = canDeleteGestionnaire(user?.role); + _currentUserId = user?.id; + }); + } + Future _loadGestionnaires() async { setState(() { _isLoading = true; @@ -67,6 +82,46 @@ class _GestionnaireManagementWidgetState } } + Future _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)), ], ); }, diff --git a/frontend/lib/widgets/admin/parent_managmant_widget.dart b/frontend/lib/widgets/admin/parent_managmant_widget.dart index 24e0ef5..69b2683 100644 --- a/frontend/lib/widgets/admin/parent_managmant_widget.dart +++ b/frontend/lib/widgets/admin/parent_managmant_widget.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/dossier_list_item.dart'; import 'package:p_tits_pas/models/parent_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_parent_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 ParentManagementWidget extends StatefulWidget { @@ -23,16 +27,24 @@ class _ParentManagementWidgetState extends State { bool _isLoading = false; String? _error; List _parents = []; + bool _canDelete = false; @override void initState() { super.initState(); + _loadRights(); _loadParents(); } @override void dispose() => super.dispose(); + Future _loadRights() async { + final user = await AuthService.getCurrentUser(); + if (!mounted) return; + setState(() => _canDelete = canDeleteMetier(user?.role)); + } + Future _loadParents() async { setState(() { _isLoading = true; @@ -54,6 +66,67 @@ class _ParentManagementWidgetState extends State { } } + bool _isLastParent(ParentModel parent) { + final co = parent.coParent?.id.trim(); + if (co != null && co.isNotEmpty) return false; + final num = (parent.user.numeroDossier ?? '').trim(); + if (num.isEmpty) return true; + return !_parents.any((other) { + if (other.user.id == parent.user.id) return false; + return (other.user.numeroDossier ?? '').trim() == num; + }); + } + + Future _confirmDelete(ParentModel parent) async { + final num = (parent.user.numeroDossier ?? '').trim(); + final name = formatDossierPersonLabel( + nom: parent.user.nom, + prenom: parent.user.prenom, + email: parent.user.email, + ); + final last = _isLastParent(parent); + final enfants = ParentModel.foyerChildrenCount(parent, _parents); + final footnotes = last + ? [ + if (num.isNotEmpty) 'Dernier parent du dossier $num.', + if (num.isEmpty) 'Dernier parent du dossier.', + 'Les $enfants enfant(s) rattaché(s) seront aussi supprimés.', + ] + : [ + if (num.isNotEmpty) + 'Ce parent sera retiré du dossier $num.', + 'Les enfants restent avec le co-parent.', + ]; + + final confirmed = await showSuppressionConfirmDialog( + context, + title: 'Supprimer le parent', + subtitle: num.isEmpty ? null : 'Dossier $num', + people: [SuppressionPersonLine.parent(name)], + footnotes: footnotes, + ); + if (!confirmed || !mounted) return; + + try { + final result = await UserService.deleteUser(parent.user.id); + if (!mounted) return; + final msg = (result['message'] ?? 'Parent supprimé.').toString(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + await _loadParents(); + } 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(); @@ -90,6 +163,8 @@ class _ParentManagementWidgetState extends State { _openParentDetails(parent); }, ), + if (_canDelete) + suppressionIconButton(onPressed: () => _confirmDelete(parent)), ], ); }, diff --git a/frontend/lib/widgets/admin/pending_validation_widget.dart b/frontend/lib/widgets/admin/pending_validation_widget.dart index 78296bd..4a71085 100644 --- a/frontend/lib/widgets/admin/pending_validation_widget.dart +++ b/frontend/lib/widgets/admin/pending_validation_widget.dart @@ -4,6 +4,7 @@ import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/pending_family.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/widgets/admin/dossier_list_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart'; /// Section « dossiers à valider » (liste unifiée AM + familles). Ticket #107 / #153. @@ -15,6 +16,8 @@ class PendingValidationWidget extends StatefulWidget { final bool compactWhenEmpty; /// Numéros des dossiers pending (pour exclure de « Tous les dossiers »). final ValueChanged>? onPendingNumerosChanged; + /// Afficher la poubelle (#160) — mêmes règles que dossiers validés. + final bool canDelete; const PendingValidationWidget({ super.key, @@ -22,6 +25,7 @@ class PendingValidationWidget extends StatefulWidget { this.searchQuery = '', this.compactWhenEmpty = false, this.onPendingNumerosChanged, + this.canDelete = false, }); @override @@ -141,6 +145,97 @@ class _PendingValidationWidgetState extends State { ); } + Future _confirmDeletePending({ + required String numeroDossier, + required String namesLine, + required bool isFamille, + }) async { + final num = numeroDossier.trim(); + if (num.isEmpty) return; + + var people = []; + 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 = isFamille + ? 'Tous les parents et enfants rattachés seront supprimés.' + : 'Le compte AM sera supprimé ; les enfants accueillis ' + 'seront conservés.'; + for (final part in namesLine.split(' - ')) { + final label = part.trim(); + if (label.isEmpty) continue; + people.add(SuppressionPersonLine( + label: label, + icon: isFamille + ? Icons.supervisor_account_outlined + : Icons.face, + role: isFamille ? 'Parent' : 'AM', + )); + } + } + + if (!mounted) return; + final confirmed = await showDossierSuppressionConfirmDialog( + context, + numeroDossier: num, + isFamille: 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 _load(); + widget.onRefresh?.call(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur suppression', + ), + ), + ); + } + } + bool _matchesQuery(String haystack) { final q = widget.searchQuery.trim().toLowerCase(); if (q.isEmpty) return true; @@ -286,12 +381,21 @@ class _PendingValidationWidgetState extends State { } Widget _buildAMCard(AppUser user) { + final names = _amNamesLine(user); + final num = user.numeroDossier ?? ''; return DossierListCard( - numeroDossier: user.numeroDossier ?? '', - namesLine: _amNamesLine(user), + numeroDossier: num, + namesLine: names, isFamille: false, photoUrl: user.photoUrl, onOpen: () => _onOpenValidation(numeroDossier: user.numeroDossier), + onDelete: widget.canDelete + ? () => _confirmDeletePending( + numeroDossier: num, + namesLine: names, + isFamille: false, + ) + : null, ); } @@ -306,6 +410,13 @@ class _PendingValidationWidgetState extends State { namesLine: names, isFamille: true, onOpen: () => _onOpenValidation(numeroDossier: family.numeroDossier), + onDelete: widget.canDelete + ? () => _confirmDeletePending( + numeroDossier: num, + namesLine: names, + isFamille: true, + ) + : null, ); } } diff --git a/frontend/lib/widgets/admin/relais_management_panel.dart b/frontend/lib/widgets/admin/relais_management_panel.dart index 614ecee..f63b5fe 100644 --- a/frontend/lib/widgets/admin/relais_management_panel.dart +++ b/frontend/lib/widgets/admin/relais_management_panel.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:p_tits_pas/models/relais_model.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/services/relais_service.dart'; +import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; class RelaisManagementPanel extends StatefulWidget { const RelaisManagementPanel({super.key}); @@ -56,28 +57,16 @@ class _RelaisManagementPanelState extends State { try { if (result.action == _RelaisDialogAction.delete) { if (relais == null) return; - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Supprimer le relais'), - content: Text('Confirmer la suppression de "${relais.nom}" ?'), - 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 confirmed = await showSuppressionConfirmDialog( + context, + title: 'Supprimer le relais', + people: [SuppressionPersonLine.relais(relais.nom)], + footnotes: const [ + 'Cette action est irréversible.', + ], ); - if (confirmed != true) return; + if (!confirmed) return; await RelaisService.deleteRelais(relais.id); } else if (relais == null) { await RelaisService.createRelais(result.payload!);