Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90d984ef92 | ||
|
|
89f65356d1 | ||
|
|
aae2beeac8 | ||
|
|
71b1897678 | ||
|
|
f745079f0a | ||
|
|
3218daa12e | ||
|
|
1d6261b312 | ||
|
|
5b83102a59 | ||
|
|
9557cf9947 | ||
|
|
939e7777ac | ||
|
|
b474842e19 | ||
|
|
a312d9c0fa | ||
|
|
b5b32062b3 | ||
|
|
946d8edcd2 | ||
|
|
eb5e4aa915 | ||
|
|
c8cb82dd24 | ||
|
|
9cd180bf6a | ||
|
|
d6d8b299dd | ||
|
|
ca07d2e111 | ||
|
|
67336c64fe | ||
|
|
97afbbcf9a | ||
|
|
e235b30140 | ||
|
|
ea0e97d930 | ||
|
|
84e46162fd | ||
|
|
14580c34e0 | ||
|
|
ae610733cc | ||
|
|
846afed86c | ||
|
|
99a6c17c23 | ||
|
|
04f49cb62f | ||
|
|
3c7f4f6e16 | ||
|
|
dcd407a3da | ||
|
|
fde63f8e72 | ||
|
|
1f8f1b9507 |
@@ -33,7 +33,6 @@ model Child {
|
|||||||
dateOfBirth DateTime
|
dateOfBirth DateTime
|
||||||
photoUrl String?
|
photoUrl String?
|
||||||
photoConsent Boolean @default(false)
|
photoConsent Boolean @default(false)
|
||||||
isMultiple Boolean @default(false)
|
|
||||||
isUnborn Boolean @default(false)
|
isUnborn Boolean @default(false)
|
||||||
parentId String
|
parentId String
|
||||||
parent Parent @relation(fields: [parentId], references: [id])
|
parent Parent @relation(fields: [parentId], references: [id])
|
||||||
|
|||||||
@@ -63,9 +63,6 @@ export class Children {
|
|||||||
@Column({ type: 'timestamptz', nullable: true, name: 'date_consentement_photo' })
|
@Column({ type: 'timestamptz', nullable: true, name: 'date_consentement_photo' })
|
||||||
consent_photo_at?: Date;
|
consent_photo_at?: Date;
|
||||||
|
|
||||||
@Column({ default: false, name: 'est_multiple', type: 'boolean' })
|
|
||||||
is_multiple: boolean;
|
|
||||||
|
|
||||||
// Lien via table de jointure enfants_parents
|
// Lien via table de jointure enfants_parents
|
||||||
@OneToMany(() => ParentsChildren, pc => pc.child)
|
@OneToMany(() => ParentsChildren, pc => pc.child)
|
||||||
parentLinks: ParentsChildren[];
|
parentLinks: ParentsChildren[];
|
||||||
|
|||||||
@@ -564,7 +564,6 @@ export class AuthService {
|
|||||||
enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE;
|
enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE;
|
||||||
enfant.consent_photo = !!enfantDto.consent_photo;
|
enfant.consent_photo = !!enfantDto.consent_photo;
|
||||||
enfant.consent_photo_at = enfant.consent_photo ? new Date() : null!;
|
enfant.consent_photo_at = enfant.consent_photo ? new Date() : null!;
|
||||||
enfant.is_multiple = enfantDto.grossesse_multiple || false;
|
|
||||||
|
|
||||||
const enfantEnregistre = await manager.save(Children, enfant);
|
const enfantEnregistre = await manager.save(Children, enfant);
|
||||||
enfantsEnregistres.push(enfantEnregistre);
|
enfantsEnregistres.push(enfantEnregistre);
|
||||||
@@ -1387,9 +1386,6 @@ export class AuthService {
|
|||||||
enfant.status = StatutEnfantType.A_NAITRE;
|
enfant.status = StatutEnfantType.A_NAITRE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (enfantDto.grossesse_multiple !== undefined) {
|
|
||||||
enfant.is_multiple = enfantDto.grossesse_multiple;
|
|
||||||
}
|
|
||||||
if (enfantDto.consent_photo !== undefined) {
|
if (enfantDto.consent_photo !== undefined) {
|
||||||
enfant.consent_photo = !!enfantDto.consent_photo;
|
enfant.consent_photo = !!enfantDto.consent_photo;
|
||||||
enfant.consent_photo_at = enfant.consent_photo
|
enfant.consent_photo_at = enfant.consent_photo
|
||||||
|
|||||||
@@ -55,11 +55,6 @@ export class EnfantInscriptionDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
photo_filename?: string;
|
photo_filename?: string;
|
||||||
|
|
||||||
@ApiProperty({ example: false, required: false, description: 'Grossesse multiple (jumeaux, triplés, etc.)' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
grossesse_multiple?: boolean;
|
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: true,
|
example: true,
|
||||||
required: false,
|
required: false,
|
||||||
|
|||||||
@@ -74,11 +74,6 @@ export class CreateEnfantsDto {
|
|||||||
@IsDateString()
|
@IsDateString()
|
||||||
consent_photo_at?: string;
|
consent_photo_at?: string;
|
||||||
|
|
||||||
@ApiProperty({ default: false })
|
|
||||||
@Transform(toBoolean)
|
|
||||||
@IsBoolean()
|
|
||||||
is_multiple: boolean;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parent pivot du foyer — obligatoire pour staff (gestionnaire/admin).
|
* Parent pivot du foyer — obligatoire pour staff (gestionnaire/admin).
|
||||||
* Ignoré / interdit en externe pour un PARENT (ticket #132).
|
* Ignoré / interdit en externe pour un PARENT (ticket #132).
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ export class EnfantResponseDto {
|
|||||||
@ApiProperty({ example: false })
|
@ApiProperty({ example: false })
|
||||||
consent_photo: boolean;
|
consent_photo: boolean;
|
||||||
|
|
||||||
@ApiProperty({ example: false })
|
|
||||||
is_multiple: boolean;
|
|
||||||
|
|
||||||
@ApiProperty({ example: 'UUID-parent' })
|
@ApiProperty({ example: 'UUID-parent' })
|
||||||
parent_id: string;
|
parent_id: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,6 @@ export class EnfantsService {
|
|||||||
photo_url: photoUrl,
|
photo_url: photoUrl,
|
||||||
consent_photo: !!dto.consent_photo,
|
consent_photo: !!dto.consent_photo,
|
||||||
consent_photo_at: consentAt,
|
consent_photo_at: consentAt,
|
||||||
is_multiple: !!dto.is_multiple,
|
|
||||||
});
|
});
|
||||||
await this.childrenRepository.save(child);
|
await this.childrenRepository.save(child);
|
||||||
|
|
||||||
|
|||||||
@@ -54,9 +54,6 @@ export class DossierFamilleEnfantDto {
|
|||||||
description: 'Consentement affichage photo (colonne consentement_photo)',
|
description: 'Consentement affichage photo (colonne consentement_photo)',
|
||||||
})
|
})
|
||||||
consent_photo?: boolean;
|
consent_photo?: boolean;
|
||||||
|
|
||||||
@ApiProperty({ required: false, description: 'Grossesse multiple (est_multiple)' })
|
|
||||||
est_multiple?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Réponse GET /parents/dossier-famille/:numeroDossier – dossier famille complet. Ticket #119 */
|
/** Réponse GET /parents/dossier-famille/:numeroDossier – dossier famille complet. Ticket #119 */
|
||||||
|
|||||||
@@ -370,7 +370,6 @@ export class ParentsService {
|
|||||||
status: child.status,
|
status: child.status,
|
||||||
photo_url: child.photo_url ?? undefined,
|
photo_url: child.photo_url ?? undefined,
|
||||||
consent_photo: child.consent_photo,
|
consent_photo: child.consent_photo,
|
||||||
est_multiple: child.is_multiple,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import 'reflect-metadata';
|
||||||
import { GestionnairesController } from './gestionnaires.controller';
|
import { GestionnairesController } from './gestionnaires.controller';
|
||||||
import { GestionnairesService } from './gestionnaires.service';
|
import { RoleType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
describe('GestionnairesController', () => {
|
describe('GestionnairesController roles (#161)', () => {
|
||||||
let controller: GestionnairesController;
|
it('POST /gestionnaires autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
|
||||||
|
const roles = Reflect.getMetadata('roles', GestionnairesController.prototype.create);
|
||||||
beforeEach(async () => {
|
expect(roles).toEqual(
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
|
||||||
controllers: [GestionnairesController],
|
);
|
||||||
providers: [GestionnairesService],
|
expect(roles).not.toContain(RoleType.GESTIONNAIRE);
|
||||||
}).compile();
|
|
||||||
|
|
||||||
controller = module.get<GestionnairesController>(GestionnairesController);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('PATCH /gestionnaires/:id autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
|
||||||
expect(controller).toBeDefined();
|
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 {
|
export class GestionnairesController {
|
||||||
constructor(private readonly gestionnairesService: GestionnairesService) { }
|
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: 201, description: 'Le gestionnaire a été créé avec succès.', type: Users })
|
||||||
@ApiResponse({ status: 409, description: 'Conflit. L\'email est déjà utilisé.' })
|
@ApiResponse({ status: 409, description: 'Conflit. L\'email est déjà utilisé.' })
|
||||||
@ApiOperation({ summary: 'Création d\'un gestionnaire' })
|
@ApiOperation({ summary: 'Création d\'un gestionnaire (admin / super admin)' })
|
||||||
@ApiBody({ type: CreateGestionnaireDto })
|
@ApiBody({ type: CreateGestionnaireDto })
|
||||||
@Post()
|
@Post()
|
||||||
create(@Body() dto: CreateGestionnaireDto): Promise<Users> {
|
create(@Body() dto: CreateGestionnaireDto): Promise<Users> {
|
||||||
@@ -43,7 +43,7 @@ export class GestionnairesController {
|
|||||||
return this.gestionnairesService.findAll();
|
return this.gestionnairesService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.GESTIONNAIRE, RoleType.SUPER_ADMIN)
|
@Roles(RoleType.GESTIONNAIRE, RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({ summary: 'Récupérer un gestionnaire par ID' })
|
@ApiOperation({ summary: 'Récupérer un gestionnaire par ID' })
|
||||||
@ApiResponse({ status: 400, description: 'ID invalide' })
|
@ApiResponse({ status: 400, description: 'ID invalide' })
|
||||||
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||||
@@ -56,8 +56,8 @@ export class GestionnairesController {
|
|||||||
return this.gestionnairesService.findOne(id);
|
return this.gestionnairesService.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({ summary: 'Mettre à jour un gestionnaire' })
|
@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: 200, description: 'Le gestionnaire a été mis à jour avec succès.', type: Users })
|
||||||
@ApiResponse({ status: 404, description: 'Gestionnaire non trouvé' })
|
@ApiResponse({ status: 404, description: 'Gestionnaire non trouvé' })
|
||||||
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||||
|
|||||||
@@ -1,20 +1,13 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import 'reflect-metadata';
|
||||||
import { UserController } from './user.controller';
|
import { UserController } from './user.controller';
|
||||||
import { UserService } from './user.service';
|
import { RoleType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
describe('UserController', () => {
|
describe('UserController roles (#161)', () => {
|
||||||
let controller: UserController;
|
it('POST /users/admin autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
|
||||||
|
const roles = Reflect.getMetadata('roles', UserController.prototype.createAdmin);
|
||||||
beforeEach(async () => {
|
expect(roles).toEqual(
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
|
||||||
controllers: [UserController],
|
);
|
||||||
providers: [UserService],
|
expect(roles).not.toContain(RoleType.GESTIONNAIRE);
|
||||||
}).compile();
|
|
||||||
|
|
||||||
controller = module.get<UserController>(UserController);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be defined', () => {
|
|
||||||
expect(controller).toBeDefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ export class UserController {
|
|||||||
private readonly suppressionService: SuppressionService,
|
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')
|
@Post('admin')
|
||||||
@Roles(RoleType.SUPER_ADMIN)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({ summary: 'Créer un nouvel administrateur (super admin seulement)' })
|
@ApiOperation({ summary: 'Créer un nouvel administrateur (admin / super admin)' })
|
||||||
createAdmin(
|
createAdmin(
|
||||||
@Body() dto: CreateAdminDto,
|
@Body() dto: CreateAdminDto,
|
||||||
@User() currentUser: Users
|
@User() currentUser: Users
|
||||||
|
|||||||
@@ -1,18 +1,87 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
|
import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
|
describe('UserService.createAdmin (#161)', () => {
|
||||||
|
const usersRepository = {
|
||||||
|
findOneBy: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
describe('UserService', () => {
|
|
||||||
let service: UserService;
|
let service: UserService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
const dto = {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
email: 'nouveau.admin@ptits-pas.fr',
|
||||||
providers: [UserService],
|
password: 'Password1!',
|
||||||
}).compile();
|
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', () => {
|
it('autorise un administrateur à créer un admin', async () => {
|
||||||
expect(service).toBeDefined();
|
usersRepository.findOneBy.mockResolvedValue(null);
|
||||||
|
usersRepository.create.mockImplementation((data) => data);
|
||||||
|
usersRepository.save.mockImplementation(async (entity) => ({
|
||||||
|
id: 'new-admin',
|
||||||
|
...entity,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await service.createAdmin(dto as never, {
|
||||||
|
id: 'admin-1',
|
||||||
|
role: RoleType.ADMINISTRATEUR,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(result.role).toBe(RoleType.ADMINISTRATEUR);
|
||||||
|
expect(result.statut).toBe(StatutUtilisateurType.ACTIF);
|
||||||
|
expect(usersRepository.save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('autorise un super_admin à créer un admin', async () => {
|
||||||
|
usersRepository.findOneBy.mockResolvedValue(null);
|
||||||
|
usersRepository.create.mockImplementation((data) => data);
|
||||||
|
usersRepository.save.mockImplementation(async (entity) => ({
|
||||||
|
id: 'new-admin',
|
||||||
|
...entity,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createAdmin(dto as never, {
|
||||||
|
id: 'sa-1',
|
||||||
|
role: RoleType.SUPER_ADMIN,
|
||||||
|
} as never),
|
||||||
|
).resolves.toMatchObject({ role: RoleType.ADMINISTRATEUR });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse un gestionnaire (403 métier)', async () => {
|
||||||
|
await expect(
|
||||||
|
service.createAdmin(dto as never, {
|
||||||
|
id: 'gest-1',
|
||||||
|
role: RoleType.GESTIONNAIRE,
|
||||||
|
} as never),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(usersRepository.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse un email déjà utilisé', async () => {
|
||||||
|
usersRepository.findOneBy.mockResolvedValue({ id: 'exists' });
|
||||||
|
await expect(
|
||||||
|
service.createAdmin(dto as never, {
|
||||||
|
id: 'admin-1',
|
||||||
|
role: RoleType.ADMINISTRATEUR,
|
||||||
|
} as never),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -117,8 +117,14 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createAdmin(dto: CreateAdminDto, currentUser: Users): Promise<Users> {
|
async createAdmin(dto: CreateAdminDto, currentUser: Users): Promise<Users> {
|
||||||
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
// #161 — admin et super_admin peuvent créer un administrateur
|
||||||
throw new ForbiddenException('Seuls les super administrateurs 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 });
|
const exist = await this.usersRepository.findOneBy({ email: dto.email });
|
||||||
|
|||||||
+1
-2
@@ -174,8 +174,7 @@ CREATE TABLE enfants (
|
|||||||
date_prevue_naissance DATE,
|
date_prevue_naissance DATE,
|
||||||
photo_url TEXT,
|
photo_url TEXT,
|
||||||
consentement_photo BOOLEAN DEFAULT false,
|
consentement_photo BOOLEAN DEFAULT false,
|
||||||
date_consentement_photo TIMESTAMPTZ,
|
date_consentement_photo TIMESTAMPTZ
|
||||||
est_multiple BOOLEAN DEFAULT false
|
|
||||||
);
|
);
|
||||||
|
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo","est_multiple"
|
"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,,False
|
"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,,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,,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,,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,,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,,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,,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,,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,,False
|
"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,
|
||||||
|
|||||||
|
@@ -0,0 +1,2 @@
|
|||||||
|
-- #152 — Suppression grossesse multiple / est_multiple
|
||||||
|
ALTER TABLE enfants DROP COLUMN IF EXISTS est_multiple;
|
||||||
@@ -69,12 +69,12 @@ ON CONFLICT (id_utilisateur) DO NOTHING;
|
|||||||
-- - child B : à naître (statut = 'a_naitre' et date_prevue_naissance requise)
|
-- - child B : à naître (statut = 'a_naitre' et date_prevue_naissance requise)
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
|
|
||||||
INSERT INTO enfants (id, prenom, nom, statut, date_naissance, jumeau_multiple)
|
INSERT INTO enfants (id, prenom, nom, statut, date_naissance)
|
||||||
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'sans_garde', '2022-04-12', false)
|
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'sans_garde', '2022-04-12')
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance, jumeau_multiple)
|
INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance)
|
||||||
VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Mila', 'Parent', 'a_naitre', '2026-02-15', false)
|
VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Mila', 'Parent', 'a_naitre', '2026-02-15')
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
|
|||||||
@@ -49,14 +49,14 @@ VALUES
|
|||||||
ON CONFLICT (id_utilisateur) DO NOTHING;
|
ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||||
|
|
||||||
-- ========== ENFANTS ==========
|
-- ========== ENFANTS ==========
|
||||||
INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut, est_multiple)
|
INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut)
|
||||||
VALUES
|
VALUES
|
||||||
('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'sans_garde', true),
|
('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', true),
|
('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', true),
|
('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', false),
|
('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', false),
|
('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', false)
|
('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'sans_garde')
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
-- ========== ENFANTS_PARENTS (liaison N:N) ==========
|
-- ========== ENFANTS_PARENTS (liaison N:N) ==========
|
||||||
|
|||||||
+53
-70
@@ -1,94 +1,77 @@
|
|||||||
# 📚 Index de la Documentation - PtitsPas App
|
# Index de la documentation — P'titsPas
|
||||||
|
|
||||||
Bienvenue dans la documentation complète de l'application PtitsPas.
|
Index de navigation du dépôt. Dernière révision : **septembre 2026** (CDC V1.4 + SRS users #117).
|
||||||
|
|
||||||
Ce fichier sert d'index pour naviguer dans toute la documentation du projet.
|
## Produit & versions
|
||||||
|
|
||||||
## 📖 Table des matières
|
| Doc | Contenu |
|
||||||
|
|-----|---------|
|
||||||
|
| [01 — Cahier des charges V1.4](./01_CAHIER-DES-CHARGES.md) | CDC **complet** (cible) ; gestion utilisateurs alignée `v0.1.0` |
|
||||||
|
| [12 — SRS gestion utilisateurs](./12_SRS-GESTION-UTILISATEURS.md) | Spécification **technique** du domaine users / dossiers |
|
||||||
|
| [05 — Versions & milestones](./05_VERSIONS-ET-MILESTONES.md) | Semver Gitea + bilans |
|
||||||
|
| [29 — Bilan version 0.1.0](./29_BILAN-VERSION-0.1.0.md) | Tickets livrés 0.1.0 |
|
||||||
|
| [04 — Roadmap générale](./04_ROADMAP-GENERALE.md) | Vision phases long terme |
|
||||||
|
| [28 — Évolution famille / responsables](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) | Limites modèle foyer / contournements |
|
||||||
|
|
||||||
### 📋 Cahier des Charges
|
## Architecture & infra
|
||||||
- [**01 - Cahier des Charges**](./01_CAHIER-DES-CHARGES.md) - Cahier des charges complet du projet P'titsPas (V1.3 - 24/11/2025)
|
|
||||||
|
|
||||||
### Architecture & Infrastructure
|
| Doc | Contenu |
|
||||||
- [**02 - Architecture**](./02_ARCHITECTURE.md) - Vue d'ensemble de l'architecture mono-repo et multi-conteneurs
|
|-----|---------|
|
||||||
- [**03 - Déploiement**](./03_DEPLOYMENT.md) - Guide complet de déploiement et configuration CI/CD
|
| [02 — Architecture](./02_ARCHITECTURE.md) | Mono-repo, conteneurs |
|
||||||
|
| [03 — Déploiement](./03_DEPLOYMENT.md) | Deploy / CI-CD |
|
||||||
|
| [10 — Database](./10_DATABASE.md) | Schéma BDD |
|
||||||
|
| [11 — API](./11_API.md) | Endpoints REST |
|
||||||
|
| [21 — Configuration système](./21_CONFIGURATION-SYSTEME.md) | Config on-premise |
|
||||||
|
| [99 — Règles de codage](./99_REGLES-CODAGE.md) | Conventions |
|
||||||
|
|
||||||
### Planification
|
## Workflows & métier
|
||||||
- [**04 - Roadmap Générale**](./04_ROADMAP-GENERALE.md) - Roadmap complète du projet (Phases 1 à 5+)
|
|
||||||
|
|
||||||
### Développement
|
| Doc | Contenu |
|
||||||
- [**10 - Database Schema**](./10_DATABASE.md) - Schéma de la base de données et modèles
|
|-----|---------|
|
||||||
- [**11 - API Documentation**](./11_API.md) - Documentation complète des endpoints REST
|
| [20 — Workflow création de compte](./20_WORKFLOW-CREATION-COMPTE.md) | Inscription / validation (détail historique) |
|
||||||
- [**14 - Note backend config setup**](./14_NOTE-BACKEND-CONFIG-SETUP.md) - Setup configuration
|
| [juridique/](./juridique/README.md) | CGU / CGC / privacy + [22 technique](./juridique/22_DOCUMENTS-LEGAUX.md) |
|
||||||
- [**92 - Note backend gestionnaires**](./92_NOTE-BACKEND-GESTIONNAIRES.md) - Gestionnaires
|
| [CHARTE_GRAPHIQUE.md](./CHARTE_GRAPHIQUE.md) | Charte UI |
|
||||||
- [**99 - Règles de codage**](./99_REGLES-CODAGE.md) - Conventions de code
|
|
||||||
|
|
||||||
### Workflows Fonctionnels
|
## Projet & outillage
|
||||||
- [**20 - Workflow Création de Compte**](./20_WORKFLOW-CREATION-COMPTE.md) - Workflow complet de création et validation des comptes utilisateurs
|
|
||||||
- [**21 - Configuration Système**](./21_CONFIGURATION-SYSTEME.md) - Configuration on-premise dynamique
|
|
||||||
- [**22 - Documents Légaux**](./juridique/22_DOCUMENTS-LEGAUX.md) - Gestion CGU/Privacy avec versioning
|
|
||||||
|
|
||||||
### Juridique (sources & technique)
|
| Doc | Contenu |
|
||||||
- [**Dossier juridique**](./juridique/README.md) - Index : CGU/CGC en Markdown,
|
|-----|---------|
|
||||||
export PDF, lien vers la doc technique n°22
|
| [23 — Suivi tickets](./23_SUIVI-TICKETS.md) | Pointeur Gitea |
|
||||||
|
| [24 — Décisions projet](./24_DECISIONS-PROJET.md) | ADR / décisions |
|
||||||
|
| [26 — API Gitea](./26_GITEA-API.md) | Issues, PR, milestones |
|
||||||
|
| [27 — Briefing frontend](./27_BRIEFING-FRONTEND.md) | Accès Git, priorités |
|
||||||
|
|
||||||
### Projet & suivi (Gitea / tickets)
|
## Audit
|
||||||
- [**23 - Liste des Tickets**](./23_LISTE-TICKETS.md) - 61 tickets Phase 1 détaillés
|
|
||||||
- [**24 - Décisions Projet**](./24_DECISIONS-PROJET.md) - Décisions architecturales et fonctionnelles
|
|
||||||
- [**25 - Backlog Phase 2**](./25_PHASE-2-BACKLOG.md) - Fonctionnalités techniques reportées
|
|
||||||
- [**28 - Évolution famille et responsables**](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) - Modèle dossier/famille, recompositions, v1.0.0 vs post-1.0.0
|
|
||||||
- [**26 - API Gitea**](./26_GITEA-API.md) - Procédure d'utilisation de l'API Gitea (issues, PR, branches, labels)
|
|
||||||
- [**27 - Briefing frontend**](./27_BRIEFING-FRONTEND.md) - Accès Git, priorités, scripts Gitea (token)
|
|
||||||
|
|
||||||
### Archive & convention de nommage
|
| Doc | Contenu |
|
||||||
- [**Dossier archive**](./archive/README.md) - Fichiers **sans** `NN_` déplacés
|
|-----|---------|
|
||||||
(temporaires, obsolètes) ; règles de rangement et suppression
|
| [90 — Audit YNOV](./90_AUDIT.md) | Analyse code étudiant |
|
||||||
- Pointeur : [PROCEDURE-API-GITEA.md](./PROCEDURE-API-GITEA.md) → voir **26**
|
|
||||||
|
|
||||||
### Exceptions de nommage (racine `docs/`)
|
## Archive
|
||||||
Fichiers **sans préfixe numérique** encore à la racine par **héritage** ou
|
|
||||||
références outils (`.cursorrules`, etc.) — **à renommer** en `NN_` quand
|
|
||||||
possible :
|
|
||||||
- `CHARTE_GRAPHIQUE.md`
|
|
||||||
- [`EVOLUTIONS_CDC.md`](./EVOLUTIONS_CDC.md) — écarts CDC / app ; voir aussi [**28 - Évolution famille**](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md)
|
|
||||||
- `SuperNounou_Cahier_Des_Charges_Complet_V1.1.md`
|
|
||||||
- `SuperNounou_SSS-001.md`
|
|
||||||
|
|
||||||
### Administration (À créer)
|
| Emplacement | Usage |
|
||||||
- [**30 - Guide d'administration**](./30_ADMIN.md) - Gestion des utilisateurs, accès PgAdmin, logs
|
|-------------|--------|
|
||||||
- [**31 - Troubleshooting**](./31_TROUBLESHOOTING.md) - Résolution des problèmes courants
|
| [archive/](./archive/README.md) | Obsolete / temporaires |
|
||||||
|
| [archive/obsolete/](./archive/obsolete/) | CDC V1.3, EVOLUTIONS_CDC, SuperNounou, listes figées |
|
||||||
|
|
||||||
### Frontend (À créer)
|
## Données de test
|
||||||
- [**40 - Frontend Flutter**](./40_FRONTEND.md) - Structure de l'application mobile/web
|
|
||||||
|
|
||||||
### Audit & Analyse
|
| Doc | Contenu |
|
||||||
- [**90 - Audit du projet YNOV**](./90_AUDIT.md) - Analyse complète du code étudiant et fonctionnalités
|
|-----|---------|
|
||||||
|
| [test-data/](./test-data/README.md) | Jeux utilisateurs test |
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Cloner le projet
|
git clone … ptitspas-app
|
||||||
git clone ssh://gitea-jmartin/jmartin/app.git ptitspas-app
|
|
||||||
|
|
||||||
# Lancer l'environnement de développement
|
|
||||||
cd ptitspas-app
|
cd ptitspas-app
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
# Front https://app.ptits-pas.fr — API /api — PgAdmin /pgadmin
|
||||||
# Accéder aux services
|
|
||||||
Frontend: https://app.ptits-pas.fr
|
|
||||||
API: https://app.ptits-pas.fr/api
|
|
||||||
PgAdmin: https://app.ptits-pas.fr/pgadmin
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔗 Liens utiles
|
## Liens
|
||||||
|
|
||||||
- **Gitea** : https://git.ptits-pas.fr
|
- Gitea : https://git.ptits-pas.fr/jmartin/petitspas
|
||||||
- **Production** : https://app.ptits-pas.fr
|
- Prod : https://app.ptits-pas.fr
|
||||||
- **Mail** : https://mail.ptits-pas.fr
|
|
||||||
|
|
||||||
## 📝 Maintenance
|
|
||||||
|
|
||||||
Cette documentation est maintenue par Julien Martin (julien.martin@ptits-pas.fr).
|
|
||||||
|
|
||||||
Dernière mise à jour : Juin 2026
|
|
||||||
|
|
||||||
|
Mainteneur : Julien Martin (julien.martin@ptits-pas.fr).
|
||||||
|
|||||||
+155
-70
@@ -1,14 +1,19 @@
|
|||||||
---
|
---
|
||||||
title: "P'titsPas - Cahier des Charges Fonctionnel"
|
title: "P'titsPas - Cahier des Charges Fonctionnel"
|
||||||
author: "Julien MARTIN"
|
author: "Julien MARTIN"
|
||||||
date: "Novembre 2025"
|
date: "Septembre 2026"
|
||||||
version: "v1.3"
|
version: "v1.4"
|
||||||
---
|
---
|
||||||
|
|
||||||
# P'titsPas – Cahier des Charges Fonctionnel
|
# P'titsPas – Cahier des Charges Fonctionnel
|
||||||
|
|
||||||
> **Objet :** Définir le périmètre fonctionnel, les rôles utilisateurs, les processus métiers et les exigences techniques de la plateforme P'titsPas, destinée à accompagner les collectivités locales dans la gestion de la garde d’enfants.
|
> **Objet :** Définir le périmètre fonctionnel, les rôles utilisateurs, les processus métiers et les exigences techniques de la plateforme P'titsPas, destinée à accompagner les collectivités locales dans la gestion de la garde d’enfants.
|
||||||
|
|
||||||
|
> **V1.4 (sept. 2026)** — Mise à jour de la **gestion des utilisateurs / dossiers / validation** pour coller au livré produit **`v0.1.0`**. Le reste du CDC (contrats, messagerie, agenda, paie, recherche AM, etc.) est **conservé** comme cible fonctionnelle.
|
||||||
|
> Détail technique du domaine utilisateurs : [12_SRS-GESTION-UTILISATEURS.md](./12_SRS-GESTION-UTILISATEURS.md).
|
||||||
|
> Bilan tickets `v0.1.0` : [29_BILAN-VERSION-0.1.0.md](./29_BILAN-VERSION-0.1.0.md).
|
||||||
|
> Archive V1.3 : [archive/obsolete/01_CAHIER-DES-CHARGES-v1.3.md](./archive/obsolete/01_CAHIER-DES-CHARGES-v1.3.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Historique des versions
|
## Historique des versions
|
||||||
@@ -19,6 +24,7 @@ version: "v1.3"
|
|||||||
| 1.1 | 24/04/2025 | Julien MARTIN | Ajouts : gestion multi-enfants, fin de contrat, tableau de bord étendu |
|
| 1.1 | 24/04/2025 | Julien MARTIN | Ajouts : gestion multi-enfants, fin de contrat, tableau de bord étendu |
|
||||||
| 1.2 | 26/05/2025 | Julien MARTIN | Remplacement de "SuperNounou" par "P'titsPas" |
|
| 1.2 | 26/05/2025 | Julien MARTIN | Remplacement de "SuperNounou" par "P'titsPas" |
|
||||||
| 1.3 | 24/11/2025 | Julien MARTIN | Correction : retrait photo de profil parent (section 3.1.1) |
|
| 1.3 | 24/11/2025 | Julien MARTIN | Correction : retrait photo de profil parent (section 3.1.1) |
|
||||||
|
| **1.4** | **15/09/2026** | Julien MARTIN | **Gestion utilisateurs** alignée `v0.1.0` (dossiers, validation/refus/reprise, fiches staff, suppressions, retrait naissance multiple & SMS) ; reste du CDC inchangé en cible |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -42,6 +48,8 @@ version: "v1.3"
|
|||||||
### 3.4 Création d’un administrateur
|
### 3.4 Création d’un administrateur
|
||||||
### 3.5 Fiche enfant
|
### 3.5 Fiche enfant
|
||||||
### 3.6 Authentification et sécurité
|
### 3.6 Authentification et sécurité
|
||||||
|
### 3.7 Numéro de dossier, validation, refus et reprise
|
||||||
|
### 3.8 Suppressions (vue métier)
|
||||||
|
|
||||||
## 4. Tableaux de bord
|
## 4. Tableaux de bord
|
||||||
### 4.1 Vue d’ensemble
|
### 4.1 Vue d’ensemble
|
||||||
@@ -60,15 +68,15 @@ version: "v1.3"
|
|||||||
#### 4.3.5 Heures supplémentaires
|
#### 4.3.5 Heures supplémentaires
|
||||||
#### 4.3.6 Messagerie
|
#### 4.3.6 Messagerie
|
||||||
### 4.4 Tableau de bord des gestionnaires
|
### 4.4 Tableau de bord des gestionnaires
|
||||||
#### 4.4.1 Comptes à valider
|
#### 4.4.1 Dossiers à valider
|
||||||
#### 4.4.2 Liste des utilisateurs
|
#### 4.4.2 Gestion des utilisateurs (partagée)
|
||||||
#### 4.4.3 Contrats
|
#### 4.4.3 Contrats
|
||||||
#### 4.4.4 Messagerie
|
#### 4.4.4 Messagerie
|
||||||
#### 4.4.5 Événements RPE
|
#### 4.4.5 Événements RPE
|
||||||
#### 4.4.6 Alertes
|
#### 4.4.6 Alertes
|
||||||
### 4.5 Tableau de bord des administrateurs
|
### 4.5 Tableau de bord des administrateurs
|
||||||
#### 4.5.1 Menu Profil
|
#### 4.5.1 Menu Profil
|
||||||
#### 4.5.2 Gestion des utilisateurs
|
#### 4.5.2 Gestion des utilisateurs (partagée admin + gestionnaire)
|
||||||
#### 4.5.3 Gestion des enfants
|
#### 4.5.3 Gestion des enfants
|
||||||
#### 4.5.4 Paramètres de la plateforme
|
#### 4.5.4 Paramètres de la plateforme
|
||||||
#### 4.5.5 Statistiques et supervision
|
#### 4.5.5 Statistiques et supervision
|
||||||
@@ -193,18 +201,23 @@ Les assistantes maternelles peuvent :
|
|||||||
|
|
||||||
Les gestionnaires (responsables de relais petite enfance) disposent d’un tableau de bord de supervision. Ils peuvent :
|
Les gestionnaires (responsables de relais petite enfance) disposent d’un tableau de bord de supervision. Ils peuvent :
|
||||||
|
|
||||||
- Valider ou rejeter les demandes de création de compte
|
- Valider ou refuser les **dossiers** (inscriptions parent / AM) et accompagner les reprises
|
||||||
- Suivre les mises en relation et les contrats
|
- Gérer les usagers (fiches parent / AM / enfant, rattachements, dossiers) — socle partagé avec l’admin
|
||||||
- Organiser des événements ou des rendez-vous
|
- Suivre les mises en relation et les contrats (cible CDC)
|
||||||
- Gérer les conflits ou les fins de contrat
|
- Organiser des événements ou des rendez-vous (cible CDC)
|
||||||
|
- Gérer les conflits ou les fins de contrat (cible CDC)
|
||||||
- Lancer des sondages ou modérer un blog RPE (si activé)
|
- Lancer des sondages ou modérer un blog RPE (si activé)
|
||||||
- Voir les historiques et statistiques liés à leur périmètre
|
- Voir les historiques et statistiques liés à leur périmètre
|
||||||
|
|
||||||
|
Ils **ne créent pas** les comptes gestionnaire / administrateur (réservé à l’admin).
|
||||||
|
|
||||||
### 2.1.4 Administrateurs
|
### 2.1.4 Administrateurs
|
||||||
|
|
||||||
Les administrateurs sont les représentants techniques et institutionnels de la collectivité (DSI ou agents désignés). Ils peuvent :
|
Les administrateurs sont les représentants techniques et institutionnels de la collectivité (DSI ou agents désignés). Ils peuvent :
|
||||||
|
|
||||||
- Créer ou supprimer des comptes (gestionnaires, parents, assistantes maternelles)
|
- Tout ce que peut faire un gestionnaire sur les usagers / dossiers
|
||||||
|
- Créer ou supprimer des comptes **staff** (gestionnaires, administrateurs) selon garde-fous
|
||||||
|
- Créer ou supprimer des comptes usagers (parents, AM, enfants) selon droits
|
||||||
- Personnaliser l’interface (logo, couleurs, nom de la ville)
|
- Personnaliser l’interface (logo, couleurs, nom de la ville)
|
||||||
- Activer ou désactiver des modules complémentaires
|
- Activer ou désactiver des modules complémentaires
|
||||||
- Consulter les statistiques d’usage
|
- Consulter les statistiques d’usage
|
||||||
@@ -245,12 +258,15 @@ Le parcours de création d’un compte parent s’effectue en plusieurs étapes
|
|||||||
**Note** : Le parent 2 ne définit **pas** de mot de passe lors de l'inscription. Il recevra un email avec un lien pour créer son mot de passe après validation du gestionnaire. Cette approche est particulièrement adaptée aux situations de parents séparés ou divorcés où la communication peut être difficile.
|
**Note** : Le parent 2 ne définit **pas** de mot de passe lors de l'inscription. Il recevra un email avec un lien pour créer son mot de passe après validation du gestionnaire. Cette approche est particulièrement adaptée aux situations de parents séparés ou divorcés où la communication peut être difficile.
|
||||||
|
|
||||||
### 3.1.3 Informations sur l'enfant
|
### 3.1.3 Informations sur l'enfant
|
||||||
|
- Un ou **plusieurs** enfants peuvent être ajoutés
|
||||||
- Prénom (facultatif si enfant à naître)
|
- Prénom (facultatif si enfant à naître)
|
||||||
- Nom (hérité des parents)
|
- Nom (hérité des parents)
|
||||||
- Genre (H / F) - obligatoire
|
- Genre (H / F / Autre) — obligatoire
|
||||||
- Date de naissance ou **date prévisionnelle de naissance** (si l'enfant n'est pas encore né, un switch modifie le label)
|
- Date de naissance ou **date prévisionnelle de naissance** (si l'enfant n'est pas encore né, un switch modifie le label)
|
||||||
- Photo obligatoire si l'enfant est né
|
- Photo (selon règles d’inscription) et **consentement photo**
|
||||||
- Rattachement automatique aux deux parents
|
- Rattachement automatique au parent 1 et au parent 2 s’il est renseigné
|
||||||
|
|
||||||
|
**Note V1.4** : il n’existe **pas** de champ « naissance multiple / jumeaux » (retiré du produit).
|
||||||
|
|
||||||
### 3.1.4 Présentation du dossier
|
### 3.1.4 Présentation du dossier
|
||||||
- Zone de texte libre permettant aux parents de décrire leur situation
|
- Zone de texte libre permettant aux parents de décrire leur situation
|
||||||
@@ -265,10 +281,12 @@ Le parcours de création d’un compte parent s’effectue en plusieurs étapes
|
|||||||
### 3.1.6 Récapitulatif et validation
|
### 3.1.6 Récapitulatif et validation
|
||||||
- Résumé des données saisies
|
- Résumé des données saisies
|
||||||
- Vérification, puis envoi de la demande
|
- Vérification, puis envoi de la demande
|
||||||
|
- Attribution d’un **numéro de dossier** (format type AAAA-NNNNNN)
|
||||||
- Les comptes parent sont soumis à validation par un gestionnaire avant activation
|
- Les comptes parent sont soumis à validation par un gestionnaire avant activation
|
||||||
- Une fois validé, chaque parent (Parent 1 et Parent 2 si renseigné) reçoit un e-mail ou un SMS contenant un lien pour créer son mot de passe
|
- Une fois validé, chaque parent (Parent 1 et Parent 2 si renseigné) reçoit un **e-mail** contenant un lien pour créer son mot de passe
|
||||||
- Le lien est valable pendant 7 jours
|
- Le lien est valable pendant une durée limitée (jeton à usage unique)
|
||||||
- Une fois le mot de passe créé, le parent peut se connecter à son espace
|
- Une fois le mot de passe créé, le parent peut se connecter à son espace
|
||||||
|
- En cas de **refus**, le dossier n’est pas supprimé : l’usager peut **reprendre** sa demande (lien e-mail ou numéro de dossier) — voir §3.7
|
||||||
|
|
||||||
## 3.2 Création de compte assistante maternelle
|
## 3.2 Création de compte assistante maternelle
|
||||||
|
|
||||||
@@ -298,7 +316,7 @@ Ce parcours est divisé en deux panneaux.
|
|||||||
- Champ libre : message à destination du gestionnaire
|
- Champ libre : message à destination du gestionnaire
|
||||||
- Permet de justifier une demande ou d’ajouter des précisions
|
- Permet de justifier une demande ou d’ajouter des précisions
|
||||||
|
|
||||||
### 3.1.4 – Acceptation des CGU
|
### 3.2.4 – Acceptation des CGU
|
||||||
- Les utilisateurs doivent cocher la case
|
- Les utilisateurs doivent cocher la case
|
||||||
« J’ai lu et j’accepte les Conditions Générales d’Utilisation et la Politique de confidentialité ».
|
« J’ai lu et j’accepte les Conditions Générales d’Utilisation et la Politique de confidentialité ».
|
||||||
- Un lien direct ouvre la version PDF des CGU.
|
- Un lien direct ouvre la version PDF des CGU.
|
||||||
@@ -307,23 +325,26 @@ Ce parcours est divisé en deux panneaux.
|
|||||||
### 3.2.5 Récapitulatif et validation
|
### 3.2.5 Récapitulatif et validation
|
||||||
- Résumé des données saisies
|
- Résumé des données saisies
|
||||||
- Vérification, puis envoi de la demande
|
- Vérification, puis envoi de la demande
|
||||||
|
- Attribution d’un **numéro de dossier**
|
||||||
- Validation par un gestionnaire requise avant activation
|
- Validation par un gestionnaire requise avant activation
|
||||||
- Une fois validé, l'assistante maternelle reçoit un e-mail ou un SMS contenant un lien pour créer son mot de passe
|
- Une fois validé, l'assistante maternelle reçoit un **e-mail** contenant un lien pour créer son mot de passe
|
||||||
- Le lien est valable pendant 7 jours
|
- Le lien est valable pendant une durée limitée (jeton à usage unique)
|
||||||
- Une fois le mot de passe créé, l'assistante maternelle peut se connecter à son espace
|
- Une fois le mot de passe créé, l'assistante maternelle peut se connecter à son espace
|
||||||
|
- En cas de **refus** : reprise possible — voir §3.7
|
||||||
|
|
||||||
## 3.3 Création d’un gestionnaire
|
## 3.3 Création d’un gestionnaire
|
||||||
|
|
||||||
- Réalisée par un administrateur
|
- Réalisée par un **administrateur** (ou super administrateur) — un gestionnaire ne crée pas de comptes staff
|
||||||
- Champs obligatoires : nom, prénom, adresse e-mail, mot de passe
|
- Champs : nom, prénom, e-mail, téléphone, mot de passe, **relais principal** (optionnel)
|
||||||
- Affectation à un ou plusieurs relais petite enfance
|
- Le mot de passe peut être modifié à la première connexion si la politique l’exige
|
||||||
- Le mot de passe doit être modifié lors de la première connexion
|
- Formulaire unifié de création / édition (même présentation que pour les administrateurs)
|
||||||
|
|
||||||
## 3.4 Création d’un administrateur
|
## 3.4 Création d’un administrateur
|
||||||
|
|
||||||
- Seuls les administrateurs existants peuvent créer de nouveaux comptes administrateurs
|
- Seuls les **administrateurs** (et super administrateur) peuvent créer de nouveaux comptes administrateurs
|
||||||
- Les droits sont équivalents (possibilité de restreindre par périmètre dans une version multi-mairies)
|
- Champs : nom, prénom, e-mail, téléphone, mot de passe (pas de relais)
|
||||||
- Obligation de changer le mot de passe à la première connexion
|
- Les droits sont équivalents entre administrateurs (le **super administrateur** est un compte d’installation non supprimable)
|
||||||
|
- Obligation de changer le mot de passe à la première connexion si exigé
|
||||||
|
|
||||||
## 3.5 Fiche enfant
|
## 3.5 Fiche enfant
|
||||||
|
|
||||||
@@ -331,22 +352,68 @@ Chaque enfant est représenté par une fiche :
|
|||||||
|
|
||||||
- Prénom (facultatif si enfant à naître)
|
- Prénom (facultatif si enfant à naître)
|
||||||
- Nom
|
- Nom
|
||||||
- Genre (H / F) - obligatoire
|
- Genre (H / F / Autre) — obligatoire
|
||||||
- Date de naissance ou date prévisionnelle
|
- Date de naissance ou date prévisionnelle
|
||||||
- Photo (obligatoire si l'enfant est né ET si l'option *Photo obligatoire* est activée)
|
- Photo et consentement photo selon configuration (consentement tracé)
|
||||||
- Consentement photo enregistré : valeur booléenne + horodatage liés à l'accord donné par le parent
|
- Statut : à naître / actif / scolarisé (évolutions de libellés possibles ultérieurement)
|
||||||
- Statut : à naître / actif / scolarisé
|
- Responsables (parents) rattachés — liens vers les fiches parent
|
||||||
- Indication possible : jumeaux, triplés, etc.
|
- Assistante maternelle de rattachement (le cas échéant)
|
||||||
- Possibilité de rattacher un enfant à plusieurs parents (garde alternée)
|
|
||||||
|
**Note V1.4** : pas d’indication « jumeaux / triplés » comme champ dédié.
|
||||||
|
|
||||||
|
Les fiches enfants sont :
|
||||||
|
- Accessibles depuis la fiche parent, la fiche AM, l’onglet **Enfants** du dashboard staff
|
||||||
|
- Créables par le staff pour un foyer existant
|
||||||
|
- Partagées entre les responsables rattachés
|
||||||
|
|
||||||
## 3.6 Authentification et sécurité
|
## 3.6 Authentification et sécurité
|
||||||
|
|
||||||
- Tous les comptes utilisent une combinaison adresse e-mail + mot de passe
|
- Authentification par e-mail + mot de passe
|
||||||
- Les gestionnaires et administrateurs doivent modifier leur mot de passe à la première connexion
|
- Comptes **en attente** ou **suspendus** : connexion refusée
|
||||||
- Des mécanismes de récupération sont disponibles en cas de perte
|
- Les gestionnaires et administrateurs doivent modifier leur mot de passe à la première connexion (si exigé)
|
||||||
|
- **Création de mot de passe** post-validation : lien e-mail (jeton TTL, usage unique)
|
||||||
|
- **Mot de passe oublié** : parcours distinct (demande → e-mail → réinitialisation)
|
||||||
|
- Pas d’envoi de lien de mot de passe par SMS (canal **e-mail** uniquement)
|
||||||
|
|
||||||
Un lien direct vers les **Mentions légales** et la **Politique de confidentialité** est accessible en permanence depuis le pied de page, y compris avant la connexion.
|
Un lien direct vers les **Mentions légales** et la **Politique de confidentialité** est accessible en permanence depuis le pied de page, y compris avant la connexion.
|
||||||
|
|
||||||
|
## 3.7 Numéro de dossier, validation, refus et reprise
|
||||||
|
|
||||||
|
### Numéro de dossier
|
||||||
|
- Format type **AAAA-NNNNNN**
|
||||||
|
- Affiché dans les listes staff, les modales et les e-mails concernés
|
||||||
|
- Sert de clé métier pour validation, refus et reprise
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
- Le staff (gestionnaire / administrateur) examine le dossier (wizard de revue)
|
||||||
|
- Action **Valider** : activation du circuit comptes + e-mails de création de mot de passe
|
||||||
|
|
||||||
|
### Refus
|
||||||
|
- Action **Refuser** : le dossier est refusé **sans suppression** des données
|
||||||
|
- L’usager est informé par e-mail et peut reprendre sa demande
|
||||||
|
|
||||||
|
### Reprise
|
||||||
|
- Via le **lien** reçu par e-mail, ou depuis l’écran de connexion avec le **numéro de dossier**
|
||||||
|
- Formulaire prérempli / correction des informations
|
||||||
|
- Nouvelle soumission → retour en file « à valider »
|
||||||
|
|
||||||
|
### Création et édition de dossiers par le staff
|
||||||
|
- En plus de l’inscription publique, le staff peut **créer** un dossier famille ou AM (wizard)
|
||||||
|
- Le staff peut **éditer** un dossier existant (y compris ajout d’un 2ᵉ parent pour une famille mono-parent)
|
||||||
|
|
||||||
|
## 3.8 Suppressions (vue métier)
|
||||||
|
|
||||||
|
Sous réserve des droits (détail technique : [SRS gestion utilisateurs](./12_SRS-GESTION-UTILISATEURS.md)) :
|
||||||
|
|
||||||
|
| Cible | Principe |
|
||||||
|
|-------|----------|
|
||||||
|
| Parent, AM, enfant, dossier | Gestionnaire, administrateur, super admin — avec confirmation |
|
||||||
|
| Gestionnaire | Administrateur / super admin uniquement |
|
||||||
|
| Administrateur | Admin / super admin, hors soi-même, hors cible super admin ; garde-fou sur le dernier administrateur |
|
||||||
|
| Super administrateur | Non supprimable |
|
||||||
|
|
||||||
|
Un gestionnaire ne peut pas supprimer **son propre** compte.
|
||||||
|
|
||||||
# 4. Tableaux de bord
|
# 4. Tableaux de bord
|
||||||
|
|
||||||
Chaque rôle utilisateur dispose d’un tableau de bord personnalisé, adapté à ses fonctions dans la plateforme. Ces interfaces sont pensées pour être lisibles, fonctionnelles et évolutives.
|
Chaque rôle utilisateur dispose d’un tableau de bord personnalisé, adapté à ses fonctions dans la plateforme. Ces interfaces sont pensées pour être lisibles, fonctionnelles et évolutives.
|
||||||
@@ -357,8 +424,8 @@ Chaque rôle utilisateur dispose d’un tableau de bord personnalisé, adapté
|
|||||||
|------------------------|-------------------------------------------------------------------|
|
|------------------------|-------------------------------------------------------------------|
|
||||||
| Parents | Recherche d'assistante maternelle, gestion des enfants, contrat, agenda |
|
| Parents | Recherche d'assistante maternelle, gestion des enfants, contrat, agenda |
|
||||||
| Assistantes maternelles| Dossiers reçus, enfants accueillis, heures sup, agenda, messagerie|
|
| Assistantes maternelles| Dossiers reçus, enfants accueillis, heures sup, agenda, messagerie|
|
||||||
| Gestionnaires | Validation de comptes, contrats, messagerie, événements RPE |
|
| Gestionnaires | Dossiers à valider, gestion utilisateurs/enfants (partagée), contrats, messagerie, événements RPE |
|
||||||
| Administrateurs | Paramètres globaux, gestion des utilisateurs, statistiques |
|
| Administrateurs | Paramètres globaux, même gestion utilisateurs (droits staff élargis), statistiques |
|
||||||
|
|
||||||
Les tableaux de bord intègrent :
|
Les tableaux de bord intègrent :
|
||||||
- Une **barre de navigation supérieure** (liens de navigation rapide)
|
- Une **barre de navigation supérieure** (liens de navigation rapide)
|
||||||
@@ -502,17 +569,24 @@ L’assistante maternelle accède à une interface dédiée à la gestion de ses
|
|||||||
|
|
||||||
Le gestionnaire RPE dispose d’une vision transversale sur les utilisateurs, les dossiers et les activités de la structure. Son rôle est d'accompagner, superviser, et arbitrer si nécessaire.
|
Le gestionnaire RPE dispose d’une vision transversale sur les utilisateurs, les dossiers et les activités de la structure. Son rôle est d'accompagner, superviser, et arbitrer si nécessaire.
|
||||||
|
|
||||||
### 4.4.1 Comptes à valider
|
### 4.4.1 Dossiers à valider
|
||||||
|
|
||||||
- File d’attente des demandes de création de compte
|
- File d’attente des **dossiers** (famille / AM) en attente de validation
|
||||||
- Détails de chaque demande : parent, assistante maternelle
|
- Affichage du **numéro de dossier** et des informations essentielles
|
||||||
- Actions possibles : Valider / Refuser / Demander des précisions
|
- Ouverture en **revue** (wizard étapes) : Valider / Refuser
|
||||||
|
- Le refus n’efface pas le dossier : l’usager peut reprendre (voir §3.7)
|
||||||
|
- L’onglet **Dossiers** du dashboard regroupe aussi une **liste unifiée** des dossiers (au-delà de la seule file « à valider »)
|
||||||
|
|
||||||
### 4.4.2 Liste des utilisateurs
|
### 4.4.2 Gestion des utilisateurs (partagée)
|
||||||
|
|
||||||
- Filtres par rôle, statut, date d’inscription
|
> **V1.4** — La gestion opérationnelle des usagers (parents, AM, enfants, dossiers) est **partagée** entre gestionnaire et administrateur via le même dashboard. Seule la gestion des **comptes staff** (création / suppression gestionnaire ou admin) est réservée à l’administrateur.
|
||||||
- Accès rapide aux informations et historiques
|
|
||||||
- Possibilité de contacter un utilisateur
|
- Onglets : Parents, Assistantes maternelles, Enfants, Gestionnaires (selon droits), Administrateurs (admin)
|
||||||
|
- Accès aux **fiches** (identité, rattachements, capacité AM, etc.)
|
||||||
|
- Création de dossiers / enfants par le staff
|
||||||
|
- Filtres et recherche ; ouverture des fiches depuis les cartes / listes
|
||||||
|
|
||||||
|
Le détail des panneaux admin historiques est décrit en §4.5.2 (même socle fonctionnel).
|
||||||
|
|
||||||
### 4.4.3 Contrats
|
### 4.4.3 Contrats
|
||||||
|
|
||||||
@@ -566,47 +640,56 @@ Le menu profil de l’administrateur comprend uniquement :
|
|||||||
|
|
||||||
Toutes les fonctionnalités de gestion sont accessibles via des onglets distincts dans le tableau de bord.
|
Toutes les fonctionnalités de gestion sont accessibles via des onglets distincts dans le tableau de bord.
|
||||||
|
|
||||||
### 4.5.2 Gestion des utilisateurs
|
### 4.5.2 Gestion des utilisateurs (partagée admin + gestionnaire)
|
||||||
|
|
||||||
L’administration des utilisateurs est organisée par panneaux distincts pour chaque type de profil.
|
L’administration des utilisateurs est organisée par panneaux / onglets. **Admin et gestionnaire** partagent les panneaux usagers ; l’admin dispose en plus des droits staff.
|
||||||
|
|
||||||
#### a. Gestion des gestionnaires
|
#### a. Gestion des gestionnaires
|
||||||
- Liste des gestionnaires existants
|
- Liste des gestionnaires existants
|
||||||
- Création (nom, prénom, e-mail, mot de passe)
|
- Création / édition (nom, prénom, e-mail, téléphone, mot de passe, relais principal) — **administrateur uniquement** pour la création
|
||||||
- Attribution à un ou plusieurs RPE
|
- Réinitialisation / nouveau mot de passe en édition
|
||||||
- Réinitialisation du mot de passe
|
- Suppression du compte — **administrateur uniquement** (pas d’auto-suppression)
|
||||||
- Suppression du compte
|
|
||||||
|
|
||||||
#### b. Gestion des parents
|
#### b. Gestion des parents
|
||||||
- Liste complète des parents enregistrés
|
- Liste des parents enregistrés
|
||||||
- Recherche par nom, statut, enfants associés
|
- Ouverture de la **fiche parent** (identité, statut, enfants, lien co-parent)
|
||||||
- Modification des informations
|
- Rattacher / détacher un enfant
|
||||||
- Suppression d’un compte
|
- Création de dossier famille (wizard staff)
|
||||||
- Consultation du statut des dossiers liés
|
- Suppression d’un compte / dossier selon droits (§3.8)
|
||||||
|
|
||||||
#### c. Gestion des assistantes maternelles
|
#### c. Gestion des assistantes maternelles
|
||||||
- Liste des assistantes avec numéro d’agrément
|
- Liste des AM (agrément, capacité, etc.)
|
||||||
- Modification ou suppression d’un compte
|
- **Fiche AM** (identité, professionnel, enfants accueillis)
|
||||||
- Filtrage par zone géographique ou capacité
|
- Respect de la **capacité max** pour les rattachements
|
||||||
|
- Création de dossier AM (wizard staff)
|
||||||
|
- Suppression selon droits
|
||||||
|
|
||||||
#### d. Gestion des administrateurs
|
#### d. Gestion des administrateurs
|
||||||
- Création de nouveaux comptes administrateurs
|
- Création de nouveaux comptes administrateurs — **administrateur / super admin**
|
||||||
- Suivi des droits
|
- Suivi des droits
|
||||||
- Obligation de modification du mot de passe à la première connexion
|
- Obligation de modification du mot de passe à la première connexion si exigé
|
||||||
|
- Suppression avec garde-fous (pas soi-même, pas le super admin, dernier admin)
|
||||||
|
|
||||||
|
#### e. Onglet Dossiers
|
||||||
|
- Liste unifiée + dossiers à valider
|
||||||
|
- Revue / édition des dossiers (wizards)
|
||||||
|
|
||||||
### 4.5.3 Gestion des enfants
|
### 4.5.3 Gestion des enfants
|
||||||
|
|
||||||
Deux accès possibles à la gestion des enfants :
|
Deux accès possibles à la gestion des enfants :
|
||||||
|
|
||||||
#### a. Par fiche parent
|
#### a. Par fiche parent (ou fiche AM)
|
||||||
- Consultation et édition des enfants associés à chaque parent
|
- Consultation et édition des enfants associés
|
||||||
|
- Rattacher / détacher depuis la fiche
|
||||||
|
|
||||||
#### b. Vue globale “Enfants”
|
#### b. Vue globale “Enfants”
|
||||||
- Liste complète avec :
|
- Liste complète avec :
|
||||||
- Nom, prénom, date de naissance ou prévisionnelle
|
- Nom, prénom, date de naissance ou prévisionnelle
|
||||||
- Statut (à naître, actif, scolarisé)
|
- Statut (à naître, actif, scolarisé)
|
||||||
- Parents associés
|
- Parents associés (alerte si **sans responsable**)
|
||||||
- Contrat en cours (si applicable)
|
- AM associée le cas échéant
|
||||||
|
- Contrat en cours (si applicable — module contrats)
|
||||||
|
- Création d’un enfant rattaché à un **foyer existant**
|
||||||
- Possibilité de modifier ou supprimer une fiche enfant
|
- Possibilité de modifier ou supprimer une fiche enfant
|
||||||
|
|
||||||
### 4.5.4 Paramètres de la plateforme
|
### 4.5.4 Paramètres de la plateforme
|
||||||
@@ -905,10 +988,10 @@ Certains outils de la plateforme sont accessibles à plusieurs profils et favori
|
|||||||
- Le gestionnaire (à titre d’information)
|
- Le gestionnaire (à titre d’information)
|
||||||
- Contient :
|
- Contient :
|
||||||
- Identité
|
- Identité
|
||||||
- Photo (obligatoire si né)
|
- Photo (selon configuration / consentement)
|
||||||
- Statut (à naître / actif / scolarisé)
|
- Statut (à naître / actif / scolarisé)
|
||||||
- Parents associés
|
- Parents associés
|
||||||
- Mention s’il s’agit de jumeaux, triplés, etc.
|
- AM associée le cas échéant
|
||||||
|
|
||||||
## 6.4 Contrats et avenants
|
## 6.4 Contrats et avenants
|
||||||
|
|
||||||
@@ -1185,11 +1268,11 @@ P'titsPas s’inscrit dans une démarche de service public, avec des fondements
|
|||||||
| **Gestionnaires** | Suivi global des situations, outils de médiation, organisation d’événements |
|
| **Gestionnaires** | Suivi global des situations, outils de médiation, organisation d’événements |
|
||||||
| **Administrateurs** | Supervision complète, gouvernance multi-rôle, contrôle des données |
|
| **Administrateurs** | Supervision complète, gouvernance multi-rôle, contrôle des données |
|
||||||
|
|
||||||
## 10.3 Périmètre fonctionnel de la V1
|
## 10.3 Périmètre fonctionnel
|
||||||
|
|
||||||
Fonctionnalités incluses dès la première version :
|
**Cible CDC (plateforme complète)** — fonctionnalités décrites dans ce document :
|
||||||
- Création de comptes
|
- Création de comptes et gestion des utilisateurs / dossiers
|
||||||
- Recherche et sélection de nounous
|
- Recherche et sélection d’assistantes maternelles
|
||||||
- Suivi des dossiers
|
- Suivi des dossiers
|
||||||
- Génération de contrat et avenants
|
- Génération de contrat et avenants
|
||||||
- Messagerie
|
- Messagerie
|
||||||
@@ -1198,6 +1281,8 @@ Fonctionnalités incluses dès la première version :
|
|||||||
- Gestion des heures supplémentaires
|
- Gestion des heures supplémentaires
|
||||||
- Suivi RGPD et administration
|
- Suivi RGPD et administration
|
||||||
|
|
||||||
|
**Livré produit `v0.1.0`** (voir [bilan](./29_BILAN-VERSION-0.1.0.md)) : cœur **gestion des utilisateurs** — inscription, validation/refus/reprise, auth, dashboard staff (dossiers, fiches, rattachements, suppressions). Les autres modules du CDC restent la **feuille de route**.
|
||||||
|
|
||||||
## 10.4 Perspectives
|
## 10.4 Perspectives
|
||||||
|
|
||||||
La structure du produit permet une montée en charge progressive :
|
La structure du produit permet une montée en charge progressive :
|
||||||
|
|||||||
+15
-15
@@ -44,22 +44,21 @@ Les **Phases 2, 3, 4+** sont des **ébauches indicatives** qui seront affinées
|
|||||||
- ✅ Logging & Monitoring
|
- ✅ Logging & Monitoring
|
||||||
- ✅ Tests & Documentation
|
- ✅ Tests & Documentation
|
||||||
|
|
||||||
### Versions incrémentales
|
### Versions incrémentales (semver / Gitea)
|
||||||
|
|
||||||
| Version | Objectif | Tickets | Estimation |
|
La table historique « ~21 tickets / 0.1.0 » est **obsolète**.
|
||||||
|---------|----------|---------|------------|
|
État réel des milestones, bilans et tag : **[05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md)**.
|
||||||
| **0.1.0** | MVP Fonctionnel | ~21 | ~45h |
|
|
||||||
| **0.2.0** | Sécurité & RGPD | ~10 | ~35h |
|
|
||||||
| **0.3.0** | Interfaces Complètes | ~17 | ~52h |
|
|
||||||
| **0.4.0** | Tests & Documentation | ~6 | ~24h |
|
|
||||||
| **0.5.0** | Monitoring & Optimisations | ~7 | ~17h |
|
|
||||||
| **1.0.0** | 🎉 **Release Phase 1** | **61** | **~173h** |
|
|
||||||
|
|
||||||
### Livrable
|
| Version | Statut (sept. 2026) |
|
||||||
|
|---------|---------------------|
|
||||||
|
| **0.1.0** | **Terminée** — [bilan](./29_BILAN-VERSION-0.1.0.md) (48 tickets fermés) |
|
||||||
|
| **0.2.0+** | Ouvertes — voir Gitea + doc 05 |
|
||||||
|
|
||||||
Application installable avec création et validation de comptes utilisateurs.
|
### Livrable Phase 1 (visée)
|
||||||
|
|
||||||
**Référence** : [23_LISTE-TICKETS.md](./23_LISTE-TICKETS.md)
|
Application installable avec création et validation de comptes utilisateurs, puis enrichissements dashboard / dossiers (0.1.0 livré).
|
||||||
|
|
||||||
|
**Tickets** : Gitea — pointeur [23_SUIVI-TICKETS.md](./23_SUIVI-TICKETS.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -216,7 +215,7 @@ Suivi quotidien des enfants + Fonctionnalités complémentaires.
|
|||||||
|
|
||||||
Application mature, optimisée et riche en fonctionnalités.
|
Application mature, optimisée et riche en fonctionnalités.
|
||||||
|
|
||||||
**Référence** : [25_PHASE-2-BACKLOG.md](./25_PHASE-2-BACKLOG.md) (anciennes fonctionnalités techniques)
|
**Référence** : [archive/obsolete/25_PHASE-2-BACKLOG.md](./archive/obsolete/25_PHASE-2-BACKLOG.md) (figé) + [05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -317,9 +316,10 @@ Exemples :
|
|||||||
- [00_INDEX.md](./00_INDEX.md) - Index général de la documentation
|
- [00_INDEX.md](./00_INDEX.md) - Index général de la documentation
|
||||||
- [01_CAHIER-DES-CHARGES.md](./01_CAHIER-DES-CHARGES.md) - Cahier des charges v1.3
|
- [01_CAHIER-DES-CHARGES.md](./01_CAHIER-DES-CHARGES.md) - Cahier des charges v1.3
|
||||||
- [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md) - Workflow création de comptes
|
- [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md) - Workflow création de comptes
|
||||||
- [23_LISTE-TICKETS.md](./23_LISTE-TICKETS.md) - Liste des 61 tickets Phase 1
|
- [23_SUIVI-TICKETS.md](./23_SUIVI-TICKETS.md) / [29_BILAN-VERSION-0.1.0.md](./29_BILAN-VERSION-0.1.0.md) — suivi Gitea + bilan 0.1.0
|
||||||
- [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md) - Décisions architecturales
|
- [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md) - Décisions architecturales
|
||||||
- [25_PHASE-2-BACKLOG.md](./25_PHASE-2-BACKLOG.md) - Anciennes fonctionnalités techniques
|
- [05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md) — milestones Gitea
|
||||||
|
- [archive/obsolete/25_PHASE-2-BACKLOG.md](./archive/obsolete/25_PHASE-2-BACKLOG.md) — backlog Phase 2 figé
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Versions & milestones — P'titsPas
|
||||||
|
|
||||||
|
**Source de vérité tickets** : Gitea [`jmartin/petitspas`](https://git.ptits-pas.fr/jmartin/petitspas)
|
||||||
|
**Bilans de version** : documents `29_BILAN-…` (et suivants)
|
||||||
|
|
||||||
|
Ce fichier remplace, pour le **semver / milestones**, les anciennes tables figées de la roadmap Phase 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## État des milestones
|
||||||
|
|
||||||
|
| Milestone | Rôle | Statut |
|
||||||
|
|-----------|------|--------|
|
||||||
|
| **0.1.0** | MVP opérable (auth, inscription, dashboard dossiers/fiches, suppressions, cleanups) | **Terminée** — [bilan](./29_BILAN-VERSION-0.1.0.md) |
|
||||||
|
| **0.2.0** | Suite produit (ex. recherche / échanges — sans contrat) | Ouverte |
|
||||||
|
| **0.3.0** | Contrat + planning | Ouverte |
|
||||||
|
| **0.4.0** | Carnet de liaison | Ouverte |
|
||||||
|
| **0.9.0** | Hors périmètre cleanup 0.1.0 (doublons, upload, tech auth/photos, UX erreurs…) | Ouverte |
|
||||||
|
| **1.0.0** | Release majeure Phase 1 (critères PO) | Réserve |
|
||||||
|
| **Backlog transverse** | Doc étendue, CI/tests, RGPD avancé, monitoring — hors semver dédié | Ouverte |
|
||||||
|
|
||||||
|
Liens Gitea : [milestones](https://git.ptits-pas.fr/jmartin/petitspas/milestones).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bilans
|
||||||
|
|
||||||
|
| Version | Document |
|
||||||
|
|---------|----------|
|
||||||
|
| 0.1.0 | [29_BILAN-VERSION-0.1.0.md](./29_BILAN-VERSION-0.1.0.md) |
|
||||||
|
|
||||||
|
## Documents produit de référence (post-0.1.0)
|
||||||
|
|
||||||
|
| Doc | Rôle |
|
||||||
|
|-----|------|
|
||||||
|
| [01_CAHIER-DES-CHARGES.md](./01_CAHIER-DES-CHARGES.md) | CDC **complet** V1.4 (users mis à jour ; reste = cible) |
|
||||||
|
| [12_SRS-GESTION-UTILISATEURS.md](./12_SRS-GESTION-UTILISATEURS.md) | SRS technique domaine utilisateurs |
|
||||||
|
| Archive CDC V1.3 | [archive/obsolete/01_CAHIER-DES-CHARGES-v1.3.md](./archive/obsolete/01_CAHIER-DES-CHARGES-v1.3.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relation avec la roadmap phases
|
||||||
|
|
||||||
|
La vision long terme (Phases 2–5 : mise en relation, contrats, carnet…) reste dans [04_ROADMAP-GENERALE.md](./04_ROADMAP-GENERALE.md).
|
||||||
|
Les **jalons livrables** se gèrent ici + dans Gitea.
|
||||||
|
|
||||||
|
Ancien backlog « Phase 2 » technique ([archive](./archive/obsolete/25_PHASE-2-BACKLOG.md)) : à croiser avec les milestones ci-dessus ; ne plus maintenir en double.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suivi des tickets
|
||||||
|
|
||||||
|
- **Création / état** : Gitea uniquement.
|
||||||
|
- **Mémoire d’une version livrée** : bilan `29_…` (pas de re-copie exhaustive dans un fichier tickets).
|
||||||
|
- Ancienne liste figée Phase 1 : [archive/obsolete/23_LISTE-TICKETS.md](./archive/obsolete/23_LISTE-TICKETS.md).
|
||||||
|
- Pointeur court : [23_SUIVI-TICKETS.md](./23_SUIVI-TICKETS.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tag Git
|
||||||
|
|
||||||
|
| Tag | Condition |
|
||||||
|
|-----|-----------|
|
||||||
|
| `v0.1.0` | Milestone 0.1.0 fermée + bilan mergé sur `master` |
|
||||||
@@ -117,7 +117,6 @@ Table des enfants pris en charge.
|
|||||||
| `photo_url` | TEXT | | URL de la photo |
|
| `photo_url` | TEXT | | URL de la photo |
|
||||||
| `consentement_photo` | BOOLEAN | DEFAULT false | Consentement photo |
|
| `consentement_photo` | BOOLEAN | DEFAULT false | Consentement photo |
|
||||||
| `date_consentement_photo` | TIMESTAMPTZ | | Date du consentement |
|
| `date_consentement_photo` | TIMESTAMPTZ | | Date du consentement |
|
||||||
| `est_multiple` | BOOLEAN | DEFAULT false | Indique si grossesse multiple |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
# SRS — Gestion des utilisateurs (domaine livré v0.1.0)
|
||||||
|
|
||||||
|
**Version** : 1.0
|
||||||
|
**Date** : 15/09/2026
|
||||||
|
**Ticket** : [#117](https://git.ptits-pas.fr/jmartin/petitspas/issues/117)
|
||||||
|
**Niveau** : **technique** (dev / QA)
|
||||||
|
**CDC associé** : [01_CAHIER-DES-CHARGES.md](./01_CAHIER-DES-CHARGES.md) (V1.4 — **CDC complet** ; cette SRS ne couvre que le domaine utilisateurs)
|
||||||
|
|
||||||
|
> Périmètre SRS : comptes, auth liée, dossiers famille/AM, enfants, affiliations, dashboard staff, suppressions.
|
||||||
|
> Le CDC V1.4 conserve **l’ensemble** des chapitres cibles (contrats, messagerie, etc.) ; ils ne sont **pas** redécrits ici.
|
||||||
|
> Hors SRS technique : OpenAPI exhaustif, PRA/CI → voir [11_API](./11_API.md), [10_DATABASE](./10_DATABASE.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Glossaire
|
||||||
|
|
||||||
|
| Terme | Définition |
|
||||||
|
|-------|------------|
|
||||||
|
| **User / utilisateur** | Compte authentifiable (`utilisateurs`) avec `role` et `statut` |
|
||||||
|
| **Parent pivot** | Responsable principal du foyer à l’inscription / création staff |
|
||||||
|
| **Co-parent** | Second responsable optionnel (au plus un), lié au pivot |
|
||||||
|
| **Foyer** | Pivot + co-parent éventuel + enfants affiliés |
|
||||||
|
| **Dossier** | Unité métier identifiée par `numero_dossier` (format `AAAA-NNNNNN`) — famille ou AM |
|
||||||
|
| **Pending / en_attente** | Compte ou dossier en attente de validation staff |
|
||||||
|
| **Refus** | Rejet sans suppression ; ouvre la **reprise** |
|
||||||
|
| **Relais** | Structure RPE ; rattachement optionnel d’un gestionnaire |
|
||||||
|
| **Staff** | `gestionnaire`, `administrateur`, `super_admin` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Acteurs et rôles applicatifs
|
||||||
|
|
||||||
|
| `role` | Inscription publique | Dashboard staff | Créer staff | Suppressions métier usagers | Supprimer gestionnaire | Supprimer admin |
|
||||||
|
|--------|---------------------|-----------------|-------------|----------------------------|------------------------|-----------------|
|
||||||
|
| `parent` | oui | non | non | non | non | non |
|
||||||
|
| `assistante_maternelle` | oui | non | non | non | non | non |
|
||||||
|
| `gestionnaire` | non (créé staff) | oui | non | oui* | non | non |
|
||||||
|
| `administrateur` | non | oui | oui | oui* | oui | oui** |
|
||||||
|
| `super_admin` | non (install) | oui | oui | oui* | oui | oui** |
|
||||||
|
|
||||||
|
\* Sous réserve des garde-fous (pas soi-même pour sa fiche staff, etc.).
|
||||||
|
\*\* Pas soi-même ; pas de cible `super_admin` ; dernier admin : règles `canDeleteAdministrateur` (front `staff_deletion_rights.dart`).
|
||||||
|
|
||||||
|
Réf. front : `frontend/lib/utils/staff_deletion_rights.dart`
|
||||||
|
- `canCreateStaffAccounts` → admin / super_admin
|
||||||
|
- `canDeleteMetier` → gestionnaire / admin / super_admin
|
||||||
|
- `canDeleteGestionnaire` → admin / super_admin
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Statuts
|
||||||
|
|
||||||
|
### 3.1 Utilisateur (`statut_utilisateur_type`)
|
||||||
|
|
||||||
|
| Statut | Signification |
|
||||||
|
|--------|----------------|
|
||||||
|
| `en_attente` | Inscrit, non validé ; **login refusé** |
|
||||||
|
| `actif` | Validé / utilisable |
|
||||||
|
| `suspendu` | Bloqué ; **login refusé** |
|
||||||
|
|
||||||
|
### 3.2 Enfant (`statut_enfant_type`)
|
||||||
|
|
||||||
|
Valeurs actuelles : `a_naitre`, `actif`, `scolarise` (évolution métier « gardé / sans garde » **hors** cette SRS — ticket dédié).
|
||||||
|
|
||||||
|
### 3.3 Validation dossier
|
||||||
|
|
||||||
|
Circuit staff : revue → **valider** ou **refuser**. Refus ≠ delete. Reprise → nouveau passage en attente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Modèle de données (vue synthétique)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
user[utilisateurs]
|
||||||
|
parent[parents]
|
||||||
|
am[assistantes_maternelles]
|
||||||
|
enfant[enfants]
|
||||||
|
ep[enfants_parents]
|
||||||
|
df[dossier_famille]
|
||||||
|
user --> parent
|
||||||
|
user --> am
|
||||||
|
parent --> ep
|
||||||
|
enfant --> ep
|
||||||
|
parent --> df
|
||||||
|
```
|
||||||
|
|
||||||
|
| Concept | Tables / liens clés |
|
||||||
|
|---------|---------------------|
|
||||||
|
| Compte | `utilisateurs` (role, statut, email, numero_dossier, relais…) |
|
||||||
|
| Parent | `parents` (+ `id_co_parent` optionnel) |
|
||||||
|
| AM | `assistantes_maternelles` (agrément, capacité, NIR…) |
|
||||||
|
| Enfant | `enfants` |
|
||||||
|
| Affiliation parent | `enfants_parents` (N–N) |
|
||||||
|
| Dossier famille | `dossier_famille` (+ enfants dossier) |
|
||||||
|
| Tokens MDP | tables / flux tokens création & reset (TTL, usage unique) |
|
||||||
|
|
||||||
|
Détail colonnes : [10_DATABASE.md](./10_DATABASE.md).
|
||||||
|
|
||||||
|
### Invariants
|
||||||
|
|
||||||
|
1. **Pas** de champ `est_multiple` / naissance multiple (#152).
|
||||||
|
2. Au plus **un** co-parent par fiche parent.
|
||||||
|
3. Affiliation création enfant : rattache **pivot + co-parent** s’il existe (#158).
|
||||||
|
4. Détachement du **dernier** parent → enfant **sans responsable** possible (#157).
|
||||||
|
5. Rattachement AM plafonné par **capacité** (#148 / #149).
|
||||||
|
6. `super_admin` **non supprimable**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Flux
|
||||||
|
|
||||||
|
### 5.1 Inscription → validation → mot de passe
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as Usager
|
||||||
|
participant API as API
|
||||||
|
participant S as Staff
|
||||||
|
participant M as Mail
|
||||||
|
U->>API: register parent/AM
|
||||||
|
API-->>U: numero_dossier, statut en_attente
|
||||||
|
S->>API: review / valider
|
||||||
|
API->>M: mail lien create-password
|
||||||
|
U->>API: verify token + set password
|
||||||
|
API-->>U: compte actif, login OK
|
||||||
|
```
|
||||||
|
|
||||||
|
Variante **refus** : staff refuse → mail refus → usager **reprise** (token lien ou n° dossier) → resoumission → à valider.
|
||||||
|
|
||||||
|
### 5.2 Mot de passe oublié
|
||||||
|
|
||||||
|
Flux distinct de la création post-validation : demande → e-mail → reset (#127). Tokens durcis (#123).
|
||||||
|
|
||||||
|
### 5.3 Création dossier staff
|
||||||
|
|
||||||
|
- Famille : wizard + `POST` staff parent/dossier (#129).
|
||||||
|
- AM : wizard + API staff AM (#156).
|
||||||
|
- Édition : mode `edit` wizards + PATCH fiches ; ajout co-parent `POST …/co-parent` (#135).
|
||||||
|
|
||||||
|
### 5.4 Rattachements
|
||||||
|
|
||||||
|
| Lien | Opérations |
|
||||||
|
|------|------------|
|
||||||
|
| Parent ↔ enfant | attach / detach (fiche parent, fiche enfant) |
|
||||||
|
| AM ↔ enfant | attach / detach (fiche AM, fiche enfant) ; UI capacité |
|
||||||
|
|
||||||
|
API : tickets #115 / #116 ; liste enfants enrichie #136.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Matrice droits × actions (synthèse)
|
||||||
|
|
||||||
|
| Action | gestionnaire | administrateur | super_admin |
|
||||||
|
|--------|:------------:|:--------------:|:-----------:|
|
||||||
|
| Voir dashboard usagers / dossiers | ✓ | ✓ | ✓ |
|
||||||
|
| Valider / refuser dossier | ✓ | ✓ | ✓ |
|
||||||
|
| Créer dossier parent / AM | ✓ | ✓ | ✓ |
|
||||||
|
| Éditer fiches parent / AM / enfant | ✓ | ✓ | ✓ |
|
||||||
|
| Rattacher / détacher enfant | ✓ | ✓ | ✓ |
|
||||||
|
| GET liste relais (combo) | ✓ | ✓ | ✓ |
|
||||||
|
| Créer gestionnaire / admin | ✗ | ✓ | ✓ |
|
||||||
|
| Supprimer parent / AM / enfant / dossier | ✓ | ✓ | ✓ |
|
||||||
|
| Supprimer gestionnaire | ✗ | ✓ | ✓ |
|
||||||
|
| Supprimer admin (garde-fous) | ✗ | ✓* | ✓* |
|
||||||
|
| Supprimer super_admin | ✗ | ✗ | ✗ |
|
||||||
|
| Supprimer son propre compte staff | ✗ | ✗ | ✗ |
|
||||||
|
|
||||||
|
\* Voir `canDeleteAdministrateur` / messages `adminDeleteBlockedReason`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. UI staff (points d’entrée code)
|
||||||
|
|
||||||
|
| Zone | Emplacement typique |
|
||||||
|
|------|---------------------|
|
||||||
|
| Panneau gestion users | `widgets/dashboard/user_management_panel.dart` |
|
||||||
|
| Onglets parents / AM / enfants / dossiers | `widgets/dashboard/*_management_widget.dart` |
|
||||||
|
| Fiche parent | `parent_edit_modal.dart` |
|
||||||
|
| Fiche AM | `am_edit_modal.dart` |
|
||||||
|
| Fiche enfant | `child_detail_modal.dart` |
|
||||||
|
| Wizards dossier | `parent_dossier_wizard.dart`, `am_dossier_wizard.dart` |
|
||||||
|
| Modale staff | `staff_user_form_modal.dart` (`StaffUserFormModal`) |
|
||||||
|
| Confirm suppressions | `widgets/dashboard/common/suppression_confirm_dialog.dart` |
|
||||||
|
| Champs contrôlés | `validation_detail_section.dart` (`ValidationEmailField`, `ValidationPhoneField`…) |
|
||||||
|
| Thème primaire modales | `validation_modal_theme.dart` |
|
||||||
|
|
||||||
|
Shell fiches / wizards : largeur **930**, labels au-dessus, primaire violet `ValidationModalTheme`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. API — groupes (renvoi)
|
||||||
|
|
||||||
|
Ne pas dupliquer OpenAPI ici. Groupes utiles au domaine :
|
||||||
|
|
||||||
|
| Groupe | Exemples d’usage |
|
||||||
|
|--------|------------------|
|
||||||
|
| Auth / register | Inscription parent & AM, login, tokens MDP, oubli MDP |
|
||||||
|
| Dossiers | `GET /dossiers/:numero`, listes à valider / unifiées, validate/refuse |
|
||||||
|
| Parents | Fiche PATCH, co-parent POST, dossier staff POST |
|
||||||
|
| AM | Fiche PATCH, dossier staff POST |
|
||||||
|
| Enfants | CRUD staff, attach/detach parent & AM |
|
||||||
|
| Users / staff | CRUD gestionnaires / admins, DELETE métier |
|
||||||
|
| Relais | `GET /relais` (autorisé gestionnaire — #151) |
|
||||||
|
| Config | setup / configuration instance |
|
||||||
|
|
||||||
|
Référence : [11_API.md](./11_API.md) (à maintenir en parallèle si écart).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Exigences non fonctionnelles (domaine users)
|
||||||
|
|
||||||
|
| ID | Exigence |
|
||||||
|
|----|----------|
|
||||||
|
| NFR-U1 | Tokens création / reset MDP : TTL strict, usage unique |
|
||||||
|
| NFR-U2 | Mots de passe : politique min. longueur (création staff / usager selon flux) |
|
||||||
|
| NFR-U3 | E-mails métier (validation, refus, MDP) via config SMTP instance |
|
||||||
|
| NFR-U4 | Pas de login si `en_attente` ou `suspendu` |
|
||||||
|
| NFR-U5 | Confirmations explicites avant toute suppression |
|
||||||
|
| NFR-U6 | Champs e-mail / téléphone : validation & normalisation côté UI staff (SRS UX #164) |
|
||||||
|
|
||||||
|
Hors scope immédiat : normalisation erreurs auth globale (#121), observabilité (#122), audit trail complet (#128).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Limites modèle famille (assumées)
|
||||||
|
|
||||||
|
Documentées pour les développeurs — détail produit : [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md).
|
||||||
|
|
||||||
|
- Un `numero_dossier` / user ; un seul co-parent.
|
||||||
|
- Familles recomposées multi-contextes : contournement (2ᵉ compte / composition staff) jusqu’à epic #139.
|
||||||
|
- La SRS **n’exige pas** N responsables en v0.1.0.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Traçabilité livré 0.1.0
|
||||||
|
|
||||||
|
Source : [29_BILAN-VERSION-0.1.0.md](./29_BILAN-VERSION-0.1.0.md) — 48 tickets milestone 0.1.0 (auth, inscription, fiches, dossiers, suppressions, cleanups #152/#155/#162/#164).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Hors périmètre de cette SRS
|
||||||
|
|
||||||
|
- Contrats, avenants, fin de contrat (restent dans le **CDC** cible)
|
||||||
|
- Messagerie, agenda, événements RPE (idem CDC)
|
||||||
|
- Paie / Pajemploi
|
||||||
|
- Recherche AM côté parent
|
||||||
|
- PRA, CI/CD, monitoring infra
|
||||||
|
- Spécification OpenAPI ligne à ligne
|
||||||
|
- Texte fonctionnel complet → [01_CAHIER-DES-CHARGES.md](./01_CAHIER-DES-CHARGES.md)
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
# Fichier déplacé
|
|
||||||
|
|
||||||
La documentation **Documents légaux** a été déplacée vers :
|
|
||||||
|
|
||||||
**[juridique/22_DOCUMENTS-LEGAUX.md](./juridique/22_DOCUMENTS-LEGAUX.md)**
|
|
||||||
|
|
||||||
Voir aussi le dossier **[juridique/](./juridique/)** pour les sources **CGU**
|
|
||||||
et **CGC** en Markdown.
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Suivi des tickets — P'titsPas
|
||||||
|
|
||||||
|
**Source de vérité** : [Gitea — issues](https://git.ptits-pas.fr/jmartin/petitspas/issues)
|
||||||
|
**Milestones / versions** : [05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md)
|
||||||
|
**API Gitea** : [26_GITEA-API.md](./26_GITEA-API.md)
|
||||||
|
|
||||||
|
Ne plus maintenir de catalogue exhaustif des tickets dans le dépôt : l’état (ouvert / fermé / milestone) change dans Gitea.
|
||||||
|
|
||||||
|
Pour une **version livrée**, lire le bilan correspondant (ex. [29_BILAN-VERSION-0.1.0.md](./29_BILAN-VERSION-0.1.0.md)).
|
||||||
|
|
||||||
|
Archive historique (liste Phase 1 figée, avril 2026) :
|
||||||
|
[archive/obsolete/23_LISTE-TICKETS.md](./archive/obsolete/23_LISTE-TICKETS.md).
|
||||||
+13
-21
@@ -423,31 +423,23 @@ ptitspas-app/
|
|||||||
- Maintenance (tout au même endroit)
|
- Maintenance (tout au même endroit)
|
||||||
- Versioning (Git)
|
- Versioning (Git)
|
||||||
|
|
||||||
**Structure** :
|
**Structure** (sept. 2026) :
|
||||||
```
|
```
|
||||||
docs/
|
docs/
|
||||||
├── 00_INDEX.md
|
├── 00_INDEX.md
|
||||||
├── 01_CAHIER-DES-CHARGES.md
|
├── 01_CAHIER-DES-CHARGES.md # V1.4 fonctionnel
|
||||||
├── 02_ARCHITECTURE.md
|
├── 05_VERSIONS-ET-MILESTONES.md
|
||||||
├── 03_DEPLOYMENT.md
|
├── 12_SRS-GESTION-UTILISATEURS.md
|
||||||
├── 10_DATABASE.md
|
├── 23_SUIVI-TICKETS.md
|
||||||
├── 11_API.md
|
|
||||||
├── 20_WORKFLOW-CREATION-COMPTE.md
|
|
||||||
├── 21_CONFIGURATION-SYSTEME.md
|
|
||||||
├── 22_DOCUMENTS-LEGAUX.md # pointeur → juridique/
|
|
||||||
├── 27_BRIEFING-FRONTEND.md
|
|
||||||
├── PROCEDURE-API-GITEA.md # pointeur → 26_GITEA-API.md
|
|
||||||
├── juridique/
|
|
||||||
│ ├── README.md
|
|
||||||
│ ├── cgu.md
|
|
||||||
│ ├── cgc.md
|
|
||||||
│ └── 22_DOCUMENTS-LEGAUX.md
|
|
||||||
├── archive/
|
|
||||||
│ ├── README.md
|
|
||||||
│ ├── temporaires/
|
|
||||||
│ └── obsolete/
|
|
||||||
├── 23_LISTE-TICKETS.md
|
|
||||||
├── 24_DECISIONS-PROJET.md (ce document)
|
├── 24_DECISIONS-PROJET.md (ce document)
|
||||||
|
├── 26_GITEA-API.md
|
||||||
|
├── 27_BRIEFING-FRONTEND.md
|
||||||
|
├── 28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md
|
||||||
|
├── 29_BILAN-VERSION-0.1.0.md
|
||||||
|
├── 99_REGLES-CODAGE.md
|
||||||
|
├── CHARTE_GRAPHIQUE.md
|
||||||
|
├── juridique/ # CGU + 22_DOCUMENTS-LEGAUX.md
|
||||||
|
├── archive/ # obsolete / temporaires
|
||||||
├── 90_AUDIT.md
|
├── 90_AUDIT.md
|
||||||
└── test-data/
|
└── test-data/
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
**Version** : 1.1
|
**Version** : 1.1
|
||||||
**Date** : 16 juin 2026
|
**Date** : 16 juin 2026
|
||||||
**Statut** : Réflexions produit / architecture — complément au [CDC](./01_CAHIER-DES-CHARGES.md)
|
**Statut** : Réflexions produit / architecture — complément au [CDC](./01_CAHIER-DES-CHARGES.md)
|
||||||
**Documents liés** : [EVOLUTIONS_CDC.md](./EVOLUTIONS_CDC.md), [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md), [23_LISTE-TICKETS.md](./23_LISTE-TICKETS.md)
|
**Documents liés** : [01_CAHIER-DES-CHARGES.md](./01_CAHIER-DES-CHARGES.md) (V1.4), [12_SRS-GESTION-UTILISATEURS.md](./12_SRS-GESTION-UTILISATEURS.md), [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md), [23_SUIVI-TICKETS.md](./23_SUIVI-TICKETS.md), [29_BILAN-VERSION-0.1.0.md](./29_BILAN-VERSION-0.1.0.md)
|
||||||
|
**Archive** : ancien patch CDC → [archive/obsolete/EVOLUTIONS_CDC.md](./archive/obsolete/EVOLUTIONS_CDC.md)
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# Bilan — Version 0.1.0
|
||||||
|
|
||||||
|
**Statut** : terminée
|
||||||
|
**Milestone Gitea** : [0.1.0](https://git.ptits-pas.fr/jmartin/petitspas/milestone/10)
|
||||||
|
**Dépôt** : `jmartin/petitspas`
|
||||||
|
**Tag prévu** : `v0.1.0` (sur `master` après merge de cette doc)
|
||||||
|
|
||||||
|
Ce document est la **mémoire produit** de la version 0.1.0 : ce qui a été livré, ticket par ticket, et ce qui a été reporté.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Périmètre produit livré
|
||||||
|
|
||||||
|
La 0.1.0 couvre le **MVP opérable** pour une collectivité :
|
||||||
|
|
||||||
|
- Authentification, création / oubli de mot de passe, e-mails associés
|
||||||
|
- Inscription parent & AM, validation / refus gestionnaire, reprise après refus
|
||||||
|
- Dashboard staff (admin + gestionnaire) : listes, fiches, rattachements
|
||||||
|
- Onglet **Dossiers** + wizards création / édition (famille & AM)
|
||||||
|
- Suppressions métier (droits, confirms, cascades API)
|
||||||
|
- Cleanups structurels (préfixe `Admin*`, panels dashboard, modale staff, retrait `est_multiple`)
|
||||||
|
|
||||||
|
**Hors 0.1.0** (reporté) : doublons avancés, famille N responsables, statut enfant gardé/sans garde, combobox RPE AM, chantier CDC (#117), tickets tech/observabilité — voir §4 et [05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Thèmes livrés
|
||||||
|
|
||||||
|
### 2.1 Auth, mot de passe, e-mails
|
||||||
|
|
||||||
|
| # | Titre | Livré |
|
||||||
|
|---|-------|--------|
|
||||||
|
| 24 | API Création mot de passe | Endpoints token → création MDP post-validation |
|
||||||
|
| 28 | Templates Email — Validation | Mails validation avec lien MDP |
|
||||||
|
| 30 | Connexion — Vérification statut | Blocage comptes pending / suspendus à la connexion |
|
||||||
|
| 43 | Écran Création Mot de Passe | UI lien e-mail création MDP |
|
||||||
|
| 47 | Écran Changement MDP Obligatoire | Première connexion staff |
|
||||||
|
| 50 | Affichage dynamique CGU | CGU/Privacy versionnées à l’inscription |
|
||||||
|
| 118 | Page création mot de passe (Front + API) | Alignement front/API du flux lien e-mail |
|
||||||
|
| 123 | Durcissement token création MDP | TTL, usage unique, contrôles API |
|
||||||
|
| 127 | Mot de passe oublié | Demande → e-mail → réinitialisation (flux distinct de #24/#43) |
|
||||||
|
|
||||||
|
### 2.2 Inscription, numéros de dossier, reprise
|
||||||
|
|
||||||
|
| # | Titre | Livré |
|
||||||
|
|---|-------|--------|
|
||||||
|
| 104 | Numéro de dossier — frontend | Affichage listes / mails / modales ; format AAAA-… |
|
||||||
|
| 112 | Reprise après refus — frontend | Lien e-mail `/reprise` + reprise par n° dossier |
|
||||||
|
| 120 | Inscription AM — photo & UX | Chaîne photo / API / UX alignée parents |
|
||||||
|
| 144 | Consentement photo enfant | Persistance du consentement à l’inscription |
|
||||||
|
|
||||||
|
### 2.3 Dashboard — fiches, listes, rattachements
|
||||||
|
|
||||||
|
| # | Titre | Livré |
|
||||||
|
|---|-------|--------|
|
||||||
|
| 115 | Rattachement enfants — backend | Attach/detach parent↔enfant et AM↔enfant |
|
||||||
|
| 116 | Rattachement enfants — frontend | UI fiches parent / AM |
|
||||||
|
| 130 | UserService — APIs métier | Branchement parents / AM / enfants côté front |
|
||||||
|
| 131 | Édition fiche parent + AM | Modales édition dashboard |
|
||||||
|
| 132 | Création enfant (onglet Enfants) | Création + rattachement foyer |
|
||||||
|
| 136 | API enfants — droits & liste | Droits gestionnaire + enrichissement liste |
|
||||||
|
| 137 | Onglet Enfants — liste globale | Panneau liste dashboard |
|
||||||
|
| 138 | Fiche enfant + liste dans parent | Fiche enfant ; enfants dans fiche parent |
|
||||||
|
| 140 | Epic fiche parent / affiliation | Livraison regroupée dashboard admin/gestionnaire |
|
||||||
|
| 142 | Clic carte → modale | Ouverture fiche depuis les listes |
|
||||||
|
| 145 | Lien co-parent cliquable | Navigation fiche parent → co-parent |
|
||||||
|
| 146 | Modale sélection enfant | UX rattacher enfant (AM + parent) |
|
||||||
|
| 147 | Modale sélection AM | UX rattacher AM depuis fiche enfant |
|
||||||
|
| 148 | Capacité max AM | Désactivation rattachement si capacité atteinte |
|
||||||
|
| 149 | Case libre AM → rattacher | Clic emplacement vide pour rattacher |
|
||||||
|
| 151 | GET /relais pour gestionnaire | Combo relais dans modale staff |
|
||||||
|
| 157 | Enfant sans responsable | Détachement dernier parent + alerte liste |
|
||||||
|
| 158 | Affiliation foyer (pivot + co-parent) | Attach/detach cohérents sur le foyer |
|
||||||
|
|
||||||
|
### 2.4 Dossiers staff (création, édition, liste)
|
||||||
|
|
||||||
|
| # | Titre | Livré |
|
||||||
|
|---|-------|--------|
|
||||||
|
| 129 | Création dossier parent | Wizard + API staff famille |
|
||||||
|
| 135 | Édition dossier + 2ᵉ parent | Mode edit wizards + `POST …/co-parent` |
|
||||||
|
| 153 | Onglet Dossiers | Liste unifiée + à valider (sans création dans l’onglet) |
|
||||||
|
| 156 | Création dossier AM | Wizard + API staff AM |
|
||||||
|
|
||||||
|
### 2.5 Suppressions & droits staff
|
||||||
|
|
||||||
|
| # | Titre | Livré |
|
||||||
|
|---|-------|--------|
|
||||||
|
| 133 | Suppression parent + AM (UI) | Première vague UI (complétée par #160) |
|
||||||
|
| 134 | Droits « Ajouter gestionnaire » | Visibilité / API selon rôle |
|
||||||
|
| 143 | Bug supprimer sa propre fiche | Masquage / interdiction auto-suppression |
|
||||||
|
| 154 | Epic suppressions | Cadrage règles métier suppressions |
|
||||||
|
| 159 | Suppressions métier — backend | Cascades, garde-fous, droits API |
|
||||||
|
| 160 | Suppressions dashboard — frontend | Poubelles + dialogues de confirmation |
|
||||||
|
| 161 | Admin création staff 403 | Admin peut créer gestionnaire et administrateur |
|
||||||
|
|
||||||
|
### 2.6 Cleanups structure & UX
|
||||||
|
|
||||||
|
| # | Titre | Livré |
|
||||||
|
|---|-------|--------|
|
||||||
|
| 25 | API Liste comptes en attente | Historique ; couvert par flux dossiers / validation |
|
||||||
|
| 26 | API Validation / Refus | Historique ; couvert par flux dossiers / validation |
|
||||||
|
| 152 | Retrait `est_multiple` | Suppression full stack (BDD, API, front, docs) |
|
||||||
|
| 155 | Rename préfixe `Admin*` | Widgets partagés sans préfixe Admin (option C) |
|
||||||
|
| 162 | Panels → `widgets/dashboard/` | Suite #155 — panels staff sous dashboard |
|
||||||
|
| 164 | Modale staff uniformisée | `StaffUserFormModal` (shell 930, champs contrôlés) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Inventaire exhaustif (48 tickets fermés, milestone 0.1.0)
|
||||||
|
|
||||||
|
| # | Titre |
|
||||||
|
|---|-------|
|
||||||
|
| 24 | [Backend] API Création mot de passe |
|
||||||
|
| 25 | [Backend] API Liste comptes en attente |
|
||||||
|
| 26 | [Backend] API Validation/Refus comptes |
|
||||||
|
| 28 | [Backend] Templates Email - Validation |
|
||||||
|
| 30 | [Backend] Connexion - Vérification statut |
|
||||||
|
| 43 | [Frontend] Écran Création Mot de Passe |
|
||||||
|
| 47 | [Frontend] Écran Changement MDP Obligatoire |
|
||||||
|
| 50 | [Frontend] Affichage dynamique CGU lors inscription |
|
||||||
|
| 104 | Numéro de dossier – frontend |
|
||||||
|
| 112 | Reprise après refus – frontend |
|
||||||
|
| 115 | [Backend] Rattachement enfants — parent et AM |
|
||||||
|
| 116 | [Frontend] Rattachement enfants — parent et AM |
|
||||||
|
| 118 | Page création mot de passe (lien email) – Front + API |
|
||||||
|
| 120 | [Full-stack] Inscription AM — photo, API et UX alignés sur les parents |
|
||||||
|
| 123 | [Tech] Durcissement token création MDP |
|
||||||
|
| 127 | [Full-stack] Mot de passe oublié — flux complet |
|
||||||
|
| 129 | Création dossier parent (wizard + API staff) |
|
||||||
|
| 130 | [Frontend] UserService — brancher APIs parents, AM et enfants |
|
||||||
|
| 131 | [Frontend] Édition fiche parent + AM |
|
||||||
|
| 132 | Création enfant depuis l’onglet Enfants |
|
||||||
|
| 133 | [Frontend] Suppression compte parent + AM |
|
||||||
|
| 134 | Droits bouton « Ajouter gestionnaire » |
|
||||||
|
| 135 | Mode édition dossier (+ ajout 2ᵉ parent) |
|
||||||
|
| 136 | [Backend] API enfants — droits gestionnaire + enrichissement liste |
|
||||||
|
| 137 | [Frontend] Onglet Enfants — liste globale |
|
||||||
|
| 138 | [Frontend] Fiche enfant + liste enfants dans fiche parent |
|
||||||
|
| 140 | Dashboard admin — fiche parent, enfants et affiliation |
|
||||||
|
| 142 | Clic sur carte → ouvrir la modale |
|
||||||
|
| 143 | Bug — Gestionnaire Supprimer sur sa propre fiche |
|
||||||
|
| 144 | Bug — Consentement photo enfant non sauvegardé |
|
||||||
|
| 145 | Lien co-parent cliquable |
|
||||||
|
| 146 | UX — modale sélection d'enfant |
|
||||||
|
| 147 | UX — modale sélection d'AM |
|
||||||
|
| 148 | Bug — capacité max AM |
|
||||||
|
| 149 | Fiche AM — clic case libre pour rattacher |
|
||||||
|
| 151 | Bug — GET /relais gestionnaire |
|
||||||
|
| 152 | Cleanup — supprimer `est_multiple` |
|
||||||
|
| 153 | Onglet permanent « Dossiers » |
|
||||||
|
| 154 | Epic — suppressions utilisateurs / dossiers / enfants / AM |
|
||||||
|
| 155 | Cleanup — renommer préfixe Admin* |
|
||||||
|
| 156 | Création dossier AM (wizard + API staff) |
|
||||||
|
| 157 | Enfant sans responsable |
|
||||||
|
| 158 | Affiliation enfant au foyer (pivot + co-parent) |
|
||||||
|
| 159 | Backend suppressions métier (#154) |
|
||||||
|
| 160 | Frontend suppressions dashboard (#154) |
|
||||||
|
| 161 | Bug — Admin création gestionnaire / administrateur |
|
||||||
|
| 162 | Cleanup — panels vers `widgets/dashboard/` |
|
||||||
|
| 164 | Uniformisation modale staff + champs contrôlés |
|
||||||
|
|
||||||
|
Issues : https://git.ptits-pas.fr/jmartin/petitspas/issues?q=&type=all&state=closed&labels=&milestone=10&assignee=0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Reporté hors 0.1.0
|
||||||
|
|
||||||
|
| # | Titre | Destination typique |
|
||||||
|
|---|-------|---------------------|
|
||||||
|
| 113 / 114 | Doublons inscription / alerte gestionnaire | 0.9.0 |
|
||||||
|
| 117 | Évolution du cahier des charges | Doc (amendement CDC post-0.1.0) |
|
||||||
|
| 121–122, 124–125 | Tech auth / photos / DB | 0.9.0 |
|
||||||
|
| 126 | Upload documents légaux 500 | 0.9.0 |
|
||||||
|
| 128 | Audit / traçabilité modifications | 0.9.0 |
|
||||||
|
| 139 | Famille complexe N responsables | Post-0.1.0 / epic |
|
||||||
|
| 141 | Statut enfant gardé / sans garde | Post-0.1.0 |
|
||||||
|
| 150 | Combobox rattachement RPE (AM) | 0.2.0 |
|
||||||
|
|
||||||
|
Voir aussi [05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Suite documentaire
|
||||||
|
|
||||||
|
1. **Amendement CDC (#117)** — CDC V1.4 : mise à jour **gestion utilisateurs** ; le reste du CDC reste la cible. SRS technique : [12_SRS-GESTION-UTILISATEURS.md](./12_SRS-GESTION-UTILISATEURS.md). Intrants historiques archivés : [archive/obsolete/EVOLUTIONS_CDC.md](./archive/obsolete/EVOLUTIONS_CDC.md), [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md).
|
||||||
|
2. **Tag** `v0.1.0` sur `master` lorsque milestone fermée + ce bilan mergé.
|
||||||
|
3. Enchaîner les milestones **0.2.0+** selon [05_VERSIONS-ET-MILESTONES.md](./05_VERSIONS-ET-MILESTONES.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Références code (points d’entrée)
|
||||||
|
|
||||||
|
- Modale staff : `frontend/lib/widgets/dashboard/staff_user_form_modal.dart`
|
||||||
|
- Fiches : `parent_edit_modal.dart`, `am_edit_modal.dart`, `child_detail_modal.dart`
|
||||||
|
- Wizards dossiers : `parent_dossier_wizard.dart`, `am_dossier_wizard.dart`
|
||||||
|
- Règles suppressions : tickets #154 / #159 / #160
|
||||||
|
- Cleanup `est_multiple` : #152
|
||||||
@@ -276,9 +276,6 @@ export class Enfants {
|
|||||||
@Column({ name: 'consentement_photo', type: 'boolean', default: false })
|
@Column({ name: 'consentement_photo', type: 'boolean', default: false })
|
||||||
consentementPhoto: boolean;
|
consentementPhoto: boolean;
|
||||||
|
|
||||||
@Column({ name: 'est_multiple', type: 'boolean', default: false })
|
|
||||||
estMultiple: boolean;
|
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'enum',
|
type: 'enum',
|
||||||
enum: StatutEnfantType,
|
enum: StatutEnfantType,
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
# Fichier déplacé / fusionné
|
|
||||||
|
|
||||||
La procédure **API Gitea** est désormais documentée sous :
|
|
||||||
|
|
||||||
**[26_GITEA-API.md](./26_GITEA-API.md)**
|
|
||||||
|
|
||||||
L’ancienne copie `PROCEDURE-API-GITEA.md` est archivée dans
|
|
||||||
`docs/archive/obsolete/` (doublon).
|
|
||||||
+8
-19
@@ -1,30 +1,19 @@
|
|||||||
# Archive documentation · P'titsPas
|
# Archive documentation · P'titsPas
|
||||||
|
|
||||||
Ce dossier regroupe les fichiers **sans préfixe numérique** à la racine de
|
Fichiers **hors références actives** : brouillons livrés, CDC historiques, listes figées.
|
||||||
`docs/` qui ne sont plus des **références actives**, ou qui sont des
|
|
||||||
**brouillons / temporaires**.
|
|
||||||
|
|
||||||
## Règle de nommage (racine `docs/`)
|
## Règle de nommage (racine `docs/`)
|
||||||
|
|
||||||
- Les documents **normatifs** à la racine portent un préfixe **`NN_`**
|
- Documents **normatifs** : préfixe **`NN_`**.
|
||||||
(deux chiffres), ex. `23_LISTE-TICKETS.md`.
|
- Exceptions héritage listées dans [00_INDEX.md](../00_INDEX.md) (`CHARTE_GRAPHIQUE.md`).
|
||||||
- **Exceptions** (héritage ou outillage) listées dans
|
|
||||||
[**00_INDEX.md**](../00_INDEX.md#exceptions-de-nommage) : charte, CDC
|
|
||||||
historique, évolutions — **cible** : les renommer progressivement en `NN_`
|
|
||||||
et mettre à jour `.cursorrules` / liens.
|
|
||||||
|
|
||||||
## Sous-dossiers ici
|
## Sous-dossiers
|
||||||
|
|
||||||
| Dossier | Usage |
|
| Dossier | Usage |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| [**temporaires/**](./temporaires/) | Notes jetables, exports de travail.
|
| [**temporaires/**](./temporaires/) | Brouillons jetables. **Vider** dès livraison. |
|
||||||
**Supprimables** quand la tâche associée est close. |
|
| [**obsolete/**](./obsolete/) | Doc remplacée (CDC SuperNounou, ancienne liste tickets, notes ponctuelles, backlog Phase 2 figé). |
|
||||||
| [**obsolete/**](./obsolete/) | Ancienne doc **remplacée** ou **doublon**
|
|
||||||
(conservée un temps pour historique). **Supprimer** après bascule confirmée
|
|
||||||
si plus aucune référence. |
|
|
||||||
|
|
||||||
## Hors `docs/` racine
|
## Politique `tmp/`
|
||||||
|
|
||||||
Les dossiers thématiques (**`juridique/`**, **`test-data/`**, etc.) peuvent
|
Le dossier `docs/tmp/` **n’est plus utilisé**. Les mini-specs de tickets livrés sont purgés ; la mémoire produit = bilans de version (`29_…`) + tickets Gitea.
|
||||||
contenir des fichiers sans `NN_` : la règle `NN_` s’applique surtout aux
|
|
||||||
fichiers **directement** sous `docs/`.
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
Ce document liste les modifications à apporter au cahier des charges original pour le rendre conforme à l'application développée.
|
Ce document liste les modifications à apporter au cahier des charges original pour le rendre conforme à l'application développée.
|
||||||
|
|
||||||
> **Document complémentaire (juin 2026)** — réflexions sur le **modèle famille / numéro de dossier**, familles recomposées, tuteurs et responsables légaux : voir **[28 - Évolution famille et responsables](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md)**.
|
> **Intrant pour #117** (amendement CDC post-0.1.0). Compléter avec le [bilan 0.1.0](./29_BILAN-VERSION-0.1.0.md) et **[28 - Évolution famille et responsables](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md)**.
|
||||||
|
|
||||||
|
> **Obsolète depuis #152** : ne plus proposer de champ « naissance multiple / `est_multiple` » — retiré de l’app (BDD, API, front).
|
||||||
|
|
||||||
## 1. Gestion des Enfants
|
## 1. Gestion des Enfants
|
||||||
|
|
||||||
@@ -11,7 +13,6 @@ Ce document liste les modifications à apporter au cahier des charges original p
|
|||||||
#### Situation actuelle dans le CDC :
|
#### Situation actuelle dans le CDC :
|
||||||
- Mentionne uniquement la collecte d'informations sur l'enfant
|
- Mentionne uniquement la collecte d'informations sur l'enfant
|
||||||
- Ne précise pas la possibilité d'ajouter plusieurs enfants
|
- Ne précise pas la possibilité d'ajouter plusieurs enfants
|
||||||
- Ne mentionne pas la gestion des naissances multiples
|
|
||||||
- Ne mentionne pas la gestion des enfants à naître
|
- Ne mentionne pas la gestion des enfants à naître
|
||||||
|
|
||||||
#### Modifications proposées :
|
#### Modifications proposées :
|
||||||
@@ -22,17 +23,18 @@ Ajouter le paragraphe suivant après la description de la collecte d'information
|
|||||||
Les parents peuvent ajouter autant d'enfants que nécessaire. Pour chaque enfant, les informations suivantes sont collectées :
|
Les parents peuvent ajouter autant d'enfants que nécessaire. Pour chaque enfant, les informations suivantes sont collectées :
|
||||||
- Prénom
|
- Prénom
|
||||||
- Date de naissance (ou date prévue pour les enfants à naître)
|
- Date de naissance (ou date prévue pour les enfants à naître)
|
||||||
|
- Genre
|
||||||
- Photo (optionnelle)
|
- Photo (optionnelle)
|
||||||
- Consentement pour l'utilisation de la photo
|
- Consentement pour l'utilisation de la photo
|
||||||
- Indication si l'enfant fait partie d'une naissance multiple (jumeaux, triplés, etc.)
|
|
||||||
|
|
||||||
Les parents peuvent :
|
Les parents peuvent :
|
||||||
- Ajouter un nouvel enfant à tout moment
|
- Ajouter un nouvel enfant à tout moment
|
||||||
- Supprimer un enfant ajouté
|
- Supprimer un enfant ajouté
|
||||||
- Modifier les informations d'un enfant existant
|
- Modifier les informations d'un enfant existant
|
||||||
- Indiquer si l'enfant est à naître
|
- Indiquer si l'enfant est à naître
|
||||||
- Indiquer si l'enfant fait partie d'une naissance multiple
|
|
||||||
- Donner ou retirer leur consentement pour l'utilisation de la photo de l'enfant
|
- Donner ou retirer leur consentement pour l'utilisation de la photo de l'enfant
|
||||||
|
|
||||||
|
Note : le concept de « naissance multiple » / jumeaux n'est pas géré par un champ dédié (retiré en 0.1.0, #152).
|
||||||
```
|
```
|
||||||
|
|
||||||
### Modifications à apporter dans la section "Workflow de création de compte"
|
### Modifications à apporter dans la section "Workflow de création de compte"
|
||||||
@@ -50,9 +52,9 @@ Remplacer l'étape 3 par :
|
|||||||
- Pour chaque enfant :
|
- Pour chaque enfant :
|
||||||
* Saisie du prénom
|
* Saisie du prénom
|
||||||
* Saisie de la date de naissance (ou date prévue)
|
* Saisie de la date de naissance (ou date prévue)
|
||||||
|
* Genre
|
||||||
* Option d'ajout d'une photo
|
* Option d'ajout d'une photo
|
||||||
* Option de consentement photo
|
* Option de consentement photo
|
||||||
* Indication si naissance multiple
|
|
||||||
* Indication si enfant à naître
|
* Indication si enfant à naître
|
||||||
- Possibilité de modifier ou supprimer un enfant
|
- Possibilité de modifier ou supprimer un enfant
|
||||||
```
|
```
|
||||||
@@ -4,11 +4,15 @@ Ancienne documentation **déplacée** depuis `docs/` :
|
|||||||
|
|
||||||
| Fichier | Motif |
|
| Fichier | Motif |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| `PROCEDURE-API-GITEA.md` | Doublon fonctionnel de
|
| `01_CAHIER-DES-CHARGES-v1.3.md` | CDC V1.3 — remplacé par [01 V1.4](../../01_CAHIER-DES-CHARGES.md) |
|
||||||
[**26_GITEA-API.md**](../../26_GITEA-API.md). |
|
| `EVOLUTIONS_CDC.md` | Patch CDC — absorbé dans V1.4 + [12_SRS](../../12_SRS-GESTION-UTILISATEURS.md) |
|
||||||
| `ARCHITECTURE_TECHNIQUE.md` | Non référencé ; la vue d’ensemble est dans
|
| `PROCEDURE-API-GITEA.md` | Doublon de [26_GITEA-API.md](../../26_GITEA-API.md) |
|
||||||
[**02_ARCHITECTURE.md**](../../02_ARCHITECTURE.md). |
|
| `ARCHITECTURE_TECHNIQUE.md` | Remplacé par [02_ARCHITECTURE.md](../../02_ARCHITECTURE.md) |
|
||||||
| `STATUS-APPLICATION.md` | Instantané daté ; non tenu comme doc vivante. |
|
| `STATUS-APPLICATION.md` | Instantané daté |
|
||||||
|
| `23_LISTE-TICKETS.md` | Liste Phase 1 figée — suivi = Gitea + bilans |
|
||||||
|
| `25_PHASE-2-BACKLOG.md` | Backlog technique figé — voir [05_VERSIONS…](../../05_VERSIONS-ET-MILESTONES.md) |
|
||||||
|
| `SuperNounou_*` | CDC / SSS historiques |
|
||||||
|
| `14_NOTE-BACKEND-CONFIG-SETUP.md` | Note ticket ponctuelle |
|
||||||
|
| `92_NOTE-BACKEND-GESTIONNAIRES.md` | Note ticket ponctuelle |
|
||||||
|
|
||||||
Après vérification qu’aucun lien externe ne pointe encore vers ces chemins, on
|
Références actives : [01 CDC V1.4](../../01_CAHIER-DES-CHARGES.md), [12 SRS users](../../12_SRS-GESTION-UTILISATEURS.md), [29 bilan 0.1.0](../../29_BILAN-VERSION-0.1.0.md).
|
||||||
peut **supprimer** ce sous-dossier ou ne garder que des pointeurs minimalistes.
|
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
# #131 — En-tête fiche parent : co-parent (note front → back)
|
|
||||||
|
|
||||||
**Ticket :** #131 (fiche parent dashboard, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1)
|
|
||||||
**Date :** 2026-06-01
|
|
||||||
**Statut front :** livré (en-tête dynamique)
|
|
||||||
**Modif backend demandée :** **aucune fonctionnelle** — ce document fixe le contrat attendu ; le back valide `co_parent` et masque les champs sensibles.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Comportement UI (front)
|
|
||||||
|
|
||||||
Dans la modale **fiche parent** (`AdminParentEditModal`) :
|
|
||||||
|
|
||||||
| Zone | Contenu |
|
|
||||||
|------|---------|
|
|
||||||
| **Titre** | `prenom` + `nom` du parent affiché (plus le libellé fixe « Fiche parent ») |
|
|
||||||
| **Sous-titre** | `Co-parent : {prenom} {nom}` — affiché **uniquement** si un co-parent est connu |
|
|
||||||
|
|
||||||
Le titre se met à jour en direct pendant l’édition des champs nom/prénom.
|
|
||||||
Le sous-titre provient du co-parent **chargé depuis l’API** (pas saisi à la main dans la modale).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Endpoints consommés
|
|
||||||
|
|
||||||
| Méthode | Route | Usage front |
|
|
||||||
|---------|-------|-------------|
|
|
||||||
| `GET` | `/api/v1/parents` | Liste parents (onglet Parents) |
|
|
||||||
| `GET` | `/api/v1/parents/:userId` | Rechargement fiche après rattachement/détachement enfant |
|
|
||||||
| `PATCH` | `/api/v1/parents/:userId/fiche` | Sauvegarde identité + statut (inchangé) |
|
|
||||||
|
|
||||||
Rôles : `super_admin`, `gestionnaire`, `administrateur` (selon route).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Contrat JSON attendu pour `co_parent`
|
|
||||||
|
|
||||||
Le front parse `ParentModel.fromJson` avec la clé **`co_parent`** (snake_case), objet utilisateur imbriqué.
|
|
||||||
|
|
||||||
### Champs minimum utilisés pour le sous-titre
|
|
||||||
|
|
||||||
| Clé JSON | Usage |
|
|
||||||
|----------|--------|
|
|
||||||
| `co_parent` | Objet ou absent/`null` |
|
|
||||||
| `co_parent.id` | Identifiant (futur lien cliquable éventuel) |
|
|
||||||
| `co_parent.prenom` | Affichage |
|
|
||||||
| `co_parent.nom` | Affichage |
|
|
||||||
|
|
||||||
Affichage front : `'{prenom} {nom}'.trim()` → libellé `Co-parent : …`.
|
|
||||||
|
|
||||||
### Exemple de fragment de réponse (`GET /parents/:id`)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"user_id": "33333333-3333-3333-3333-333333333333",
|
|
||||||
"numero_dossier": "2026-000042",
|
|
||||||
"user": {
|
|
||||||
"id": "33333333-3333-3333-3333-333333333333",
|
|
||||||
"email": "parent1@example.com",
|
|
||||||
"prenom": "Paul",
|
|
||||||
"nom": "PARENT",
|
|
||||||
"statut": "actif",
|
|
||||||
"telephone": "0601020304"
|
|
||||||
},
|
|
||||||
"co_parent": {
|
|
||||||
"id": "44444444-4444-4444-4444-444444444444",
|
|
||||||
"email": "coparent1@example.com",
|
|
||||||
"prenom": "Clara",
|
|
||||||
"nom": "COPARENT",
|
|
||||||
"role": "parent",
|
|
||||||
"statut": "actif"
|
|
||||||
},
|
|
||||||
"parentChildren": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Note :** le front lit `user` (pas `utilisateur`). La doc `11_API.md` § Parents mentionne encore `utilisateur` / `id_co_parent` seul — le contrat **effectif** côté Nest/TypeORM est l’entité `Parents` sérialisée (`user`, `co_parent`, `parentChildren`, …).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. État backend
|
|
||||||
|
|
||||||
### Relations (déjà en place)
|
|
||||||
|
|
||||||
- `findAll()` et `findOne(user_id)` chargent **`co_parent`** ;
|
|
||||||
- FK : `parents.id_co_parent` → `utilisateurs.id` ;
|
|
||||||
- inscription couple : les deux sens renseignés en principe (`auth.service.ts`).
|
|
||||||
|
|
||||||
### Livraison back (#131)
|
|
||||||
|
|
||||||
- `mapParentForApi` / `sanitizeUserForApi` : réponses `GET/PATCH/POST/DELETE` parents **sans** `password`, `token_creation_mdp`, `password_reset_*` sur `user` et `co_parent`.
|
|
||||||
|
|
||||||
**Checklist validation :**
|
|
||||||
|
|
||||||
- [x] `GET /parents/:id` renvoie `co_parent` peuplé quand `id_co_parent` est non null
|
|
||||||
- [x] `GET /parents` (liste) inclut `co_parent`
|
|
||||||
- [x] `prenom` / `nom` du co-parent présents
|
|
||||||
- [x] Pas de fuite `password` / tokens sur `user` ni `co_parent`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Points d’attention (hors périmètre immédiat)
|
|
||||||
|
|
||||||
| Sujet | Détail |
|
|
||||||
|-------|--------|
|
|
||||||
| **Lien inverse** | Si B est co-parent de A (`A.id_co_parent = B`) mais `B.id_co_parent` est `null`, le sous-titre **ne s’affichera pas** sur la fiche de B. Pas de résolution inverse côté front. |
|
|
||||||
| **Familles > 2 adultes** | Sous-titre = co-parent direct (`id_co_parent`) uniquement. |
|
|
||||||
| **Trou AM ↔ enfants en garde** | Pas de lien AM–enfant aujourd’hui (à documenter / traiter plus tard). |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Fichiers back concernés
|
|
||||||
|
|
||||||
| Fichier | Rôle |
|
|
||||||
|---------|------|
|
|
||||||
| `backend/src/routes/parents/parents.service.ts` | `findOne`, `findAll` + relations |
|
|
||||||
| `backend/src/routes/parents/parents.controller.ts` | `mapParentForApi` sur les réponses |
|
|
||||||
| `backend/src/routes/parents/parents.mapper.ts` | Sérialisation API |
|
|
||||||
| `backend/src/common/utils/sanitize-user-for-api.ts` | Masquage secrets |
|
|
||||||
| `backend/src/entities/parents.entity.ts` | relation `co_parent` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Références
|
|
||||||
|
|
||||||
- `docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1
|
|
||||||
- Ticket Gitea **#131**
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# Archivé docs/archive/temporaires/ — export jetable, supprimer si inutile.
|
|
||||||
Point tickets frontend (API Gitea) - 27/01/2026
|
|
||||||
================================================
|
|
||||||
|
|
||||||
Issues avec label "frontend" : 20 (ouvertes: 12, fermees: 8)
|
|
||||||
|
|
||||||
Num | Etat | Titre
|
|
||||||
----+--------+--------------------------------------------------------
|
|
||||||
35 | open | [Frontend] Écran Création Gestionnaire
|
|
||||||
36 | closed | [Frontend] Inscription Parent - Étape 1 (Parent 1)
|
|
||||||
37 | closed | [Frontend] Inscription Parent - Étape 2 (Parent 2)
|
|
||||||
38 | closed | [Frontend] Inscription Parent - Étape 3 (Enfants)
|
|
||||||
39 | closed | [Frontend] Inscription Parent - Étapes 4-6 (Finalisatio
|
|
||||||
40 | closed | [Frontend] Inscription AM - Panneau 1 (Identité)
|
|
||||||
41 | closed | [Frontend] Inscription AM - Panneau 2 (Infos pro)
|
|
||||||
42 | closed | [Frontend] Inscription AM - Finalisation
|
|
||||||
43 | open | [Frontend] Écran Création Mot de Passe
|
|
||||||
44 | closed | [Frontend] Dashboard Gestionnaire - Structure
|
|
||||||
45 | open | [Frontend] Dashboard Gestionnaire - Liste Parents
|
|
||||||
46 | open | [Frontend] Dashboard Gestionnaire - Liste AM
|
|
||||||
47 | open | [Frontend] Écran Changement MDP Obligatoire
|
|
||||||
48 | open | [Frontend] Gestion Erreurs & Messages
|
|
||||||
49 | open | [Frontend] Écran Gestion Documents Légaux (Admin)
|
|
||||||
50 | open | [Frontend] Affichage dynamique CGU lors inscription
|
|
||||||
51 | open | [Frontend] Écran Logs Admin (optionnel v1.1)
|
|
||||||
54 | open | [Tests] Tests E2E Frontend
|
|
||||||
82 | closed | [Frontend] Adapter �cran Login pour mobile
|
|
||||||
83 | closed | [Frontend] Adapter �cran Choix Inscription pour mobile
|
|
||||||
|
|
||||||
Suivi doc 23_LISTE-TICKETS (Gitea #73,78,79,81,82,83):
|
|
||||||
#73 closed labels=[]
|
|
||||||
#78 closed labels=[]
|
|
||||||
#79 closed labels=[]
|
|
||||||
#81 closed labels=[]
|
|
||||||
#82 closed (écran Login mobile)
|
|
||||||
#83 closed labels=['frontend', 'p3', 'phase-1', 'ux']
|
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
# Temporaires
|
# Temporaires
|
||||||
|
|
||||||
Fichiers **non numérotés** de travail (brouillons, listes de tickets exportées,
|
Dossier **vide** après clôture 0.1.0 (purge sept. 2026).
|
||||||
alignements UI en cours, etc.).
|
|
||||||
|
|
||||||
- Préfixe conseillé pour les nouveaux fichiers jetables : **`TEMP_`** ou
|
Si un brouillon de travail est nécessaire un temps :
|
||||||
**`WIP_`** dans ce dossier.
|
|
||||||
- **Suppression** : dès que la fonctionnalité est livrée ou le sujet clos,
|
- le placer ici avec préfixe `TEMP_` / `WIP_` ;
|
||||||
supprimer le fichier (ou le déplacer vers `obsolete/` si une trace utile
|
- le **supprimer** dès livraison (ne pas laisser pourrir) ;
|
||||||
reste nécessaire).
|
- pour une trace utile durable → bilan de version ou archive `obsolete/`.
|
||||||
|
|
||||||
|
Ne plus utiliser `docs/tmp/`.
|
||||||
|
|||||||
@@ -1,244 +0,0 @@
|
|||||||
# #112 — Alignement front après évolution back (reprise dossier complet)
|
|
||||||
|
|
||||||
**Branche déployée :** `feature/112-reprise-apres-refus-front`
|
|
||||||
**Commit back :** `d70577b1` — `feat(#112): reprise après refus — dossier complet GET/PATCH`
|
|
||||||
**Date :** 2026-06-16
|
|
||||||
|
|
||||||
Ce document décrit le **contrat API réel** après extension du back, et ce que le front doit encore brancher pour exploiter le dossier complet (au-delà de l’identité seule).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Endpoints (inchangés côté URL)
|
|
||||||
|
|
||||||
| Méthode | Route | Auth |
|
|
||||||
|---------|-------|------|
|
|
||||||
| `GET` | `/api/v1/auth/reprise-dossier?token={uuid}` | Public |
|
|
||||||
| `PATCH` | `/api/v1/auth/reprise-resoumettre` | Public |
|
|
||||||
| `POST` | `/api/v1/auth/reprise-identify` | Public (inchangé) |
|
|
||||||
|
|
||||||
> **Note :** le ticket #111 parlait de `PUT` ; l’implémentation reste en **`PATCH`** (comme avant).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. `GET /auth/reprise-dossier` — réponse enrichie
|
|
||||||
|
|
||||||
### Champs communs (toujours présents)
|
|
||||||
|
|
||||||
Identiques à avant : `id`, `email`, `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal`, `numero_dossier`, `role`, `photo_url`, `genre`, `situation_familiale`.
|
|
||||||
|
|
||||||
### Rôle `parent` (+ champs #119)
|
|
||||||
|
|
||||||
Alignés sur `DossierFamilleCompletDto` :
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"parents": [
|
|
||||||
{
|
|
||||||
"user_id": "uuid",
|
|
||||||
"email": "…",
|
|
||||||
"prenom": "…",
|
|
||||||
"nom": "…",
|
|
||||||
"telephone": "…",
|
|
||||||
"adresse": "…",
|
|
||||||
"ville": "…",
|
|
||||||
"code_postal": "…",
|
|
||||||
"statut": "refuse",
|
|
||||||
"co_parent_id": "uuid-parent-entity"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"enfants": [
|
|
||||||
{
|
|
||||||
"id": "uuid-enfant",
|
|
||||||
"first_name": "Emma",
|
|
||||||
"last_name": "MARTIN",
|
|
||||||
"genre": "F",
|
|
||||||
"status": "actif",
|
|
||||||
"birth_date": "2023-02-15T00:00:00.000Z",
|
|
||||||
"due_date": null,
|
|
||||||
"photo_url": "/uploads/photos/…",
|
|
||||||
"consent_photo": true,
|
|
||||||
"est_multiple": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"texte_motivation": "Nous recherchons…"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Mapping front suggéré :**
|
|
||||||
|
|
||||||
| JSON back | Modèle / wizard parent |
|
|
||||||
|-----------|-------------------------|
|
|
||||||
| `parents[]` | `UserRegistrationData.parent1` + `parent2` (matcher par `email` ou ordre : titulaire = `id` du GET racine) |
|
|
||||||
| `enfants[].first_name` / `last_name` | `ChildData.firstName` / `lastName` |
|
|
||||||
| `enfants[].birth_date` | `ChildData.birthDate` (ISO → `DateTime`) |
|
|
||||||
| `enfants[].due_date` | `ChildData.dueDate` (enfant `a_naitre`) |
|
|
||||||
| `enfants[].status` | `actif` = né, `a_naitre` = à naître |
|
|
||||||
| `enfants[].photo_url` | `ApiConfig.absoluteMediaUrl()` + conserver pour reprise sans re-upload |
|
|
||||||
| `enfants[].id` | **Obligatoire** pour le PATCH (update par id) |
|
|
||||||
| `enfants[].est_multiple` | `grossesse_multiple` si utilisé |
|
|
||||||
| `texte_motivation` | étape présentation / motivation |
|
|
||||||
|
|
||||||
Si `numero_dossier` absent : pas de `parents[]` / `enfants[]` / `texte_motivation` (identité seule).
|
|
||||||
|
|
||||||
### Rôle `assistante_maternelle`
|
|
||||||
|
|
||||||
Champs racine + fiche pro (structure **aplatie**, pas de sous-objet `user`) :
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"consentement_photo": true,
|
|
||||||
"date_naissance": "1985-03-12T00:00:00.000Z",
|
|
||||||
"lieu_naissance_ville": "Paris",
|
|
||||||
"lieu_naissance_pays": "France",
|
|
||||||
"numero_agrement": "AGR-2024-12345",
|
|
||||||
"nir": "123456789012345",
|
|
||||||
"date_agrement": "2024-06-01T00:00:00.000Z",
|
|
||||||
"nb_max_enfants": 4,
|
|
||||||
"place_disponible": 2,
|
|
||||||
"biographie": "…"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Mapping `AmRegistrationData` :**
|
|
||||||
|
|
||||||
| JSON back | Champ front |
|
|
||||||
|-----------|-------------|
|
|
||||||
| `nb_max_enfants` | `capaciteAccueil` |
|
|
||||||
| `place_disponible` | `placesDisponibles` |
|
|
||||||
| `numero_agrement` | `numeroAgrement` |
|
|
||||||
| `biographie` | `biographie` / présentation |
|
|
||||||
| `photo_url` | déjà géré via `RepriseSession.photoUrl` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. `PATCH /auth/reprise-resoumettre` — body étendu
|
|
||||||
|
|
||||||
### Commun
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "token": "uuid-reprise" }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Parent — champs à envoyer depuis le wizard
|
|
||||||
|
|
||||||
| Champ PATCH | Source wizard | Notes |
|
|
||||||
|-------------|---------------|-------|
|
|
||||||
| `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal` | Parent 1 (titulaire token) | Champs racine |
|
|
||||||
| `co_parent_prenom`, `co_parent_nom`, `co_parent_telephone` | Parent 2 | |
|
|
||||||
| `co_parent_meme_adresse`, `co_parent_adresse`, `co_parent_code_postal`, `co_parent_ville` | Parent 2 adresse | |
|
|
||||||
| `texte_motivation` **ou** `presentation_dossier` | Étape motivation | Les deux alias acceptés |
|
|
||||||
| `enfants[]` | Liste enfants | Voir ci-dessous |
|
|
||||||
|
|
||||||
**Structure `enfants[]` (miroir inscription + `id` obligatoire) :**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "uuid-enfant-existant",
|
|
||||||
"prenom": "Emma",
|
|
||||||
"nom": "MARTIN",
|
|
||||||
"date_naissance": "2023-02-15",
|
|
||||||
"date_previsionnelle_naissance": null,
|
|
||||||
"genre": "F",
|
|
||||||
"photo_base64": "data:image/jpeg;base64,…",
|
|
||||||
"photo_filename": "emma.jpg",
|
|
||||||
"grossesse_multiple": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **v1 back :** update par `id` uniquement — pas de création/suppression d’enfant.
|
|
||||||
- Si `id` inconnu pour ce dossier → **400** `Enfant inconnu pour ce dossier : {id}`.
|
|
||||||
- Sans nouvelle photo : ne pas envoyer `photo_base64` (l’existant est conservé).
|
|
||||||
|
|
||||||
### AM — champs à envoyer
|
|
||||||
|
|
||||||
| Champ PATCH | Source |
|
|
||||||
|-------------|--------|
|
|
||||||
| Identité + `photo_url` ou `photo_base64` + `photo_filename` | Étapes 1–2 |
|
|
||||||
| `consentement_photo`, `date_naissance`, `lieu_naissance_ville`, `lieu_naissance_pays` | Identité |
|
|
||||||
| `numero_agrement`, `nir`, `date_agrement` | Pro |
|
|
||||||
| `capacite_accueil`, `places_disponibles` | Pro |
|
|
||||||
| `biographie` | Présentation |
|
|
||||||
|
|
||||||
Validation NIR identique à l’inscription si `nir` fourni.
|
|
||||||
|
|
||||||
### Réponse succès (nouveau format)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "Dossier resoumis avec succès. Il est de nouveau en attente de validation.",
|
|
||||||
"statut": "en_attente",
|
|
||||||
"user_id": "uuid",
|
|
||||||
"numero_dossier": "2026-000021"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Code HTTP : **200** (pas de corps `Users` brut comme l’ancien back).
|
|
||||||
|
|
||||||
### Effet métier
|
|
||||||
|
|
||||||
- **Parent :** tous les users `role=parent` avec le même `numero_dossier` passent en `en_attente` ; `token_reprise` invalidé sur **tous** (symétrique refus #110).
|
|
||||||
- **AM :** un seul user.
|
|
||||||
|
|
||||||
### E-mail accusé resoumission (parent)
|
|
||||||
|
|
||||||
Après `PATCH` réussi, un e-mail est envoyé à **chaque parent** du dossier (`sendResoumissionPendingEmail`) :
|
|
||||||
- confirmation de resoumission ;
|
|
||||||
- rappel du **numéro de dossier** ;
|
|
||||||
- mention « en attente de validation ».
|
|
||||||
|
|
||||||
Échec SMTP : logué, **ne bloque pas** la resoumission (même règle que l'inscription initiale).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Fichiers front à modifier (checklist)
|
|
||||||
|
|
||||||
### Modèles
|
|
||||||
|
|
||||||
- [ ] `lib/models/reprise_dossier.dart` — parser `parents[]`, `enfants[]`, `texte_motivation`, champs AM
|
|
||||||
- [ ] Réutiliser ou mapper vers `DossierFamilleEnfant` / structures existantes (#119 admin) si possible
|
|
||||||
|
|
||||||
### Session / préremplissage
|
|
||||||
|
|
||||||
- [ ] `lib/services/reprise_session.dart`
|
|
||||||
- `applyToParent` : remplir parent1/parent2 depuis `parents[]`, enfants, motivation
|
|
||||||
- `applyToAm` : remplir tous les champs AM
|
|
||||||
|
|
||||||
### API
|
|
||||||
|
|
||||||
- [ ] `lib/services/auth_service.dart` — `resoumettreReprise()` : accepter body complet (parent + AM), pas seulement identité
|
|
||||||
- [ ] Étendre `UserRegistrationData` / `AmRegistrationData` helpers `toReprisePatchBody()` si utile
|
|
||||||
|
|
||||||
### Écrans fin de parcours
|
|
||||||
|
|
||||||
- [ ] `parent_register_step5_screen.dart` — PATCH avec co-parent, enfants, motivation
|
|
||||||
- [ ] `am_register_step4_screen.dart` — PATCH avec fiche AM complète
|
|
||||||
|
|
||||||
### Hors scope back (inchangé)
|
|
||||||
|
|
||||||
RIB / IBAN / attestation CAF (étape 5 wizard parent) : **non persistés** — rien à envoyer en reprise.
|
|
||||||
|
|
||||||
### Non implémenté front (ticket #112 initial)
|
|
||||||
|
|
||||||
- [ ] Modale login « J’ai un numéro de dossier » → `POST /auth/reprise-identify` (back prêt, front absent)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Tests manuels suggérés
|
|
||||||
|
|
||||||
1. Refuser un dossier parent complet (≥1 enfant + co-parent + motivation).
|
|
||||||
2. Ouvrir le lien mail `/reprise?token=…`.
|
|
||||||
3. Vérifier dans DevTools que le GET contient `enfants[]` et `texte_motivation`.
|
|
||||||
4. Après branchement front : wizard prérempli sur toutes les étapes.
|
|
||||||
5. Resoumettre → statut `en_attente` pour les deux parents ; dossier visible file validation admin (#119).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Références code back
|
|
||||||
|
|
||||||
```
|
|
||||||
backend/src/routes/auth/dto/reprise-dossier.dto.ts
|
|
||||||
backend/src/routes/auth/dto/resoumettre-reprise.dto.ts
|
|
||||||
backend/src/routes/auth/dto/enfant-reprise.dto.ts
|
|
||||||
backend/src/routes/auth/auth.service.ts → getRepriseDossier, resoumettreReprise
|
|
||||||
backend/src/routes/parents/dto/dossier-famille-complet.dto.ts
|
|
||||||
```
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
# #131 — Fiche AM éditable + affiliation enfants (note front → back)
|
|
||||||
|
|
||||||
**Ticket :** #131 (partie AM, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1)
|
|
||||||
**Date :** 2026-06-01
|
|
||||||
**Statut front :** modale livrée (2 onglets) — **API affiliation AM↔enfant à implémenter**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Comportement UI (front)
|
|
||||||
|
|
||||||
Modale `AdminAmEditModal` — même shell que la fiche parent (~930 px) :
|
|
||||||
|
|
||||||
| Onglet | Contenu |
|
|
||||||
|--------|---------|
|
|
||||||
| **Identité & professionnel** | `IdentityBlock` éditable + grille pro (agrément, ville résidence, capacité, places, NIR/agrément date en lecture seule, biographie, switch disponible) + gélule statut |
|
|
||||||
| **Enfants accueillis** | Liste cartes enfants (réutilise `AdminChildrenAffiliationPanel` / `AdminEnfantUserCard`) + rattacher / détacher |
|
|
||||||
|
|
||||||
En-tête : prénom nom · sous-titre `Zone · Agrément · Dossier`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Endpoints consommés
|
|
||||||
|
|
||||||
### Déjà existants (partiels)
|
|
||||||
|
|
||||||
| Méthode | Route | Usage |
|
|
||||||
|---------|-------|-------|
|
|
||||||
| `GET` | `/api/v1/assistantes-maternelles` | Liste AM |
|
|
||||||
| `GET` | `/api/v1/assistantes-maternelles/:userId` | Détail (403 possible pour `administrateur` → fallback liste) |
|
|
||||||
| `PATCH` | `/api/v1/users/:userId` | Identité + statut (admin / super_admin uniquement) |
|
|
||||||
| `PATCH` | `/api/v1/assistantes-maternelles/:userId` | Champs pro (gestionnaire / super_admin) |
|
|
||||||
|
|
||||||
### À créer (recommandé — miroir parent #131 / #115)
|
|
||||||
|
|
||||||
| Méthode | Route | Rôle |
|
|
||||||
|---------|-------|------|
|
|
||||||
| `PATCH` | `/api/v1/assistantes-maternelles/:userId/fiche` | Mise à jour unifiée identité + pro + statut (`super_admin`, `gestionnaire`, `administrateur`) |
|
|
||||||
| `POST` | `/api/v1/assistantes-maternelles/:userId/enfants/:enfantId` | Rattacher un enfant |
|
|
||||||
| `DELETE` | `/api/v1/assistantes-maternelles/:userId/enfants/:enfantId` | Détacher un enfant |
|
|
||||||
| `GET` | `/api/v1/assistantes-maternelles/:userId` | Inclure `amChildren[]` (relation enfant) |
|
|
||||||
|
|
||||||
Le front appelle déjà ces routes ; en l’absence de `PATCH …/fiche`, il tente un fallback `PATCH users` + `PATCH assistantes-maternelles` (échoue selon le rôle connecté).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Modèle de données affiliation AM ↔ enfant
|
|
||||||
|
|
||||||
**À définir côté BDD** (pas de table dédiée aujourd’hui, contrairement à `enfants_parents`) :
|
|
||||||
|
|
||||||
Proposition alignée parent :
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Piste : enfants_assistantes_maternelles
|
|
||||||
CREATE TABLE enfants_assistantes_maternelles (
|
|
||||||
id_am UUID NOT NULL REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
|
||||||
id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE,
|
|
||||||
PRIMARY KEY (id_am, id_enfant)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
Réponse API attendue sur `GET /assistantes-maternelles/:id` :
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"user_id": "uuid-am",
|
|
||||||
"user": { "id": "…", "prenom": "Claire", "nom": "MARTIN", "statut": "actif" },
|
|
||||||
"approval_number": "AGR-2024-12345",
|
|
||||||
"residence_city": "Bezons",
|
|
||||||
"max_children": 4,
|
|
||||||
"places_available": 2,
|
|
||||||
"available": true,
|
|
||||||
"amChildren": [
|
|
||||||
{
|
|
||||||
"child": {
|
|
||||||
"id": "uuid-enfant",
|
|
||||||
"first_name": "Emma",
|
|
||||||
"last_name": "MARTIN",
|
|
||||||
"status": "actif",
|
|
||||||
"birth_date": "2023-02-15"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Le front parse `amChildren` / `am_children` / `assistanteChildren` (même logique que `parentChildren`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Body `PATCH …/fiche` suggéré
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nom": "MARTIN",
|
|
||||||
"prenom": "Claire",
|
|
||||||
"email": "claire@example.com",
|
|
||||||
"telephone": "0612345678",
|
|
||||||
"adresse": "5 place Bellecour",
|
|
||||||
"ville": "Lyon",
|
|
||||||
"code_postal": "69002",
|
|
||||||
"statut": "actif",
|
|
||||||
"approval_number": "AGR-2024-12345",
|
|
||||||
"residence_city": "Lyon",
|
|
||||||
"max_children": 4,
|
|
||||||
"places_available": 2,
|
|
||||||
"biography": "…",
|
|
||||||
"available": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
NIR et date d’agrément : lecture seule dans la modale (modification hors périmètre admin v1).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Fichiers front concernés
|
|
||||||
|
|
||||||
| Fichier | Rôle |
|
|
||||||
|---------|------|
|
|
||||||
| `frontend/lib/widgets/admin/common/admin_am_edit_modal.dart` | Modale 2 onglets |
|
|
||||||
| `frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart` | Liste enfants partagée parent/AM |
|
|
||||||
| `frontend/lib/widgets/admin/common/admin_status_capsule.dart` | Gélule statut partagée |
|
|
||||||
| `frontend/lib/models/assistante_maternelle_model.dart` | Parse champs pro + `amChildren` |
|
|
||||||
| `frontend/lib/services/user_service.dart` | `getAssistanteMaternelle`, `updateAmFiche`, `attachEnfantToAm`, `detachEnfantFromAm` |
|
|
||||||
| `frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart` | Ouverture modale au clic Modifier |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Références
|
|
||||||
|
|
||||||
- Fiche parent : `PATCH /parents/:id/fiche`, `POST|DELETE /parents/:id/enfants/:enfantId`
|
|
||||||
- Ticket Gitea **#131**, **#115**
|
|
||||||
- `docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md`
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
# #131 — En-tête fiche parent : co-parent (note front → back)
|
|
||||||
|
|
||||||
**Ticket :** #131 (fiche parent dashboard, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1)
|
|
||||||
**Date :** 2026-06-01
|
|
||||||
**Statut front :** livré (en-tête dynamique)
|
|
||||||
**Modif backend demandée :** **aucune** — ce document fixe le contrat attendu et invite à valider que l’existant le couvre.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Comportement UI (front)
|
|
||||||
|
|
||||||
Dans la modale **fiche parent** (`AdminParentEditModal`) :
|
|
||||||
|
|
||||||
| Zone | Contenu |
|
|
||||||
|------|---------|
|
|
||||||
| **Titre** | `prenom` + `nom` du parent affiché (plus le libellé fixe « Fiche parent ») |
|
|
||||||
| **Sous-titre** | `Co-parent : {prenom} {nom}` — affiché **uniquement** si un co-parent est connu |
|
|
||||||
|
|
||||||
Le titre se met à jour en direct pendant l’édition des champs nom/prénom.
|
|
||||||
Le sous-titre provient du co-parent **chargé depuis l’API** (pas saisi à la main dans la modale).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Endpoints consommés
|
|
||||||
|
|
||||||
| Méthode | Route | Usage front |
|
|
||||||
|---------|-------|-------------|
|
|
||||||
| `GET` | `/api/v1/parents` | Liste parents (onglet Parents) |
|
|
||||||
| `GET` | `/api/v1/parents/:userId` | Rechargement fiche après rattachement/détachement enfant |
|
|
||||||
| `PATCH` | `/api/v1/parents/:userId/fiche` | Sauvegarde identité + statut (inchangé) |
|
|
||||||
|
|
||||||
Rôles : `super_admin`, `gestionnaire`, `administrateur` (selon route).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Contrat JSON attendu pour `co_parent`
|
|
||||||
|
|
||||||
Le front parse `ParentModel.fromJson` avec la clé **`co_parent`** (snake_case), objet utilisateur imbriqué.
|
|
||||||
|
|
||||||
### Champs minimum utilisés pour le sous-titre
|
|
||||||
|
|
||||||
| Clé JSON | Usage |
|
|
||||||
|----------|--------|
|
|
||||||
| `co_parent` | Objet ou absent/`null` |
|
|
||||||
| `co_parent.id` | Identifiant (futur lien cliquable éventuel) |
|
|
||||||
| `co_parent.prenom` | Affichage |
|
|
||||||
| `co_parent.nom` | Affichage |
|
|
||||||
|
|
||||||
Affichage front : `'{prenom} {nom}'.trim()` → libellé `Co-parent : …`.
|
|
||||||
|
|
||||||
### Exemple de fragment de réponse (`GET /parents/:id`)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"user_id": "33333333-3333-3333-3333-333333333333",
|
|
||||||
"numero_dossier": "2026-000042",
|
|
||||||
"user": {
|
|
||||||
"id": "33333333-3333-3333-3333-333333333333",
|
|
||||||
"email": "parent1@example.com",
|
|
||||||
"prenom": "Paul",
|
|
||||||
"nom": "PARENT",
|
|
||||||
"statut": "actif",
|
|
||||||
"telephone": "0601020304"
|
|
||||||
},
|
|
||||||
"co_parent": {
|
|
||||||
"id": "44444444-4444-4444-4444-444444444444",
|
|
||||||
"email": "coparent1@example.com",
|
|
||||||
"prenom": "Clara",
|
|
||||||
"nom": "COPARENT",
|
|
||||||
"role": "parent",
|
|
||||||
"statut": "actif"
|
|
||||||
},
|
|
||||||
"parentChildren": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Note :** le front lit `user` (pas `utilisateur`). La doc `11_API.md` § Parents mentionne encore `utilisateur` / `id_co_parent` seul — le contrat **effectif** côté Nest/TypeORM est l’entité `Parents` sérialisée (`user`, `co_parent`, `parentChildren`, …).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. État backend (à valider, pas à refaire)
|
|
||||||
|
|
||||||
D’après le code actuel (`parents.service.ts`) :
|
|
||||||
|
|
||||||
- `findAll()` et `findOne(user_id)` chargent déjà la relation **`co_parent`** ;
|
|
||||||
- la FK métier est `parents.id_co_parent` → `utilisateurs.id` ;
|
|
||||||
- à l’inscription couple, les deux sens sont en principe renseignés (`auth.service.ts`).
|
|
||||||
|
|
||||||
**Checklist validation back :**
|
|
||||||
|
|
||||||
- [ ] `GET /parents/:id` renvoie bien `co_parent` peuplé quand `id_co_parent` est non null
|
|
||||||
- [ ] `GET /parents` (liste) inclut aussi `co_parent` (sous-titre disponible dès l’ouverture sans re-fetch)
|
|
||||||
- [ ] Les champs `prenom` / `nom` du co-parent sont présents dans la réponse JSON
|
|
||||||
|
|
||||||
Si ces trois points passent en recette, **aucun changement backend n’est nécessaire** pour cette fonctionnalité.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Points d’attention (hors périmètre immédiat)
|
|
||||||
|
|
||||||
| Sujet | Détail |
|
|
||||||
|-------|--------|
|
|
||||||
| **Lien inverse** | Si le parent B est le co-parent de A (`A.id_co_parent = B`) mais que `B.id_co_parent` est `null`, le sous-titre **ne s’affichera pas** sur la fiche de B. Le front ne fait pas de résolution inverse. À traiter côté back **seulement si** des données legacy ont un lien à sens unique. |
|
|
||||||
| **Familles > 2 adultes** | Le sous-titre n’affiche que le co-parent direct (`id_co_parent`). Les autres responsables liés uniquement via `enfants_parents` ne sont pas listés ici (cf. doc 28 §6). |
|
|
||||||
| **Données sensibles** | Vérifier que la sérialisation de `co_parent` n’expose pas `password` / tokens (même remarque que pour `user`). |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Fichiers front concernés
|
|
||||||
|
|
||||||
| Fichier | Rôle |
|
|
||||||
|---------|------|
|
|
||||||
| `frontend/lib/models/parent_model.dart` | Parse `co_parent` → `AppUser? coParent` |
|
|
||||||
| `frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart` | Titre + sous-titre |
|
|
||||||
| `frontend/lib/services/user_service.dart` | `getParents()` / `getParent()` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Références
|
|
||||||
|
|
||||||
- `docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1
|
|
||||||
- `backend/src/routes/parents/parents.service.ts` — `findOne`, `findAll`
|
|
||||||
- `backend/src/entities/parents.entity.ts` — relation `co_parent`
|
|
||||||
- Ticket Gitea **#131**
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# TEMP — Alignement front / API (inscription AM & validation gestionnaire)
|
|
||||||
|
|
||||||
> **Archivé** (`docs/archive/temporaires/`) — **fichier temporaire** ; à
|
|
||||||
> **supprimer** une fois le front livré ou le sujet clos (voir
|
|
||||||
> `docs/archive/temporaires/README.md`).
|
|
||||||
|
|
||||||
Ce document décrit les changements **côté API** et ce que **Flutter** doit faire pour rester aligné. Aucune modification front n’a été faite dans le chantier backend associé.
|
|
||||||
|
|
||||||
## 1. `POST /auth/register/am` — lieu de naissance obligatoire
|
|
||||||
|
|
||||||
- **`lieu_naissance_ville`** et **`lieu_naissance_pays`** sont **obligatoires** (non vides après trim, min. **2 caractères** chacun, max 100).
|
|
||||||
- Réponses **400** si manquants ou invalides (messages class-validator).
|
|
||||||
- **Action front** : champs obligatoires dans le parcours AM (étapes identité / naissance), validation UI avant envoi ; afficher les erreurs renvoyées par l’API.
|
|
||||||
|
|
||||||
## 2. Réponse `GET /dossiers/:numeroDossier` (type `am`)
|
|
||||||
|
|
||||||
Sous `dossier.user`, l’API peut inclure :
|
|
||||||
|
|
||||||
| Clé JSON | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `date_naissance` | Date (si renseignée à l’inscription) |
|
|
||||||
| `lieu_naissance_ville` | Ville de naissance |
|
|
||||||
| `lieu_naissance_pays` | Pays de naissance |
|
|
||||||
| `consentement_photo` | Booléen (exposé dans `dossier.user`) |
|
|
||||||
|
|
||||||
À la **racine** de `dossier` (objet AM), champs déjà renvoyés par le backend : `disponible`, `annees_experience`, `specialite`, `nb_max_enfants`, `place_disponible`, etc.
|
|
||||||
|
|
||||||
**Action front** :
|
|
||||||
|
|
||||||
- Étendre **`AppUser.fromJson` / `toJson`** (`lib/models/user.dart`) pour mapper `date_naissance`, `lieu_naissance_ville`, `lieu_naissance_pays`, `consentement_photo`.
|
|
||||||
- Étendre **`DossierAM.fromJson`** (`lib/models/dossier_unifie.dart`) pour parser `disponible`, `annees_experience`, `specialite` à la racine du dossier (noms snake_case comme dans la réponse JSON Nest).
|
|
||||||
|
|
||||||
## 3. `ValidationAmWizard` (admin)
|
|
||||||
|
|
||||||
Afficher pour cohérence avec le formulaire d’inscription :
|
|
||||||
|
|
||||||
- **Informations personnelles** : date de naissance, ville / pays de naissance, consentement photo (Oui/Non).
|
|
||||||
- **Informations professionnelles** : disponibilité, années d’expérience, spécialité (afficher « – » si `null`).
|
|
||||||
|
|
||||||
## 4. `place_disponible` à l’inscription
|
|
||||||
|
|
||||||
- Le backend initialise **`place_disponible`** sur la fiche AM à la **même valeur** que **`capacite_accueil`** à la création. Le wizard peut donc afficher une valeur cohérente avec la capacité sans champ séparé côté public.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Dernière mise à jour : alignement backend branche `feature/120-inscription-am-photo-backend`.*
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
# #112 — Alignement front après évolution back (reprise dossier complet)
|
|
||||||
|
|
||||||
**Branche déployée :** `feature/112-reprise-apres-refus-front`
|
|
||||||
**Commit back :** `d70577b1` — `feat(#112): reprise après refus — dossier complet GET/PATCH`
|
|
||||||
**Date :** 2026-06-16
|
|
||||||
|
|
||||||
Ce document décrit le **contrat API réel** après extension du back, et ce que le front doit encore brancher pour exploiter le dossier complet (au-delà de l’identité seule).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Endpoints (inchangés côté URL)
|
|
||||||
|
|
||||||
| Méthode | Route | Auth |
|
|
||||||
|---------|-------|------|
|
|
||||||
| `GET` | `/api/v1/auth/reprise-dossier?token={uuid}` | Public |
|
|
||||||
| `PATCH` | `/api/v1/auth/reprise-resoumettre` | Public |
|
|
||||||
| `POST` | `/api/v1/auth/reprise-identify` | Public (inchangé) |
|
|
||||||
|
|
||||||
> **Note :** le ticket #111 parlait de `PUT` ; l’implémentation reste en **`PATCH`** (comme avant).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. `GET /auth/reprise-dossier` — réponse enrichie
|
|
||||||
|
|
||||||
### Champs communs (toujours présents)
|
|
||||||
|
|
||||||
Identiques à avant : `id`, `email`, `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal`, `numero_dossier`, `role`, `photo_url`, `genre`, `situation_familiale`.
|
|
||||||
|
|
||||||
### Rôle `parent` (+ champs #119)
|
|
||||||
|
|
||||||
Alignés sur `DossierFamilleCompletDto` :
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"parents": [
|
|
||||||
{
|
|
||||||
"user_id": "uuid",
|
|
||||||
"email": "…",
|
|
||||||
"prenom": "…",
|
|
||||||
"nom": "…",
|
|
||||||
"telephone": "…",
|
|
||||||
"adresse": "…",
|
|
||||||
"ville": "…",
|
|
||||||
"code_postal": "…",
|
|
||||||
"statut": "refuse",
|
|
||||||
"co_parent_id": "uuid-parent-entity"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"enfants": [
|
|
||||||
{
|
|
||||||
"id": "uuid-enfant",
|
|
||||||
"first_name": "Emma",
|
|
||||||
"last_name": "MARTIN",
|
|
||||||
"genre": "F",
|
|
||||||
"status": "actif",
|
|
||||||
"birth_date": "2023-02-15T00:00:00.000Z",
|
|
||||||
"due_date": null,
|
|
||||||
"photo_url": "/uploads/photos/…",
|
|
||||||
"consent_photo": true,
|
|
||||||
"est_multiple": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"texte_motivation": "Nous recherchons…"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Mapping front suggéré :**
|
|
||||||
|
|
||||||
| JSON back | Modèle / wizard parent |
|
|
||||||
|-----------|-------------------------|
|
|
||||||
| `parents[]` | `UserRegistrationData.parent1` + `parent2` (matcher par `email` ou ordre : titulaire = `id` du GET racine) |
|
|
||||||
| `enfants[].first_name` / `last_name` | `ChildData.firstName` / `lastName` |
|
|
||||||
| `enfants[].birth_date` | `ChildData.birthDate` (ISO → `DateTime`) |
|
|
||||||
| `enfants[].due_date` | `ChildData.dueDate` (enfant `a_naitre`) |
|
|
||||||
| `enfants[].status` | `actif` = né, `a_naitre` = à naître |
|
|
||||||
| `enfants[].photo_url` | `ApiConfig.absoluteMediaUrl()` + conserver pour reprise sans re-upload |
|
|
||||||
| `enfants[].id` | **Obligatoire** pour le PATCH (update par id) |
|
|
||||||
| `enfants[].est_multiple` | `grossesse_multiple` si utilisé |
|
|
||||||
| `texte_motivation` | étape présentation / motivation |
|
|
||||||
|
|
||||||
Si `numero_dossier` absent : pas de `parents[]` / `enfants[]` / `texte_motivation` (identité seule).
|
|
||||||
|
|
||||||
### Rôle `assistante_maternelle`
|
|
||||||
|
|
||||||
Champs racine + fiche pro (structure **aplatie**, pas de sous-objet `user`) :
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"consentement_photo": true,
|
|
||||||
"date_naissance": "1985-03-12T00:00:00.000Z",
|
|
||||||
"lieu_naissance_ville": "Paris",
|
|
||||||
"lieu_naissance_pays": "France",
|
|
||||||
"numero_agrement": "AGR-2024-12345",
|
|
||||||
"nir": "123456789012345",
|
|
||||||
"date_agrement": "2024-06-01T00:00:00.000Z",
|
|
||||||
"nb_max_enfants": 4,
|
|
||||||
"place_disponible": 2,
|
|
||||||
"biographie": "…"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Mapping `AmRegistrationData` :**
|
|
||||||
|
|
||||||
| JSON back | Champ front |
|
|
||||||
|-----------|-------------|
|
|
||||||
| `nb_max_enfants` | `capaciteAccueil` |
|
|
||||||
| `place_disponible` | `placesDisponibles` |
|
|
||||||
| `numero_agrement` | `numeroAgrement` |
|
|
||||||
| `biographie` | `biographie` / présentation |
|
|
||||||
| `photo_url` | déjà géré via `RepriseSession.photoUrl` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. `PATCH /auth/reprise-resoumettre` — body étendu
|
|
||||||
|
|
||||||
### Commun
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "token": "uuid-reprise" }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Parent — champs à envoyer depuis le wizard
|
|
||||||
|
|
||||||
| Champ PATCH | Source wizard | Notes |
|
|
||||||
|-------------|---------------|-------|
|
|
||||||
| `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal` | Parent 1 (titulaire token) | Champs racine |
|
|
||||||
| `co_parent_prenom`, `co_parent_nom`, `co_parent_telephone` | Parent 2 | |
|
|
||||||
| `co_parent_meme_adresse`, `co_parent_adresse`, `co_parent_code_postal`, `co_parent_ville` | Parent 2 adresse | |
|
|
||||||
| `texte_motivation` **ou** `presentation_dossier` | Étape motivation | Les deux alias acceptés |
|
|
||||||
| `enfants[]` | Liste enfants | Voir ci-dessous |
|
|
||||||
|
|
||||||
**Structure `enfants[]` (miroir inscription + `id` obligatoire) :**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "uuid-enfant-existant",
|
|
||||||
"prenom": "Emma",
|
|
||||||
"nom": "MARTIN",
|
|
||||||
"date_naissance": "2023-02-15",
|
|
||||||
"date_previsionnelle_naissance": null,
|
|
||||||
"genre": "F",
|
|
||||||
"photo_base64": "data:image/jpeg;base64,…",
|
|
||||||
"photo_filename": "emma.jpg",
|
|
||||||
"grossesse_multiple": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **v1 back :** update par `id` uniquement — pas de création/suppression d’enfant.
|
|
||||||
- Si `id` inconnu pour ce dossier → **400** `Enfant inconnu pour ce dossier : {id}`.
|
|
||||||
- Sans nouvelle photo : ne pas envoyer `photo_base64` (l’existant est conservé).
|
|
||||||
|
|
||||||
### AM — champs à envoyer
|
|
||||||
|
|
||||||
| Champ PATCH | Source |
|
|
||||||
|-------------|--------|
|
|
||||||
| Identité + `photo_url` ou `photo_base64` + `photo_filename` | Étapes 1–2 |
|
|
||||||
| `consentement_photo`, `date_naissance`, `lieu_naissance_ville`, `lieu_naissance_pays` | Identité |
|
|
||||||
| `numero_agrement`, `nir`, `date_agrement` | Pro |
|
|
||||||
| `capacite_accueil`, `places_disponibles` | Pro |
|
|
||||||
| `biographie` | Présentation |
|
|
||||||
|
|
||||||
Validation NIR identique à l’inscription si `nir` fourni.
|
|
||||||
|
|
||||||
### Réponse succès (nouveau format)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "Dossier resoumis avec succès. Il est de nouveau en attente de validation.",
|
|
||||||
"statut": "en_attente",
|
|
||||||
"user_id": "uuid",
|
|
||||||
"numero_dossier": "2026-000021"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Code HTTP : **200** (pas de corps `Users` brut comme l’ancien back).
|
|
||||||
|
|
||||||
### Effet métier
|
|
||||||
|
|
||||||
- **Parent :** tous les users `role=parent` avec le même `numero_dossier` passent en `en_attente` ; `token_reprise` invalidé sur **tous** (symétrique refus #110).
|
|
||||||
- **AM :** un seul user.
|
|
||||||
|
|
||||||
### E-mail accusé resoumission (parent)
|
|
||||||
|
|
||||||
Après `PATCH` réussi, un e-mail est envoyé à **chaque parent** du dossier (`sendResoumissionPendingEmail`) :
|
|
||||||
- confirmation de resoumission ;
|
|
||||||
- rappel du **numéro de dossier** ;
|
|
||||||
- mention « en attente de validation ».
|
|
||||||
|
|
||||||
Échec SMTP : logué, **ne bloque pas** la resoumission (même règle que l'inscription initiale).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Fichiers front à modifier (checklist)
|
|
||||||
|
|
||||||
### Modèles
|
|
||||||
|
|
||||||
- [ ] `lib/models/reprise_dossier.dart` — parser `parents[]`, `enfants[]`, `texte_motivation`, champs AM
|
|
||||||
- [ ] Réutiliser ou mapper vers `DossierFamilleEnfant` / structures existantes (#119 admin) si possible
|
|
||||||
|
|
||||||
### Session / préremplissage
|
|
||||||
|
|
||||||
- [ ] `lib/services/reprise_session.dart`
|
|
||||||
- `applyToParent` : remplir parent1/parent2 depuis `parents[]`, enfants, motivation
|
|
||||||
- `applyToAm` : remplir tous les champs AM
|
|
||||||
|
|
||||||
### API
|
|
||||||
|
|
||||||
- [ ] `lib/services/auth_service.dart` — `resoumettreReprise()` : accepter body complet (parent + AM), pas seulement identité
|
|
||||||
- [ ] Étendre `UserRegistrationData` / `AmRegistrationData` helpers `toReprisePatchBody()` si utile
|
|
||||||
|
|
||||||
### Écrans fin de parcours
|
|
||||||
|
|
||||||
- [ ] `parent_register_step5_screen.dart` — PATCH avec co-parent, enfants, motivation
|
|
||||||
- [ ] `am_register_step4_screen.dart` — PATCH avec fiche AM complète
|
|
||||||
|
|
||||||
### Hors scope back (inchangé)
|
|
||||||
|
|
||||||
RIB / IBAN / attestation CAF (étape 5 wizard parent) : **non persistés** — rien à envoyer en reprise.
|
|
||||||
|
|
||||||
### Non implémenté front (ticket #112 initial)
|
|
||||||
|
|
||||||
- [ ] Modale login « J’ai un numéro de dossier » → `POST /auth/reprise-identify` (back prêt, front absent)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Tests manuels suggérés
|
|
||||||
|
|
||||||
1. Refuser un dossier parent complet (≥1 enfant + co-parent + motivation).
|
|
||||||
2. Ouvrir le lien mail `/reprise?token=…`.
|
|
||||||
3. Vérifier dans DevTools que le GET contient `enfants[]` et `texte_motivation`.
|
|
||||||
4. Après branchement front : wizard prérempli sur toutes les étapes.
|
|
||||||
5. Resoumettre → statut `en_attente` pour les deux parents ; dossier visible file validation admin (#119).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Références code back
|
|
||||||
|
|
||||||
```
|
|
||||||
backend/src/routes/auth/dto/reprise-dossier.dto.ts
|
|
||||||
backend/src/routes/auth/dto/resoumettre-reprise.dto.ts
|
|
||||||
backend/src/routes/auth/dto/enfant-reprise.dto.ts
|
|
||||||
backend/src/routes/auth/auth.service.ts → getRepriseDossier, resoumettreReprise
|
|
||||||
backend/src/routes/parents/dto/dossier-famille-complet.dto.ts
|
|
||||||
```
|
|
||||||
@@ -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 s’appeler `Admin*`.
|
||||||
|
On garde `Admin*` seulement là où c’est 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 l’option 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 d’arborescence (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 d’imports (à 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 d’exé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 d’imports panels vers l’ancien 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 d’acceptation
|
||||||
|
|
||||||
|
- [ ] 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 d’autres 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 d’archive citant `widgets/admin/…` : pas obligatoire de mettre à jour ; `docs/27_BRIEFING-FRONTEND.md` oui si encore listé
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Mini-spec — Uniformisation modale staff (Gestionnaire / Administrateur)
|
||||||
|
|
||||||
|
**Ticket** : **#164** — https://git.ptits-pas.fr/jmartin/petitspas/issues/164
|
||||||
|
**Branche** : `feature/164-staff-modal-uniformisation` (depuis `develop`)
|
||||||
|
**Périmètre** : **front only** — pas d’API / BDD
|
||||||
|
**Milestone** : **0.1.0**
|
||||||
|
|
||||||
|
Voir le corps du ticket #164 pour la spec complète.
|
||||||
|
|
||||||
|
## Livré
|
||||||
|
|
||||||
|
- `frontend/lib/widgets/dashboard/staff_user_form_modal.dart` → `StaffUserFormModal`
|
||||||
|
- Ancien `AdminUserFormDialog` / `gestionnaires_create.dart` retiré
|
||||||
|
- Imports : `user_management_panel`, `gestionnaire_management_widget`, `admin_management_widget`
|
||||||
@@ -13,6 +13,10 @@ class DossierListItem {
|
|||||||
final String? statut;
|
final String? statut;
|
||||||
/// Photo profil (AM) — affichée à la place de l’icône si présente.
|
/// Photo profil (AM) — affichée à la place de l’icône si présente.
|
||||||
final String? photoUrl;
|
final String? photoUrl;
|
||||||
|
/// Dossier famille sans enfant lié (#159 / #160).
|
||||||
|
final bool sansEnfant;
|
||||||
|
/// Nombre d’enfants du foyer (famille uniquement ; null pour AM).
|
||||||
|
final int? enfantsCount;
|
||||||
|
|
||||||
const DossierListItem({
|
const DossierListItem({
|
||||||
required this.type,
|
required this.type,
|
||||||
@@ -21,8 +25,32 @@ class DossierListItem {
|
|||||||
this.emails = const [],
|
this.emails = const [],
|
||||||
this.statut,
|
this.statut,
|
||||||
this.photoUrl,
|
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 isFamille => type == DossierListType.famille;
|
||||||
bool get isAm => type == DossierListType.assistanteMaternelle;
|
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(
|
items.add(
|
||||||
DossierListItem(
|
DossierListItem(
|
||||||
type: DossierListType.famille,
|
type: DossierListType.famille,
|
||||||
@@ -105,6 +146,8 @@ class DossierListItem {
|
|||||||
libelle: names.isNotEmpty ? names.join(' - ') : 'Famille',
|
libelle: names.isNotEmpty ? names.join(' - ') : 'Famille',
|
||||||
emails: emails,
|
emails: emails,
|
||||||
statut: _preferStatut(statuts),
|
statut: _preferStatut(statuts),
|
||||||
|
enfantsCount: enfantsCount,
|
||||||
|
sansEnfant: enfantsCount == 0,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,7 +185,6 @@ class EnfantDossier {
|
|||||||
final String? dueDate;
|
final String? dueDate;
|
||||||
final String? photoUrl;
|
final String? photoUrl;
|
||||||
final bool consentPhoto;
|
final bool consentPhoto;
|
||||||
final bool estMultiple;
|
|
||||||
|
|
||||||
EnfantDossier({
|
EnfantDossier({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -197,7 +196,6 @@ class EnfantDossier {
|
|||||||
this.dueDate,
|
this.dueDate,
|
||||||
this.photoUrl,
|
this.photoUrl,
|
||||||
this.consentPhoto = false,
|
this.consentPhoto = false,
|
||||||
this.estMultiple = false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
String get fullName => '${firstName ?? ''} ${lastName ?? ''}'.trim();
|
String get fullName => '${firstName ?? ''} ${lastName ?? ''}'.trim();
|
||||||
@@ -231,8 +229,6 @@ class EnfantDossier {
|
|||||||
photoUrl: resolvedPhoto,
|
photoUrl: resolvedPhoto,
|
||||||
consentPhoto:
|
consentPhoto:
|
||||||
json['consent_photo'] == true || json['consentPhoto'] == true,
|
json['consent_photo'] == true || json['consentPhoto'] == true,
|
||||||
estMultiple:
|
|
||||||
json['est_multiple'] == true || json['estMultiple'] == true,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ class EnfantAdminModel {
|
|||||||
final String status;
|
final String status;
|
||||||
final String? photoUrl;
|
final String? photoUrl;
|
||||||
final bool consentPhoto;
|
final bool consentPhoto;
|
||||||
final bool isMultiple;
|
|
||||||
final List<EnfantParentLink> parentLinks;
|
final List<EnfantParentLink> parentLinks;
|
||||||
/// Flag API #157 (sinon déduit de [parentLinks]).
|
/// Flag API #157 (sinon déduit de [parentLinks]).
|
||||||
final bool? sansResponsable;
|
final bool? sansResponsable;
|
||||||
@@ -27,7 +26,6 @@ class EnfantAdminModel {
|
|||||||
required this.status,
|
required this.status,
|
||||||
this.photoUrl,
|
this.photoUrl,
|
||||||
this.consentPhoto = false,
|
this.consentPhoto = false,
|
||||||
this.isMultiple = false,
|
|
||||||
this.parentLinks = const [],
|
this.parentLinks = const [],
|
||||||
this.sansResponsable,
|
this.sansResponsable,
|
||||||
});
|
});
|
||||||
@@ -56,7 +54,6 @@ class EnfantAdminModel {
|
|||||||
String? status,
|
String? status,
|
||||||
String? photoUrl,
|
String? photoUrl,
|
||||||
bool? consentPhoto,
|
bool? consentPhoto,
|
||||||
bool? isMultiple,
|
|
||||||
List<EnfantParentLink>? parentLinks,
|
List<EnfantParentLink>? parentLinks,
|
||||||
bool? sansResponsable,
|
bool? sansResponsable,
|
||||||
}) {
|
}) {
|
||||||
@@ -70,7 +67,6 @@ class EnfantAdminModel {
|
|||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
photoUrl: photoUrl ?? this.photoUrl,
|
photoUrl: photoUrl ?? this.photoUrl,
|
||||||
consentPhoto: consentPhoto ?? this.consentPhoto,
|
consentPhoto: consentPhoto ?? this.consentPhoto,
|
||||||
isMultiple: isMultiple ?? this.isMultiple,
|
|
||||||
parentLinks: parentLinks ?? this.parentLinks,
|
parentLinks: parentLinks ?? this.parentLinks,
|
||||||
sansResponsable: sansResponsable ?? this.sansResponsable,
|
sansResponsable: sansResponsable ?? this.sansResponsable,
|
||||||
);
|
);
|
||||||
@@ -111,8 +107,6 @@ class EnfantAdminModel {
|
|||||||
),
|
),
|
||||||
photoUrl: photoUrl,
|
photoUrl: photoUrl,
|
||||||
consentPhoto: consentPhoto,
|
consentPhoto: consentPhoto,
|
||||||
isMultiple: _parseBool(json['is_multiple']) ||
|
|
||||||
_parseBool(json['est_multiple']),
|
|
||||||
parentLinks: links,
|
parentLinks: links,
|
||||||
sansResponsable: sansResponsable,
|
sansResponsable: sansResponsable,
|
||||||
);
|
);
|
||||||
@@ -127,7 +121,6 @@ class EnfantAdminModel {
|
|||||||
if (birthDate != null && birthDate!.isNotEmpty) 'birth_date': birthDate,
|
if (birthDate != null && birthDate!.isNotEmpty) 'birth_date': birthDate,
|
||||||
if (dueDate != null && dueDate!.isNotEmpty) 'due_date': dueDate,
|
if (dueDate != null && dueDate!.isNotEmpty) 'due_date': dueDate,
|
||||||
'consent_photo': consentPhoto,
|
'consent_photo': consentPhoto,
|
||||||
'is_multiple': isMultiple,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ class ChildData {
|
|||||||
String lastName;
|
String lastName;
|
||||||
String dob; // Date de naissance ou prévisionnelle
|
String dob; // Date de naissance ou prévisionnelle
|
||||||
bool photoConsent;
|
bool photoConsent;
|
||||||
bool multipleBirth;
|
|
||||||
bool isUnbornChild;
|
bool isUnbornChild;
|
||||||
File? imageFile;
|
File? imageFile;
|
||||||
CardColorVertical cardColor; // Nouveau champ pour la couleur de la carte
|
CardColorVertical cardColor; // Nouveau champ pour la couleur de la carte
|
||||||
@@ -40,7 +39,6 @@ class ChildData {
|
|||||||
this.lastName = '',
|
this.lastName = '',
|
||||||
this.dob = '',
|
this.dob = '',
|
||||||
this.photoConsent = false,
|
this.photoConsent = false,
|
||||||
this.multipleBirth = false,
|
|
||||||
this.isUnbornChild = false,
|
this.isUnbornChild = false,
|
||||||
this.imageFile,
|
this.imageFile,
|
||||||
required this.cardColor, // Rendre requis dans le constructeur
|
required this.cardColor, // Rendre requis dans le constructeur
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ class ChildData {
|
|||||||
/// Valeurs API : `H`, `F`, `Autre` (GenreType backend). Vide tant que non choisi.
|
/// Valeurs API : `H`, `F`, `Autre` (GenreType backend). Vide tant que non choisi.
|
||||||
String genre;
|
String genre;
|
||||||
bool photoConsent;
|
bool photoConsent;
|
||||||
bool multipleBirth;
|
|
||||||
bool isUnbornChild;
|
bool isUnbornChild;
|
||||||
File? imageFile;
|
File? imageFile;
|
||||||
/// Octets de la photo (fiable à l’envoi API ; [imageFile] peut être absent sur le web).
|
/// Octets de la photo (fiable à l’envoi API ; [imageFile] peut être absent sur le web).
|
||||||
@@ -55,7 +54,6 @@ class ChildData {
|
|||||||
this.dob = '',
|
this.dob = '',
|
||||||
this.genre = '',
|
this.genre = '',
|
||||||
this.photoConsent = false,
|
this.photoConsent = false,
|
||||||
this.multipleBirth = false,
|
|
||||||
this.isUnbornChild = false,
|
this.isUnbornChild = false,
|
||||||
this.imageFile,
|
this.imageFile,
|
||||||
this.imageBytes,
|
this.imageBytes,
|
||||||
@@ -70,7 +68,6 @@ class ChildData {
|
|||||||
String? dob,
|
String? dob,
|
||||||
String? genre,
|
String? genre,
|
||||||
bool? photoConsent,
|
bool? photoConsent,
|
||||||
bool? multipleBirth,
|
|
||||||
bool? isUnbornChild,
|
bool? isUnbornChild,
|
||||||
Object? imageFile = _unsetImage,
|
Object? imageFile = _unsetImage,
|
||||||
Object? imageBytes = _unsetImageBytes,
|
Object? imageBytes = _unsetImageBytes,
|
||||||
@@ -84,7 +81,6 @@ class ChildData {
|
|||||||
dob: dob ?? this.dob,
|
dob: dob ?? this.dob,
|
||||||
genre: genre ?? this.genre,
|
genre: genre ?? this.genre,
|
||||||
photoConsent: photoConsent ?? this.photoConsent,
|
photoConsent: photoConsent ?? this.photoConsent,
|
||||||
multipleBirth: multipleBirth ?? this.multipleBirth,
|
|
||||||
isUnbornChild: isUnbornChild ?? this.isUnbornChild,
|
isUnbornChild: isUnbornChild ?? this.isUnbornChild,
|
||||||
imageFile: identical(imageFile, _unsetImage) ? this.imageFile : imageFile as File?,
|
imageFile: identical(imageFile, _unsetImage) ? this.imageFile : imageFile as File?,
|
||||||
imageBytes:
|
imageBytes:
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
import 'package:p_tits_pas/services/auth_service.dart';
|
import 'package:p_tits_pas/services/auth_service.dart';
|
||||||
import 'package:p_tits_pas/services/configuration_service.dart';
|
import 'package:p_tits_pas/services/configuration_service.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
|
import 'package:p_tits_pas/widgets/dashboard/user_management_sub_bar.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/parametres_panel.dart';
|
import 'package:p_tits_pas/widgets/dashboard/parametres_panel.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/app_footer.dart';
|
||||||
import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.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/models/user.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/utils/email_utils.dart';
|
import 'package:p_tits_pas/utils/email_utils.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
|
||||||
import 'package:p_tits_pas/widgets/email_text_field.dart';
|
import 'package:p_tits_pas/widgets/email_text_field.dart';
|
||||||
import 'package:p_tits_pas/widgets/french_phone_field.dart';
|
import 'package:p_tits_pas/widgets/french_phone_field.dart';
|
||||||
|
|
||||||
@@ -151,30 +152,19 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
|
|||||||
Future<void> _delete() async {
|
Future<void> _delete() async {
|
||||||
if (!_isEditMode || _isSubmitting) return;
|
if (!_isEditMode || _isSubmitting) return;
|
||||||
|
|
||||||
final confirmed = await showDialog<bool>(
|
final name = widget.initialUser!.fullName.isEmpty
|
||||||
context: context,
|
? widget.initialUser!.email
|
||||||
builder: (ctx) {
|
: widget.initialUser!.fullName;
|
||||||
return AlertDialog(
|
final confirmed = await showSuppressionConfirmDialog(
|
||||||
title: const Text('Confirmer la suppression'),
|
context,
|
||||||
content: Text(
|
title: 'Supprimer l\'administrateur',
|
||||||
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
|
people: [SuppressionPersonLine.administrateur(name)],
|
||||||
),
|
footnotes: const [
|
||||||
actions: [
|
'Le compte sera définitivement supprimé.',
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(ctx).pop(false),
|
|
||||||
child: const Text('Annuler'),
|
|
||||||
),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: () => Navigator.of(ctx).pop(true),
|
|
||||||
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
|
|
||||||
child: const Text('Supprimer'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (confirmed != true) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isSubmitting = true;
|
_isSubmitting = true;
|
||||||
|
|||||||
@@ -1,670 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:p_tits_pas/models/relais_model.dart';
|
|
||||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
|
||||||
import 'package:p_tits_pas/utils/email_utils.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/email_text_field.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/french_phone_field.dart';
|
|
||||||
import 'package:p_tits_pas/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';
|
|
||||||
|
|
||||||
class AdminUserFormDialog extends StatefulWidget {
|
|
||||||
final AppUser? initialUser;
|
|
||||||
final bool withRelais;
|
|
||||||
final bool adminMode;
|
|
||||||
final bool readOnly;
|
|
||||||
|
|
||||||
const AdminUserFormDialog({
|
|
||||||
super.key,
|
|
||||||
this.initialUser,
|
|
||||||
this.withRelais = true,
|
|
||||||
this.adminMode = false,
|
|
||||||
this.readOnly = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<AdminUserFormDialog> createState() => _AdminUserFormDialogState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|
||||||
final _formKey = GlobalKey<FormState>();
|
|
||||||
final _nomController = TextEditingController();
|
|
||||||
final _prenomController = TextEditingController();
|
|
||||||
final _emailController = TextEditingController();
|
|
||||||
final _passwordController = TextEditingController();
|
|
||||||
final _telephoneController = TextEditingController();
|
|
||||||
final _passwordToggleFocusNode =
|
|
||||||
FocusNode(skipTraversal: true, canRequestFocus: false);
|
|
||||||
|
|
||||||
bool _isSubmitting = false;
|
|
||||||
bool _obscurePassword = true;
|
|
||||||
bool _isLoadingRelais = true;
|
|
||||||
List<RelaisModel> _relais = [];
|
|
||||||
String? _selectedRelaisId;
|
|
||||||
String? _currentUserId;
|
|
||||||
bool get _isEditMode => widget.initialUser != null;
|
|
||||||
bool get _isSuperAdminTarget =>
|
|
||||||
(widget.initialUser?.role ?? '').toLowerCase() == 'super_admin';
|
|
||||||
bool get _isSelfTarget =>
|
|
||||||
_isEditMode &&
|
|
||||||
_currentUserId != null &&
|
|
||||||
widget.initialUser!.id == _currentUserId;
|
|
||||||
bool get _canDeleteTarget => !_isSuperAdminTarget && !_isSelfTarget;
|
|
||||||
bool get _isLockedAdminIdentity =>
|
|
||||||
_isEditMode && widget.adminMode && _isSuperAdminTarget;
|
|
||||||
String get _targetRoleKey {
|
|
||||||
if (widget.initialUser != null) {
|
|
||||||
return (widget.initialUser!.role).toLowerCase();
|
|
||||||
}
|
|
||||||
return widget.adminMode ? 'administrateur' : 'gestionnaire';
|
|
||||||
}
|
|
||||||
|
|
||||||
String get _targetRoleLabel {
|
|
||||||
switch (_targetRoleKey) {
|
|
||||||
case 'super_admin':
|
|
||||||
return 'Super administrateur';
|
|
||||||
case 'administrateur':
|
|
||||||
return 'Administrateur';
|
|
||||||
case 'gestionnaire':
|
|
||||||
return 'Gestionnaire';
|
|
||||||
case 'assistante_maternelle':
|
|
||||||
return 'Assistante maternelle';
|
|
||||||
case 'parent':
|
|
||||||
return 'Parent';
|
|
||||||
default:
|
|
||||||
return 'Utilisateur';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
IconData get _targetRoleIcon {
|
|
||||||
switch (_targetRoleKey) {
|
|
||||||
case 'super_admin':
|
|
||||||
return Icons.verified_user_outlined;
|
|
||||||
case 'administrateur':
|
|
||||||
return Icons.admin_panel_settings_outlined;
|
|
||||||
case 'gestionnaire':
|
|
||||||
return Icons.assignment_ind_outlined;
|
|
||||||
case 'assistante_maternelle':
|
|
||||||
return Icons.child_care_outlined;
|
|
||||||
case 'parent':
|
|
||||||
return Icons.supervisor_account_outlined;
|
|
||||||
default:
|
|
||||||
return Icons.person_outline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
final user = widget.initialUser;
|
|
||||||
if (user != null) {
|
|
||||||
_nomController.text = user.nom ?? '';
|
|
||||||
_prenomController.text = user.prenom ?? '';
|
|
||||||
_emailController.text = user.email;
|
|
||||||
_telephoneController.text = formatPhoneForDisplay(user.telephone ?? '');
|
|
||||||
// En édition, on ne préremplit jamais le mot de passe.
|
|
||||||
_passwordController.clear();
|
|
||||||
final initialRelaisId = user.relaisId?.trim();
|
|
||||||
_selectedRelaisId =
|
|
||||||
(initialRelaisId == null || initialRelaisId.isEmpty)
|
|
||||||
? null
|
|
||||||
: initialRelaisId;
|
|
||||||
}
|
|
||||||
if (widget.withRelais) {
|
|
||||||
_loadRelais();
|
|
||||||
} else {
|
|
||||||
_isLoadingRelais = false;
|
|
||||||
}
|
|
||||||
_loadCurrentUserId();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadCurrentUserId() async {
|
|
||||||
final cached = await AuthService.getCurrentUser();
|
|
||||||
if (!mounted) return;
|
|
||||||
if (cached != null) {
|
|
||||||
setState(() {
|
|
||||||
_currentUserId = cached.id;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final refreshed = await AuthService.refreshCurrentUser();
|
|
||||||
if (!mounted || refreshed == null) return;
|
|
||||||
setState(() {
|
|
||||||
_currentUserId = refreshed.id;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_nomController.dispose();
|
|
||||||
_prenomController.dispose();
|
|
||||||
_emailController.dispose();
|
|
||||||
_passwordController.dispose();
|
|
||||||
_telephoneController.dispose();
|
|
||||||
_passwordToggleFocusNode.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fallback si GET /relais échoue : conserve le relais déjà connu sur l'utilisateur.
|
|
||||||
List<RelaisModel> _fallbackRelaisFromUser() {
|
|
||||||
final id = _selectedRelaisId?.trim();
|
|
||||||
if (id == null || id.isEmpty) return const [];
|
|
||||||
final nom = (widget.initialUser?.relaisNom ?? '').trim();
|
|
||||||
return [
|
|
||||||
RelaisModel(
|
|
||||||
id: id,
|
|
||||||
nom: nom.isNotEmpty ? nom : 'Relais actuel',
|
|
||||||
adresse: '',
|
|
||||||
actif: true,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadRelais() async {
|
|
||||||
try {
|
|
||||||
final list = await RelaisService.getRelais();
|
|
||||||
if (!mounted) return;
|
|
||||||
final uniqueById = <String, RelaisModel>{};
|
|
||||||
for (final relais in list) {
|
|
||||||
uniqueById[relais.id] = relais;
|
|
||||||
}
|
|
||||||
|
|
||||||
final filtered = uniqueById.values.where((r) => r.actif).toList();
|
|
||||||
if (_selectedRelaisId != null &&
|
|
||||||
!filtered.any((r) => r.id == _selectedRelaisId)) {
|
|
||||||
final selected = uniqueById[_selectedRelaisId!];
|
|
||||||
if (selected != null) {
|
|
||||||
filtered.add(selected);
|
|
||||||
} else {
|
|
||||||
// Garder l'id sélectionné et afficher un item de secours (nom carte).
|
|
||||||
filtered.addAll(_fallbackRelaisFromUser());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_relais = filtered;
|
|
||||||
_isLoadingRelais = false;
|
|
||||||
});
|
|
||||||
} catch (_) {
|
|
||||||
if (!mounted) return;
|
|
||||||
// Ne pas nullifier _selectedRelaisId (#151) — la carte a déjà le bon libellé.
|
|
||||||
setState(() {
|
|
||||||
_relais = _fallbackRelaisFromUser();
|
|
||||||
_isLoadingRelais = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _required(String? value, String field) {
|
|
||||||
if (value == null || value.trim().isEmpty) {
|
|
||||||
return '$field est requis';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _validateEmail(String? value) {
|
|
||||||
final base = _required(value, 'Email');
|
|
||||||
if (base != null) {
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
return validateEmail(value, allowEmpty: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _validatePassword(String? value) {
|
|
||||||
if (_isEditMode && (value == null || value.trim().isEmpty)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final base = _required(value, 'Mot de passe');
|
|
||||||
if (base != null) return base;
|
|
||||||
if (value!.trim().length < 6) return 'Minimum 6 caractères';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _validatePhone(String? value) {
|
|
||||||
if (_isEditMode && (value == null || value.trim().isEmpty)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final base = _required(value, 'Téléphone');
|
|
||||||
if (base != null) {
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
return validateFrenchNationalPhone(value, allowEmpty: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _toTitleCase(String raw) {
|
|
||||||
final trimmed = raw.trim();
|
|
||||||
if (trimmed.isEmpty) return trimmed;
|
|
||||||
final words = trimmed.split(RegExp(r'\s+'));
|
|
||||||
final normalizedWords = words.map(_capitalizeComposedWord).toList();
|
|
||||||
return normalizedWords.join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
String _capitalizeComposedWord(String word) {
|
|
||||||
if (word.isEmpty) return word;
|
|
||||||
final lower = word.toLowerCase();
|
|
||||||
final separators = <String>{"-", "'", "’"};
|
|
||||||
final buffer = StringBuffer();
|
|
||||||
var capitalizeNext = true;
|
|
||||||
|
|
||||||
for (var i = 0; i < lower.length; i++) {
|
|
||||||
final char = lower[i];
|
|
||||||
if (capitalizeNext && RegExp(r'[a-zà-öø-ÿ]').hasMatch(char)) {
|
|
||||||
buffer.write(char.toUpperCase());
|
|
||||||
capitalizeNext = false;
|
|
||||||
} else {
|
|
||||||
buffer.write(char);
|
|
||||||
capitalizeNext = separators.contains(char);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return buffer.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _submit() async {
|
|
||||||
if (widget.readOnly) return;
|
|
||||||
if (_isSubmitting) return;
|
|
||||||
if (!_formKey.currentState!.validate()) return;
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_isSubmitting = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
final normalizedNom = _toTitleCase(_nomController.text);
|
|
||||||
final normalizedPrenom = _toTitleCase(_prenomController.text);
|
|
||||||
final normalizedPhone = normalizePhone(_telephoneController.text);
|
|
||||||
final passwordProvided = _passwordController.text.trim().isNotEmpty;
|
|
||||||
|
|
||||||
if (_isEditMode) {
|
|
||||||
if (widget.adminMode) {
|
|
||||||
final lockedNom = _toTitleCase(widget.initialUser!.nom ?? '');
|
|
||||||
final lockedPrenom = _toTitleCase(widget.initialUser!.prenom ?? '');
|
|
||||||
await UserService.updateAdministrateur(
|
|
||||||
adminId: widget.initialUser!.id,
|
|
||||||
nom: _isLockedAdminIdentity ? lockedNom : normalizedNom,
|
|
||||||
prenom: _isLockedAdminIdentity ? lockedPrenom : normalizedPrenom,
|
|
||||||
email: _emailController.text.trim(),
|
|
||||||
telephone: normalizedPhone.isEmpty
|
|
||||||
? normalizePhone(widget.initialUser!.telephone ?? '')
|
|
||||||
: normalizedPhone,
|
|
||||||
password: passwordProvided ? _passwordController.text : null,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
final currentUser = widget.initialUser!;
|
|
||||||
final initialNom = _toTitleCase(currentUser.nom ?? '');
|
|
||||||
final initialPrenom = _toTitleCase(currentUser.prenom ?? '');
|
|
||||||
final initialEmail = currentUser.email.trim();
|
|
||||||
final initialPhone = normalizePhone(currentUser.telephone ?? '');
|
|
||||||
|
|
||||||
final onlyRelaisChanged =
|
|
||||||
normalizedNom == initialNom &&
|
|
||||||
normalizedPrenom == initialPrenom &&
|
|
||||||
_emailController.text.trim() == initialEmail &&
|
|
||||||
normalizedPhone == initialPhone &&
|
|
||||||
!passwordProvided;
|
|
||||||
|
|
||||||
if (onlyRelaisChanged) {
|
|
||||||
await UserService.updateGestionnaireRelais(
|
|
||||||
gestionnaireId: currentUser.id,
|
|
||||||
relaisId: _selectedRelaisId,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await UserService.updateGestionnaire(
|
|
||||||
gestionnaireId: currentUser.id,
|
|
||||||
nom: normalizedNom,
|
|
||||||
prenom: normalizedPrenom,
|
|
||||||
email: _emailController.text.trim(),
|
|
||||||
telephone: normalizedPhone.isEmpty ? initialPhone : normalizedPhone,
|
|
||||||
relaisId: _selectedRelaisId,
|
|
||||||
password: passwordProvided ? _passwordController.text : null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (widget.adminMode) {
|
|
||||||
await UserService.createAdministrateur(
|
|
||||||
nom: normalizedNom,
|
|
||||||
prenom: normalizedPrenom,
|
|
||||||
email: _emailController.text.trim(),
|
|
||||||
password: _passwordController.text,
|
|
||||||
telephone: normalizePhone(_telephoneController.text),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await UserService.createGestionnaire(
|
|
||||||
nom: normalizedNom,
|
|
||||||
prenom: normalizedPrenom,
|
|
||||||
email: _emailController.text.trim(),
|
|
||||||
password: _passwordController.text,
|
|
||||||
telephone: normalizePhone(_telephoneController.text),
|
|
||||||
relaisId: _selectedRelaisId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
_isEditMode
|
|
||||||
? (widget.adminMode
|
|
||||||
? 'Administrateur modifié avec succès.'
|
|
||||||
: 'Gestionnaire modifié avec succès.')
|
|
||||||
: (widget.adminMode
|
|
||||||
? 'Administrateur créé avec succès.'
|
|
||||||
: 'Gestionnaire créé avec succès.'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
Navigator.of(context).pop(true);
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
e.toString().replaceFirst('Exception: ', ''),
|
|
||||||
),
|
|
||||||
backgroundColor: Colors.red.shade700,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_isSubmitting = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _delete() async {
|
|
||||||
if (widget.readOnly) return;
|
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (confirmed != true) return;
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_isSubmitting = true;
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await UserService.deleteUser(widget.initialUser!.id);
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Gestionnaire supprimé.')),
|
|
||||||
);
|
|
||||||
Navigator.of(context).pop(true);
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(e.toString().replaceFirst('Exception: ', '')),
|
|
||||||
backgroundColor: Colors.red.shade700,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
setState(() {
|
|
||||||
_isSubmitting = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return AlertDialog(
|
|
||||||
title: Row(
|
|
||||||
children: [
|
|
||||||
CircleAvatar(
|
|
||||||
radius: 16,
|
|
||||||
backgroundColor: const Color(0xFFEDE5FA),
|
|
||||||
child: Icon(
|
|
||||||
_targetRoleIcon,
|
|
||||||
size: 20,
|
|
||||||
color: const Color(0xFF6B3FA0),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
_isEditMode
|
|
||||||
? (widget.readOnly
|
|
||||||
? 'Consulter un "$_targetRoleLabel"'
|
|
||||||
: 'Modifier un "$_targetRoleLabel"')
|
|
||||||
: 'Créer un "$_targetRoleLabel"',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (_isEditMode && !widget.readOnly)
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
tooltip: 'Fermer',
|
|
||||||
onPressed: _isSubmitting
|
|
||||||
? null
|
|
||||||
: () => Navigator.of(context).pop(false),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
content: SizedBox(
|
|
||||||
width: 620,
|
|
||||||
child: Form(
|
|
||||||
key: _formKey,
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(child: _buildPrenomField()),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(child: _buildNomField()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_buildEmailField(),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(child: _buildPasswordField()),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(child: _buildTelephoneField()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (widget.withRelais) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_buildRelaisField(),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
if (widget.readOnly) ...[
|
|
||||||
FilledButton(
|
|
||||||
onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(false),
|
|
||||||
child: const Text('Fermer'),
|
|
||||||
),
|
|
||||||
] else if (_isEditMode) ...[
|
|
||||||
if (_canDeleteTarget)
|
|
||||||
OutlinedButton(
|
|
||||||
onPressed: _isSubmitting ? null : _delete,
|
|
||||||
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
|
|
||||||
child: const Text('Supprimer'),
|
|
||||||
),
|
|
||||||
FilledButton.icon(
|
|
||||||
onPressed: _isSubmitting ? null : _submit,
|
|
||||||
icon: _isSubmitting
|
|
||||||
? const SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.edit),
|
|
||||||
label: Text(_isSubmitting ? 'Modification...' : 'Modifier'),
|
|
||||||
),
|
|
||||||
] else ...[
|
|
||||||
OutlinedButton(
|
|
||||||
onPressed:
|
|
||||||
_isSubmitting ? null : () => Navigator.of(context).pop(false),
|
|
||||||
child: const Text('Annuler'),
|
|
||||||
),
|
|
||||||
FilledButton.icon(
|
|
||||||
onPressed: _isSubmitting ? null : _submit,
|
|
||||||
icon: _isSubmitting
|
|
||||||
? const SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.person_add_alt_1),
|
|
||||||
label: Text(_isSubmitting ? 'Création...' : 'Créer'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildNomField() {
|
|
||||||
return TextFormField(
|
|
||||||
controller: _nomController,
|
|
||||||
readOnly: widget.readOnly || _isLockedAdminIdentity,
|
|
||||||
textCapitalization: TextCapitalization.words,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Nom',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
validator: (widget.readOnly || _isLockedAdminIdentity)
|
|
||||||
? null
|
|
||||||
: (v) => _required(v, 'Nom'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildPrenomField() {
|
|
||||||
return TextFormField(
|
|
||||||
controller: _prenomController,
|
|
||||||
readOnly: widget.readOnly || _isLockedAdminIdentity,
|
|
||||||
textCapitalization: TextCapitalization.words,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Prénom',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
validator: (widget.readOnly || _isLockedAdminIdentity)
|
|
||||||
? null
|
|
||||||
: (v) => _required(v, 'Prénom'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildEmailField() {
|
|
||||||
return EmailTextFormField(
|
|
||||||
controller: _emailController,
|
|
||||||
readOnly: widget.readOnly,
|
|
||||||
label: 'Email',
|
|
||||||
validator: widget.readOnly ? null : _validateEmail,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildPasswordField() {
|
|
||||||
return TextFormField(
|
|
||||||
controller: _passwordController,
|
|
||||||
readOnly: widget.readOnly,
|
|
||||||
obscureText: _obscurePassword,
|
|
||||||
enableSuggestions: false,
|
|
||||||
autocorrect: false,
|
|
||||||
autofillHints: _isEditMode
|
|
||||||
? const <String>[]
|
|
||||||
: const [AutofillHints.newPassword],
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: _isEditMode
|
|
||||||
? 'Nouveau mot de passe'
|
|
||||||
: 'Mot de passe',
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
suffixIcon: widget.readOnly
|
|
||||||
? null
|
|
||||||
: ExcludeFocus(
|
|
||||||
child: IconButton(
|
|
||||||
focusNode: _passwordToggleFocusNode,
|
|
||||||
onPressed: () {
|
|
||||||
setState(() {
|
|
||||||
_obscurePassword = !_obscurePassword;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
icon: Icon(
|
|
||||||
_obscurePassword ? Icons.visibility_off : Icons.visibility,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
validator: widget.readOnly ? null : _validatePassword,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTelephoneField() {
|
|
||||||
return FrenchPhoneTextFormField(
|
|
||||||
controller: _telephoneController,
|
|
||||||
readOnly: widget.readOnly,
|
|
||||||
label: 'Téléphone (ex: 06 12 34 56 78)',
|
|
||||||
validator: widget.readOnly ? null : _validatePhone,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildRelaisField() {
|
|
||||||
final selectedValue = _selectedRelaisId != null &&
|
|
||||||
_relais.any((relais) => relais.id == _selectedRelaisId)
|
|
||||||
? _selectedRelaisId
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
DropdownButtonFormField<String?>(
|
|
||||||
isExpanded: true,
|
|
||||||
value: selectedValue,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Relais principal',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
items: [
|
|
||||||
const DropdownMenuItem<String?>(
|
|
||||||
value: null,
|
|
||||||
child: Text('Aucun relais'),
|
|
||||||
),
|
|
||||||
..._relais.map(
|
|
||||||
(relais) => DropdownMenuItem<String?>(
|
|
||||||
value: relais.id,
|
|
||||||
child: Text(relais.nom),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
onChanged: (_isLoadingRelais || widget.readOnly)
|
|
||||||
? null
|
|
||||||
: (value) {
|
|
||||||
setState(() {
|
|
||||||
_selectedRelaisId = value;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (_isLoadingRelais) ...[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
const LinearProgressIndicator(minHeight: 2),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -121,7 +121,6 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
|
|||||||
dob: '',
|
dob: '',
|
||||||
isUnbornChild: false,
|
isUnbornChild: false,
|
||||||
photoConsent: false,
|
photoConsent: false,
|
||||||
multipleBirth: false,
|
|
||||||
cardColor: cardColor,
|
cardColor: cardColor,
|
||||||
);
|
);
|
||||||
registrationData.addChild(newChild);
|
registrationData.addChild(newChild);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
import 'package:p_tits_pas/services/auth_service.dart';
|
import 'package:p_tits_pas/services/auth_service.dart';
|
||||||
import 'package:p_tits_pas/widgets/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/app_footer.dart';
|
||||||
import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart';
|
import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart';
|
||||||
|
|
||||||
@@ -62,7 +62,10 @@ class _GestionnaireDashboardScreenState extends State<GestionnaireDashboardScree
|
|||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: UserManagementPanel(showAdministrateursTab: false),
|
child: UserManagementPanel(
|
||||||
|
showAdministrateursTab: false,
|
||||||
|
allowStaffAccountCreation: false,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const AppFooter(),
|
const AppFooter(),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -646,14 +646,93 @@ class UserService {
|
|||||||
return enrichEnfantParentNames(enfant);
|
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(
|
final response = await http.delete(
|
||||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'),
|
||||||
headers: await _headers(),
|
headers: await _headers(),
|
||||||
);
|
);
|
||||||
if (response.statusCode != 200 && response.statusCode != 204) {
|
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).
|
/// AM dont la liste d'enfants actifs contient [enfantId] (API actuelle).
|
||||||
@@ -1224,22 +1303,18 @@ class UserService {
|
|||||||
return AppUser.fromJson(data);
|
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(
|
final response = await http.delete(
|
||||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'),
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'),
|
||||||
headers: await _headers(),
|
headers: await _headers(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode != 200 && response.statusCode != 204) {
|
if (response.statusCode != 200 && response.statusCode != 204) {
|
||||||
final decoded = jsonDecode(response.body);
|
throw Exception(
|
||||||
if (decoded is Map<String, dynamic>) {
|
_extractErrorMessage(response.body, 'Erreur suppression utilisateur'),
|
||||||
final message = decoded['message'];
|
);
|
||||||
if (message is List && message.isNotEmpty) {
|
|
||||||
throw Exception(message.join(' - '));
|
|
||||||
}
|
|
||||||
throw Exception(_toStr(message) ?? 'Erreur suppression utilisateur');
|
|
||||||
}
|
|
||||||
throw Exception('Erreur suppression utilisateur');
|
|
||||||
}
|
}
|
||||||
|
return _parseSuppressionBody(response.body);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,7 +166,6 @@ class ParentRegistrationPayload {
|
|||||||
static Map<String, dynamic> _childToJson(ChildData c, int index, String parentNom) {
|
static Map<String, dynamic> _childToJson(ChildData c, int index, String parentNom) {
|
||||||
final map = <String, dynamic>{
|
final map = <String, dynamic>{
|
||||||
'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre',
|
'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre',
|
||||||
'grossesse_multiple': c.multipleBirth,
|
|
||||||
'consent_photo': c.photoConsent,
|
'consent_photo': c.photoConsent,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ class RepriseMapper {
|
|||||||
dob: dob,
|
dob: dob,
|
||||||
genre: e.gender ?? '',
|
genre: e.gender ?? '',
|
||||||
photoConsent: e.consentPhoto,
|
photoConsent: e.consentPhoto,
|
||||||
multipleBirth: e.estMultiple,
|
|
||||||
isUnbornChild: isUnborn,
|
isUnbornChild: isUnborn,
|
||||||
cardColor: _childCardColors[index % _childCardColors.length],
|
cardColor: _childCardColors[index % _childCardColors.length],
|
||||||
repriseChildId: e.id,
|
repriseChildId: e.id,
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/// Création comptes staff (gestionnaire / admin) — ticket #161.
|
||||||
|
bool canCreateStaffAccounts(String? role) {
|
||||||
|
final r = (role ?? '').trim().toLowerCase();
|
||||||
|
return r == 'administrateur' || r == 'super_admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Droits d’affichage poubelle — tickets #154 / #160.
|
||||||
|
bool canDeleteMetier(String? role) {
|
||||||
|
final r = (role ?? '').trim().toLowerCase();
|
||||||
|
return r == 'gestionnaire' ||
|
||||||
|
r == 'administrateur' ||
|
||||||
|
r == 'super_admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool canDeleteGestionnaire(String? role) {
|
||||||
|
final r = (role ?? '').trim().toLowerCase();
|
||||||
|
return r == 'administrateur' || r == 'super_admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool canDeleteAdministrateur({
|
||||||
|
required String? currentRole,
|
||||||
|
required String? currentUserId,
|
||||||
|
required String targetUserId,
|
||||||
|
required String targetRole,
|
||||||
|
required int adminCount,
|
||||||
|
}) {
|
||||||
|
final me = (currentRole ?? '').trim().toLowerCase();
|
||||||
|
if (me != 'administrateur' && me != 'super_admin') return false;
|
||||||
|
if (targetUserId.trim().isEmpty) return false;
|
||||||
|
if (currentUserId != null &&
|
||||||
|
currentUserId.trim() == targetUserId.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final target = targetRole.trim().toLowerCase();
|
||||||
|
if (target == 'super_admin') return false;
|
||||||
|
if (adminCount <= 1 && me != 'super_admin') return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? adminDeleteBlockedReason({
|
||||||
|
required String? currentRole,
|
||||||
|
required String? currentUserId,
|
||||||
|
required String targetUserId,
|
||||||
|
required String targetRole,
|
||||||
|
required int adminCount,
|
||||||
|
}) {
|
||||||
|
if (currentUserId != null &&
|
||||||
|
currentUserId.trim() == targetUserId.trim()) {
|
||||||
|
return 'Vous ne pouvez pas supprimer votre propre compte.';
|
||||||
|
}
|
||||||
|
if (targetRole.trim().toLowerCase() == 'super_admin') {
|
||||||
|
return 'Le super administrateur ne peut pas être supprimé.';
|
||||||
|
}
|
||||||
|
if (adminCount <= 1 &&
|
||||||
|
(currentRole ?? '').trim().toLowerCase() != 'super_admin') {
|
||||||
|
return 'Seul un super administrateur peut supprimer le dernier administrateur.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
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/widgets/dashboard/staff_user_form_modal.dart';
|
||||||
import 'package:p_tits_pas/services/auth_service.dart';
|
import 'package:p_tits_pas/services/auth_service.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/user_list.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 {
|
class AdminManagementWidget extends StatefulWidget {
|
||||||
final String searchQuery;
|
final String searchQuery;
|
||||||
@@ -24,6 +26,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
String? _error;
|
String? _error;
|
||||||
List<AppUser> _admins = [];
|
List<AppUser> _admins = [];
|
||||||
String? _currentUserRole;
|
String? _currentUserRole;
|
||||||
|
String? _currentUserId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -62,6 +65,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentUserRole = (cached.role).toLowerCase();
|
_currentUserRole = (cached.role).toLowerCase();
|
||||||
|
_currentUserId = cached.id;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -69,6 +73,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
if (!mounted || refreshed == null) return;
|
if (!mounted || refreshed == null) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentUserRole = (refreshed.role).toLowerCase();
|
_currentUserRole = (refreshed.role).toLowerCase();
|
||||||
|
_currentUserId = refreshed.id;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,13 +84,23 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
return _currentUserRole == 'super_admin';
|
return _currentUserRole == 'super_admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _canDeleteAdmin(AppUser target) {
|
||||||
|
return canDeleteAdministrateur(
|
||||||
|
currentRole: _currentUserRole,
|
||||||
|
currentUserId: _currentUserId,
|
||||||
|
targetUserId: target.id,
|
||||||
|
targetRole: target.role,
|
||||||
|
adminCount: _admins.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _openAdminEditDialog(AppUser user) async {
|
Future<void> _openAdminEditDialog(AppUser user) async {
|
||||||
final canEdit = _canEditAdmin(user);
|
final canEdit = _canEditAdmin(user);
|
||||||
final changed = await showDialog<bool>(
|
final changed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
return AdminUserFormDialog(
|
return StaffUserFormModal(
|
||||||
initialUser: user,
|
initialUser: user,
|
||||||
adminMode: true,
|
adminMode: true,
|
||||||
withRelais: false,
|
withRelais: false,
|
||||||
@@ -98,6 +113,56 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDelete(AppUser user) async {
|
||||||
|
final blocked = adminDeleteBlockedReason(
|
||||||
|
currentRole: _currentUserRole,
|
||||||
|
currentUserId: _currentUserId,
|
||||||
|
targetUserId: user.id,
|
||||||
|
targetRole: user.role,
|
||||||
|
adminCount: _admins.length,
|
||||||
|
);
|
||||||
|
if (blocked != null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(blocked)),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final name = user.fullName.isNotEmpty ? user.fullName : user.email;
|
||||||
|
final isLast = _admins.length <= 1;
|
||||||
|
final confirmed = await showSuppressionConfirmDialog(
|
||||||
|
context,
|
||||||
|
title: 'Supprimer l\'administrateur',
|
||||||
|
people: [SuppressionPersonLine.administrateur(name)],
|
||||||
|
footnotes: [
|
||||||
|
if (isLast)
|
||||||
|
'Attention : c’est le dernier administrateur.',
|
||||||
|
'Le compte sera définitivement supprimé.',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (!confirmed || !mounted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await UserService.deleteUser(user.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
final msg =
|
||||||
|
(result['message'] ?? 'Administrateur supprimé.').toString();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||||
|
await _loadAdmins();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
e is Exception
|
||||||
|
? e.toString().replaceFirst('Exception: ', '')
|
||||||
|
: 'Erreur suppression',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final query = widget.searchQuery.toLowerCase();
|
final query = widget.searchQuery.toLowerCase();
|
||||||
@@ -117,7 +182,8 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
final user = filteredAdmins[index];
|
final user = filteredAdmins[index];
|
||||||
final isSuperAdmin = _isSuperAdmin(user);
|
final isSuperAdmin = _isSuperAdmin(user);
|
||||||
final canEdit = _canEditAdmin(user);
|
final canEdit = _canEditAdmin(user);
|
||||||
return AdminUserCard(
|
final canDelete = _canDeleteAdmin(user);
|
||||||
|
return UserCard(
|
||||||
title: user.fullName,
|
title: user.fullName,
|
||||||
fallbackIcon: isSuperAdmin
|
fallbackIcon: isSuperAdmin
|
||||||
? Icons.verified_user_outlined
|
? Icons.verified_user_outlined
|
||||||
@@ -148,6 +214,8 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
_openAdminEditDialog(user);
|
_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/models/user.dart';
|
||||||
import 'package:p_tits_pas/utils/name_format_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/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.
|
/// Valeurs affichées dans un [IdentityBlock] en lecture seule.
|
||||||
class IdentityValues {
|
class IdentityValues {
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@ import 'package:p_tits_pas/utils/date_display_utils.dart';
|
|||||||
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
||||||
|
|
||||||
/// Grille 2×2 des places d'accueil AM (max 4, limitée à [capacity]).
|
/// Grille 2×2 des places d'accueil AM (max 4, limitée à [capacity]).
|
||||||
class AdminAmChildrenCapacityGrid extends StatelessWidget {
|
class AmChildrenCapacityGrid extends StatelessWidget {
|
||||||
static const int _gridSlots = 4;
|
static const int _gridSlots = 4;
|
||||||
static const double _slotHeight = 44;
|
static const double _slotHeight = 44;
|
||||||
static const double _gridPadding = 10;
|
static const double _gridPadding = 10;
|
||||||
@@ -23,7 +23,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
|
|||||||
/// Clic sur une case libre → même flux que « Rattacher un enfant » (#149).
|
/// Clic sur une case libre → même flux que « Rattacher un enfant » (#149).
|
||||||
final VoidCallback? onAttachEmpty;
|
final VoidCallback? onAttachEmpty;
|
||||||
|
|
||||||
const AdminAmChildrenCapacityGrid({
|
const AmChildrenCapacityGrid({
|
||||||
super.key,
|
super.key,
|
||||||
required this.children,
|
required this.children,
|
||||||
required this.capacity,
|
required this.capacity,
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
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].
|
/// Modale de création dossier AM (#156) — même shell que [ValidationDossierModal].
|
||||||
class AmDossierCreateModal extends StatefulWidget {
|
class AmDossierCreateModal extends StatefulWidget {
|
||||||
+18
-18
@@ -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/nir_utils.dart';
|
||||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||||
import 'package:p_tits_pas/utils/postal_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/dashboard/am_photo_frame.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
|
import 'package:p_tits_pas/widgets/dashboard/detail_modal.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';
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_refus_form.dart';
|
import 'package:p_tits_pas/widgets/dashboard/validation_refus_form.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_valider_confirm_dialog.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/auth_network_image.dart';
|
||||||
import 'package:p_tits_pas/widgets/common/identity_block.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);
|
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
|
||||||
|
|
||||||
List<AdminDetailField> _photoProFields(DossierAM d) {
|
List<DetailField> _photoProFields(DossierAM d) {
|
||||||
final u = d.user;
|
final u = d.user;
|
||||||
return [
|
return [
|
||||||
AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)),
|
DetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)),
|
||||||
AdminDetailField(
|
DetailField(
|
||||||
label: 'Date de naissance',
|
label: 'Date de naissance',
|
||||||
value: formatIsoDateFr(u.dateNaissance),
|
value: formatIsoDateFr(u.dateNaissance),
|
||||||
),
|
),
|
||||||
AdminDetailField(
|
DetailField(
|
||||||
label: 'Ville de naissance',
|
label: 'Ville de naissance',
|
||||||
value: _v(u.lieuNaissanceVille),
|
value: _v(u.lieuNaissanceVille),
|
||||||
),
|
),
|
||||||
AdminDetailField(
|
DetailField(
|
||||||
label: 'Pays de naissance',
|
label: 'Pays de naissance',
|
||||||
value: _v(u.lieuNaissancePays),
|
value: _v(u.lieuNaissancePays),
|
||||||
),
|
),
|
||||||
AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)),
|
DetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)),
|
||||||
AdminDetailField(
|
DetailField(
|
||||||
label: 'Date d’agrément',
|
label: 'Date d’agrément',
|
||||||
value: formatIsoDateFr(d.dateAgrement),
|
value: formatIsoDateFr(d.dateAgrement),
|
||||||
),
|
),
|
||||||
AdminDetailField(
|
DetailField(
|
||||||
label: 'Capacité max (enfants)',
|
label: 'Capacité max (enfants)',
|
||||||
value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '–',
|
value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '–',
|
||||||
),
|
),
|
||||||
AdminDetailField(
|
DetailField(
|
||||||
label: 'Places disponibles',
|
label: 'Places disponibles',
|
||||||
value: d.placesDisponibles != null
|
value: d.placesDisponibles != null
|
||||||
? d.placesDisponibles.toString()
|
? d.placesDisponibles.toString()
|
||||||
@@ -741,14 +741,14 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
|||||||
|
|
||||||
Widget _buildStep1() {
|
Widget _buildStep1() {
|
||||||
// Modale calée sur les TF ; photo étirée sur toute la hauteur utile
|
// 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(
|
return LayoutBuilder(
|
||||||
builder: (context, c) {
|
builder: (context, c) {
|
||||||
final maxRowW = c.maxWidth;
|
final maxRowW = c.maxWidth;
|
||||||
final maxRowH = c.maxHeight.clamp(0.0, double.infinity);
|
final maxRowH = c.maxHeight.clamp(0.0, double.infinity);
|
||||||
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
|
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
|
||||||
.clamp(0.0, double.infinity);
|
.clamp(0.0, double.infinity);
|
||||||
var photoW = AdminAmPhotoFrame.columnWidthForHeight(maxRowH)
|
var photoW = AmPhotoFrame.columnWidthForHeight(maxRowH)
|
||||||
.clamp(_photoColumnMinWidth, 360.0);
|
.clamp(_photoColumnMinWidth, 360.0);
|
||||||
if (photoW > maxPhotoW) photoW = maxPhotoW;
|
if (photoW > maxPhotoW) photoW = maxPhotoW;
|
||||||
|
|
||||||
@@ -762,7 +762,7 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
|||||||
|
|
||||||
final Widget photo;
|
final Widget photo;
|
||||||
if (_isCreate) {
|
if (_isCreate) {
|
||||||
photo = AdminAmPhotoFrame(
|
photo = AmPhotoFrame(
|
||||||
imageBytes: _photoBytes,
|
imageBytes: _photoBytes,
|
||||||
onTap: _pickPhoto,
|
onTap: _pickPhoto,
|
||||||
onClear: _photoBytes != null ? _clearPhoto : null,
|
onClear: _photoBytes != null ? _clearPhoto : null,
|
||||||
+40
-53
@@ -7,31 +7,31 @@ import 'package:p_tits_pas/utils/date_display_utils.dart';
|
|||||||
import 'package:p_tits_pas/utils/nir_utils.dart';
|
import 'package:p_tits_pas/utils/nir_utils.dart';
|
||||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_children_capacity_grid.dart';
|
import 'package:p_tits_pas/widgets/dashboard/am_children_capacity_grid.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
|
import 'package:p_tits_pas/widgets/dashboard/am_photo_frame.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
|
import 'package:p_tits_pas/widgets/dashboard/child_detail_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart';
|
import 'package:p_tits_pas/widgets/dashboard/select_enfant_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
|
import 'package:p_tits_pas/widgets/dashboard/status_capsule.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';
|
||||||
import 'package:p_tits_pas/widgets/common/identity_block.dart';
|
import 'package:p_tits_pas/widgets/common/identity_block.dart';
|
||||||
import 'package:p_tits_pas/widgets/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.
|
/// Fiche AM éditable (ticket #131) — identité | fiche pro (photo) | enfants.
|
||||||
class AdminAmEditModal extends StatefulWidget {
|
class AmEditModal extends StatefulWidget {
|
||||||
final AssistanteMaternelleModel assistante;
|
final AssistanteMaternelleModel assistante;
|
||||||
final VoidCallback? onSaved;
|
final VoidCallback? onSaved;
|
||||||
|
|
||||||
const AdminAmEditModal({
|
const AmEditModal({
|
||||||
super.key,
|
super.key,
|
||||||
required this.assistante,
|
required this.assistante,
|
||||||
this.onSaved,
|
this.onSaved,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AdminAmEditModal> createState() => _AdminAmEditModalState();
|
State<AmEditModal> createState() => _AmEditModalState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AdminAmEditModalState extends State<AdminAmEditModal>
|
class _AmEditModalState extends State<AmEditModal>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late final TabController _tabCtrl;
|
late final TabController _tabCtrl;
|
||||||
late final TextEditingController _nomCtrl;
|
late final TextEditingController _nomCtrl;
|
||||||
@@ -69,29 +69,6 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
static const double _proTabHeight = 300;
|
static const double _proTabHeight = 300;
|
||||||
static const List<int> _photoProRowLayout = [2, 2, 2];
|
static const List<int> _photoProRowLayout = [2, 2, 2];
|
||||||
|
|
||||||
/// Hauteur onglet enfants : champs + titre + grille 2×2 (+ alerte places si besoin).
|
|
||||||
double _childrenTabHeight() {
|
|
||||||
const capacityFields = 72.0;
|
|
||||||
const titleSection = 40.0;
|
|
||||||
const inconsistencyExtra = 46.0;
|
|
||||||
var h = capacityFields +
|
|
||||||
titleSection +
|
|
||||||
AdminAmChildrenCapacityGrid.fixedHeight;
|
|
||||||
if (_placesInconsistent()) h += inconsistencyExtra;
|
|
||||||
return h + 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
double _tabViewHeight(int index) {
|
|
||||||
switch (index) {
|
|
||||||
case 1:
|
|
||||||
return _proTabHeight;
|
|
||||||
case 2:
|
|
||||||
return _childrenTabHeight();
|
|
||||||
default:
|
|
||||||
return 292;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -427,7 +404,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AdminChildDetailModal(
|
builder: (ctx) => ChildDetailModal(
|
||||||
enfant: enfant,
|
enfant: enfant,
|
||||||
onSaved: _refreshChildrenDetails,
|
onSaved: _refreshChildrenDetails,
|
||||||
),
|
),
|
||||||
@@ -473,7 +450,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
|
|
||||||
Future<void> _attachChild() async {
|
Future<void> _attachChild() async {
|
||||||
if (_capacityFull || !mounted) return;
|
if (_capacityFull || !mounted) return;
|
||||||
final selected = await AdminSelectEnfantModal.show(
|
final selected = await SelectEnfantModal.show(
|
||||||
context,
|
context,
|
||||||
excludeIds: _children.map((c) => c.id).toSet(),
|
excludeIds: _children.map((c) => c.id).toSet(),
|
||||||
title: 'Rattacher un enfant',
|
title: 'Rattacher un enfant',
|
||||||
@@ -541,8 +518,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
|
|
||||||
|
|
||||||
Widget _identityTab() {
|
Widget _identityTab() {
|
||||||
return SingleChildScrollView(
|
return IdentityBlock.editable(
|
||||||
child: IdentityBlock.editable(
|
|
||||||
title: 'Identité et coordonnées',
|
title: 'Identité et coordonnées',
|
||||||
nomController: _nomCtrl,
|
nomController: _nomCtrl,
|
||||||
prenomController: _prenomCtrl,
|
prenomController: _prenomCtrl,
|
||||||
@@ -551,7 +527,6 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
adresseController: _adresseCtrl,
|
adresseController: _adresseCtrl,
|
||||||
codePostalController: _cpCtrl,
|
codePostalController: _cpCtrl,
|
||||||
villeController: _villeCtrl,
|
villeController: _villeCtrl,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,9 +585,11 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, c) {
|
builder: (context, c) {
|
||||||
final maxRowW = c.maxWidth;
|
final maxRowW = c.maxWidth;
|
||||||
final maxRowH = c.maxHeight;
|
// Hauteur bornée : l’onglet pro a une hauteur fixe (photo ID).
|
||||||
|
final maxRowH =
|
||||||
|
c.maxHeight.isFinite ? c.maxHeight : _proTabHeight;
|
||||||
final bodyH = maxRowH;
|
final bodyH = maxRowH;
|
||||||
final idealPhotoW = bodyH * AdminAmPhotoFrame.idPhotoAspectRatio + 16;
|
final idealPhotoW = bodyH * AmPhotoFrame.idPhotoAspectRatio + 16;
|
||||||
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
|
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
|
||||||
.clamp(0.0, double.infinity);
|
.clamp(0.0, double.infinity);
|
||||||
var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 220.0);
|
var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 220.0);
|
||||||
@@ -623,7 +600,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: photoW,
|
width: photoW,
|
||||||
child: AdminAmPhotoFrame(
|
child: AmPhotoFrame(
|
||||||
photoUrl: widget.assistante.user.photoUrl,
|
photoUrl: widget.assistante.user.photoUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -659,6 +636,18 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Corps de l’onglet actif — hauteur naturelle (pas de TabBarView).
|
||||||
|
Widget _buildActiveTabBody() {
|
||||||
|
switch (_tabCtrl.index) {
|
||||||
|
case 1:
|
||||||
|
return SizedBox(height: _proTabHeight, child: _proTab());
|
||||||
|
case 2:
|
||||||
|
return _childrenTab();
|
||||||
|
default:
|
||||||
|
return _identityTab();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _childrenCapacityFields() {
|
Widget _childrenCapacityFields() {
|
||||||
final inconsistent = _placesInconsistent();
|
final inconsistent = _placesInconsistent();
|
||||||
return Column(
|
return Column(
|
||||||
@@ -739,7 +728,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
AdminAmChildrenCapacityGrid(
|
AmChildrenCapacityGrid(
|
||||||
children: _children,
|
children: _children,
|
||||||
capacity: capacity,
|
capacity: capacity,
|
||||||
onOpen: _openChild,
|
onOpen: _openChild,
|
||||||
@@ -831,7 +820,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 160,
|
width: 160,
|
||||||
child: AdminStatusCapsule(
|
child: StatusCapsule(
|
||||||
statut: _statut,
|
statut: _statut,
|
||||||
onChanged: (v) => setState(() {
|
onChanged: (v) => setState(() {
|
||||||
_statut = v;
|
_statut = v;
|
||||||
@@ -867,15 +856,13 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
|
||||||
child: SizedBox(
|
child: AnimatedSize(
|
||||||
height: _tabViewHeight(_tabCtrl.index),
|
duration: const Duration(milliseconds: 180),
|
||||||
child: TabBarView(
|
curve: Curves.easeInOut,
|
||||||
controller: _tabCtrl,
|
alignment: Alignment.topCenter,
|
||||||
children: [
|
child: KeyedSubtree(
|
||||||
_identityTab(),
|
key: ValueKey<int>(_tabCtrl.index),
|
||||||
_proTab(),
|
child: _buildActiveTabBody(),
|
||||||
_childrenTab(),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
+2
-2
@@ -5,7 +5,7 @@ import 'package:p_tits_pas/services/api/api_config.dart';
|
|||||||
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
||||||
|
|
||||||
/// Cadre photo identité AM / enfant (35×45 mm) — même logique que [ValidationAmWizard].
|
/// Cadre photo identité AM / enfant (35×45 mm) — même logique que [ValidationAmWizard].
|
||||||
class AdminAmPhotoFrame extends StatelessWidget {
|
class AmPhotoFrame extends StatelessWidget {
|
||||||
final String? photoUrl;
|
final String? photoUrl;
|
||||||
final Uint8List? imageBytes;
|
final Uint8List? imageBytes;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
@@ -14,7 +14,7 @@ class AdminAmPhotoFrame extends StatelessWidget {
|
|||||||
|
|
||||||
static const double idPhotoAspectRatio = 35 / 45;
|
static const double idPhotoAspectRatio = 35 / 45;
|
||||||
|
|
||||||
const AdminAmPhotoFrame({
|
const AmPhotoFrame({
|
||||||
super.key,
|
super.key,
|
||||||
this.photoUrl,
|
this.photoUrl,
|
||||||
this.imageBytes,
|
this.imageBytes,
|
||||||
+61
-5
@@ -1,10 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
||||||
|
import 'package:p_tits_pas/models/dossier_list_item.dart';
|
||||||
import 'package:p_tits_pas/utils/am_vigilance.dart';
|
import 'package:p_tits_pas/utils/am_vigilance.dart';
|
||||||
|
import 'package:p_tits_pas/services/auth_service.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
|
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
import 'package:p_tits_pas/widgets/dashboard/am_edit_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/user_list.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 {
|
class AssistanteMaternelleManagementWidget extends StatefulWidget {
|
||||||
final String searchQuery;
|
final String searchQuery;
|
||||||
@@ -26,16 +30,24 @@ class _AssistanteMaternelleManagementWidgetState
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
List<AssistanteMaternelleModel> _assistantes = [];
|
List<AssistanteMaternelleModel> _assistantes = [];
|
||||||
|
bool _canDelete = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_loadRights();
|
||||||
_loadAssistantes();
|
_loadAssistantes();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() => super.dispose();
|
void dispose() => super.dispose();
|
||||||
|
|
||||||
|
Future<void> _loadRights() async {
|
||||||
|
final user = await AuthService.getCurrentUser();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _canDelete = canDeleteMetier(user?.role));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadAssistantes() async {
|
Future<void> _loadAssistantes() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
@@ -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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final query = widget.searchQuery.toLowerCase();
|
final query = widget.searchQuery.toLowerCase();
|
||||||
@@ -78,7 +130,7 @@ class _AssistanteMaternelleManagementWidgetState
|
|||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final assistante = filteredAssistantes[index];
|
final assistante = filteredAssistantes[index];
|
||||||
final vigilance = amPlacesVigilanceMessage(assistante);
|
final vigilance = amPlacesVigilanceMessage(assistante);
|
||||||
return AdminUserCard(
|
return UserCard(
|
||||||
title: assistante.user.fullName,
|
title: assistante.user.fullName,
|
||||||
avatarUrl: assistante.user.photoUrl,
|
avatarUrl: assistante.user.photoUrl,
|
||||||
fallbackIcon: Icons.face,
|
fallbackIcon: Icons.face,
|
||||||
@@ -96,6 +148,10 @@ class _AssistanteMaternelleManagementWidgetState
|
|||||||
_openAssistanteDetails(assistante);
|
_openAssistanteDetails(assistante);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (_canDelete)
|
||||||
|
suppressionIconButton(
|
||||||
|
onPressed: () => _confirmDelete(assistante),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -105,7 +161,7 @@ class _AssistanteMaternelleManagementWidgetState
|
|||||||
void _openAssistanteDetails(AssistanteMaternelleModel assistante) {
|
void _openAssistanteDetails(AssistanteMaternelleModel assistante) {
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AdminAmEditModal(
|
builder: (context) => AmEditModal(
|
||||||
assistante: assistante,
|
assistante: assistante,
|
||||||
onSaved: _loadAssistantes,
|
onSaved: _loadAssistantes,
|
||||||
),
|
),
|
||||||
+116
-50
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
||||||
|
import 'package:p_tits_pas/models/dossier_list_item.dart';
|
||||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||||
import 'package:p_tits_pas/services/auth_service.dart';
|
import 'package:p_tits_pas/services/auth_service.dart';
|
||||||
@@ -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/date_display_utils.dart';
|
||||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||||
import 'package:p_tits_pas/utils/name_format_utils.dart';
|
import 'package:p_tits_pas/utils/name_format_utils.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
|
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
|
import 'package:p_tits_pas/widgets/dashboard/am_edit_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart';
|
import 'package:p_tits_pas/widgets/dashboard/am_photo_frame.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_select_am_modal.dart';
|
import 'package:p_tits_pas/widgets/dashboard/parent_edit_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_select_famille_modal.dart';
|
import 'package:p_tits_pas/widgets/dashboard/select_am_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
import 'package:p_tits_pas/widgets/dashboard/select_famille_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/dashboard/common/validation_detail_section.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart';
|
||||||
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
||||||
|
|
||||||
/// Fiche enfant consultation / édition (#138) ou création (#132).
|
/// Fiche enfant consultation / édition (#138) ou création (#132).
|
||||||
class AdminChildDetailModal extends StatefulWidget {
|
class ChildDetailModal extends StatefulWidget {
|
||||||
final EnfantAdminModel? enfant;
|
final EnfantAdminModel? enfant;
|
||||||
final VoidCallback? onSaved;
|
final VoidCallback? onSaved;
|
||||||
final VoidCallback? onDeleted;
|
final VoidCallback? onDeleted;
|
||||||
final bool isCreating;
|
final bool isCreating;
|
||||||
|
|
||||||
const AdminChildDetailModal({
|
const ChildDetailModal({
|
||||||
super.key,
|
super.key,
|
||||||
required EnfantAdminModel this.enfant,
|
required EnfantAdminModel this.enfant,
|
||||||
this.onSaved,
|
this.onSaved,
|
||||||
@@ -33,7 +36,7 @@ class AdminChildDetailModal extends StatefulWidget {
|
|||||||
}) : isCreating = false;
|
}) : isCreating = false;
|
||||||
|
|
||||||
/// Création depuis l'onglet Enfants (#132).
|
/// Création depuis l'onglet Enfants (#132).
|
||||||
const AdminChildDetailModal.create({
|
const ChildDetailModal.create({
|
||||||
super.key,
|
super.key,
|
||||||
this.onSaved,
|
this.onSaved,
|
||||||
}) : enfant = null,
|
}) : enfant = null,
|
||||||
@@ -41,10 +44,10 @@ class AdminChildDetailModal extends StatefulWidget {
|
|||||||
isCreating = true;
|
isCreating = true;
|
||||||
|
|
||||||
@override
|
@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 _prenomCtrl;
|
||||||
late final TextEditingController _nomCtrl;
|
late final TextEditingController _nomCtrl;
|
||||||
late final TextEditingController _birthCtrl;
|
late final TextEditingController _birthCtrl;
|
||||||
@@ -52,7 +55,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
late String _status;
|
late String _status;
|
||||||
late String? _gender;
|
late String? _gender;
|
||||||
late bool _consentPhoto;
|
late bool _consentPhoto;
|
||||||
late bool _isMultiple;
|
|
||||||
bool _dirty = false;
|
bool _dirty = false;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
bool _deleting = false;
|
bool _deleting = false;
|
||||||
@@ -63,7 +65,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
String? _baselineAmUserId;
|
String? _baselineAmUserId;
|
||||||
|
|
||||||
/// Famille choisie en mode création (#132).
|
/// Famille choisie en mode création (#132).
|
||||||
AdminFamilleFoyer? _selectedFamily;
|
FamilleFoyer? _selectedFamily;
|
||||||
|
|
||||||
/// Liens parents locaux (édition) — mis à jour après rattachement foyer (#157).
|
/// Liens parents locaux (édition) — mis à jour après rattachement foyer (#157).
|
||||||
List<EnfantParentLink>? _localParentLinks;
|
List<EnfantParentLink>? _localParentLinks;
|
||||||
@@ -97,8 +99,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
_isUnborn ? 'Date prévisionnelle' : 'Date de naissance';
|
_isUnborn ? 'Date prévisionnelle' : 'Date de naissance';
|
||||||
|
|
||||||
bool get _canDelete =>
|
bool get _canDelete =>
|
||||||
!widget.isCreating &&
|
!widget.isCreating && canDeleteMetier(_currentUserRole);
|
||||||
(_currentUserRole ?? '').toLowerCase() == 'super_admin';
|
|
||||||
|
|
||||||
bool get _busy => _saving || _deleting;
|
bool get _busy => _saving || _deleting;
|
||||||
|
|
||||||
@@ -140,7 +141,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
_gender = _normalizeGender(e?.gender, allowUnknown: _isUnborn);
|
_gender = _normalizeGender(e?.gender, allowUnknown: _isUnborn);
|
||||||
}
|
}
|
||||||
_consentPhoto = e?.consentPhoto ?? false;
|
_consentPhoto = e?.consentPhoto ?? false;
|
||||||
_isMultiple = e?.isMultiple ?? false;
|
|
||||||
for (final c in [_prenomCtrl, _nomCtrl]) {
|
for (final c in [_prenomCtrl, _nomCtrl]) {
|
||||||
c.addListener(_onNameChanged);
|
c.addListener(_onNameChanged);
|
||||||
}
|
}
|
||||||
@@ -283,7 +283,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AdminParentEditModal(
|
builder: (ctx) => ParentEditModal(
|
||||||
parent: parent,
|
parent: parent,
|
||||||
onSaved: () => widget.onSaved?.call(),
|
onSaved: () => widget.onSaved?.call(),
|
||||||
),
|
),
|
||||||
@@ -381,7 +381,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
else if (_dateToIso(_birthCtrl.text) != null)
|
else if (_dateToIso(_birthCtrl.text) != null)
|
||||||
'birth_date': _dateToIso(_birthCtrl.text),
|
'birth_date': _dateToIso(_birthCtrl.text),
|
||||||
'consent_photo': _consentPhoto,
|
'consent_photo': _consentPhoto,
|
||||||
'is_multiple': _isMultiple,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -458,7 +457,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
'birth_date': _dateToIso(_birthCtrl.text),
|
'birth_date': _dateToIso(_birthCtrl.text),
|
||||||
},
|
},
|
||||||
'consent_photo': _consentPhoto,
|
'consent_photo': _consentPhoto,
|
||||||
'is_multiple': _isMultiple,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -506,37 +504,99 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
if (!_canDelete || _deleting) return;
|
if (!_canDelete || _deleting) return;
|
||||||
|
|
||||||
final name = _headerTitle();
|
final name = _headerTitle();
|
||||||
final confirmed = await showDialog<bool>(
|
final enfant = widget.enfant;
|
||||||
context: context,
|
if (enfant == null) return;
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
title: const Text('Supprimer l\'enfant'),
|
bool deleteDossierFlag = false;
|
||||||
content: Text(
|
String? numero;
|
||||||
'Supprimer définitivement la fiche de $name ?\n'
|
String familleLabel = '';
|
||||||
'Cette action est irréversible.',
|
bool isLast = false;
|
||||||
),
|
|
||||||
actions: [
|
String? amLabel;
|
||||||
TextButton(
|
final linked = _linkedAm;
|
||||||
onPressed: () => Navigator.pop(ctx, false),
|
if (linked != null) {
|
||||||
child: const Text('Annuler'),
|
final label = formatDossierPersonLabel(
|
||||||
),
|
nom: linked.user.nom,
|
||||||
ElevatedButton(
|
prenom: linked.user.prenom,
|
||||||
onPressed: () => Navigator.pop(ctx, true),
|
email: linked.user.email,
|
||||||
style: ElevatedButton.styleFrom(
|
);
|
||||||
backgroundColor: Colors.red.shade700,
|
if (label.isNotEmpty) amLabel = label;
|
||||||
foregroundColor: Colors.white,
|
} else {
|
||||||
),
|
try {
|
||||||
child: const Text('Supprimer'),
|
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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (confirmed != true || !mounted) return;
|
if (!confirmed || !mounted) return;
|
||||||
|
}
|
||||||
|
|
||||||
setState(() => _deleting = true);
|
setState(() => _deleting = true);
|
||||||
try {
|
try {
|
||||||
final id = widget.enfant?.id;
|
final id = widget.enfant?.id;
|
||||||
if (id == null || id.isEmpty) return;
|
if (id == null || id.isEmpty) return;
|
||||||
await UserService.deleteEnfant(id);
|
await UserService.deleteEnfant(id, deleteDossier: deleteDossierFlag);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
widget.onDeleted?.call();
|
widget.onDeleted?.call();
|
||||||
widget.onSaved?.call();
|
widget.onSaved?.call();
|
||||||
@@ -548,7 +608,13 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _deleting = false);
|
setState(() => _deleting = false);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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;
|
if (am == null) return;
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AdminAmEditModal(
|
builder: (ctx) => AmEditModal(
|
||||||
assistante: am,
|
assistante: am,
|
||||||
onSaved: () async {
|
onSaved: () async {
|
||||||
await _reloadPlacementFromServer();
|
await _reloadPlacementFromServer();
|
||||||
@@ -610,7 +676,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final selected = await AdminSelectAmModal.show(
|
final selected = await SelectAmModal.show(
|
||||||
context,
|
context,
|
||||||
excludeIds: {
|
excludeIds: {
|
||||||
if (_linkedAm != null) _linkedAm!.user.id,
|
if (_linkedAm != null) _linkedAm!.user.id,
|
||||||
@@ -920,7 +986,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
final maxRowW = c.maxWidth;
|
final maxRowW = c.maxWidth;
|
||||||
final maxRowH = c.maxHeight;
|
final maxRowH = c.maxHeight;
|
||||||
final idealPhotoW =
|
final idealPhotoW =
|
||||||
maxRowH * AdminAmPhotoFrame.idPhotoAspectRatio + 16;
|
maxRowH * AmPhotoFrame.idPhotoAspectRatio + 16;
|
||||||
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
|
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
|
||||||
.clamp(0.0, double.infinity);
|
.clamp(0.0, double.infinity);
|
||||||
var photoW = idealPhotoW.clamp(_photoColumnMinWidth, _photoColumnMaxWidth);
|
var photoW = idealPhotoW.clamp(_photoColumnMinWidth, _photoColumnMaxWidth);
|
||||||
@@ -935,7 +1001,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AdminAmPhotoFrame(
|
child: AmPhotoFrame(
|
||||||
photoUrl: widget.isCreating
|
photoUrl: widget.isCreating
|
||||||
? null
|
? null
|
||||||
: widget.enfant?.photoUrl,
|
: widget.enfant?.photoUrl,
|
||||||
@@ -1263,7 +1329,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
|
|
||||||
Future<void> _pickFamily() async {
|
Future<void> _pickFamily() async {
|
||||||
if (_busy) return;
|
if (_busy) return;
|
||||||
final selected = await AdminSelectFamilleModal.show(
|
final selected = await SelectFamilleModal.show(
|
||||||
context,
|
context,
|
||||||
title: 'Choisir une famille',
|
title: 'Choisir une famille',
|
||||||
);
|
);
|
||||||
+4
-4
@@ -1,9 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
||||||
import 'package:p_tits_pas/widgets/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).
|
/// Liste scrollable d'enfants rattachés (fiche parent / fiche AM).
|
||||||
class AdminChildrenAffiliationPanel extends StatelessWidget {
|
class ChildrenAffiliationPanel extends StatelessWidget {
|
||||||
final List<ParentChildSummary> children;
|
final List<ParentChildSummary> children;
|
||||||
final ScrollController scrollController;
|
final ScrollController scrollController;
|
||||||
final void Function(ParentChildSummary child) onOpen;
|
final void Function(ParentChildSummary child) onOpen;
|
||||||
@@ -14,7 +14,7 @@ class AdminChildrenAffiliationPanel extends StatelessWidget {
|
|||||||
static const double _itemHeight = 58;
|
static const double _itemHeight = 58;
|
||||||
static const double defaultViewportHeight = _itemHeight * 2.5 + 8;
|
static const double defaultViewportHeight = _itemHeight * 2.5 + 8;
|
||||||
|
|
||||||
const AdminChildrenAffiliationPanel({
|
const ChildrenAffiliationPanel({
|
||||||
super.key,
|
super.key,
|
||||||
required this.children,
|
required this.children,
|
||||||
required this.scrollController,
|
required this.scrollController,
|
||||||
@@ -62,7 +62,7 @@ class AdminChildrenAffiliationPanel extends StatelessWidget {
|
|||||||
itemCount: children.length,
|
itemCount: children.length,
|
||||||
itemBuilder: (_, i) {
|
itemBuilder: (_, i) {
|
||||||
final c = children[i];
|
final c = children[i];
|
||||||
return AdminEnfantUserCard.fromSummary(
|
return EnfantUserCard.fromSummary(
|
||||||
c,
|
c,
|
||||||
onCardTap: () => onOpen(c),
|
onCardTap: () => onOpen(c),
|
||||||
actions: [
|
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 d’un dossier famille (#160).
|
||||||
|
enum DernierEnfantSuppressionChoice {
|
||||||
|
enfantSeul,
|
||||||
|
dossierAussi,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ligne d’impact (une personne / une fiche).
|
||||||
|
class SuppressionPersonLine {
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final String? role;
|
||||||
|
|
||||||
|
const SuppressionPersonLine({
|
||||||
|
required this.label,
|
||||||
|
this.icon = Icons.person_outline,
|
||||||
|
this.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory SuppressionPersonLine.parent(String label) => SuppressionPersonLine(
|
||||||
|
label: label,
|
||||||
|
icon: Icons.supervisor_account_outlined,
|
||||||
|
role: 'Parent',
|
||||||
|
);
|
||||||
|
|
||||||
|
factory SuppressionPersonLine.enfant(String label) => SuppressionPersonLine(
|
||||||
|
label: label,
|
||||||
|
icon: Icons.child_care_outlined,
|
||||||
|
role: 'Enfant',
|
||||||
|
);
|
||||||
|
|
||||||
|
factory SuppressionPersonLine.am(String label) => SuppressionPersonLine(
|
||||||
|
label: label,
|
||||||
|
icon: Icons.face,
|
||||||
|
role: 'AM',
|
||||||
|
);
|
||||||
|
|
||||||
|
factory SuppressionPersonLine.gestionnaire(String label) =>
|
||||||
|
SuppressionPersonLine(
|
||||||
|
label: label,
|
||||||
|
icon: Icons.assignment_ind_outlined,
|
||||||
|
role: 'Gestionnaire',
|
||||||
|
);
|
||||||
|
|
||||||
|
factory SuppressionPersonLine.administrateur(String label) =>
|
||||||
|
SuppressionPersonLine(
|
||||||
|
label: label,
|
||||||
|
icon: Icons.manage_accounts_outlined,
|
||||||
|
role: 'Admin',
|
||||||
|
);
|
||||||
|
|
||||||
|
factory SuppressionPersonLine.relais(String label) => SuppressionPersonLine(
|
||||||
|
label: label,
|
||||||
|
icon: Icons.apartment_outlined,
|
||||||
|
role: 'Relais',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget unique pour toutes les boîtes de confirmation de suppression (#160).
|
||||||
|
class SuppressionConfirmDialog extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final String? message;
|
||||||
|
final List<SuppressionPersonLine> people;
|
||||||
|
final List<String> footnotes;
|
||||||
|
final List<Widget> actions;
|
||||||
|
|
||||||
|
const SuppressionConfirmDialog({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
this.message,
|
||||||
|
this.people = const [],
|
||||||
|
this.footnotes = const [],
|
||||||
|
required this.actions,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Variante oui/non standard (Annuler / Supprimer).
|
||||||
|
static SuppressionConfirmDialog yesNo({
|
||||||
|
required String title,
|
||||||
|
String? subtitle,
|
||||||
|
String? message,
|
||||||
|
List<SuppressionPersonLine> people = const [],
|
||||||
|
List<String> footnotes = const [],
|
||||||
|
String confirmLabel = 'Supprimer',
|
||||||
|
required VoidCallback onCancel,
|
||||||
|
required VoidCallback onConfirm,
|
||||||
|
}) {
|
||||||
|
return SuppressionConfirmDialog(
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitle,
|
||||||
|
message: message,
|
||||||
|
people: people,
|
||||||
|
footnotes: footnotes,
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: onCancel, child: const Text('Annuler')),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: onConfirm,
|
||||||
|
style: _dangerButtonStyle,
|
||||||
|
child: Text(confirmLabel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ButtonStyle get _dangerButtonStyle => FilledButton.styleFrom(
|
||||||
|
backgroundColor: Colors.red.shade700,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return AlertDialog(
|
||||||
|
backgroundColor: const Color(0xFFF7F2FB),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
titlePadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
|
||||||
|
contentPadding: const EdgeInsets.fromLTRB(24, 12, 24, 8),
|
||||||
|
actionsPadding: const EdgeInsets.fromLTRB(16, 4, 16, 14),
|
||||||
|
title: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.delete_outline,
|
||||||
|
color: Colors.red.shade700,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: const Color(0xFF2F2F2F),
|
||||||
|
fontSize: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if ((subtitle ?? '').trim().isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
subtitle!.trim(),
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: const Color(0xFF6D4EA1),
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 420,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if ((message ?? '').trim().isNotEmpty) ...[
|
||||||
|
Text(
|
||||||
|
message!.trim(),
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Colors.black87,
|
||||||
|
height: 1.35,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (people.isNotEmpty || footnotes.isNotEmpty)
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
if (people.isNotEmpty) ...[
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.9),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: const Color(0xFFE5D8F2)),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < people.length; i++) ...[
|
||||||
|
if (i > 0)
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
indent: 44,
|
||||||
|
endIndent: 12,
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
),
|
||||||
|
_SuppressionPersonRow(line: people[i]),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (footnotes.isNotEmpty) const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
for (final note in footnotes)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 6),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: Icon(
|
||||||
|
Icons.info_outline,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.orange.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
note,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.black54,
|
||||||
|
height: 1.35,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: actions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SuppressionPersonRow extends StatelessWidget {
|
||||||
|
final SuppressionPersonLine line;
|
||||||
|
|
||||||
|
const _SuppressionPersonRow({required this.line});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(line.icon, size: 18, color: const Color(0xFF6D4EA1)),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
line.label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 14,
|
||||||
|
color: Color(0xFF2F2F2F),
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if ((line.role ?? '').trim().isNotEmpty) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFEDE5FA),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
line.role!.trim(),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF6D4EA1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Affiche [SuppressionConfirmDialog] et renvoie `true` si confirmé.
|
||||||
|
Future<bool> showSuppressionConfirmDialog(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
String? subtitle,
|
||||||
|
String? message,
|
||||||
|
List<SuppressionPersonLine> people = const [],
|
||||||
|
List<String> footnotes = const [],
|
||||||
|
String confirmLabel = 'Supprimer',
|
||||||
|
}) async {
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => SuppressionConfirmDialog.yesNo(
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitle,
|
||||||
|
message: message,
|
||||||
|
people: people,
|
||||||
|
footnotes: footnotes,
|
||||||
|
confirmLabel: confirmLabel,
|
||||||
|
onCancel: () => Navigator.of(ctx).pop(false),
|
||||||
|
onConfirm: () => Navigator.of(ctx).pop(true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return result == true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirmation delete dossier famille / AM avec liste nominative.
|
||||||
|
Future<bool> showDossierSuppressionConfirmDialog(
|
||||||
|
BuildContext context, {
|
||||||
|
required String numeroDossier,
|
||||||
|
required bool isFamille,
|
||||||
|
required List<SuppressionPersonLine> people,
|
||||||
|
String? fallbackSummary,
|
||||||
|
}) {
|
||||||
|
final num = numeroDossier.trim();
|
||||||
|
if (isFamille) {
|
||||||
|
return showSuppressionConfirmDialog(
|
||||||
|
context,
|
||||||
|
title: 'Supprimer le dossier',
|
||||||
|
subtitle: 'Dossier famille $num',
|
||||||
|
people: people,
|
||||||
|
footnotes: people.isEmpty && (fallbackSummary ?? '').isNotEmpty
|
||||||
|
? [fallbackSummary!]
|
||||||
|
: const [
|
||||||
|
'Tous les comptes et fiches listés seront définitivement '
|
||||||
|
'supprimés.',
|
||||||
|
'Les placements AM des enfants seront clos.',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return showSuppressionConfirmDialog(
|
||||||
|
context,
|
||||||
|
title: 'Supprimer le dossier',
|
||||||
|
subtitle: 'Dossier AM $num',
|
||||||
|
people: people,
|
||||||
|
footnotes: const [
|
||||||
|
'Le compte et le dossier AM seront supprimés.',
|
||||||
|
'Les enfants accueillis ne seront pas supprimés '
|
||||||
|
'(placements clos).',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dialog dernier enfant : deux actions métier.
|
||||||
|
Future<DernierEnfantSuppressionChoice?> showDernierEnfantSuppressionDialog(
|
||||||
|
BuildContext context, {
|
||||||
|
required String enfantName,
|
||||||
|
required String familleLabel,
|
||||||
|
required String numeroDossier,
|
||||||
|
String? amLabel,
|
||||||
|
}) {
|
||||||
|
final am = (amLabel ?? '').trim();
|
||||||
|
return showDialog<DernierEnfantSuppressionChoice>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => SuppressionConfirmDialog(
|
||||||
|
title: 'Dernier enfant du dossier',
|
||||||
|
subtitle: 'Dossier $numeroDossier'
|
||||||
|
'${familleLabel.isEmpty ? '' : ' · $familleLabel'}',
|
||||||
|
people: [SuppressionPersonLine.enfant(enfantName)],
|
||||||
|
footnotes: [
|
||||||
|
'« Enfant seulement » : le dossier reste sans enfant.',
|
||||||
|
'« Dossier aussi » : parents et dossier sont également '
|
||||||
|
'supprimés.',
|
||||||
|
if (am.isNotEmpty) 'Le placement chez $am sera clos.',
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(
|
||||||
|
DernierEnfantSuppressionChoice.enfantSeul,
|
||||||
|
),
|
||||||
|
child: const Text('Enfant seulement'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(
|
||||||
|
DernierEnfantSuppressionChoice.dossierAussi,
|
||||||
|
),
|
||||||
|
style: SuppressionConfirmDialog._dangerButtonStyle,
|
||||||
|
child: const Text('Dossier aussi'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notes d’impact pour suppression d’un enfant (AM optionnelle).
|
||||||
|
List<String> enfantSuppressionFootnotes({
|
||||||
|
required String? numeroDossier,
|
||||||
|
required String familleLabel,
|
||||||
|
String? amLabel,
|
||||||
|
}) {
|
||||||
|
final notes = <String>[];
|
||||||
|
final num = (numeroDossier ?? '').trim();
|
||||||
|
final famille = familleLabel.trim();
|
||||||
|
final am = (amLabel ?? '').trim();
|
||||||
|
if (num.isEmpty) {
|
||||||
|
notes.add('Supprimer définitivement cette fiche enfant.');
|
||||||
|
} else {
|
||||||
|
notes.add(
|
||||||
|
'L’enfant sera retiré du dossier de '
|
||||||
|
'${famille.isEmpty ? 'la famille' : famille}.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (am.isNotEmpty) {
|
||||||
|
notes.add('Le placement chez $am sera clos.');
|
||||||
|
}
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bouton poubelle compact pour les cartes liste.
|
||||||
|
Widget suppressionIconButton({
|
||||||
|
required VoidCallback? onPressed,
|
||||||
|
String tooltip = 'Supprimer',
|
||||||
|
}) {
|
||||||
|
return IconButton(
|
||||||
|
icon: Icon(Icons.delete_outline, color: Colors.red.shade700),
|
||||||
|
tooltip: tooltip,
|
||||||
|
onPressed: onPressed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construit les lignes parents / enfants depuis un dossier unifié.
|
||||||
|
List<SuppressionPersonLine> suppressionPeopleFromDossier({
|
||||||
|
required bool isFamille,
|
||||||
|
required List<({String nom, String prenom, String email})> parents,
|
||||||
|
required List<({String nom, String prenom})> enfants,
|
||||||
|
String? amName,
|
||||||
|
}) {
|
||||||
|
final lines = <SuppressionPersonLine>[];
|
||||||
|
if (isFamille) {
|
||||||
|
for (final p in parents) {
|
||||||
|
final label = formatDossierPersonLabel(
|
||||||
|
nom: p.nom,
|
||||||
|
prenom: p.prenom,
|
||||||
|
email: p.email,
|
||||||
|
);
|
||||||
|
if (label.isEmpty) continue;
|
||||||
|
lines.add(SuppressionPersonLine.parent(label));
|
||||||
|
}
|
||||||
|
for (final e in enfants) {
|
||||||
|
final label = formatDossierPersonLabel(nom: e.nom, prenom: e.prenom);
|
||||||
|
if (label.isEmpty) continue;
|
||||||
|
lines.add(SuppressionPersonLine.enfant(label));
|
||||||
|
}
|
||||||
|
} else if ((amName ?? '').trim().isNotEmpty) {
|
||||||
|
lines.add(SuppressionPersonLine.am(amName!.trim()));
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
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 {
|
class UserList extends StatelessWidget {
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
@@ -28,7 +28,7 @@ class UserList extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
AdminListState(
|
UserListState(
|
||||||
isLoading: isLoading,
|
isLoading: isLoading,
|
||||||
error: error,
|
error: error,
|
||||||
isEmpty: isEmpty,
|
isEmpty: isEmpty,
|
||||||
+2
-2
@@ -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/nir_utils.dart';
|
||||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||||
import 'package:p_tits_pas/utils/postal_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**.
|
/// Réglages des formulaires validation / wizard AM — **jouer sur ces 3 leviers**.
|
||||||
class ValidationFormMetrics {
|
class ValidationFormMetrics {
|
||||||
@@ -60,7 +60,7 @@ class ValidationFormMetrics {
|
|||||||
class ValidationDetailSection extends StatelessWidget {
|
class ValidationDetailSection extends StatelessWidget {
|
||||||
/// Si null ou vide, pas de bandeau titre (gain de place vertical, ex. wizard AM).
|
/// Si null ou vide, pas de bandeau titre (gain de place vertical, ex. wizard AM).
|
||||||
final String? title;
|
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é.
|
/// 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;
|
final List<int>? rowLayout;
|
||||||
+5
-5
@@ -1,23 +1,23 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class AdminDetailField {
|
class DetailField {
|
||||||
final String label;
|
final String label;
|
||||||
final String value;
|
final String value;
|
||||||
|
|
||||||
const AdminDetailField({
|
const DetailField({
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.value,
|
required this.value,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class AdminDetailModal extends StatelessWidget {
|
class DetailModal extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final String? subtitle;
|
final String? subtitle;
|
||||||
final List<AdminDetailField> fields;
|
final List<DetailField> fields;
|
||||||
final VoidCallback onEdit;
|
final VoidCallback onEdit;
|
||||||
final VoidCallback onDelete;
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
const AdminDetailModal({
|
const DetailModal({
|
||||||
super.key,
|
super.key,
|
||||||
required this.title,
|
required this.title,
|
||||||
this.subtitle,
|
this.subtitle,
|
||||||
+33
-4
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
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 l’icône.
|
/// Carte dossier unifiée (#153) — fond neutre, accent couleur sur l’icône.
|
||||||
class DossierListCard extends StatelessWidget {
|
class DossierListCard extends StatelessWidget {
|
||||||
@@ -9,6 +10,12 @@ class DossierListCard extends StatelessWidget {
|
|||||||
final VoidCallback onOpen;
|
final VoidCallback onOpen;
|
||||||
/// Photo AM (si absente → icône fallback).
|
/// Photo AM (si absente → icône fallback).
|
||||||
final String? photoUrl;
|
final String? photoUrl;
|
||||||
|
final VoidCallback? onDelete;
|
||||||
|
/// Warning vigilance (ex. dossier sans enfant #160).
|
||||||
|
final String? vigilanceTooltip;
|
||||||
|
/// Nombre d’enfants (famille) — affiché à côté des noms.
|
||||||
|
final int? enfantsCount;
|
||||||
|
final bool sansEnfant;
|
||||||
|
|
||||||
/// Lavande — Famille / Parents.
|
/// Lavande — Famille / Parents.
|
||||||
static const Color familleAccent = Color(0xFFB289C9);
|
static const Color familleAccent = Color(0xFFB289C9);
|
||||||
@@ -23,6 +30,10 @@ class DossierListCard extends StatelessWidget {
|
|||||||
required this.isFamille,
|
required this.isFamille,
|
||||||
required this.onOpen,
|
required this.onOpen,
|
||||||
this.photoUrl,
|
this.photoUrl,
|
||||||
|
this.onDelete,
|
||||||
|
this.vigilanceTooltip,
|
||||||
|
this.enfantsCount,
|
||||||
|
this.sansEnfant = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -31,23 +42,41 @@ class DossierListCard extends StatelessWidget {
|
|||||||
final num = numeroDossier.trim().isEmpty ? '–' : numeroDossier.trim();
|
final num = numeroDossier.trim().isEmpty ? '–' : numeroDossier.trim();
|
||||||
final names = namesLine.trim();
|
final names = namesLine.trim();
|
||||||
final avatar = (photoUrl ?? '').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,
|
title: num,
|
||||||
subtitleLines: names.isEmpty ? const [] : [names],
|
subtitleLines: subtitle,
|
||||||
avatarUrl: !isFamille && avatar.isNotEmpty ? avatar : null,
|
avatarUrl: !isFamille && avatar.isNotEmpty ? avatar : null,
|
||||||
fallbackIcon:
|
fallbackIcon:
|
||||||
isFamille ? Icons.family_restroom_outlined : Icons.face,
|
isFamille ? Icons.family_restroom_outlined : Icons.face,
|
||||||
// N° = titre neutre (comme Parents / AM) ; accent = icône seule.
|
// N° = titre neutre (comme Parents / AM) ; accent = icône seule.
|
||||||
avatarIconColor: accent,
|
avatarIconColor: accent,
|
||||||
infoColor: Colors.black87,
|
infoColor: emptyKids ? Colors.red.shade700 : Colors.black87,
|
||||||
onCardTap: onOpen,
|
onCardTap: onOpen,
|
||||||
|
vigilanceTooltip: emptyKids
|
||||||
|
? (vigilanceTooltip ??
|
||||||
|
'Aucun enfant rattaché à ce dossier famille')
|
||||||
|
: vigilanceTooltip,
|
||||||
|
borderColor: emptyKids ? Colors.red.shade300 : null,
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Ouvrir',
|
tooltip: 'Ouvrir',
|
||||||
icon: Icon(Icons.open_in_new, size: 20, color: accent),
|
icon: Icon(Icons.open_in_new, size: 20, color: accent),
|
||||||
onPressed: onOpen,
|
onPressed: onOpen,
|
||||||
),
|
),
|
||||||
|
if (onDelete != null)
|
||||||
|
suppressionIconButton(onPressed: onDelete),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+130
-4
@@ -1,9 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/models/dossier_list_item.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/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/dossier_list_card.dart';
|
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart';
|
import 'package:p_tits_pas/widgets/dashboard/dossier_list_card.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.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.
|
/// Onglet permanent « Dossiers » (#153) : pending en haut + liste unifiée en bas.
|
||||||
class DossiersManagementWidget extends StatefulWidget {
|
class DossiersManagementWidget extends StatefulWidget {
|
||||||
@@ -27,13 +30,21 @@ class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
|
|||||||
List<DossierListItem> _all = [];
|
List<DossierListItem> _all = [];
|
||||||
Set<String> _pendingNumeros = {};
|
Set<String> _pendingNumeros = {};
|
||||||
int _pendingRefreshTick = 0;
|
int _pendingRefreshTick = 0;
|
||||||
|
bool _canDelete = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_loadRights();
|
||||||
_loadAll();
|
_loadAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadRights() async {
|
||||||
|
final user = await AuthService.getCurrentUser();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _canDelete = canDeleteMetier(user?.role));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadAll() async {
|
Future<void> _loadAll() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_loading = true;
|
_loading = true;
|
||||||
@@ -42,12 +53,29 @@ class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
|
|||||||
try {
|
try {
|
||||||
final parents = await UserService.getParents();
|
final parents = await UserService.getParents();
|
||||||
final ams = await UserService.getAssistantesMaternelles();
|
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;
|
if (!mounted) return;
|
||||||
final items = <DossierListItem>[
|
final items = <DossierListItem>[
|
||||||
...DossierListItem.fromParents(parents),
|
...DossierListItem.fromParents(parents),
|
||||||
...DossierListItem.fromAssistantes(ams),
|
...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) {
|
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);
|
final byNum = a.numeroDossier.compareTo(b.numeroDossier);
|
||||||
if (byNum != 0) return byNum;
|
if (byNum != 0) return byNum;
|
||||||
return a.typeLabel.compareTo(b.typeLabel);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final query = widget.searchQuery;
|
final query = widget.searchQuery;
|
||||||
@@ -115,6 +232,7 @@ class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
|
|||||||
key: ValueKey('pending-$_pendingRefreshTick'),
|
key: ValueKey('pending-$_pendingRefreshTick'),
|
||||||
searchQuery: query,
|
searchQuery: query,
|
||||||
compactWhenEmpty: true,
|
compactWhenEmpty: true,
|
||||||
|
canDelete: _canDelete,
|
||||||
onPendingNumerosChanged: (nums) {
|
onPendingNumerosChanged: (nums) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _pendingNumeros = nums);
|
setState(() => _pendingNumeros = nums);
|
||||||
@@ -189,7 +307,15 @@ class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
|
|||||||
namesLine: item.namesLine,
|
namesLine: item.namesLine,
|
||||||
isFamille: item.isFamille,
|
isFamille: item.isFamille,
|
||||||
photoUrl: item.photoUrl,
|
photoUrl: item.photoUrl,
|
||||||
|
sansEnfant: item.sansEnfant,
|
||||||
|
enfantsCount: item.enfantsCount,
|
||||||
|
vigilanceTooltip: item.sansEnfant
|
||||||
|
? 'Aucun enfant rattaché à ce dossier famille'
|
||||||
|
: null,
|
||||||
onOpen: () => _openDossier(item.numeroDossier),
|
onOpen: () => _openDossier(item.numeroDossier),
|
||||||
|
onDelete: _canDelete
|
||||||
|
? () => _confirmDeleteDossier(item)
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
childCount: filtered.length,
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-8
@@ -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/date_display_utils.dart';
|
||||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||||
import 'package:p_tits_pas/utils/enfant_vigilance.dart';
|
import 'package:p_tits_pas/utils/enfant_vigilance.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
import 'package:p_tits_pas/widgets/dashboard/user_card.dart';
|
||||||
|
|
||||||
List<String> enfantAdminSubtitleLines({
|
List<String> enfantAdminSubtitleLines({
|
||||||
required String status,
|
required String status,
|
||||||
@@ -29,7 +29,7 @@ List<String> enfantAdminSubtitleLines({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Carte enfant admin (photo, nom, âge) — même rendu onglet Enfants / fiche parent.
|
/// Carte enfant admin (photo, nom, âge) — même rendu onglet Enfants / fiche parent.
|
||||||
class AdminEnfantUserCard extends StatelessWidget {
|
class EnfantUserCard extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final String? photoUrl;
|
final String? photoUrl;
|
||||||
final List<String> subtitleLines;
|
final List<String> subtitleLines;
|
||||||
@@ -40,7 +40,7 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
final Color? borderColor;
|
final Color? borderColor;
|
||||||
final String? vigilanceTooltip;
|
final String? vigilanceTooltip;
|
||||||
|
|
||||||
const AdminEnfantUserCard({
|
const EnfantUserCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.title,
|
required this.title,
|
||||||
this.photoUrl,
|
this.photoUrl,
|
||||||
@@ -53,7 +53,7 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
this.vigilanceTooltip,
|
this.vigilanceTooltip,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory AdminEnfantUserCard.fromEnfant(
|
factory EnfantUserCard.fromEnfant(
|
||||||
EnfantAdminModel enfant, {
|
EnfantAdminModel enfant, {
|
||||||
List<String> extraSubtitleLines = const [],
|
List<String> extraSubtitleLines = const [],
|
||||||
List<Widget> actions = const [],
|
List<Widget> actions = const [],
|
||||||
@@ -67,7 +67,7 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
.map((l) => l.parentName ?? 'Parent')
|
.map((l) => l.parentName ?? 'Parent')
|
||||||
.join(', ');
|
.join(', ');
|
||||||
final orphan = enfantHasNoResponsable(enfant);
|
final orphan = enfantHasNoResponsable(enfant);
|
||||||
return AdminEnfantUserCard(
|
return EnfantUserCard(
|
||||||
title: enfant.fullName,
|
title: enfant.fullName,
|
||||||
photoUrl: enfant.photoUrl,
|
photoUrl: enfant.photoUrl,
|
||||||
subtitleLines: enfantAdminSubtitleLines(
|
subtitleLines: enfantAdminSubtitleLines(
|
||||||
@@ -92,7 +92,7 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
factory AdminEnfantUserCard.fromSummary(
|
factory EnfantUserCard.fromSummary(
|
||||||
ParentChildSummary child, {
|
ParentChildSummary child, {
|
||||||
List<String> extraSubtitleLines = const [],
|
List<String> extraSubtitleLines = const [],
|
||||||
List<Widget> actions = const [],
|
List<Widget> actions = const [],
|
||||||
@@ -100,7 +100,7 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
EdgeInsetsGeometry? margin,
|
EdgeInsetsGeometry? margin,
|
||||||
EdgeInsetsGeometry? contentPadding,
|
EdgeInsetsGeometry? contentPadding,
|
||||||
}) {
|
}) {
|
||||||
return AdminEnfantUserCard(
|
return EnfantUserCard(
|
||||||
title: child.fullName,
|
title: child.fullName,
|
||||||
photoUrl: child.photoUrl,
|
photoUrl: child.photoUrl,
|
||||||
subtitleLines: enfantAdminSubtitleLines(
|
subtitleLines: enfantAdminSubtitleLines(
|
||||||
@@ -118,7 +118,7 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AdminUserCard(
|
return UserCard(
|
||||||
title: title,
|
title: title,
|
||||||
fallbackIcon: Icons.child_care_outlined,
|
fallbackIcon: Icons.child_care_outlined,
|
||||||
avatarUrl: photoUrl,
|
avatarUrl: photoUrl,
|
||||||
+64
-5
@@ -1,9 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
|
import 'package:p_tits_pas/widgets/dashboard/staff_user_form_modal.dart';
|
||||||
|
import 'package:p_tits_pas/services/auth_service.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/user_list.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 {
|
class GestionnaireManagementWidget extends StatefulWidget {
|
||||||
final String searchQuery;
|
final String searchQuery;
|
||||||
@@ -23,16 +26,28 @@ class _GestionnaireManagementWidgetState
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
List<AppUser> _gestionnaires = [];
|
List<AppUser> _gestionnaires = [];
|
||||||
|
bool _canDelete = false;
|
||||||
|
String? _currentUserId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_loadRights();
|
||||||
_loadGestionnaires();
|
_loadGestionnaires();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() => super.dispose();
|
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 {
|
Future<void> _loadGestionnaires() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
@@ -59,7 +74,7 @@ class _GestionnaireManagementWidgetState
|
|||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
return AdminUserFormDialog(initialUser: user);
|
return StaffUserFormModal(initialUser: user);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (changed == true) {
|
if (changed == 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final query = widget.searchQuery.toLowerCase();
|
final query = widget.searchQuery.toLowerCase();
|
||||||
@@ -84,7 +139,9 @@ class _GestionnaireManagementWidgetState
|
|||||||
itemCount: filteredGestionnaires.length,
|
itemCount: filteredGestionnaires.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final user = filteredGestionnaires[index];
|
final user = filteredGestionnaires[index];
|
||||||
return AdminUserCard(
|
final isSelf =
|
||||||
|
_currentUserId != null && _currentUserId == user.id;
|
||||||
|
return UserCard(
|
||||||
title: user.fullName,
|
title: user.fullName,
|
||||||
fallbackIcon: Icons.assignment_ind_outlined,
|
fallbackIcon: Icons.assignment_ind_outlined,
|
||||||
avatarUrl: user.photoUrl,
|
avatarUrl: user.photoUrl,
|
||||||
@@ -102,6 +159,8 @@ class _GestionnaireManagementWidgetState
|
|||||||
_openGestionnaireEditDialog(user);
|
_openGestionnaireEditDialog(user);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (_canDelete && !isSelf)
|
||||||
|
suppressionIconButton(onPressed: () => _confirmDelete(user)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
+1
-1
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:p_tits_pas/services/configuration_service.dart';
|
import 'package:p_tits_pas/services/configuration_service.dart';
|
||||||
import 'package:p_tits_pas/utils/email_utils.dart';
|
import 'package:p_tits_pas/utils/email_utils.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/relais_management_panel.dart';
|
import 'package:p_tits_pas/widgets/dashboard/relais_management_panel.dart';
|
||||||
|
|
||||||
/// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé.
|
/// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé.
|
||||||
class ParametresPanel extends StatefulWidget {
|
class ParametresPanel extends StatefulWidget {
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
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].
|
/// Modale de création dossier famille (#129) — même shell que [AmDossierCreateModal].
|
||||||
class ParentDossierCreateModal extends StatefulWidget {
|
class ParentDossierCreateModal extends StatefulWidget {
|
||||||
+6
-8
@@ -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/name_format_utils.dart';
|
||||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||||
import 'package:p_tits_pas/utils/postal_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/dashboard/am_photo_frame.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';
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_refus_form.dart';
|
import 'package:p_tits_pas/widgets/dashboard/validation_refus_form.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_valider_confirm_dialog.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/auth_network_image.dart';
|
||||||
import 'package:p_tits_pas/widgets/common/identity_block.dart';
|
import 'package:p_tits_pas/widgets/common/identity_block.dart';
|
||||||
|
|
||||||
@@ -860,7 +860,7 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: pw,
|
width: pw,
|
||||||
height: ph,
|
height: ph,
|
||||||
child: AdminAmPhotoFrame(
|
child: AmPhotoFrame(
|
||||||
photoUrl: child.photoBytes == null
|
photoUrl: child.photoBytes == null
|
||||||
? child.existingPhotoUrl
|
? child.existingPhotoUrl
|
||||||
: null,
|
: null,
|
||||||
@@ -1466,7 +1466,6 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
final map = <String, dynamic>{
|
final map = <String, dynamic>{
|
||||||
'genre': c.genre,
|
'genre': c.genre,
|
||||||
'consent_photo': true,
|
'consent_photo': true,
|
||||||
'grossesse_multiple': false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (prenom.length >= 2) {
|
if (prenom.length >= 2) {
|
||||||
@@ -1723,7 +1722,6 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
'status': status,
|
'status': status,
|
||||||
'gender': gender,
|
'gender': gender,
|
||||||
'consent_photo': true,
|
'consent_photo': true,
|
||||||
'is_multiple': false,
|
|
||||||
};
|
};
|
||||||
if (prenom.length >= 2) map['first_name'] = prenom;
|
if (prenom.length >= 2) map['first_name'] = prenom;
|
||||||
if (nom.length >= 2) map['last_name'] = nom;
|
if (nom.length >= 2) map['last_name'] = nom;
|
||||||
+14
-14
@@ -4,30 +4,30 @@ import 'package:p_tits_pas/models/parent_child_summary.dart';
|
|||||||
import 'package:p_tits_pas/models/parent_model.dart';
|
import 'package:p_tits_pas/models/parent_model.dart';
|
||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
|
import 'package:p_tits_pas/widgets/dashboard/child_detail_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart';
|
import 'package:p_tits_pas/widgets/dashboard/children_affiliation_panel.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart';
|
import 'package:p_tits_pas/widgets/dashboard/select_enfant_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.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/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).
|
/// Fiche parent éditable (doc 28 §6.1, tickets #131 / #138).
|
||||||
/// Shell et typo alignés sur [ValidationDossierModal] / wizards validation.
|
/// Shell et typo alignés sur [ValidationDossierModal] / wizards validation.
|
||||||
class AdminParentEditModal extends StatefulWidget {
|
class ParentEditModal extends StatefulWidget {
|
||||||
final ParentModel parent;
|
final ParentModel parent;
|
||||||
final VoidCallback? onSaved;
|
final VoidCallback? onSaved;
|
||||||
|
|
||||||
const AdminParentEditModal({
|
const ParentEditModal({
|
||||||
super.key,
|
super.key,
|
||||||
required this.parent,
|
required this.parent,
|
||||||
this.onSaved,
|
this.onSaved,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@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 _nomCtrl;
|
||||||
late final TextEditingController _prenomCtrl;
|
late final TextEditingController _prenomCtrl;
|
||||||
late final TextEditingController _emailCtrl;
|
late final TextEditingController _emailCtrl;
|
||||||
@@ -129,7 +129,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AdminParentEditModal(
|
builder: (ctx) => ParentEditModal(
|
||||||
parent: parent,
|
parent: parent,
|
||||||
onSaved: () async {
|
onSaved: () async {
|
||||||
try {
|
try {
|
||||||
@@ -244,7 +244,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AdminChildDetailModal(
|
builder: (ctx) => ChildDetailModal(
|
||||||
enfant: enfant,
|
enfant: enfant,
|
||||||
onSaved: _reloadChildren,
|
onSaved: _reloadChildren,
|
||||||
),
|
),
|
||||||
@@ -322,7 +322,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
|
|
||||||
Future<void> _attachChild() async {
|
Future<void> _attachChild() async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final selected = await AdminSelectEnfantModal.show(
|
final selected = await SelectEnfantModal.show(
|
||||||
context,
|
context,
|
||||||
excludeIds: _children.map((c) => c.id).toSet(),
|
excludeIds: _children.map((c) => c.id).toSet(),
|
||||||
title: 'Rattacher un enfant',
|
title: 'Rattacher un enfant',
|
||||||
@@ -350,7 +350,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _childrenPanel() {
|
Widget _childrenPanel() {
|
||||||
return AdminChildrenAffiliationPanel(
|
return ChildrenAffiliationPanel(
|
||||||
children: _children,
|
children: _children,
|
||||||
scrollController: _childrenScrollCtrl,
|
scrollController: _childrenScrollCtrl,
|
||||||
onOpen: _openChild,
|
onOpen: _openChild,
|
||||||
@@ -437,7 +437,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 160,
|
width: 160,
|
||||||
child: AdminStatusCapsule(
|
child: StatusCapsule(
|
||||||
statut: _statut,
|
statut: _statut,
|
||||||
onChanged: (v) => setState(() {
|
onChanged: (v) => setState(() {
|
||||||
_statut = v;
|
_statut = v;
|
||||||
+80
-5
@@ -1,9 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/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/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart';
|
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
import 'package:p_tits_pas/widgets/dashboard/parent_edit_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/user_list.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 {
|
class ParentManagementWidget extends StatefulWidget {
|
||||||
final String searchQuery;
|
final String searchQuery;
|
||||||
@@ -23,16 +27,24 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
List<ParentModel> _parents = [];
|
List<ParentModel> _parents = [];
|
||||||
|
bool _canDelete = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_loadRights();
|
||||||
_loadParents();
|
_loadParents();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() => super.dispose();
|
void dispose() => super.dispose();
|
||||||
|
|
||||||
|
Future<void> _loadRights() async {
|
||||||
|
final user = await AuthService.getCurrentUser();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _canDelete = canDeleteMetier(user?.role));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadParents() async {
|
Future<void> _loadParents() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final query = widget.searchQuery.toLowerCase();
|
final query = widget.searchQuery.toLowerCase();
|
||||||
@@ -73,7 +146,7 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
|||||||
itemCount: filteredParents.length,
|
itemCount: filteredParents.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final parent = filteredParents[index];
|
final parent = filteredParents[index];
|
||||||
return AdminUserCard(
|
return UserCard(
|
||||||
title: parent.user.fullName,
|
title: parent.user.fullName,
|
||||||
fallbackIcon: Icons.supervisor_account_outlined,
|
fallbackIcon: Icons.supervisor_account_outlined,
|
||||||
avatarUrl: parent.user.photoUrl,
|
avatarUrl: parent.user.photoUrl,
|
||||||
@@ -90,6 +163,8 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
|||||||
_openParentDetails(parent);
|
_openParentDetails(parent);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (_canDelete)
|
||||||
|
suppressionIconButton(onPressed: () => _confirmDelete(parent)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -114,7 +189,7 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
|||||||
void _openParentDetails(ParentModel parent) {
|
void _openParentDetails(ParentModel parent) {
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AdminParentEditModal(
|
builder: (context) => ParentEditModal(
|
||||||
parent: parent,
|
parent: parent,
|
||||||
onSaved: _loadParents,
|
onSaved: _loadParents,
|
||||||
),
|
),
|
||||||
+115
-4
@@ -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/user.dart';
|
||||||
import 'package:p_tits_pas/models/pending_family.dart';
|
import 'package:p_tits_pas/models/pending_family.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/dossier_list_card.dart';
|
import 'package:p_tits_pas/widgets/dashboard/dossier_list_card.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.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.
|
/// Section « dossiers à valider » (liste unifiée AM + familles). Ticket #107 / #153.
|
||||||
class PendingValidationWidget extends StatefulWidget {
|
class PendingValidationWidget extends StatefulWidget {
|
||||||
@@ -15,6 +16,8 @@ class PendingValidationWidget extends StatefulWidget {
|
|||||||
final bool compactWhenEmpty;
|
final bool compactWhenEmpty;
|
||||||
/// Numéros des dossiers pending (pour exclure de « Tous les dossiers »).
|
/// Numéros des dossiers pending (pour exclure de « Tous les dossiers »).
|
||||||
final ValueChanged<Set<String>>? onPendingNumerosChanged;
|
final ValueChanged<Set<String>>? onPendingNumerosChanged;
|
||||||
|
/// Afficher la poubelle (#160) — mêmes règles que dossiers validés.
|
||||||
|
final bool canDelete;
|
||||||
|
|
||||||
const PendingValidationWidget({
|
const PendingValidationWidget({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -22,6 +25,7 @@ class PendingValidationWidget extends StatefulWidget {
|
|||||||
this.searchQuery = '',
|
this.searchQuery = '',
|
||||||
this.compactWhenEmpty = false,
|
this.compactWhenEmpty = false,
|
||||||
this.onPendingNumerosChanged,
|
this.onPendingNumerosChanged,
|
||||||
|
this.canDelete = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@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) {
|
bool _matchesQuery(String haystack) {
|
||||||
final q = widget.searchQuery.trim().toLowerCase();
|
final q = widget.searchQuery.trim().toLowerCase();
|
||||||
if (q.isEmpty) return true;
|
if (q.isEmpty) return true;
|
||||||
@@ -286,12 +381,21 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAMCard(AppUser user) {
|
Widget _buildAMCard(AppUser user) {
|
||||||
|
final names = _amNamesLine(user);
|
||||||
|
final num = user.numeroDossier ?? '';
|
||||||
return DossierListCard(
|
return DossierListCard(
|
||||||
numeroDossier: user.numeroDossier ?? '',
|
numeroDossier: num,
|
||||||
namesLine: _amNamesLine(user),
|
namesLine: names,
|
||||||
isFamille: false,
|
isFamille: false,
|
||||||
photoUrl: user.photoUrl,
|
photoUrl: user.photoUrl,
|
||||||
onOpen: () => _onOpenValidation(numeroDossier: user.numeroDossier),
|
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,
|
namesLine: names,
|
||||||
isFamille: true,
|
isFamille: true,
|
||||||
onOpen: () => _onOpenValidation(numeroDossier: family.numeroDossier),
|
onOpen: () => _onOpenValidation(numeroDossier: family.numeroDossier),
|
||||||
|
onDelete: widget.canDelete
|
||||||
|
? () => _confirmDeletePending(
|
||||||
|
numeroDossier: num,
|
||||||
|
namesLine: names,
|
||||||
|
isFamille: true,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+8
-19
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:p_tits_pas/models/relais_model.dart';
|
import 'package:p_tits_pas/models/relais_model.dart';
|
||||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||||
import 'package:p_tits_pas/services/relais_service.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 {
|
class RelaisManagementPanel extends StatefulWidget {
|
||||||
const RelaisManagementPanel({super.key});
|
const RelaisManagementPanel({super.key});
|
||||||
@@ -56,28 +57,16 @@ class _RelaisManagementPanelState extends State<RelaisManagementPanel> {
|
|||||||
try {
|
try {
|
||||||
if (result.action == _RelaisDialogAction.delete) {
|
if (result.action == _RelaisDialogAction.delete) {
|
||||||
if (relais == null) return;
|
if (relais == null) return;
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showSuppressionConfirmDialog(
|
||||||
context: context,
|
context,
|
||||||
builder: (ctx) => AlertDialog(
|
title: 'Supprimer le relais',
|
||||||
title: const Text('Supprimer le relais'),
|
people: [SuppressionPersonLine.relais(relais.nom)],
|
||||||
content: Text('Confirmer la suppression de "${relais.nom}" ?'),
|
footnotes: const [
|
||||||
actions: [
|
'Cette action est irréversible.',
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(ctx).pop(false),
|
|
||||||
child: const Text('Annuler'),
|
|
||||||
),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: () => Navigator.of(ctx).pop(true),
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: Colors.red.shade700,
|
|
||||||
),
|
|
||||||
child: const Text('Supprimer'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed != true) return;
|
if (!confirmed) return;
|
||||||
await RelaisService.deleteRelais(relais.id);
|
await RelaisService.deleteRelais(relais.id);
|
||||||
} else if (relais == null) {
|
} else if (relais == null) {
|
||||||
await RelaisService.createRelais(result.payload!);
|
await RelaisService.createRelais(result.payload!);
|
||||||
+11
-11
@@ -3,10 +3,10 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/utils/am_vigilance.dart';
|
import 'package:p_tits_pas/utils/am_vigilance.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
|
import 'package:p_tits_pas/widgets/dashboard/am_edit_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
|
import 'package:p_tits_pas/widgets/dashboard/select_list_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
import 'package:p_tits_pas/widgets/dashboard/user_card.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
import 'package:p_tits_pas/widgets/dashboard/validation_modal_theme.dart';
|
||||||
|
|
||||||
List<String> _amSelectSubtitleLines(AssistanteMaternelleModel am) {
|
List<String> _amSelectSubtitleLines(AssistanteMaternelleModel am) {
|
||||||
final lines = <String>[];
|
final lines = <String>[];
|
||||||
@@ -28,22 +28,22 @@ List<String> _amSelectSubtitleLines(AssistanteMaternelleModel am) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sélection d'une AM à rattacher (fiche enfant) — ticket #147.
|
/// Sélection d'une AM à rattacher (fiche enfant) — ticket #147.
|
||||||
/// S'appuie sur [AdminSelectListModal] (shell partagé avec #146).
|
/// S'appuie sur [SelectListModal] (shell partagé avec #146).
|
||||||
class AdminSelectAmModal {
|
class SelectAmModal {
|
||||||
AdminSelectAmModal._();
|
SelectAmModal._();
|
||||||
|
|
||||||
static Future<AssistanteMaternelleModel?> show(
|
static Future<AssistanteMaternelleModel?> show(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
Set<String> excludeIds = const {},
|
Set<String> excludeIds = const {},
|
||||||
String title = 'Choisir une assistante maternelle',
|
String title = 'Choisir une assistante maternelle',
|
||||||
}) {
|
}) {
|
||||||
return AdminSelectListModal.show<AssistanteMaternelleModel>(
|
return SelectListModal.show<AssistanteMaternelleModel>(
|
||||||
context,
|
context,
|
||||||
title: title,
|
title: title,
|
||||||
searchHint: 'Rechercher par nom, prénom ou zone…',
|
searchHint: 'Rechercher par nom, prénom ou zone…',
|
||||||
emptyMessage: 'Aucune assistante maternelle disponible',
|
emptyMessage: 'Aucune assistante maternelle disponible',
|
||||||
noResultsMessage: 'Aucune AM avec place libre pour cette recherche',
|
noResultsMessage: 'Aucune AM avec place libre pour cette recherche',
|
||||||
toggleFilter: const AdminSelectToggleFilter<AssistanteMaternelleModel>(
|
toggleFilter: const SelectToggleFilter<AssistanteMaternelleModel>(
|
||||||
label: 'Libre',
|
label: 'Libre',
|
||||||
initialValue: true,
|
initialValue: true,
|
||||||
whenEnabled: amHasFreePlace,
|
whenEnabled: amHasFreePlace,
|
||||||
@@ -76,7 +76,7 @@ class AdminSelectAmModal {
|
|||||||
_resolveAmSelection(ctx, am, reload),
|
_resolveAmSelection(ctx, am, reload),
|
||||||
itemBuilder: (context, am, onSelect) {
|
itemBuilder: (context, am, onSelect) {
|
||||||
final full = !amHasFreePlace(am);
|
final full = !amHasFreePlace(am);
|
||||||
return AdminUserCard(
|
return UserCard(
|
||||||
title: am.user.fullName,
|
title: am.user.fullName,
|
||||||
avatarUrl: am.user.photoUrl,
|
avatarUrl: am.user.photoUrl,
|
||||||
fallbackIcon: Icons.face,
|
fallbackIcon: Icons.face,
|
||||||
@@ -150,7 +150,7 @@ class _AmNoPlaceWarningDialogState extends State<_AmNoPlaceWarningDialog> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AdminAmEditModal(
|
builder: (ctx) => AmEditModal(
|
||||||
assistante: _am,
|
assistante: _am,
|
||||||
onSaved: () async {
|
onSaved: () async {
|
||||||
try {
|
try {
|
||||||
+8
-8
@@ -2,13 +2,13 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart';
|
import 'package:p_tits_pas/widgets/dashboard/enfant_user_card.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.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élection d'un enfant à rattacher (fiche AM / fiche parent) — ticket #146.
|
||||||
/// S'appuie sur [AdminSelectListModal] (shell partagé avec #147).
|
/// S'appuie sur [SelectListModal] (shell partagé avec #147).
|
||||||
class AdminSelectEnfantModal {
|
class SelectEnfantModal {
|
||||||
AdminSelectEnfantModal._();
|
SelectEnfantModal._();
|
||||||
|
|
||||||
static Future<EnfantAdminModel?> show(
|
static Future<EnfantAdminModel?> show(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
@@ -17,7 +17,7 @@ class AdminSelectEnfantModal {
|
|||||||
/// Affiché uniquement depuis la fiche AM : filtre les enfants déjà en garde.
|
/// Affiché uniquement depuis la fiche AM : filtre les enfants déjà en garde.
|
||||||
bool showSansGardeFilter = false,
|
bool showSansGardeFilter = false,
|
||||||
}) {
|
}) {
|
||||||
return AdminSelectListModal.show<EnfantAdminModel>(
|
return SelectListModal.show<EnfantAdminModel>(
|
||||||
context,
|
context,
|
||||||
title: title,
|
title: title,
|
||||||
searchHint: 'Rechercher par nom ou prénom…',
|
searchHint: 'Rechercher par nom ou prénom…',
|
||||||
@@ -26,7 +26,7 @@ class AdminSelectEnfantModal {
|
|||||||
? 'Aucun enfant sans garde pour cette recherche'
|
? 'Aucun enfant sans garde pour cette recherche'
|
||||||
: 'Aucun résultat pour cette recherche',
|
: 'Aucun résultat pour cette recherche',
|
||||||
toggleFilter: showSansGardeFilter
|
toggleFilter: showSansGardeFilter
|
||||||
? AdminSelectToggleFilter<EnfantAdminModel>(
|
? SelectToggleFilter<EnfantAdminModel>(
|
||||||
label: 'Sans garde',
|
label: 'Sans garde',
|
||||||
initialValue: true,
|
initialValue: true,
|
||||||
whenEnabled: (e) =>
|
whenEnabled: (e) =>
|
||||||
@@ -50,7 +50,7 @@ class AdminSelectEnfantModal {
|
|||||||
return name.contains(q) || fn.contains(q) || ln.contains(q);
|
return name.contains(q) || fn.contains(q) || ln.contains(q);
|
||||||
},
|
},
|
||||||
itemBuilder: (context, e, onSelect) {
|
itemBuilder: (context, e, onSelect) {
|
||||||
return AdminEnfantUserCard.fromEnfant(
|
return EnfantUserCard.fromEnfant(
|
||||||
e,
|
e,
|
||||||
onCardTap: onSelect,
|
onCardTap: onSelect,
|
||||||
margin: const EdgeInsets.only(bottom: 4),
|
margin: const EdgeInsets.only(bottom: 4),
|
||||||
+12
-12
@@ -1,11 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/models/parent_model.dart';
|
import 'package:p_tits_pas/models/parent_model.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
|
import 'package:p_tits_pas/widgets/dashboard/select_list_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
import 'package:p_tits_pas/widgets/dashboard/user_card.dart';
|
||||||
|
|
||||||
/// Foyer / famille sélectionnable pour rattacher un nouvel enfant (#132 / #157).
|
/// Foyer / famille sélectionnable pour rattacher un nouvel enfant (#132 / #157).
|
||||||
class AdminFamilleFoyer {
|
class FamilleFoyer {
|
||||||
/// Parent pivot pour `POST /enfants` (`parent_user_id`).
|
/// Parent pivot pour `POST /enfants` (`parent_user_id`).
|
||||||
final String pivotParentUserId;
|
final String pivotParentUserId;
|
||||||
/// Co-parent éventuel (rattachement foyer #157).
|
/// Co-parent éventuel (rattachement foyer #157).
|
||||||
@@ -14,7 +14,7 @@ class AdminFamilleFoyer {
|
|||||||
final String displayTitle;
|
final String displayTitle;
|
||||||
final List<String> parentNames;
|
final List<String> parentNames;
|
||||||
|
|
||||||
const AdminFamilleFoyer({
|
const FamilleFoyer({
|
||||||
required this.pivotParentUserId,
|
required this.pivotParentUserId,
|
||||||
required this.displayTitle,
|
required this.displayTitle,
|
||||||
required this.parentNames,
|
required this.parentNames,
|
||||||
@@ -42,10 +42,10 @@ class AdminFamilleFoyer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Construit la liste des foyers uniques à partir de `GET /parents`.
|
/// 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 seenDossiers = <String>{};
|
||||||
final seenUserIds = <String>{};
|
final seenUserIds = <String>{};
|
||||||
final foyers = <AdminFamilleFoyer>[];
|
final foyers = <FamilleFoyer>[];
|
||||||
|
|
||||||
for (final p in parents) {
|
for (final p in parents) {
|
||||||
final dossier = (p.user.numeroDossier ?? '').trim();
|
final dossier = (p.user.numeroDossier ?? '').trim();
|
||||||
@@ -70,7 +70,7 @@ List<AdminFamilleFoyer> buildFamilleFoyers(List<ParentModel> parents) {
|
|||||||
: (names.isNotEmpty ? names.first : 'Famille');
|
: (names.isNotEmpty ? names.first : 'Famille');
|
||||||
|
|
||||||
foyers.add(
|
foyers.add(
|
||||||
AdminFamilleFoyer(
|
FamilleFoyer(
|
||||||
pivotParentUserId: p.user.id,
|
pivotParentUserId: p.user.id,
|
||||||
coParentUserId: co?.id,
|
coParentUserId: co?.id,
|
||||||
numeroDossier: dossier.isNotEmpty ? dossier : null,
|
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.
|
/// Sélection d'une famille / dossier pour créer un enfant — ticket #132.
|
||||||
class AdminSelectFamilleModal {
|
class SelectFamilleModal {
|
||||||
AdminSelectFamilleModal._();
|
SelectFamilleModal._();
|
||||||
|
|
||||||
static Future<AdminFamilleFoyer?> show(
|
static Future<FamilleFoyer?> show(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
String title = 'Choisir une famille',
|
String title = 'Choisir une famille',
|
||||||
}) {
|
}) {
|
||||||
return AdminSelectListModal.show<AdminFamilleFoyer>(
|
return SelectListModal.show<FamilleFoyer>(
|
||||||
context,
|
context,
|
||||||
title: title,
|
title: title,
|
||||||
searchHint: 'Rechercher par dossier, nom…',
|
searchHint: 'Rechercher par dossier, nom…',
|
||||||
@@ -111,7 +111,7 @@ class AdminSelectFamilleModal {
|
|||||||
return dossier.contains(q) || title.contains(q) || names.contains(q);
|
return dossier.contains(q) || title.contains(q) || names.contains(q);
|
||||||
},
|
},
|
||||||
itemBuilder: (context, f, onSelect) {
|
itemBuilder: (context, f, onSelect) {
|
||||||
return AdminUserCard(
|
return UserCard(
|
||||||
title: f.displayTitle,
|
title: f.displayTitle,
|
||||||
fallbackIcon: Icons.family_restroom,
|
fallbackIcon: Icons.family_restroom,
|
||||||
subtitleLines: [
|
subtitleLines: [
|
||||||
+11
-11
@@ -1,8 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
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.
|
/// Filtre optionnel (switch) sur la même ligne que la barre de recherche.
|
||||||
class AdminSelectToggleFilter<T> {
|
class SelectToggleFilter<T> {
|
||||||
final String label;
|
final String label;
|
||||||
final bool initialValue;
|
final bool initialValue;
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ class AdminSelectToggleFilter<T> {
|
|||||||
/// [whenEnabled] renvoie `true`.
|
/// [whenEnabled] renvoie `true`.
|
||||||
final bool Function(T item) whenEnabled;
|
final bool Function(T item) whenEnabled;
|
||||||
|
|
||||||
const AdminSelectToggleFilter({
|
const SelectToggleFilter({
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.whenEnabled,
|
required this.whenEnabled,
|
||||||
this.initialValue = true,
|
this.initialValue = true,
|
||||||
@@ -19,7 +19,7 @@ class AdminSelectToggleFilter<T> {
|
|||||||
|
|
||||||
/// Shell générique « rechercher + liste + sélection » pour les modales admin.
|
/// Shell générique « rechercher + liste + sélection » pour les modales admin.
|
||||||
/// Utilisé par la sélection d'enfant (#146) et la sélection d'AM (#147).
|
/// Utilisé par la sélection d'enfant (#146) et la sélection d'AM (#147).
|
||||||
class AdminSelectListModal<T> extends StatefulWidget {
|
class SelectListModal<T> extends StatefulWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final String searchHint;
|
final String searchHint;
|
||||||
final Future<List<T>> Function() loadItems;
|
final Future<List<T>> Function() loadItems;
|
||||||
@@ -40,7 +40,7 @@ class AdminSelectListModal<T> extends StatefulWidget {
|
|||||||
final int minVisibleCards;
|
final int minVisibleCards;
|
||||||
|
|
||||||
/// Switch optionnel à droite du champ de recherche (ex. « Sans garde », « Libre »).
|
/// Switch optionnel à droite du champ de recherche (ex. « Sans garde », « Libre »).
|
||||||
final AdminSelectToggleFilter<T>? toggleFilter;
|
final SelectToggleFilter<T>? toggleFilter;
|
||||||
|
|
||||||
/// Si fourni, appelé avant de valider la sélection.
|
/// Si fourni, appelé avant de valider la sélection.
|
||||||
/// Retourne l'élément à pop (éventuellement rafraîchi), ou `null` pour annuler.
|
/// Retourne l'élément à pop (éventuellement rafraîchi), ou `null` pour annuler.
|
||||||
@@ -50,7 +50,7 @@ class AdminSelectListModal<T> extends StatefulWidget {
|
|||||||
Future<void> Function() reload,
|
Future<void> Function() reload,
|
||||||
)? resolveSelect;
|
)? resolveSelect;
|
||||||
|
|
||||||
const AdminSelectListModal({
|
const SelectListModal({
|
||||||
super.key,
|
super.key,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.loadItems,
|
required this.loadItems,
|
||||||
@@ -82,7 +82,7 @@ class AdminSelectListModal<T> extends StatefulWidget {
|
|||||||
double modalWidth = 930,
|
double modalWidth = 930,
|
||||||
double cardExtent = 52,
|
double cardExtent = 52,
|
||||||
int minVisibleCards = 8,
|
int minVisibleCards = 8,
|
||||||
AdminSelectToggleFilter<T>? toggleFilter,
|
SelectToggleFilter<T>? toggleFilter,
|
||||||
Future<T?> Function(
|
Future<T?> Function(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
T item,
|
T item,
|
||||||
@@ -91,7 +91,7 @@ class AdminSelectListModal<T> extends StatefulWidget {
|
|||||||
}) {
|
}) {
|
||||||
return showDialog<T>(
|
return showDialog<T>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AdminSelectListModal<T>(
|
builder: (ctx) => SelectListModal<T>(
|
||||||
title: title,
|
title: title,
|
||||||
loadItems: loadItems,
|
loadItems: loadItems,
|
||||||
matchesQuery: matchesQuery,
|
matchesQuery: matchesQuery,
|
||||||
@@ -109,11 +109,11 @@ class AdminSelectListModal<T> extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AdminSelectListModal<T>> createState() =>
|
State<SelectListModal<T>> createState() =>
|
||||||
_AdminSelectListModalState<T>();
|
_SelectListModalState<T>();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AdminSelectListModalState<T> extends State<AdminSelectListModal<T>> {
|
class _SelectListModalState<T> extends State<SelectListModal<T>> {
|
||||||
final _searchCtrl = TextEditingController();
|
final _searchCtrl = TextEditingController();
|
||||||
List<T> _all = [];
|
List<T> _all = [];
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user