From 84e46162fdaa7d5d81d0677a75e693b18166ce75 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 24 Jul 2026 19:41:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(#153):=20onglet=20permanent=20Dossiers=20?= =?UTF-8?q?=E2=80=94=20liste=20unifi=C3=A9e=20+=20pending.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remplace l’onglet conditionnel « À valider » par un onglet Dossiers (pending en haut, familles + AM en dessous), avec GET /dossiers, recherche, et libellés pending NOM Prénom. Co-authored-by: Cursor --- .../dossiers/dossiers.controller.spec.ts | 63 +++ .../routes/dossiers/dossiers.controller.ts | 32 +- .../routes/dossiers/dossiers.service.spec.ts | 142 ++++++ .../src/routes/dossiers/dossiers.service.ts | 157 +++++- .../dossiers/dto/dossier-list-item.dto.ts | 52 ++ .../routes/parents/dto/pending-family.dto.ts | 11 +- backend/src/routes/parents/parents.service.ts | 24 +- docs/tmp/153-contrat-api-liste-dossiers.md | 77 +++ docs/tmp/153-mini-spec-front-dossiers.md | 167 +++++++ frontend/lib/models/dossier_list_item.dart | 196 ++++++++ .../widgets/admin/common/admin_user_card.dart | 10 +- .../lib/widgets/admin/dashboard_admin.dart | 57 ++- .../lib/widgets/admin/dossier_list_card.dart | 54 +++ .../admin/dossiers_management_widget.dart | 202 ++++++++ .../admin/pending_validation_widget.dart | 450 +++++++----------- .../widgets/admin/user_management_panel.dart | 142 +++--- 16 files changed, 1462 insertions(+), 374 deletions(-) create mode 100644 backend/src/routes/dossiers/dossiers.controller.spec.ts create mode 100644 backend/src/routes/dossiers/dossiers.service.spec.ts create mode 100644 backend/src/routes/dossiers/dto/dossier-list-item.dto.ts create mode 100644 docs/tmp/153-contrat-api-liste-dossiers.md create mode 100644 docs/tmp/153-mini-spec-front-dossiers.md create mode 100644 frontend/lib/models/dossier_list_item.dart create mode 100644 frontend/lib/widgets/admin/dossier_list_card.dart create mode 100644 frontend/lib/widgets/admin/dossiers_management_widget.dart diff --git a/backend/src/routes/dossiers/dossiers.controller.spec.ts b/backend/src/routes/dossiers/dossiers.controller.spec.ts new file mode 100644 index 0000000..165e1e3 --- /dev/null +++ b/backend/src/routes/dossiers/dossiers.controller.spec.ts @@ -0,0 +1,63 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DossiersController } from './dossiers.controller'; +import { DossiersService } from './dossiers.service'; +import { AuthGuard } from 'src/common/guards/auth.guard'; +import { RolesGuard } from 'src/common/guards/roles.guard'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; + +describe('DossiersController', () => { + let controller: DossiersController; + const dossiersServiceMock = { + listDossiers: jest.fn(), + getDossierByNumero: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [DossiersController], + providers: [{ provide: DossiersService, useValue: dossiersServiceMock }], + }) + .overrideGuard(AuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(DossiersController); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('list delegates to dossiersService.listDossiers with q', async () => { + dossiersServiceMock.listDossiers.mockResolvedValue([ + { + type: 'famille', + numero_dossier: '2026-000043', + libelle: 'Claire MARTIN', + emails: ['claire@test.fr'], + user_ids: ['u1'], + statut: StatutUtilisateurType.ACTIF, + a_valider: false, + date_reference: null, + }, + ]); + + const res = await controller.list('martin'); + expect(dossiersServiceMock.listDossiers).toHaveBeenCalledWith('martin'); + expect(res).toHaveLength(1); + expect(res[0].numero_dossier).toBe('2026-000043'); + }); + + it('getDossier delegates to getDossierByNumero', async () => { + dossiersServiceMock.getDossierByNumero.mockResolvedValue({ + type: 'family', + dossier: { numero_dossier: '2026-000001' }, + }); + const res = await controller.getDossier('2026-000001'); + expect(dossiersServiceMock.getDossierByNumero).toHaveBeenCalledWith('2026-000001'); + expect(res.type).toBe('family'); + }); +}); diff --git a/backend/src/routes/dossiers/dossiers.controller.ts b/backend/src/routes/dossiers/dossiers.controller.ts index 2f3f677..4849f9b 100644 --- a/backend/src/routes/dossiers/dossiers.controller.ts +++ b/backend/src/routes/dossiers/dossiers.controller.ts @@ -1,18 +1,46 @@ -import { Controller, Get, Param, UseGuards } from '@nestjs/common'; -import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiParam, + ApiQuery, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { Roles } from 'src/common/decorators/roles.decorator'; import { RoleType } from 'src/entities/users.entity'; import { AuthGuard } from 'src/common/guards/auth.guard'; import { RolesGuard } from 'src/common/guards/roles.guard'; import { DossiersService } from './dossiers.service'; import { DossierUnifieDto } from './dto/dossier-unifie.dto'; +import { DossierListItemDto } from './dto/dossier-list-item.dto'; @ApiTags('Dossiers') +@ApiBearerAuth('access-token') @Controller('dossiers') @UseGuards(AuthGuard, RolesGuard) export class DossiersController { constructor(private readonly dossiersService: DossiersService) {} + @Get() + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) + @ApiOperation({ + summary: 'Liste unifiée des dossiers (familles + AM) — ticket #153', + description: + '1 entrée = 1 numero_dossier. Types `famille` | `assistante_maternelle`. ' + + 'Filtre optionnel `q` (n°, nom, email). Tri : à valider d’abord, puis n° décroissant.', + }) + @ApiQuery({ + name: 'q', + required: false, + description: 'Recherche libre : n° dossier, libellé, email…', + }) + @ApiResponse({ status: 200, type: [DossierListItemDto] }) + @ApiResponse({ status: 403, description: 'Accès refusé' }) + list(@Query('q') q?: string): Promise { + return this.dossiersService.listDossiers(q); + } + @Get(':numeroDossier') @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) @ApiOperation({ summary: 'Dossier complet par numéro (AM ou famille) – Ticket #119' }) diff --git a/backend/src/routes/dossiers/dossiers.service.spec.ts b/backend/src/routes/dossiers/dossiers.service.spec.ts new file mode 100644 index 0000000..ecdf772 --- /dev/null +++ b/backend/src/routes/dossiers/dossiers.service.spec.ts @@ -0,0 +1,142 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DossiersService } from './dossiers.service'; +import { Parents } from 'src/entities/parents.entity'; +import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; +import { ParentsService } from '../parents/parents.service'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; + +describe('DossiersService.listDossiers', () => { + let service: DossiersService; + const parentsQb = { + innerJoinAndSelect: jest.fn().mockReturnThis(), + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn(), + }; + const amQb = { + innerJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn(), + }; + const parentsRepo = { + createQueryBuilder: jest.fn(() => parentsQb), + findOne: jest.fn(), + }; + const amRepo = { + createQueryBuilder: jest.fn(() => amQb), + findOne: jest.fn(), + }; + const parentsService = { + getDossierFamilleByNumero: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DossiersService, + { provide: getRepositoryToken(Parents), useValue: parentsRepo }, + { provide: getRepositoryToken(AssistanteMaternelle), useValue: amRepo }, + { provide: ParentsService, useValue: parentsService }, + ], + }).compile(); + + service = module.get(DossiersService); + jest.clearAllMocks(); + parentsRepo.createQueryBuilder.mockReturnValue(parentsQb); + amRepo.createQueryBuilder.mockReturnValue(amQb); + }); + + it('aggregates famille (pivot+co-parent) and AM, sorts a_valider first', async () => { + parentsQb.getMany.mockResolvedValue([ + { + user_id: 'p1', + numero_dossier: '2026-000010', + user: { + id: 'p1', + email: 'claire@test.fr', + prenom: 'Claire', + nom: 'Martin', + statut: StatutUtilisateurType.ACTIF, + cree_le: new Date('2026-01-01'), + }, + co_parent: { + id: 'p2', + email: 'thomas@test.fr', + prenom: 'Thomas', + nom: 'Martin', + statut: StatutUtilisateurType.ACTIF, + cree_le: new Date('2026-01-02'), + }, + }, + { + user_id: 'p3', + numero_dossier: '2026-000020', + user: { + id: 'p3', + email: 'pending@test.fr', + prenom: 'Paul', + nom: 'Pending', + statut: StatutUtilisateurType.EN_ATTENTE, + cree_le: new Date('2026-02-01'), + }, + co_parent: undefined, + }, + ]); + amQb.getMany.mockResolvedValue([ + { + user_id: 'am1', + numero_dossier: '2026-000015', + user: { + id: 'am1', + email: 'am@test.fr', + prenom: 'Marie', + nom: 'Dupont', + statut: StatutUtilisateurType.ACTIF, + cree_le: new Date('2026-01-15'), + }, + }, + ]); + + const list = await service.listDossiers(); + expect(list).toHaveLength(3); + expect(list[0].a_valider).toBe(true); + expect(list[0].type).toBe('famille'); + expect(list[0].numero_dossier).toBe('2026-000020'); + + const famille = list.find((i) => i.numero_dossier === '2026-000010')!; + expect(famille.type).toBe('famille'); + expect(famille.user_ids).toEqual(expect.arrayContaining(['p1', 'p2'])); + expect(famille.emails).toHaveLength(2); + expect(famille.libelle).toContain('MARTIN'); + + const am = list.find((i) => i.type === 'assistante_maternelle')!; + expect(am.numero_dossier).toBe('2026-000015'); + expect(am.libelle).toContain('Marie'); + }); + + it('filters with q', async () => { + parentsQb.getMany.mockResolvedValue([]); + amQb.getMany.mockResolvedValue([ + { + user_id: 'am1', + numero_dossier: '2026-000015', + user: { + id: 'am1', + email: 'am@test.fr', + prenom: 'Marie', + nom: 'Dupont', + statut: StatutUtilisateurType.ACTIF, + cree_le: new Date('2026-01-15'), + }, + }, + ]); + + const hit = await service.listDossiers('dupont'); + expect(hit).toHaveLength(1); + const miss = await service.listDossiers('zzz'); + expect(miss).toHaveLength(0); + }); +}); diff --git a/backend/src/routes/dossiers/dossiers.service.ts b/backend/src/routes/dossiers/dossiers.service.ts index f9715e6..cfc4711 100644 --- a/backend/src/routes/dossiers/dossiers.service.ts +++ b/backend/src/routes/dossiers/dossiers.service.ts @@ -3,12 +3,14 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Parents } from 'src/entities/parents.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; +import { StatutUtilisateurType, Users } from 'src/entities/users.entity'; import { ParentsService } from '../parents/parents.service'; import { DossierUnifieDto } from './dto/dossier-unifie.dto'; import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto'; +import { DossierListItemDto } from './dto/dossier-list-item.dto'; /** - * Endpoint unifié GET /dossiers/:numeroDossier – AM ou famille. Ticket #119. + * Dossiers unifiés — détail (#119) + liste (#153). */ @Injectable() export class DossiersService { @@ -20,6 +22,159 @@ export class DossiersService { private readonly parentsService: ParentsService, ) {} + /** + * Liste unifiée tous dossiers (familles + AM) ayant un numero_dossier. + * Ticket #153 — optionnel `q` filtre n° / nom / prénom / email (côté serveur). + */ + async listDossiers(q?: string): Promise { + const items: DossierListItemDto[] = [ + ...(await this.listFamilleItems()), + ...(await this.listAmItems()), + ]; + + const needle = (q ?? '').trim().toLowerCase(); + const filtered = needle + ? items.filter((item) => this.matchesQuery(item, needle)) + : items; + + filtered.sort((a, b) => { + // À valider d'abord, puis n° dossier décroissant + if (a.a_valider !== b.a_valider) return a.a_valider ? -1 : 1; + return b.numero_dossier.localeCompare(a.numero_dossier, 'fr'); + }); + + return filtered; + } + + private async listFamilleItems(): Promise { + const parents = await this.parentsRepository + .createQueryBuilder('p') + .innerJoinAndSelect('p.user', 'u') + .leftJoinAndSelect('p.co_parent', 'cp') + .where('p.numero_dossier IS NOT NULL') + .andWhere("TRIM(p.numero_dossier) <> ''") + .getMany(); + + const byNum = new Map(); + for (const p of parents) { + const num = (p.numero_dossier ?? '').trim(); + if (!num) continue; + const group = byNum.get(num) ?? []; + group.push(p); + byNum.set(num, group); + } + + const items: DossierListItemDto[] = []; + for (const [numero_dossier, group] of byNum) { + const usersMap = new Map(); + for (const p of group) { + if (p.user) usersMap.set(p.user.id, p.user); + if (p.co_parent) usersMap.set(p.co_parent.id, p.co_parent); + } + const users = [...usersMap.values()].sort((a, b) => { + const an = `${a.nom ?? ''} ${a.prenom ?? ''}`.toLowerCase(); + const bn = `${b.nom ?? ''} ${b.prenom ?? ''}`.toLowerCase(); + return an.localeCompare(bn, 'fr') || a.id.localeCompare(b.id); + }); + if (users.length === 0) continue; + + const names = users.map((u) => this.formatPersonName(u)).filter(Boolean); + const libelle = + names.length === 0 + ? `Dossier ${numero_dossier}` + : names.length === 1 + ? names[0] + : names.join(' & '); + + const emails = users.map((u) => u.email).filter(Boolean); + const user_ids = users.map((u) => u.id); + const a_valider = users.some((u) => u.statut === StatutUtilisateurType.EN_ATTENTE); + const statut = a_valider + ? StatutUtilisateurType.EN_ATTENTE + : (users[0].statut ?? StatutUtilisateurType.ACTIF); + const date_reference = this.minCreeLeIso(users); + + items.push({ + type: 'famille', + numero_dossier, + libelle, + emails, + user_ids, + statut, + a_valider, + date_reference, + }); + } + return items; + } + + private async listAmItems(): Promise { + const ams = await this.amRepository + .createQueryBuilder('am') + .innerJoinAndSelect('am.user', 'u') + .where('am.numero_dossier IS NOT NULL') + .andWhere("TRIM(am.numero_dossier) <> ''") + .getMany(); + + const byNum = new Map(); + for (const am of ams) { + const num = (am.numero_dossier ?? '').trim(); + if (!num || !am.user) continue; + // Un n° = une AM ; garder le premier + if (!byNum.has(num)) byNum.set(num, am); + } + + const items: DossierListItemDto[] = []; + for (const [numero_dossier, am] of byNum) { + const u = am.user!; + const libelle = this.formatPersonName(u) || `AM ${numero_dossier}`; + const a_valider = u.statut === StatutUtilisateurType.EN_ATTENTE; + items.push({ + type: 'assistante_maternelle', + numero_dossier, + libelle, + emails: u.email ? [u.email] : [], + user_ids: [u.id], + statut: u.statut ?? StatutUtilisateurType.ACTIF, + a_valider, + date_reference: this.minCreeLeIso([u]), + }); + } + return items; + } + + private matchesQuery(item: DossierListItemDto, needle: string): boolean { + const hay = [ + item.numero_dossier, + item.libelle, + ...item.emails, + item.statut, + item.type, + ] + .join(' ') + .toLowerCase(); + return hay.includes(needle); + } + + private formatPersonName(u: Users): string { + const prenom = (u.prenom ?? '').trim(); + const nom = (u.nom ?? '').trim(); + const nomFmt = nom ? nom.toUpperCase() : ''; + return [prenom, nomFmt].filter(Boolean).join(' '); + } + + private minCreeLeIso(users: Users[]): string | null { + let min: Date | null = null; + for (const u of users) { + const d = u.cree_le; + if (!d) continue; + const date = d instanceof Date ? d : new Date(d); + if (Number.isNaN(date.getTime())) continue; + if (!min || date < min) min = date; + } + return min ? min.toISOString() : null; + } + async getDossierByNumero(numeroDossier: string): Promise { const num = numeroDossier?.trim(); if (!num) { diff --git a/backend/src/routes/dossiers/dto/dossier-list-item.dto.ts b/backend/src/routes/dossiers/dto/dossier-list-item.dto.ts new file mode 100644 index 0000000..c510f7e --- /dev/null +++ b/backend/src/routes/dossiers/dto/dossier-list-item.dto.ts @@ -0,0 +1,52 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; + +/** Ligne de liste GET /dossiers (#153). */ +export class DossierListItemDto { + @ApiProperty({ + enum: ['famille', 'assistante_maternelle'], + description: 'Type de dossier', + }) + type: 'famille' | 'assistante_maternelle'; + + @ApiProperty({ example: '2026-000043' }) + numero_dossier: string; + + @ApiProperty({ + example: 'Claire MARTIN & Thomas MARTIN', + description: 'Libellé affiché (noms)', + }) + libelle: string; + + @ApiProperty({ + type: [String], + example: ['claire@example.com', 'thomas@example.com'], + }) + emails: string[]; + + @ApiProperty({ + type: [String], + format: 'uuid', + description: 'IDs utilisateur liés au dossier (parents du foyer ou AM)', + }) + user_ids: string[]; + + @ApiProperty({ + enum: StatutUtilisateurType, + description: + 'Statut agrégé : en_attente si au moins un user en_attente, sinon statut du premier', + }) + statut: StatutUtilisateurType; + + @ApiProperty({ + description: 'True si le dossier est en attente de validation (section haute UI)', + }) + a_valider: boolean; + + @ApiPropertyOptional({ + nullable: true, + example: '2026-01-12T10:00:00.000Z', + description: 'Date de référence (MIN cree_le des users du dossier)', + }) + date_reference: string | null; +} diff --git a/backend/src/routes/parents/dto/pending-family.dto.ts b/backend/src/routes/parents/dto/pending-family.dto.ts index 86389b2..68dd50c 100644 --- a/backend/src/routes/parents/dto/pending-family.dto.ts +++ b/backend/src/routes/parents/dto/pending-family.dto.ts @@ -7,6 +7,12 @@ export class ParentPendingSummaryDto { @ApiProperty() email: string; + @ApiPropertyOptional({ nullable: true }) + nom?: string | null; + + @ApiPropertyOptional({ nullable: true }) + prenom?: string | null; + @ApiPropertyOptional({ nullable: true }) telephone?: string | null; @@ -18,7 +24,10 @@ export class ParentPendingSummaryDto { } export class PendingFamilyDto { - @ApiProperty({ example: 'Famille Dupont', description: 'Libellé affiché pour la famille' }) + @ApiProperty({ + example: 'MARTIN Claire - MARTIN Thomas', + description: 'Libellé affiché : NOM Prénom (séparés par « - » si co-parent)', + }) libelle: string; @ApiProperty({ diff --git a/backend/src/routes/parents/parents.service.ts b/backend/src/routes/parents/parents.service.ts index 15c0581..77dc3c6 100644 --- a/backend/src/routes/parents/parents.service.ts +++ b/backend/src/routes/parents/parents.service.ts @@ -251,7 +251,15 @@ export class ParentsService { SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id ) SELECT - 'Famille ' || string_agg(u.nom, ' - ' ORDER BY u.nom, u.prenom) AS libelle, + string_agg( + UPPER(TRIM(u.nom)) + || CASE + WHEN u.prenom IS NOT NULL AND TRIM(u.prenom) <> '' + THEN ' ' || INITCAP(TRIM(u.prenom)) + ELSE '' + END, + ' - ' ORDER BY u.nom, u.prenom, u.id + ) AS libelle, array_agg(p.id_utilisateur ORDER BY u.nom, u.prenom, u.id) AS "parentIds", (array_agg(p.numero_dossier))[1] AS numero_dossier, MIN(u.cree_le) AS date_soumission, @@ -267,6 +275,8 @@ export class ParentsService { json_build_object( 'id', u.id::text, 'email', u.email, + 'nom', u.nom, + 'prenom', u.prenom, 'telephone', u.telephone, 'code_postal', u.code_postal, 'ville', u.ville @@ -317,11 +327,21 @@ export class ParentsService { return []; } - private normalizeParents(parents: unknown): { id: string; email: string; telephone: string | null; code_postal: string | null; ville: string | null }[] { + private normalizeParents(parents: unknown): { + id: string; + email: string; + nom: string | null; + prenom: string | null; + telephone: string | null; + code_postal: string | null; + ville: string | null; + }[] { if (Array.isArray(parents)) { return parents.map((p: any) => ({ id: String(p?.id ?? ''), email: String(p?.email ?? ''), + nom: p?.nom != null ? String(p.nom) : null, + prenom: p?.prenom != null ? String(p.prenom) : null, telephone: p?.telephone != null ? String(p.telephone) : null, code_postal: p?.code_postal != null ? String(p.code_postal) : null, ville: p?.ville != null ? String(p.ville) : null, diff --git a/docs/tmp/153-contrat-api-liste-dossiers.md b/docs/tmp/153-contrat-api-liste-dossiers.md new file mode 100644 index 0000000..b427a7f --- /dev/null +++ b/docs/tmp/153-contrat-api-liste-dossiers.md @@ -0,0 +1,77 @@ +# Mini-spec API — GET /dossiers (#153) + +Contrat pour le **plan front** (onglet permanent Dossiers). + +## Endpoint + +| | | +|--|--| +| **Méthode** | `GET` | +| **URL** | `{base}/api/v1/dossiers` | +| **Auth** | Bearer JWT | +| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` | +| **Query** | `q` (optionnel) — recherche n° / libellé / email | + +Complète `GET /dossiers/:numeroDossier` (#119) déjà existant. + +--- + +## Réponse 200 + +Tableau de lignes (1 entrée = 1 `numero_dossier`) : + +```json +[ + { + "type": "famille", + "numero_dossier": "2026-000043", + "libelle": "Claire MARTIN & Thomas MARTIN", + "emails": ["claire@test.fr", "thomas@test.fr"], + "user_ids": ["uuid-pivot", "uuid-co"], + "statut": "actif", + "a_valider": false, + "date_reference": "2026-01-12T10:00:00.000Z" + }, + { + "type": "assistante_maternelle", + "numero_dossier": "2026-000042", + "libelle": "Marie DUPONT", + "emails": ["marie@test.fr"], + "user_ids": ["uuid-am"], + "statut": "en_attente", + "a_valider": true, + "date_reference": "2026-02-01T08:00:00.000Z" + } +] +``` + +### Champs + +| Champ | Notes | +|-------|--------| +| `type` | `famille` \| `assistante_maternelle` | +| `numero_dossier` | Clé d’unité | +| `libelle` | Noms formatés (foyer : `A & B`) | +| `emails` / `user_ids` | Membres du foyer ou AM | +| `statut` | Agrégé : `en_attente` si au moins un user pending | +| `a_valider` | `true` si pending → section haute UI | +| `date_reference` | `MIN(cree_le)` des users | + +**Tri** : `a_valider` d’abord, puis `numero_dossier` décroissant. + +**Famille** : dédupliquée par `numero_dossier` (pivot + co-parent = 1 ligne). + +--- + +## Front + +- `UserService.getDossiers({ q? })` → cet endpoint +- Section haute : filtrer `a_valider == true` **ou** continuer pending APIs existantes +- Section basse : liste complète (ou hors pending selon règle UX) +- Clic → `GET /dossiers/:numero` (détail) / validation review + +Composition client `getParents`+`getAM` **plus nécessaire** si cet endpoint est déployé. + +## Branche + +`feature/153-onglet-dossiers` diff --git a/docs/tmp/153-mini-spec-front-dossiers.md b/docs/tmp/153-mini-spec-front-dossiers.md new file mode 100644 index 0000000..9e4f8f6 --- /dev/null +++ b/docs/tmp/153-mini-spec-front-dossiers.md @@ -0,0 +1,167 @@ +# Mini-spec front — Onglet permanent « Dossiers » (#153) + +Branche Git (front + back) : `feature/153-onglet-dossiers` +Ticket Gitea : **#153** (ticket normal, plus epic) + +> Suite prévue : **#135** = au clic, mode **édition** wizard + ajout 2ᵉ parent. +> **#153** = onglet + listes + navigation / validation pending. **Pas** de création, **pas** d’édition complète. + +--- + +## Contexte / objectif + +Remplacer l’onglet conditionnel **« À valider »** (apparaît/disparaît selon pending) par un onglet **permanent « Dossiers »** dans le dashboard admin/gestionnaire. + +Quand on ouvre **Dossiers** : + +1. **En haut** — section **Dossiers à valider** (AM + familles pending) +2. **En dessous** — liste de **tous les dossiers** (familles **et** AM), 1 ligne = 1 `numero_dossier` +3. Différenciation visuelle famille vs AM : **couleur + icône** +4. **Barre de recherche** (n° dossier, nom, email…) + +**Pas** de bouton « Créer un dossier » ici (création via **+ Parents** #129 / **+ Asmat** #156). + +--- + +## UX cible + +### Onglets dashboard (`UserManagementPanel`) + +| Avant (#107) | Après (#153) | +|--------------|--------------| +| « À valider » **conditionnel** si pending | **« Dossiers » toujours visible** (admin + gestionnaire) | +| Contenu = seulement pending | Pending **en haut** + liste complète **en bas** | + +Ordre suggéré des onglets : + +`Dossiers` | `Parents` | `Enfants` | `Assistantes maternelles` | `Gestionnaires` | (`Administrateurs`) + +### Section haute — À valider + +- Réutiliser / adapter `PendingValidationWidget` (ou extraire la liste dans un sous-widget). +- Sources déjà branchées : + - `UserService.getPendingUsers(role: 'assistante_maternelle')` + - `UserService.getPendingFamilies()` +- Clic ligne pending → **`ValidationDossierModal`** / wizards `.review` (inchangé). +- Si section vide : ne pas afficher de gros vide ; masquer la section ou message court « Aucun dossier en attente ». + +### Section basse — Tous les dossiers + +1 ligne = **1 dossier** (`numero_dossier`), type : + +| Type | Libellé UI | Couleur (suggestion) | +|------|------------|----------------------| +| `famille` | Famille / Parents | teinte existante parents (ex. violet / rose dashboard) | +| `assistante_maternelle` | AM | teinte existante AM (ex. teal / bleu) | + +Colonnes / infos utiles (cartes style `AdminUserCard` ou lignes type pending) : + +- n° dossier +- type (pastille couleur + icône) +- libellé (noms parents ou AM) +- email(s) principal(aux) +- statut user / dossier si dispo (`actif`, `en_attente`, …) +- date utile si dispo + +**Déduplication** : un foyer (pivot + co-parent) = **une** ligne famille (même `numero_dossier`). Idem AM. + +### Recherche + +- La search bar du panel (aujourd’hui désactivée / hint « pas de recherche » sur À valider) doit **filtrer la liste unifiée** (et idéalement aussi le pending affiché). +- Critères **minimum** : `numero_dossier`, nom, prénom, email. +- Harmoniser le hint : `Rechercher un dossier (n°, nom, email)…` + +### État vide liste complète + +Aide optionnelle : *« Pour créer un dossier → onglet Parents (+ Parents) ou Assistantes maternelles (+ Asmat) »*. + +### Clic sur un dossier de la liste complète (#153) + +| Cas | Comportement #153 | +|-----|-------------------| +| Pending | Ouvrir validation (review) — déjà en place | +| Dossier **actif** / non pending | Ouvrir consultation via `GET /dossiers/:numeroDossier` (`UserService.getDossierByNumero`) en **lecture / review** si possible **sans** save édition | + +**Ne pas** implémenter le mode `edit` ni le switch 2ᵉ parent → **#135**. + +Si l’ouverture « review » d’un dossier actif est trop lourde pour ce ticket : clic peut temporairement no-op / snackbar *« Édition dossier : prochainement (#135) »* — **à éviter** si `getDossierByNumero` + wizard review marche déjà pour les deux types. + +--- + +## Données / APIs (front) + +### Déjà disponibles (préférer composer côté front pour #153) + +| Besoin | API / service | +|--------|----------------| +| Pending AM | `getPendingUsers(role: assistante_maternelle)` | +| Pending familles | `getPendingFamilies()` | +| Parents (avec `numero_dossier`) | `getParents()` | +| AM (avec `numero_dossier`) | `getAssistantesMaternelles()` | +| Détail unifié | `getDossierByNumero(numero)` → `GET /dossiers/:numeroDossier` | + +**Pas d’endpoint `GET /dossiers` liste** aujourd’hui. Pour #153 : + +- Construire la liste unifiée **côté client** à partir de `getParents()` + `getAssistantesMaternelles()` (group by `numero_dossier`). +- Exclure ou marquer les pending déjà dans la section haute (éviter doublons visuels, ou les laisser dans les deux avec badge « à valider » — **préférence** : pending **uniquement** en haut ; liste basse = tous **hors** pending **ou** tous avec badge ; choisir une règle claire et documenter dans le PR). + +**Règle recommandée** : +- Haut = pending only +- Bas = **tous** les dossiers ayant un `numero_dossier` (y compris pending) **OU** bas = non-pending only +→ **Recommandation produit** : bas = **tous** (vision complète), pending aussi en haut pour action rapide. Si doublon gênant : bas = non-pending only. + +### Si le back ajoute plus tard `GET /dossiers` + +Brancher `UserService.getDossiers()` — hors scope bloquant #153 front si composition client OK. + +--- + +## Fichiers front probables + +| Fichier | Rôle | +|---------|------| +| `frontend/lib/widgets/admin/user_management_panel.dart` | Onglet permanent **Dossiers** ; retirer logique conditionnelle À valider ; search sur cet onglet | +| `frontend/lib/widgets/admin/pending_validation_widget.dart` | Réemploi section haute (ou refactor léger) | +| **Nouveau** `…/dossiers_management_widget.dart` (nom libre) | Shell onglet : pending + liste unifiée + refresh | +| **Nouveau** modèle léger `DossierListItem` (type, numero, libelle, emails, statut…) | Mapping parents/AM → ligne | +| `user_service.dart` / `api_config.dart` | Seulement si helper `getDossiersUnified()` côté client (pas forcément nouvel endpoint) | +| `validation_dossier_modal.dart` | Réemploi ouverture pending / détail | + +Réutiliser look & feel cartes / hover « Ouvrir » de `_PendingValidationRow` / `AdminUserCard`. + +--- + +## Hors scope (#153) + +- Bouton créer dossier +- Mode `edit` wizard + ajout 2ᵉ parent → **#135** +- Suppressions → **#154** +- Famille N responsables → **#139** +- Changer les onglets Parents / AM / Enfants (restent) + +--- + +## Critères d’acceptation front + +- [ ] Onglet **Dossiers** toujours visible (même 0 pending) +- [ ] Plus d’onglet conditionnel **« À valider »** +- [ ] Section haute pending si non vide ; validation au clic OK +- [ ] Liste unifiée familles + AM en dessous ; 1 ligne / `numero_dossier` +- [ ] Couleur + icône différencient famille / AM +- [ ] Recherche filtre (n° + nom + email minimum) +- [ ] **Aucun** bouton créer dans cet onglet +- [ ] Pas de régression validation pending (valider / refuser) + +--- + +## Back (info — Cursor back séparé si besoin) + +- Liste unifiée : **pas bloquante** si composition front +- Optionnel : `GET /api/v1/dossiers` (liste) pour perf / pagination plus tard +- `GET /dossiers/:numero` déjà là (#119) + +--- + +## Branche + +`feature/153-onglet-dossiers` (depuis `develop`) diff --git a/frontend/lib/models/dossier_list_item.dart b/frontend/lib/models/dossier_list_item.dart new file mode 100644 index 0000000..25ec22f --- /dev/null +++ b/frontend/lib/models/dossier_list_item.dart @@ -0,0 +1,196 @@ +import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; +import 'package:p_tits_pas/models/parent_model.dart'; +import 'package:p_tits_pas/utils/name_format_utils.dart'; + +/// Ligne de liste unifiée dossiers (famille ou AM) — ticket #153. +enum DossierListType { famille, assistanteMaternelle } + +class DossierListItem { + final DossierListType type; + final String numeroDossier; + final String libelle; + final List emails; + final String? statut; + /// Photo profil (AM) — affichée à la place de l’icône si présente. + final String? photoUrl; + + const DossierListItem({ + required this.type, + required this.numeroDossier, + required this.libelle, + this.emails = const [], + this.statut, + this.photoUrl, + }); + + bool get isFamille => type == DossierListType.famille; + bool get isAm => type == DossierListType.assistanteMaternelle; + + String get typeLabel => isFamille ? 'Famille' : 'AM'; + + /// Sous-titre carte : `NOM Prénom` ou `NOM Prénom - NOM Prénom`. + String get namesLine => libelle; + + String get emailsLine => emails.where((e) => e.trim().isNotEmpty).join(' · '); + + /// Titre carte : numéro de dossier seul. + String get titleLine => numeroDossier; + + bool matchesQuery(String query) { + final q = query.trim().toLowerCase(); + if (q.isEmpty) return true; + if (numeroDossier.toLowerCase().contains(q)) return true; + if (libelle.toLowerCase().contains(q)) return true; + for (final e in emails) { + if (e.toLowerCase().contains(q)) return true; + } + return false; + } + + /// Une ligne par `numero_dossier` (foyer dédupliqué). + static List fromParents(List parents) { + final byDossier = >{}; + for (final p in parents) { + final num = (p.user.numeroDossier ?? '').trim(); + if (num.isEmpty) continue; + byDossier.putIfAbsent(num, () => []).add(p); + } + + final items = []; + for (final entry in byDossier.entries) { + final seenIds = {}; + final names = []; + final emails = []; + final statuts = []; + + void consider( + String? id, + String? nom, + String? prenom, + String? email, + String? statut, + ) { + final uid = (id ?? '').trim(); + if (uid.isEmpty || !seenIds.add(uid)) return; + final label = formatDossierPersonLabel( + nom: nom, + prenom: prenom, + email: email, + ); + if (label.isNotEmpty) names.add(label); + final e = (email ?? '').trim(); + if (e.isNotEmpty) emails.add(e); + final s = (statut ?? '').trim(); + if (s.isNotEmpty) statuts.add(s); + } + + for (final p in entry.value) { + consider( + p.user.id, + p.user.nom, + p.user.prenom, + p.user.email, + p.user.statut, + ); + final co = p.coParent; + if (co != null) { + consider(co.id, co.nom, co.prenom, co.email, co.statut); + } + } + + items.add( + DossierListItem( + type: DossierListType.famille, + numeroDossier: entry.key, + libelle: names.isNotEmpty ? names.join(' - ') : 'Famille', + emails: emails, + statut: _preferStatut(statuts), + ), + ); + } + return items; + } + + static List fromAssistantes( + List ams, + ) { + final byDossier = {}; + for (final am in ams) { + final num = (am.user.numeroDossier ?? '').trim(); + if (num.isEmpty) continue; + byDossier.putIfAbsent(num, () => am); + } + + return byDossier.entries.map((e) { + final u = e.value.user; + final name = formatDossierPersonLabel( + nom: u.nom, + prenom: u.prenom, + email: u.email, + ); + final photo = (u.photoUrl ?? '').trim(); + return DossierListItem( + type: DossierListType.assistanteMaternelle, + numeroDossier: e.key, + libelle: name.isNotEmpty ? name : 'AM', + emails: u.email.trim().isEmpty ? const [] : [u.email.trim()], + statut: u.statut?.trim(), + photoUrl: photo.isEmpty ? null : photo, + ); + }).toList(); + } + + /// Priorité affichage : en_attente > suspendu > refuse > actif > autre. + static String? _preferStatut(List raw) { + if (raw.isEmpty) return null; + const order = ['en_attente', 'suspendu', 'refuse', 'actif']; + for (final wanted in order) { + for (final s in raw) { + if (s.toLowerCase() == wanted) return s; + } + } + return raw.first; + } +} + +/// Affichage carte dossier : `NOM Prénom` (repli email). +String formatDossierPersonLabel({ + String? nom, + String? prenom, + String? email, +}) { + final n = (nom ?? '').trim().toUpperCase(); + final p = formatPersonNameCase(prenom ?? ''); + if (n.isNotEmpty && p.isNotEmpty) return '$n $p'; + if (n.isNotEmpty) return n; + if (p.isNotEmpty) return p; + return (email ?? '').trim(); +} + +/// Reformate un libellé famille API (`A & B` / `Famille …`) en `NOM Prénom - …`. +String formatDossierFamilyNamesLine(String libelle) { + var raw = libelle.trim(); + if (raw.isEmpty) return ''; + raw = raw.replaceFirst(RegExp(r'^famille\s+', caseSensitive: false), ''); + raw = raw + .replaceAll(RegExp(r'\s+&\s+'), ' - ') + .replaceAll(RegExp(r'\s+et\s+', caseSensitive: false), ' - '); + final parts = raw + .split(RegExp(r'\s+-\s+')) + .map((part) => _formatLoosePersonSegment(part.trim())) + .where((s) => s.isNotEmpty) + .toList(); + return parts.join(' - '); +} + +/// Segment libre type « martin sophie » ou « DURAND Amélie » → `NOM Prénom`. +String _formatLoosePersonSegment(String segment) { + final words = + segment.split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList(); + if (words.isEmpty) return ''; + if (words.length == 1) return words.first.toUpperCase(); + // Convention affichage : premier mot = NOM, reste = prénom(s). + final nom = words.first.toUpperCase(); + final prenom = formatPersonNameCase(words.sublist(1).join(' ')); + return '$nom $prenom'; +} diff --git a/frontend/lib/widgets/admin/common/admin_user_card.dart b/frontend/lib/widgets/admin/common/admin_user_card.dart index f7cbc1b..640d293 100644 --- a/frontend/lib/widgets/admin/common/admin_user_card.dart +++ b/frontend/lib/widgets/admin/common/admin_user_card.dart @@ -12,6 +12,10 @@ class AdminUserCard extends StatefulWidget { final Color? backgroundColor; final Color? titleColor; final Color? infoColor; + /// Fond du cercle avatar / icône (défaut lavande admin). + final Color? avatarBackgroundColor; + /// Couleur de l’icône fallback (défaut violet admin). + final Color? avatarIconColor; final String? vigilanceTooltip; final VoidCallback? onCardTap; final EdgeInsetsGeometry? margin; @@ -28,6 +32,8 @@ class AdminUserCard extends StatefulWidget { this.backgroundColor, this.titleColor, this.infoColor, + this.avatarBackgroundColor, + this.avatarIconColor, this.vigilanceTooltip, this.onCardTap, this.margin, @@ -159,8 +165,8 @@ class _AdminUserCardState extends State { Widget _buildAvatar(String url) { const size = 28.0; - const bg = Color(0xFFEDE5FA); - const iconColor = Color(0xFF6B3FA0); + final bg = widget.avatarBackgroundColor ?? const Color(0xFFEDE5FA); + final iconColor = widget.avatarIconColor ?? const Color(0xFF6B3FA0); if (url.isEmpty) { return CircleAvatar( diff --git a/frontend/lib/widgets/admin/dashboard_admin.dart b/frontend/lib/widgets/admin/dashboard_admin.dart index 47c0983..b0cc6fa 100644 --- a/frontend/lib/widgets/admin/dashboard_admin.dart +++ b/frontend/lib/widgets/admin/dashboard_admin.dart @@ -8,6 +8,8 @@ class DashboardUserManagementSubBar extends StatelessWidget { final ValueChanged onSubTabChange; final TextEditingController searchController; final String searchHint; + /// Infobulle au survol de la barre de recherche (ex. critères de recherche). + final String? searchTooltip; final Widget? filterControl; final VoidCallback? onAddPressed; final String addLabel; @@ -22,12 +24,16 @@ class DashboardUserManagementSubBar extends StatelessWidget { 'Administrateurs', ]; + /// Aligné sur la taille des libellés d’onglets. + static const double _searchFontSize = 13; + const DashboardUserManagementSubBar({ Key? key, required this.selectedSubIndex, required this.onSubTabChange, required this.searchController, required this.searchHint, + this.searchTooltip, this.filterControl, this.onAddPressed, this.addLabel = '+ Ajouter', @@ -54,22 +60,7 @@ class DashboardUserManagementSubBar extends StatelessWidget { _buildSubNavItem(context, labels[i], i), ], const SizedBox(width: 36), - _pillField( - width: 320, - child: TextField( - controller: searchController, - decoration: InputDecoration( - hintText: searchHint, - prefixIcon: const Icon(Icons.search, size: 18), - border: InputBorder.none, - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 8, - ), - ), - ), - ), + _buildSearchField(), if (filterControl != null) ...[ const SizedBox(width: 12), _pillField(width: 150, child: filterControl!), @@ -81,6 +72,40 @@ class DashboardUserManagementSubBar extends StatelessWidget { ); } + Widget _buildSearchField() { + final field = _pillField( + width: 320, + child: TextField( + controller: searchController, + style: const TextStyle(fontSize: _searchFontSize), + decoration: InputDecoration( + hintText: searchHint, + hintStyle: TextStyle( + fontSize: _searchFontSize, + fontStyle: FontStyle.normal, + fontWeight: FontWeight.normal, + color: Colors.black45, + ), + prefixIcon: const Icon(Icons.search, size: 18), + border: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + ), + ), + ); + final tip = (searchTooltip ?? '').trim(); + if (tip.isEmpty) return field; + return Tooltip( + message: tip, + preferBelow: false, + waitDuration: const Duration(milliseconds: 400), + child: field, + ); + } + Widget _pillField({required double width, required Widget child}) { return Container( width: width, diff --git a/frontend/lib/widgets/admin/dossier_list_card.dart b/frontend/lib/widgets/admin/dossier_list_card.dart new file mode 100644 index 0000000..b824eae --- /dev/null +++ b/frontend/lib/widgets/admin/dossier_list_card.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; + +/// Carte dossier unifiée (#153) — fond neutre, accent couleur sur l’icône. +class DossierListCard extends StatelessWidget { + final String numeroDossier; + final String namesLine; + final bool isFamille; + final VoidCallback onOpen; + /// Photo AM (si absente → icône fallback). + final String? photoUrl; + + /// Lavande — Famille / Parents. + static const Color familleAccent = Color(0xFFB289C9); + + /// Menthe logo — AM. + static const Color amAccent = Color(0xFF5A9D94); + + const DossierListCard({ + super.key, + required this.numeroDossier, + required this.namesLine, + required this.isFamille, + required this.onOpen, + this.photoUrl, + }); + + @override + Widget build(BuildContext context) { + final accent = isFamille ? familleAccent : amAccent; + final num = numeroDossier.trim().isEmpty ? '–' : numeroDossier.trim(); + final names = namesLine.trim(); + final avatar = (photoUrl ?? '').trim(); + + return AdminUserCard( + title: num, + subtitleLines: names.isEmpty ? const [] : [names], + 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, + onCardTap: onOpen, + actions: [ + IconButton( + tooltip: 'Ouvrir', + icon: Icon(Icons.open_in_new, size: 20, color: accent), + onPressed: onOpen, + ), + ], + ); + } +} diff --git a/frontend/lib/widgets/admin/dossiers_management_widget.dart b/frontend/lib/widgets/admin/dossiers_management_widget.dart new file mode 100644 index 0000000..62ec3ab --- /dev/null +++ b/frontend/lib/widgets/admin/dossiers_management_widget.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/dossier_list_item.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/pending_validation_widget.dart'; +import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart'; + +/// Onglet permanent « Dossiers » (#153) : pending en haut + liste unifiée en bas. +class DossiersManagementWidget extends StatefulWidget { + final String searchQuery; + final VoidCallback? onRefresh; + + const DossiersManagementWidget({ + super.key, + this.searchQuery = '', + this.onRefresh, + }); + + @override + State createState() => + _DossiersManagementWidgetState(); +} + +class _DossiersManagementWidgetState extends State { + bool _loading = true; + String? _error; + List _all = []; + Set _pendingNumeros = {}; + int _pendingRefreshTick = 0; + + @override + void initState() { + super.initState(); + _loadAll(); + } + + Future _loadAll() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final parents = await UserService.getParents(); + final ams = await UserService.getAssistantesMaternelles(); + if (!mounted) return; + final items = [ + ...DossierListItem.fromParents(parents), + ...DossierListItem.fromAssistantes(ams), + ]; + items.sort((a, b) { + final byNum = a.numeroDossier.compareTo(b.numeroDossier); + if (byNum != 0) return byNum; + return a.typeLabel.compareTo(b.typeLabel); + }); + setState(() { + _all = items; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur inconnue'; + _loading = false; + }); + } + } + + Future _refreshEverything() async { + setState(() => _pendingRefreshTick++); + await _loadAll(); + widget.onRefresh?.call(); + } + + void _openDossier(String numeroDossier) { + final num = numeroDossier.trim(); + if (num.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Numéro de dossier manquant.')), + ); + return; + } + showDialog( + context: context, + builder: (context) => ValidationDossierModal( + numeroDossier: num, + onClose: () => Navigator.of(context).pop(), + onSuccess: () { + Navigator.of(context).pop(); + _refreshEverything(); + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + final query = widget.searchQuery; + // Pending uniquement en haut — exclus de « Tous les dossiers ». + final filtered = _all + .where((d) => !_pendingNumeros.contains(d.numeroDossier)) + .where((d) => (d.statut ?? '').toLowerCase() != 'en_attente') + .where((d) => d.matchesQuery(query)) + .toList(growable: false); + + return RefreshIndicator( + onRefresh: _refreshEverything, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: PendingValidationWidget( + key: ValueKey('pending-$_pendingRefreshTick'), + searchQuery: query, + compactWhenEmpty: true, + onPendingNumerosChanged: (nums) { + if (!mounted) return; + setState(() => _pendingNumeros = nums); + }, + onRefresh: () { + _loadAll(); + widget.onRefresh?.call(); + }, + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text( + 'Tous les dossiers', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + ), + ), + if (_loading) + const SliverFillRemaining( + hasScrollBody: false, + child: Center(child: CircularProgressIndicator()), + ) + else if (_error != null && _error!.isNotEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_error!, style: const TextStyle(color: Colors.red)), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadAll, + child: const Text('Réessayer'), + ), + ], + ), + ), + ), + ) + else if (filtered.isEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 32), + child: Text( + query.trim().isEmpty + ? 'Aucun dossier pour le moment.\n' + 'Pour créer un dossier → onglet Parents (+ Parents) ' + 'ou Assistantes maternelles (+ Asmat).' + : 'Aucun dossier ne correspond à la recherche.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey.shade600, height: 1.4), + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final item = filtered[index]; + return DossierListCard( + numeroDossier: item.numeroDossier, + namesLine: item.namesLine, + isFamille: item.isFamille, + photoUrl: item.photoUrl, + onOpen: () => _openDossier(item.numeroDossier), + ); + }, + childCount: filtered.length, + ), + ), + ), + ], + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/pending_validation_widget.dart b/frontend/lib/widgets/admin/pending_validation_widget.dart index 361a54c..78296bd 100644 --- a/frontend/lib/widgets/admin/pending_validation_widget.dart +++ b/frontend/lib/widgets/admin/pending_validation_widget.dart @@ -1,19 +1,32 @@ import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; +import 'package:p_tits_pas/models/dossier_list_item.dart'; 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/utils/phone_utils.dart'; +import 'package:p_tits_pas/widgets/admin/dossier_list_card.dart'; import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart'; -/// Onglet « À valider » : deux listes (AM en attente, familles en attente). Ticket #107. +/// Section « dossiers à valider » (liste unifiée AM + familles). Ticket #107 / #153. class PendingValidationWidget extends StatefulWidget { final VoidCallback? onRefresh; + /// Filtre client (n°, nom, email) — onglet Dossiers (#153). + final String searchQuery; + /// Si true et liste vide : message court (pas de grand vide centré). + final bool compactWhenEmpty; + /// Numéros des dossiers pending (pour exclure de « Tous les dossiers »). + final ValueChanged>? onPendingNumerosChanged; - const PendingValidationWidget({super.key, this.onRefresh}); + const PendingValidationWidget({ + super.key, + this.onRefresh, + this.searchQuery = '', + this.compactWhenEmpty = false, + this.onPendingNumerosChanged, + }); @override - State createState() => _PendingValidationWidgetState(); + State createState() => + _PendingValidationWidgetState(); } class _PendingValidationWidgetState extends State { @@ -21,6 +34,8 @@ class _PendingValidationWidgetState extends State { String? _error; List _pendingAM = []; List _pendingFamilies = []; + /// Noms enrichis via GET /dossiers/:numero (libelle API = noms seuls). + final Map _familyNamesByNumero = {}; @override void initState() { @@ -34,24 +49,77 @@ class _PendingValidationWidgetState extends State { _error = null; }); try { - final am = await UserService.getPendingUsers(role: 'assistante_maternelle'); + final am = + await UserService.getPendingUsers(role: 'assistante_maternelle'); final families = await UserService.getPendingFamilies(); + final namesByNumero = await _enrichFamilyNames(families); if (!mounted) return; setState(() { _pendingAM = am; _pendingFamilies = families; + _familyNamesByNumero + ..clear() + ..addAll(namesByNumero); _isLoading = false; }); + _emitPendingNumeros(); } catch (e) { if (!mounted) return; setState(() { - _error = e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur inconnue'; + _error = e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur inconnue'; _isLoading = false; }); + widget.onPendingNumerosChanged?.call(const {}); } } - void _onOpenValidation({String? type, String? id, String? numeroDossier}) { + /// Complète `NOM Prénom` via le détail dossier (sans changer le back). + Future> _enrichFamilyNames( + List families, + ) async { + final out = {}; + await Future.wait(families.map((f) async { + final num = (f.numeroDossier ?? '').trim(); + if (num.isEmpty) return; + try { + final dossier = await UserService.getDossier(num); + if (!dossier.isFamily) return; + final labels = []; + final seen = {}; + for (final p in dossier.asFamily.parents) { + final id = p.id.trim(); + if (id.isNotEmpty && !seen.add(id)) continue; + final label = formatDossierPersonLabel( + nom: p.nom, + prenom: p.prenom, + email: p.email, + ); + if (label.isNotEmpty) labels.add(label); + } + if (labels.isNotEmpty) out[num] = labels.join(' - '); + } catch (_) { + // Repli libellé API ci-dessous. + } + })); + return out; + } + + void _emitPendingNumeros() { + final nums = {}; + for (final u in _pendingAM) { + final n = (u.numeroDossier ?? '').trim(); + if (n.isNotEmpty) nums.add(n); + } + for (final f in _pendingFamilies) { + final n = (f.numeroDossier ?? '').trim(); + if (n.isNotEmpty) nums.add(n); + } + widget.onPendingNumerosChanged?.call(nums); + } + + void _onOpenValidation({String? numeroDossier}) { final num = numeroDossier?.trim(); if (num == null || num.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( @@ -73,9 +141,48 @@ class _PendingValidationWidgetState extends State { ); } + bool _matchesQuery(String haystack) { + final q = widget.searchQuery.trim().toLowerCase(); + if (q.isEmpty) return true; + return haystack.toLowerCase().contains(q); + } + + List get _filteredAM { + return _pendingAM.where((u) { + final bits = [ + u.numeroDossier ?? '', + u.fullName, + u.email, + u.nom ?? '', + u.prenom ?? '', + ].join(' '); + return _matchesQuery(bits); + }).toList(); + } + + List get _filteredFamilies { + return _pendingFamilies.where((f) { + final num = (f.numeroDossier ?? '').trim(); + final enriched = _familyNamesByNumero[num] ?? ''; + final bits = [ + f.numeroDossier ?? '', + f.libelle, + enriched, + f.emails.join(' '), + ].join(' '); + return _matchesQuery(bits); + }).toList(); + } + @override Widget build(BuildContext context) { if (_isLoading) { + if (widget.compactWhenEmpty) { + return const Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator()), + ); + } return const Center(child: CircularProgressIndicator()); } if (_error != null && _error!.isNotEmpty) { @@ -97,14 +204,32 @@ class _PendingValidationWidgetState extends State { ); } - final hasAM = _pendingAM.isNotEmpty; - final hasFamilies = _pendingFamilies.isNotEmpty; - if (!hasAM && !hasFamilies) { + final pendingAM = _filteredAM; + final pendingFamilies = _filteredFamilies; + final cards = [ + ...pendingAM.map(_buildAMCard), + ...pendingFamilies.map(_buildFamilyCard), + ]; + + if (cards.isEmpty) { + if (widget.compactWhenEmpty) { + final searching = widget.searchQuery.trim().isNotEmpty; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text( + searching + ? 'Aucun dossier en attente ne correspond à la recherche.' + : 'Aucun dossier en attente.', + style: TextStyle(color: Colors.grey.shade600, fontSize: 13), + ), + ); + } return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.check_circle_outline, size: 64, color: Colors.grey.shade400), + Icon(Icons.check_circle_outline, + size: 64, color: Colors.grey.shade400), const SizedBox(height: 16), Text( 'Aucun dossier en attente de validation', @@ -117,6 +242,28 @@ class _PendingValidationWidgetState extends State { ); } + final list = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Dossiers à valider', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const SizedBox(height: 8), + ...cards, + ], + ); + + if (widget.compactWhenEmpty) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: list, + ); + } + return RefreshIndicator( onRefresh: () async { await _load(); @@ -125,275 +272,40 @@ class _PendingValidationWidgetState extends State { child: SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (hasAM) ...[ - _sectionTitle('Assistantes maternelles en attente'), - const SizedBox(height: 8), - ..._pendingAM.map((u) => _buildAMCard(u)), - const SizedBox(height: 24), - ], - if (hasFamilies) ...[ - _sectionTitle('Familles en attente'), - const SizedBox(height: 8), - ..._pendingFamilies.map((f) => _buildFamilyCard(f)), - ], - ], - ), + child: list, ), ); } - Widget _sectionTitle(String title) { - return Text( - title, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: Colors.black87, - ), + String _amNamesLine(AppUser user) { + return formatDossierPersonLabel( + nom: user.nom, + prenom: user.prenom, + email: user.email, ); } - - /// Sous-titre AM : `email - date • tél. • CP ville` (plan affichage lignes À valider). - String _amSubtitleLine(AppUser user) { - final email = user.email.trim(); - final bits = []; - bits.add(DateFormat('dd/MM/yyyy').format(user.createdAt.toLocal())); - final tel = user.telephone?.trim(); - if (tel != null && tel.isNotEmpty) { - bits.add(formatPhoneForDisplay(tel)); - } - final cp = user.codePostal?.trim(); - final ville = user.ville?.trim(); - final loc = [if (cp != null && cp.isNotEmpty) cp, if (ville != null && ville.isNotEmpty) ville] - .join(' ') - .trim(); - if (loc.isNotEmpty) bits.add(loc); - final infos = bits.join(' • '); - if (email.isEmpty) return infos; - return '$email - $infos'; - } - Widget _buildAMCard(AppUser user) { - final numDossier = user.numeroDossier ?? '–'; - final nameBold = - user.fullName.isNotEmpty ? user.fullName : (user.email.isNotEmpty ? user.email : '–'); - return _PendingValidationRow( - icon: Icons.person_outline, - title: Text.rich( - TextSpan( - style: const TextStyle(fontSize: 14, color: Colors.black87), - children: [ - TextSpan( - text: nameBold, - style: const TextStyle(fontWeight: FontWeight.w700), - ), - TextSpan( - text: ' - $numDossier', - style: const TextStyle(fontWeight: FontWeight.w400), - ), - ], - ), - ), - subtitle: _amSubtitleLine(user), - subtitleStyle: TextStyle( - fontSize: 12, - fontStyle: FontStyle.italic, - color: Colors.grey.shade600, - ), - onOpen: () => _onOpenValidation( - type: 'AM', - id: user.id, - numeroDossier: user.numeroDossier, - ), + return DossierListCard( + numeroDossier: user.numeroDossier ?? '', + namesLine: _amNamesLine(user), + isFamille: false, + photoUrl: user.photoUrl, + onOpen: () => _onOpenValidation(numeroDossier: user.numeroDossier), ); } - /// `email, tél., localisation` par parent, puis `date soumission`, puis `nb enfants`. - String _familyParentSegment(PendingParentLine p) { - final parts = []; - final e = p.email?.trim(); - if (e != null && e.isNotEmpty) parts.add(e); - final t = p.telephone?.trim(); - if (t != null && t.isNotEmpty) parts.add(formatPhoneForDisplay(t)); - final cp = p.codePostal?.trim(); - final v = p.ville?.trim(); - final loc = [if (cp != null && cp.isNotEmpty) cp, if (v != null && v.isNotEmpty) v] - .join(' ') - .trim(); - if (loc.isNotEmpty) parts.add(loc); - return parts.join(', '); - } - - String _familySubtitleLine(PendingFamily family) { - final blocks = family.parentLines - .map(_familyParentSegment) - .where((s) => s.isNotEmpty) - .join(' - '); - - final tail = []; - final date = family.dateSoumission; - if (date != null) { - tail.add(DateFormat('dd/MM/yyyy').format(date.toLocal())); - } - if (family.nombreEnfants > 0) { - tail.add( - family.nombreEnfants > 1 - ? '${family.nombreEnfants} enfants' - : '1 enfant', - ); - } - final right = tail.join(' - '); - - if (blocks.isEmpty && right.isEmpty) return ''; - if (blocks.isEmpty) return right; - if (right.isEmpty) return blocks; - return '$blocks - $right'; - } - Widget _buildFamilyCard(PendingFamily family) { - final numDossier = family.numeroDossier ?? '–'; - final nameBold = family.libelle.isNotEmpty ? family.libelle : 'Famille'; - return _PendingValidationRow( - icon: Icons.family_restroom_outlined, - title: Text.rich( - TextSpan( - style: const TextStyle(fontSize: 14, color: Colors.black87), - children: [ - TextSpan( - text: nameBold, - style: const TextStyle(fontWeight: FontWeight.w700), - ), - TextSpan( - text: ' - $numDossier', - style: const TextStyle(fontWeight: FontWeight.w400), - ), - ], - ), - ), - subtitle: _familySubtitleLine(family), - subtitleStyle: TextStyle( - fontSize: 12, - fontStyle: FontStyle.italic, - color: Colors.grey.shade600, - ), - onOpen: () => _onOpenValidation( - type: 'famille', - id: family.parentIds.isNotEmpty ? family.parentIds.first : null, - numeroDossier: family.numeroDossier, - ), + final num = (family.numeroDossier ?? '').trim(); + final enriched = num.isNotEmpty ? _familyNamesByNumero[num] : null; + final names = (enriched != null && enriched.isNotEmpty) + ? enriched + : formatDossierFamilyNamesLine(family.libelle); + return DossierListCard( + numeroDossier: family.numeroDossier ?? '', + namesLine: names, + isFamille: true, + onOpen: () => _onOpenValidation(numeroDossier: family.numeroDossier), ); } } - -/// Ligne « À valider » : survol comme [AdminUserCard], icône « Ouvrir » visible au hover uniquement. -class _PendingValidationRow extends StatefulWidget { - final IconData icon; - final Widget title; - final String? subtitle; - final TextStyle? subtitleStyle; - final VoidCallback onOpen; - - const _PendingValidationRow({ - required this.icon, - required this.title, - this.subtitle, - this.subtitleStyle, - required this.onOpen, - }); - - @override - State<_PendingValidationRow> createState() => _PendingValidationRowState(); -} - -class _PendingValidationRowState extends State<_PendingValidationRow> { - bool _isHovered = false; - - @override - Widget build(BuildContext context) { - final subStyle = widget.subtitleStyle ?? - TextStyle( - fontSize: 12, - color: Colors.grey.shade600, - ); - return MouseRegion( - onEnter: (_) => setState(() => _isHovered = true), - onExit: (_) => setState(() => _isHovered = false), - cursor: SystemMouseCursors.click, - child: Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(10), - child: InkWell( - onTap: widget.onOpen, - borderRadius: BorderRadius.circular(10), - hoverColor: const Color(0x149CC5C0), - child: Card( - margin: const EdgeInsets.only(bottom: 12), - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - side: BorderSide(color: Colors.grey.shade300), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(widget.icon, color: Colors.grey.shade600, size: 28), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - widget.title, - if (widget.subtitle != null && - widget.subtitle!.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - widget.subtitle!, - style: subStyle, - ), - ], - ], - ), - ), - SizedBox( - width: 52, - child: Center( - child: AnimatedOpacity( - duration: const Duration(milliseconds: 120), - opacity: _isHovered ? 1 : 0, - child: IgnorePointer( - ignoring: !_isHovered, - child: IconButtonTheme( - data: IconButtonThemeData( - style: IconButton.styleFrom( - padding: const EdgeInsets.all(0), - minimumSize: const Size(48, 48), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - child: IconButton( - onPressed: widget.onOpen, - icon: const Icon(Icons.open_in_new), - iconSize: 34, - tooltip: 'Ouvrir', - ), - ), - ), - ), - ), - ), - ], - ), - ), - ), - ), - ), - ); - } -} - diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index 7be38c7..32e6017 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -1,19 +1,18 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart'; -import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/am_dossier_create_modal.dart'; import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; +import 'package:p_tits_pas/widgets/admin/dossiers_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/parent_dossier_create_modal.dart'; import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart'; -import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart'; class UserManagementPanel extends StatefulWidget { - /// Afficher l'onglet Administrateurs (sinon 3 onglets : Gestionnaires, Parents, AM). + /// Afficher l'onglet Administrateurs (sinon sans Administrateurs). final bool showAdministrateursTab; const UserManagementPanel({ @@ -32,45 +31,17 @@ class _UserManagementPanelState extends State { int _adminRefreshTick = 0; int _enfantRefreshTick = 0; int _amRefreshTick = 0; + int _dossiersRefreshTick = 0; final TextEditingController _searchController = TextEditingController(); final TextEditingController _amCapacityController = TextEditingController(); String? _parentStatus; String? _enfantStatus; - bool _hasPending = false; - bool _pendingLoading = true; @override void initState() { super.initState(); _searchController.addListener(_onFilterChanged); _amCapacityController.addListener(_onFilterChanged); - _loadPending(); - } - - Future _loadPending() async { - try { - final am = await UserService.getPendingUsers(role: 'assistante_maternelle'); - final families = await UserService.getPendingFamilies(); - if (!mounted) return; - final hasPending = am.isNotEmpty || families.isNotEmpty; - setState(() { - final hadPending = _hasPending; - _hasPending = hasPending; - _pendingLoading = false; - // Si on passe à "plus de dossiers", recaler l'index (onglet À valider disparaît). - if (hadPending && !hasPending) { - _subIndex = (_subIndex > 0 ? _subIndex - 1 : 0).clamp(0, _tabLabels.length - 1); - } else if (!hadPending && hasPending) { - _subIndex = 0; // Afficher l'onglet À valider - } - }); - } catch (_) { - if (!mounted) return; - setState(() { - _hasPending = false; - _pendingLoading = false; - }); - } } @override @@ -87,13 +58,19 @@ class _UserManagementPanelState extends State { setState(() {}); } + /// Ordre #153 : Dossiers | Parents | Enfants | AM | Gestionnaires | (Admin). List get _tabLabels { - const base = ['Parents', 'Enfants', 'Assistantes maternelles', 'Gestionnaires']; - final withAdmin = [...base, 'Administrateurs']; - final list = widget.showAdministrateursTab ? withAdmin : base; - // Onglet « À valider » visible seulement s'il y a des dossiers en attente (ticket #107). - if (!_pendingLoading && _hasPending) return ['À valider', ...list]; - return list; + const base = [ + 'Dossiers', + 'Parents', + 'Enfants', + 'Assistantes maternelles', + 'Gestionnaires', + ]; + if (widget.showAdministrateursTab) { + return [...base, 'Administrateurs']; + } + return base; } void _onSubTabChange(int index) { @@ -107,31 +84,35 @@ class _UserManagementPanelState extends State { }); } - /// Index du contenu : -1 = À valider, 0 = Parents, 1 = Enfants, 2 = AM, 3 = Gestionnaires, 4 = Admin. - int get _contentIndexOffset => (_hasPending && !_pendingLoading) ? 1 : 0; + bool get _isDossiersTab => _subIndex == 0; String _searchHintForTab() { - final contentIndex = _subIndex - _contentIndexOffset; - switch (contentIndex) { - case -1: - return 'À valider (pas de recherche)'; + switch (_subIndex) { case 0: - return 'Rechercher un parent...'; + return 'Rechercher un dossier'; case 1: - return 'Rechercher un enfant...'; + return 'Rechercher un parent...'; case 2: - return 'Rechercher une assistante...'; + return 'Rechercher un enfant...'; case 3: - return 'Rechercher un gestionnaire...'; + return 'Rechercher une assistante...'; case 4: + return 'Rechercher un gestionnaire...'; + case 5: return 'Rechercher un administrateur...'; default: return 'Rechercher...'; } } + String? _searchTooltipForTab() { + if (_subIndex != 0) return null; + return 'Recherche possible : n° de dossier, nom, prénom ou e-mail.'; + } + Widget? _subBarFilterControl() { - if (_subIndex == _contentIndexOffset + 0) { + // Parents + if (_subIndex == 1) { return DropdownButtonHideUnderline( child: DropdownButton( value: _parentStatus, @@ -186,7 +167,8 @@ class _UserManagementPanelState extends State { ); } - if (_subIndex == _contentIndexOffset + 1) { + // Enfants + if (_subIndex == 2) { return DropdownButtonHideUnderline( child: DropdownButton( value: _enfantStatus, @@ -241,7 +223,8 @@ class _UserManagementPanelState extends State { ); } - if (_subIndex == _contentIndexOffset + 2) { + // AM + if (_subIndex == 3) { return TextField( controller: _amCapacityController, decoration: const InputDecoration( @@ -258,35 +241,36 @@ class _UserManagementPanelState extends State { } Widget _buildBody() { - final contentIndex = _subIndex - _contentIndexOffset; - if (_hasPending && !_pendingLoading && contentIndex == -1) { - return PendingValidationWidget(onRefresh: _loadPending); - } - switch (contentIndex) { + switch (_subIndex) { case 0: + return DossiersManagementWidget( + key: ValueKey('dossiers-$_dossiersRefreshTick'), + searchQuery: _searchController.text, + ); + case 1: return ParentManagementWidget( key: ValueKey('parents-$_parentRefreshTick'), searchQuery: _searchController.text, statusFilter: _parentStatus, ); - case 1: + case 2: return EnfantManagementWidget( key: ValueKey('enfants-$_enfantRefreshTick'), searchQuery: _searchController.text, statusFilter: _enfantStatus, ); - case 2: + case 3: return AssistanteMaternelleManagementWidget( key: ValueKey('ams-$_amRefreshTick'), searchQuery: _searchController.text, capacityMin: int.tryParse(_amCapacityController.text), ); - case 3: + case 4: return GestionnaireManagementWidget( key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'), searchQuery: _searchController.text, ); - case 4: + case 5: return AdminManagementWidget( key: ValueKey('admins-$_adminRefreshTick'), searchQuery: _searchController.text, @@ -299,7 +283,6 @@ class _UserManagementPanelState extends State { @override Widget build(BuildContext context) { final labels = _tabLabels; - final isAValiderTab = _hasPending && !_pendingLoading && _subIndex == 0; return Column( children: [ DashboardUserManagementSubBar( @@ -307,8 +290,10 @@ class _UserManagementPanelState extends State { onSubTabChange: _onSubTabChange, searchController: _searchController, searchHint: _searchHintForTab(), + searchTooltip: _searchTooltipForTab(), filterControl: _subBarFilterControl(), - onAddPressed: isAValiderTab ? null : _handleAddPressed, + // Pas de « Créer » sur l’onglet Dossiers (#153). + onAddPressed: _isDossiersTab ? null : _handleAddPressed, addLabel: 'Ajouter', subTabCount: labels.length, tabLabels: labels, @@ -319,9 +304,8 @@ class _UserManagementPanelState extends State { } Future _handleAddPressed() async { - final contentIndex = _subIndex - _contentIndexOffset; - - if (contentIndex == 0) { + // 1 Parents, 2 Enfants, 3 AM, 4 Gestionnaires, 5 Admin + if (_subIndex == 1) { await showDialog( context: context, barrierDismissible: false, @@ -331,7 +315,10 @@ class _UserManagementPanelState extends State { onSuccess: () { Navigator.of(dialogContext).pop(); if (!mounted) return; - setState(() => _parentRefreshTick++); + setState(() { + _parentRefreshTick++; + _dossiersRefreshTick++; + }); }, ); }, @@ -339,7 +326,7 @@ class _UserManagementPanelState extends State { return; } - if (contentIndex == 1) { + if (_subIndex == 2) { await showDialog( context: context, barrierDismissible: false, @@ -355,7 +342,7 @@ class _UserManagementPanelState extends State { return; } - if (contentIndex == 2) { + if (_subIndex == 3) { await showDialog( context: context, barrierDismissible: false, @@ -365,7 +352,10 @@ class _UserManagementPanelState extends State { onSuccess: () { Navigator.of(dialogContext).pop(); if (!mounted) return; - setState(() => _amRefreshTick++); + setState(() { + _amRefreshTick++; + _dossiersRefreshTick++; + }); }, ); }, @@ -373,7 +363,7 @@ class _UserManagementPanelState extends State { return; } - if (contentIndex == 3) { + if (_subIndex == 4) { final created = await showDialog( context: context, barrierDismissible: false, @@ -391,7 +381,7 @@ class _UserManagementPanelState extends State { return; } - if (contentIndex == 4) { + if (_subIndex == 5) { final created = await showDialog( context: context, barrierDismissible: false, @@ -409,16 +399,6 @@ class _UserManagementPanelState extends State { _adminRefreshTick++; }); } - return; } - - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'La création parent sera disponible avec le ticket #129.', - ), - ), - ); } }