Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0029c5ab86 | ||
|
|
2ce9e9215f | ||
|
|
3fdd913367 | ||
|
|
6708f73b06 | ||
|
|
f596f062a6 | ||
|
|
86701731e3 | ||
|
|
b4abb7d6de | ||
|
|
cb5c1a5518 | ||
|
|
30ca99fb65 |
@@ -29,7 +29,7 @@ import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||
ParentsChildren,
|
||||
]),
|
||||
forwardRef(() => UserModule),
|
||||
ParentsModule,
|
||||
forwardRef(() => ParentsModule),
|
||||
DossiersModule,
|
||||
AppConfigModule,
|
||||
MailModule,
|
||||
|
||||
@@ -416,11 +416,20 @@ export class AuthService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Inscription Parent COMPLÈTE - Workflow CDC 6 étapes en 1 transaction
|
||||
* Gère : Parent 1 + Parent 2 (opt) + Enfants + Présentation + CGU
|
||||
* Cœur partagé création dossier parent (#129).
|
||||
* - public : statut en_attente + mails pending
|
||||
* - staff : statut actif + mails création MDP (pas de mail « dossier en attente »)
|
||||
*/
|
||||
async inscrireParentComplet(dto: RegisterParentCompletDto) {
|
||||
if (!dto.acceptation_cgu || !dto.acceptation_privacy) {
|
||||
async createParentDossier(
|
||||
dto: RegisterParentCompletDto,
|
||||
options: {
|
||||
statut: StatutUtilisateurType;
|
||||
sendPendingEmail: boolean;
|
||||
sendPasswordSetupEmail: boolean;
|
||||
requireCgu: boolean;
|
||||
},
|
||||
) {
|
||||
if (options.requireCgu && (!dto.acceptation_cgu || !dto.acceptation_privacy)) {
|
||||
throw new BadRequestException('L\'acceptation des CGU et de la politique de confidentialité est obligatoire');
|
||||
}
|
||||
|
||||
@@ -471,7 +480,7 @@ export class AuthService {
|
||||
prenom: dto.prenom,
|
||||
nom: dto.nom,
|
||||
role: RoleType.PARENT,
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
statut: options.statut,
|
||||
telephone: dto.telephone,
|
||||
adresse: dto.adresse,
|
||||
code_postal: dto.code_postal,
|
||||
@@ -496,7 +505,7 @@ export class AuthService {
|
||||
prenom: dto.co_parent_prenom,
|
||||
nom: dto.co_parent_nom,
|
||||
role: RoleType.PARENT,
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
statut: options.statut,
|
||||
telephone: dto.co_parent_telephone,
|
||||
adresse: dto.co_parent_meme_adresse ? dto.adresse : dto.co_parent_adresse,
|
||||
code_postal: dto.co_parent_meme_adresse ? dto.code_postal : dto.co_parent_code_postal,
|
||||
@@ -612,6 +621,7 @@ export class AuthService {
|
||||
|
||||
const numeroDossier = resultat.parent1.numero_dossier ?? '';
|
||||
|
||||
if (options.sendPendingEmail) {
|
||||
try {
|
||||
await this.mailService.sendRegistrationPendingEmail(
|
||||
resultat.parent1.email,
|
||||
@@ -629,21 +639,85 @@ export class AuthService {
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
"[inscrireParentComplet] Échec envoi email d'accusé de réception (inscription conservée)",
|
||||
"[createParentDossier] Échec envoi email d'accusé de réception (inscription conservée)",
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.sendPasswordSetupEmail) {
|
||||
try {
|
||||
await this.mailService.sendValidatedAccountPasswordSetupEmail(
|
||||
{
|
||||
email: resultat.parent1.email,
|
||||
prenom: resultat.parent1.prenom ?? '',
|
||||
nom: resultat.parent1.nom ?? '',
|
||||
token: resultat.tokenCreationMdp,
|
||||
numeroDossier,
|
||||
},
|
||||
'parent',
|
||||
);
|
||||
if (resultat.parent2 && resultat.tokenCoParent) {
|
||||
await this.mailService.sendValidatedAccountPasswordSetupEmail(
|
||||
{
|
||||
email: resultat.parent2.email,
|
||||
prenom: resultat.parent2.prenom ?? '',
|
||||
nom: resultat.parent2.nom ?? '',
|
||||
token: resultat.tokenCoParent,
|
||||
numeroDossier,
|
||||
},
|
||||
'parent',
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'[createParentDossier] Échec envoi email création MDP (dossier conservé)',
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const message = options.sendPasswordSetupEmail
|
||||
? 'Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.'
|
||||
: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.';
|
||||
|
||||
return {
|
||||
message: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
|
||||
message,
|
||||
parent_id: resultat.parent1.id,
|
||||
co_parent_id: resultat.parent2?.id,
|
||||
parent_user_id: resultat.parent1.id,
|
||||
co_parent_id: resultat.parent2?.id ?? null,
|
||||
co_parent_user_id: resultat.parent2?.id ?? null,
|
||||
enfants_ids: resultat.enfants.map(e => e.id),
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
enfant_ids: resultat.enfants.map(e => e.id),
|
||||
statut: options.statut,
|
||||
numero_dossier: numeroDossier,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inscription Parent publique — CDC (statut en_attente + mail pending).
|
||||
*/
|
||||
async inscrireParentComplet(dto: RegisterParentCompletDto) {
|
||||
return this.createParentDossier(dto, {
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
sendPendingEmail: true,
|
||||
sendPasswordSetupEmail: false,
|
||||
requireCgu: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Création dossier parent par le staff (#129) — actif + mail création MDP.
|
||||
*/
|
||||
async createParentDossierStaff(dto: RegisterParentCompletDto) {
|
||||
return this.createParentDossier(dto, {
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
sendPendingEmail: false,
|
||||
sendPasswordSetupEmail: true,
|
||||
requireCgu: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cœur partagé création dossier AM (#156).
|
||||
* - public : statut en_attente + mail pending
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DossiersController } from './dossiers.controller';
|
||||
import { DossiersService } from './dossiers.service';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
|
||||
describe('DossiersController', () => {
|
||||
let controller: DossiersController;
|
||||
const dossiersServiceMock = {
|
||||
listDossiers: jest.fn(),
|
||||
getDossierByNumero: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [DossiersController],
|
||||
providers: [{ provide: DossiersService, useValue: dossiersServiceMock }],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.overrideGuard(RolesGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<DossiersController>(DossiersController);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
it('list delegates to dossiersService.listDossiers with q', async () => {
|
||||
dossiersServiceMock.listDossiers.mockResolvedValue([
|
||||
{
|
||||
type: 'famille',
|
||||
numero_dossier: '2026-000043',
|
||||
libelle: 'Claire MARTIN',
|
||||
emails: ['claire@test.fr'],
|
||||
user_ids: ['u1'],
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
a_valider: false,
|
||||
date_reference: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await controller.list('martin');
|
||||
expect(dossiersServiceMock.listDossiers).toHaveBeenCalledWith('martin');
|
||||
expect(res).toHaveLength(1);
|
||||
expect(res[0].numero_dossier).toBe('2026-000043');
|
||||
});
|
||||
|
||||
it('getDossier delegates to getDossierByNumero', async () => {
|
||||
dossiersServiceMock.getDossierByNumero.mockResolvedValue({
|
||||
type: 'family',
|
||||
dossier: { numero_dossier: '2026-000001' },
|
||||
});
|
||||
const res = await controller.getDossier('2026-000001');
|
||||
expect(dossiersServiceMock.getDossierByNumero).toHaveBeenCalledWith('2026-000001');
|
||||
expect(res.type).toBe('family');
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,46 @@
|
||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||
import { RoleType } from 'src/entities/users.entity';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
import { DossiersService } from './dossiers.service';
|
||||
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
||||
import { DossierListItemDto } from './dto/dossier-list-item.dto';
|
||||
|
||||
@ApiTags('Dossiers')
|
||||
@ApiBearerAuth('access-token')
|
||||
@Controller('dossiers')
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
export class DossiersController {
|
||||
constructor(private readonly dossiersService: DossiersService) {}
|
||||
|
||||
@Get()
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({
|
||||
summary: 'Liste unifiée des dossiers (familles + AM) — ticket #153',
|
||||
description:
|
||||
'1 entrée = 1 numero_dossier. Types `famille` | `assistante_maternelle`. ' +
|
||||
'Filtre optionnel `q` (n°, nom, email). Tri : à valider d’abord, puis n° décroissant.',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'q',
|
||||
required: false,
|
||||
description: 'Recherche libre : n° dossier, libellé, email…',
|
||||
})
|
||||
@ApiResponse({ status: 200, type: [DossierListItemDto] })
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||
list(@Query('q') q?: string): Promise<DossierListItemDto[]> {
|
||||
return this.dossiersService.listDossiers(q);
|
||||
}
|
||||
|
||||
@Get(':numeroDossier')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({ summary: 'Dossier complet par numéro (AM ou famille) – Ticket #119' })
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { DossiersService } from './dossiers.service';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||
import { ParentsService } from '../parents/parents.service';
|
||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
|
||||
describe('DossiersService.listDossiers', () => {
|
||||
let service: DossiersService;
|
||||
const parentsQb = {
|
||||
innerJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn(),
|
||||
};
|
||||
const amQb = {
|
||||
innerJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn(),
|
||||
};
|
||||
const parentsRepo = {
|
||||
createQueryBuilder: jest.fn(() => parentsQb),
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
const amRepo = {
|
||||
createQueryBuilder: jest.fn(() => amQb),
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
const parentsService = {
|
||||
getDossierFamilleByNumero: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DossiersService,
|
||||
{ provide: getRepositoryToken(Parents), useValue: parentsRepo },
|
||||
{ provide: getRepositoryToken(AssistanteMaternelle), useValue: amRepo },
|
||||
{ provide: ParentsService, useValue: parentsService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(DossiersService);
|
||||
jest.clearAllMocks();
|
||||
parentsRepo.createQueryBuilder.mockReturnValue(parentsQb);
|
||||
amRepo.createQueryBuilder.mockReturnValue(amQb);
|
||||
});
|
||||
|
||||
it('aggregates famille (pivot+co-parent) and AM, sorts a_valider first', async () => {
|
||||
parentsQb.getMany.mockResolvedValue([
|
||||
{
|
||||
user_id: 'p1',
|
||||
numero_dossier: '2026-000010',
|
||||
user: {
|
||||
id: 'p1',
|
||||
email: 'claire@test.fr',
|
||||
prenom: 'Claire',
|
||||
nom: 'Martin',
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
cree_le: new Date('2026-01-01'),
|
||||
},
|
||||
co_parent: {
|
||||
id: 'p2',
|
||||
email: 'thomas@test.fr',
|
||||
prenom: 'Thomas',
|
||||
nom: 'Martin',
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
cree_le: new Date('2026-01-02'),
|
||||
},
|
||||
},
|
||||
{
|
||||
user_id: 'p3',
|
||||
numero_dossier: '2026-000020',
|
||||
user: {
|
||||
id: 'p3',
|
||||
email: 'pending@test.fr',
|
||||
prenom: 'Paul',
|
||||
nom: 'Pending',
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
cree_le: new Date('2026-02-01'),
|
||||
},
|
||||
co_parent: undefined,
|
||||
},
|
||||
]);
|
||||
amQb.getMany.mockResolvedValue([
|
||||
{
|
||||
user_id: 'am1',
|
||||
numero_dossier: '2026-000015',
|
||||
user: {
|
||||
id: 'am1',
|
||||
email: 'am@test.fr',
|
||||
prenom: 'Marie',
|
||||
nom: 'Dupont',
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
cree_le: new Date('2026-01-15'),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const list = await service.listDossiers();
|
||||
expect(list).toHaveLength(3);
|
||||
expect(list[0].a_valider).toBe(true);
|
||||
expect(list[0].type).toBe('famille');
|
||||
expect(list[0].numero_dossier).toBe('2026-000020');
|
||||
|
||||
const famille = list.find((i) => i.numero_dossier === '2026-000010')!;
|
||||
expect(famille.type).toBe('famille');
|
||||
expect(famille.user_ids).toEqual(expect.arrayContaining(['p1', 'p2']));
|
||||
expect(famille.emails).toHaveLength(2);
|
||||
expect(famille.libelle).toContain('MARTIN');
|
||||
|
||||
const am = list.find((i) => i.type === 'assistante_maternelle')!;
|
||||
expect(am.numero_dossier).toBe('2026-000015');
|
||||
expect(am.libelle).toContain('Marie');
|
||||
});
|
||||
|
||||
it('filters with q', async () => {
|
||||
parentsQb.getMany.mockResolvedValue([]);
|
||||
amQb.getMany.mockResolvedValue([
|
||||
{
|
||||
user_id: 'am1',
|
||||
numero_dossier: '2026-000015',
|
||||
user: {
|
||||
id: 'am1',
|
||||
email: 'am@test.fr',
|
||||
prenom: 'Marie',
|
||||
nom: 'Dupont',
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
cree_le: new Date('2026-01-15'),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const hit = await service.listDossiers('dupont');
|
||||
expect(hit).toHaveLength(1);
|
||||
const miss = await service.listDossiers('zzz');
|
||||
expect(miss).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,14 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||
import { StatutUtilisateurType, Users } from 'src/entities/users.entity';
|
||||
import { ParentsService } from '../parents/parents.service';
|
||||
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
||||
import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto';
|
||||
import { DossierListItemDto } from './dto/dossier-list-item.dto';
|
||||
|
||||
/**
|
||||
* Endpoint unifié GET /dossiers/:numeroDossier – AM ou famille. Ticket #119.
|
||||
* Dossiers unifiés — détail (#119) + liste (#153).
|
||||
*/
|
||||
@Injectable()
|
||||
export class DossiersService {
|
||||
@@ -20,6 +22,159 @@ export class DossiersService {
|
||||
private readonly parentsService: ParentsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Liste unifiée tous dossiers (familles + AM) ayant un numero_dossier.
|
||||
* Ticket #153 — optionnel `q` filtre n° / nom / prénom / email (côté serveur).
|
||||
*/
|
||||
async listDossiers(q?: string): Promise<DossierListItemDto[]> {
|
||||
const items: DossierListItemDto[] = [
|
||||
...(await this.listFamilleItems()),
|
||||
...(await this.listAmItems()),
|
||||
];
|
||||
|
||||
const needle = (q ?? '').trim().toLowerCase();
|
||||
const filtered = needle
|
||||
? items.filter((item) => this.matchesQuery(item, needle))
|
||||
: items;
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
// À valider d'abord, puis n° dossier décroissant
|
||||
if (a.a_valider !== b.a_valider) return a.a_valider ? -1 : 1;
|
||||
return b.numero_dossier.localeCompare(a.numero_dossier, 'fr');
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private async listFamilleItems(): Promise<DossierListItemDto[]> {
|
||||
const parents = await this.parentsRepository
|
||||
.createQueryBuilder('p')
|
||||
.innerJoinAndSelect('p.user', 'u')
|
||||
.leftJoinAndSelect('p.co_parent', 'cp')
|
||||
.where('p.numero_dossier IS NOT NULL')
|
||||
.andWhere("TRIM(p.numero_dossier) <> ''")
|
||||
.getMany();
|
||||
|
||||
const byNum = new Map<string, Parents[]>();
|
||||
for (const p of parents) {
|
||||
const num = (p.numero_dossier ?? '').trim();
|
||||
if (!num) continue;
|
||||
const group = byNum.get(num) ?? [];
|
||||
group.push(p);
|
||||
byNum.set(num, group);
|
||||
}
|
||||
|
||||
const items: DossierListItemDto[] = [];
|
||||
for (const [numero_dossier, group] of byNum) {
|
||||
const usersMap = new Map<string, Users>();
|
||||
for (const p of group) {
|
||||
if (p.user) usersMap.set(p.user.id, p.user);
|
||||
if (p.co_parent) usersMap.set(p.co_parent.id, p.co_parent);
|
||||
}
|
||||
const users = [...usersMap.values()].sort((a, b) => {
|
||||
const an = `${a.nom ?? ''} ${a.prenom ?? ''}`.toLowerCase();
|
||||
const bn = `${b.nom ?? ''} ${b.prenom ?? ''}`.toLowerCase();
|
||||
return an.localeCompare(bn, 'fr') || a.id.localeCompare(b.id);
|
||||
});
|
||||
if (users.length === 0) continue;
|
||||
|
||||
const names = users.map((u) => this.formatPersonName(u)).filter(Boolean);
|
||||
const libelle =
|
||||
names.length === 0
|
||||
? `Dossier ${numero_dossier}`
|
||||
: names.length === 1
|
||||
? names[0]
|
||||
: names.join(' & ');
|
||||
|
||||
const emails = users.map((u) => u.email).filter(Boolean);
|
||||
const user_ids = users.map((u) => u.id);
|
||||
const a_valider = users.some((u) => u.statut === StatutUtilisateurType.EN_ATTENTE);
|
||||
const statut = a_valider
|
||||
? StatutUtilisateurType.EN_ATTENTE
|
||||
: (users[0].statut ?? StatutUtilisateurType.ACTIF);
|
||||
const date_reference = this.minCreeLeIso(users);
|
||||
|
||||
items.push({
|
||||
type: 'famille',
|
||||
numero_dossier,
|
||||
libelle,
|
||||
emails,
|
||||
user_ids,
|
||||
statut,
|
||||
a_valider,
|
||||
date_reference,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private async listAmItems(): Promise<DossierListItemDto[]> {
|
||||
const ams = await this.amRepository
|
||||
.createQueryBuilder('am')
|
||||
.innerJoinAndSelect('am.user', 'u')
|
||||
.where('am.numero_dossier IS NOT NULL')
|
||||
.andWhere("TRIM(am.numero_dossier) <> ''")
|
||||
.getMany();
|
||||
|
||||
const byNum = new Map<string, AssistanteMaternelle>();
|
||||
for (const am of ams) {
|
||||
const num = (am.numero_dossier ?? '').trim();
|
||||
if (!num || !am.user) continue;
|
||||
// Un n° = une AM ; garder le premier
|
||||
if (!byNum.has(num)) byNum.set(num, am);
|
||||
}
|
||||
|
||||
const items: DossierListItemDto[] = [];
|
||||
for (const [numero_dossier, am] of byNum) {
|
||||
const u = am.user!;
|
||||
const libelle = this.formatPersonName(u) || `AM ${numero_dossier}`;
|
||||
const a_valider = u.statut === StatutUtilisateurType.EN_ATTENTE;
|
||||
items.push({
|
||||
type: 'assistante_maternelle',
|
||||
numero_dossier,
|
||||
libelle,
|
||||
emails: u.email ? [u.email] : [],
|
||||
user_ids: [u.id],
|
||||
statut: u.statut ?? StatutUtilisateurType.ACTIF,
|
||||
a_valider,
|
||||
date_reference: this.minCreeLeIso([u]),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private matchesQuery(item: DossierListItemDto, needle: string): boolean {
|
||||
const hay = [
|
||||
item.numero_dossier,
|
||||
item.libelle,
|
||||
...item.emails,
|
||||
item.statut,
|
||||
item.type,
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return hay.includes(needle);
|
||||
}
|
||||
|
||||
private formatPersonName(u: Users): string {
|
||||
const prenom = (u.prenom ?? '').trim();
|
||||
const nom = (u.nom ?? '').trim();
|
||||
const nomFmt = nom ? nom.toUpperCase() : '';
|
||||
return [prenom, nomFmt].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
private minCreeLeIso(users: Users[]): string | null {
|
||||
let min: Date | null = null;
|
||||
for (const u of users) {
|
||||
const d = u.cree_le;
|
||||
if (!d) continue;
|
||||
const date = d instanceof Date ? d : new Date(d);
|
||||
if (Number.isNaN(date.getTime())) continue;
|
||||
if (!min || date < min) min = date;
|
||||
}
|
||||
return min ? min.toISOString() : null;
|
||||
}
|
||||
|
||||
async getDossierByNumero(numeroDossier: string): Promise<DossierUnifieDto> {
|
||||
const num = numeroDossier?.trim();
|
||||
if (!num) {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
|
||||
/** Ligne de liste GET /dossiers (#153). */
|
||||
export class DossierListItemDto {
|
||||
@ApiProperty({
|
||||
enum: ['famille', 'assistante_maternelle'],
|
||||
description: 'Type de dossier',
|
||||
})
|
||||
type: 'famille' | 'assistante_maternelle';
|
||||
|
||||
@ApiProperty({ example: '2026-000043' })
|
||||
numero_dossier: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'Claire MARTIN & Thomas MARTIN',
|
||||
description: 'Libellé affiché (noms)',
|
||||
})
|
||||
libelle: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
example: ['claire@example.com', 'thomas@example.com'],
|
||||
})
|
||||
emails: string[];
|
||||
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description: 'IDs utilisateur liés au dossier (parents du foyer ou AM)',
|
||||
})
|
||||
user_ids: string[];
|
||||
|
||||
@ApiProperty({
|
||||
enum: StatutUtilisateurType,
|
||||
description:
|
||||
'Statut agrégé : en_attente si au moins un user en_attente, sinon statut du premier',
|
||||
})
|
||||
statut: StatutUtilisateurType;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'True si le dossier est en attente de validation (section haute UI)',
|
||||
})
|
||||
a_valider: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
example: '2026-01-12T10:00:00.000Z',
|
||||
description: 'Date de référence (MIN cree_le des users du dossier)',
|
||||
})
|
||||
date_reference: string | null;
|
||||
}
|
||||
@@ -7,6 +7,12 @@ export class ParentPendingSummaryDto {
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
nom?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
prenom?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
telephone?: string | null;
|
||||
|
||||
@@ -18,7 +24,10 @@ export class ParentPendingSummaryDto {
|
||||
}
|
||||
|
||||
export class PendingFamilyDto {
|
||||
@ApiProperty({ example: 'Famille Dupont', description: 'Libellé affiché pour la famille' })
|
||||
@ApiProperty({
|
||||
example: 'MARTIN Claire - MARTIN Thomas',
|
||||
description: 'Libellé affiché : NOM Prénom (séparés par « - » si co-parent)',
|
||||
})
|
||||
libelle: string;
|
||||
|
||||
@ApiProperty({
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
|
||||
/** Réponse 201 POST /parents/dossier (#129). */
|
||||
export class StaffCreateParentDossierResponseDto {
|
||||
@ApiProperty()
|
||||
message: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: '2026-000043',
|
||||
description: 'Numéro de dossier famille attribué',
|
||||
})
|
||||
numero_dossier: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid', description: 'UUID user du parent pivot' })
|
||||
parent_user_id: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
nullable: true,
|
||||
description: 'UUID user du co-parent, ou null',
|
||||
})
|
||||
co_parent_user_id: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
enum: StatutUtilisateurType,
|
||||
example: StatutUtilisateurType.ACTIF,
|
||||
})
|
||||
statut: StatutUtilisateurType;
|
||||
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description: 'IDs des enfants créés',
|
||||
})
|
||||
enfant_ids: string[];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ApiPropertyOptional, OmitType } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
import { RegisterParentCompletDto } from 'src/routes/auth/dto/register-parent-complet.dto';
|
||||
|
||||
/**
|
||||
* Création dossier parent/famille par staff (#129).
|
||||
* Mêmes champs que l'inscription publique, sans CGU/privacy obligatoires
|
||||
* (acceptées côté serveur pour le compte du gestionnaire).
|
||||
*/
|
||||
export class StaffCreateParentDossierDto extends OmitType(RegisterParentCompletDto, [
|
||||
'acceptation_cgu',
|
||||
'acceptation_privacy',
|
||||
] as const) {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Ignoré côté staff (CGU acceptées serveur). Conservé pour compat éventuelle.',
|
||||
default: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
acceptation_cgu?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Ignoré côté staff (privacy acceptée serveur).',
|
||||
default: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
acceptation_privacy?: boolean;
|
||||
}
|
||||
@@ -1,18 +1,80 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ParentsController } from './parents.controller';
|
||||
import { ParentsService } from './parents.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
|
||||
describe('ParentsController', () => {
|
||||
let controller: ParentsController;
|
||||
const authServiceMock = {
|
||||
createParentDossierStaff: jest.fn(),
|
||||
};
|
||||
const parentsServiceMock = {};
|
||||
const userServiceMock = {};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [ParentsController],
|
||||
}).compile();
|
||||
providers: [
|
||||
{ provide: ParentsService, useValue: parentsServiceMock },
|
||||
{ provide: UserService, useValue: userServiceMock },
|
||||
{ provide: AuthService, useValue: authServiceMock },
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.overrideGuard(RolesGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<ParentsController>(ParentsController);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
it('createDossier delegates to authService.createParentDossierStaff with CGU accepted', async () => {
|
||||
authServiceMock.createParentDossierStaff.mockResolvedValue({
|
||||
message: 'Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.',
|
||||
parent_user_id: 'p1',
|
||||
co_parent_user_id: 'p2',
|
||||
enfant_ids: ['e1'],
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
numero_dossier: '2026-000043',
|
||||
});
|
||||
|
||||
const body = {
|
||||
email: 'parent.staff@test.fr',
|
||||
prenom: 'Claire',
|
||||
nom: 'MARTIN',
|
||||
telephone: '0689567890',
|
||||
enfants: [
|
||||
{
|
||||
prenom: 'Emma',
|
||||
nom: 'MARTIN',
|
||||
date_naissance: '2023-02-15',
|
||||
genre: 'F',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const res = await controller.createDossier(body as any);
|
||||
expect(authServiceMock.createParentDossierStaff).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email: body.email,
|
||||
acceptation_cgu: true,
|
||||
acceptation_privacy: true,
|
||||
}),
|
||||
);
|
||||
expect(res.numero_dossier).toBe('2026-000043');
|
||||
expect(res.parent_user_id).toBe('p1');
|
||||
expect(res.co_parent_user_id).toBe('p2');
|
||||
expect(res.enfant_ids).toEqual(['e1']);
|
||||
expect(res.statut).toBe(StatutUtilisateurType.ACTIF);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
@@ -10,14 +12,25 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ParentsService } from './parents.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { Users } from 'src/entities/users.entity';
|
||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||
import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { CreateParentDto } from '../user/dto/create_parent.dto';
|
||||
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
|
||||
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
|
||||
import { StaffCreateParentDossierDto } from './dto/staff-create-parent-dossier.dto';
|
||||
import { StaffCreateParentDossierResponseDto } from './dto/staff-create-parent-dossier-response.dto';
|
||||
import { RegisterParentCompletDto } from '../auth/dto/register-parent-complet.dto';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
import { User } from 'src/common/decorators/user.decorator';
|
||||
@@ -26,14 +39,50 @@ import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto';
|
||||
import { mapParentForApi, mapParentsForApi } from './parents.mapper';
|
||||
|
||||
@ApiTags('Parents')
|
||||
@ApiBearerAuth('access-token')
|
||||
@Controller('parents')
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
export class ParentsController {
|
||||
constructor(
|
||||
private readonly parentsService: ParentsService,
|
||||
private readonly userService: UserService,
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@Post('dossier')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: 'Créer un dossier famille/parent complet (staff) — ticket #129',
|
||||
description:
|
||||
'Crée parent (+ co-parent optionnel) + enfants + n° dossier avec statut actif, ' +
|
||||
'et envoie l’e-mail de création de mot de passe. ' +
|
||||
'Ne pas utiliser POST /auth/register/parent depuis le dashboard.',
|
||||
})
|
||||
@ApiBody({ type: StaffCreateParentDossierDto })
|
||||
@ApiResponse({ status: 201, type: StaffCreateParentDossierResponseDto })
|
||||
@ApiResponse({ status: 400, description: 'Validation DTO / métier' })
|
||||
@ApiResponse({ status: 403, description: 'Rôle non autorisé' })
|
||||
@ApiResponse({ status: 409, description: 'Email pivot et/ou co-parent déjà pris' })
|
||||
async createDossier(
|
||||
@Body() dto: StaffCreateParentDossierDto,
|
||||
): Promise<StaffCreateParentDossierResponseDto> {
|
||||
const registerDto = {
|
||||
...dto,
|
||||
acceptation_cgu: true,
|
||||
acceptation_privacy: true,
|
||||
} as RegisterParentCompletDto;
|
||||
const result = await this.authService.createParentDossierStaff(registerDto);
|
||||
return {
|
||||
message: result.message,
|
||||
numero_dossier: result.numero_dossier,
|
||||
parent_user_id: result.parent_user_id,
|
||||
co_parent_user_id: result.co_parent_user_id ?? null,
|
||||
statut: result.statut,
|
||||
enfant_ids: result.enfant_ids,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('pending-families')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' })
|
||||
|
||||
@@ -9,11 +9,13 @@ import { ParentsController } from './parents.controller';
|
||||
import { ParentsService } from './parents.service';
|
||||
import { Users } from 'src/entities/users.entity';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant, ParentsChildren]),
|
||||
forwardRef(() => UserModule),
|
||||
forwardRef(() => AuthModule),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
|
||||
@@ -251,7 +251,15 @@ export class ParentsService {
|
||||
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
|
||||
)
|
||||
SELECT
|
||||
'Famille ' || string_agg(u.nom, ' - ' ORDER BY u.nom, u.prenom) AS libelle,
|
||||
string_agg(
|
||||
UPPER(TRIM(u.nom))
|
||||
|| CASE
|
||||
WHEN u.prenom IS NOT NULL AND TRIM(u.prenom) <> ''
|
||||
THEN ' ' || INITCAP(TRIM(u.prenom))
|
||||
ELSE ''
|
||||
END,
|
||||
' - ' ORDER BY u.nom, u.prenom, u.id
|
||||
) AS libelle,
|
||||
array_agg(p.id_utilisateur ORDER BY u.nom, u.prenom, u.id) AS "parentIds",
|
||||
(array_agg(p.numero_dossier))[1] AS numero_dossier,
|
||||
MIN(u.cree_le) AS date_soumission,
|
||||
@@ -267,6 +275,8 @@ export class ParentsService {
|
||||
json_build_object(
|
||||
'id', u.id::text,
|
||||
'email', u.email,
|
||||
'nom', u.nom,
|
||||
'prenom', u.prenom,
|
||||
'telephone', u.telephone,
|
||||
'code_postal', u.code_postal,
|
||||
'ville', u.ville
|
||||
@@ -317,11 +327,21 @@ export class ParentsService {
|
||||
return [];
|
||||
}
|
||||
|
||||
private normalizeParents(parents: unknown): { id: string; email: string; telephone: string | null; code_postal: string | null; ville: string | null }[] {
|
||||
private normalizeParents(parents: unknown): {
|
||||
id: string;
|
||||
email: string;
|
||||
nom: string | null;
|
||||
prenom: string | null;
|
||||
telephone: string | null;
|
||||
code_postal: string | null;
|
||||
ville: string | null;
|
||||
}[] {
|
||||
if (Array.isArray(parents)) {
|
||||
return parents.map((p: any) => ({
|
||||
id: String(p?.id ?? ''),
|
||||
email: String(p?.email ?? ''),
|
||||
nom: p?.nom != null ? String(p.nom) : null,
|
||||
prenom: p?.prenom != null ? String(p.prenom) : null,
|
||||
telephone: p?.telephone != null ? String(p.telephone) : null,
|
||||
code_postal: p?.code_postal != null ? String(p.code_postal) : null,
|
||||
ville: p?.ville != null ? String(p.ville) : null,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Mini-spec API — POST /parents/dossier (#129)
|
||||
|
||||
Contrat pour le **plan front** (wizard création dossier famille staff).
|
||||
|
||||
Miroir de **#156** (`POST /assistantes-maternelles/dossier`).
|
||||
|
||||
## Endpoint
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **Méthode** | `POST` |
|
||||
| **URL** | `{base}/parents/dossier` |
|
||||
| **Auth** | Bearer JWT |
|
||||
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
|
||||
| **Content-Type** | `application/json` |
|
||||
|
||||
Ne **pas** appeler `POST /auth/register/parent` depuis le dashboard.
|
||||
|
||||
## Body (JSON)
|
||||
|
||||
Aligné `RegisterParentCompletDto`, **sans** CGU/privacy obligatoires (acceptées serveur).
|
||||
|
||||
### Parent 1 (obligatoire)
|
||||
|
||||
| Champ | Type | Obligatoire | Notes |
|
||||
|-------|------|-------------|--------|
|
||||
| `email` | string | oui | unique |
|
||||
| `prenom` | string | oui | |
|
||||
| `nom` | string | oui | |
|
||||
| `telephone` | string | oui | `0X…` ou `+33…` |
|
||||
| `adresse` | string | non | |
|
||||
| `code_postal` | string | non | |
|
||||
| `ville` | string | non | |
|
||||
|
||||
### Co-parent (optionnel)
|
||||
|
||||
`co_parent_email`, `co_parent_prenom`, `co_parent_nom`, `co_parent_telephone`,
|
||||
`co_parent_meme_adresse`, `co_parent_adresse`, `co_parent_code_postal`, `co_parent_ville`.
|
||||
|
||||
Si co-parent fourni : e-mail distinct ; mêmes règles téléphone / adresse que register.
|
||||
|
||||
### Enfants (≥ 1)
|
||||
|
||||
| Champ | Type | Notes |
|
||||
|-------|------|--------|
|
||||
| `enfants` | `EnfantInscriptionDto[]` | `prenom`, `nom`, `date_naissance` / `date_previsionnelle_naissance`, `genre`, `photo_base64`, `photo_filename`, etc. |
|
||||
|
||||
### Présentation
|
||||
|
||||
| Champ | Type | Obligatoire |
|
||||
|-------|------|-------------|
|
||||
| `presentation_dossier` | string | non (max 2000) |
|
||||
|
||||
## Réponses
|
||||
|
||||
### 201 Created
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.",
|
||||
"numero_dossier": "2026-000043",
|
||||
"parent_user_id": "uuid-pivot",
|
||||
"co_parent_user_id": "uuid-ou-null",
|
||||
"statut": "actif",
|
||||
"enfant_ids": ["uuid", "..."]
|
||||
}
|
||||
```
|
||||
|
||||
Effets serveur : user(s) parent **actif**, fiches `parents`, enfants + foyer, n° dossier,
|
||||
**e-mail création MDP** pour chaque compte sans MDP (pas d’accusé « en attente »).
|
||||
|
||||
### Erreurs
|
||||
|
||||
| Code | Cas |
|
||||
|------|-----|
|
||||
| 400 | Validation DTO / métier (enfants vides, dates, etc.) |
|
||||
| 401 | Token manquant / invalide |
|
||||
| 403 | Rôle non staff |
|
||||
| 409 | Conflit e-mail (pivot et/ou co-parent) |
|
||||
|
||||
## Front
|
||||
|
||||
- `UserService.createParentDossier(body)` → cet endpoint
|
||||
- Wizard create basé sur `ValidationFamilyWizard`
|
||||
- Ne pas envoyer `acceptation_cgu` / `acceptation_privacy` (optionnels)
|
||||
|
||||
## Branche
|
||||
|
||||
`feature/129-creation-dossier-parent`
|
||||
@@ -0,0 +1,77 @@
|
||||
# Mini-spec API — GET /dossiers (#153)
|
||||
|
||||
Contrat pour le **plan front** (onglet permanent Dossiers).
|
||||
|
||||
## Endpoint
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **Méthode** | `GET` |
|
||||
| **URL** | `{base}/api/v1/dossiers` |
|
||||
| **Auth** | Bearer JWT |
|
||||
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
|
||||
| **Query** | `q` (optionnel) — recherche n° / libellé / email |
|
||||
|
||||
Complète `GET /dossiers/:numeroDossier` (#119) déjà existant.
|
||||
|
||||
---
|
||||
|
||||
## Réponse 200
|
||||
|
||||
Tableau de lignes (1 entrée = 1 `numero_dossier`) :
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"type": "famille",
|
||||
"numero_dossier": "2026-000043",
|
||||
"libelle": "Claire MARTIN & Thomas MARTIN",
|
||||
"emails": ["claire@test.fr", "thomas@test.fr"],
|
||||
"user_ids": ["uuid-pivot", "uuid-co"],
|
||||
"statut": "actif",
|
||||
"a_valider": false,
|
||||
"date_reference": "2026-01-12T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"type": "assistante_maternelle",
|
||||
"numero_dossier": "2026-000042",
|
||||
"libelle": "Marie DUPONT",
|
||||
"emails": ["marie@test.fr"],
|
||||
"user_ids": ["uuid-am"],
|
||||
"statut": "en_attente",
|
||||
"a_valider": true,
|
||||
"date_reference": "2026-02-01T08:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Champs
|
||||
|
||||
| Champ | Notes |
|
||||
|-------|--------|
|
||||
| `type` | `famille` \| `assistante_maternelle` |
|
||||
| `numero_dossier` | Clé d’unité |
|
||||
| `libelle` | Noms formatés (foyer : `A & B`) |
|
||||
| `emails` / `user_ids` | Membres du foyer ou AM |
|
||||
| `statut` | Agrégé : `en_attente` si au moins un user pending |
|
||||
| `a_valider` | `true` si pending → section haute UI |
|
||||
| `date_reference` | `MIN(cree_le)` des users |
|
||||
|
||||
**Tri** : `a_valider` d’abord, puis `numero_dossier` décroissant.
|
||||
|
||||
**Famille** : dédupliquée par `numero_dossier` (pivot + co-parent = 1 ligne).
|
||||
|
||||
---
|
||||
|
||||
## Front
|
||||
|
||||
- `UserService.getDossiers({ q? })` → cet endpoint
|
||||
- Section haute : filtrer `a_valider == true` **ou** continuer pending APIs existantes
|
||||
- Section basse : liste complète (ou hors pending selon règle UX)
|
||||
- Clic → `GET /dossiers/:numero` (détail) / validation review
|
||||
|
||||
Composition client `getParents`+`getAM` **plus nécessaire** si cet endpoint est déployé.
|
||||
|
||||
## Branche
|
||||
|
||||
`feature/153-onglet-dossiers`
|
||||
@@ -0,0 +1,167 @@
|
||||
# Mini-spec front — Onglet permanent « Dossiers » (#153)
|
||||
|
||||
Branche Git (front + back) : `feature/153-onglet-dossiers`
|
||||
Ticket Gitea : **#153** (ticket normal, plus epic)
|
||||
|
||||
> Suite prévue : **#135** = au clic, mode **édition** wizard + ajout 2ᵉ parent.
|
||||
> **#153** = onglet + listes + navigation / validation pending. **Pas** de création, **pas** d’édition complète.
|
||||
|
||||
---
|
||||
|
||||
## Contexte / objectif
|
||||
|
||||
Remplacer l’onglet conditionnel **« À valider »** (apparaît/disparaît selon pending) par un onglet **permanent « Dossiers »** dans le dashboard admin/gestionnaire.
|
||||
|
||||
Quand on ouvre **Dossiers** :
|
||||
|
||||
1. **En haut** — section **Dossiers à valider** (AM + familles pending)
|
||||
2. **En dessous** — liste de **tous les dossiers** (familles **et** AM), 1 ligne = 1 `numero_dossier`
|
||||
3. Différenciation visuelle famille vs AM : **couleur + icône**
|
||||
4. **Barre de recherche** (n° dossier, nom, email…)
|
||||
|
||||
**Pas** de bouton « Créer un dossier » ici (création via **+ Parents** #129 / **+ Asmat** #156).
|
||||
|
||||
---
|
||||
|
||||
## UX cible
|
||||
|
||||
### Onglets dashboard (`UserManagementPanel`)
|
||||
|
||||
| Avant (#107) | Après (#153) |
|
||||
|--------------|--------------|
|
||||
| « À valider » **conditionnel** si pending | **« Dossiers » toujours visible** (admin + gestionnaire) |
|
||||
| Contenu = seulement pending | Pending **en haut** + liste complète **en bas** |
|
||||
|
||||
Ordre suggéré des onglets :
|
||||
|
||||
`Dossiers` | `Parents` | `Enfants` | `Assistantes maternelles` | `Gestionnaires` | (`Administrateurs`)
|
||||
|
||||
### Section haute — À valider
|
||||
|
||||
- Réutiliser / adapter `PendingValidationWidget` (ou extraire la liste dans un sous-widget).
|
||||
- Sources déjà branchées :
|
||||
- `UserService.getPendingUsers(role: 'assistante_maternelle')`
|
||||
- `UserService.getPendingFamilies()`
|
||||
- Clic ligne pending → **`ValidationDossierModal`** / wizards `.review` (inchangé).
|
||||
- Si section vide : ne pas afficher de gros vide ; masquer la section ou message court « Aucun dossier en attente ».
|
||||
|
||||
### Section basse — Tous les dossiers
|
||||
|
||||
1 ligne = **1 dossier** (`numero_dossier`), type :
|
||||
|
||||
| Type | Libellé UI | Couleur (suggestion) |
|
||||
|------|------------|----------------------|
|
||||
| `famille` | Famille / Parents | teinte existante parents (ex. violet / rose dashboard) |
|
||||
| `assistante_maternelle` | AM | teinte existante AM (ex. teal / bleu) |
|
||||
|
||||
Colonnes / infos utiles (cartes style `AdminUserCard` ou lignes type pending) :
|
||||
|
||||
- n° dossier
|
||||
- type (pastille couleur + icône)
|
||||
- libellé (noms parents ou AM)
|
||||
- email(s) principal(aux)
|
||||
- statut user / dossier si dispo (`actif`, `en_attente`, …)
|
||||
- date utile si dispo
|
||||
|
||||
**Déduplication** : un foyer (pivot + co-parent) = **une** ligne famille (même `numero_dossier`). Idem AM.
|
||||
|
||||
### Recherche
|
||||
|
||||
- La search bar du panel (aujourd’hui désactivée / hint « pas de recherche » sur À valider) doit **filtrer la liste unifiée** (et idéalement aussi le pending affiché).
|
||||
- Critères **minimum** : `numero_dossier`, nom, prénom, email.
|
||||
- Harmoniser le hint : `Rechercher un dossier (n°, nom, email)…`
|
||||
|
||||
### État vide liste complète
|
||||
|
||||
Aide optionnelle : *« Pour créer un dossier → onglet Parents (+ Parents) ou Assistantes maternelles (+ Asmat) »*.
|
||||
|
||||
### Clic sur un dossier de la liste complète (#153)
|
||||
|
||||
| Cas | Comportement #153 |
|
||||
|-----|-------------------|
|
||||
| Pending | Ouvrir validation (review) — déjà en place |
|
||||
| Dossier **actif** / non pending | Ouvrir consultation via `GET /dossiers/:numeroDossier` (`UserService.getDossierByNumero`) en **lecture / review** si possible **sans** save édition |
|
||||
|
||||
**Ne pas** implémenter le mode `edit` ni le switch 2ᵉ parent → **#135**.
|
||||
|
||||
Si l’ouverture « review » d’un dossier actif est trop lourde pour ce ticket : clic peut temporairement no-op / snackbar *« Édition dossier : prochainement (#135) »* — **à éviter** si `getDossierByNumero` + wizard review marche déjà pour les deux types.
|
||||
|
||||
---
|
||||
|
||||
## Données / APIs (front)
|
||||
|
||||
### Déjà disponibles (préférer composer côté front pour #153)
|
||||
|
||||
| Besoin | API / service |
|
||||
|--------|----------------|
|
||||
| Pending AM | `getPendingUsers(role: assistante_maternelle)` |
|
||||
| Pending familles | `getPendingFamilies()` |
|
||||
| Parents (avec `numero_dossier`) | `getParents()` |
|
||||
| AM (avec `numero_dossier`) | `getAssistantesMaternelles()` |
|
||||
| Détail unifié | `getDossierByNumero(numero)` → `GET /dossiers/:numeroDossier` |
|
||||
|
||||
**Pas d’endpoint `GET /dossiers` liste** aujourd’hui. Pour #153 :
|
||||
|
||||
- Construire la liste unifiée **côté client** à partir de `getParents()` + `getAssistantesMaternelles()` (group by `numero_dossier`).
|
||||
- Exclure ou marquer les pending déjà dans la section haute (éviter doublons visuels, ou les laisser dans les deux avec badge « à valider » — **préférence** : pending **uniquement** en haut ; liste basse = tous **hors** pending **ou** tous avec badge ; choisir une règle claire et documenter dans le PR).
|
||||
|
||||
**Règle recommandée** :
|
||||
- Haut = pending only
|
||||
- Bas = **tous** les dossiers ayant un `numero_dossier` (y compris pending) **OU** bas = non-pending only
|
||||
→ **Recommandation produit** : bas = **tous** (vision complète), pending aussi en haut pour action rapide. Si doublon gênant : bas = non-pending only.
|
||||
|
||||
### Si le back ajoute plus tard `GET /dossiers`
|
||||
|
||||
Brancher `UserService.getDossiers()` — hors scope bloquant #153 front si composition client OK.
|
||||
|
||||
---
|
||||
|
||||
## Fichiers front probables
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---------|------|
|
||||
| `frontend/lib/widgets/admin/user_management_panel.dart` | Onglet permanent **Dossiers** ; retirer logique conditionnelle À valider ; search sur cet onglet |
|
||||
| `frontend/lib/widgets/admin/pending_validation_widget.dart` | Réemploi section haute (ou refactor léger) |
|
||||
| **Nouveau** `…/dossiers_management_widget.dart` (nom libre) | Shell onglet : pending + liste unifiée + refresh |
|
||||
| **Nouveau** modèle léger `DossierListItem` (type, numero, libelle, emails, statut…) | Mapping parents/AM → ligne |
|
||||
| `user_service.dart` / `api_config.dart` | Seulement si helper `getDossiersUnified()` côté client (pas forcément nouvel endpoint) |
|
||||
| `validation_dossier_modal.dart` | Réemploi ouverture pending / détail |
|
||||
|
||||
Réutiliser look & feel cartes / hover « Ouvrir » de `_PendingValidationRow` / `AdminUserCard`.
|
||||
|
||||
---
|
||||
|
||||
## Hors scope (#153)
|
||||
|
||||
- Bouton créer dossier
|
||||
- Mode `edit` wizard + ajout 2ᵉ parent → **#135**
|
||||
- Suppressions → **#154**
|
||||
- Famille N responsables → **#139**
|
||||
- Changer les onglets Parents / AM / Enfants (restent)
|
||||
|
||||
---
|
||||
|
||||
## Critères d’acceptation front
|
||||
|
||||
- [ ] Onglet **Dossiers** toujours visible (même 0 pending)
|
||||
- [ ] Plus d’onglet conditionnel **« À valider »**
|
||||
- [ ] Section haute pending si non vide ; validation au clic OK
|
||||
- [ ] Liste unifiée familles + AM en dessous ; 1 ligne / `numero_dossier`
|
||||
- [ ] Couleur + icône différencient famille / AM
|
||||
- [ ] Recherche filtre (n° + nom + email minimum)
|
||||
- [ ] **Aucun** bouton créer dans cet onglet
|
||||
- [ ] Pas de régression validation pending (valider / refuser)
|
||||
|
||||
---
|
||||
|
||||
## Back (info — Cursor back séparé si besoin)
|
||||
|
||||
- Liste unifiée : **pas bloquante** si composition front
|
||||
- Optionnel : `GET /api/v1/dossiers` (liste) pour perf / pagination plus tard
|
||||
- `GET /dossiers/:numero` déjà là (#119)
|
||||
|
||||
---
|
||||
|
||||
## Branche
|
||||
|
||||
`feature/153-onglet-dossiers` (depuis `develop`)
|
||||
@@ -0,0 +1,196 @@
|
||||
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
||||
import 'package:p_tits_pas/models/parent_model.dart';
|
||||
import 'package:p_tits_pas/utils/name_format_utils.dart';
|
||||
|
||||
/// Ligne de liste unifiée dossiers (famille ou AM) — ticket #153.
|
||||
enum DossierListType { famille, assistanteMaternelle }
|
||||
|
||||
class DossierListItem {
|
||||
final DossierListType type;
|
||||
final String numeroDossier;
|
||||
final String libelle;
|
||||
final List<String> emails;
|
||||
final String? statut;
|
||||
/// Photo profil (AM) — affichée à la place de l’icône si présente.
|
||||
final String? photoUrl;
|
||||
|
||||
const DossierListItem({
|
||||
required this.type,
|
||||
required this.numeroDossier,
|
||||
required this.libelle,
|
||||
this.emails = const [],
|
||||
this.statut,
|
||||
this.photoUrl,
|
||||
});
|
||||
|
||||
bool get isFamille => type == DossierListType.famille;
|
||||
bool get isAm => type == DossierListType.assistanteMaternelle;
|
||||
|
||||
String get typeLabel => isFamille ? 'Famille' : 'AM';
|
||||
|
||||
/// Sous-titre carte : `NOM Prénom` ou `NOM Prénom - NOM Prénom`.
|
||||
String get namesLine => libelle;
|
||||
|
||||
String get emailsLine => emails.where((e) => e.trim().isNotEmpty).join(' · ');
|
||||
|
||||
/// Titre carte : numéro de dossier seul.
|
||||
String get titleLine => numeroDossier;
|
||||
|
||||
bool matchesQuery(String query) {
|
||||
final q = query.trim().toLowerCase();
|
||||
if (q.isEmpty) return true;
|
||||
if (numeroDossier.toLowerCase().contains(q)) return true;
|
||||
if (libelle.toLowerCase().contains(q)) return true;
|
||||
for (final e in emails) {
|
||||
if (e.toLowerCase().contains(q)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Une ligne par `numero_dossier` (foyer dédupliqué).
|
||||
static List<DossierListItem> fromParents(List<ParentModel> parents) {
|
||||
final byDossier = <String, List<ParentModel>>{};
|
||||
for (final p in parents) {
|
||||
final num = (p.user.numeroDossier ?? '').trim();
|
||||
if (num.isEmpty) continue;
|
||||
byDossier.putIfAbsent(num, () => []).add(p);
|
||||
}
|
||||
|
||||
final items = <DossierListItem>[];
|
||||
for (final entry in byDossier.entries) {
|
||||
final seenIds = <String>{};
|
||||
final names = <String>[];
|
||||
final emails = <String>[];
|
||||
final statuts = <String>[];
|
||||
|
||||
void consider(
|
||||
String? id,
|
||||
String? nom,
|
||||
String? prenom,
|
||||
String? email,
|
||||
String? statut,
|
||||
) {
|
||||
final uid = (id ?? '').trim();
|
||||
if (uid.isEmpty || !seenIds.add(uid)) return;
|
||||
final label = formatDossierPersonLabel(
|
||||
nom: nom,
|
||||
prenom: prenom,
|
||||
email: email,
|
||||
);
|
||||
if (label.isNotEmpty) names.add(label);
|
||||
final e = (email ?? '').trim();
|
||||
if (e.isNotEmpty) emails.add(e);
|
||||
final s = (statut ?? '').trim();
|
||||
if (s.isNotEmpty) statuts.add(s);
|
||||
}
|
||||
|
||||
for (final p in entry.value) {
|
||||
consider(
|
||||
p.user.id,
|
||||
p.user.nom,
|
||||
p.user.prenom,
|
||||
p.user.email,
|
||||
p.user.statut,
|
||||
);
|
||||
final co = p.coParent;
|
||||
if (co != null) {
|
||||
consider(co.id, co.nom, co.prenom, co.email, co.statut);
|
||||
}
|
||||
}
|
||||
|
||||
items.add(
|
||||
DossierListItem(
|
||||
type: DossierListType.famille,
|
||||
numeroDossier: entry.key,
|
||||
libelle: names.isNotEmpty ? names.join(' - ') : 'Famille',
|
||||
emails: emails,
|
||||
statut: _preferStatut(statuts),
|
||||
),
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
static List<DossierListItem> fromAssistantes(
|
||||
List<AssistanteMaternelleModel> ams,
|
||||
) {
|
||||
final byDossier = <String, AssistanteMaternelleModel>{};
|
||||
for (final am in ams) {
|
||||
final num = (am.user.numeroDossier ?? '').trim();
|
||||
if (num.isEmpty) continue;
|
||||
byDossier.putIfAbsent(num, () => am);
|
||||
}
|
||||
|
||||
return byDossier.entries.map((e) {
|
||||
final u = e.value.user;
|
||||
final name = formatDossierPersonLabel(
|
||||
nom: u.nom,
|
||||
prenom: u.prenom,
|
||||
email: u.email,
|
||||
);
|
||||
final photo = (u.photoUrl ?? '').trim();
|
||||
return DossierListItem(
|
||||
type: DossierListType.assistanteMaternelle,
|
||||
numeroDossier: e.key,
|
||||
libelle: name.isNotEmpty ? name : 'AM',
|
||||
emails: u.email.trim().isEmpty ? const [] : [u.email.trim()],
|
||||
statut: u.statut?.trim(),
|
||||
photoUrl: photo.isEmpty ? null : photo,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Priorité affichage : en_attente > suspendu > refuse > actif > autre.
|
||||
static String? _preferStatut(List<String> raw) {
|
||||
if (raw.isEmpty) return null;
|
||||
const order = ['en_attente', 'suspendu', 'refuse', 'actif'];
|
||||
for (final wanted in order) {
|
||||
for (final s in raw) {
|
||||
if (s.toLowerCase() == wanted) return s;
|
||||
}
|
||||
}
|
||||
return raw.first;
|
||||
}
|
||||
}
|
||||
|
||||
/// Affichage carte dossier : `NOM Prénom` (repli email).
|
||||
String formatDossierPersonLabel({
|
||||
String? nom,
|
||||
String? prenom,
|
||||
String? email,
|
||||
}) {
|
||||
final n = (nom ?? '').trim().toUpperCase();
|
||||
final p = formatPersonNameCase(prenom ?? '');
|
||||
if (n.isNotEmpty && p.isNotEmpty) return '$n $p';
|
||||
if (n.isNotEmpty) return n;
|
||||
if (p.isNotEmpty) return p;
|
||||
return (email ?? '').trim();
|
||||
}
|
||||
|
||||
/// Reformate un libellé famille API (`A & B` / `Famille …`) en `NOM Prénom - …`.
|
||||
String formatDossierFamilyNamesLine(String libelle) {
|
||||
var raw = libelle.trim();
|
||||
if (raw.isEmpty) return '';
|
||||
raw = raw.replaceFirst(RegExp(r'^famille\s+', caseSensitive: false), '');
|
||||
raw = raw
|
||||
.replaceAll(RegExp(r'\s+&\s+'), ' - ')
|
||||
.replaceAll(RegExp(r'\s+et\s+', caseSensitive: false), ' - ');
|
||||
final parts = raw
|
||||
.split(RegExp(r'\s+-\s+'))
|
||||
.map((part) => _formatLoosePersonSegment(part.trim()))
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
return parts.join(' - ');
|
||||
}
|
||||
|
||||
/// Segment libre type « martin sophie » ou « DURAND Amélie » → `NOM Prénom`.
|
||||
String _formatLoosePersonSegment(String segment) {
|
||||
final words =
|
||||
segment.split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList();
|
||||
if (words.isEmpty) return '';
|
||||
if (words.length == 1) return words.first.toUpperCase();
|
||||
// Convention affichage : premier mot = NOM, reste = prénom(s).
|
||||
final nom = words.first.toUpperCase();
|
||||
final prenom = formatPersonNameCase(words.sublist(1).join(' '));
|
||||
return '$nom $prenom';
|
||||
}
|
||||
@@ -60,6 +60,8 @@ class ApiConfig {
|
||||
static const String userChildren = '/users/children';
|
||||
static const String gestionnaires = '/gestionnaires';
|
||||
static const String parents = '/parents';
|
||||
/// Création dossier famille actif par le staff (#129) — body type register parent.
|
||||
static const String parentsDossier = '/parents/dossier';
|
||||
static const String assistantesMaternelles = '/assistantes-maternelles';
|
||||
/// Création dossier AM actif par le staff (#156) — body type register AM.
|
||||
static const String assistantesMaternellesDossier =
|
||||
|
||||
@@ -645,6 +645,54 @@ class UserService {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/// Création dossier famille actif côté staff (#129).
|
||||
/// `POST /parents/dossier` — body aligné sur register parent complet.
|
||||
/// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur, par parent créé).
|
||||
/// Ne pas utiliser `POST /auth/register/parent` (public / en_attente).
|
||||
static Future<Map<String, dynamic>> createParentDossier(
|
||||
Map<String, dynamic> body,
|
||||
) async {
|
||||
final http.Response response;
|
||||
try {
|
||||
response = await http.post(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parentsDossier}'),
|
||||
headers: await _headers(),
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
} on http.ClientException {
|
||||
throw Exception(
|
||||
'Connexion au serveur impossible. Vérifiez votre réseau puis réessayez.',
|
||||
);
|
||||
}
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||
if (response.body.trim().isEmpty) return <String, dynamic>{};
|
||||
try {
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is Map) {
|
||||
return Map<String, dynamic>.from(decoded);
|
||||
}
|
||||
} catch (_) {}
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
|
||||
final message = _extractErrorMessage(
|
||||
response.body,
|
||||
'Erreur création dossier famille',
|
||||
);
|
||||
if (response.statusCode == 409) {
|
||||
throw Exception(
|
||||
message.isNotEmpty ? message : 'Conflit : e-mail déjà utilisé.',
|
||||
);
|
||||
}
|
||||
if (response.statusCode == 400) {
|
||||
throw Exception(
|
||||
message.isNotEmpty ? message : 'Données invalides (400).',
|
||||
);
|
||||
}
|
||||
throw Exception(message);
|
||||
}
|
||||
|
||||
/// Création dossier AM actif côté staff (#156).
|
||||
/// `POST /assistantes-maternelles/dossier` — body aligné sur register AM.
|
||||
/// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur).
|
||||
|
||||
@@ -12,6 +12,10 @@ class AdminUserCard extends StatefulWidget {
|
||||
final Color? backgroundColor;
|
||||
final Color? titleColor;
|
||||
final Color? infoColor;
|
||||
/// Fond du cercle avatar / icône (défaut lavande admin).
|
||||
final Color? avatarBackgroundColor;
|
||||
/// Couleur de l’icône fallback (défaut violet admin).
|
||||
final Color? avatarIconColor;
|
||||
final String? vigilanceTooltip;
|
||||
final VoidCallback? onCardTap;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
@@ -28,6 +32,8 @@ class AdminUserCard extends StatefulWidget {
|
||||
this.backgroundColor,
|
||||
this.titleColor,
|
||||
this.infoColor,
|
||||
this.avatarBackgroundColor,
|
||||
this.avatarIconColor,
|
||||
this.vigilanceTooltip,
|
||||
this.onCardTap,
|
||||
this.margin,
|
||||
@@ -159,8 +165,8 @@ class _AdminUserCardState extends State<AdminUserCard> {
|
||||
|
||||
Widget _buildAvatar(String url) {
|
||||
const size = 28.0;
|
||||
const bg = Color(0xFFEDE5FA);
|
||||
const iconColor = Color(0xFF6B3FA0);
|
||||
final bg = widget.avatarBackgroundColor ?? const Color(0xFFEDE5FA);
|
||||
final iconColor = widget.avatarIconColor ?? const Color(0xFF6B3FA0);
|
||||
|
||||
if (url.isEmpty) {
|
||||
return CircleAvatar(
|
||||
|
||||
@@ -35,19 +35,23 @@ class ValidationFormMetrics {
|
||||
);
|
||||
|
||||
static double get sectionTitleBlockHeight =>
|
||||
sectionTitleFontSize + sectionTitleGapBelow;
|
||||
sectionTitleFontSize * 1.25 + sectionTitleGapBelow;
|
||||
|
||||
/// Libellé : marge au-dessus de [fieldLabelFontSize] (métriques police).
|
||||
static double get labeledRowHeight =>
|
||||
fieldLabelFontSize + fieldLabelGapBelow + fieldHeight + rowGapBelow;
|
||||
fieldLabelFontSize * 1.25 +
|
||||
fieldLabelGapBelow +
|
||||
fieldHeight +
|
||||
rowGapBelow;
|
||||
|
||||
/// Corps modale AM : padding wizard + titre + [rows] lignes + nav.
|
||||
/// Corps modale AM / famille : padding wizard + titre + [rows] lignes + nav.
|
||||
static double shellBodyHeightForRows(int rows) =>
|
||||
20 * 2 + // padding wizard
|
||||
4 + // espace haut
|
||||
sectionTitleBlockHeight +
|
||||
rows * labeledRowHeight +
|
||||
24 + // avant nav
|
||||
44; // boutons
|
||||
48; // boutons (+ marge anti-overflow)
|
||||
}
|
||||
|
||||
/// Bloc type formulaire (titre de section + champs read-only) pour les modales de validation.
|
||||
@@ -134,6 +138,7 @@ class ValidationFormGrid extends StatelessWidget {
|
||||
label: f.label,
|
||||
field: f.field,
|
||||
expand: expandVertically,
|
||||
labelTrailing: f.labelTrailing,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
@@ -262,27 +267,36 @@ class ValidationLabeledField extends StatelessWidget {
|
||||
final String label;
|
||||
final Widget field;
|
||||
final bool expand;
|
||||
/// Widget aligné à droite sur la ligne du libellé (ex. switch « Même adresse »).
|
||||
final Widget? labelTrailing;
|
||||
|
||||
const ValidationLabeledField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.field,
|
||||
this.expand = false,
|
||||
this.labelTrailing,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final labelStyle = TextStyle(
|
||||
fontSize: ValidationFormMetrics.fieldLabelFontSize,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey.shade700,
|
||||
);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: ValidationFormMetrics.fieldLabelFontSize,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
if (labelTrailing == null)
|
||||
Text(label, style: labelStyle)
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text(label, style: labelStyle)),
|
||||
labelTrailing!,
|
||||
],
|
||||
),
|
||||
SizedBox(height: ValidationFormMetrics.fieldLabelGapBelow),
|
||||
if (expand) Expanded(child: field) else field,
|
||||
@@ -299,6 +313,7 @@ class ValidationEditableField extends StatelessWidget {
|
||||
final String? hintText;
|
||||
final int maxLines;
|
||||
final bool compact;
|
||||
final bool enabled;
|
||||
|
||||
const ValidationEditableField({
|
||||
super.key,
|
||||
@@ -308,6 +323,7 @@ class ValidationEditableField extends StatelessWidget {
|
||||
this.hintText,
|
||||
this.maxLines = 1,
|
||||
this.compact = false,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
static const double _compactFieldHeight = 34;
|
||||
@@ -339,6 +355,7 @@ class ValidationEditableField extends StatelessWidget {
|
||||
if (maxLines > 1) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
enabled: enabled,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLines: maxLines,
|
||||
@@ -350,6 +367,7 @@ class ValidationEditableField extends StatelessWidget {
|
||||
return _validationFieldFillHeight(
|
||||
TextField(
|
||||
controller: controller,
|
||||
enabled: enabled,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLines: 1,
|
||||
@@ -365,6 +383,7 @@ class ValidationEditableField extends StatelessWidget {
|
||||
decoration: _compactDecoration(),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
enabled: enabled,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLines: 1,
|
||||
@@ -381,13 +400,15 @@ class ValidationEditableField extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Étire le champ si le parent impose une hauteur (grille [expandVertically]).
|
||||
/// Hauteur TF fixe ; en grille [expandVertically], étire jusqu’à la hauteur dispo.
|
||||
Widget _validationFieldFillHeight(Widget field) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
if (!c.hasBoundedHeight || !c.maxHeight.isFinite) return field;
|
||||
final h = (c.hasBoundedHeight && c.maxHeight.isFinite)
|
||||
? c.maxHeight
|
||||
: ValidationFormMetrics.fieldHeight;
|
||||
return SizedBox(
|
||||
height: c.maxHeight,
|
||||
height: h,
|
||||
width: double.infinity,
|
||||
child: field,
|
||||
);
|
||||
@@ -481,12 +502,14 @@ class ValidationPostalCodeField extends StatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final String? hintText;
|
||||
final bool allowEmpty;
|
||||
final bool enabled;
|
||||
|
||||
const ValidationPostalCodeField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.hintText,
|
||||
this.allowEmpty = false,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -536,6 +559,7 @@ class _ValidationPostalCodeFieldState extends State<ValidationPostalCodeField> {
|
||||
key: _fieldKey,
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
enabled: widget.enabled,
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.next,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
|
||||
@@ -8,6 +8,8 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
||||
final ValueChanged<int> onSubTabChange;
|
||||
final TextEditingController searchController;
|
||||
final String searchHint;
|
||||
/// Infobulle au survol de la barre de recherche (ex. critères de recherche).
|
||||
final String? searchTooltip;
|
||||
final Widget? filterControl;
|
||||
final VoidCallback? onAddPressed;
|
||||
final String addLabel;
|
||||
@@ -22,12 +24,16 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
||||
'Administrateurs',
|
||||
];
|
||||
|
||||
/// Aligné sur la taille des libellés d’onglets.
|
||||
static const double _searchFontSize = 13;
|
||||
|
||||
const DashboardUserManagementSubBar({
|
||||
Key? key,
|
||||
required this.selectedSubIndex,
|
||||
required this.onSubTabChange,
|
||||
required this.searchController,
|
||||
required this.searchHint,
|
||||
this.searchTooltip,
|
||||
this.filterControl,
|
||||
this.onAddPressed,
|
||||
this.addLabel = '+ Ajouter',
|
||||
@@ -54,12 +60,32 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
||||
_buildSubNavItem(context, labels[i], i),
|
||||
],
|
||||
const SizedBox(width: 36),
|
||||
_pillField(
|
||||
_buildSearchField(),
|
||||
if (filterControl != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
_pillField(width: 150, child: filterControl!),
|
||||
],
|
||||
const Spacer(),
|
||||
if (onAddPressed != null) _buildAddButton(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchField() {
|
||||
final field = _pillField(
|
||||
width: 320,
|
||||
child: TextField(
|
||||
controller: searchController,
|
||||
style: const TextStyle(fontSize: _searchFontSize),
|
||||
decoration: InputDecoration(
|
||||
hintText: searchHint,
|
||||
hintStyle: TextStyle(
|
||||
fontSize: _searchFontSize,
|
||||
fontStyle: FontStyle.normal,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: Colors.black45,
|
||||
),
|
||||
prefixIcon: const Icon(Icons.search, size: 18),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
@@ -69,15 +95,14 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (filterControl != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
_pillField(width: 150, child: filterControl!),
|
||||
],
|
||||
const Spacer(),
|
||||
if (onAddPressed != null) _buildAddButton(),
|
||||
],
|
||||
),
|
||||
);
|
||||
final tip = (searchTooltip ?? '').trim();
|
||||
if (tip.isEmpty) return field;
|
||||
return Tooltip(
|
||||
message: tip,
|
||||
preferBelow: false,
|
||||
waitDuration: const Duration(milliseconds: 400),
|
||||
child: field,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||
|
||||
/// Carte dossier unifiée (#153) — fond neutre, accent couleur sur l’icône.
|
||||
class DossierListCard extends StatelessWidget {
|
||||
final String numeroDossier;
|
||||
final String namesLine;
|
||||
final bool isFamille;
|
||||
final VoidCallback onOpen;
|
||||
/// Photo AM (si absente → icône fallback).
|
||||
final String? photoUrl;
|
||||
|
||||
/// Lavande — Famille / Parents.
|
||||
static const Color familleAccent = Color(0xFFB289C9);
|
||||
|
||||
/// Menthe logo — AM.
|
||||
static const Color amAccent = Color(0xFF5A9D94);
|
||||
|
||||
const DossierListCard({
|
||||
super.key,
|
||||
required this.numeroDossier,
|
||||
required this.namesLine,
|
||||
required this.isFamille,
|
||||
required this.onOpen,
|
||||
this.photoUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = isFamille ? familleAccent : amAccent;
|
||||
final num = numeroDossier.trim().isEmpty ? '–' : numeroDossier.trim();
|
||||
final names = namesLine.trim();
|
||||
final avatar = (photoUrl ?? '').trim();
|
||||
|
||||
return AdminUserCard(
|
||||
title: num,
|
||||
subtitleLines: names.isEmpty ? const [] : [names],
|
||||
avatarUrl: !isFamille && avatar.isNotEmpty ? avatar : null,
|
||||
fallbackIcon:
|
||||
isFamille ? Icons.family_restroom_outlined : Icons.face,
|
||||
// N° = titre neutre (comme Parents / AM) ; accent = icône seule.
|
||||
avatarIconColor: accent,
|
||||
infoColor: Colors.black87,
|
||||
onCardTap: onOpen,
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Ouvrir',
|
||||
icon: Icon(Icons.open_in_new, size: 20, color: accent),
|
||||
onPressed: onOpen,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/dossier_list_item.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/dossier_list_card.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart';
|
||||
|
||||
/// Onglet permanent « Dossiers » (#153) : pending en haut + liste unifiée en bas.
|
||||
class DossiersManagementWidget extends StatefulWidget {
|
||||
final String searchQuery;
|
||||
final VoidCallback? onRefresh;
|
||||
|
||||
const DossiersManagementWidget({
|
||||
super.key,
|
||||
this.searchQuery = '',
|
||||
this.onRefresh,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DossiersManagementWidget> createState() =>
|
||||
_DossiersManagementWidgetState();
|
||||
}
|
||||
|
||||
class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
List<DossierListItem> _all = [];
|
||||
Set<String> _pendingNumeros = {};
|
||||
int _pendingRefreshTick = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadAll();
|
||||
}
|
||||
|
||||
Future<void> _loadAll() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final parents = await UserService.getParents();
|
||||
final ams = await UserService.getAssistantesMaternelles();
|
||||
if (!mounted) return;
|
||||
final items = <DossierListItem>[
|
||||
...DossierListItem.fromParents(parents),
|
||||
...DossierListItem.fromAssistantes(ams),
|
||||
];
|
||||
items.sort((a, b) {
|
||||
final byNum = a.numeroDossier.compareTo(b.numeroDossier);
|
||||
if (byNum != 0) return byNum;
|
||||
return a.typeLabel.compareTo(b.typeLabel);
|
||||
});
|
||||
setState(() {
|
||||
_all = items;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e is Exception
|
||||
? e.toString().replaceFirst('Exception: ', '')
|
||||
: 'Erreur inconnue';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshEverything() async {
|
||||
setState(() => _pendingRefreshTick++);
|
||||
await _loadAll();
|
||||
widget.onRefresh?.call();
|
||||
}
|
||||
|
||||
void _openDossier(String numeroDossier) {
|
||||
final num = numeroDossier.trim();
|
||||
if (num.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Numéro de dossier manquant.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => ValidationDossierModal(
|
||||
numeroDossier: num,
|
||||
onClose: () => Navigator.of(context).pop(),
|
||||
onSuccess: () {
|
||||
Navigator.of(context).pop();
|
||||
_refreshEverything();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final query = widget.searchQuery;
|
||||
// Pending uniquement en haut — exclus de « Tous les dossiers ».
|
||||
final filtered = _all
|
||||
.where((d) => !_pendingNumeros.contains(d.numeroDossier))
|
||||
.where((d) => (d.statut ?? '').toLowerCase() != 'en_attente')
|
||||
.where((d) => d.matchesQuery(query))
|
||||
.toList(growable: false);
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _refreshEverything,
|
||||
child: CustomScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: PendingValidationWidget(
|
||||
key: ValueKey('pending-$_pendingRefreshTick'),
|
||||
searchQuery: query,
|
||||
compactWhenEmpty: true,
|
||||
onPendingNumerosChanged: (nums) {
|
||||
if (!mounted) return;
|
||||
setState(() => _pendingNumeros = nums);
|
||||
},
|
||||
onRefresh: () {
|
||||
_loadAll();
|
||||
widget.onRefresh?.call();
|
||||
},
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(
|
||||
'Tous les dossiers',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_loading)
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (_error != null && _error!.isNotEmpty)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_error!, style: const TextStyle(color: Colors.red)),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadAll,
|
||||
child: const Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (filtered.isEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 32),
|
||||
child: Text(
|
||||
query.trim().isEmpty
|
||||
? 'Aucun dossier pour le moment.\n'
|
||||
'Pour créer un dossier → onglet Parents (+ Parents) '
|
||||
'ou Assistantes maternelles (+ Asmat).'
|
||||
: 'Aucun dossier ne correspond à la recherche.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey.shade600, height: 1.4),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final item = filtered[index];
|
||||
return DossierListCard(
|
||||
numeroDossier: item.numeroDossier,
|
||||
namesLine: item.namesLine,
|
||||
isFamille: item.isFamille,
|
||||
photoUrl: item.photoUrl,
|
||||
onOpen: () => _openDossier(item.numeroDossier),
|
||||
);
|
||||
},
|
||||
childCount: filtered.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart';
|
||||
|
||||
/// Modale de création dossier famille (#129) — même shell que [AmDossierCreateModal].
|
||||
class ParentDossierCreateModal extends StatefulWidget {
|
||||
final VoidCallback onClose;
|
||||
final VoidCallback? onSuccess;
|
||||
|
||||
const ParentDossierCreateModal({
|
||||
super.key,
|
||||
required this.onClose,
|
||||
this.onSuccess,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ParentDossierCreateModal> createState() =>
|
||||
_ParentDossierCreateModalState();
|
||||
}
|
||||
|
||||
class _ParentDossierCreateModalState extends State<ParentDossierCreateModal> {
|
||||
int? _stepIndex;
|
||||
int? _stepTotal;
|
||||
|
||||
void _onStepChanged(int step, int total) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_stepIndex = step;
|
||||
_stepTotal = total;
|
||||
});
|
||||
}
|
||||
|
||||
void _onSuccess() {
|
||||
widget.onSuccess?.call();
|
||||
}
|
||||
|
||||
static const double _modalWidth = 930;
|
||||
/// Hauteur calculée depuis 4 lignes de TF (voir [ParentDossierWizard.shellBodyHeight]).
|
||||
static double get _bodyHeight => ParentDossierWizard.shellBodyHeight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maxH = MediaQuery.of(context).size.height * 0.85;
|
||||
final showStep =
|
||||
_stepIndex != null && _stepTotal != null && (_stepTotal ?? 0) > 0;
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: _modalWidth, maxHeight: maxH),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(18, 18, 0, 12),
|
||||
child: Text(
|
||||
'Nouveau dossier famille',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (showStep) ...[
|
||||
Text(
|
||||
'Étape ${(_stepIndex ?? 0) + 1}/${_stepTotal ?? 1}',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Colors.black54,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: widget.onClose,
|
||||
tooltip: 'Fermer',
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SizedBox(
|
||||
height: _bodyHeight,
|
||||
child: ParentDossierWizard.create(
|
||||
onClose: widget.onClose,
|
||||
onSuccess: _onSuccess,
|
||||
onStepChanged: _onStepChanged,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:p_tits_pas/models/dossier_list_item.dart';
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
import 'package:p_tits_pas/models/pending_family.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/dossier_list_card.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart';
|
||||
|
||||
/// Onglet « À valider » : deux listes (AM en attente, familles en attente). Ticket #107.
|
||||
/// Section « dossiers à valider » (liste unifiée AM + familles). Ticket #107 / #153.
|
||||
class PendingValidationWidget extends StatefulWidget {
|
||||
final VoidCallback? onRefresh;
|
||||
/// Filtre client (n°, nom, email) — onglet Dossiers (#153).
|
||||
final String searchQuery;
|
||||
/// Si true et liste vide : message court (pas de grand vide centré).
|
||||
final bool compactWhenEmpty;
|
||||
/// Numéros des dossiers pending (pour exclure de « Tous les dossiers »).
|
||||
final ValueChanged<Set<String>>? onPendingNumerosChanged;
|
||||
|
||||
const PendingValidationWidget({super.key, this.onRefresh});
|
||||
const PendingValidationWidget({
|
||||
super.key,
|
||||
this.onRefresh,
|
||||
this.searchQuery = '',
|
||||
this.compactWhenEmpty = false,
|
||||
this.onPendingNumerosChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PendingValidationWidget> createState() => _PendingValidationWidgetState();
|
||||
State<PendingValidationWidget> createState() =>
|
||||
_PendingValidationWidgetState();
|
||||
}
|
||||
|
||||
class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
||||
@@ -21,6 +34,8 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
||||
String? _error;
|
||||
List<AppUser> _pendingAM = [];
|
||||
List<PendingFamily> _pendingFamilies = [];
|
||||
/// Noms enrichis via GET /dossiers/:numero (libelle API = noms seuls).
|
||||
final Map<String, String> _familyNamesByNumero = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -34,24 +49,77 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final am = await UserService.getPendingUsers(role: 'assistante_maternelle');
|
||||
final am =
|
||||
await UserService.getPendingUsers(role: 'assistante_maternelle');
|
||||
final families = await UserService.getPendingFamilies();
|
||||
final namesByNumero = await _enrichFamilyNames(families);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_pendingAM = am;
|
||||
_pendingFamilies = families;
|
||||
_familyNamesByNumero
|
||||
..clear()
|
||||
..addAll(namesByNumero);
|
||||
_isLoading = false;
|
||||
});
|
||||
_emitPendingNumeros();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur inconnue';
|
||||
_error = e is Exception
|
||||
? e.toString().replaceFirst('Exception: ', '')
|
||||
: 'Erreur inconnue';
|
||||
_isLoading = false;
|
||||
});
|
||||
widget.onPendingNumerosChanged?.call(const {});
|
||||
}
|
||||
}
|
||||
|
||||
void _onOpenValidation({String? type, String? id, String? numeroDossier}) {
|
||||
/// Complète `NOM Prénom` via le détail dossier (sans changer le back).
|
||||
Future<Map<String, String>> _enrichFamilyNames(
|
||||
List<PendingFamily> families,
|
||||
) async {
|
||||
final out = <String, String>{};
|
||||
await Future.wait(families.map((f) async {
|
||||
final num = (f.numeroDossier ?? '').trim();
|
||||
if (num.isEmpty) return;
|
||||
try {
|
||||
final dossier = await UserService.getDossier(num);
|
||||
if (!dossier.isFamily) return;
|
||||
final labels = <String>[];
|
||||
final seen = <String>{};
|
||||
for (final p in dossier.asFamily.parents) {
|
||||
final id = p.id.trim();
|
||||
if (id.isNotEmpty && !seen.add(id)) continue;
|
||||
final label = formatDossierPersonLabel(
|
||||
nom: p.nom,
|
||||
prenom: p.prenom,
|
||||
email: p.email,
|
||||
);
|
||||
if (label.isNotEmpty) labels.add(label);
|
||||
}
|
||||
if (labels.isNotEmpty) out[num] = labels.join(' - ');
|
||||
} catch (_) {
|
||||
// Repli libellé API ci-dessous.
|
||||
}
|
||||
}));
|
||||
return out;
|
||||
}
|
||||
|
||||
void _emitPendingNumeros() {
|
||||
final nums = <String>{};
|
||||
for (final u in _pendingAM) {
|
||||
final n = (u.numeroDossier ?? '').trim();
|
||||
if (n.isNotEmpty) nums.add(n);
|
||||
}
|
||||
for (final f in _pendingFamilies) {
|
||||
final n = (f.numeroDossier ?? '').trim();
|
||||
if (n.isNotEmpty) nums.add(n);
|
||||
}
|
||||
widget.onPendingNumerosChanged?.call(nums);
|
||||
}
|
||||
|
||||
void _onOpenValidation({String? numeroDossier}) {
|
||||
final num = numeroDossier?.trim();
|
||||
if (num == null || num.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -73,9 +141,48 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
bool _matchesQuery(String haystack) {
|
||||
final q = widget.searchQuery.trim().toLowerCase();
|
||||
if (q.isEmpty) return true;
|
||||
return haystack.toLowerCase().contains(q);
|
||||
}
|
||||
|
||||
List<AppUser> get _filteredAM {
|
||||
return _pendingAM.where((u) {
|
||||
final bits = [
|
||||
u.numeroDossier ?? '',
|
||||
u.fullName,
|
||||
u.email,
|
||||
u.nom ?? '',
|
||||
u.prenom ?? '',
|
||||
].join(' ');
|
||||
return _matchesQuery(bits);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
List<PendingFamily> get _filteredFamilies {
|
||||
return _pendingFamilies.where((f) {
|
||||
final num = (f.numeroDossier ?? '').trim();
|
||||
final enriched = _familyNamesByNumero[num] ?? '';
|
||||
final bits = [
|
||||
f.numeroDossier ?? '',
|
||||
f.libelle,
|
||||
enriched,
|
||||
f.emails.join(' '),
|
||||
].join(' ');
|
||||
return _matchesQuery(bits);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
if (widget.compactWhenEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_error != null && _error!.isNotEmpty) {
|
||||
@@ -97,14 +204,32 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
final hasAM = _pendingAM.isNotEmpty;
|
||||
final hasFamilies = _pendingFamilies.isNotEmpty;
|
||||
if (!hasAM && !hasFamilies) {
|
||||
final pendingAM = _filteredAM;
|
||||
final pendingFamilies = _filteredFamilies;
|
||||
final cards = <Widget>[
|
||||
...pendingAM.map(_buildAMCard),
|
||||
...pendingFamilies.map(_buildFamilyCard),
|
||||
];
|
||||
|
||||
if (cards.isEmpty) {
|
||||
if (widget.compactWhenEmpty) {
|
||||
final searching = widget.searchQuery.trim().isNotEmpty;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Text(
|
||||
searching
|
||||
? 'Aucun dossier en attente ne correspond à la recherche.'
|
||||
: 'Aucun dossier en attente.',
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 13),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_circle_outline, size: 64, color: Colors.grey.shade400),
|
||||
Icon(Icons.check_circle_outline,
|
||||
size: 64, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Aucun dossier en attente de validation',
|
||||
@@ -117,6 +242,28 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
final list = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Dossiers à valider',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...cards,
|
||||
],
|
||||
);
|
||||
|
||||
if (widget.compactWhenEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: list,
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await _load();
|
||||
@@ -125,275 +272,40 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (hasAM) ...[
|
||||
_sectionTitle('Assistantes maternelles en attente'),
|
||||
const SizedBox(height: 8),
|
||||
..._pendingAM.map((u) => _buildAMCard(u)),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
if (hasFamilies) ...[
|
||||
_sectionTitle('Familles en attente'),
|
||||
const SizedBox(height: 8),
|
||||
..._pendingFamilies.map((f) => _buildFamilyCard(f)),
|
||||
],
|
||||
],
|
||||
),
|
||||
child: list,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionTitle(String title) {
|
||||
return Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
String _amNamesLine(AppUser user) {
|
||||
return formatDossierPersonLabel(
|
||||
nom: user.nom,
|
||||
prenom: user.prenom,
|
||||
email: user.email,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// Sous-titre AM : `email - date • tél. • CP ville` (plan affichage lignes À valider).
|
||||
String _amSubtitleLine(AppUser user) {
|
||||
final email = user.email.trim();
|
||||
final bits = <String>[];
|
||||
bits.add(DateFormat('dd/MM/yyyy').format(user.createdAt.toLocal()));
|
||||
final tel = user.telephone?.trim();
|
||||
if (tel != null && tel.isNotEmpty) {
|
||||
bits.add(formatPhoneForDisplay(tel));
|
||||
}
|
||||
final cp = user.codePostal?.trim();
|
||||
final ville = user.ville?.trim();
|
||||
final loc = [if (cp != null && cp.isNotEmpty) cp, if (ville != null && ville.isNotEmpty) ville]
|
||||
.join(' ')
|
||||
.trim();
|
||||
if (loc.isNotEmpty) bits.add(loc);
|
||||
final infos = bits.join(' • ');
|
||||
if (email.isEmpty) return infos;
|
||||
return '$email - $infos';
|
||||
}
|
||||
|
||||
Widget _buildAMCard(AppUser user) {
|
||||
final numDossier = user.numeroDossier ?? '–';
|
||||
final nameBold =
|
||||
user.fullName.isNotEmpty ? user.fullName : (user.email.isNotEmpty ? user.email : '–');
|
||||
return _PendingValidationRow(
|
||||
icon: Icons.person_outline,
|
||||
title: Text.rich(
|
||||
TextSpan(
|
||||
style: const TextStyle(fontSize: 14, color: Colors.black87),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: nameBold,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' - $numDossier',
|
||||
style: const TextStyle(fontWeight: FontWeight.w400),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
subtitle: _amSubtitleLine(user),
|
||||
subtitleStyle: TextStyle(
|
||||
fontSize: 12,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
onOpen: () => _onOpenValidation(
|
||||
type: 'AM',
|
||||
id: user.id,
|
||||
numeroDossier: user.numeroDossier,
|
||||
),
|
||||
return DossierListCard(
|
||||
numeroDossier: user.numeroDossier ?? '',
|
||||
namesLine: _amNamesLine(user),
|
||||
isFamille: false,
|
||||
photoUrl: user.photoUrl,
|
||||
onOpen: () => _onOpenValidation(numeroDossier: user.numeroDossier),
|
||||
);
|
||||
}
|
||||
|
||||
/// `email, tél., localisation` par parent, puis `date soumission`, puis `nb enfants`.
|
||||
String _familyParentSegment(PendingParentLine p) {
|
||||
final parts = <String>[];
|
||||
final e = p.email?.trim();
|
||||
if (e != null && e.isNotEmpty) parts.add(e);
|
||||
final t = p.telephone?.trim();
|
||||
if (t != null && t.isNotEmpty) parts.add(formatPhoneForDisplay(t));
|
||||
final cp = p.codePostal?.trim();
|
||||
final v = p.ville?.trim();
|
||||
final loc = [if (cp != null && cp.isNotEmpty) cp, if (v != null && v.isNotEmpty) v]
|
||||
.join(' ')
|
||||
.trim();
|
||||
if (loc.isNotEmpty) parts.add(loc);
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
String _familySubtitleLine(PendingFamily family) {
|
||||
final blocks = family.parentLines
|
||||
.map(_familyParentSegment)
|
||||
.where((s) => s.isNotEmpty)
|
||||
.join(' - ');
|
||||
|
||||
final tail = <String>[];
|
||||
final date = family.dateSoumission;
|
||||
if (date != null) {
|
||||
tail.add(DateFormat('dd/MM/yyyy').format(date.toLocal()));
|
||||
}
|
||||
if (family.nombreEnfants > 0) {
|
||||
tail.add(
|
||||
family.nombreEnfants > 1
|
||||
? '${family.nombreEnfants} enfants'
|
||||
: '1 enfant',
|
||||
);
|
||||
}
|
||||
final right = tail.join(' - ');
|
||||
|
||||
if (blocks.isEmpty && right.isEmpty) return '';
|
||||
if (blocks.isEmpty) return right;
|
||||
if (right.isEmpty) return blocks;
|
||||
return '$blocks - $right';
|
||||
}
|
||||
|
||||
Widget _buildFamilyCard(PendingFamily family) {
|
||||
final numDossier = family.numeroDossier ?? '–';
|
||||
final nameBold = family.libelle.isNotEmpty ? family.libelle : 'Famille';
|
||||
return _PendingValidationRow(
|
||||
icon: Icons.family_restroom_outlined,
|
||||
title: Text.rich(
|
||||
TextSpan(
|
||||
style: const TextStyle(fontSize: 14, color: Colors.black87),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: nameBold,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' - $numDossier',
|
||||
style: const TextStyle(fontWeight: FontWeight.w400),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
subtitle: _familySubtitleLine(family),
|
||||
subtitleStyle: TextStyle(
|
||||
fontSize: 12,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
onOpen: () => _onOpenValidation(
|
||||
type: 'famille',
|
||||
id: family.parentIds.isNotEmpty ? family.parentIds.first : null,
|
||||
numeroDossier: family.numeroDossier,
|
||||
),
|
||||
final num = (family.numeroDossier ?? '').trim();
|
||||
final enriched = num.isNotEmpty ? _familyNamesByNumero[num] : null;
|
||||
final names = (enriched != null && enriched.isNotEmpty)
|
||||
? enriched
|
||||
: formatDossierFamilyNamesLine(family.libelle);
|
||||
return DossierListCard(
|
||||
numeroDossier: family.numeroDossier ?? '',
|
||||
namesLine: names,
|
||||
isFamille: true,
|
||||
onOpen: () => _onOpenValidation(numeroDossier: family.numeroDossier),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ligne « À valider » : survol comme [AdminUserCard], icône « Ouvrir » visible au hover uniquement.
|
||||
class _PendingValidationRow extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final Widget title;
|
||||
final String? subtitle;
|
||||
final TextStyle? subtitleStyle;
|
||||
final VoidCallback onOpen;
|
||||
|
||||
const _PendingValidationRow({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.subtitleStyle,
|
||||
required this.onOpen,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_PendingValidationRow> createState() => _PendingValidationRowState();
|
||||
}
|
||||
|
||||
class _PendingValidationRowState extends State<_PendingValidationRow> {
|
||||
bool _isHovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final subStyle = widget.subtitleStyle ??
|
||||
TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
);
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: InkWell(
|
||||
onTap: widget.onOpen,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
hoverColor: const Color(0x149CC5C0),
|
||||
child: Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
side: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(widget.icon, color: Colors.grey.shade600, size: 28),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
widget.title,
|
||||
if (widget.subtitle != null &&
|
||||
widget.subtitle!.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.subtitle!,
|
||||
style: subStyle,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 52,
|
||||
child: Center(
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
opacity: _isHovered ? 1 : 0,
|
||||
child: IgnorePointer(
|
||||
ignoring: !_isHovered,
|
||||
child: IconButtonTheme(
|
||||
data: IconButtonThemeData(
|
||||
style: IconButton.styleFrom(
|
||||
padding: const EdgeInsets.all(0),
|
||||
minimumSize: const Size(48, 48),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: widget.onOpen,
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
iconSize: 34,
|
||||
tooltip: 'Ouvrir',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/am_dossier_create_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/dossiers_management_widget.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/parent_dossier_create_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart';
|
||||
|
||||
class UserManagementPanel extends StatefulWidget {
|
||||
/// Afficher l'onglet Administrateurs (sinon 3 onglets : Gestionnaires, Parents, AM).
|
||||
/// Afficher l'onglet Administrateurs (sinon sans Administrateurs).
|
||||
final bool showAdministrateursTab;
|
||||
|
||||
const UserManagementPanel({
|
||||
@@ -27,48 +27,21 @@ class UserManagementPanel extends StatefulWidget {
|
||||
class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
int _subIndex = 0;
|
||||
int _gestionnaireRefreshTick = 0;
|
||||
int _parentRefreshTick = 0;
|
||||
int _adminRefreshTick = 0;
|
||||
int _enfantRefreshTick = 0;
|
||||
int _amRefreshTick = 0;
|
||||
int _dossiersRefreshTick = 0;
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final TextEditingController _amCapacityController = TextEditingController();
|
||||
String? _parentStatus;
|
||||
String? _enfantStatus;
|
||||
bool _hasPending = false;
|
||||
bool _pendingLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchController.addListener(_onFilterChanged);
|
||||
_amCapacityController.addListener(_onFilterChanged);
|
||||
_loadPending();
|
||||
}
|
||||
|
||||
Future<void> _loadPending() async {
|
||||
try {
|
||||
final am = await UserService.getPendingUsers(role: 'assistante_maternelle');
|
||||
final families = await UserService.getPendingFamilies();
|
||||
if (!mounted) return;
|
||||
final hasPending = am.isNotEmpty || families.isNotEmpty;
|
||||
setState(() {
|
||||
final hadPending = _hasPending;
|
||||
_hasPending = hasPending;
|
||||
_pendingLoading = false;
|
||||
// Si on passe à "plus de dossiers", recaler l'index (onglet À valider disparaît).
|
||||
if (hadPending && !hasPending) {
|
||||
_subIndex = (_subIndex > 0 ? _subIndex - 1 : 0).clamp(0, _tabLabels.length - 1);
|
||||
} else if (!hadPending && hasPending) {
|
||||
_subIndex = 0; // Afficher l'onglet À valider
|
||||
}
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_hasPending = false;
|
||||
_pendingLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -85,13 +58,19 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
/// Ordre #153 : Dossiers | Parents | Enfants | AM | Gestionnaires | (Admin).
|
||||
List<String> get _tabLabels {
|
||||
const base = ['Parents', 'Enfants', 'Assistantes maternelles', 'Gestionnaires'];
|
||||
final withAdmin = [...base, 'Administrateurs'];
|
||||
final list = widget.showAdministrateursTab ? withAdmin : base;
|
||||
// Onglet « À valider » visible seulement s'il y a des dossiers en attente (ticket #107).
|
||||
if (!_pendingLoading && _hasPending) return ['À valider', ...list];
|
||||
return list;
|
||||
const base = [
|
||||
'Dossiers',
|
||||
'Parents',
|
||||
'Enfants',
|
||||
'Assistantes maternelles',
|
||||
'Gestionnaires',
|
||||
];
|
||||
if (widget.showAdministrateursTab) {
|
||||
return [...base, 'Administrateurs'];
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
void _onSubTabChange(int index) {
|
||||
@@ -105,31 +84,35 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Index du contenu : -1 = À valider, 0 = Parents, 1 = Enfants, 2 = AM, 3 = Gestionnaires, 4 = Admin.
|
||||
int get _contentIndexOffset => (_hasPending && !_pendingLoading) ? 1 : 0;
|
||||
bool get _isDossiersTab => _subIndex == 0;
|
||||
|
||||
String _searchHintForTab() {
|
||||
final contentIndex = _subIndex - _contentIndexOffset;
|
||||
switch (contentIndex) {
|
||||
case -1:
|
||||
return 'À valider (pas de recherche)';
|
||||
switch (_subIndex) {
|
||||
case 0:
|
||||
return 'Rechercher un parent...';
|
||||
return 'Rechercher un dossier';
|
||||
case 1:
|
||||
return 'Rechercher un enfant...';
|
||||
return 'Rechercher un parent...';
|
||||
case 2:
|
||||
return 'Rechercher une assistante...';
|
||||
return 'Rechercher un enfant...';
|
||||
case 3:
|
||||
return 'Rechercher un gestionnaire...';
|
||||
return 'Rechercher une assistante...';
|
||||
case 4:
|
||||
return 'Rechercher un gestionnaire...';
|
||||
case 5:
|
||||
return 'Rechercher un administrateur...';
|
||||
default:
|
||||
return 'Rechercher...';
|
||||
}
|
||||
}
|
||||
|
||||
String? _searchTooltipForTab() {
|
||||
if (_subIndex != 0) return null;
|
||||
return 'Recherche possible : n° de dossier, nom, prénom ou e-mail.';
|
||||
}
|
||||
|
||||
Widget? _subBarFilterControl() {
|
||||
if (_subIndex == _contentIndexOffset + 0) {
|
||||
// Parents
|
||||
if (_subIndex == 1) {
|
||||
return DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String?>(
|
||||
value: _parentStatus,
|
||||
@@ -184,7 +167,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
);
|
||||
}
|
||||
|
||||
if (_subIndex == _contentIndexOffset + 1) {
|
||||
// Enfants
|
||||
if (_subIndex == 2) {
|
||||
return DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String?>(
|
||||
value: _enfantStatus,
|
||||
@@ -239,7 +223,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
);
|
||||
}
|
||||
|
||||
if (_subIndex == _contentIndexOffset + 2) {
|
||||
// AM
|
||||
if (_subIndex == 3) {
|
||||
return TextField(
|
||||
controller: _amCapacityController,
|
||||
decoration: const InputDecoration(
|
||||
@@ -256,34 +241,36 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
final contentIndex = _subIndex - _contentIndexOffset;
|
||||
if (_hasPending && !_pendingLoading && contentIndex == -1) {
|
||||
return PendingValidationWidget(onRefresh: _loadPending);
|
||||
}
|
||||
switch (contentIndex) {
|
||||
switch (_subIndex) {
|
||||
case 0:
|
||||
return DossiersManagementWidget(
|
||||
key: ValueKey('dossiers-$_dossiersRefreshTick'),
|
||||
searchQuery: _searchController.text,
|
||||
);
|
||||
case 1:
|
||||
return ParentManagementWidget(
|
||||
key: ValueKey('parents-$_parentRefreshTick'),
|
||||
searchQuery: _searchController.text,
|
||||
statusFilter: _parentStatus,
|
||||
);
|
||||
case 1:
|
||||
case 2:
|
||||
return EnfantManagementWidget(
|
||||
key: ValueKey('enfants-$_enfantRefreshTick'),
|
||||
searchQuery: _searchController.text,
|
||||
statusFilter: _enfantStatus,
|
||||
);
|
||||
case 2:
|
||||
case 3:
|
||||
return AssistanteMaternelleManagementWidget(
|
||||
key: ValueKey('ams-$_amRefreshTick'),
|
||||
searchQuery: _searchController.text,
|
||||
capacityMin: int.tryParse(_amCapacityController.text),
|
||||
);
|
||||
case 3:
|
||||
case 4:
|
||||
return GestionnaireManagementWidget(
|
||||
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
|
||||
searchQuery: _searchController.text,
|
||||
);
|
||||
case 4:
|
||||
case 5:
|
||||
return AdminManagementWidget(
|
||||
key: ValueKey('admins-$_adminRefreshTick'),
|
||||
searchQuery: _searchController.text,
|
||||
@@ -296,7 +283,6 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final labels = _tabLabels;
|
||||
final isAValiderTab = _hasPending && !_pendingLoading && _subIndex == 0;
|
||||
return Column(
|
||||
children: [
|
||||
DashboardUserManagementSubBar(
|
||||
@@ -304,8 +290,10 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
onSubTabChange: _onSubTabChange,
|
||||
searchController: _searchController,
|
||||
searchHint: _searchHintForTab(),
|
||||
searchTooltip: _searchTooltipForTab(),
|
||||
filterControl: _subBarFilterControl(),
|
||||
onAddPressed: isAValiderTab ? null : _handleAddPressed,
|
||||
// Pas de « Créer » sur l’onglet Dossiers (#153).
|
||||
onAddPressed: _isDossiersTab ? null : _handleAddPressed,
|
||||
addLabel: 'Ajouter',
|
||||
subTabCount: labels.length,
|
||||
tabLabels: labels,
|
||||
@@ -316,9 +304,29 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
}
|
||||
|
||||
Future<void> _handleAddPressed() async {
|
||||
final contentIndex = _subIndex - _contentIndexOffset;
|
||||
// 1 Parents, 2 Enfants, 3 AM, 4 Gestionnaires, 5 Admin
|
||||
if (_subIndex == 1) {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) {
|
||||
return ParentDossierCreateModal(
|
||||
onClose: () => Navigator.of(dialogContext).pop(),
|
||||
onSuccess: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_parentRefreshTick++;
|
||||
_dossiersRefreshTick++;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (contentIndex == 1) {
|
||||
if (_subIndex == 2) {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
@@ -334,7 +342,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (contentIndex == 2) {
|
||||
if (_subIndex == 3) {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
@@ -344,7 +352,10 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
onSuccess: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
if (!mounted) return;
|
||||
setState(() => _amRefreshTick++);
|
||||
setState(() {
|
||||
_amRefreshTick++;
|
||||
_dossiersRefreshTick++;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -352,7 +363,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (contentIndex == 3) {
|
||||
if (_subIndex == 4) {
|
||||
final created = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
@@ -370,7 +381,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (contentIndex == 4) {
|
||||
if (_subIndex == 5) {
|
||||
final created = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
@@ -388,16 +399,6 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
_adminRefreshTick++;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'La création parent sera disponible avec le ticket #129.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:p_tits_pas/models/dossier_unifie.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/services/user_service.dart';
|
||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
||||
import 'package:p_tits_pas/widgets/common/identity_block.dart';
|
||||
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
||||
import 'validation_modal_theme.dart';
|
||||
import 'validation_refus_form.dart';
|
||||
import 'validation_valider_confirm_dialog.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart';
|
||||
|
||||
/// Wizard de validation dossier famille : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107.
|
||||
class ValidationFamilyWizard extends StatefulWidget {
|
||||
/// Wrapper historique (#107) — délègue à [ParentDossierWizard.review].
|
||||
class ValidationFamilyWizard extends StatelessWidget {
|
||||
final DossierFamille dossier;
|
||||
final VoidCallback onClose;
|
||||
final VoidCallback onSuccess;
|
||||
@@ -28,697 +17,13 @@ class ValidationFamilyWizard extends StatefulWidget {
|
||||
this.onStepChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ValidationFamilyWizard> createState() => _ValidationFamilyWizardState();
|
||||
}
|
||||
|
||||
class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
|
||||
int _step = 0;
|
||||
bool _showRefusForm = false;
|
||||
bool _submitting = false;
|
||||
final ScrollController _enfantsScrollController = ScrollController();
|
||||
|
||||
/// Même logique que [ParentRegisterStep3Screen] : masque alpha sur les bords (ShaderMask dstIn).
|
||||
bool _enfantsIsScrollable = false;
|
||||
bool _enfantsFadeLeft = false;
|
||||
bool _enfantsFadeRight = false;
|
||||
|
||||
/// Fraction de la largeur du viewport pour le fondu (identique inscription étape 3).
|
||||
static const double _enfantsFadeExtent = 0.05;
|
||||
|
||||
int get _stepCount => 4;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_enfantsScrollController.addListener(_syncEnfantsScrollFades);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_enfantsScrollController.removeListener(_syncEnfantsScrollFades);
|
||||
_enfantsScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
|
||||
|
||||
void _syncEnfantsScrollFades() {
|
||||
if (!mounted) return;
|
||||
if (!_enfantsScrollController.hasClients) {
|
||||
if (_enfantsFadeLeft || _enfantsFadeRight || _enfantsIsScrollable) {
|
||||
setState(() {
|
||||
_enfantsIsScrollable = false;
|
||||
_enfantsFadeLeft = false;
|
||||
_enfantsFadeRight = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
final p = _enfantsScrollController.position;
|
||||
final scrollable = p.maxScrollExtent > 0;
|
||||
final left = scrollable &&
|
||||
p.pixels > (p.viewportDimension * _enfantsFadeExtent / 2);
|
||||
final right = scrollable &&
|
||||
p.pixels <
|
||||
(p.maxScrollExtent -
|
||||
(p.viewportDimension * _enfantsFadeExtent / 2));
|
||||
if (scrollable != _enfantsIsScrollable ||
|
||||
left != _enfantsFadeLeft ||
|
||||
right != _enfantsFadeRight) {
|
||||
setState(() {
|
||||
_enfantsIsScrollable = scrollable;
|
||||
_enfantsFadeLeft = left;
|
||||
_enfantsFadeRight = right;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bool get _isEnAttente => widget.dossier.isEnAttente;
|
||||
|
||||
String? get _firstParentId => widget.dossier.parents.isNotEmpty
|
||||
? widget.dossier.parents.first.id
|
||||
: null;
|
||||
|
||||
static String _v(String? s) =>
|
||||
(s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini';
|
||||
|
||||
/// Date de naissance en jour/mois/année (dd/MM/yyyy).
|
||||
static String _formatBirthDate(String? s) =>
|
||||
formatIsoDateFr(s, ifEmpty: 'Non défini');
|
||||
|
||||
static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_showRefusForm) {
|
||||
return _buildRefusPage();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Expanded(child: _buildStepContent()),
|
||||
const SizedBox(height: 24),
|
||||
_buildNavigation(),
|
||||
],
|
||||
),
|
||||
return ParentDossierWizard.review(
|
||||
dossier: dossier,
|
||||
onClose: onClose,
|
||||
onSuccess: onSuccess,
|
||||
onStepChanged: onStepChanged,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStepContent() {
|
||||
final d = widget.dossier;
|
||||
switch (_step) {
|
||||
case 0:
|
||||
return IdentityBlock.readOnlyFromParentDossier(
|
||||
d.parents.first,
|
||||
title: 'Parent principal',
|
||||
);
|
||||
case 1:
|
||||
return _buildParent2Step();
|
||||
case 2:
|
||||
return _buildEnfantsStep();
|
||||
case 3:
|
||||
return _buildPresentationStep();
|
||||
default:
|
||||
return const SizedBox();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildParent2Step() {
|
||||
if (widget.dossier.parents.length < 2) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text('Un seul parent pour ce dossier.',
|
||||
style: TextStyle(color: Colors.black87)),
|
||||
);
|
||||
}
|
||||
return IdentityBlock.readOnlyFromParentDossier(
|
||||
widget.dossier.parents[1],
|
||||
title: 'Deuxième parent',
|
||||
);
|
||||
}
|
||||
|
||||
static const double _idPhotoAspectRatio = 35 / 45;
|
||||
|
||||
Widget _buildEnfantsStep() {
|
||||
final enfants = widget.dossier.enfants;
|
||||
if (enfants.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text('Aucun enfant renseigné.',
|
||||
style: TextStyle(color: Colors.black87)),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Enfants',
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final cardHeight = constraints.maxHeight;
|
||||
// Carte large : 1/3 photo + 2/3 champs (scroll horizontal si plusieurs enfants).
|
||||
final cardWidth = (cardHeight * 1.72).clamp(500.0, 700.0);
|
||||
return NotificationListener<ScrollMetricsNotification>(
|
||||
onNotification: (_) {
|
||||
_syncEnfantsScrollFades();
|
||||
return false;
|
||||
},
|
||||
child: ShaderMask(
|
||||
blendMode: BlendMode.dstIn,
|
||||
shaderCallback: (Rect bounds) {
|
||||
final stops = <double>[
|
||||
0.0,
|
||||
_enfantsFadeExtent,
|
||||
1.0 - _enfantsFadeExtent,
|
||||
1.0,
|
||||
];
|
||||
if (!_enfantsIsScrollable) {
|
||||
return LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: const <Color>[
|
||||
Colors.black,
|
||||
Colors.black,
|
||||
Colors.black,
|
||||
Colors.black,
|
||||
],
|
||||
stops: stops,
|
||||
).createShader(bounds);
|
||||
}
|
||||
final leftMask =
|
||||
_enfantsFadeLeft ? Colors.transparent : Colors.black;
|
||||
final rightMask =
|
||||
_enfantsFadeRight ? Colors.transparent : Colors.black;
|
||||
return LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: <Color>[
|
||||
leftMask,
|
||||
Colors.black,
|
||||
Colors.black,
|
||||
rightMask,
|
||||
],
|
||||
stops: stops,
|
||||
).createShader(bounds);
|
||||
},
|
||||
child: Listener(
|
||||
onPointerSignal: (event) {
|
||||
if (event is PointerScrollEvent &&
|
||||
_enfantsScrollController.hasClients) {
|
||||
final offset = _enfantsScrollController.offset +
|
||||
event.scrollDelta.dy;
|
||||
_enfantsScrollController.jumpTo(offset.clamp(
|
||||
_enfantsScrollController.position.minScrollExtent,
|
||||
_enfantsScrollController.position.maxScrollExtent,
|
||||
));
|
||||
}
|
||||
},
|
||||
child: ListView.builder(
|
||||
controller: _enfantsScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: enfants.length,
|
||||
itemBuilder: (_, i) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: i < enfants.length - 1 ? 16 : 0),
|
||||
child: SizedBox(
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
child: _buildEnfantCard(enfants[i]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Fond carte enfant : teintes très pastel ; bordure discrète ; accent léger (barre).
|
||||
static const Color _enfantCardBoyBg = Color(0xFFF0F7FB);
|
||||
static const Color _enfantCardBoyBorder = Color(0xFFE3EDF4);
|
||||
static const Color _enfantCardGirlBg = Color(0xFFFCF5F8);
|
||||
static const Color _enfantCardGirlBorder = Color(0xFFEAE3E7);
|
||||
|
||||
static const double _enfantCardRadius = 12;
|
||||
|
||||
static List<BoxShadow> _enfantCardShadows() => [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.06),
|
||||
blurRadius: 14,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
];
|
||||
|
||||
static BoxDecoration _enfantCardDecoration(String? gender) {
|
||||
final g = (gender ?? '').trim().toUpperCase();
|
||||
if (g == 'H') {
|
||||
return BoxDecoration(
|
||||
color: _enfantCardBoyBg,
|
||||
borderRadius: BorderRadius.circular(_enfantCardRadius),
|
||||
border: Border.all(color: _enfantCardBoyBorder, width: 1),
|
||||
boxShadow: _enfantCardShadows(),
|
||||
);
|
||||
}
|
||||
if (g == 'F') {
|
||||
return BoxDecoration(
|
||||
color: _enfantCardGirlBg,
|
||||
borderRadius: BorderRadius.circular(_enfantCardRadius),
|
||||
border: Border.all(color: _enfantCardGirlBorder, width: 1),
|
||||
boxShadow: _enfantCardShadows(),
|
||||
);
|
||||
}
|
||||
return BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(_enfantCardRadius),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
boxShadow: _enfantCardShadows(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Carte enfant : prénom pleine largeur, puis ligne photo 1/3 + colonne 2/3 (champs + statut hors TF si besoin).
|
||||
Widget _buildEnfantCard(EnfantDossier e) {
|
||||
final photoUrl = _fullPhotoUrl(e.photoUrl);
|
||||
final columnStatusLabel = _enfantColumnStatusLabel(e);
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(_enfantCardRadius),
|
||||
child: Container(
|
||||
decoration: _enfantCardDecoration(e.gender),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||
child: _enfantLabeledField('Prénom', _v(e.firstName)),
|
||||
),
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
// Même marge gauche que le bloc « Prénom » (12) ; droite / haut / bas 8.
|
||||
const padL = 12.0;
|
||||
const padR = 8.0;
|
||||
const padV = 8.0;
|
||||
final maxW =
|
||||
(c.maxWidth - padL - padR).clamp(0.0, double.infinity);
|
||||
final maxH =
|
||||
(c.maxHeight - 2 * padV).clamp(0.0, double.infinity);
|
||||
const ar = _idPhotoAspectRatio;
|
||||
double ph = maxH;
|
||||
double pw = ph * ar;
|
||||
if (pw > maxW) {
|
||||
pw = maxW;
|
||||
ph = pw / ar;
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(padL, padV, padR, padV),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: _buildEnfantPhotoSlot(photoUrl, pw, ph),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 4, 14, 12),
|
||||
child: columnStatusLabel == null
|
||||
? SingleChildScrollView(
|
||||
child: _buildEnfantInfoFields(e),
|
||||
)
|
||||
: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: _buildEnfantInfoFields(e),
|
||||
),
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: Text(
|
||||
columnStatusLabel,
|
||||
textAlign: TextAlign.center,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 14,
|
||||
fontStyle: FontStyle.italic,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey.shade800,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Statut dans la colonne 2/3 (scolarisé·e, à naître, sans garde, en garde).
|
||||
String? _enfantColumnStatusLabel(EnfantDossier e) {
|
||||
return enfantColumnStatusLabel(status: e.status, gender: e.gender);
|
||||
}
|
||||
|
||||
/// Nom ; date de naissance et genre sur une ligne (prénom au-dessus, pleine largeur).
|
||||
Widget _buildEnfantInfoFields(EnfantDossier e) {
|
||||
final isANaitre = (e.status ?? '').trim().toLowerCase() == 'a_naitre';
|
||||
final dueDateRenseignee = e.dueDate != null && e.dueDate!.trim().isNotEmpty;
|
||||
final dateValue = isANaitre
|
||||
? (dueDateRenseignee ? '${_formatBirthDate(e.dueDate)} (P)' : '– (P)')
|
||||
: _formatBirthDate(e.birthDate);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _enfantLabeledField('Nom', _formatNom(e.lastName)),
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: _enfantLabeledField('Date de naissance', dateValue),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _enfantLabeledField(
|
||||
'Genre',
|
||||
_genreEnfantLabel(e.gender, e.status),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _enfantLabeledField(String label, String value) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ValidationReadOnlyField(value: value),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEnfantPhotoSlot(String photoUrl, double width, double height) {
|
||||
const photoRadius = 8.0;
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(photoRadius),
|
||||
border: Border.all(color: Colors.black.withOpacity(0.08)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: photoUrl.isEmpty
|
||||
? ColoredBox(
|
||||
color: Colors.grey.shade100,
|
||||
child: Center(
|
||||
child: Icon(Icons.person_outline, size: 32, color: Colors.grey.shade400),
|
||||
),
|
||||
)
|
||||
: AuthNetworkImage(
|
||||
url: photoUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: width,
|
||||
height: height,
|
||||
errorBuilder: (_, __, ___) => ColoredBox(
|
||||
color: Colors.grey.shade100,
|
||||
child: Center(
|
||||
child: Icon(Icons.broken_image_outlined, size: 32, color: Colors.grey.shade400),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _formatNom(String? lastName) {
|
||||
final n = (lastName ?? '').trim().toUpperCase();
|
||||
return n.isEmpty ? 'Non défini' : n;
|
||||
}
|
||||
|
||||
/// Genre enfant : Garçon, Fille, ou "Non connu" (uniquement si l'enfant est à naître).
|
||||
static String _genreEnfantLabel(String? gender, String? status) {
|
||||
final g = (gender ?? '').trim().toUpperCase();
|
||||
final isANaitre = (status ?? '').trim().toLowerCase() == 'a_naitre';
|
||||
if (g == 'H') return 'Garçon';
|
||||
if (g == 'F') return 'Fille';
|
||||
if (isANaitre) return 'Non connu';
|
||||
if (g.isEmpty) return 'Non défini';
|
||||
return (gender ?? '').trim();
|
||||
}
|
||||
|
||||
Widget _buildPresentationStep() {
|
||||
final p = widget.dossier.presentation ?? '';
|
||||
final text = p.trim().isEmpty ? 'Non défini' : p;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Présentation',
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: SelectableText(
|
||||
text,
|
||||
style:
|
||||
const TextStyle(color: Colors.black87, fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNavigation() {
|
||||
if (_step == 3) {
|
||||
return Row(
|
||||
children: [
|
||||
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
|
||||
const Spacer(),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() => _step = 2);
|
||||
_emitStep();
|
||||
},
|
||||
child: const Text('Précédent'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (_isEnAttente && _firstParentId != null) ...[
|
||||
OutlinedButton(
|
||||
onPressed: _submitting ? null : _refuser,
|
||||
child: const Text('Refuser')),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
style: ValidationModalTheme.primaryElevatedStyle,
|
||||
onPressed: _submitting ? null : _onValiderPressed,
|
||||
child: Text(_submitting ? 'Envoi...' : 'Valider'),
|
||||
),
|
||||
] else if (!_isEnAttente)
|
||||
ElevatedButton(
|
||||
style: ValidationModalTheme.primaryElevatedStyle,
|
||||
onPressed: widget.onClose,
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
children: [
|
||||
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
|
||||
const Spacer(),
|
||||
if (_step > 0) ...[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() => _step--);
|
||||
_emitStep();
|
||||
},
|
||||
child: const Text('Précédent'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
ElevatedButton(
|
||||
style: ValidationModalTheme.primaryElevatedStyle,
|
||||
onPressed: () {
|
||||
setState(() => _step++);
|
||||
_emitStep();
|
||||
},
|
||||
child: const Text('Suivant'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onValiderPressed() async {
|
||||
if (_submitting || _firstParentId == null) return;
|
||||
final ok = await showValidationValiderConfirmDialog(
|
||||
context,
|
||||
body:
|
||||
'Voulez-vous valider ce dossier famille ? Les comptes parents concernés seront confirmés.',
|
||||
);
|
||||
if (!mounted || !ok) return;
|
||||
await _valider();
|
||||
}
|
||||
|
||||
Future<void> _valider() async {
|
||||
if (_submitting || _firstParentId == null) return;
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
await UserService.validerDossierFamille(_firstParentId!);
|
||||
if (!mounted) return;
|
||||
widget.onSuccess();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e is Exception
|
||||
? e.toString().replaceFirst('Exception: ', '')
|
||||
: 'Erreur'),
|
||||
backgroundColor: Colors.red.shade700,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _refuser() => setState(() => _showRefusForm = true);
|
||||
|
||||
Widget _buildRefusPage() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ValidationRefusForm(
|
||||
isSubmitting: _submitting,
|
||||
onCancel: widget.onClose,
|
||||
onPrevious: () => setState(() => _showRefusForm = false),
|
||||
onSubmit: (comment) {
|
||||
if (comment == null || comment.trim().isEmpty) return;
|
||||
_refuserEnvoyer(comment.trim());
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Un seul appel : le back refuse tout le dossier famille (co-parents + mails). Ticket #110.
|
||||
Future<void> _refuserEnvoyer(String comment) async {
|
||||
if (_submitting) return;
|
||||
final parentId = _firstParentId?.trim();
|
||||
if (parentId == null || parentId.isEmpty || !_isEnAttente) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Aucun compte en attente à refuser pour ce dossier.'),
|
||||
backgroundColor: Colors.red.shade700,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
await UserService.refuseUser(parentId, comment: comment);
|
||||
if (!mounted) return;
|
||||
final nbEnAttente = widget.dossier.parents
|
||||
.where((p) => p.statut == 'en_attente')
|
||||
.length;
|
||||
final msg = nbEnAttente > 1
|
||||
? 'Refus enregistré. Un e-mail de reprise a été envoyé à chaque parent du dossier.'
|
||||
: 'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(msg),
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
widget.onSuccess();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e is Exception
|
||||
? e.toString().replaceFirst('Exception: ', '')
|
||||
: 'Erreur'),
|
||||
backgroundColor: Colors.red.shade700,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user