Compare commits

...
Author SHA1 Message Date
jmartinandCursor 939e7777ac docs(#155): mini-spec phase 2 — move panels admin → dashboard.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 11:05:47 +02:00
jmartinandCursor b474842e19 refactor(#155): phase 2 — panels staff sous widgets/dashboard/.
Déplace les panels/wizards/validation/common partagés hors de
widgets/admin/ ; ne conserve que AdminManagementWidget (variante A).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 11:05:23 +02:00
jmartinandCursor a312d9c0fa refactor(#155): option C — widgets partagés sans préfixe Admin.
Déplace les composants Admin* du dashboard partagé vers
widgets/dashboard/ (ChildDetailModal, AmEditModal, UserCard, Select*, …).
Conserve AdminManagementWidget et screens/administrateurs. Rename mécanique,
aucun changement de comportement.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 10:57:58 +02:00
jmartin b5b32062b3 merge(#152): suppression grossesse multiple / est_multiple. 2026-09-14 10:57:28 +02:00
jmartinandCursor 946d8edcd2 feat(#152): suppression complète grossesse multiple / est_multiple.
Retire le champ partout : BDD (migration + seed), entity/DTO/services Nest,
modèles et payloads Flutter, scripts de test et docs. Back et front alignés
(forbidNonWhitelisted). Aucun fantôme de compat.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 23:08:36 +02:00
jmartin c8cb82dd24 merge(#161): admin peut créer gestionnaire et administrateur. 2026-09-10 23:00:28 +02:00
jmartinandCursor 9cd180bf6a feat(#161): admin peut créer gestionnaire et administrateur.
Assouplit POST /gestionnaires, PATCH /gestionnaires/:id et POST /users/admin
(+ check createAdmin) pour ADMINISTRATEUR. Masque « Ajouter » staff sur le
dashboard gestionnaire. Tests droits Roles + createAdmin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 22:54:49 +02:00
jmartin ca07d2e111 merge(#160): suppressions dashboard front + API #159. 2026-09-10 22:41:02 +02:00
jmartinandCursor 67336c64fe fix(#160): un gestionnaire ne peut plus supprimer un autre depuis la fiche.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 22:38:57 +02:00
jmartinandCursor 97afbbcf9a fix(am): hauteur modale fiche AM par onglet (sans scroll inutile).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 22:16:06 +02:00
jmartinandCursor e235b30140 feat(#160): suppressions dashboard — poubelle, confirmations et sans_enfant.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 22:16:05 +02:00
84 changed files with 2297 additions and 599 deletions
-1
View File
@@ -33,7 +33,6 @@ model Child {
dateOfBirth DateTime
photoUrl String?
photoConsent Boolean @default(false)
isMultiple Boolean @default(false)
isUnborn Boolean @default(false)
parentId String
parent Parent @relation(fields: [parentId], references: [id])
-3
View File
@@ -63,9 +63,6 @@ export class Children {
@Column({ type: 'timestamptz', nullable: true, name: 'date_consentement_photo' })
consent_photo_at?: Date;
@Column({ default: false, name: 'est_multiple', type: 'boolean' })
is_multiple: boolean;
// Lien via table de jointure enfants_parents
@OneToMany(() => ParentsChildren, pc => pc.child)
parentLinks: ParentsChildren[];
-4
View File
@@ -564,7 +564,6 @@ export class AuthService {
enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE;
enfant.consent_photo = !!enfantDto.consent_photo;
enfant.consent_photo_at = enfant.consent_photo ? new Date() : null!;
enfant.is_multiple = enfantDto.grossesse_multiple || false;
const enfantEnregistre = await manager.save(Children, enfant);
enfantsEnregistres.push(enfantEnregistre);
@@ -1387,9 +1386,6 @@ export class AuthService {
enfant.status = StatutEnfantType.A_NAITRE;
}
}
if (enfantDto.grossesse_multiple !== undefined) {
enfant.is_multiple = enfantDto.grossesse_multiple;
}
if (enfantDto.consent_photo !== undefined) {
enfant.consent_photo = !!enfantDto.consent_photo;
enfant.consent_photo_at = enfant.consent_photo
@@ -55,11 +55,6 @@ export class EnfantInscriptionDto {
@IsString()
photo_filename?: string;
@ApiProperty({ example: false, required: false, description: 'Grossesse multiple (jumeaux, triplés, etc.)' })
@IsOptional()
@IsBoolean()
grossesse_multiple?: boolean;
@ApiProperty({
example: true,
required: false,
@@ -74,11 +74,6 @@ export class CreateEnfantsDto {
@IsDateString()
consent_photo_at?: string;
@ApiProperty({ default: false })
@Transform(toBoolean)
@IsBoolean()
is_multiple: boolean;
/**
* Parent pivot du foyer — obligatoire pour staff (gestionnaire/admin).
* Ignoré / interdit en externe pour un PARENT (ticket #132).
@@ -29,9 +29,6 @@ export class EnfantResponseDto {
@ApiProperty({ example: false })
consent_photo: boolean;
@ApiProperty({ example: false })
is_multiple: boolean;
@ApiProperty({ example: 'UUID-parent' })
parent_id: string;
}
@@ -92,7 +92,6 @@ export class EnfantsService {
photo_url: photoUrl,
consent_photo: !!dto.consent_photo,
consent_photo_at: consentAt,
is_multiple: !!dto.is_multiple,
});
await this.childrenRepository.save(child);
@@ -54,9 +54,6 @@ export class DossierFamilleEnfantDto {
description: 'Consentement affichage photo (colonne consentement_photo)',
})
consent_photo?: boolean;
@ApiProperty({ required: false, description: 'Grossesse multiple (est_multiple)' })
est_multiple?: boolean;
}
/** Réponse GET /parents/dossier-famille/:numeroDossier dossier famille complet. Ticket #119 */
@@ -370,7 +370,6 @@ export class ParentsService {
status: child.status,
photo_url: child.photo_url ?? undefined,
consent_photo: child.consent_photo,
est_multiple: child.is_multiple,
};
}
@@ -1,20 +1,21 @@
import { Test, TestingModule } from '@nestjs/testing';
import 'reflect-metadata';
import { GestionnairesController } from './gestionnaires.controller';
import { GestionnairesService } from './gestionnaires.service';
import { RoleType } from 'src/entities/users.entity';
describe('GestionnairesController', () => {
let controller: GestionnairesController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [GestionnairesController],
providers: [GestionnairesService],
}).compile();
controller = module.get<GestionnairesController>(GestionnairesController);
describe('GestionnairesController roles (#161)', () => {
it('POST /gestionnaires autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
const roles = Reflect.getMetadata('roles', GestionnairesController.prototype.create);
expect(roles).toEqual(
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
);
expect(roles).not.toContain(RoleType.GESTIONNAIRE);
});
it('should be defined', () => {
expect(controller).toBeDefined();
it('PATCH /gestionnaires/:id autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
const roles = Reflect.getMetadata('roles', GestionnairesController.prototype.update);
expect(roles).toEqual(
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
);
expect(roles).not.toContain(RoleType.GESTIONNAIRE);
});
});
@@ -25,10 +25,10 @@ import { AuthGuard } from 'src/common/guards/auth.guard';
export class GestionnairesController {
constructor(private readonly gestionnairesService: GestionnairesService) { }
@Roles(RoleType.SUPER_ADMIN)
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiResponse({ status: 201, description: 'Le gestionnaire a été créé avec succès.', type: Users })
@ApiResponse({ status: 409, description: 'Conflit. L\'email est déjà utilisé.' })
@ApiOperation({ summary: 'Création d\'un gestionnaire' })
@ApiOperation({ summary: 'Création d\'un gestionnaire (admin / super admin)' })
@ApiBody({ type: CreateGestionnaireDto })
@Post()
create(@Body() dto: CreateGestionnaireDto): Promise<Users> {
@@ -43,7 +43,7 @@ export class GestionnairesController {
return this.gestionnairesService.findAll();
}
@Roles(RoleType.GESTIONNAIRE, RoleType.SUPER_ADMIN)
@Roles(RoleType.GESTIONNAIRE, RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Récupérer un gestionnaire par ID' })
@ApiResponse({ status: 400, description: 'ID invalide' })
@ApiResponse({ status: 403, description: 'Accès refusé' })
@@ -56,8 +56,8 @@ export class GestionnairesController {
return this.gestionnairesService.findOne(id);
}
@Roles(RoleType.SUPER_ADMIN)
@ApiOperation({ summary: 'Mettre à jour un gestionnaire' })
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Mettre à jour un gestionnaire (admin / super admin)' })
@ApiResponse({ status: 200, description: 'Le gestionnaire a été mis à jour avec succès.', type: Users })
@ApiResponse({ status: 404, description: 'Gestionnaire non trouvé' })
@ApiResponse({ status: 403, description: 'Accès refusé' })
@@ -1,20 +1,13 @@
import { Test, TestingModule } from '@nestjs/testing';
import 'reflect-metadata';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { RoleType } from 'src/entities/users.entity';
describe('UserController', () => {
let controller: UserController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [UserController],
providers: [UserService],
}).compile();
controller = module.get<UserController>(UserController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
describe('UserController roles (#161)', () => {
it('POST /users/admin autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
const roles = Reflect.getMetadata('roles', UserController.prototype.createAdmin);
expect(roles).toEqual(
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
);
expect(roles).not.toContain(RoleType.GESTIONNAIRE);
});
});
+3 -3
View File
@@ -22,10 +22,10 @@ export class UserController {
private readonly suppressionService: SuppressionService,
) { }
// Création d'un administrateur (réservée aux super admins)
// Création d'un administrateur (admin + super admin) — #161
@Post('admin')
@Roles(RoleType.SUPER_ADMIN)
@ApiOperation({ summary: 'Créer un nouvel administrateur (super admin seulement)' })
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Créer un nouvel administrateur (admin / super admin)' })
createAdmin(
@Body() dto: CreateAdminDto,
@User() currentUser: Users
+78 -9
View File
@@ -1,18 +1,87 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { UserService } from './user.service';
import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
describe('UserService.createAdmin (#161)', () => {
const usersRepository = {
findOneBy: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};
describe('UserService', () => {
let service: UserService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [UserService],
}).compile();
const dto = {
email: 'nouveau.admin@ptits-pas.fr',
password: 'Password1!',
prenom: 'Nina',
nom: 'Admin',
telephone: '0601020304',
};
service = module.get<UserService>(UserService);
beforeEach(() => {
jest.clearAllMocks();
service = new UserService(
usersRepository as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
});
it('should be defined', () => {
expect(service).toBeDefined();
it('autorise un administrateur à créer un admin', async () => {
usersRepository.findOneBy.mockResolvedValue(null);
usersRepository.create.mockImplementation((data) => data);
usersRepository.save.mockImplementation(async (entity) => ({
id: 'new-admin',
...entity,
}));
const result = await service.createAdmin(dto as never, {
id: 'admin-1',
role: RoleType.ADMINISTRATEUR,
} as never);
expect(result.role).toBe(RoleType.ADMINISTRATEUR);
expect(result.statut).toBe(StatutUtilisateurType.ACTIF);
expect(usersRepository.save).toHaveBeenCalled();
});
it('autorise un super_admin à créer un admin', async () => {
usersRepository.findOneBy.mockResolvedValue(null);
usersRepository.create.mockImplementation((data) => data);
usersRepository.save.mockImplementation(async (entity) => ({
id: 'new-admin',
...entity,
}));
await expect(
service.createAdmin(dto as never, {
id: 'sa-1',
role: RoleType.SUPER_ADMIN,
} as never),
).resolves.toMatchObject({ role: RoleType.ADMINISTRATEUR });
});
it('refuse un gestionnaire (403 métier)', async () => {
await expect(
service.createAdmin(dto as never, {
id: 'gest-1',
role: RoleType.GESTIONNAIRE,
} as never),
).rejects.toBeInstanceOf(ForbiddenException);
expect(usersRepository.save).not.toHaveBeenCalled();
});
it('refuse un email déjà utilisé', async () => {
usersRepository.findOneBy.mockResolvedValue({ id: 'exists' });
await expect(
service.createAdmin(dto as never, {
id: 'admin-1',
role: RoleType.ADMINISTRATEUR,
} as never),
).rejects.toBeInstanceOf(BadRequestException);
});
});
+8 -2
View File
@@ -117,8 +117,14 @@ export class UserService {
}
async createAdmin(dto: CreateAdminDto, currentUser: Users): Promise<Users> {
if (currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Seuls les super administrateurs peuvent créer un administrateur');
// #161 — admin et super_admin peuvent créer un administrateur
if (
currentUser.role !== RoleType.SUPER_ADMIN &&
currentUser.role !== RoleType.ADMINISTRATEUR
) {
throw new ForbiddenException(
'Seuls les administrateurs et super administrateurs peuvent créer un administrateur',
);
}
const exist = await this.usersRepository.findOneBy({ email: dto.email });
+1 -2
View File
@@ -174,8 +174,7 @@ CREATE TABLE enfants (
date_prevue_naissance DATE,
photo_url TEXT,
consentement_photo BOOLEAN DEFAULT false,
date_consentement_photo TIMESTAMPTZ,
est_multiple BOOLEAN DEFAULT false
date_consentement_photo TIMESTAMPTZ
);
-- ==========================================================
+10 -10
View File
@@ -1,10 +1,10 @@
"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo","est_multiple"
"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","sans_garde","Emma","Dupont","F","2020-06-01",,,False,,False
"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","sans_garde",,,,"2020-01-01","2025-01-01",,False,,False
"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","sans_garde","Emma","Martin",,"2023-02-15",,,False,,False
"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","sans_garde","Noah","Martin",,"2023-02-15",,,False,,False
"e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e","sans_garde","Léa","Martin",,"2023-02-15",,,False,,False
"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","sans_garde","Chloé","Rousseau",,"2022-04-20",,,False,,False
"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","sans_garde","Hugo","Rousseau",,"2024-03-10",,,False,,False
"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","sans_garde","Maxime","Lecomte",,"2023-04-15",,,False,,False
"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,,False
"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo"
"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","sans_garde","Emma","Dupont","F","2020-06-01",,,False,
"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","sans_garde",,,,"2020-01-01","2025-01-01",,False,
"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","sans_garde","Emma","Martin",,"2023-02-15",,,False,
"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","sans_garde","Noah","Martin",,"2023-02-15",,,False,
"e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e","sans_garde","Léa","Martin",,"2023-02-15",,,False,
"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","sans_garde","Chloé","Rousseau",,"2022-04-20",,,False,
"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","sans_garde","Hugo","Rousseau",,"2024-03-10",,,False,
"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","sans_garde","Maxime","Lecomte",,"2023-04-15",,,False,
"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,
1 id statut prenom nom genre date_naissance date_prevue_naissance photo_url consentement_photo date_consentement_photo est_multiple
2 5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf sans_garde Emma Dupont F 2020-06-01 False False
3 a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d sans_garde 2020-01-01 2025-01-01 False False
4 e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c sans_garde Emma Martin 2023-02-15 False False
5 e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d sans_garde Noah Martin 2023-02-15 False False
6 e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e sans_garde Léa Martin 2023-02-15 False False
7 e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f sans_garde Chloé Rousseau 2022-04-20 False False
8 e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a sans_garde Hugo Rousseau 2024-03-10 False False
9 e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b sans_garde Maxime Lecomte 2023-04-15 False False
10 edd19cd1-bb67-4f14-8a37-c66b75c94537 scolarise Lucas Durand H 2018-09-15 False False
@@ -0,0 +1,2 @@
-- #152 — Suppression grossesse multiple / est_multiple
ALTER TABLE enfants DROP COLUMN IF EXISTS est_multiple;
+4 -4
View File
@@ -69,12 +69,12 @@ ON CONFLICT (id_utilisateur) DO NOTHING;
-- - child B : à naître (statut = 'a_naitre' et date_prevue_naissance requise)
-- ------------------------------------------------------------
INSERT INTO enfants (id, prenom, nom, statut, date_naissance, jumeau_multiple)
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'sans_garde', '2022-04-12', false)
INSERT INTO enfants (id, prenom, nom, statut, date_naissance)
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'sans_garde', '2022-04-12')
ON CONFLICT (id) DO NOTHING;
INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance, jumeau_multiple)
VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Mila', 'Parent', 'a_naitre', '2026-02-15', false)
INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance)
VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Mila', 'Parent', 'a_naitre', '2026-02-15')
ON CONFLICT (id) DO NOTHING;
-- ------------------------------------------------------------
+7 -7
View File
@@ -49,14 +49,14 @@ VALUES
ON CONFLICT (id_utilisateur) DO NOTHING;
-- ========== ENFANTS ==========
INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut, est_multiple)
INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut)
VALUES
('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'sans_garde', true),
('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'sans_garde', true),
('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'sans_garde', true),
('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'sans_garde', false),
('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'sans_garde', false),
('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'sans_garde', false)
('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'sans_garde'),
('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'sans_garde'),
('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'sans_garde'),
('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'sans_garde'),
('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'sans_garde'),
('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'sans_garde')
ON CONFLICT (id) DO NOTHING;
-- ========== ENFANTS_PARENTS (liaison N:N) ==========
-1
View File
@@ -117,7 +117,6 @@ Table des enfants pris en charge.
| `photo_url` | TEXT | | URL de la photo |
| `consentement_photo` | BOOLEAN | DEFAULT false | Consentement photo |
| `date_consentement_photo` | TIMESTAMPTZ | | Date du consentement |
| `est_multiple` | BOOLEAN | DEFAULT false | Indique si grossesse multiple |
---
-3
View File
@@ -276,9 +276,6 @@ export class Enfants {
@Column({ name: 'consentement_photo', type: 'boolean', default: false })
consentementPhoto: boolean;
@Column({ name: 'est_multiple', type: 'boolean', default: false })
estMultiple: boolean;
@Column({
type: 'enum',
enum: StatutEnfantType,
@@ -0,0 +1,65 @@
# Mini-spec — Suppression complète grossesse multiple / `est_multiple`
**Ticket** : **#152** — https://git.ptits-pas.fr/jmartin/petitspas/issues/152
**Branche** : `feature/152-remove-est-multiple` (depuis `develop`)
**Périmètre** : **full stack** — BDD + back + front + scripts + docs. **Aucun fantôme.**
---
## Décision
On **supprime tout**. Pas de DTO « ignorés », pas de compat payload.
`forbidNonWhitelisted: true` ⇒ back et front **partent ensemble** (même feature / même déploiement).
---
## Alias retirés
`est_multiple` · `is_multiple` · `grossesse_multiple` · `multipleBirth` · `estMultiple` · `isMultiple` · `jumeau_multiple`
---
## Back / BDD (fait sur la branche)
- [x] Migration `database/migrations/2026_drop_enfants_est_multiple.sql`
- [x] `BDD.sql`, seeds, CSV test
- [x] Entity `Children` sans colonne
- [x] DTO create/inscription/réponse/dossier famille **sans** le champ
- [x] Services auth / enfants / parents : plus de mapping
- [x] Prisma legacy `isMultiple` retiré
## Front (fait sur la branche)
- [x] Modèles admin / dossier / inscription
- [x] Payloads inscription + reprise
- [x] Modale enfant + wizard dossier famille
- [x] Step3 inscription parent
## Scripts / docs
- [x] `tests/scripts/register-parent-*.mjs`
- [x] `docs/10_DATABASE.md`, `docs/99_REGLES-CODAGE.md`
- Docs tmp/archive #112 : mentions historiques OK (archive)
---
## Déploiement
1. Appliquer la migration SQL sur la BDD vivante
2. Deploy back **et** front de cette branche
3. Smoke : création enfant staff, inscription parent, reprise, wizard famille
## Vérif
```bash
rg -n 'est_multiple|is_multiple|grossesse_multiple|multipleBirth|estMultiple|isMultiple|jumeau_multiple' \
backend/src frontend/lib database tests/scripts docs/10_DATABASE.md docs/99_REGLES-CODAGE.md
```
**0** hit (hors ce fichier mini-spec / archives).
## Hors scope
- Métier futur « fratrie / jumeaux » → nouveau ticket
- #155 rename Admin*
- Ticket modales staff
@@ -0,0 +1,54 @@
# Mini-spec — Rename préfixe `Admin*` dashboard partagé (#155)
**Ticket** : **#155**
**Branche** : `feature/155-rename-admin-prefix-dashboard` (depuis `develop`)
**Décision naming** : **option C** — dossier neutre `widgets/dashboard/` + noms **sans** préfixe `Admin`.
---
## Principe
Les widgets partagés **admin + gestionnaire** ne doivent plus sappeler `Admin*`.
On garde `Admin*` seulement là où cest vraiment le rôle administrateur.
## Gardé `Admin*` (hors rename)
| Élément | Raison |
|---------|--------|
| `AdminManagementWidget` | Onglet **Administrateurs** |
| `screens/administrateurs/*` | `AdminDashboardScreen`, `AdminCreateDialog`, `AdminUserFormDialog` |
| `EnfantAdminModel` | Modèle API (pas un widget) — hors scope ticket |
## Renames faits
| Avant | Après |
|-------|--------|
| `widgets/admin/common/admin_child_detail_modal.dart``AdminChildDetailModal` | `widgets/dashboard/child_detail_modal.dart``ChildDetailModal` |
| `admin_am_edit_modal``AdminAmEditModal` | `am_edit_modal``AmEditModal` |
| `admin_parent_edit_modal``AdminParentEditModal` | `parent_edit_modal``ParentEditModal` |
| `admin_user_card``AdminUserCard` | `user_card``UserCard` |
| `admin_enfant_user_card``AdminEnfantUserCard` | `enfant_user_card``EnfantUserCard` |
| `admin_am_photo_frame``AdminAmPhotoFrame` | `am_photo_frame``AmPhotoFrame` |
| `admin_am_children_capacity_grid` | `am_children_capacity_grid``AmChildrenCapacityGrid` |
| `admin_children_affiliation_panel` | `children_affiliation_panel``ChildrenAffiliationPanel` |
| `admin_select_*` / `AdminSelect*` / `AdminFamilleFoyer` | `select_*` / `Select*` / `FamilleFoyer` |
| `admin_status_capsule` | `status_capsule``StatusCapsule` |
| `admin_list_state``AdminListState` | `user_list_state``UserListState` |
| `admin_detail_modal``AdminDetailModal` / `AdminDetailField` | `detail_modal``DetailModal` / `DetailField` |
| `dashboard_admin.dart` | `user_management_sub_bar.dart` (`DashboardUserManagementSubBar` inchangé) |
Dossier `widgets/admin/` conserve encore les panels métier (`user_management_panel`, wizards, etc.) + `AdminManagementWidget`.
**Phase 2** (même ticket #155) : déplacer ces panels → `widgets/dashboard/` — voir [155-suite-move-admin-panels-to-dashboard.md](./155-suite-move-admin-panels-to-dashboard.md).
## Hors scope
- Refonte UX modales (ticket dédié)
- Rename API / back
- Déplacer tout `widgets/admin/``widgets/dashboard/` (panels) — possible follow-up
## Critères
- [x] Plus de préfixe `Admin` sur les composants **partagés** listés
- [ ] Build Flutter / recette dashboard admin + gestionnaire OK
- [x] Pas de changement comportemental (rename mécanique)
@@ -0,0 +1,203 @@
# Mini-spec — Déplacer les panels `widgets/admin/` → `widgets/dashboard/`
**Ticket** : **#155** (phase 2 — même ticket que le rename `Admin*`)
**Phase 1** : widgets `Admin*``widgets/dashboard/` (déjà sur `feature/155-rename-admin-prefix-dashboard`)
**Branche** : poursuivre / rebaser `feature/155-rename-admin-prefix-dashboard` (ou nouvelle branche depuis `develop` après merge phase 1)
**Nature** : rename / move mécanique — **zéro** changement UX / métier
---
## Contexte
Après la phase 1 (#155), la situation est **hybride** :
| Emplacement | Contenu |
|-------------|---------|
| `widgets/dashboard/` | Composants partagés sans préfixe `Admin*` (modales, cartes, selects, sub-bar…) |
| `widgets/admin/` | **Panels** du dashboard staff (listes, wizards, validation, shell `UserManagementPanel`…) + `AdminManagementWidget` |
Le dossier `admin/` laisse encore croire « réservé administrateur », alors que **gestionnaire** consomme les mêmes panels (`GestionnaireDashboardScreen``UserManagementPanel`).
Ce ticket **termine loption C** au niveau dossier : tout le dashboard staff vit sous `widgets/dashboard/`, sauf ce qui est **vraiment** rôle admin.
---
## Objectif
```
frontend/lib/widgets/admin/<panels & common partagés>
↓ git mv + update imports
frontend/lib/widgets/dashboard/…
```
Critère : un nouveau dev ne doit plus ouvrir `widgets/admin/` pour du code partagé admin+gestionnaire.
---
## Cible darborescence (proposée)
```
widgets/dashboard/
├── (déjà là #155) child_detail_modal.dart, am_edit_modal.dart, user_card.dart, …
├── user_management_panel.dart ← shell onglets
├── user_management_sub_bar.dart ← déjà déplacé #155
├── dossiers_management_widget.dart
├── dossier_list_card.dart
├── parent_management_widget.dart ← corriger le typo managmant au passage ?
├── enfant_management_widget.dart
├── assistante_maternelle_management_widget.dart
├── gestionnaire_management_widget.dart
├── pending_validation_widget.dart
├── parent_dossier_create_modal.dart
├── parent_dossier_wizard.dart
├── am_dossier_create_modal.dart
├── am_dossier_wizard.dart
├── validation_*.dart ← family/am wizards, refus, theme, confirm
├── parametres_panel.dart ← utilisé par écran admin (OK dans dashboard)
├── relais_management_panel.dart
├── common/ ← sous-dossier optionnel
│ ├── suppression_confirm_dialog.dart
│ ├── user_list.dart
│ └── validation_detail_section.dart
└── …
widgets/admin/ ← mince, rôle admin seulement
└── admin_management_widget.dart ← onglet Administrateurs
```
### Variante B (plus stricte)
`AdminManagementWidget` + éventuels helpers purement admin →
`screens/administrateurs/widgets/`
et **suppression** du dossier `widgets/admin/`.
**Reco** : **variante A** (garder `widgets/admin/` minimal avec seulement `AdminManagementWidget`) — moins de churn screens, clair.
---
## Inventaire à déplacer (état actuel)
### Racine `widgets/admin/` → `widgets/dashboard/`
| Fichier actuel | Notes |
|----------------|--------|
| `user_management_panel.dart` | Shell partagé admin + gestionnaire |
| `dossiers_management_widget.dart` | |
| `dossier_list_card.dart` | |
| `parent_managmant_widget.dart` | Typo historique `managmant`**option** : renommer → `parent_management_widget.dart` dans le même ticket ou ticket typo séparé |
| `enfant_management_widget.dart` | |
| `assistante_maternelle_management_widget.dart` | |
| `gestionnaire_management_widget.dart` | |
| `pending_validation_widget.dart` | |
| `parent_dossier_create_modal.dart` | |
| `parent_dossier_wizard.dart` | |
| `am_dossier_create_modal.dart` | |
| `am_dossier_wizard.dart` | |
| `validation_am_wizard.dart` | |
| `validation_family_wizard.dart` | |
| `validation_dossier_modal.dart` | |
| `validation_modal_theme.dart` | |
| `validation_refus_form.dart` | |
| `validation_valider_confirm_dialog.dart` | |
| `parametres_panel.dart` | Écran admin seulement, mais pas préfixé Admin — OK dashboard |
| `relais_management_panel.dart` | |
### `widgets/admin/common/` → `widgets/dashboard/common/` (ou plat)
| Fichier | Notes |
|---------|--------|
| `suppression_confirm_dialog.dart` | Partagé (y compris `screens/administrateurs/creation/*`) |
| `user_list.dart` | |
| `validation_detail_section.dart` | |
### **Ne pas** déplacer
| Fichier | Destination |
|---------|-------------|
| `admin_management_widget.dart` | Reste `widgets/admin/` (ou variante B → screens) |
### Déjà fait (#155) — ne pas retraiter
Tout ce qui est déjà sous `widgets/dashboard/` (`child_detail_modal`, `am_edit_modal`, `user_card`, `select_*`, `user_management_sub_bar`, …).
---
## Consommateurs dimports (à mettre à jour)
### Screens
- `screens/administrateurs/admin_dashboardScreen.dart``UserManagementPanel`, `ParametresPanel`
- `screens/gestionnaire/gestionnaire_dashboard_screen.dart``UserManagementPanel`
- `screens/administrateurs/creation/admin_create.dart``suppression_confirm_dialog`
- `screens/administrateurs/creation/gestionnaires_create.dart` — idem
### Widgets déjà en `dashboard/`
- `am_edit_modal`, `child_detail_modal`, `parent_edit_modal`, `select_*` — imports vers `widgets/admin/common/*` ou panels
### Divers
- `widgets/common/identity_block.dart` (si import admin)
- Tous les fichiers **déplacés** entre eux (imports relatifs / package)
### Hors scope rename classes
Sauf décision explicite sur le typo `parent_managmant_widget` → pas de rename de **classes** métier dans ce ticket (seulement chemins de fichiers + imports).
`AdminManagementWidget` **conserve** son nom.
---
## Plan dexécution
1. Partir de `feature/155-rename-admin-prefix-dashboard` (phase 1) **ou** `develop` si phase 1 déjà mergée
2. `git mv` fichiers selon inventaire
3. Remplacer globalement
`package:p_tits_pas/widgets/admin/``package:p_tits_pas/widgets/dashboard/`
**sauf** `…/widgets/admin/admin_management_widget.dart`
4. Corriger imports relatifs cassés
5. Grep de contrôle (ci-dessous)
6. Build Flutter web (Docker) + smoke dashboard admin **et** gestionnaire
7. Merge → squash master si flux habituel
---
## Vérifs
```bash
# Plus de panels partagés sous admin (seul AdminManagement attendu)
find frontend/lib/widgets/admin -name '*.dart'
# Plus dimports panels vers lancien chemin (sauf AdminManagement)
rg -n "widgets/admin/(user_management|dossiers_|parent_|enfant_|assistante|gestionnaire|pending|validation_|parametres|relais|am_dossier|parent_dossier|dossier_list|common/)" frontend/lib
# Screens OK
rg -n "widgets/admin/" frontend/lib/screens
```
Attendu screens : **0** hit vers panels ; éventuellement plus aucun hit `widgets/admin/` sauf si import explicite `AdminManagementWidget` depuis `user_management_panel` (chemin `widgets/admin/admin_management_widget.dart`).
---
## Hors scope
- Refonte UX des modales / panels (ticket dédié annoncé)
- Rename `EnfantAdminModel`
- Rename `screens/administrateurs/`
- Rename `AdminUserFormDialog` / `AdminCreateDialog`
- Changement API / back
- #152 (`est_multiple`) — autre branche
---
## Critères dacceptation
- [ ] Inventaire déplacé selon tableau
- [ ] `widgets/admin/` ne contient plus que `admin_management_widget.dart` (variante A)
- [ ] Imports screens + widgets à jour
- [ ] Build Flutter OK
- [ ] Recette : dashboard **administrateur** et **gestionnaire** (listes, ouverture fiches, validation, création dossier) sans régression
- [ ] Aucun changement comportemental volontaire
---
## Risques / notes
- **Conflits de merge** si dautres features touchent les panels → faire ce ticket quand la surface dashboard est calme (fin 0.1.0 OK)
- Typo `parent_managmant_widget` : soit inclus (bonus), soit ticket cleanup 1-ligne séparé
- Docs darchive citant `widgets/admin/…` : pas obligatoire de mettre à jour ; `docs/27_BRIEFING-FRONTEND.md` oui si encore listé
@@ -13,6 +13,10 @@ class DossierListItem {
final String? statut;
/// Photo profil (AM) — affichée à la place de licône si présente.
final String? photoUrl;
/// Dossier famille sans enfant lié (#159 / #160).
final bool sansEnfant;
/// Nombre denfants du foyer (famille uniquement ; null pour AM).
final int? enfantsCount;
const DossierListItem({
required this.type,
@@ -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<String>? emails,
String? statut,
String? photoUrl,
bool? sansEnfant,
int? enfantsCount,
}) {
return DossierListItem(
type: type ?? this.type,
numeroDossier: numeroDossier ?? this.numeroDossier,
libelle: libelle ?? this.libelle,
emails: emails ?? this.emails,
statut: statut ?? this.statut,
photoUrl: photoUrl ?? this.photoUrl,
sansEnfant: sansEnfant ?? this.sansEnfant,
enfantsCount: enfantsCount ?? this.enfantsCount,
);
}
bool get isFamille => type == DossierListType.famille;
bool get isAm => type == DossierListType.assistanteMaternelle;
@@ -98,6 +126,19 @@ class DossierListItem {
}
}
final childIds = <String>{};
var maxCountFallback = 0;
for (final p in entry.value) {
for (final c in p.children) {
final id = c.id.trim();
if (id.isNotEmpty) childIds.add(id);
}
final n = p.children.isNotEmpty ? p.children.length : p.childrenCount;
if (n > maxCountFallback) maxCountFallback = n;
}
final enfantsCount =
childIds.isNotEmpty ? childIds.length : maxCountFallback;
items.add(
DossierListItem(
type: DossierListType.famille,
@@ -105,6 +146,8 @@ class DossierListItem {
libelle: names.isNotEmpty ? names.join(' - ') : 'Famille',
emails: emails,
statut: _preferStatut(statuts),
enfantsCount: enfantsCount,
sansEnfant: enfantsCount == 0,
),
);
}
-4
View File
@@ -185,7 +185,6 @@ class EnfantDossier {
final String? dueDate;
final String? photoUrl;
final bool consentPhoto;
final bool estMultiple;
EnfantDossier({
required this.id,
@@ -197,7 +196,6 @@ class EnfantDossier {
this.dueDate,
this.photoUrl,
this.consentPhoto = false,
this.estMultiple = false,
});
String get fullName => '${firstName ?? ''} ${lastName ?? ''}'.trim();
@@ -231,8 +229,6 @@ class EnfantDossier {
photoUrl: resolvedPhoto,
consentPhoto:
json['consent_photo'] == true || json['consentPhoto'] == true,
estMultiple:
json['est_multiple'] == true || json['estMultiple'] == true,
);
}
}
@@ -12,7 +12,6 @@ class EnfantAdminModel {
final String status;
final String? photoUrl;
final bool consentPhoto;
final bool isMultiple;
final List<EnfantParentLink> parentLinks;
/// Flag API #157 (sinon déduit de [parentLinks]).
final bool? sansResponsable;
@@ -27,7 +26,6 @@ class EnfantAdminModel {
required this.status,
this.photoUrl,
this.consentPhoto = false,
this.isMultiple = false,
this.parentLinks = const [],
this.sansResponsable,
});
@@ -56,7 +54,6 @@ class EnfantAdminModel {
String? status,
String? photoUrl,
bool? consentPhoto,
bool? isMultiple,
List<EnfantParentLink>? parentLinks,
bool? sansResponsable,
}) {
@@ -70,7 +67,6 @@ class EnfantAdminModel {
status: status ?? this.status,
photoUrl: photoUrl ?? this.photoUrl,
consentPhoto: consentPhoto ?? this.consentPhoto,
isMultiple: isMultiple ?? this.isMultiple,
parentLinks: parentLinks ?? this.parentLinks,
sansResponsable: sansResponsable ?? this.sansResponsable,
);
@@ -111,8 +107,6 @@ class EnfantAdminModel {
),
photoUrl: photoUrl,
consentPhoto: consentPhoto,
isMultiple: _parseBool(json['is_multiple']) ||
_parseBool(json['est_multiple']),
parentLinks: links,
sansResponsable: sansResponsable,
);
@@ -127,7 +121,6 @@ class EnfantAdminModel {
if (birthDate != null && birthDate!.isNotEmpty) 'birth_date': birthDate,
if (dueDate != null && dueDate!.isNotEmpty) 'due_date': dueDate,
'consent_photo': consentPhoto,
'is_multiple': isMultiple,
};
}
@@ -30,7 +30,6 @@ class ChildData {
String lastName;
String dob; // Date de naissance ou prévisionnelle
bool photoConsent;
bool multipleBirth;
bool isUnbornChild;
File? imageFile;
CardColorVertical cardColor; // Nouveau champ pour la couleur de la carte
@@ -40,7 +39,6 @@ class ChildData {
this.lastName = '',
this.dob = '',
this.photoConsent = false,
this.multipleBirth = false,
this.isUnbornChild = false,
this.imageFile,
required this.cardColor, // Rendre requis dans le constructeur
@@ -38,7 +38,6 @@ class ChildData {
/// Valeurs API : `H`, `F`, `Autre` (GenreType backend). Vide tant que non choisi.
String genre;
bool photoConsent;
bool multipleBirth;
bool isUnbornChild;
File? imageFile;
/// Octets de la photo (fiable à lenvoi API ; [imageFile] peut être absent sur le web).
@@ -55,7 +54,6 @@ class ChildData {
this.dob = '',
this.genre = '',
this.photoConsent = false,
this.multipleBirth = false,
this.isUnbornChild = false,
this.imageFile,
this.imageBytes,
@@ -70,7 +68,6 @@ class ChildData {
String? dob,
String? genre,
bool? photoConsent,
bool? multipleBirth,
bool? isUnbornChild,
Object? imageFile = _unsetImage,
Object? imageBytes = _unsetImageBytes,
@@ -84,7 +81,6 @@ class ChildData {
dob: dob ?? this.dob,
genre: genre ?? this.genre,
photoConsent: photoConsent ?? this.photoConsent,
multipleBirth: multipleBirth ?? this.multipleBirth,
isUnbornChild: isUnbornChild ?? this.isUnbornChild,
imageFile: identical(imageFile, _unsetImage) ? this.imageFile : imageFile as File?,
imageBytes:
@@ -2,9 +2,9 @@ import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/configuration_service.dart';
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
import 'package:p_tits_pas/widgets/admin/parametres_panel.dart';
import 'package:p_tits_pas/widgets/admin/user_management_panel.dart';
import 'package:p_tits_pas/widgets/dashboard/user_management_sub_bar.dart';
import 'package:p_tits_pas/widgets/dashboard/parametres_panel.dart';
import 'package:p_tits_pas/widgets/dashboard/user_management_panel.dart';
import 'package:p_tits_pas/widgets/app_footer.dart';
import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.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/dashboard/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/email_text_field.dart';
import 'package:p_tits_pas/widgets/french_phone_field.dart';
@@ -151,30 +152,19 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
Future<void> _delete() async {
if (!_isEditMode || _isSubmitting) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) {
return AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text(
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
child: const Text('Supprimer'),
),
],
);
},
final name = widget.initialUser!.fullName.isEmpty
? widget.initialUser!.email
: widget.initialUser!.fullName;
final confirmed = await showSuppressionConfirmDialog(
context,
title: 'Supprimer l\'administrateur',
people: [SuppressionPersonLine.administrateur(name)],
footnotes: const [
'Le compte sera définitivement supprimé.',
],
);
if (confirmed != true) return;
if (!confirmed) return;
setState(() {
_isSubmitting = true;
@@ -8,6 +8,8 @@ import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/relais_service.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
class AdminUserFormDialog extends StatefulWidget {
final AppUser? initialUser;
@@ -43,6 +45,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
List<RelaisModel> _relais = [];
String? _selectedRelaisId;
String? _currentUserId;
String? _currentUserRole;
bool get _isEditMode => widget.initialUser != null;
bool get _isSuperAdminTarget =>
(widget.initialUser?.role ?? '').toLowerCase() == 'super_admin';
@@ -50,7 +53,12 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
_isEditMode &&
_currentUserId != null &&
widget.initialUser!.id == _currentUserId;
bool get _canDeleteTarget => !_isSuperAdminTarget && !_isSelfTarget;
/// Gestionnaire : pas de delete staff. Admin+ seulement (#154 / #160).
bool get _canDeleteTarget {
if (!_isEditMode || widget.readOnly) return false;
if (_isSelfTarget || _isSuperAdminTarget) return false;
return canDeleteGestionnaire(_currentUserRole);
}
bool get _isLockedAdminIdentity =>
_isEditMode && widget.adminMode && _isSuperAdminTarget;
String get _targetRoleKey {
@@ -125,6 +133,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
if (cached != null) {
setState(() {
_currentUserId = cached.id;
_currentUserRole = cached.role;
});
return;
}
@@ -132,6 +141,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
if (!mounted || refreshed == null) return;
setState(() {
_currentUserId = refreshed.id;
_currentUserRole = refreshed.role;
});
}
@@ -378,30 +388,25 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
if (!_canDeleteTarget) return;
if (!_isEditMode || _isSubmitting) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) {
return AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text(
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
child: const Text('Supprimer'),
),
],
);
},
final name = widget.initialUser!.fullName.isEmpty
? widget.initialUser!.email
: widget.initialUser!.fullName;
final confirmed = await showSuppressionConfirmDialog(
context,
title: widget.adminMode
? 'Supprimer l\'administrateur'
: 'Supprimer le gestionnaire',
people: [
widget.adminMode
? SuppressionPersonLine.administrateur(name)
: SuppressionPersonLine.gestionnaire(name),
],
footnotes: const [
'Le compte sera définitivement supprimé.',
],
);
if (confirmed != true) return;
if (!confirmed) return;
setState(() {
_isSubmitting = true;
@@ -121,7 +121,6 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
dob: '',
isUnbornChild: false,
photoConsent: false,
multipleBirth: false,
cardColor: cardColor,
);
registrationData.addChild(newChild);
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/widgets/admin/user_management_panel.dart';
import 'package:p_tits_pas/widgets/dashboard/user_management_panel.dart';
import 'package:p_tits_pas/widgets/app_footer.dart';
import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart';
@@ -62,7 +62,10 @@ class _GestionnaireDashboardScreenState extends State<GestionnaireDashboardScree
body: Column(
children: [
Expanded(
child: UserManagementPanel(showAdministrateursTab: false),
child: UserManagementPanel(
showAdministrateursTab: false,
allowStaffAccountCreation: false,
),
),
const AppFooter(),
],
+88 -13
View File
@@ -646,14 +646,93 @@ class UserService {
return enrichEnfantParentNames(enfant);
}
static Future<void> deleteEnfant(String enfantId) async {
/// DELETE /enfants/:id?deleteDossier= — ticket #159 / #160.
static Future<Map<String, dynamic>> deleteEnfant(
String enfantId, {
bool deleteDossier = false,
}) async {
final uri = Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId',
).replace(
queryParameters: {
'deleteDossier': deleteDossier ? 'true' : 'false',
},
);
final response = await http.delete(uri, headers: await _headers());
if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception(
_extractErrorMessage(response.body, 'Erreur suppression enfant'),
);
}
return _parseSuppressionBody(response.body);
}
/// DELETE /dossiers/:numeroDossier — ticket #159 / #160.
static Future<Map<String, dynamic>> deleteDossier(
String numeroDossier,
) async {
final num = numeroDossier.trim();
if (num.isEmpty) {
throw Exception('Numéro de dossier manquant.');
}
final encoded = Uri.encodeComponent(num);
final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'),
headers: await _headers(),
);
if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception(_extractErrorMessage(response.body, 'Erreur suppression enfant'));
throw Exception(
_extractErrorMessage(response.body, 'Erreur suppression dossier'),
);
}
return _parseSuppressionBody(response.body);
}
/// Liste unifiée GET /dossiers — flags `sans_enfant` (#159).
static Future<List<Map<String, dynamic>>> listDossiers({String? q}) async {
final uri = Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}')
.replace(
queryParameters: (q != null && q.trim().isNotEmpty)
? {'q': q.trim()}
: null,
);
final response = await http.get(uri, headers: await _headers());
if (response.statusCode != 200) {
throw Exception(
_extractErrorMessage(response.body, 'Erreur chargement dossiers'),
);
}
final decoded = jsonDecode(response.body);
if (decoded is! List) return const [];
return decoded
.whereType<Map>()
.map((e) => Map<String, dynamic>.from(e))
.toList();
}
/// Map `numero_dossier` → `sans_enfant` (familles uniquement).
static Future<Map<String, bool>> getSansEnfantByNumero() async {
final rows = await listDossiers();
final out = <String, bool>{};
for (final row in rows) {
final num = (row['numero_dossier'] ?? '').toString().trim();
if (num.isEmpty) continue;
final type = (row['type'] ?? '').toString().toLowerCase();
if (type != 'famille') continue;
out[num] = row['sans_enfant'] == true;
}
return out;
}
static Map<String, dynamic> _parseSuppressionBody(String body) {
if (body.trim().isEmpty) return <String, dynamic>{};
try {
final decoded = jsonDecode(body);
if (decoded is Map) {
return Map<String, dynamic>.from(decoded);
}
} catch (_) {}
return <String, dynamic>{};
}
/// AM dont la liste d'enfants actifs contient [enfantId] (API actuelle).
@@ -1224,22 +1303,18 @@ class UserService {
return AppUser.fromJson(data);
}
static Future<void> deleteUser(String userId) async {
/// DELETE /users/:id — cascades métier #159 / #160.
static Future<Map<String, dynamic>> deleteUser(String userId) async {
final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'),
headers: await _headers(),
);
if (response.statusCode != 200 && response.statusCode != 204) {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, dynamic>) {
final message = decoded['message'];
if (message is List && message.isNotEmpty) {
throw Exception(message.join(' - '));
}
throw Exception(_toStr(message) ?? 'Erreur suppression utilisateur');
}
throw Exception('Erreur suppression utilisateur');
throw Exception(
_extractErrorMessage(response.body, 'Erreur suppression utilisateur'),
);
}
return _parseSuppressionBody(response.body);
}
}
@@ -166,7 +166,6 @@ class ParentRegistrationPayload {
static Map<String, dynamic> _childToJson(ChildData c, int index, String parentNom) {
final map = <String, dynamic>{
'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre',
'grossesse_multiple': c.multipleBirth,
'consent_photo': c.photoConsent,
};
-1
View File
@@ -44,7 +44,6 @@ class RepriseMapper {
dob: dob,
genre: e.gender ?? '',
photoConsent: e.consentPhoto,
multipleBirth: e.estMultiple,
isUnbornChild: isUnborn,
cardColor: _childCardColors[index % _childCardColors.length],
repriseChildId: e.id,
@@ -0,0 +1,59 @@
/// Création comptes staff (gestionnaire / admin) — ticket #161.
bool canCreateStaffAccounts(String? role) {
final r = (role ?? '').trim().toLowerCase();
return r == 'administrateur' || r == 'super_admin';
}
/// Droits daffichage poubelle — tickets #154 / #160.
bool canDeleteMetier(String? role) {
final r = (role ?? '').trim().toLowerCase();
return r == 'gestionnaire' ||
r == 'administrateur' ||
r == 'super_admin';
}
bool canDeleteGestionnaire(String? role) {
final r = (role ?? '').trim().toLowerCase();
return r == 'administrateur' || r == 'super_admin';
}
bool canDeleteAdministrateur({
required String? currentRole,
required String? currentUserId,
required String targetUserId,
required String targetRole,
required int adminCount,
}) {
final me = (currentRole ?? '').trim().toLowerCase();
if (me != 'administrateur' && me != 'super_admin') return false;
if (targetUserId.trim().isEmpty) return false;
if (currentUserId != null &&
currentUserId.trim() == targetUserId.trim()) {
return false;
}
final target = targetRole.trim().toLowerCase();
if (target == 'super_admin') return false;
if (adminCount <= 1 && me != 'super_admin') return false;
return true;
}
String? adminDeleteBlockedReason({
required String? currentRole,
required String? currentUserId,
required String targetUserId,
required String targetRole,
required int adminCount,
}) {
if (currentUserId != null &&
currentUserId.trim() == targetUserId.trim()) {
return 'Vous ne pouvez pas supprimer votre propre compte.';
}
if (targetRole.trim().toLowerCase() == 'super_admin') {
return 'Le super administrateur ne peut pas être supprimé.';
}
if (adminCount <= 1 &&
(currentRole ?? '').trim().toLowerCase() != 'super_admin') {
return 'Seul un super administrateur peut supprimer le dernier administrateur.';
}
return null;
}
@@ -4,8 +4,10 @@ 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/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
import 'package:p_tits_pas/widgets/dashboard/user_card.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/dashboard/common/user_list.dart';
class AdminManagementWidget extends StatefulWidget {
final String searchQuery;
@@ -24,6 +26,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
String? _error;
List<AppUser> _admins = [];
String? _currentUserRole;
String? _currentUserId;
@override
void initState() {
@@ -62,6 +65,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
if (cached != null) {
setState(() {
_currentUserRole = (cached.role).toLowerCase();
_currentUserId = cached.id;
});
return;
}
@@ -69,6 +73,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
if (!mounted || refreshed == null) return;
setState(() {
_currentUserRole = (refreshed.role).toLowerCase();
_currentUserId = refreshed.id;
});
}
@@ -79,6 +84,16 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
return _currentUserRole == 'super_admin';
}
bool _canDeleteAdmin(AppUser target) {
return canDeleteAdministrateur(
currentRole: _currentUserRole,
currentUserId: _currentUserId,
targetUserId: target.id,
targetRole: target.role,
adminCount: _admins.length,
);
}
Future<void> _openAdminEditDialog(AppUser user) async {
final canEdit = _canEditAdmin(user);
final changed = await showDialog<bool>(
@@ -98,6 +113,56 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
}
}
Future<void> _confirmDelete(AppUser user) async {
final blocked = adminDeleteBlockedReason(
currentRole: _currentUserRole,
currentUserId: _currentUserId,
targetUserId: user.id,
targetRole: user.role,
adminCount: _admins.length,
);
if (blocked != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(blocked)),
);
return;
}
final name = user.fullName.isNotEmpty ? user.fullName : user.email;
final isLast = _admins.length <= 1;
final confirmed = await showSuppressionConfirmDialog(
context,
title: 'Supprimer l\'administrateur',
people: [SuppressionPersonLine.administrateur(name)],
footnotes: [
if (isLast)
'Attention : cest le dernier administrateur.',
'Le compte sera définitivement supprimé.',
],
);
if (!confirmed || !mounted) return;
try {
final result = await UserService.deleteUser(user.id);
if (!mounted) return;
final msg =
(result['message'] ?? 'Administrateur supprimé.').toString();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
await _loadAdmins();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur suppression',
),
),
);
}
}
@override
Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase();
@@ -117,7 +182,8 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
final user = filteredAdmins[index];
final isSuperAdmin = _isSuperAdmin(user);
final canEdit = _canEditAdmin(user);
return AdminUserCard(
final canDelete = _canDeleteAdmin(user);
return UserCard(
title: user.fullName,
fallbackIcon: isSuperAdmin
? Icons.verified_user_outlined
@@ -148,6 +214,8 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
_openAdminEditDialog(user);
},
),
if (canDelete)
suppressionIconButton(onPressed: () => _confirmDelete(user)),
],
);
},
@@ -1,106 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
/// Onglet liste globale des enfants (doc 28 §6.2, ticket #137).
class EnfantManagementWidget extends StatefulWidget {
final String searchQuery;
final String? statusFilter;
const EnfantManagementWidget({
super.key,
required this.searchQuery,
this.statusFilter,
});
@override
State<EnfantManagementWidget> createState() => _EnfantManagementWidgetState();
}
class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
bool _isLoading = false;
String? _error;
List<EnfantAdminModel> _enfants = [];
@override
void initState() {
super.initState();
_loadEnfants();
}
Future<void> _loadEnfants() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final list = await UserService.getEnfants();
if (!mounted) return;
setState(() {
_enfants = list;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
Future<void> _openEnfant(EnfantAdminModel enfant) async {
await showDialog<void>(
context: context,
builder: (ctx) => AdminChildDetailModal(
enfant: enfant,
onSaved: _loadEnfants,
),
);
}
@override
Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase();
final filtered = _enfants.where((e) {
final matchesName = e.fullName.toLowerCase().contains(query);
final matchesStatus = widget.statusFilter == null ||
normalizeEnfantStatus(e.status) ==
normalizeEnfantStatus(widget.statusFilter);
return matchesName && matchesStatus;
}).toList()
..sort((a, b) {
// Orphelins (#157) en tête, puis ordre alphabétique.
final ao = a.hasNoResponsable ? 0 : 1;
final bo = b.hasNoResponsable ? 0 : 1;
if (ao != bo) return ao.compareTo(bo);
return a.fullName.toLowerCase().compareTo(b.fullName.toLowerCase());
});
return UserList(
isLoading: _isLoading,
error: _error,
isEmpty: filtered.isEmpty,
emptyMessage: 'Aucun enfant trouvé.',
itemCount: filtered.length,
itemBuilder: (context, index) {
final enfant = filtered[index];
return AdminEnfantUserCard.fromEnfant(
enfant,
onCardTap: () => _openEnfant(enfant),
actions: [
IconButton(
icon: const Icon(Icons.visibility_outlined),
tooltip: 'Voir / modifier',
onPressed: () => _openEnfant(enfant),
),
],
);
},
);
}
}
@@ -3,7 +3,7 @@ import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/dashboard/common/validation_detail_section.dart';
/// Valeurs affichées dans un [IdentityBlock] en lecture seule.
class IdentityValues {
@@ -5,7 +5,7 @@ import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
/// Grille 2×2 des places d'accueil AM (max 4, limitée à [capacity]).
class AdminAmChildrenCapacityGrid extends StatelessWidget {
class AmChildrenCapacityGrid extends StatelessWidget {
static const int _gridSlots = 4;
static const double _slotHeight = 44;
static const double _gridPadding = 10;
@@ -23,7 +23,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
/// Clic sur une case libre → même flux que « Rattacher un enfant » (#149).
final VoidCallback? onAttachEmpty;
const AdminAmChildrenCapacityGrid({
const AmChildrenCapacityGrid({
super.key,
required this.children,
required this.capacity,
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart';
import 'package:p_tits_pas/widgets/dashboard/am_dossier_wizard.dart';
/// Modale de création dossier AM (#156) — même shell que [ValidationDossierModal].
class AmDossierCreateModal extends StatefulWidget {
@@ -14,12 +14,12 @@ import 'package:p_tits_pas/utils/name_format_utils.dart';
import 'package:p_tits_pas/utils/nir_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/utils/postal_utils.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.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/admin/validation_refus_form.dart';
import 'package:p_tits_pas/widgets/admin/validation_valider_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/dashboard/am_photo_frame.dart';
import 'package:p_tits_pas/widgets/dashboard/detail_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_refus_form.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_valider_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart';
@@ -221,32 +221,32 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
List<AdminDetailField> _photoProFields(DossierAM d) {
List<DetailField> _photoProFields(DossierAM d) {
final u = d.user;
return [
AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)),
AdminDetailField(
DetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)),
DetailField(
label: 'Date de naissance',
value: formatIsoDateFr(u.dateNaissance),
),
AdminDetailField(
DetailField(
label: 'Ville de naissance',
value: _v(u.lieuNaissanceVille),
),
AdminDetailField(
DetailField(
label: 'Pays de naissance',
value: _v(u.lieuNaissancePays),
),
AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)),
AdminDetailField(
DetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)),
DetailField(
label: 'Date dagrément',
value: formatIsoDateFr(d.dateAgrement),
),
AdminDetailField(
DetailField(
label: 'Capacité max (enfants)',
value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '',
),
AdminDetailField(
DetailField(
label: 'Places disponibles',
value: d.placesDisponibles != null
? d.placesDisponibles.toString()
@@ -741,14 +741,14 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
Widget _buildStep1() {
// Modale calée sur les TF ; photo étirée sur toute la hauteur utile
// (largeur = [AdminAmPhotoFrame.columnWidthForHeight], cadre inclus).
// (largeur = [AmPhotoFrame.columnWidthForHeight], cadre inclus).
return LayoutBuilder(
builder: (context, c) {
final maxRowW = c.maxWidth;
final maxRowH = c.maxHeight.clamp(0.0, double.infinity);
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
.clamp(0.0, double.infinity);
var photoW = AdminAmPhotoFrame.columnWidthForHeight(maxRowH)
var photoW = AmPhotoFrame.columnWidthForHeight(maxRowH)
.clamp(_photoColumnMinWidth, 360.0);
if (photoW > maxPhotoW) photoW = maxPhotoW;
@@ -762,7 +762,7 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
final Widget photo;
if (_isCreate) {
photo = AdminAmPhotoFrame(
photo = AmPhotoFrame(
imageBytes: _photoBytes,
onTap: _pickPhoto,
onClear: _photoBytes != null ? _clearPhoto : null,
@@ -7,31 +7,31 @@ import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/nir_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_children_capacity_grid.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/dashboard/am_children_capacity_grid.dart';
import 'package:p_tits_pas/widgets/dashboard/am_photo_frame.dart';
import 'package:p_tits_pas/widgets/dashboard/child_detail_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/select_enfant_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/status_capsule.dart';
import 'package:p_tits_pas/widgets/dashboard/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart';
/// Fiche AM éditable (ticket #131) — identité | fiche pro (photo) | enfants.
class AdminAmEditModal extends StatefulWidget {
class AmEditModal extends StatefulWidget {
final AssistanteMaternelleModel assistante;
final VoidCallback? onSaved;
const AdminAmEditModal({
const AmEditModal({
super.key,
required this.assistante,
this.onSaved,
});
@override
State<AdminAmEditModal> createState() => _AdminAmEditModalState();
State<AmEditModal> createState() => _AmEditModalState();
}
class _AdminAmEditModalState extends State<AdminAmEditModal>
class _AmEditModalState extends State<AmEditModal>
with SingleTickerProviderStateMixin {
late final TabController _tabCtrl;
late final TextEditingController _nomCtrl;
@@ -69,29 +69,6 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
static const double _proTabHeight = 300;
static const List<int> _photoProRowLayout = [2, 2, 2];
/// Hauteur onglet enfants : champs + titre + grille 2×2 (+ alerte places si besoin).
double _childrenTabHeight() {
const capacityFields = 72.0;
const titleSection = 40.0;
const inconsistencyExtra = 46.0;
var h = capacityFields +
titleSection +
AdminAmChildrenCapacityGrid.fixedHeight;
if (_placesInconsistent()) h += inconsistencyExtra;
return h + 4;
}
double _tabViewHeight(int index) {
switch (index) {
case 1:
return _proTabHeight;
case 2:
return _childrenTabHeight();
default:
return 292;
}
}
@override
void initState() {
super.initState();
@@ -427,7 +404,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AdminChildDetailModal(
builder: (ctx) => ChildDetailModal(
enfant: enfant,
onSaved: _refreshChildrenDetails,
),
@@ -473,7 +450,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
Future<void> _attachChild() async {
if (_capacityFull || !mounted) return;
final selected = await AdminSelectEnfantModal.show(
final selected = await SelectEnfantModal.show(
context,
excludeIds: _children.map((c) => c.id).toSet(),
title: 'Rattacher un enfant',
@@ -541,17 +518,15 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
Widget _identityTab() {
return SingleChildScrollView(
child: IdentityBlock.editable(
title: 'Identité et coordonnées',
nomController: _nomCtrl,
prenomController: _prenomCtrl,
telephoneController: _telCtrl,
emailController: _emailCtrl,
adresseController: _adresseCtrl,
codePostalController: _cpCtrl,
villeController: _villeCtrl,
),
return IdentityBlock.editable(
title: 'Identité et coordonnées',
nomController: _nomCtrl,
prenomController: _prenomCtrl,
telephoneController: _telCtrl,
emailController: _emailCtrl,
adresseController: _adresseCtrl,
codePostalController: _cpCtrl,
villeController: _villeCtrl,
);
}
@@ -610,9 +585,11 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
return LayoutBuilder(
builder: (context, c) {
final maxRowW = c.maxWidth;
final maxRowH = c.maxHeight;
// Hauteur bornée : longlet 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 idealPhotoW = bodyH * AmPhotoFrame.idPhotoAspectRatio + 16;
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
.clamp(0.0, double.infinity);
var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 220.0);
@@ -623,7 +600,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
children: [
SizedBox(
width: photoW,
child: AdminAmPhotoFrame(
child: AmPhotoFrame(
photoUrl: widget.assistante.user.photoUrl,
),
),
@@ -659,6 +636,18 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
);
}
/// Corps de longlet actif — hauteur naturelle (pas de TabBarView).
Widget _buildActiveTabBody() {
switch (_tabCtrl.index) {
case 1:
return SizedBox(height: _proTabHeight, child: _proTab());
case 2:
return _childrenTab();
default:
return _identityTab();
}
}
Widget _childrenCapacityFields() {
final inconsistent = _placesInconsistent();
return Column(
@@ -739,7 +728,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
),
),
const SizedBox(height: 8),
AdminAmChildrenCapacityGrid(
AmChildrenCapacityGrid(
children: _children,
capacity: capacity,
onOpen: _openChild,
@@ -831,7 +820,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
const SizedBox(width: 12),
SizedBox(
width: 160,
child: AdminStatusCapsule(
child: StatusCapsule(
statut: _statut,
onChanged: (v) => setState(() {
_statut = v;
@@ -867,15 +856,13 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
child: SizedBox(
height: _tabViewHeight(_tabCtrl.index),
child: TabBarView(
controller: _tabCtrl,
children: [
_identityTab(),
_proTab(),
_childrenTab(),
],
child: AnimatedSize(
duration: const Duration(milliseconds: 180),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: KeyedSubtree(
key: ValueKey<int>(_tabCtrl.index),
child: _buildActiveTabBody(),
),
),
),
@@ -5,7 +5,7 @@ import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
/// Cadre photo identité AM / enfant (35×45 mm) — même logique que [ValidationAmWizard].
class AdminAmPhotoFrame extends StatelessWidget {
class AmPhotoFrame extends StatelessWidget {
final String? photoUrl;
final Uint8List? imageBytes;
final VoidCallback? onTap;
@@ -14,7 +14,7 @@ class AdminAmPhotoFrame extends StatelessWidget {
static const double idPhotoAspectRatio = 35 / 45;
const AdminAmPhotoFrame({
const AmPhotoFrame({
super.key,
this.photoUrl,
this.imageBytes,
@@ -1,10 +1,14 @@
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/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/user_list.dart';
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
import 'package:p_tits_pas/widgets/dashboard/am_edit_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/user_card.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/dashboard/common/user_list.dart';
class AssistanteMaternelleManagementWidget extends StatefulWidget {
final String searchQuery;
@@ -26,16 +30,24 @@ class _AssistanteMaternelleManagementWidgetState
bool _isLoading = false;
String? _error;
List<AssistanteMaternelleModel> _assistantes = [];
bool _canDelete = false;
@override
void initState() {
super.initState();
_loadRights();
_loadAssistantes();
}
@override
void dispose() => super.dispose();
Future<void> _loadRights() async {
final user = await AuthService.getCurrentUser();
if (!mounted) return;
setState(() => _canDelete = canDeleteMetier(user?.role));
}
Future<void> _loadAssistantes() async {
setState(() {
_isLoading = true;
@@ -57,6 +69,46 @@ class _AssistanteMaternelleManagementWidgetState
}
}
Future<void> _confirmDelete(AssistanteMaternelleModel am) async {
final num = (am.user.numeroDossier ?? '').trim();
final name = formatDossierPersonLabel(
nom: am.user.nom,
prenom: am.user.prenom,
email: am.user.email,
);
final confirmed = await showSuppressionConfirmDialog(
context,
title: 'Supprimer l\'assistante maternelle',
subtitle: num.isEmpty ? null : 'Dossier $num',
people: [SuppressionPersonLine.am(name)],
footnotes: const [
'Le compte et le dossier AM seront supprimés.',
'Les enfants accueillis ne seront pas supprimés '
'(placements clos).',
],
);
if (!confirmed || !mounted) return;
try {
final result = await UserService.deleteUser(am.user.id);
if (!mounted) return;
final msg = (result['message'] ?? 'AM supprimée.').toString();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
await _loadAssistantes();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur suppression',
),
),
);
}
}
@override
Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase();
@@ -78,7 +130,7 @@ class _AssistanteMaternelleManagementWidgetState
itemBuilder: (context, index) {
final assistante = filteredAssistantes[index];
final vigilance = amPlacesVigilanceMessage(assistante);
return AdminUserCard(
return UserCard(
title: assistante.user.fullName,
avatarUrl: assistante.user.photoUrl,
fallbackIcon: Icons.face,
@@ -96,6 +148,10 @@ class _AssistanteMaternelleManagementWidgetState
_openAssistanteDetails(assistante);
},
),
if (_canDelete)
suppressionIconButton(
onPressed: () => _confirmDelete(assistante),
),
],
);
},
@@ -105,7 +161,7 @@ class _AssistanteMaternelleManagementWidgetState
void _openAssistanteDetails(AssistanteMaternelleModel assistante) {
showDialog<void>(
context: context,
builder: (context) => AdminAmEditModal(
builder: (context) => AmEditModal(
assistante: assistante,
onSaved: _loadAssistantes,
),
@@ -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,23 +10,25 @@ 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/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/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
import 'package:p_tits_pas/widgets/dashboard/am_edit_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/am_photo_frame.dart';
import 'package:p_tits_pas/widgets/dashboard/parent_edit_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/select_am_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/select_famille_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/dashboard/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
/// Fiche enfant consultation / édition (#138) ou création (#132).
class AdminChildDetailModal extends StatefulWidget {
class ChildDetailModal extends StatefulWidget {
final EnfantAdminModel? enfant;
final VoidCallback? onSaved;
final VoidCallback? onDeleted;
final bool isCreating;
const AdminChildDetailModal({
const ChildDetailModal({
super.key,
required EnfantAdminModel this.enfant,
this.onSaved,
@@ -33,7 +36,7 @@ class AdminChildDetailModal extends StatefulWidget {
}) : isCreating = false;
/// Création depuis l'onglet Enfants (#132).
const AdminChildDetailModal.create({
const ChildDetailModal.create({
super.key,
this.onSaved,
}) : enfant = null,
@@ -41,10 +44,10 @@ class AdminChildDetailModal extends StatefulWidget {
isCreating = true;
@override
State<AdminChildDetailModal> createState() => _AdminChildDetailModalState();
State<ChildDetailModal> createState() => _ChildDetailModalState();
}
class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
class _ChildDetailModalState extends State<ChildDetailModal> {
late final TextEditingController _prenomCtrl;
late final TextEditingController _nomCtrl;
late final TextEditingController _birthCtrl;
@@ -52,7 +55,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
late String _status;
late String? _gender;
late bool _consentPhoto;
late bool _isMultiple;
bool _dirty = false;
bool _saving = false;
bool _deleting = false;
@@ -63,7 +65,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
String? _baselineAmUserId;
/// Famille choisie en mode création (#132).
AdminFamilleFoyer? _selectedFamily;
FamilleFoyer? _selectedFamily;
/// Liens parents locaux (édition) — mis à jour après rattachement foyer (#157).
List<EnfantParentLink>? _localParentLinks;
@@ -97,8 +99,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
_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;
@@ -140,7 +141,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
_gender = _normalizeGender(e?.gender, allowUnknown: _isUnborn);
}
_consentPhoto = e?.consentPhoto ?? false;
_isMultiple = e?.isMultiple ?? false;
for (final c in [_prenomCtrl, _nomCtrl]) {
c.addListener(_onNameChanged);
}
@@ -283,7 +283,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AdminParentEditModal(
builder: (ctx) => ParentEditModal(
parent: parent,
onSaved: () => widget.onSaved?.call(),
),
@@ -381,7 +381,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
else if (_dateToIso(_birthCtrl.text) != null)
'birth_date': _dateToIso(_birthCtrl.text),
'consent_photo': _consentPhoto,
'is_multiple': _isMultiple,
},
);
if (!mounted) return;
@@ -458,7 +457,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
'birth_date': _dateToIso(_birthCtrl.text),
},
'consent_photo': _consentPhoto,
'is_multiple': _isMultiple,
},
);
if (!mounted) return;
@@ -506,37 +504,99 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
if (!_canDelete || _deleting) return;
final name = _headerTitle();
final confirmed = await showDialog<bool>(
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 +608,13 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
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',
),
),
);
}
}
@@ -558,7 +624,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
if (am == null) return;
await showDialog<void>(
context: context,
builder: (ctx) => AdminAmEditModal(
builder: (ctx) => AmEditModal(
assistante: am,
onSaved: () async {
await _reloadPlacementFromServer();
@@ -610,7 +676,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
}
if (!mounted) return;
final selected = await AdminSelectAmModal.show(
final selected = await SelectAmModal.show(
context,
excludeIds: {
if (_linkedAm != null) _linkedAm!.user.id,
@@ -920,7 +986,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
final maxRowW = c.maxWidth;
final maxRowH = c.maxHeight;
final idealPhotoW =
maxRowH * AdminAmPhotoFrame.idPhotoAspectRatio + 16;
maxRowH * AmPhotoFrame.idPhotoAspectRatio + 16;
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
.clamp(0.0, double.infinity);
var photoW = idealPhotoW.clamp(_photoColumnMinWidth, _photoColumnMaxWidth);
@@ -935,7 +1001,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: AdminAmPhotoFrame(
child: AmPhotoFrame(
photoUrl: widget.isCreating
? null
: widget.enfant?.photoUrl,
@@ -1263,7 +1329,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
Future<void> _pickFamily() async {
if (_busy) return;
final selected = await AdminSelectFamilleModal.show(
final selected = await SelectFamilleModal.show(
context,
title: 'Choisir une famille',
);
@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart';
import 'package:p_tits_pas/widgets/dashboard/enfant_user_card.dart';
/// Liste scrollable d'enfants rattachés (fiche parent / fiche AM).
class AdminChildrenAffiliationPanel extends StatelessWidget {
class ChildrenAffiliationPanel extends StatelessWidget {
final List<ParentChildSummary> children;
final ScrollController scrollController;
final void Function(ParentChildSummary child) onOpen;
@@ -14,7 +14,7 @@ class AdminChildrenAffiliationPanel extends StatelessWidget {
static const double _itemHeight = 58;
static const double defaultViewportHeight = _itemHeight * 2.5 + 8;
const AdminChildrenAffiliationPanel({
const ChildrenAffiliationPanel({
super.key,
required this.children,
required this.scrollController,
@@ -62,7 +62,7 @@ class AdminChildrenAffiliationPanel extends StatelessWidget {
itemCount: children.length,
itemBuilder: (_, i) {
final c = children[i];
return AdminEnfantUserCard.fromSummary(
return EnfantUserCard.fromSummary(
c,
onCardTap: () => onOpen(c),
actions: [
@@ -0,0 +1,467 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_list_item.dart';
/// Choix pour le dernier enfant dun dossier famille (#160).
enum DernierEnfantSuppressionChoice {
enfantSeul,
dossierAussi,
}
/// Ligne dimpact (une personne / une fiche).
class SuppressionPersonLine {
final String label;
final IconData icon;
final String? role;
const SuppressionPersonLine({
required this.label,
this.icon = Icons.person_outline,
this.role,
});
factory SuppressionPersonLine.parent(String label) => SuppressionPersonLine(
label: label,
icon: Icons.supervisor_account_outlined,
role: 'Parent',
);
factory SuppressionPersonLine.enfant(String label) => SuppressionPersonLine(
label: label,
icon: Icons.child_care_outlined,
role: 'Enfant',
);
factory SuppressionPersonLine.am(String label) => SuppressionPersonLine(
label: label,
icon: Icons.face,
role: 'AM',
);
factory SuppressionPersonLine.gestionnaire(String label) =>
SuppressionPersonLine(
label: label,
icon: Icons.assignment_ind_outlined,
role: 'Gestionnaire',
);
factory SuppressionPersonLine.administrateur(String label) =>
SuppressionPersonLine(
label: label,
icon: Icons.manage_accounts_outlined,
role: 'Admin',
);
factory SuppressionPersonLine.relais(String label) => SuppressionPersonLine(
label: label,
icon: Icons.apartment_outlined,
role: 'Relais',
);
}
/// Widget unique pour toutes les boîtes de confirmation de suppression (#160).
class SuppressionConfirmDialog extends StatelessWidget {
final String title;
final String? subtitle;
final String? message;
final List<SuppressionPersonLine> people;
final List<String> footnotes;
final List<Widget> actions;
const SuppressionConfirmDialog({
super.key,
required this.title,
this.subtitle,
this.message,
this.people = const [],
this.footnotes = const [],
required this.actions,
});
/// Variante oui/non standard (Annuler / Supprimer).
static SuppressionConfirmDialog yesNo({
required String title,
String? subtitle,
String? message,
List<SuppressionPersonLine> people = const [],
List<String> footnotes = const [],
String confirmLabel = 'Supprimer',
required VoidCallback onCancel,
required VoidCallback onConfirm,
}) {
return SuppressionConfirmDialog(
title: title,
subtitle: subtitle,
message: message,
people: people,
footnotes: footnotes,
actions: [
TextButton(onPressed: onCancel, child: const Text('Annuler')),
FilledButton(
onPressed: onConfirm,
style: _dangerButtonStyle,
child: Text(confirmLabel),
),
],
);
}
static ButtonStyle get _dangerButtonStyle => FilledButton.styleFrom(
backgroundColor: Colors.red.shade700,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AlertDialog(
backgroundColor: const Color(0xFFF7F2FB),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
titlePadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
contentPadding: const EdgeInsets.fromLTRB(24, 12, 24, 8),
actionsPadding: const EdgeInsets.fromLTRB(16, 4, 16, 14),
title: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.delete_outline,
color: Colors.red.shade700,
size: 22,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
color: const Color(0xFF2F2F2F),
fontSize: 20,
),
),
if ((subtitle ?? '').trim().isNotEmpty) ...[
const SizedBox(height: 4),
Text(
subtitle!.trim(),
style: theme.textTheme.bodyMedium?.copyWith(
color: const Color(0xFF6D4EA1),
fontWeight: FontWeight.w600,
),
),
],
],
),
),
],
),
content: SizedBox(
width: 420,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if ((message ?? '').trim().isNotEmpty) ...[
Text(
message!.trim(),
style: theme.textTheme.bodyMedium?.copyWith(
color: Colors.black87,
height: 1.35,
),
),
if (people.isNotEmpty || footnotes.isNotEmpty)
const SizedBox(height: 12),
],
if (people.isNotEmpty) ...[
Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE5D8F2)),
),
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
children: [
for (var i = 0; i < people.length; i++) ...[
if (i > 0)
Divider(
height: 1,
indent: 44,
endIndent: 12,
color: Colors.grey.shade200,
),
_SuppressionPersonRow(line: people[i]),
],
],
),
),
if (footnotes.isNotEmpty) const SizedBox(height: 12),
],
for (final note in footnotes)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(
Icons.info_outline,
size: 16,
color: Colors.orange.shade800,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
note,
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.black54,
height: 1.35,
),
),
),
],
),
),
],
),
),
actions: actions,
);
}
}
class _SuppressionPersonRow extends StatelessWidget {
final SuppressionPersonLine line;
const _SuppressionPersonRow({required this.line});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Icon(line.icon, size: 18, color: const Color(0xFF6D4EA1)),
const SizedBox(width: 10),
Expanded(
child: Text(
line.label,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
color: Color(0xFF2F2F2F),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if ((line.role ?? '').trim().isNotEmpty) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFEDE5FA),
borderRadius: BorderRadius.circular(10),
),
child: Text(
line.role!.trim(),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF6D4EA1),
),
),
),
],
],
),
);
}
}
/// Affiche [SuppressionConfirmDialog] et renvoie `true` si confirmé.
Future<bool> showSuppressionConfirmDialog(
BuildContext context, {
required String title,
String? subtitle,
String? message,
List<SuppressionPersonLine> people = const [],
List<String> footnotes = const [],
String confirmLabel = 'Supprimer',
}) async {
final result = await showDialog<bool>(
context: context,
builder: (ctx) => SuppressionConfirmDialog.yesNo(
title: title,
subtitle: subtitle,
message: message,
people: people,
footnotes: footnotes,
confirmLabel: confirmLabel,
onCancel: () => Navigator.of(ctx).pop(false),
onConfirm: () => Navigator.of(ctx).pop(true),
),
);
return result == true;
}
/// Confirmation delete dossier famille / AM avec liste nominative.
Future<bool> showDossierSuppressionConfirmDialog(
BuildContext context, {
required String numeroDossier,
required bool isFamille,
required List<SuppressionPersonLine> people,
String? fallbackSummary,
}) {
final num = numeroDossier.trim();
if (isFamille) {
return showSuppressionConfirmDialog(
context,
title: 'Supprimer le dossier',
subtitle: 'Dossier famille $num',
people: people,
footnotes: people.isEmpty && (fallbackSummary ?? '').isNotEmpty
? [fallbackSummary!]
: const [
'Tous les comptes et fiches listés seront définitivement '
'supprimés.',
'Les placements AM des enfants seront clos.',
],
);
}
return showSuppressionConfirmDialog(
context,
title: 'Supprimer le dossier',
subtitle: 'Dossier AM $num',
people: people,
footnotes: const [
'Le compte et le dossier AM seront supprimés.',
'Les enfants accueillis ne seront pas supprimés '
'(placements clos).',
],
);
}
/// Dialog dernier enfant : deux actions métier.
Future<DernierEnfantSuppressionChoice?> showDernierEnfantSuppressionDialog(
BuildContext context, {
required String enfantName,
required String familleLabel,
required String numeroDossier,
String? amLabel,
}) {
final am = (amLabel ?? '').trim();
return showDialog<DernierEnfantSuppressionChoice>(
context: context,
builder: (ctx) => SuppressionConfirmDialog(
title: 'Dernier enfant du dossier',
subtitle: 'Dossier $numeroDossier'
'${familleLabel.isEmpty ? '' : ' · $familleLabel'}',
people: [SuppressionPersonLine.enfant(enfantName)],
footnotes: [
'« Enfant seulement » : le dossier reste sans enfant.',
'« Dossier aussi » : parents et dossier sont également '
'supprimés.',
if (am.isNotEmpty) 'Le placement chez $am sera clos.',
],
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Annuler'),
),
OutlinedButton(
onPressed: () => Navigator.of(ctx).pop(
DernierEnfantSuppressionChoice.enfantSeul,
),
child: const Text('Enfant seulement'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(
DernierEnfantSuppressionChoice.dossierAussi,
),
style: SuppressionConfirmDialog._dangerButtonStyle,
child: const Text('Dossier aussi'),
),
],
),
);
}
/// Notes dimpact pour suppression dun enfant (AM optionnelle).
List<String> enfantSuppressionFootnotes({
required String? numeroDossier,
required String familleLabel,
String? amLabel,
}) {
final notes = <String>[];
final num = (numeroDossier ?? '').trim();
final famille = familleLabel.trim();
final am = (amLabel ?? '').trim();
if (num.isEmpty) {
notes.add('Supprimer définitivement cette fiche enfant.');
} else {
notes.add(
'Lenfant sera retiré du dossier de '
'${famille.isEmpty ? 'la famille' : famille}.',
);
}
if (am.isNotEmpty) {
notes.add('Le placement chez $am sera clos.');
}
return notes;
}
/// Bouton poubelle compact pour les cartes liste.
Widget suppressionIconButton({
required VoidCallback? onPressed,
String tooltip = 'Supprimer',
}) {
return IconButton(
icon: Icon(Icons.delete_outline, color: Colors.red.shade700),
tooltip: tooltip,
onPressed: onPressed,
);
}
/// Construit les lignes parents / enfants depuis un dossier unifié.
List<SuppressionPersonLine> suppressionPeopleFromDossier({
required bool isFamille,
required List<({String nom, String prenom, String email})> parents,
required List<({String nom, String prenom})> enfants,
String? amName,
}) {
final lines = <SuppressionPersonLine>[];
if (isFamille) {
for (final p in parents) {
final label = formatDossierPersonLabel(
nom: p.nom,
prenom: p.prenom,
email: p.email,
);
if (label.isEmpty) continue;
lines.add(SuppressionPersonLine.parent(label));
}
for (final e in enfants) {
final label = formatDossierPersonLabel(nom: e.nom, prenom: e.prenom);
if (label.isEmpty) continue;
lines.add(SuppressionPersonLine.enfant(label));
}
} else if ((amName ?? '').trim().isNotEmpty) {
lines.add(SuppressionPersonLine.am(amName!.trim()));
}
return lines;
}
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_list_state.dart';
import 'package:p_tits_pas/widgets/dashboard/user_list_state.dart';
class UserList extends StatelessWidget {
final bool isLoading;
@@ -28,7 +28,7 @@ class UserList extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AdminListState(
UserListState(
isLoading: isLoading,
error: error,
isEmpty: isEmpty,
@@ -4,7 +4,7 @@ import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/utils/nir_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/utils/postal_utils.dart';
import 'admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/detail_modal.dart';
/// Réglages des formulaires validation / wizard AM — **jouer sur ces 3 leviers**.
class ValidationFormMetrics {
@@ -60,7 +60,7 @@ class ValidationFormMetrics {
class ValidationDetailSection extends StatelessWidget {
/// Si null ou vide, pas de bandeau titre (gain de place vertical, ex. wizard AM).
final String? title;
final List<AdminDetailField> fields;
final List<DetailField> fields;
/// Nombre de champs par ligne (1 = plein largeur, 2 = deux côte à côte). Ex. [2, 2, 1, 2] pour identité.
final List<int>? rowLayout;
@@ -1,23 +1,23 @@
import 'package:flutter/material.dart';
class AdminDetailField {
class DetailField {
final String label;
final String value;
const AdminDetailField({
const DetailField({
required this.label,
required this.value,
});
}
class AdminDetailModal extends StatelessWidget {
class DetailModal extends StatelessWidget {
final String title;
final String? subtitle;
final List<AdminDetailField> fields;
final List<DetailField> fields;
final VoidCallback onEdit;
final VoidCallback onDelete;
const AdminDetailModal({
const DetailModal({
super.key,
required this.title,
this.subtitle,
@@ -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/dashboard/user_card.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
/// Carte dossier unifiée (#153) — fond neutre, accent couleur sur licô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 denfants (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');
return AdminUserCard(
final subtitle = <String>[
if (names.isNotEmpty) names,
if (emptyKids) 'Sans enfant',
if (!emptyKids && countLabel != null) countLabel,
];
return UserCard(
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),
],
);
}
@@ -1,9 +1,12 @@
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/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';
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
import 'package:p_tits_pas/widgets/dashboard/dossier_list_card.dart';
import 'package:p_tits_pas/widgets/dashboard/pending_validation_widget.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_dossier_modal.dart';
/// Onglet permanent « Dossiers » (#153) : pending en haut + liste unifiée en bas.
class DossiersManagementWidget extends StatefulWidget {
@@ -27,13 +30,21 @@ class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
List<DossierListItem> _all = [];
Set<String> _pendingNumeros = {};
int _pendingRefreshTick = 0;
bool _canDelete = false;
@override
void initState() {
super.initState();
_loadRights();
_loadAll();
}
Future<void> _loadRights() async {
final user = await AuthService.getCurrentUser();
if (!mounted) return;
setState(() => _canDelete = canDeleteMetier(user?.role));
}
Future<void> _loadAll() async {
setState(() {
_loading = true;
@@ -42,12 +53,29 @@ class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
try {
final parents = await UserService.getParents();
final ams = await UserService.getAssistantesMaternelles();
Map<String, bool> sansEnfant = {};
try {
sansEnfant = await UserService.getSansEnfantByNumero();
} catch (_) {
// Flag optionnel : ne bloque pas la liste.
}
if (!mounted) return;
final items = <DossierListItem>[
...DossierListItem.fromParents(parents),
...DossierListItem.fromAssistantes(ams),
];
].map((item) {
if (!item.isFamille) return item;
final apiFlag = sansEnfant[item.numeroDossier] == true;
final localEmpty = (item.enfantsCount ?? 0) == 0;
return item.copyWith(
sansEnfant: apiFlag || localEmpty || item.sansEnfant,
);
}).toList();
items.sort((a, b) {
// Dossiers sans enfant en tête (#160), comme orphelins #157.
final ae = a.sansEnfant ? 0 : 1;
final be = b.sansEnfant ? 0 : 1;
if (ae != be) return ae.compareTo(be);
final byNum = a.numeroDossier.compareTo(b.numeroDossier);
if (byNum != 0) return byNum;
return a.typeLabel.compareTo(b.typeLabel);
@@ -95,6 +123,95 @@ class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
);
}
Future<void> _confirmDeleteDossier(DossierListItem item) async {
final num = item.numeroDossier.trim();
if (num.isEmpty) return;
var people = <SuppressionPersonLine>[];
String? fallbackSummary;
try {
final dossier = await UserService.getDossier(num);
if (dossier.isFamily) {
final f = dossier.asFamily;
people = suppressionPeopleFromDossier(
isFamille: true,
parents: f.parents
.map((p) => (
nom: p.nom ?? '',
prenom: p.prenom ?? '',
email: p.email,
))
.toList(),
enfants: f.enfants
.map((e) => (
nom: e.lastName ?? '',
prenom: e.firstName ?? '',
))
.toList(),
);
} else {
final am = dossier.asAm.user;
people = suppressionPeopleFromDossier(
isFamille: false,
parents: const [],
enfants: const [],
amName: formatDossierPersonLabel(
nom: am.nom,
prenom: am.prenom,
email: am.email,
),
);
}
} catch (_) {
fallbackSummary = item.isFamille
? 'Tous les parents et enfants rattachés seront supprimés.'
: 'Le compte AM sera supprimé ; les enfants accueillis '
'seront conservés.';
if (item.namesLine.trim().isNotEmpty) {
for (final part in item.namesLine.split(' - ')) {
final label = part.trim();
if (label.isEmpty) continue;
people.add(SuppressionPersonLine(
label: label,
icon: item.isFamille
? Icons.supervisor_account_outlined
: Icons.face,
role: item.isFamille ? 'Parent' : 'AM',
));
}
}
}
if (!mounted) return;
final confirmed = await showDossierSuppressionConfirmDialog(
context,
numeroDossier: num,
isFamille: item.isFamille,
people: people,
fallbackSummary: fallbackSummary,
);
if (!confirmed || !mounted) return;
try {
final result = await UserService.deleteDossier(num);
if (!mounted) return;
final msg = (result['message'] ?? 'Dossier supprimé.').toString();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
await _refreshEverything();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur suppression',
),
),
);
}
}
@override
Widget build(BuildContext context) {
final query = widget.searchQuery;
@@ -115,6 +232,7 @@ class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
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<DossiersManagementWidget> {
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,
@@ -0,0 +1,245 @@
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/dashboard/child_detail_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/enfant_user_card.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/dashboard/common/user_list.dart';
/// Onglet liste globale des enfants (doc 28 §6.2, ticket #137).
class EnfantManagementWidget extends StatefulWidget {
final String searchQuery;
final String? statusFilter;
const EnfantManagementWidget({
super.key,
required this.searchQuery,
this.statusFilter,
});
@override
State<EnfantManagementWidget> createState() => _EnfantManagementWidgetState();
}
class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
bool _isLoading = false;
String? _error;
List<EnfantAdminModel> _enfants = [];
bool _canDelete = false;
@override
void initState() {
super.initState();
_loadRights();
_loadEnfants();
}
Future<void> _loadRights() async {
final user = await AuthService.getCurrentUser();
if (!mounted) return;
setState(() => _canDelete = canDeleteMetier(user?.role));
}
Future<void> _loadEnfants() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final list = await UserService.getEnfants();
if (!mounted) return;
setState(() {
_enfants = list;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
Future<void> _openEnfant(EnfantAdminModel enfant) async {
await showDialog<void>(
context: context,
builder: (ctx) => ChildDetailModal(
enfant: enfant,
onSaved: _loadEnfants,
onDeleted: _loadEnfants,
),
);
}
Future<({String? numero, String famille, bool isLast, String? amLabel})>
_resolveContext(
EnfantAdminModel enfant,
) async {
String? amLabel;
try {
final am = await UserService.findAmForEnfant(enfant.id);
if (am != null) {
final label = formatDossierPersonLabel(
nom: am.user.nom,
prenom: am.user.prenom,
email: am.user.email,
);
if (label.isNotEmpty) amLabel = label;
}
} catch (_) {}
final parentId = enfant.parentLinks
.map((l) => l.parentId.trim())
.firstWhere((id) => id.isNotEmpty, orElse: () => '');
if (parentId.isEmpty) {
return (numero: null, famille: '', isLast: true, amLabel: amLabel);
}
try {
final parent = await UserService.getParent(parentId);
final num = (parent.user.numeroDossier ?? '').trim();
final famille = parent.user.fullName.isNotEmpty
? parent.user.fullName
: parent.user.email;
if (num.isEmpty) {
return (
numero: null,
famille: famille,
isLast: true,
amLabel: amLabel,
);
}
final dossier = await UserService.getDossier(num);
if (!dossier.isFamily) {
return (
numero: num,
famille: famille,
isLast: true,
amLabel: amLabel,
);
}
final n = dossier.asFamily.enfants.length;
final names = dossier.asFamily.parents
.map((p) => formatDossierPersonLabel(
nom: p.nom,
prenom: p.prenom,
email: p.email,
))
.where((s) => s.isNotEmpty)
.join(' - ');
return (
numero: num,
famille: names.isNotEmpty ? names : famille,
isLast: n <= 1,
amLabel: amLabel,
);
} catch (_) {
return (numero: null, famille: '', isLast: false, amLabel: amLabel);
}
}
Future<void> _confirmDelete(EnfantAdminModel enfant) async {
final ctx = await _resolveContext(enfant);
if (!mounted) return;
bool deleteDossier = false;
if (ctx.isLast && (ctx.numero ?? '').isNotEmpty) {
final choice = await showDernierEnfantSuppressionDialog(
context,
enfantName: enfant.fullName,
familleLabel: ctx.famille,
numeroDossier: ctx.numero!,
amLabel: ctx.amLabel,
);
if (choice == null || !mounted) return;
deleteDossier =
choice == DernierEnfantSuppressionChoice.dossierAussi;
} else {
final confirmed = await showSuppressionConfirmDialog(
context,
title: 'Supprimer l\'enfant',
subtitle: (ctx.numero ?? '').isEmpty
? null
: 'Dossier ${ctx.numero}',
people: [SuppressionPersonLine.enfant(enfant.fullName)],
footnotes: enfantSuppressionFootnotes(
numeroDossier: ctx.numero,
familleLabel: ctx.famille,
amLabel: ctx.amLabel,
),
);
if (!confirmed || !mounted) return;
}
try {
final result = await UserService.deleteEnfant(
enfant.id,
deleteDossier: deleteDossier,
);
if (!mounted) return;
final msg = (result['message'] ?? 'Enfant supprimé.').toString();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
await _loadEnfants();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur suppression',
),
),
);
}
}
@override
Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase();
final filtered = _enfants.where((e) {
final matchesName = e.fullName.toLowerCase().contains(query);
final matchesStatus = widget.statusFilter == null ||
normalizeEnfantStatus(e.status) ==
normalizeEnfantStatus(widget.statusFilter);
return matchesName && matchesStatus;
}).toList()
..sort((a, b) {
// Orphelins (#157) en tête, puis ordre alphabétique.
final ao = a.hasNoResponsable ? 0 : 1;
final bo = b.hasNoResponsable ? 0 : 1;
if (ao != bo) return ao.compareTo(bo);
return a.fullName.toLowerCase().compareTo(b.fullName.toLowerCase());
});
return UserList(
isLoading: _isLoading,
error: _error,
isEmpty: filtered.isEmpty,
emptyMessage: 'Aucun enfant trouvé.',
itemCount: filtered.length,
itemBuilder: (context, index) {
final enfant = filtered[index];
return EnfantUserCard.fromEnfant(
enfant,
onCardTap: () => _openEnfant(enfant),
actions: [
IconButton(
icon: const Icon(Icons.visibility_outlined),
tooltip: 'Voir / modifier',
onPressed: () => _openEnfant(enfant),
),
if (_canDelete)
suppressionIconButton(
onPressed: () => _confirmDelete(enfant),
),
],
);
},
);
}
}
@@ -4,7 +4,7 @@ import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
import 'package:p_tits_pas/utils/enfant_vigilance.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/dashboard/user_card.dart';
List<String> enfantAdminSubtitleLines({
required String status,
@@ -29,7 +29,7 @@ List<String> enfantAdminSubtitleLines({
}
/// Carte enfant admin (photo, nom, âge) — même rendu onglet Enfants / fiche parent.
class AdminEnfantUserCard extends StatelessWidget {
class EnfantUserCard extends StatelessWidget {
final String title;
final String? photoUrl;
final List<String> subtitleLines;
@@ -40,7 +40,7 @@ class AdminEnfantUserCard extends StatelessWidget {
final Color? borderColor;
final String? vigilanceTooltip;
const AdminEnfantUserCard({
const EnfantUserCard({
super.key,
required this.title,
this.photoUrl,
@@ -53,7 +53,7 @@ class AdminEnfantUserCard extends StatelessWidget {
this.vigilanceTooltip,
});
factory AdminEnfantUserCard.fromEnfant(
factory EnfantUserCard.fromEnfant(
EnfantAdminModel enfant, {
List<String> extraSubtitleLines = const [],
List<Widget> actions = const [],
@@ -67,7 +67,7 @@ class AdminEnfantUserCard extends StatelessWidget {
.map((l) => l.parentName ?? 'Parent')
.join(', ');
final orphan = enfantHasNoResponsable(enfant);
return AdminEnfantUserCard(
return EnfantUserCard(
title: enfant.fullName,
photoUrl: enfant.photoUrl,
subtitleLines: enfantAdminSubtitleLines(
@@ -92,7 +92,7 @@ class AdminEnfantUserCard extends StatelessWidget {
);
}
factory AdminEnfantUserCard.fromSummary(
factory EnfantUserCard.fromSummary(
ParentChildSummary child, {
List<String> extraSubtitleLines = const [],
List<Widget> actions = const [],
@@ -100,7 +100,7 @@ class AdminEnfantUserCard extends StatelessWidget {
EdgeInsetsGeometry? margin,
EdgeInsetsGeometry? contentPadding,
}) {
return AdminEnfantUserCard(
return EnfantUserCard(
title: child.fullName,
photoUrl: child.photoUrl,
subtitleLines: enfantAdminSubtitleLines(
@@ -118,7 +118,7 @@ class AdminEnfantUserCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AdminUserCard(
return UserCard(
title: title,
fallbackIcon: Icons.child_care_outlined,
avatarUrl: photoUrl,
@@ -1,9 +1,12 @@
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/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
import 'package:p_tits_pas/widgets/dashboard/user_card.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/dashboard/common/user_list.dart';
class GestionnaireManagementWidget extends StatefulWidget {
final String searchQuery;
@@ -23,16 +26,28 @@ class _GestionnaireManagementWidgetState
bool _isLoading = false;
String? _error;
List<AppUser> _gestionnaires = [];
bool _canDelete = false;
String? _currentUserId;
@override
void initState() {
super.initState();
_loadRights();
_loadGestionnaires();
}
@override
void dispose() => super.dispose();
Future<void> _loadRights() async {
final user = await AuthService.getCurrentUser();
if (!mounted) return;
setState(() {
_canDelete = canDeleteGestionnaire(user?.role);
_currentUserId = user?.id;
});
}
Future<void> _loadGestionnaires() async {
setState(() {
_isLoading = true;
@@ -67,6 +82,46 @@ class _GestionnaireManagementWidgetState
}
}
Future<void> _confirmDelete(AppUser user) async {
if (_currentUserId != null && _currentUserId == user.id) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Vous ne pouvez pas supprimer votre propre compte.'),
),
);
return;
}
final name = user.fullName.isNotEmpty ? user.fullName : user.email;
final confirmed = await showSuppressionConfirmDialog(
context,
title: 'Supprimer le gestionnaire',
people: [SuppressionPersonLine.gestionnaire(name)],
footnotes: const [
'Le compte sera définitivement supprimé.',
],
);
if (!confirmed || !mounted) return;
try {
final result = await UserService.deleteUser(user.id);
if (!mounted) return;
final msg = (result['message'] ?? 'Gestionnaire supprimé.').toString();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
await _loadGestionnaires();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur suppression',
),
),
);
}
}
@override
Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase();
@@ -84,7 +139,9 @@ class _GestionnaireManagementWidgetState
itemCount: filteredGestionnaires.length,
itemBuilder: (context, index) {
final user = filteredGestionnaires[index];
return AdminUserCard(
final isSelf =
_currentUserId != null && _currentUserId == user.id;
return UserCard(
title: user.fullName,
fallbackIcon: Icons.assignment_ind_outlined,
avatarUrl: user.photoUrl,
@@ -102,6 +159,8 @@ class _GestionnaireManagementWidgetState
_openGestionnaireEditDialog(user);
},
),
if (_canDelete && !isSelf)
suppressionIconButton(onPressed: () => _confirmDelete(user)),
],
);
},
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:p_tits_pas/services/configuration_service.dart';
import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/widgets/admin/relais_management_panel.dart';
import 'package:p_tits_pas/widgets/dashboard/relais_management_panel.dart';
/// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé.
class ParametresPanel extends StatefulWidget {
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart';
import 'package:p_tits_pas/widgets/dashboard/parent_dossier_wizard.dart';
/// Modale de création dossier famille (#129) — même shell que [AmDossierCreateModal].
class ParentDossierCreateModal extends StatefulWidget {
@@ -14,11 +14,11 @@ 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/phone_utils.dart';
import 'package:p_tits_pas/utils/postal_utils.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.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/admin/validation_refus_form.dart';
import 'package:p_tits_pas/widgets/admin/validation_valider_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/dashboard/am_photo_frame.dart';
import 'package:p_tits_pas/widgets/dashboard/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_refus_form.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_valider_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart';
@@ -860,7 +860,7 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
child: SizedBox(
width: pw,
height: ph,
child: AdminAmPhotoFrame(
child: AmPhotoFrame(
photoUrl: child.photoBytes == null
? child.existingPhotoUrl
: null,
@@ -1466,7 +1466,6 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
final map = <String, dynamic>{
'genre': c.genre,
'consent_photo': true,
'grossesse_multiple': false,
};
if (prenom.length >= 2) {
@@ -1723,7 +1722,6 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
'status': status,
'gender': gender,
'consent_photo': true,
'is_multiple': false,
};
if (prenom.length >= 2) map['first_name'] = prenom;
if (nom.length >= 2) map['last_name'] = nom;
@@ -4,30 +4,30 @@ import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
import 'package:p_tits_pas/widgets/dashboard/child_detail_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/children_affiliation_panel.dart';
import 'package:p_tits_pas/widgets/dashboard/select_enfant_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/status_capsule.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart';
/// Fiche parent éditable (doc 28 §6.1, tickets #131 / #138).
/// Shell et typo alignés sur [ValidationDossierModal] / wizards validation.
class AdminParentEditModal extends StatefulWidget {
class ParentEditModal extends StatefulWidget {
final ParentModel parent;
final VoidCallback? onSaved;
const AdminParentEditModal({
const ParentEditModal({
super.key,
required this.parent,
this.onSaved,
});
@override
State<AdminParentEditModal> createState() => _AdminParentEditModalState();
State<ParentEditModal> createState() => _ParentEditModalState();
}
class _AdminParentEditModalState extends State<AdminParentEditModal> {
class _ParentEditModalState extends State<ParentEditModal> {
late final TextEditingController _nomCtrl;
late final TextEditingController _prenomCtrl;
late final TextEditingController _emailCtrl;
@@ -129,7 +129,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AdminParentEditModal(
builder: (ctx) => ParentEditModal(
parent: parent,
onSaved: () async {
try {
@@ -244,7 +244,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AdminChildDetailModal(
builder: (ctx) => ChildDetailModal(
enfant: enfant,
onSaved: _reloadChildren,
),
@@ -322,7 +322,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
Future<void> _attachChild() async {
if (!mounted) return;
final selected = await AdminSelectEnfantModal.show(
final selected = await SelectEnfantModal.show(
context,
excludeIds: _children.map((c) => c.id).toSet(),
title: 'Rattacher un enfant',
@@ -350,7 +350,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
}
Widget _childrenPanel() {
return AdminChildrenAffiliationPanel(
return ChildrenAffiliationPanel(
children: _children,
scrollController: _childrenScrollCtrl,
onOpen: _openChild,
@@ -437,7 +437,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
const SizedBox(width: 12),
SizedBox(
width: 160,
child: AdminStatusCapsule(
child: StatusCapsule(
statut: _statut,
onChanged: (v) => setState(() {
_statut = v;
@@ -1,9 +1,13 @@
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/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/user_list.dart';
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
import 'package:p_tits_pas/widgets/dashboard/parent_edit_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/user_card.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/dashboard/common/user_list.dart';
class ParentManagementWidget extends StatefulWidget {
final String searchQuery;
@@ -23,16 +27,24 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
bool _isLoading = false;
String? _error;
List<ParentModel> _parents = [];
bool _canDelete = false;
@override
void initState() {
super.initState();
_loadRights();
_loadParents();
}
@override
void dispose() => super.dispose();
Future<void> _loadRights() async {
final user = await AuthService.getCurrentUser();
if (!mounted) return;
setState(() => _canDelete = canDeleteMetier(user?.role));
}
Future<void> _loadParents() async {
setState(() {
_isLoading = true;
@@ -54,6 +66,67 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
}
}
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<void> _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
? <String>[
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.',
]
: <String>[
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();
@@ -73,7 +146,7 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
itemCount: filteredParents.length,
itemBuilder: (context, index) {
final parent = filteredParents[index];
return AdminUserCard(
return UserCard(
title: parent.user.fullName,
fallbackIcon: Icons.supervisor_account_outlined,
avatarUrl: parent.user.photoUrl,
@@ -90,6 +163,8 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
_openParentDetails(parent);
},
),
if (_canDelete)
suppressionIconButton(onPressed: () => _confirmDelete(parent)),
],
);
},
@@ -114,7 +189,7 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
void _openParentDetails(ParentModel parent) {
showDialog<void>(
context: context,
builder: (context) => AdminParentEditModal(
builder: (context) => ParentEditModal(
parent: parent,
onSaved: _loadParents,
),
@@ -3,8 +3,9 @@ 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/widgets/admin/dossier_list_card.dart';
import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/dossier_list_card.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_dossier_modal.dart';
/// Section « dossiers à valider » (liste unifiée AM + familles). Ticket #107 / #153.
class PendingValidationWidget extends StatefulWidget {
@@ -15,6 +16,8 @@ class PendingValidationWidget extends StatefulWidget {
final bool compactWhenEmpty;
/// Numéros des dossiers pending (pour exclure de « Tous les dossiers »).
final ValueChanged<Set<String>>? 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<PendingValidationWidget> {
);
}
Future<void> _confirmDeletePending({
required String numeroDossier,
required String namesLine,
required bool isFamille,
}) async {
final num = numeroDossier.trim();
if (num.isEmpty) return;
var people = <SuppressionPersonLine>[];
String? fallbackSummary;
try {
final dossier = await UserService.getDossier(num);
if (dossier.isFamily) {
final f = dossier.asFamily;
people = suppressionPeopleFromDossier(
isFamille: true,
parents: f.parents
.map((p) => (
nom: p.nom ?? '',
prenom: p.prenom ?? '',
email: p.email,
))
.toList(),
enfants: f.enfants
.map((e) => (
nom: e.lastName ?? '',
prenom: e.firstName ?? '',
))
.toList(),
);
} else {
final am = dossier.asAm.user;
people = suppressionPeopleFromDossier(
isFamille: false,
parents: const [],
enfants: const [],
amName: formatDossierPersonLabel(
nom: am.nom,
prenom: am.prenom,
email: am.email,
),
);
}
} catch (_) {
fallbackSummary = 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<PendingValidationWidget> {
}
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<PendingValidationWidget> {
namesLine: names,
isFamille: true,
onOpen: () => _onOpenValidation(numeroDossier: family.numeroDossier),
onDelete: widget.canDelete
? () => _confirmDeletePending(
numeroDossier: num,
namesLine: names,
isFamille: true,
)
: null,
);
}
}
@@ -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/dashboard/common/suppression_confirm_dialog.dart';
class RelaisManagementPanel extends StatefulWidget {
const RelaisManagementPanel({super.key});
@@ -56,28 +57,16 @@ class _RelaisManagementPanelState extends State<RelaisManagementPanel> {
try {
if (result.action == _RelaisDialogAction.delete) {
if (relais == null) return;
final confirmed = await showDialog<bool>(
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!);
@@ -3,10 +3,10 @@ import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/am_vigilance.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
import 'package:p_tits_pas/widgets/dashboard/am_edit_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/select_list_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/user_card.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart';
List<String> _amSelectSubtitleLines(AssistanteMaternelleModel am) {
final lines = <String>[];
@@ -28,22 +28,22 @@ List<String> _amSelectSubtitleLines(AssistanteMaternelleModel am) {
}
/// Sélection d'une AM à rattacher (fiche enfant) — ticket #147.
/// S'appuie sur [AdminSelectListModal] (shell partagé avec #146).
class AdminSelectAmModal {
AdminSelectAmModal._();
/// S'appuie sur [SelectListModal] (shell partagé avec #146).
class SelectAmModal {
SelectAmModal._();
static Future<AssistanteMaternelleModel?> show(
BuildContext context, {
Set<String> excludeIds = const {},
String title = 'Choisir une assistante maternelle',
}) {
return AdminSelectListModal.show<AssistanteMaternelleModel>(
return SelectListModal.show<AssistanteMaternelleModel>(
context,
title: title,
searchHint: 'Rechercher par nom, prénom ou zone…',
emptyMessage: 'Aucune assistante maternelle disponible',
noResultsMessage: 'Aucune AM avec place libre pour cette recherche',
toggleFilter: const AdminSelectToggleFilter<AssistanteMaternelleModel>(
toggleFilter: const SelectToggleFilter<AssistanteMaternelleModel>(
label: 'Libre',
initialValue: true,
whenEnabled: amHasFreePlace,
@@ -76,7 +76,7 @@ class AdminSelectAmModal {
_resolveAmSelection(ctx, am, reload),
itemBuilder: (context, am, onSelect) {
final full = !amHasFreePlace(am);
return AdminUserCard(
return UserCard(
title: am.user.fullName,
avatarUrl: am.user.photoUrl,
fallbackIcon: Icons.face,
@@ -150,7 +150,7 @@ class _AmNoPlaceWarningDialogState extends State<_AmNoPlaceWarningDialog> {
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AdminAmEditModal(
builder: (ctx) => AmEditModal(
assistante: _am,
onSaved: () async {
try {
@@ -2,13 +2,13 @@ import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/enfant_user_card.dart';
import 'package:p_tits_pas/widgets/dashboard/select_list_modal.dart';
/// Sélection d'un enfant à rattacher (fiche AM / fiche parent) — ticket #146.
/// S'appuie sur [AdminSelectListModal] (shell partagé avec #147).
class AdminSelectEnfantModal {
AdminSelectEnfantModal._();
/// S'appuie sur [SelectListModal] (shell partagé avec #147).
class SelectEnfantModal {
SelectEnfantModal._();
static Future<EnfantAdminModel?> show(
BuildContext context, {
@@ -17,7 +17,7 @@ class AdminSelectEnfantModal {
/// Affiché uniquement depuis la fiche AM : filtre les enfants déjà en garde.
bool showSansGardeFilter = false,
}) {
return AdminSelectListModal.show<EnfantAdminModel>(
return SelectListModal.show<EnfantAdminModel>(
context,
title: title,
searchHint: 'Rechercher par nom ou prénom…',
@@ -26,7 +26,7 @@ class AdminSelectEnfantModal {
? 'Aucun enfant sans garde pour cette recherche'
: 'Aucun résultat pour cette recherche',
toggleFilter: showSansGardeFilter
? AdminSelectToggleFilter<EnfantAdminModel>(
? SelectToggleFilter<EnfantAdminModel>(
label: 'Sans garde',
initialValue: true,
whenEnabled: (e) =>
@@ -50,7 +50,7 @@ class AdminSelectEnfantModal {
return name.contains(q) || fn.contains(q) || ln.contains(q);
},
itemBuilder: (context, e, onSelect) {
return AdminEnfantUserCard.fromEnfant(
return EnfantUserCard.fromEnfant(
e,
onCardTap: onSelect,
margin: const EdgeInsets.only(bottom: 4),
@@ -1,11 +1,11 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/dashboard/select_list_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/user_card.dart';
/// Foyer / famille sélectionnable pour rattacher un nouvel enfant (#132 / #157).
class AdminFamilleFoyer {
class FamilleFoyer {
/// Parent pivot pour `POST /enfants` (`parent_user_id`).
final String pivotParentUserId;
/// Co-parent éventuel (rattachement foyer #157).
@@ -14,7 +14,7 @@ class AdminFamilleFoyer {
final String displayTitle;
final List<String> parentNames;
const AdminFamilleFoyer({
const FamilleFoyer({
required this.pivotParentUserId,
required this.displayTitle,
required this.parentNames,
@@ -42,10 +42,10 @@ class AdminFamilleFoyer {
}
/// Construit la liste des foyers uniques à partir de `GET /parents`.
List<AdminFamilleFoyer> buildFamilleFoyers(List<ParentModel> parents) {
List<FamilleFoyer> buildFamilleFoyers(List<ParentModel> parents) {
final seenDossiers = <String>{};
final seenUserIds = <String>{};
final foyers = <AdminFamilleFoyer>[];
final foyers = <FamilleFoyer>[];
for (final p in parents) {
final dossier = (p.user.numeroDossier ?? '').trim();
@@ -70,7 +70,7 @@ List<AdminFamilleFoyer> buildFamilleFoyers(List<ParentModel> parents) {
: (names.isNotEmpty ? names.first : 'Famille');
foyers.add(
AdminFamilleFoyer(
FamilleFoyer(
pivotParentUserId: p.user.id,
coParentUserId: co?.id,
numeroDossier: dossier.isNotEmpty ? dossier : null,
@@ -87,14 +87,14 @@ List<AdminFamilleFoyer> buildFamilleFoyers(List<ParentModel> parents) {
}
/// Sélection d'une famille / dossier pour créer un enfant — ticket #132.
class AdminSelectFamilleModal {
AdminSelectFamilleModal._();
class SelectFamilleModal {
SelectFamilleModal._();
static Future<AdminFamilleFoyer?> show(
static Future<FamilleFoyer?> show(
BuildContext context, {
String title = 'Choisir une famille',
}) {
return AdminSelectListModal.show<AdminFamilleFoyer>(
return SelectListModal.show<FamilleFoyer>(
context,
title: title,
searchHint: 'Rechercher par dossier, nom…',
@@ -111,7 +111,7 @@ class AdminSelectFamilleModal {
return dossier.contains(q) || title.contains(q) || names.contains(q);
},
itemBuilder: (context, f, onSelect) {
return AdminUserCard(
return UserCard(
title: f.displayTitle,
fallbackIcon: Icons.family_restroom,
subtitleLines: [
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart';
/// Filtre optionnel (switch) sur la même ligne que la barre de recherche.
class AdminSelectToggleFilter<T> {
class SelectToggleFilter<T> {
final String label;
final bool initialValue;
@@ -10,7 +10,7 @@ class AdminSelectToggleFilter<T> {
/// [whenEnabled] renvoie `true`.
final bool Function(T item) whenEnabled;
const AdminSelectToggleFilter({
const SelectToggleFilter({
required this.label,
required this.whenEnabled,
this.initialValue = true,
@@ -19,7 +19,7 @@ class AdminSelectToggleFilter<T> {
/// Shell générique « rechercher + liste + sélection » pour les modales admin.
/// Utilisé par la sélection d'enfant (#146) et la sélection d'AM (#147).
class AdminSelectListModal<T> extends StatefulWidget {
class SelectListModal<T> extends StatefulWidget {
final String title;
final String searchHint;
final Future<List<T>> Function() loadItems;
@@ -40,7 +40,7 @@ class AdminSelectListModal<T> extends StatefulWidget {
final int minVisibleCards;
/// Switch optionnel à droite du champ de recherche (ex. « Sans garde », « Libre »).
final AdminSelectToggleFilter<T>? toggleFilter;
final SelectToggleFilter<T>? toggleFilter;
/// Si fourni, appelé avant de valider la sélection.
/// Retourne l'élément à pop (éventuellement rafraîchi), ou `null` pour annuler.
@@ -50,7 +50,7 @@ class AdminSelectListModal<T> extends StatefulWidget {
Future<void> Function() reload,
)? resolveSelect;
const AdminSelectListModal({
const SelectListModal({
super.key,
required this.title,
required this.loadItems,
@@ -82,7 +82,7 @@ class AdminSelectListModal<T> extends StatefulWidget {
double modalWidth = 930,
double cardExtent = 52,
int minVisibleCards = 8,
AdminSelectToggleFilter<T>? toggleFilter,
SelectToggleFilter<T>? toggleFilter,
Future<T?> Function(
BuildContext context,
T item,
@@ -91,7 +91,7 @@ class AdminSelectListModal<T> extends StatefulWidget {
}) {
return showDialog<T>(
context: context,
builder: (ctx) => AdminSelectListModal<T>(
builder: (ctx) => SelectListModal<T>(
title: title,
loadItems: loadItems,
matchesQuery: matchesQuery,
@@ -109,11 +109,11 @@ class AdminSelectListModal<T> extends StatefulWidget {
}
@override
State<AdminSelectListModal<T>> createState() =>
_AdminSelectListModalState<T>();
State<SelectListModal<T>> createState() =>
_SelectListModalState<T>();
}
class _AdminSelectListModalState<T> extends State<AdminSelectListModal<T>> {
class _SelectListModalState<T> extends State<SelectListModal<T>> {
final _searchCtrl = TextEditingController();
List<T> _all = [];
bool _loading = true;
@@ -1,13 +1,13 @@
import 'package:flutter/material.dart';
/// Gélule de sélection du statut utilisateur (fiches admin parent / AM).
class AdminStatusCapsule extends StatelessWidget {
class StatusCapsule extends StatelessWidget {
final String statut;
final ValueChanged<String>? onChanged;
static const statuts = ['actif', 'en_attente', 'suspendu', 'refuse'];
const AdminStatusCapsule({
const StatusCapsule({
super.key,
required this.statut,
this.onChanged,
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
class AdminUserCard extends StatefulWidget {
class UserCard extends StatefulWidget {
final String title;
final List<String> subtitleLines;
final String? avatarUrl;
@@ -21,7 +21,7 @@ class AdminUserCard extends StatefulWidget {
final EdgeInsetsGeometry? margin;
final EdgeInsetsGeometry? contentPadding;
const AdminUserCard({
const UserCard({
super.key,
required this.title,
required this.subtitleLines,
@@ -41,10 +41,10 @@ class AdminUserCard extends StatefulWidget {
});
@override
State<AdminUserCard> createState() => _AdminUserCardState();
State<UserCard> createState() => _UserCardState();
}
class _AdminUserCardState extends State<AdminUserCard> {
class _UserCardState extends State<UserCard> {
bool _isHovered = false;
@override
@@ -1,13 +1,13 @@
import 'package:flutter/material.dart';
class AdminListState extends StatelessWidget {
class UserListState extends StatelessWidget {
final bool isLoading;
final String? error;
final bool isEmpty;
final String emptyMessage;
final Widget list;
const AdminListState({
const UserListState({
super.key,
required this.isLoading,
required this.error,
@@ -1,23 +1,27 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.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/dashboard/am_dossier_create_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/assistante_maternelle_management_widget.dart';
import 'package:p_tits_pas/widgets/dashboard/child_detail_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/user_management_sub_bar.dart';
import 'package:p_tits_pas/widgets/dashboard/dossiers_management_widget.dart';
import 'package:p_tits_pas/widgets/dashboard/enfant_management_widget.dart';
import 'package:p_tits_pas/widgets/dashboard/gestionnaire_management_widget.dart';
import 'package:p_tits_pas/widgets/dashboard/parent_dossier_create_modal.dart';
import 'package:p_tits_pas/widgets/dashboard/parent_managmant_widget.dart';
class UserManagementPanel extends StatefulWidget {
/// Afficher l'onglet Administrateurs (sinon sans Administrateurs).
final bool showAdministrateursTab;
/// Création gestionnaire / admin (#161). False pour le dashboard gestionnaire.
final bool allowStaffAccountCreation;
const UserManagementPanel({
super.key,
this.showAdministrateursTab = true,
this.allowStaffAccountCreation = true,
});
@override
@@ -86,6 +90,15 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
bool get _isDossiersTab => _subIndex == 0;
bool get _isStaffAccountsTab =>
_subIndex == 4 || (widget.showAdministrateursTab && _subIndex == 5);
bool get _canShowAddButton {
if (_isDossiersTab) return false;
if (_isStaffAccountsTab && !widget.allowStaffAccountCreation) return false;
return true;
}
String _searchHintForTab() {
switch (_subIndex) {
case 0:
@@ -293,7 +306,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
searchTooltip: _searchTooltipForTab(),
filterControl: _subBarFilterControl(),
// Pas de « Créer » sur longlet Dossiers (#153).
onAddPressed: _isDossiersTab ? null : _handleAddPressed,
// Pas de création staff pour le dashboard gestionnaire (#161).
onAddPressed: _canShowAddButton ? _handleAddPressed : null,
addLabel: 'Ajouter',
subTabCount: labels.length,
tabLabels: labels,
@@ -305,6 +319,9 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
Future<void> _handleAddPressed() async {
// 1 Parents, 2 Enfants, 3 AM, 4 Gestionnaires, 5 Admin
if (_isStaffAccountsTab && !widget.allowStaffAccountCreation) {
return;
}
if (_subIndex == 1) {
await showDialog<void>(
context: context,
@@ -331,7 +348,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return AdminChildDetailModal.create(
return ChildDetailModal.create(
onSaved: () {
if (!mounted) return;
setState(() => _enfantRefreshTick++);
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart';
import 'package:p_tits_pas/widgets/dashboard/am_dossier_wizard.dart';
/// Wrapper historique (#107) — délègue à [AmDossierWizard.review].
class ValidationAmWizard extends StatelessWidget {
@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart';
import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart';
import 'package:p_tits_pas/widgets/admin/validation_am_wizard.dart';
import 'package:p_tits_pas/widgets/admin/validation_family_wizard.dart';
import 'package:p_tits_pas/widgets/dashboard/am_dossier_wizard.dart';
import 'package:p_tits_pas/widgets/dashboard/parent_dossier_wizard.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_am_wizard.dart';
import 'package:p_tits_pas/widgets/dashboard/validation_family_wizard.dart';
/// Modale (dialog) : charge le dossier par numéro puis affiche le wizard AM ou Famille.
/// Ticket #107 / #119 (review), #135 (`openAsEdit`).
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart';
import 'package:p_tits_pas/widgets/dashboard/parent_dossier_wizard.dart';
/// Wrapper historique (#107) — délègue à [ParentDossierWizard.review].
class ValidationFamilyWizard extends StatelessWidget {
@@ -74,8 +74,7 @@ try {
date_naissance: '2022-04-20',
genre: 'F',
photo_base64: toDataUri(chloeJpg),
photo_filename: 'chloe_rousseau.jpg',
grossesse_multiple: false,
photo_filename: 'chloe_rousseau.jpg'
},
{
prenom: 'Hugo',
@@ -83,8 +82,7 @@ try {
date_naissance: '2024-03-10',
genre: 'H',
photo_base64: toDataUri(hugoJpg),
photo_filename: 'hugo_rousseau.jpg',
grossesse_multiple: false,
photo_filename: 'hugo_rousseau.jpg'
},
],
presentation_dossier: presentationDossier,
@@ -40,8 +40,7 @@ const body = {
date_naissance: '2023-04-15',
genre: 'H',
photo_base64: toDataUri(path.join(photosDir, 'lecomte-maxime.png')),
photo_filename: 'maxime_lecomte.png',
grossesse_multiple: false,
photo_filename: 'maxime_lecomte.png'
},
],
presentation_dossier: presentationDossier,
@@ -46,8 +46,7 @@ const body = {
date_naissance: '2023-02-15',
genre: 'F',
photo_base64: toDataUri(path.join(photosDir, 'martin-emma.png')),
photo_filename: 'emma_martin.png',
grossesse_multiple: true,
photo_filename: 'emma_martin.png'
},
{
prenom: 'Noah',
@@ -55,8 +54,7 @@ const body = {
date_naissance: '2023-02-15',
genre: 'H',
photo_base64: toDataUri(path.join(photosDir, 'martin-noah.png')),
photo_filename: 'noah_martin.png',
grossesse_multiple: true,
photo_filename: 'noah_martin.png'
},
{
prenom: 'Léa',
@@ -64,8 +62,7 @@ const body = {
date_naissance: '2023-02-15',
genre: 'F',
photo_base64: toDataUri(path.join(photosDir, 'martin-lea.png')),
photo_filename: 'lea_martin.png',
grossesse_multiple: true,
photo_filename: 'lea_martin.png'
},
],
presentation_dossier: presentationDossier,