Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cd180bf6a | ||
|
|
ca07d2e111 | ||
|
|
67336c64fe | ||
|
|
97afbbcf9a | ||
|
|
e235b30140 | ||
|
|
7f23356273 | ||
|
|
c8c9cfbc4d | ||
|
|
530e896b66 | ||
|
|
0029c5ab86 | ||
|
|
2ce9e9215f | ||
|
|
3fdd913367 | ||
|
|
6708f73b06 | ||
|
|
f596f062a6 |
@@ -18,6 +18,7 @@ import { AppConfigModule } from './modules/config/config.module';
|
|||||||
import { DocumentsLegauxModule } from './modules/documents-legaux';
|
import { DocumentsLegauxModule } from './modules/documents-legaux';
|
||||||
import { RelaisModule } from './routes/relais/relais.module';
|
import { RelaisModule } from './routes/relais/relais.module';
|
||||||
import { DossiersModule } from './routes/dossiers/dossiers.module';
|
import { DossiersModule } from './routes/dossiers/dossiers.module';
|
||||||
|
import { SuppressionsModule } from './routes/suppressions/suppressions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -57,6 +58,7 @@ import { DossiersModule } from './routes/dossiers/dossiers.module';
|
|||||||
DocumentsLegauxModule,
|
DocumentsLegauxModule,
|
||||||
RelaisModule,
|
RelaisModule,
|
||||||
DossiersModule,
|
DossiersModule,
|
||||||
|
SuppressionsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { MailService } from 'src/modules/mail/mail.service';
|
|||||||
import { ParentsService } from '../parents/parents.service';
|
import { ParentsService } from '../parents/parents.service';
|
||||||
import { DossiersService } from '../dossiers/dossiers.service';
|
import { DossiersService } from '../dossiers/dossiers.service';
|
||||||
import { DossierAmCompletDto } from '../dossiers/dto/dossier-am-complet.dto';
|
import { DossierAmCompletDto } from '../dossiers/dto/dossier-am-complet.dto';
|
||||||
|
import { StaffAddCoParentDto } from '../parents/dto/staff-add-co-parent.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -718,6 +719,167 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute un co-parent à un foyer existant (mono-parent) — ticket #135.
|
||||||
|
* Compte actif + mail création MDP + liens Parents bidirectionnels + enfants du foyer.
|
||||||
|
*/
|
||||||
|
async addCoParentStaff(pivotUserId: string, dto: StaffAddCoParentDto) {
|
||||||
|
const pivotParent = await this.parentsRepo.findOne({
|
||||||
|
where: { user_id: pivotUserId },
|
||||||
|
relations: ['user', 'co_parent', 'parentChildren'],
|
||||||
|
});
|
||||||
|
if (!pivotParent?.user) {
|
||||||
|
throw new NotFoundException('Parent introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pivotParent.co_parent) {
|
||||||
|
throw new BadRequestException('Ce foyer a déjà un co-parent.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeroDossier = pivotParent.numero_dossier?.trim() || pivotParent.user.numero_dossier?.trim();
|
||||||
|
if (!numeroDossier) {
|
||||||
|
throw new BadRequestException("Ce parent n'a pas de numéro de dossier.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sameDossierCount = await this.parentsRepo.count({
|
||||||
|
where: { numero_dossier: numeroDossier },
|
||||||
|
});
|
||||||
|
if (sameDossierCount >= 2) {
|
||||||
|
throw new BadRequestException('Ce dossier a déjà deux responsables.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = dto.email.trim().toLowerCase();
|
||||||
|
if (pivotParent.user.email.trim().toLowerCase() === email) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"L'email du co-parent doit être différent de celui du parent principal.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailExiste = await this.usersService.findByEmailOrNull(dto.email);
|
||||||
|
if (emailExiste) {
|
||||||
|
throw new ConflictException("L'email du co-parent est déjà utilisé");
|
||||||
|
}
|
||||||
|
|
||||||
|
const memeAdresse = dto.meme_adresse ?? true;
|
||||||
|
if (!memeAdresse) {
|
||||||
|
if (!dto.adresse?.trim() || !dto.ville?.trim() || !dto.code_postal?.trim()) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Adresse, code postal et ville du co-parent sont requis si meme_adresse est faux.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const joursExpirationToken = await this.appConfigService.get<number>(
|
||||||
|
'password_reset_token_expiry_days',
|
||||||
|
7,
|
||||||
|
);
|
||||||
|
const tokenCreationMdp = crypto.randomUUID();
|
||||||
|
const dateExpiration = new Date();
|
||||||
|
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken);
|
||||||
|
|
||||||
|
let coParent: Users;
|
||||||
|
|
||||||
|
try {
|
||||||
|
coParent = await this.usersRepo.manager.transaction(async (manager) => {
|
||||||
|
const pivotUser = await manager.findOne(Users, {
|
||||||
|
where: { id: pivotUserId },
|
||||||
|
});
|
||||||
|
if (!pivotUser) {
|
||||||
|
throw new NotFoundException('Parent introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pivotEntite = await manager.findOne(Parents, {
|
||||||
|
where: { user_id: pivotUserId },
|
||||||
|
relations: ['parentChildren'],
|
||||||
|
});
|
||||||
|
if (!pivotEntite) {
|
||||||
|
throw new NotFoundException('Parent introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const coUser = manager.create(Users, {
|
||||||
|
email: dto.email.trim(),
|
||||||
|
prenom: dto.prenom,
|
||||||
|
nom: dto.nom,
|
||||||
|
role: RoleType.PARENT,
|
||||||
|
statut: StatutUtilisateurType.ACTIF,
|
||||||
|
telephone: dto.telephone,
|
||||||
|
adresse: memeAdresse ? pivotUser.adresse : dto.adresse,
|
||||||
|
code_postal: memeAdresse ? pivotUser.code_postal : dto.code_postal,
|
||||||
|
ville: memeAdresse ? pivotUser.ville : dto.ville,
|
||||||
|
token_creation_mdp: tokenCreationMdp,
|
||||||
|
token_creation_mdp_expire_le: dateExpiration,
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
|
});
|
||||||
|
const coUserSaved = await manager.save(Users, coUser);
|
||||||
|
|
||||||
|
pivotEntite.co_parent = coUserSaved;
|
||||||
|
pivotEntite.numero_dossier = numeroDossier;
|
||||||
|
await manager.save(Parents, pivotEntite);
|
||||||
|
|
||||||
|
const coEntite = manager.create(Parents, {
|
||||||
|
user_id: coUserSaved.id,
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
|
});
|
||||||
|
coEntite.user = coUserSaved;
|
||||||
|
coEntite.co_parent = pivotUser;
|
||||||
|
await manager.save(Parents, coEntite);
|
||||||
|
|
||||||
|
const enfantIds = (pivotEntite.parentChildren ?? [])
|
||||||
|
.map((pc) => pc.enfantId)
|
||||||
|
.filter(Boolean);
|
||||||
|
for (const enfantId of enfantIds) {
|
||||||
|
const existing = await manager.findOne(ParentsChildren, {
|
||||||
|
where: { parentId: coUserSaved.id, enfantId },
|
||||||
|
});
|
||||||
|
if (existing) continue;
|
||||||
|
await manager.save(
|
||||||
|
ParentsChildren,
|
||||||
|
manager.create(ParentsChildren, {
|
||||||
|
parentId: coUserSaved.id,
|
||||||
|
enfantId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return coUserSaved;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (this.isPostgresUniqueViolation(err)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Un compte avec cet email existe déjà (contrainte unique en base).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.mailService.sendValidatedAccountPasswordSetupEmail(
|
||||||
|
{
|
||||||
|
email: coParent.email,
|
||||||
|
prenom: coParent.prenom ?? '',
|
||||||
|
nom: coParent.nom ?? '',
|
||||||
|
token: tokenCreationMdp,
|
||||||
|
numeroDossier,
|
||||||
|
},
|
||||||
|
'parent',
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
'[addCoParentStaff] Échec envoi email création MDP (co-parent conservé)',
|
||||||
|
err instanceof Error ? err.stack : String(err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
'Co-parent ajouté au foyer. Un e-mail de création de mot de passe a été envoyé.',
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
|
parent_user_id: pivotUserId,
|
||||||
|
co_parent_user_id: coParent.id,
|
||||||
|
statut: StatutUtilisateurType.ACTIF,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cœur partagé création dossier AM (#156).
|
* Cœur partagé création dossier AM (#156).
|
||||||
* - public : statut en_attente + mail pending
|
* - public : statut en_attente + mail pending
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { DossiersController } from './dossiers.controller';
|
||||||
|
import { DossiersService } from './dossiers.service';
|
||||||
|
import { SuppressionService } from '../suppressions/suppression.service';
|
||||||
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
|
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
|
describe('DossiersController', () => {
|
||||||
|
let controller: DossiersController;
|
||||||
|
const dossiersServiceMock = {
|
||||||
|
listDossiers: jest.fn(),
|
||||||
|
getDossierByNumero: jest.fn(),
|
||||||
|
};
|
||||||
|
const suppressionServiceMock = {
|
||||||
|
deleteDossier: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [DossiersController],
|
||||||
|
providers: [
|
||||||
|
{ provide: DossiersService, useValue: dossiersServiceMock },
|
||||||
|
{ provide: SuppressionService, useValue: suppressionServiceMock },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.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');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove delegates to suppressionService.deleteDossier', async () => {
|
||||||
|
const user = { id: 'u1', role: 'gestionnaire' } as never;
|
||||||
|
suppressionServiceMock.deleteDossier.mockResolvedValue({
|
||||||
|
type: 'famille',
|
||||||
|
deleted_user_ids: [],
|
||||||
|
deleted_enfant_ids: [],
|
||||||
|
message: 'ok',
|
||||||
|
});
|
||||||
|
await controller.remove('2026-000001', user);
|
||||||
|
expect(suppressionServiceMock.deleteDossier).toHaveBeenCalledWith(
|
||||||
|
'2026-000001',
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,17 +1,58 @@
|
|||||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
import {
|
||||||
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
Controller,
|
||||||
|
Delete,
|
||||||
|
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 { Roles } from 'src/common/decorators/roles.decorator';
|
||||||
import { RoleType } from 'src/entities/users.entity';
|
import { RoleType, Users } from 'src/entities/users.entity';
|
||||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
|
import { User } from 'src/common/decorators/user.decorator';
|
||||||
import { DossiersService } from './dossiers.service';
|
import { DossiersService } from './dossiers.service';
|
||||||
|
import { SuppressionService } from '../suppressions/suppression.service';
|
||||||
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
||||||
|
import { DossierListItemDto } from './dto/dossier-list-item.dto';
|
||||||
|
|
||||||
@ApiTags('Dossiers')
|
@ApiTags('Dossiers')
|
||||||
|
@ApiBearerAuth('access-token')
|
||||||
@Controller('dossiers')
|
@Controller('dossiers')
|
||||||
@UseGuards(AuthGuard, RolesGuard)
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
export class DossiersController {
|
export class DossiersController {
|
||||||
constructor(private readonly dossiersService: DossiersService) {}
|
constructor(
|
||||||
|
private readonly dossiersService: DossiersService,
|
||||||
|
private readonly suppressionService: SuppressionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
|
@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. ' +
|
||||||
|
'`sans_enfant` (#159) pour dossiers famille sans enfant.',
|
||||||
|
})
|
||||||
|
@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')
|
@Get(':numeroDossier')
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
@@ -23,4 +64,21 @@ export class DossiersController {
|
|||||||
getDossier(@Param('numeroDossier') numeroDossier: string): Promise<DossierUnifieDto> {
|
getDossier(@Param('numeroDossier') numeroDossier: string): Promise<DossierUnifieDto> {
|
||||||
return this.dossiersService.getDossierByNumero(numeroDossier);
|
return this.dossiersService.getDossierByNumero(numeroDossier);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete(':numeroDossier')
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Supprimer un dossier (famille ou AM) — #159',
|
||||||
|
description:
|
||||||
|
'Famille : parents + enfants. AM : compte AM + dossier AM (enfants conservés, placements clos).',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'numeroDossier', description: 'Numéro de dossier' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Résultat de suppression' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Dossier introuvable' })
|
||||||
|
remove(
|
||||||
|
@Param('numeroDossier') numeroDossier: string,
|
||||||
|
@User() currentUser: Users,
|
||||||
|
) {
|
||||||
|
return this.suppressionService.deleteDossier(numeroDossier, currentUser);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { JwtModule } from '@nestjs/jwt';
|
|||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
import { ParentsModule } from '../parents/parents.module';
|
import { ParentsModule } from '../parents/parents.module';
|
||||||
|
import { SuppressionsModule } from '../suppressions/suppressions.module';
|
||||||
import { DossiersController } from './dossiers.controller';
|
import { DossiersController } from './dossiers.controller';
|
||||||
import { DossiersService } from './dossiers.service';
|
import { DossiersService } from './dossiers.service';
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ import { DossiersService } from './dossiers.service';
|
|||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Parents, AssistanteMaternelle]),
|
TypeOrmModule.forFeature([Parents, AssistanteMaternelle]),
|
||||||
ParentsModule,
|
ParentsModule,
|
||||||
|
SuppressionsModule,
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
useFactory: (config: ConfigService) => ({
|
useFactory: (config: ConfigService) => ({
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
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 { SuppressionService } from '../suppressions/suppression.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(),
|
||||||
|
};
|
||||||
|
const suppressionService = {
|
||||||
|
countEnfantsForNumero: jest.fn().mockResolvedValue(1),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
DossiersService,
|
||||||
|
{ provide: getRepositoryToken(Parents), useValue: parentsRepo },
|
||||||
|
{ provide: getRepositoryToken(AssistanteMaternelle), useValue: amRepo },
|
||||||
|
{ provide: ParentsService, useValue: parentsService },
|
||||||
|
{ provide: SuppressionService, useValue: suppressionService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get(DossiersService);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
parentsRepo.createQueryBuilder.mockReturnValue(parentsQb);
|
||||||
|
amRepo.createQueryBuilder.mockReturnValue(amQb);
|
||||||
|
suppressionService.countEnfantsForNumero.mockResolvedValue(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aggregates famille (pivot+co-parent) and AM, sorts a_valider first', async () => {
|
||||||
|
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,15 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
|
import { StatutUtilisateurType, Users } from 'src/entities/users.entity';
|
||||||
import { ParentsService } from '../parents/parents.service';
|
import { ParentsService } from '../parents/parents.service';
|
||||||
|
import { SuppressionService } from '../suppressions/suppression.service';
|
||||||
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
||||||
import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.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) + sans_enfant (#159).
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DossiersService {
|
export class DossiersService {
|
||||||
@@ -18,8 +21,173 @@ export class DossiersService {
|
|||||||
@InjectRepository(AssistanteMaternelle)
|
@InjectRepository(AssistanteMaternelle)
|
||||||
private readonly amRepository: Repository<AssistanteMaternelle>,
|
private readonly amRepository: Repository<AssistanteMaternelle>,
|
||||||
private readonly parentsService: ParentsService,
|
private readonly parentsService: ParentsService,
|
||||||
|
private readonly suppressionService: SuppressionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of filtered) {
|
||||||
|
if (item.type === 'famille') {
|
||||||
|
const n = await this.suppressionService.countEnfantsForNumero(
|
||||||
|
item.numero_dossier,
|
||||||
|
);
|
||||||
|
item.sans_enfant = n === 0;
|
||||||
|
} else {
|
||||||
|
item.sans_enfant = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
async getDossierByNumero(numeroDossier: string): Promise<DossierUnifieDto> {
|
||||||
const num = numeroDossier?.trim();
|
const num = numeroDossier?.trim();
|
||||||
if (!num) {
|
if (!num) {
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'True si dossier famille sans enfant lié (#159). Omis ou false pour AM.',
|
||||||
|
})
|
||||||
|
sans_enfant?: boolean;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
ApiBody,
|
ApiBody,
|
||||||
ApiConsumes,
|
ApiConsumes,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
|
ApiQuery,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { diskStorage } from 'multer';
|
import { diskStorage } from 'multer';
|
||||||
@@ -36,6 +38,7 @@ import { User } from 'src/common/decorators/user.decorator';
|
|||||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
|
import { SuppressionService } from '../suppressions/suppression.service';
|
||||||
|
|
||||||
const photoMulterOptions = {
|
const photoMulterOptions = {
|
||||||
storage: diskStorage({
|
storage: diskStorage({
|
||||||
@@ -83,7 +86,10 @@ class OptionalEnfantPhotoInterceptor implements NestInterceptor {
|
|||||||
@UseGuards(AuthGuard, RolesGuard)
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
@Controller('enfants')
|
@Controller('enfants')
|
||||||
export class EnfantsController {
|
export class EnfantsController {
|
||||||
constructor(private readonly enfantsService: EnfantsService) { }
|
constructor(
|
||||||
|
private readonly enfantsService: EnfantsService,
|
||||||
|
private readonly suppressionService: SuppressionService,
|
||||||
|
) { }
|
||||||
|
|
||||||
@Roles(
|
@Roles(
|
||||||
RoleType.PARENT,
|
RoleType.PARENT,
|
||||||
@@ -141,17 +147,47 @@ export class EnfantsController {
|
|||||||
RoleType.GESTIONNAIRE,
|
RoleType.GESTIONNAIRE,
|
||||||
)
|
)
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Mettre à jour un enfant',
|
||||||
|
description:
|
||||||
|
'JSON sans photo OK ; avec nouvelle photo → multipart (champ fichier `photo`, max 5 Mo).',
|
||||||
|
})
|
||||||
|
@ApiConsumes('application/json', 'multipart/form-data')
|
||||||
|
@UseInterceptors(OptionalEnfantPhotoInterceptor)
|
||||||
update(
|
update(
|
||||||
@Param('id', new ParseUUIDPipe()) id: string,
|
@Param('id', new ParseUUIDPipe()) id: string,
|
||||||
@Body() dto: UpdateEnfantsDto,
|
@Body() dto: UpdateEnfantsDto,
|
||||||
|
@UploadedFile() photo: Express.Multer.File,
|
||||||
@User() currentUser: Users,
|
@User() currentUser: Users,
|
||||||
) {
|
) {
|
||||||
return this.enfantsService.update(id, dto, currentUser);
|
return this.enfantsService.update(id, dto, currentUser, photo);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN)
|
@Roles(
|
||||||
|
RoleType.SUPER_ADMIN,
|
||||||
|
RoleType.ADMINISTRATEUR,
|
||||||
|
RoleType.GESTIONNAIRE,
|
||||||
|
)
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
remove(@Param('id', new ParseUUIDPipe()) id: string) {
|
@ApiOperation({
|
||||||
return this.enfantsService.remove(id);
|
summary: 'Supprimer un enfant (#159)',
|
||||||
|
description:
|
||||||
|
'Query `deleteDossier=true` si dernier enfant et suppression du dossier famille souhaitée.',
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'deleteDossier',
|
||||||
|
required: false,
|
||||||
|
description: 'Si true et dernier enfant : cascade dossier famille',
|
||||||
|
})
|
||||||
|
remove(
|
||||||
|
@Param('id', new ParseUUIDPipe()) id: string,
|
||||||
|
@Query('deleteDossier') deleteDossier: string | undefined,
|
||||||
|
@User() currentUser: Users,
|
||||||
|
) {
|
||||||
|
const flag =
|
||||||
|
deleteDossier === 'true' ||
|
||||||
|
deleteDossier === '1' ||
|
||||||
|
deleteDossier === 'yes';
|
||||||
|
return this.suppressionService.deleteEnfant(id, flag, currentUser);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ import { Children } from 'src/entities/children.entity';
|
|||||||
import { Parents } from 'src/entities/parents.entity';
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { SuppressionsModule } from '../suppressions/suppressions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Children, Parents, ParentsChildren]),
|
imports: [
|
||||||
AuthModule
|
TypeOrmModule.forFeature([Children, Parents, ParentsChildren]),
|
||||||
|
AuthModule,
|
||||||
|
SuppressionsModule,
|
||||||
],
|
],
|
||||||
controllers: [EnfantsController],
|
controllers: [EnfantsController],
|
||||||
providers: [EnfantsService]
|
providers: [EnfantsService],
|
||||||
|
exports: [EnfantsService],
|
||||||
})
|
})
|
||||||
export class EnfantsModule { }
|
export class EnfantsModule { }
|
||||||
|
|||||||
@@ -195,7 +195,12 @@ export class EnfantsService {
|
|||||||
|
|
||||||
|
|
||||||
// Mise à jour
|
// Mise à jour
|
||||||
async update(id: string, dto: Partial<CreateEnfantsDto>, currentUser: Users): Promise<Children> {
|
async update(
|
||||||
|
id: string,
|
||||||
|
dto: Partial<CreateEnfantsDto>,
|
||||||
|
currentUser: Users,
|
||||||
|
photoFile?: Express.Multer.File,
|
||||||
|
): Promise<Children> {
|
||||||
const child = await this.childrenRepository.findOne({ where: { id } });
|
const child = await this.childrenRepository.findOne({ where: { id } });
|
||||||
if (!child) throw new NotFoundException('Enfant introuvable');
|
if (!child) throw new NotFoundException('Enfant introuvable');
|
||||||
|
|
||||||
@@ -205,6 +210,13 @@ export class EnfantsService {
|
|||||||
patch.consent_photo = dto.consent_photo;
|
patch.consent_photo = dto.consent_photo;
|
||||||
patch.consent_photo_at = dto.consent_photo ? new Date() : null!;
|
patch.consent_photo_at = dto.consent_photo ? new Date() : null!;
|
||||||
}
|
}
|
||||||
|
if (photoFile) {
|
||||||
|
patch.photo_url = `/uploads/photos/${photoFile.filename}`;
|
||||||
|
if (dto.consent_photo !== false) {
|
||||||
|
patch.consent_photo = true;
|
||||||
|
patch.consent_photo_at = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.childrenRepository.update(id, patch);
|
await this.childrenRepository.update(id, patch);
|
||||||
return this.findOne(id, currentUser);
|
return this.findOne(id, currentUser);
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ export class ParentPendingSummaryDto {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
email: string;
|
email: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true })
|
||||||
|
nom?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ nullable: true })
|
||||||
|
prenom?: string | null;
|
||||||
|
|
||||||
@ApiPropertyOptional({ nullable: true })
|
@ApiPropertyOptional({ nullable: true })
|
||||||
telephone?: string | null;
|
telephone?: string | null;
|
||||||
|
|
||||||
@@ -18,7 +24,10 @@ export class ParentPendingSummaryDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class PendingFamilyDto {
|
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;
|
libelle: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
|
/** Réponse 201 POST /parents/:id/co-parent (#135). */
|
||||||
|
export class StaffAddCoParentResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2026-000043' })
|
||||||
|
numero_dossier: string;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'uuid', description: 'UUID du parent pivot' })
|
||||||
|
parent_user_id: string;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'uuid', description: 'UUID du co-parent créé' })
|
||||||
|
co_parent_user_id: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
enum: StatutUtilisateurType,
|
||||||
|
example: StatutUtilisateurType.ACTIF,
|
||||||
|
})
|
||||||
|
statut: StatutUtilisateurType;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsEmail,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajout d’un co-parent sur un foyer existant (staff) — ticket #135.
|
||||||
|
* Corps sans préfixe `co_parent_*` (l’URL cible déjà le pivot).
|
||||||
|
*/
|
||||||
|
export class StaffAddCoParentDto {
|
||||||
|
@ApiProperty({ example: 'thomas.martin@ptits-pas.fr' })
|
||||||
|
@IsEmail({}, { message: 'Email invalide' })
|
||||||
|
@IsNotEmpty({ message: "L'email est requis" })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Thomas' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: 'Le prénom est requis' })
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(100)
|
||||||
|
prenom: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'MARTIN' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: 'Le nom est requis' })
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(100)
|
||||||
|
nom: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '0678456789' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: 'Le téléphone est requis' })
|
||||||
|
@Matches(/^(\+33|0)[1-9](\d{2}){4}$/, {
|
||||||
|
message: 'Le numéro de téléphone doit être valide (ex: 0689567890 ou +33689567890)',
|
||||||
|
})
|
||||||
|
telephone: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: true,
|
||||||
|
description: 'Si true, copie l’adresse du parent pivot',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
meme_adresse?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
adresse?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10)
|
||||||
|
code_postal?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(150)
|
||||||
|
ville?: string;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ describe('ParentsController', () => {
|
|||||||
let controller: ParentsController;
|
let controller: ParentsController;
|
||||||
const authServiceMock = {
|
const authServiceMock = {
|
||||||
createParentDossierStaff: jest.fn(),
|
createParentDossierStaff: jest.fn(),
|
||||||
|
addCoParentStaff: jest.fn(),
|
||||||
};
|
};
|
||||||
const parentsServiceMock = {};
|
const parentsServiceMock = {};
|
||||||
const userServiceMock = {};
|
const userServiceMock = {};
|
||||||
@@ -77,4 +78,27 @@ describe('ParentsController', () => {
|
|||||||
expect(res.enfant_ids).toEqual(['e1']);
|
expect(res.enfant_ids).toEqual(['e1']);
|
||||||
expect(res.statut).toBe(StatutUtilisateurType.ACTIF);
|
expect(res.statut).toBe(StatutUtilisateurType.ACTIF);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('addCoParent delegates to authService.addCoParentStaff', async () => {
|
||||||
|
authServiceMock.addCoParentStaff.mockResolvedValue({
|
||||||
|
message: 'ok',
|
||||||
|
numero_dossier: '2026-000043',
|
||||||
|
parent_user_id: 'p1',
|
||||||
|
co_parent_user_id: 'p2',
|
||||||
|
statut: StatutUtilisateurType.ACTIF,
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
email: 'coparent@test.fr',
|
||||||
|
prenom: 'Thomas',
|
||||||
|
nom: 'MARTIN',
|
||||||
|
telephone: '0678456789',
|
||||||
|
meme_adresse: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await controller.addCoParent('p1', body as any);
|
||||||
|
expect(authServiceMock.addCoParentStaff).toHaveBeenCalledWith('p1', body);
|
||||||
|
expect(res.co_parent_user_id).toBe('p2');
|
||||||
|
expect(res.statut).toBe(StatutUtilisateurType.ACTIF);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import { UpdateParentsDto } from '../user/dto/update_parent.dto';
|
|||||||
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
|
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
|
||||||
import { StaffCreateParentDossierDto } from './dto/staff-create-parent-dossier.dto';
|
import { StaffCreateParentDossierDto } from './dto/staff-create-parent-dossier.dto';
|
||||||
import { StaffCreateParentDossierResponseDto } from './dto/staff-create-parent-dossier-response.dto';
|
import { StaffCreateParentDossierResponseDto } from './dto/staff-create-parent-dossier-response.dto';
|
||||||
|
import { StaffAddCoParentDto } from './dto/staff-add-co-parent.dto';
|
||||||
|
import { StaffAddCoParentResponseDto } from './dto/staff-add-co-parent-response.dto';
|
||||||
import { RegisterParentCompletDto } from '../auth/dto/register-parent-complet.dto';
|
import { RegisterParentCompletDto } from '../auth/dto/register-parent-complet.dto';
|
||||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
@@ -176,6 +178,28 @@ export class ParentsController {
|
|||||||
return mapParentForApi(parent);
|
return mapParentForApi(parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
|
@Post(':id/co-parent')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Ajouter un co-parent à un foyer existant (staff) — ticket #135',
|
||||||
|
description:
|
||||||
|
'Foyer mono-parent uniquement. Crée le co-parent actif, liens foyer + enfants, ' +
|
||||||
|
'e-mail de création de mot de passe. Ne pas utiliser POST /auth/register/parent.',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: 'UUID utilisateur du parent pivot' })
|
||||||
|
@ApiBody({ type: StaffAddCoParentDto })
|
||||||
|
@ApiResponse({ status: 201, type: StaffAddCoParentResponseDto })
|
||||||
|
@ApiResponse({ status: 400, description: 'Foyer déjà à 2 parents / validation' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Parent introuvable' })
|
||||||
|
@ApiResponse({ status: 409, description: 'Email déjà pris' })
|
||||||
|
async addCoParent(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: StaffAddCoParentDto,
|
||||||
|
): Promise<StaffAddCoParentResponseDto> {
|
||||||
|
return this.authService.addCoParentStaff(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
@Post(':id/enfants/:enfantId')
|
@Post(':id/enfants/:enfantId')
|
||||||
@ApiOperation({ summary: 'Rattacher un enfant à un parent — ticket #115' })
|
@ApiOperation({ summary: 'Rattacher un enfant à un parent — ticket #115' })
|
||||||
|
|||||||
@@ -251,7 +251,15 @@ export class ParentsService {
|
|||||||
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
|
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
|
||||||
)
|
)
|
||||||
SELECT
|
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.id_utilisateur ORDER BY u.nom, u.prenom, u.id) AS "parentIds",
|
||||||
(array_agg(p.numero_dossier))[1] AS numero_dossier,
|
(array_agg(p.numero_dossier))[1] AS numero_dossier,
|
||||||
MIN(u.cree_le) AS date_soumission,
|
MIN(u.cree_le) AS date_soumission,
|
||||||
@@ -267,6 +275,8 @@ export class ParentsService {
|
|||||||
json_build_object(
|
json_build_object(
|
||||||
'id', u.id::text,
|
'id', u.id::text,
|
||||||
'email', u.email,
|
'email', u.email,
|
||||||
|
'nom', u.nom,
|
||||||
|
'prenom', u.prenom,
|
||||||
'telephone', u.telephone,
|
'telephone', u.telephone,
|
||||||
'code_postal', u.code_postal,
|
'code_postal', u.code_postal,
|
||||||
'ville', u.ville
|
'ville', u.ville
|
||||||
@@ -317,11 +327,21 @@ export class ParentsService {
|
|||||||
return [];
|
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)) {
|
if (Array.isArray(parents)) {
|
||||||
return parents.map((p: any) => ({
|
return parents.map((p: any) => ({
|
||||||
id: String(p?.id ?? ''),
|
id: String(p?.id ?? ''),
|
||||||
email: String(p?.email ?? ''),
|
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,
|
telephone: p?.telephone != null ? String(p.telephone) : null,
|
||||||
code_postal: p?.code_postal != null ? String(p.code_postal) : null,
|
code_postal: p?.code_postal != null ? String(p.code_postal) : null,
|
||||||
ville: p?.ville != null ? String(p.ville) : null,
|
ville: p?.ville != null ? String(p.ville) : null,
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { SuppressionService } from './suppression.service';
|
||||||
|
import { RoleType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
|
describe('SuppressionService (#159)', () => {
|
||||||
|
const dataSource = {
|
||||||
|
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) =>
|
||||||
|
cb({
|
||||||
|
delete: jest.fn(),
|
||||||
|
query: jest.fn(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const usersRepository = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
count: jest.fn(),
|
||||||
|
};
|
||||||
|
const parentsRepository = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
find: jest.fn(),
|
||||||
|
query: jest.fn(),
|
||||||
|
};
|
||||||
|
const amRepository = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
const childrenRepository = {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
};
|
||||||
|
const parentsChildrenRepository = {
|
||||||
|
find: jest.fn(),
|
||||||
|
};
|
||||||
|
const amChildrenRepository = {
|
||||||
|
find: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
count: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let service: SuppressionService;
|
||||||
|
|
||||||
|
const staff = {
|
||||||
|
id: 'staff-1',
|
||||||
|
role: RoleType.GESTIONNAIRE,
|
||||||
|
} as never;
|
||||||
|
|
||||||
|
const admin = {
|
||||||
|
id: 'admin-1',
|
||||||
|
role: RoleType.ADMINISTRATEUR,
|
||||||
|
} as never;
|
||||||
|
|
||||||
|
const superAdmin = {
|
||||||
|
id: 'sa-1',
|
||||||
|
role: RoleType.SUPER_ADMIN,
|
||||||
|
} as never;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
service = new SuppressionService(
|
||||||
|
dataSource as never,
|
||||||
|
usersRepository as never,
|
||||||
|
parentsRepository as never,
|
||||||
|
amRepository as never,
|
||||||
|
childrenRepository as never,
|
||||||
|
parentsChildrenRepository as never,
|
||||||
|
amChildrenRepository as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse self-delete', async () => {
|
||||||
|
usersRepository.findOne.mockResolvedValue({
|
||||||
|
id: 'admin-1',
|
||||||
|
role: RoleType.ADMINISTRATEUR,
|
||||||
|
});
|
||||||
|
await expect(service.deleteUser('admin-1', admin)).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse gestionnaire deleting another gestionnaire', async () => {
|
||||||
|
usersRepository.findOne.mockResolvedValue({
|
||||||
|
id: 'g2',
|
||||||
|
role: RoleType.GESTIONNAIRE,
|
||||||
|
});
|
||||||
|
await expect(service.deleteUser('g2', staff)).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dernier admin : refus si pas super_admin', async () => {
|
||||||
|
usersRepository.findOne.mockResolvedValue({
|
||||||
|
id: 'admin-2',
|
||||||
|
role: RoleType.ADMINISTRATEUR,
|
||||||
|
});
|
||||||
|
usersRepository.count.mockResolvedValue(1);
|
||||||
|
await expect(service.deleteUser('admin-2', admin)).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dernier admin : OK pour super_admin', async () => {
|
||||||
|
usersRepository.findOne.mockResolvedValue({
|
||||||
|
id: 'admin-2',
|
||||||
|
role: RoleType.ADMINISTRATEUR,
|
||||||
|
});
|
||||||
|
usersRepository.count.mockResolvedValue(1);
|
||||||
|
usersRepository.delete.mockResolvedValue({ affected: 1 });
|
||||||
|
const res = await service.deleteUser('admin-2', superAdmin);
|
||||||
|
expect(res.deleted_user_ids).toEqual(['admin-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delete AM : clos placements, pas d’enfants deleted', async () => {
|
||||||
|
usersRepository.findOne.mockResolvedValue({
|
||||||
|
id: 'am-1',
|
||||||
|
role: RoleType.ASSISTANTE_MATERNELLE,
|
||||||
|
});
|
||||||
|
amRepository.findOne.mockResolvedValue({
|
||||||
|
user_id: 'am-1',
|
||||||
|
numero_dossier: '2026-000015',
|
||||||
|
});
|
||||||
|
amChildrenRepository.find.mockResolvedValue([
|
||||||
|
{
|
||||||
|
amId: 'am-1',
|
||||||
|
enfantId: 'e1',
|
||||||
|
child: { id: 'e1', status: 'garde' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
amChildrenRepository.count.mockResolvedValue(0);
|
||||||
|
amChildrenRepository.save.mockImplementation(async (x) => x);
|
||||||
|
childrenRepository.save.mockResolvedValue({});
|
||||||
|
usersRepository.delete.mockResolvedValue({ affected: 1 });
|
||||||
|
|
||||||
|
const res = await service.deleteUser('am-1', staff);
|
||||||
|
expect(res.deleted_enfant_ids).toEqual([]);
|
||||||
|
expect(res.deleted_user_ids).toEqual(['am-1']);
|
||||||
|
expect(res.type).toBe('assistante_maternelle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delete dossier famille introuvable', async () => {
|
||||||
|
parentsRepository.findOne.mockResolvedValue(null);
|
||||||
|
amRepository.findOne.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
service.deleteDossier('2026-999999', staff),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('co-parent : delete user seul', async () => {
|
||||||
|
usersRepository.findOne.mockResolvedValue({
|
||||||
|
id: 'p2',
|
||||||
|
role: RoleType.PARENT,
|
||||||
|
});
|
||||||
|
parentsRepository.findOne.mockResolvedValue({
|
||||||
|
user_id: 'p2',
|
||||||
|
numero_dossier: '2026-000010',
|
||||||
|
co_parent: { id: 'p1' },
|
||||||
|
});
|
||||||
|
parentsRepository.query.mockResolvedValue([
|
||||||
|
{ id: 'p1' },
|
||||||
|
{ id: 'p2' },
|
||||||
|
]);
|
||||||
|
dataSource.transaction.mockImplementation(async (cb) =>
|
||||||
|
cb({
|
||||||
|
delete: jest.fn(),
|
||||||
|
query: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await service.deleteUser('p2', staff);
|
||||||
|
expect(res.deleted_enfant_ids).toEqual([]);
|
||||||
|
expect(res.deleted_user_ids).toEqual(['p2']);
|
||||||
|
expect(res.message).toMatch(/co-parent/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, In, IsNull, Repository } from 'typeorm';
|
||||||
|
import { RoleType, Users } from 'src/entities/users.entity';
|
||||||
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
|
import { Children, StatutEnfantType } from 'src/entities/children.entity';
|
||||||
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
|
import { AmChildren } from 'src/entities/am_children.entity';
|
||||||
|
|
||||||
|
export type SuppressionResult = {
|
||||||
|
type?: 'famille' | 'assistante_maternelle';
|
||||||
|
numero_dossier?: string;
|
||||||
|
deleted_user_ids: string[];
|
||||||
|
deleted_enfant_ids: string[];
|
||||||
|
dossier_supprime?: boolean;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STAFF_METIER: RoleType[] = [
|
||||||
|
RoleType.GESTIONNAIRE,
|
||||||
|
RoleType.ADMINISTRATEUR,
|
||||||
|
RoleType.SUPER_ADMIN,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cascades de suppression métier — tickets #154 / #159.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SuppressionService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
@InjectRepository(Users)
|
||||||
|
private readonly usersRepository: Repository<Users>,
|
||||||
|
@InjectRepository(Parents)
|
||||||
|
private readonly parentsRepository: Repository<Parents>,
|
||||||
|
@InjectRepository(AssistanteMaternelle)
|
||||||
|
private readonly amRepository: Repository<AssistanteMaternelle>,
|
||||||
|
@InjectRepository(Children)
|
||||||
|
private readonly childrenRepository: Repository<Children>,
|
||||||
|
@InjectRepository(ParentsChildren)
|
||||||
|
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
||||||
|
@InjectRepository(AmChildren)
|
||||||
|
private readonly amChildrenRepository: Repository<AmChildren>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
assertStaffMetier(currentUser: Users): void {
|
||||||
|
if (!STAFF_METIER.includes(currentUser.role)) {
|
||||||
|
throw new ForbiddenException('Accès refusé');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteDossier(
|
||||||
|
numeroDossier: string,
|
||||||
|
currentUser: Users,
|
||||||
|
): Promise<SuppressionResult> {
|
||||||
|
this.assertStaffMetier(currentUser);
|
||||||
|
const num = numeroDossier?.trim();
|
||||||
|
if (!num) {
|
||||||
|
throw new BadRequestException('Numéro de dossier requis.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentHit = await this.parentsRepository.findOne({
|
||||||
|
where: { numero_dossier: num },
|
||||||
|
});
|
||||||
|
if (parentHit) {
|
||||||
|
return this.deleteFamilleByNumero(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
const amHit = await this.amRepository.findOne({
|
||||||
|
where: { numero_dossier: num },
|
||||||
|
relations: ['user'],
|
||||||
|
});
|
||||||
|
if (amHit?.user) {
|
||||||
|
return this.deleteAmUser(amHit.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new NotFoundException('Aucun dossier trouvé pour ce numéro.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUser(
|
||||||
|
id: string,
|
||||||
|
currentUser: Users,
|
||||||
|
): Promise<SuppressionResult> {
|
||||||
|
const target = await this.usersRepository.findOne({ where: { id } });
|
||||||
|
if (!target) {
|
||||||
|
throw new NotFoundException('Utilisateur introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.id === currentUser.id) {
|
||||||
|
throw new ForbiddenException('Vous ne pouvez pas supprimer votre propre compte.');
|
||||||
|
}
|
||||||
|
if (target.role === RoleType.SUPER_ADMIN) {
|
||||||
|
throw new ForbiddenException('Le super administrateur ne peut pas être supprimé.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.role === RoleType.PARENT) {
|
||||||
|
this.assertStaffMetier(currentUser);
|
||||||
|
return this.deleteParentUser(target.id);
|
||||||
|
}
|
||||||
|
if (target.role === RoleType.ASSISTANTE_MATERNELLE) {
|
||||||
|
this.assertStaffMetier(currentUser);
|
||||||
|
return this.deleteAmUser(target.id);
|
||||||
|
}
|
||||||
|
if (target.role === RoleType.GESTIONNAIRE) {
|
||||||
|
if (
|
||||||
|
currentUser.role !== RoleType.ADMINISTRATEUR &&
|
||||||
|
currentUser.role !== RoleType.SUPER_ADMIN
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Seul un administrateur peut supprimer un gestionnaire.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.usersRepository.delete(target.id);
|
||||||
|
return {
|
||||||
|
deleted_user_ids: [target.id],
|
||||||
|
deleted_enfant_ids: [],
|
||||||
|
message: 'Gestionnaire supprimé.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (target.role === RoleType.ADMINISTRATEUR) {
|
||||||
|
await this.assertCanDeleteAdministrateur(target, currentUser);
|
||||||
|
await this.usersRepository.delete(target.id);
|
||||||
|
return {
|
||||||
|
deleted_user_ids: [target.id],
|
||||||
|
deleted_enfant_ids: [],
|
||||||
|
message: 'Administrateur supprimé.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BadRequestException('Type d’utilisateur non supprimable via cet endpoint.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEnfant(
|
||||||
|
enfantId: string,
|
||||||
|
deleteDossier: boolean,
|
||||||
|
currentUser: Users,
|
||||||
|
): Promise<SuppressionResult> {
|
||||||
|
this.assertStaffMetier(currentUser);
|
||||||
|
|
||||||
|
const child = await this.childrenRepository.findOne({
|
||||||
|
where: { id: enfantId },
|
||||||
|
relations: ['parentLinks', 'parentLinks.parent'],
|
||||||
|
});
|
||||||
|
if (!child) {
|
||||||
|
throw new NotFoundException('Enfant introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentIds = (child.parentLinks ?? [])
|
||||||
|
.map((l) => l.parentId ?? l.parent?.user_id)
|
||||||
|
.filter(Boolean) as string[];
|
||||||
|
|
||||||
|
let numero: string | undefined;
|
||||||
|
if (parentIds.length > 0) {
|
||||||
|
const parents = await this.parentsRepository.find({
|
||||||
|
where: { user_id: In(parentIds) },
|
||||||
|
});
|
||||||
|
numero = parents.map((p) => p.numero_dossier?.trim()).find((n) => !!n);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!numero) {
|
||||||
|
await this.closePlacementsForEnfants([enfantId]);
|
||||||
|
await this.childrenRepository.delete(enfantId);
|
||||||
|
return {
|
||||||
|
deleted_user_ids: [],
|
||||||
|
deleted_enfant_ids: [enfantId],
|
||||||
|
dossier_supprime: false,
|
||||||
|
message: 'Enfant supprimé.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const siblingIds = await this.listEnfantIdsForNumero(numero);
|
||||||
|
const isLast = siblingIds.length <= 1;
|
||||||
|
|
||||||
|
if (isLast && deleteDossier) {
|
||||||
|
const result = await this.deleteFamilleByNumero(numero);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
dossier_supprime: true,
|
||||||
|
message: 'Dernier enfant et dossier famille supprimés.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.closePlacementsForEnfants([enfantId]);
|
||||||
|
await this.childrenRepository.delete(enfantId);
|
||||||
|
return {
|
||||||
|
deleted_user_ids: [],
|
||||||
|
deleted_enfant_ids: [enfantId],
|
||||||
|
dossier_supprime: false,
|
||||||
|
numero_dossier: numero,
|
||||||
|
type: 'famille',
|
||||||
|
message: isLast
|
||||||
|
? 'Dernier enfant supprimé. Le dossier famille reste sans enfant.'
|
||||||
|
: 'Enfant supprimé du dossier famille.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compte enfants liés à un numero_dossier famille (pour flag sans_enfant). */
|
||||||
|
async countEnfantsForNumero(numeroDossier: string): Promise<number> {
|
||||||
|
const ids = await this.listEnfantIdsForNumero(numeroDossier);
|
||||||
|
return ids.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCanDeleteAdministrateur(
|
||||||
|
target: Users,
|
||||||
|
currentUser: Users,
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
currentUser.role !== RoleType.ADMINISTRATEUR &&
|
||||||
|
currentUser.role !== RoleType.SUPER_ADMIN
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Seul un administrateur peut supprimer un administrateur.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const adminCount = await this.usersRepository.count({
|
||||||
|
where: { role: RoleType.ADMINISTRATEUR },
|
||||||
|
});
|
||||||
|
if (adminCount <= 1) {
|
||||||
|
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Seul le super administrateur peut supprimer le dernier administrateur.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteParentUser(userId: string): Promise<SuppressionResult> {
|
||||||
|
const parent = await this.parentsRepository.findOne({
|
||||||
|
where: { user_id: userId },
|
||||||
|
relations: ['co_parent'],
|
||||||
|
});
|
||||||
|
if (!parent) {
|
||||||
|
// Compte parent sans fiche — hard delete user
|
||||||
|
await this.usersRepository.delete(userId);
|
||||||
|
return {
|
||||||
|
deleted_user_ids: [userId],
|
||||||
|
deleted_enfant_ids: [],
|
||||||
|
message: 'Parent supprimé.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const numero = parent.numero_dossier?.trim();
|
||||||
|
const foyerIds = numero
|
||||||
|
? await this.listParentUserIdsForNumero(numero)
|
||||||
|
: [userId];
|
||||||
|
|
||||||
|
const isLast = foyerIds.filter((id) => id !== userId).length === 0;
|
||||||
|
|
||||||
|
if (!isLast) {
|
||||||
|
// Co-parent : retirer liens enfants de ce parent, clear co_parent refs, delete user
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.delete(ParentsChildren, { parentId: userId });
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE parents SET id_co_parent = NULL WHERE id_co_parent = $1 OR id_utilisateur = $1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
await manager.delete(Users, { id: userId });
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
deleted_user_ids: [userId],
|
||||||
|
deleted_enfant_ids: [],
|
||||||
|
numero_dossier: numero,
|
||||||
|
type: 'famille',
|
||||||
|
message: 'Parent retiré du dossier (co-parent).',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dernier parent : + enfants
|
||||||
|
const enfantIds = numero
|
||||||
|
? await this.listEnfantIdsForNumero(numero)
|
||||||
|
: await this.listEnfantIdsForParent(userId);
|
||||||
|
await this.closePlacementsForEnfants(enfantIds);
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
if (enfantIds.length) {
|
||||||
|
await manager.delete(Children, { id: In(enfantIds) });
|
||||||
|
}
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE parents SET id_co_parent = NULL WHERE id_utilisateur = $1 OR id_co_parent = $1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
await manager.delete(Users, { id: userId });
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
deleted_user_ids: [userId],
|
||||||
|
deleted_enfant_ids: enfantIds,
|
||||||
|
numero_dossier: numero,
|
||||||
|
type: 'famille',
|
||||||
|
message: 'Dernier parent et enfants rattachés supprimés.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteFamilleByNumero(numero: string): Promise<SuppressionResult> {
|
||||||
|
const parentIds = await this.listParentUserIdsForNumero(numero);
|
||||||
|
if (parentIds.length === 0) {
|
||||||
|
throw new NotFoundException('Aucun parent pour ce dossier.');
|
||||||
|
}
|
||||||
|
const enfantIds = await this.listEnfantIdsForNumero(numero);
|
||||||
|
await this.closePlacementsForEnfants(enfantIds);
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
if (enfantIds.length) {
|
||||||
|
await manager.delete(Children, { id: In(enfantIds) });
|
||||||
|
}
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE parents SET id_co_parent = NULL WHERE id_utilisateur = ANY($1::uuid[]) OR id_co_parent = ANY($1::uuid[])`,
|
||||||
|
[parentIds],
|
||||||
|
);
|
||||||
|
await manager.delete(Users, { id: In(parentIds) });
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'famille',
|
||||||
|
numero_dossier: numero,
|
||||||
|
deleted_user_ids: parentIds,
|
||||||
|
deleted_enfant_ids: enfantIds,
|
||||||
|
message: 'Dossier famille supprimé.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteAmUser(userId: string): Promise<SuppressionResult> {
|
||||||
|
const am = await this.amRepository.findOne({ where: { user_id: userId } });
|
||||||
|
const numero = am?.numero_dossier?.trim();
|
||||||
|
|
||||||
|
const active = await this.amChildrenRepository.find({
|
||||||
|
where: { amId: userId, date_fin: IsNull() },
|
||||||
|
relations: ['child'],
|
||||||
|
});
|
||||||
|
const now = new Date();
|
||||||
|
for (const link of active) {
|
||||||
|
link.date_fin = now;
|
||||||
|
await this.amChildrenRepository.save(link);
|
||||||
|
if (link.child) {
|
||||||
|
await this.applySansGarde(link.child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.usersRepository.delete(userId);
|
||||||
|
return {
|
||||||
|
type: 'assistante_maternelle',
|
||||||
|
numero_dossier: numero,
|
||||||
|
deleted_user_ids: [userId],
|
||||||
|
deleted_enfant_ids: [],
|
||||||
|
message: 'Dossier assistante maternelle supprimé.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applySansGarde(child: Children): Promise<void> {
|
||||||
|
if (
|
||||||
|
child.status === StatutEnfantType.A_NAITRE ||
|
||||||
|
child.status === StatutEnfantType.SCOLARISE
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const remaining = await this.amChildrenRepository.count({
|
||||||
|
where: { enfantId: child.id, date_fin: IsNull() },
|
||||||
|
});
|
||||||
|
if (remaining === 0) {
|
||||||
|
child.status = StatutEnfantType.SANS_GARDE;
|
||||||
|
await this.childrenRepository.save(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async closePlacementsForEnfants(enfantIds: string[]): Promise<void> {
|
||||||
|
if (!enfantIds.length) return;
|
||||||
|
const links = await this.amChildrenRepository.find({
|
||||||
|
where: { enfantId: In(enfantIds), date_fin: IsNull() },
|
||||||
|
relations: ['child'],
|
||||||
|
});
|
||||||
|
const now = new Date();
|
||||||
|
for (const link of links) {
|
||||||
|
link.date_fin = now;
|
||||||
|
await this.amChildrenRepository.save(link);
|
||||||
|
if (link.child) {
|
||||||
|
await this.applySansGarde(link.child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listParentUserIdsForNumero(numero: string): Promise<string[]> {
|
||||||
|
const rows: Array<{ id: string }> = await this.parentsRepository.query(
|
||||||
|
`
|
||||||
|
SELECT DISTINCT x.id::text AS id FROM (
|
||||||
|
SELECT id_utilisateur AS id FROM parents WHERE TRIM(numero_dossier) = $1
|
||||||
|
UNION
|
||||||
|
SELECT id_co_parent AS id FROM parents
|
||||||
|
WHERE TRIM(numero_dossier) = $1 AND id_co_parent IS NOT NULL
|
||||||
|
UNION
|
||||||
|
SELECT p2.id_utilisateur AS id FROM parents p1
|
||||||
|
JOIN parents p2 ON p2.id_utilisateur = p1.id_co_parent
|
||||||
|
WHERE TRIM(p1.numero_dossier) = $1
|
||||||
|
) x WHERE x.id IS NOT NULL
|
||||||
|
`,
|
||||||
|
[numero],
|
||||||
|
);
|
||||||
|
return rows.map((r) => r.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listEnfantIdsForNumero(numero: string): Promise<string[]> {
|
||||||
|
const parentIds = await this.listParentUserIdsForNumero(numero);
|
||||||
|
if (!parentIds.length) return [];
|
||||||
|
return this.listEnfantIdsForParents(parentIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listEnfantIdsForParent(parentId: string): Promise<string[]> {
|
||||||
|
return this.listEnfantIdsForParents([parentId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listEnfantIdsForParents(parentIds: string[]): Promise<string[]> {
|
||||||
|
const links = await this.parentsChildrenRepository.find({
|
||||||
|
where: { parentId: In(parentIds) },
|
||||||
|
});
|
||||||
|
return [...new Set(links.map((l) => l.enfantId))];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Users } from 'src/entities/users.entity';
|
||||||
|
import { Parents } from 'src/entities/parents.entity';
|
||||||
|
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||||
|
import { Children } from 'src/entities/children.entity';
|
||||||
|
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||||
|
import { AmChildren } from 'src/entities/am_children.entity';
|
||||||
|
import { SuppressionService } from './suppression.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
Users,
|
||||||
|
Parents,
|
||||||
|
AssistanteMaternelle,
|
||||||
|
Children,
|
||||||
|
ParentsChildren,
|
||||||
|
AmChildren,
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
providers: [SuppressionService],
|
||||||
|
exports: [SuppressionService],
|
||||||
|
})
|
||||||
|
export class SuppressionsModule {}
|
||||||
@@ -1,20 +1,21 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import 'reflect-metadata';
|
||||||
import { GestionnairesController } from './gestionnaires.controller';
|
import { GestionnairesController } from './gestionnaires.controller';
|
||||||
import { GestionnairesService } from './gestionnaires.service';
|
import { RoleType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
describe('GestionnairesController', () => {
|
describe('GestionnairesController roles (#161)', () => {
|
||||||
let controller: GestionnairesController;
|
it('POST /gestionnaires autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
|
||||||
|
const roles = Reflect.getMetadata('roles', GestionnairesController.prototype.create);
|
||||||
beforeEach(async () => {
|
expect(roles).toEqual(
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
|
||||||
controllers: [GestionnairesController],
|
);
|
||||||
providers: [GestionnairesService],
|
expect(roles).not.toContain(RoleType.GESTIONNAIRE);
|
||||||
}).compile();
|
|
||||||
|
|
||||||
controller = module.get<GestionnairesController>(GestionnairesController);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('PATCH /gestionnaires/:id autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
|
||||||
expect(controller).toBeDefined();
|
const roles = Reflect.getMetadata('roles', GestionnairesController.prototype.update);
|
||||||
|
expect(roles).toEqual(
|
||||||
|
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
|
||||||
|
);
|
||||||
|
expect(roles).not.toContain(RoleType.GESTIONNAIRE);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ import { AuthGuard } from 'src/common/guards/auth.guard';
|
|||||||
export class GestionnairesController {
|
export class GestionnairesController {
|
||||||
constructor(private readonly gestionnairesService: GestionnairesService) { }
|
constructor(private readonly gestionnairesService: GestionnairesService) { }
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
@ApiResponse({ status: 201, description: 'Le gestionnaire a été créé avec succès.', type: Users })
|
@ApiResponse({ status: 201, description: 'Le gestionnaire a été créé avec succès.', type: Users })
|
||||||
@ApiResponse({ status: 409, description: 'Conflit. L\'email est déjà utilisé.' })
|
@ApiResponse({ status: 409, description: 'Conflit. L\'email est déjà utilisé.' })
|
||||||
@ApiOperation({ summary: 'Création d\'un gestionnaire' })
|
@ApiOperation({ summary: 'Création d\'un gestionnaire (admin / super admin)' })
|
||||||
@ApiBody({ type: CreateGestionnaireDto })
|
@ApiBody({ type: CreateGestionnaireDto })
|
||||||
@Post()
|
@Post()
|
||||||
create(@Body() dto: CreateGestionnaireDto): Promise<Users> {
|
create(@Body() dto: CreateGestionnaireDto): Promise<Users> {
|
||||||
@@ -43,7 +43,7 @@ export class GestionnairesController {
|
|||||||
return this.gestionnairesService.findAll();
|
return this.gestionnairesService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.GESTIONNAIRE, RoleType.SUPER_ADMIN)
|
@Roles(RoleType.GESTIONNAIRE, RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({ summary: 'Récupérer un gestionnaire par ID' })
|
@ApiOperation({ summary: 'Récupérer un gestionnaire par ID' })
|
||||||
@ApiResponse({ status: 400, description: 'ID invalide' })
|
@ApiResponse({ status: 400, description: 'ID invalide' })
|
||||||
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||||
@@ -56,8 +56,8 @@ export class GestionnairesController {
|
|||||||
return this.gestionnairesService.findOne(id);
|
return this.gestionnairesService.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(RoleType.SUPER_ADMIN)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({ summary: 'Mettre à jour un gestionnaire' })
|
@ApiOperation({ summary: 'Mettre à jour un gestionnaire (admin / super admin)' })
|
||||||
@ApiResponse({ status: 200, description: 'Le gestionnaire a été mis à jour avec succès.', type: Users })
|
@ApiResponse({ status: 200, description: 'Le gestionnaire a été mis à jour avec succès.', type: Users })
|
||||||
@ApiResponse({ status: 404, description: 'Gestionnaire non trouvé' })
|
@ApiResponse({ status: 404, description: 'Gestionnaire non trouvé' })
|
||||||
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||||
|
|||||||
@@ -1,20 +1,13 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import 'reflect-metadata';
|
||||||
import { UserController } from './user.controller';
|
import { UserController } from './user.controller';
|
||||||
import { UserService } from './user.service';
|
import { RoleType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
describe('UserController', () => {
|
describe('UserController roles (#161)', () => {
|
||||||
let controller: UserController;
|
it('POST /users/admin autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
|
||||||
|
const roles = Reflect.getMetadata('roles', UserController.prototype.createAdmin);
|
||||||
beforeEach(async () => {
|
expect(roles).toEqual(
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
|
||||||
controllers: [UserController],
|
);
|
||||||
providers: [UserService],
|
expect(roles).not.toContain(RoleType.GESTIONNAIRE);
|
||||||
}).compile();
|
|
||||||
|
|
||||||
controller = module.get<UserController>(UserController);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be defined', () => {
|
|
||||||
expect(controller).toBeDefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,18 +10,22 @@ import { CreateUserDto } from './dto/create_user.dto';
|
|||||||
import { CreateAdminDto } from './dto/create_admin.dto';
|
import { CreateAdminDto } from './dto/create_admin.dto';
|
||||||
import { UpdateUserDto } from './dto/update_user.dto';
|
import { UpdateUserDto } from './dto/update_user.dto';
|
||||||
import { AffecterNumeroDossierDto } from './dto/affecter-numero-dossier.dto';
|
import { AffecterNumeroDossierDto } from './dto/affecter-numero-dossier.dto';
|
||||||
|
import { SuppressionService } from '../suppressions/suppression.service';
|
||||||
|
|
||||||
@ApiTags('Utilisateurs')
|
@ApiTags('Utilisateurs')
|
||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
@UseGuards(AuthGuard, RolesGuard)
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
@Controller('users')
|
@Controller('users')
|
||||||
export class UserController {
|
export class UserController {
|
||||||
constructor(private readonly userService: UserService) { }
|
constructor(
|
||||||
|
private readonly userService: UserService,
|
||||||
|
private readonly suppressionService: SuppressionService,
|
||||||
|
) { }
|
||||||
|
|
||||||
// Création d'un administrateur (réservée aux super admins)
|
// Création d'un administrateur (admin + super admin) — #161
|
||||||
@Post('admin')
|
@Post('admin')
|
||||||
@Roles(RoleType.SUPER_ADMIN)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
@ApiOperation({ summary: 'Créer un nouvel administrateur (super admin seulement)' })
|
@ApiOperation({ summary: 'Créer un nouvel administrateur (admin / super admin)' })
|
||||||
createAdmin(
|
createAdmin(
|
||||||
@Body() dto: CreateAdminDto,
|
@Body() dto: CreateAdminDto,
|
||||||
@User() currentUser: Users
|
@User() currentUser: Users
|
||||||
@@ -146,12 +150,20 @@ export class UserController {
|
|||||||
return this.userService.suspendUser(id, currentUser, comment);
|
return this.userService.suspendUser(id, currentUser, comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Supprimer un utilisateur (super_admin uniquement)
|
// Supprimer un utilisateur — cascades métier #159
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@Roles(RoleType.SUPER_ADMIN)
|
@Roles(
|
||||||
@ApiOperation({ summary: 'Supprimer un utilisateur' })
|
RoleType.SUPER_ADMIN,
|
||||||
|
RoleType.ADMINISTRATEUR,
|
||||||
|
RoleType.GESTIONNAIRE,
|
||||||
|
)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Supprimer un utilisateur (cascades métier #159)',
|
||||||
|
description:
|
||||||
|
'Parent / AM / staff selon matrice. Gestionnaire ne peut pas supprimer un autre gestionnaire. Self interdit.',
|
||||||
|
})
|
||||||
@ApiParam({ name: 'id', description: "UUID de l'utilisateur" })
|
@ApiParam({ name: 'id', description: "UUID de l'utilisateur" })
|
||||||
remove(@Param('id') id: string, @User() currentUser: Users) {
|
remove(@Param('id') id: string, @User() currentUser: Users) {
|
||||||
return this.userService.remove(id, currentUser);
|
return this.suppressionService.deleteUser(id, currentUser);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Parents } from 'src/entities/parents.entity';
|
|||||||
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
|
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
|
||||||
import { MailModule } from 'src/modules/mail/mail.module';
|
import { MailModule } from 'src/modules/mail/mail.module';
|
||||||
import { AppConfigModule } from 'src/modules/config/config.module';
|
import { AppConfigModule } from 'src/modules/config/config.module';
|
||||||
|
import { SuppressionsModule } from '../suppressions/suppressions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature(
|
imports: [TypeOrmModule.forFeature(
|
||||||
@@ -26,6 +27,7 @@ import { AppConfigModule } from 'src/modules/config/config.module';
|
|||||||
GestionnairesModule,
|
GestionnairesModule,
|
||||||
MailModule,
|
MailModule,
|
||||||
AppConfigModule,
|
AppConfigModule,
|
||||||
|
SuppressionsModule,
|
||||||
],
|
],
|
||||||
controllers: [UserController],
|
controllers: [UserController],
|
||||||
providers: [UserService],
|
providers: [UserService],
|
||||||
|
|||||||
@@ -1,18 +1,87 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
|
import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
|
describe('UserService.createAdmin (#161)', () => {
|
||||||
|
const usersRepository = {
|
||||||
|
findOneBy: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
describe('UserService', () => {
|
|
||||||
let service: UserService;
|
let service: UserService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
const dto = {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
email: 'nouveau.admin@ptits-pas.fr',
|
||||||
providers: [UserService],
|
password: 'Password1!',
|
||||||
}).compile();
|
prenom: 'Nina',
|
||||||
|
nom: 'Admin',
|
||||||
|
telephone: '0601020304',
|
||||||
|
};
|
||||||
|
|
||||||
service = module.get<UserService>(UserService);
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
service = new UserService(
|
||||||
|
usersRepository as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('autorise un administrateur à créer un admin', async () => {
|
||||||
expect(service).toBeDefined();
|
usersRepository.findOneBy.mockResolvedValue(null);
|
||||||
|
usersRepository.create.mockImplementation((data) => data);
|
||||||
|
usersRepository.save.mockImplementation(async (entity) => ({
|
||||||
|
id: 'new-admin',
|
||||||
|
...entity,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await service.createAdmin(dto as never, {
|
||||||
|
id: 'admin-1',
|
||||||
|
role: RoleType.ADMINISTRATEUR,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(result.role).toBe(RoleType.ADMINISTRATEUR);
|
||||||
|
expect(result.statut).toBe(StatutUtilisateurType.ACTIF);
|
||||||
|
expect(usersRepository.save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('autorise un super_admin à créer un admin', async () => {
|
||||||
|
usersRepository.findOneBy.mockResolvedValue(null);
|
||||||
|
usersRepository.create.mockImplementation((data) => data);
|
||||||
|
usersRepository.save.mockImplementation(async (entity) => ({
|
||||||
|
id: 'new-admin',
|
||||||
|
...entity,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createAdmin(dto as never, {
|
||||||
|
id: 'sa-1',
|
||||||
|
role: RoleType.SUPER_ADMIN,
|
||||||
|
} as never),
|
||||||
|
).resolves.toMatchObject({ role: RoleType.ADMINISTRATEUR });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse un gestionnaire (403 métier)', async () => {
|
||||||
|
await expect(
|
||||||
|
service.createAdmin(dto as never, {
|
||||||
|
id: 'gest-1',
|
||||||
|
role: RoleType.GESTIONNAIRE,
|
||||||
|
} as never),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(usersRepository.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse un email déjà utilisé', async () => {
|
||||||
|
usersRepository.findOneBy.mockResolvedValue({ id: 'exists' });
|
||||||
|
await expect(
|
||||||
|
service.createAdmin(dto as never, {
|
||||||
|
id: 'admin-1',
|
||||||
|
role: RoleType.ADMINISTRATEUR,
|
||||||
|
} as never),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -117,8 +117,14 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createAdmin(dto: CreateAdminDto, currentUser: Users): Promise<Users> {
|
async createAdmin(dto: CreateAdminDto, currentUser: Users): Promise<Users> {
|
||||||
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
// #161 — admin et super_admin peuvent créer un administrateur
|
||||||
throw new ForbiddenException('Seuls les super administrateurs peuvent créer un administrateur');
|
if (
|
||||||
|
currentUser.role !== RoleType.SUPER_ADMIN &&
|
||||||
|
currentUser.role !== RoleType.ADMINISTRATEUR
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Seuls les administrateurs et super administrateurs peuvent créer un administrateur',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const exist = await this.usersRepository.findOneBy({ email: dto.email });
|
const exist = await this.usersRepository.findOneBy({ email: dto.email });
|
||||||
@@ -520,6 +526,7 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string, currentUser: Users): Promise<void> {
|
async remove(id: string, currentUser: Users): Promise<void> {
|
||||||
|
// Délégué historiquement ; préférer SuppressionService via controller (#159).
|
||||||
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
||||||
throw new ForbiddenException('Accès réservé aux super admins');
|
throw new ForbiddenException('Accès réservé aux super admins');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Mini-spec API — POST /parents/:id/co-parent (#135)
|
||||||
|
|
||||||
|
Contrat back pour l’ajout d’un **2ᵉ parent** sur un foyer mono-parent (staff).
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|--|--|
|
||||||
|
| **Méthode** | `POST` |
|
||||||
|
| **URL** | `{base}/api/v1/parents/{parentUserId}/co-parent` |
|
||||||
|
| **Auth** | Bearer JWT |
|
||||||
|
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
|
||||||
|
| **Succès** | **201** |
|
||||||
|
|
||||||
|
`parentUserId` = UUID du **parent pivot** (déjà dans le dossier).
|
||||||
|
|
||||||
|
Ne **pas** appeler `POST /auth/register/parent` ni `POST /parents/dossier`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Body (JSON)
|
||||||
|
|
||||||
|
| Champ | Type | Obligatoire | Notes |
|
||||||
|
|-------|------|-------------|--------|
|
||||||
|
| `email` | string | oui | unique |
|
||||||
|
| `prenom` | string | oui | |
|
||||||
|
| `nom` | string | oui | |
|
||||||
|
| `telephone` | string | oui | `0X…` ou `+33…` |
|
||||||
|
| `meme_adresse` | bool | non | défaut **true** → copie adresse du pivot |
|
||||||
|
| `adresse` | string | si `meme_adresse=false` | |
|
||||||
|
| `code_postal` | string | si `meme_adresse=false` | |
|
||||||
|
| `ville` | string | si `meme_adresse=false` | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comportement 201
|
||||||
|
|
||||||
|
- User co-parent **actif** + token création MDP
|
||||||
|
- Fiche `parents` + liens pivot ↔ co-parent + même `numero_dossier`
|
||||||
|
- Enfants du foyer rattachés au co-parent
|
||||||
|
- E-mail **création MDP** (pas mail « en attente »)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Co-parent ajouté au foyer. 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-co",
|
||||||
|
"statut": "actif"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Erreurs
|
||||||
|
|
||||||
|
| Code | Cas |
|
||||||
|
|------|-----|
|
||||||
|
| 400 | Déjà un co-parent / 2 responsables / validation adresse |
|
||||||
|
| 401 | Token invalide |
|
||||||
|
| 403 | Rôle non staff |
|
||||||
|
| 404 | Pivot introuvable |
|
||||||
|
| 409 | Email déjà pris |
|
||||||
|
|
||||||
|
## Réemploi édition identité
|
||||||
|
|
||||||
|
| Endpoint | Usage |
|
||||||
|
|----------|--------|
|
||||||
|
| `GET /dossiers/:numero` | Préremplir wizard edit |
|
||||||
|
| `PATCH /parents/:id/fiche` | Sauver identité pivot / co-parent existant |
|
||||||
|
| `PATCH /assistantes-maternelles/:id/fiche` | Édition AM |
|
||||||
|
|
||||||
|
## Branche
|
||||||
|
|
||||||
|
`feature/135-edition-dossier`
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Mini-spec front — Mode édition dossier + ajout 2ᵉ parent (#135)
|
||||||
|
|
||||||
|
Branche : `feature/135-edition-dossier`
|
||||||
|
Ticket : **#135** (full-stack)
|
||||||
|
|
||||||
|
Prérequis : **#153** (liste Dossiers) livré.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
1. Clic sur un dossier (liste #153) → ouvrir le wizard en mode **`edit`**
|
||||||
|
2. Foyer **mono-parent** : page co-parent → **switch** ajouter un 2ᵉ parent
|
||||||
|
3. Sauvegarder les champs via APIs existantes + nouvel endpoint co-parent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modes wizard
|
||||||
|
|
||||||
|
| Mode | Famille | AM |
|
||||||
|
|------|---------|-----|
|
||||||
|
| `review` | déjà | déjà |
|
||||||
|
| `create` | déjà (#129) | déjà (#156) |
|
||||||
|
| **`edit`** | **à faire** | **à faire** |
|
||||||
|
|
||||||
|
Factories : `ParentDossierWizard.edit(...)` / `AmDossierWizard.edit(...)`
|
||||||
|
Préremplir via `UserService.getDossierByNumero(numero)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## APIs
|
||||||
|
|
||||||
|
| Action | Endpoint |
|
||||||
|
|--------|----------|
|
||||||
|
| Charger | `GET /dossiers/:numero` |
|
||||||
|
| Sauver parent | `PATCH /parents/:id/fiche` |
|
||||||
|
| Sauver AM | `PATCH /assistantes-maternelles/:id/fiche` |
|
||||||
|
| **Ajouter co-parent** | **`POST /parents/:pivotUserId/co-parent`** — voir `docs/tmp/135-contrat-api-ajout-co-parent.md` |
|
||||||
|
|
||||||
|
Body co-parent :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "thomas@…",
|
||||||
|
"prenom": "Thomas",
|
||||||
|
"nom": "MARTIN",
|
||||||
|
"telephone": "0678456789",
|
||||||
|
"meme_adresse": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`UserService.addCoParent(pivotUserId, body)` → cet endpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UX
|
||||||
|
|
||||||
|
- Depuis `DossiersManagementWidget` / carte liste : clic → edit (plus seulement review pending)
|
||||||
|
- Pending : garder validation (review) ; dossiers actifs → edit
|
||||||
|
- Mono-parent : switch « Ajouter un co-parent » (comme create) → au save, `POST …/co-parent` si nouveau
|
||||||
|
- Déjà 2 parents : éditer les deux fiches ; pas de 3ᵉ
|
||||||
|
- Pas de bouton créer dans l’onglet Dossiers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hors scope
|
||||||
|
|
||||||
|
- Famille N responsables (#139)
|
||||||
|
- Suppressions (#154)
|
||||||
|
- Création dossier initial (#129 / #156)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critères d’acceptation
|
||||||
|
|
||||||
|
- [ ] Clic dossier actif → wizard edit prérempli
|
||||||
|
- [ ] PATCH fiche enregistre les modifs
|
||||||
|
- [ ] Mono-parent + switch → co-parent créé (actif + mail MDP)
|
||||||
|
- [ ] review / create inchangés
|
||||||
|
|
||||||
|
## Branche
|
||||||
|
|
||||||
|
`feature/135-edition-dossier`
|
||||||
@@ -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,35 @@
|
|||||||
|
# Matrice suppression — #154 / back **#159** / front **#160**
|
||||||
|
|
||||||
|
**Statut** : cadrage PO validé (sept. 2026)
|
||||||
|
**Milestone** : 0.1.0
|
||||||
|
**Email** : pas d’email de suppression (cas rare)
|
||||||
|
|
||||||
|
## Droits
|
||||||
|
|
||||||
|
| Cible | Qui peut supprimer |
|
||||||
|
|-------|-------------------|
|
||||||
|
| Dossier / parent / enfant / AM | `GESTIONNAIRE`, `ADMINISTRATEUR`, `SUPER_ADMIN` |
|
||||||
|
| Gestionnaire (user) | `ADMINISTRATEUR`, `SUPER_ADMIN` |
|
||||||
|
| Administrateur (user) | Autre admin OK ; **self interdit** ; **dernier admin** = `SUPER_ADMIN` only ; `SUPER_ADMIN` non supprimable |
|
||||||
|
|
||||||
|
## Matrice métier
|
||||||
|
|
||||||
|
| Point d’entrée | Action | Effet |
|
||||||
|
|----------------|--------|--------|
|
||||||
|
| Dossiers | Delete dossier **famille** | Tous **parents** + tous **enfants** ; clore placements AM des enfants |
|
||||||
|
| Dossiers / AM | Delete dossier **AM** ou compte AM | **Compte AM + dossier AM** ; enfants **conservés** ; placements **clos** |
|
||||||
|
| Parents | Co-parent (autre parent reste) | Compte parent seul ; dossier + enfants restent |
|
||||||
|
| Parents | Dernier parent | Parent + **enfants** rattachés |
|
||||||
|
| Enfants | Pas dernier | Enfant seul (retiré du dossier) |
|
||||||
|
| Enfants | Dernier + `deleteDossier=true` | Cascade dossier famille (parents + enfants) |
|
||||||
|
| Enfants | Dernier + `deleteDossier=false` | Enfant seul ; dossier peut apparaître **`sans_enfant`** |
|
||||||
|
| Pending / validé | — | **Mêmes règles** (pas de différenciation) |
|
||||||
|
|
||||||
|
## Warning
|
||||||
|
|
||||||
|
- `sans_enfant` sur liste `GET /dossiers` (dossier famille sans enfant lié).
|
||||||
|
- Miroir de `sans_responsable` (#157) côté enfants.
|
||||||
|
|
||||||
|
## Hors scope
|
||||||
|
|
||||||
|
Soft-delete RGPD, audit (#128), famille N (#139), restriction admin-only métier (plus tard).
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# Mini-spec front — Suppressions dashboard
|
||||||
|
|
||||||
|
**Ticket front** : **#160** — https://git.ptits-pas.fr/jmartin/petitspas/issues/160
|
||||||
|
**Ticket back** : **#159** — https://git.ptits-pas.fr/jmartin/petitspas/issues/159
|
||||||
|
**Epic** : #154 (complète #133)
|
||||||
|
**Branche back** : `feature/159-suppressions-backend`
|
||||||
|
**Doc matrice** : [154-matrice-suppression.md](./154-matrice-suppression.md)
|
||||||
|
|
||||||
|
Travail **en parallèle** : ce contrat est la source de vérité UI ↔ API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UX commune
|
||||||
|
|
||||||
|
Sur chaque ligne / carte des listes :
|
||||||
|
|
||||||
|
- Icône **poubelle** en bout de ligne
|
||||||
|
- Clic → **dialog de confirmation** (texte d’impact) → DELETE → **refresh** liste
|
||||||
|
- Pending = **mêmes** règles que validés
|
||||||
|
- **Pas** d’email
|
||||||
|
|
||||||
|
| Liste | Poubelle visible si |
|
||||||
|
|-------|---------------------|
|
||||||
|
| Dossiers, Parents, Enfants, AM | gestionnaire **ou** admin |
|
||||||
|
| Gestionnaires | **admin** only |
|
||||||
|
| Administrateurs | admin+ ; **pas** sur sa propre ligne ; dernier admin : UI warning + réservé super_admin |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contrat API
|
||||||
|
|
||||||
|
Base : auth Bearer. Erreurs : `400` / `403` / `404` / `409` avec `message` FR.
|
||||||
|
|
||||||
|
### 1. `DELETE /dossiers/:numeroDossier`
|
||||||
|
|
||||||
|
- **Famille** → supprime tous parents + enfants du n° ; clos placements AM des enfants.
|
||||||
|
- **AM** → compte AM + dossier AM ; enfants gardés ; placements clos.
|
||||||
|
|
||||||
|
**Réponse 200** (exemple) :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "famille",
|
||||||
|
"numero_dossier": "2026-000043",
|
||||||
|
"deleted_user_ids": ["…"],
|
||||||
|
"deleted_enfant_ids": ["…"],
|
||||||
|
"message": "Dossier famille supprimé."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
ou
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "assistante_maternelle",
|
||||||
|
"numero_dossier": "2026-000015",
|
||||||
|
"deleted_user_ids": ["…"],
|
||||||
|
"deleted_enfant_ids": [],
|
||||||
|
"message": "Dossier assistante maternelle supprimé."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dialog UI** : lister libellé + n° + « X parent(s), Y enfant(s) » (ou « compte AM, enfants conservés »).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. `DELETE /users/:id`
|
||||||
|
|
||||||
|
Comportement selon le **rôle** de la cible :
|
||||||
|
|
||||||
|
| Cible | Effet |
|
||||||
|
|-------|--------|
|
||||||
|
| Parent **co-parent** | Delete ce user seul |
|
||||||
|
| Parent **dernier** du dossier | Delete user + enfants du foyer |
|
||||||
|
| AM | Delete user AM + dossier AM ; enfants conservés ; placements clos |
|
||||||
|
| Gestionnaire | Admin only ; self → 403 |
|
||||||
|
| Administrateur | Self → 403 ; dernier admin → super_admin only sinon 403 ; super_admin → 403 |
|
||||||
|
|
||||||
|
**Réponse 200** :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"deleted_user_ids": ["…"],
|
||||||
|
"deleted_enfant_ids": ["…"],
|
||||||
|
"message": "…"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dialogs** :
|
||||||
|
|
||||||
|
- Co-parent : « Ce parent sera retiré / supprimé du dossier {n°}. Les enfants restent avec le co-parent. »
|
||||||
|
- Dernier parent : « Dernier parent du dossier {n°}. Les enfants rattachés seront aussi supprimés. »
|
||||||
|
- AM : « Le compte et le dossier AM seront supprimés. Les enfants accueillis ne seront pas supprimés. »
|
||||||
|
|
||||||
|
Optionnel (si exposé) : `GET /users/:id/suppression-impact` — sinon calculer depuis données déjà en liste / détail dossier.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. `DELETE /enfants/:id?deleteDossier=true|false`
|
||||||
|
|
||||||
|
- Pas dernier enfant → delete enfant (`deleteDossier` ignoré ou false).
|
||||||
|
- Dernier enfant + `deleteDossier=false` → delete enfant ; dossier famille peut passer `sans_enfant`.
|
||||||
|
- Dernier enfant + `deleteDossier=true` → cascade dossier famille (parents + enfants).
|
||||||
|
|
||||||
|
**Réponse 200** :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"deleted_enfant_ids": ["…"],
|
||||||
|
"deleted_user_ids": ["…"],
|
||||||
|
"dossier_supprime": false,
|
||||||
|
"message": "…"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dialog** :
|
||||||
|
|
||||||
|
- Standard : « L’enfant sera supprimé du dossier de {famille} ({n°}). »
|
||||||
|
- Dernier : proposer **deux actions** :
|
||||||
|
1. Supprimer l’enfant seulement (`deleteDossier=false`)
|
||||||
|
2. Supprimer aussi le dossier / parents (`deleteDossier=true`)
|
||||||
|
|
||||||
|
Pour savoir si dernier : compter enfants du `numero_dossier` (détail dossier ou champ impact API).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. `GET /dossiers` — flag `sans_enfant`
|
||||||
|
|
||||||
|
Chaque item famille peut exposer :
|
||||||
|
|
||||||
|
```json
|
||||||
|
"sans_enfant": true
|
||||||
|
```
|
||||||
|
|
||||||
|
- `true` si dossier **famille** sans enfant lié.
|
||||||
|
- AM : `false` ou omis.
|
||||||
|
|
||||||
|
**UI** : badge / warning vigilance (comme `sans_responsable` / alertes AM).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UserService (Flutter) — signatures cibles
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Future<void> deleteDossier(String numeroDossier);
|
||||||
|
Future<Map<String, dynamic>> deleteUser(String userId);
|
||||||
|
Future<Map<String, dynamic>> deleteEnfant(String enfantId, {bool deleteDossier = false});
|
||||||
|
```
|
||||||
|
|
||||||
|
(Adapter le parsing au JSON réel une fois le back mergé ; en parallèle, stubber sur ce contrat.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fichiers front probables
|
||||||
|
|
||||||
|
- Cartes listes : `admin_user_card.dart`, `admin_enfant_user_card.dart`, cartes dossiers
|
||||||
|
- Listes : `dossiers_management_widget.dart`, `parent_managmant_widget.dart`, `enfant_management_widget.dart`, `assistante_maternelle_management_widget.dart`, `gestionnaire_management_widget.dart`, `admin_management_widget.dart`
|
||||||
|
- `user_service.dart`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critères front (#160)
|
||||||
|
|
||||||
|
- [ ] Poubelle selon droits
|
||||||
|
- [ ] Confirmations avec impact
|
||||||
|
- [ ] Refresh après succès
|
||||||
|
- [ ] Dernier enfant : choix dossier oui/non
|
||||||
|
- [ ] Warning `sans_enfant`
|
||||||
|
- [ ] Self-admin / dernier admin gérés côté UI (masquer ou message 403)
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
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;
|
||||||
|
/// Dossier famille sans enfant lié (#159 / #160).
|
||||||
|
final bool sansEnfant;
|
||||||
|
/// Nombre d’enfants du foyer (famille uniquement ; null pour AM).
|
||||||
|
final int? enfantsCount;
|
||||||
|
|
||||||
|
const DossierListItem({
|
||||||
|
required this.type,
|
||||||
|
required this.numeroDossier,
|
||||||
|
required this.libelle,
|
||||||
|
this.emails = const [],
|
||||||
|
this.statut,
|
||||||
|
this.photoUrl,
|
||||||
|
this.sansEnfant = false,
|
||||||
|
this.enfantsCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
DossierListItem copyWith({
|
||||||
|
DossierListType? type,
|
||||||
|
String? numeroDossier,
|
||||||
|
String? libelle,
|
||||||
|
List<String>? emails,
|
||||||
|
String? statut,
|
||||||
|
String? photoUrl,
|
||||||
|
bool? sansEnfant,
|
||||||
|
int? enfantsCount,
|
||||||
|
}) {
|
||||||
|
return DossierListItem(
|
||||||
|
type: type ?? this.type,
|
||||||
|
numeroDossier: numeroDossier ?? this.numeroDossier,
|
||||||
|
libelle: libelle ?? this.libelle,
|
||||||
|
emails: emails ?? this.emails,
|
||||||
|
statut: statut ?? this.statut,
|
||||||
|
photoUrl: photoUrl ?? this.photoUrl,
|
||||||
|
sansEnfant: sansEnfant ?? this.sansEnfant,
|
||||||
|
enfantsCount: enfantsCount ?? this.enfantsCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isFamille => type == DossierListType.famille;
|
||||||
|
bool get isAm => type == DossierListType.assistanteMaternelle;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final childIds = <String>{};
|
||||||
|
var maxCountFallback = 0;
|
||||||
|
for (final p in entry.value) {
|
||||||
|
for (final c in p.children) {
|
||||||
|
final id = c.id.trim();
|
||||||
|
if (id.isNotEmpty) childIds.add(id);
|
||||||
|
}
|
||||||
|
final n = p.children.isNotEmpty ? p.children.length : p.childrenCount;
|
||||||
|
if (n > maxCountFallback) maxCountFallback = n;
|
||||||
|
}
|
||||||
|
final enfantsCount =
|
||||||
|
childIds.isNotEmpty ? childIds.length : maxCountFallback;
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
DossierListItem(
|
||||||
|
type: DossierListType.famille,
|
||||||
|
numeroDossier: entry.key,
|
||||||
|
libelle: names.isNotEmpty ? names.join(' - ') : 'Famille',
|
||||||
|
emails: emails,
|
||||||
|
statut: _preferStatut(statuts),
|
||||||
|
enfantsCount: enfantsCount,
|
||||||
|
sansEnfant: enfantsCount == 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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';
|
||||||
|
}
|
||||||
@@ -215,8 +215,13 @@ class EnfantDossier {
|
|||||||
factory EnfantDossier.fromJson(Map<String, dynamic> json) {
|
factory EnfantDossier.fromJson(Map<String, dynamic> json) {
|
||||||
final rawPhoto = json['photo_url'] ?? json['photoUrl'];
|
final rawPhoto = json['photo_url'] ?? json['photoUrl'];
|
||||||
final resolvedPhoto = _optionalPhotoUrl(rawPhoto);
|
final resolvedPhoto = _optionalPhotoUrl(rawPhoto);
|
||||||
|
final rawId = json['id'] ??
|
||||||
|
json['enfant_id'] ??
|
||||||
|
json['enfantId'] ??
|
||||||
|
json['child_id'] ??
|
||||||
|
json['childId'];
|
||||||
return EnfantDossier(
|
return EnfantDossier(
|
||||||
id: json['id']?.toString() ?? '',
|
id: rawId?.toString().trim() ?? '',
|
||||||
firstName: (json['first_name'] ?? json['prenom'])?.toString(),
|
firstName: (json['first_name'] ?? json['prenom'])?.toString(),
|
||||||
lastName: (json['last_name'] ?? json['nom'])?.toString(),
|
lastName: (json['last_name'] ?? json['nom'])?.toString(),
|
||||||
birthDate: json['birth_date']?.toString(),
|
birthDate: json['birth_date']?.toString(),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:p_tits_pas/utils/phone_utils.dart';
|
|||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/utils/email_utils.dart';
|
import 'package:p_tits_pas/utils/email_utils.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||||
import 'package:p_tits_pas/widgets/email_text_field.dart';
|
import 'package:p_tits_pas/widgets/email_text_field.dart';
|
||||||
import 'package:p_tits_pas/widgets/french_phone_field.dart';
|
import 'package:p_tits_pas/widgets/french_phone_field.dart';
|
||||||
|
|
||||||
@@ -151,30 +152,19 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
|
|||||||
Future<void> _delete() async {
|
Future<void> _delete() async {
|
||||||
if (!_isEditMode || _isSubmitting) return;
|
if (!_isEditMode || _isSubmitting) return;
|
||||||
|
|
||||||
final confirmed = await showDialog<bool>(
|
final name = widget.initialUser!.fullName.isEmpty
|
||||||
context: context,
|
? widget.initialUser!.email
|
||||||
builder: (ctx) {
|
: widget.initialUser!.fullName;
|
||||||
return AlertDialog(
|
final confirmed = await showSuppressionConfirmDialog(
|
||||||
title: const Text('Confirmer la suppression'),
|
context,
|
||||||
content: Text(
|
title: 'Supprimer l\'administrateur',
|
||||||
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
|
people: [SuppressionPersonLine.administrateur(name)],
|
||||||
),
|
footnotes: const [
|
||||||
actions: [
|
'Le compte sera définitivement supprimé.',
|
||||||
TextButton(
|
],
|
||||||
onPressed: () => Navigator.of(ctx).pop(false),
|
|
||||||
child: const Text('Annuler'),
|
|
||||||
),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: () => Navigator.of(ctx).pop(true),
|
|
||||||
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
|
|
||||||
child: const Text('Supprimer'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed != true) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isSubmitting = true;
|
_isSubmitting = true;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import 'package:p_tits_pas/models/user.dart';
|
|||||||
import 'package:p_tits_pas/services/auth_service.dart';
|
import 'package:p_tits_pas/services/auth_service.dart';
|
||||||
import 'package:p_tits_pas/services/relais_service.dart';
|
import 'package:p_tits_pas/services/relais_service.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||||
|
|
||||||
class AdminUserFormDialog extends StatefulWidget {
|
class AdminUserFormDialog extends StatefulWidget {
|
||||||
final AppUser? initialUser;
|
final AppUser? initialUser;
|
||||||
@@ -43,6 +45,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
List<RelaisModel> _relais = [];
|
List<RelaisModel> _relais = [];
|
||||||
String? _selectedRelaisId;
|
String? _selectedRelaisId;
|
||||||
String? _currentUserId;
|
String? _currentUserId;
|
||||||
|
String? _currentUserRole;
|
||||||
bool get _isEditMode => widget.initialUser != null;
|
bool get _isEditMode => widget.initialUser != null;
|
||||||
bool get _isSuperAdminTarget =>
|
bool get _isSuperAdminTarget =>
|
||||||
(widget.initialUser?.role ?? '').toLowerCase() == 'super_admin';
|
(widget.initialUser?.role ?? '').toLowerCase() == 'super_admin';
|
||||||
@@ -50,7 +53,12 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
_isEditMode &&
|
_isEditMode &&
|
||||||
_currentUserId != null &&
|
_currentUserId != null &&
|
||||||
widget.initialUser!.id == _currentUserId;
|
widget.initialUser!.id == _currentUserId;
|
||||||
bool get _canDeleteTarget => !_isSuperAdminTarget && !_isSelfTarget;
|
/// Gestionnaire : pas de delete staff. Admin+ seulement (#154 / #160).
|
||||||
|
bool get _canDeleteTarget {
|
||||||
|
if (!_isEditMode || widget.readOnly) return false;
|
||||||
|
if (_isSelfTarget || _isSuperAdminTarget) return false;
|
||||||
|
return canDeleteGestionnaire(_currentUserRole);
|
||||||
|
}
|
||||||
bool get _isLockedAdminIdentity =>
|
bool get _isLockedAdminIdentity =>
|
||||||
_isEditMode && widget.adminMode && _isSuperAdminTarget;
|
_isEditMode && widget.adminMode && _isSuperAdminTarget;
|
||||||
String get _targetRoleKey {
|
String get _targetRoleKey {
|
||||||
@@ -125,6 +133,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentUserId = cached.id;
|
_currentUserId = cached.id;
|
||||||
|
_currentUserRole = cached.role;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -132,6 +141,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
if (!mounted || refreshed == null) return;
|
if (!mounted || refreshed == null) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentUserId = refreshed.id;
|
_currentUserId = refreshed.id;
|
||||||
|
_currentUserRole = refreshed.role;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,30 +388,25 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
if (!_canDeleteTarget) return;
|
if (!_canDeleteTarget) return;
|
||||||
if (!_isEditMode || _isSubmitting) return;
|
if (!_isEditMode || _isSubmitting) return;
|
||||||
|
|
||||||
final confirmed = await showDialog<bool>(
|
final name = widget.initialUser!.fullName.isEmpty
|
||||||
context: context,
|
? widget.initialUser!.email
|
||||||
builder: (ctx) {
|
: widget.initialUser!.fullName;
|
||||||
return AlertDialog(
|
final confirmed = await showSuppressionConfirmDialog(
|
||||||
title: const Text('Confirmer la suppression'),
|
context,
|
||||||
content: Text(
|
title: widget.adminMode
|
||||||
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
|
? 'Supprimer l\'administrateur'
|
||||||
),
|
: 'Supprimer le gestionnaire',
|
||||||
actions: [
|
people: [
|
||||||
TextButton(
|
widget.adminMode
|
||||||
onPressed: () => Navigator.of(ctx).pop(false),
|
? SuppressionPersonLine.administrateur(name)
|
||||||
child: const Text('Annuler'),
|
: SuppressionPersonLine.gestionnaire(name),
|
||||||
),
|
],
|
||||||
FilledButton(
|
footnotes: const [
|
||||||
onPressed: () => Navigator.of(ctx).pop(true),
|
'Le compte sera définitivement supprimé.',
|
||||||
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
|
],
|
||||||
child: const Text('Supprimer'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed != true) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isSubmitting = true;
|
_isSubmitting = true;
|
||||||
|
|||||||
@@ -62,7 +62,10 @@ class _GestionnaireDashboardScreenState extends State<GestionnaireDashboardScree
|
|||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: UserManagementPanel(showAdministrateursTab: false),
|
child: UserManagementPanel(
|
||||||
|
showAdministrateursTab: false,
|
||||||
|
allowStaffAccountCreation: false,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const AppFooter(),
|
const AppFooter(),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -525,17 +525,58 @@ class UserService {
|
|||||||
return enfant.copyWith(parentLinks: enriched);
|
return enfant.copyWith(parentLinks: enriched);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mise à jour enfant. Avec [photoBytes] : multipart (champ `photo`), sinon JSON.
|
||||||
static Future<EnfantAdminModel> updateEnfant({
|
static Future<EnfantAdminModel> updateEnfant({
|
||||||
required String enfantId,
|
required String enfantId,
|
||||||
required Map<String, dynamic> body,
|
required Map<String, dynamic> body,
|
||||||
|
List<int>? photoBytes,
|
||||||
|
String? photoFilename,
|
||||||
}) async {
|
}) async {
|
||||||
final response = await http.patch(
|
final id = enfantId.trim();
|
||||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
|
if (id.isEmpty) {
|
||||||
headers: await _headers(),
|
throw Exception('Identifiant enfant manquant.');
|
||||||
body: jsonEncode(body),
|
}
|
||||||
);
|
final hasPhoto = photoBytes != null && photoBytes.isNotEmpty;
|
||||||
|
final http.Response response;
|
||||||
|
if (hasPhoto) {
|
||||||
|
final token = await TokenService.getToken();
|
||||||
|
final req = http.MultipartRequest(
|
||||||
|
'PATCH',
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$id'),
|
||||||
|
);
|
||||||
|
req.headers['Accept'] = 'application/json';
|
||||||
|
if (token != null) {
|
||||||
|
req.headers['Authorization'] = 'Bearer $token';
|
||||||
|
}
|
||||||
|
body.forEach((key, value) {
|
||||||
|
if (value == null) return;
|
||||||
|
req.fields[key] = value is bool
|
||||||
|
? (value ? 'true' : 'false')
|
||||||
|
: value.toString();
|
||||||
|
});
|
||||||
|
final name = (photoFilename ?? '').trim();
|
||||||
|
final filename = name.isNotEmpty ? name : 'photo.jpg';
|
||||||
|
req.files.add(
|
||||||
|
http.MultipartFile.fromBytes(
|
||||||
|
'photo',
|
||||||
|
photoBytes,
|
||||||
|
filename: filename,
|
||||||
|
contentType: _imageMediaType(filename, photoBytes),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final streamed = await req.send();
|
||||||
|
response = await http.Response.fromStream(streamed);
|
||||||
|
} else {
|
||||||
|
response = await http.patch(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$id'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant'));
|
throw Exception(
|
||||||
|
_extractErrorMessage(response.body, 'Erreur mise à jour enfant'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
final enfant = EnfantAdminModel.fromJson(
|
final enfant = EnfantAdminModel.fromJson(
|
||||||
jsonDecode(response.body) as Map<String, dynamic>,
|
jsonDecode(response.body) as Map<String, dynamic>,
|
||||||
@@ -605,14 +646,93 @@ class UserService {
|
|||||||
return enrichEnfantParentNames(enfant);
|
return enrichEnfantParentNames(enfant);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> deleteEnfant(String enfantId) async {
|
/// DELETE /enfants/:id?deleteDossier= — ticket #159 / #160.
|
||||||
|
static Future<Map<String, dynamic>> deleteEnfant(
|
||||||
|
String enfantId, {
|
||||||
|
bool deleteDossier = false,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse(
|
||||||
|
'${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId',
|
||||||
|
).replace(
|
||||||
|
queryParameters: {
|
||||||
|
'deleteDossier': deleteDossier ? 'true' : 'false',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final response = await http.delete(uri, headers: await _headers());
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 204) {
|
||||||
|
throw Exception(
|
||||||
|
_extractErrorMessage(response.body, 'Erreur suppression enfant'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _parseSuppressionBody(response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE /dossiers/:numeroDossier — ticket #159 / #160.
|
||||||
|
static Future<Map<String, dynamic>> deleteDossier(
|
||||||
|
String numeroDossier,
|
||||||
|
) async {
|
||||||
|
final num = numeroDossier.trim();
|
||||||
|
if (num.isEmpty) {
|
||||||
|
throw Exception('Numéro de dossier manquant.');
|
||||||
|
}
|
||||||
|
final encoded = Uri.encodeComponent(num);
|
||||||
final response = await http.delete(
|
final response = await http.delete(
|
||||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'),
|
||||||
headers: await _headers(),
|
headers: await _headers(),
|
||||||
);
|
);
|
||||||
if (response.statusCode != 200 && response.statusCode != 204) {
|
if (response.statusCode != 200 && response.statusCode != 204) {
|
||||||
throw Exception(_extractErrorMessage(response.body, 'Erreur suppression enfant'));
|
throw Exception(
|
||||||
|
_extractErrorMessage(response.body, 'Erreur suppression dossier'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
return _parseSuppressionBody(response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liste unifiée GET /dossiers — flags `sans_enfant` (#159).
|
||||||
|
static Future<List<Map<String, dynamic>>> listDossiers({String? q}) async {
|
||||||
|
final uri = Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}')
|
||||||
|
.replace(
|
||||||
|
queryParameters: (q != null && q.trim().isNotEmpty)
|
||||||
|
? {'q': q.trim()}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
final response = await http.get(uri, headers: await _headers());
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception(
|
||||||
|
_extractErrorMessage(response.body, 'Erreur chargement dossiers'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is! List) return const [];
|
||||||
|
return decoded
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => Map<String, dynamic>.from(e))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map `numero_dossier` → `sans_enfant` (familles uniquement).
|
||||||
|
static Future<Map<String, bool>> getSansEnfantByNumero() async {
|
||||||
|
final rows = await listDossiers();
|
||||||
|
final out = <String, bool>{};
|
||||||
|
for (final row in rows) {
|
||||||
|
final num = (row['numero_dossier'] ?? '').toString().trim();
|
||||||
|
if (num.isEmpty) continue;
|
||||||
|
final type = (row['type'] ?? '').toString().toLowerCase();
|
||||||
|
if (type != 'famille') continue;
|
||||||
|
out[num] = row['sans_enfant'] == true;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, dynamic> _parseSuppressionBody(String body) {
|
||||||
|
if (body.trim().isEmpty) return <String, dynamic>{};
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(body);
|
||||||
|
if (decoded is Map) {
|
||||||
|
return Map<String, dynamic>.from(decoded);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return <String, dynamic>{};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AM dont la liste d'enfants actifs contient [enfantId] (API actuelle).
|
/// AM dont la liste d'enfants actifs contient [enfantId] (API actuelle).
|
||||||
@@ -645,6 +765,57 @@ class UserService {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ajout d’un co-parent sur foyer mono-parent (staff, #135).
|
||||||
|
/// `POST /parents/:pivotUserId/co-parent` — succès 201.
|
||||||
|
static Future<Map<String, dynamic>> addCoParent(
|
||||||
|
String pivotUserId, {
|
||||||
|
required Map<String, dynamic> body,
|
||||||
|
}) async {
|
||||||
|
final id = pivotUserId.trim();
|
||||||
|
if (id.isEmpty) {
|
||||||
|
throw Exception('Identifiant du parent pivot manquant.');
|
||||||
|
}
|
||||||
|
final http.Response response;
|
||||||
|
try {
|
||||||
|
response = await http.post(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$id/co-parent'),
|
||||||
|
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 ajout co-parent',
|
||||||
|
);
|
||||||
|
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 famille actif côté staff (#129).
|
/// Création dossier famille actif côté staff (#129).
|
||||||
/// `POST /parents/dossier` — body aligné sur register parent complet.
|
/// `POST /parents/dossier` — body aligné sur register parent complet.
|
||||||
/// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur, par parent créé).
|
/// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur, par parent créé).
|
||||||
@@ -1132,22 +1303,18 @@ class UserService {
|
|||||||
return AppUser.fromJson(data);
|
return AppUser.fromJson(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> deleteUser(String userId) async {
|
/// DELETE /users/:id — cascades métier #159 / #160.
|
||||||
|
static Future<Map<String, dynamic>> deleteUser(String userId) async {
|
||||||
final response = await http.delete(
|
final response = await http.delete(
|
||||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'),
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'),
|
||||||
headers: await _headers(),
|
headers: await _headers(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode != 200 && response.statusCode != 204) {
|
if (response.statusCode != 200 && response.statusCode != 204) {
|
||||||
final decoded = jsonDecode(response.body);
|
throw Exception(
|
||||||
if (decoded is Map<String, dynamic>) {
|
_extractErrorMessage(response.body, 'Erreur suppression utilisateur'),
|
||||||
final message = decoded['message'];
|
);
|
||||||
if (message is List && message.isNotEmpty) {
|
|
||||||
throw Exception(message.join(' - '));
|
|
||||||
}
|
|
||||||
throw Exception(_toStr(message) ?? 'Erreur suppression utilisateur');
|
|
||||||
}
|
|
||||||
throw Exception('Erreur suppression utilisateur');
|
|
||||||
}
|
}
|
||||||
|
return _parseSuppressionBody(response.body);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/// Création comptes staff (gestionnaire / admin) — ticket #161.
|
||||||
|
bool canCreateStaffAccounts(String? role) {
|
||||||
|
final r = (role ?? '').trim().toLowerCase();
|
||||||
|
return r == 'administrateur' || r == 'super_admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Droits d’affichage poubelle — tickets #154 / #160.
|
||||||
|
bool canDeleteMetier(String? role) {
|
||||||
|
final r = (role ?? '').trim().toLowerCase();
|
||||||
|
return r == 'gestionnaire' ||
|
||||||
|
r == 'administrateur' ||
|
||||||
|
r == 'super_admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool canDeleteGestionnaire(String? role) {
|
||||||
|
final r = (role ?? '').trim().toLowerCase();
|
||||||
|
return r == 'administrateur' || r == 'super_admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool canDeleteAdministrateur({
|
||||||
|
required String? currentRole,
|
||||||
|
required String? currentUserId,
|
||||||
|
required String targetUserId,
|
||||||
|
required String targetRole,
|
||||||
|
required int adminCount,
|
||||||
|
}) {
|
||||||
|
final me = (currentRole ?? '').trim().toLowerCase();
|
||||||
|
if (me != 'administrateur' && me != 'super_admin') return false;
|
||||||
|
if (targetUserId.trim().isEmpty) return false;
|
||||||
|
if (currentUserId != null &&
|
||||||
|
currentUserId.trim() == targetUserId.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final target = targetRole.trim().toLowerCase();
|
||||||
|
if (target == 'super_admin') return false;
|
||||||
|
if (adminCount <= 1 && me != 'super_admin') return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? adminDeleteBlockedReason({
|
||||||
|
required String? currentRole,
|
||||||
|
required String? currentUserId,
|
||||||
|
required String targetUserId,
|
||||||
|
required String targetRole,
|
||||||
|
required int adminCount,
|
||||||
|
}) {
|
||||||
|
if (currentUserId != null &&
|
||||||
|
currentUserId.trim() == targetUserId.trim()) {
|
||||||
|
return 'Vous ne pouvez pas supprimer votre propre compte.';
|
||||||
|
}
|
||||||
|
if (targetRole.trim().toLowerCase() == 'super_admin') {
|
||||||
|
return 'Le super administrateur ne peut pas être supprimé.';
|
||||||
|
}
|
||||||
|
if (adminCount <= 1 &&
|
||||||
|
(currentRole ?? '').trim().toLowerCase() != 'super_admin') {
|
||||||
|
return 'Seul un super administrateur peut supprimer le dernier administrateur.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ import 'package:p_tits_pas/utils/phone_utils.dart';
|
|||||||
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
|
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
|
||||||
import 'package:p_tits_pas/services/auth_service.dart';
|
import 'package:p_tits_pas/services/auth_service.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||||
|
|
||||||
class AdminManagementWidget extends StatefulWidget {
|
class AdminManagementWidget extends StatefulWidget {
|
||||||
@@ -24,6 +26,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
String? _error;
|
String? _error;
|
||||||
List<AppUser> _admins = [];
|
List<AppUser> _admins = [];
|
||||||
String? _currentUserRole;
|
String? _currentUserRole;
|
||||||
|
String? _currentUserId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -62,6 +65,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentUserRole = (cached.role).toLowerCase();
|
_currentUserRole = (cached.role).toLowerCase();
|
||||||
|
_currentUserId = cached.id;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -69,6 +73,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
if (!mounted || refreshed == null) return;
|
if (!mounted || refreshed == null) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentUserRole = (refreshed.role).toLowerCase();
|
_currentUserRole = (refreshed.role).toLowerCase();
|
||||||
|
_currentUserId = refreshed.id;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +84,16 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
return _currentUserRole == 'super_admin';
|
return _currentUserRole == 'super_admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _canDeleteAdmin(AppUser target) {
|
||||||
|
return canDeleteAdministrateur(
|
||||||
|
currentRole: _currentUserRole,
|
||||||
|
currentUserId: _currentUserId,
|
||||||
|
targetUserId: target.id,
|
||||||
|
targetRole: target.role,
|
||||||
|
adminCount: _admins.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _openAdminEditDialog(AppUser user) async {
|
Future<void> _openAdminEditDialog(AppUser user) async {
|
||||||
final canEdit = _canEditAdmin(user);
|
final canEdit = _canEditAdmin(user);
|
||||||
final changed = await showDialog<bool>(
|
final changed = await showDialog<bool>(
|
||||||
@@ -98,6 +113,56 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDelete(AppUser user) async {
|
||||||
|
final blocked = adminDeleteBlockedReason(
|
||||||
|
currentRole: _currentUserRole,
|
||||||
|
currentUserId: _currentUserId,
|
||||||
|
targetUserId: user.id,
|
||||||
|
targetRole: user.role,
|
||||||
|
adminCount: _admins.length,
|
||||||
|
);
|
||||||
|
if (blocked != null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(blocked)),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final name = user.fullName.isNotEmpty ? user.fullName : user.email;
|
||||||
|
final isLast = _admins.length <= 1;
|
||||||
|
final confirmed = await showSuppressionConfirmDialog(
|
||||||
|
context,
|
||||||
|
title: 'Supprimer l\'administrateur',
|
||||||
|
people: [SuppressionPersonLine.administrateur(name)],
|
||||||
|
footnotes: [
|
||||||
|
if (isLast)
|
||||||
|
'Attention : c’est le dernier administrateur.',
|
||||||
|
'Le compte sera définitivement supprimé.',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (!confirmed || !mounted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await UserService.deleteUser(user.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
final msg =
|
||||||
|
(result['message'] ?? 'Administrateur supprimé.').toString();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||||
|
await _loadAdmins();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
e is Exception
|
||||||
|
? e.toString().replaceFirst('Exception: ', '')
|
||||||
|
: 'Erreur suppression',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final query = widget.searchQuery.toLowerCase();
|
final query = widget.searchQuery.toLowerCase();
|
||||||
@@ -117,6 +182,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
final user = filteredAdmins[index];
|
final user = filteredAdmins[index];
|
||||||
final isSuperAdmin = _isSuperAdmin(user);
|
final isSuperAdmin = _isSuperAdmin(user);
|
||||||
final canEdit = _canEditAdmin(user);
|
final canEdit = _canEditAdmin(user);
|
||||||
|
final canDelete = _canDeleteAdmin(user);
|
||||||
return AdminUserCard(
|
return AdminUserCard(
|
||||||
title: user.fullName,
|
title: user.fullName,
|
||||||
fallbackIcon: isSuperAdmin
|
fallbackIcon: isSuperAdmin
|
||||||
@@ -148,6 +214,8 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
_openAdminEditDialog(user);
|
_openAdminEditDialog(user);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (canDelete)
|
||||||
|
suppressionIconButton(onPressed: () => _confirmDelete(user)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ import 'package:p_tits_pas/widgets/admin/validation_valider_confirm_dialog.dart'
|
|||||||
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
||||||
import 'package:p_tits_pas/widgets/common/identity_block.dart';
|
import 'package:p_tits_pas/widgets/common/identity_block.dart';
|
||||||
|
|
||||||
enum AmDossierWizardMode { review, create }
|
enum AmDossierWizardMode { review, create, edit }
|
||||||
|
|
||||||
/// Wizard dossier AM — modes [review] (validation pending) et [create] (staff #156).
|
/// Wizard dossier AM — [review] (#107), [create] (#156), [edit] (#135).
|
||||||
class AmDossierWizard extends StatefulWidget {
|
class AmDossierWizard extends StatefulWidget {
|
||||||
final AmDossierWizardMode mode;
|
final AmDossierWizardMode mode;
|
||||||
final DossierAM? dossier;
|
final DossierAM? dossier;
|
||||||
@@ -74,7 +74,25 @@ class AmDossierWizard extends StatefulWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
factory AmDossierWizard.edit({
|
||||||
|
Key? key,
|
||||||
|
required DossierAM dossier,
|
||||||
|
required VoidCallback onClose,
|
||||||
|
required VoidCallback onSuccess,
|
||||||
|
void Function(int step, int total)? onStepChanged,
|
||||||
|
}) {
|
||||||
|
return AmDossierWizard._(
|
||||||
|
key: key,
|
||||||
|
mode: AmDossierWizardMode.edit,
|
||||||
|
dossier: dossier,
|
||||||
|
onClose: onClose,
|
||||||
|
onSuccess: onSuccess,
|
||||||
|
onStepChanged: onStepChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
bool get isCreate => mode == AmDossierWizardMode.create;
|
bool get isCreate => mode == AmDossierWizardMode.create;
|
||||||
|
bool get isEdit => mode == AmDossierWizardMode.edit;
|
||||||
|
|
||||||
/// Hauteur corps modale AM — dérivée de [ValidationFormMetrics] (4 lignes).
|
/// Hauteur corps modale AM — dérivée de [ValidationFormMetrics] (4 lignes).
|
||||||
static double get shellBodyHeight =>
|
static double get shellBodyHeight =>
|
||||||
@@ -118,9 +136,11 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
|||||||
String? _photoFilename;
|
String? _photoFilename;
|
||||||
|
|
||||||
bool get _isCreate => widget.isCreate;
|
bool get _isCreate => widget.isCreate;
|
||||||
|
bool get _isEdit => widget.isEdit;
|
||||||
|
bool get _isEditable => _isCreate || _isEdit;
|
||||||
DossierAM get _dossier => widget.dossier!;
|
DossierAM get _dossier => widget.dossier!;
|
||||||
bool get _isEnAttente =>
|
bool get _isEnAttente =>
|
||||||
!_isCreate && _dossier.user.statut == 'en_attente';
|
!_isCreate && !_isEdit && _dossier.user.statut == 'en_attente';
|
||||||
|
|
||||||
static String _v(String? s) =>
|
static String _v(String? s) =>
|
||||||
(s != null && s.trim().isNotEmpty) ? s.trim() : '–';
|
(s != null && s.trim().isNotEmpty) ? s.trim() : '–';
|
||||||
@@ -151,9 +171,33 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
|||||||
_capaciteCtrl = TextEditingController(text: '1');
|
_capaciteCtrl = TextEditingController(text: '1');
|
||||||
_placesCtrl = TextEditingController(text: '1');
|
_placesCtrl = TextEditingController(text: '1');
|
||||||
_presentationCtrl = TextEditingController();
|
_presentationCtrl = TextEditingController();
|
||||||
|
if (_isEdit) {
|
||||||
|
_prefillFromDossier();
|
||||||
|
}
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
|
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _prefillFromDossier() {
|
||||||
|
final u = _dossier.user;
|
||||||
|
_nomCtrl.text = (u.nom ?? '').trim();
|
||||||
|
_prenomCtrl.text = (u.prenom ?? '').trim();
|
||||||
|
_telCtrl.text = (u.telephone ?? '').trim();
|
||||||
|
_emailCtrl.text = u.email.trim();
|
||||||
|
_adresseCtrl.text = (u.adresse ?? '').trim();
|
||||||
|
_cpCtrl.text = (u.codePostal ?? '').trim();
|
||||||
|
_villeCtrl.text = (u.ville ?? '').trim();
|
||||||
|
_nirCtrl.text = (_dossier.nir ?? '').trim();
|
||||||
|
_dateNaissanceCtrl.text = formatIsoDateFrInput(u.dateNaissance);
|
||||||
|
_lieuNaissanceVilleCtrl.text = (u.lieuNaissanceVille ?? '').trim();
|
||||||
|
final pays = (u.lieuNaissancePays ?? '').trim();
|
||||||
|
_lieuNaissancePaysCtrl.text = pays.isEmpty ? 'France' : pays;
|
||||||
|
_agrementCtrl.text = (_dossier.numeroAgrement ?? '').trim();
|
||||||
|
_dateAgrementCtrl.text = formatIsoDateFrInput(_dossier.dateAgrement);
|
||||||
|
_capaciteCtrl.text = '${_dossier.nbMaxEnfants ?? 1}';
|
||||||
|
_placesCtrl.text = '${_dossier.placesDisponibles ?? 0}';
|
||||||
|
_presentationCtrl.text = (_dossier.presentation ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_nomCtrl.dispose();
|
_nomCtrl.dispose();
|
||||||
@@ -373,8 +417,8 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _validateStep1() {
|
String? _validateStep1({required bool requirePhoto}) {
|
||||||
if (_photoBytes == null || _photoBytes!.isEmpty) {
|
if (requirePhoto && (_photoBytes == null || _photoBytes!.isEmpty)) {
|
||||||
return 'Une photo de profil est requise.';
|
return 'Une photo de profil est requise.';
|
||||||
}
|
}
|
||||||
final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text);
|
final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text);
|
||||||
@@ -407,12 +451,12 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String? _validateCurrentStep() {
|
String? _validateCurrentStep() {
|
||||||
if (!_isCreate) return null;
|
if (!_isEditable) return null;
|
||||||
switch (_step) {
|
switch (_step) {
|
||||||
case 0:
|
case 0:
|
||||||
return _validateStep0();
|
return _validateStep0();
|
||||||
case 1:
|
case 1:
|
||||||
return _validateStep1();
|
return _validateStep1(requirePhoto: _isCreate);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -511,7 +555,7 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final err1 = _validateStep1();
|
final err1 = _validateStep1(requirePhoto: true);
|
||||||
if (err1 != null) {
|
if (err1 != null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(err1), backgroundColor: Colors.red.shade700),
|
SnackBar(content: Text(err1), backgroundColor: Colors.red.shade700),
|
||||||
@@ -554,6 +598,94 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _saveEdit() async {
|
||||||
|
if (_submitting || !_isEdit) return;
|
||||||
|
final err0 = _validateStep0();
|
||||||
|
if (err0 != null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(err0), backgroundColor: Colors.red.shade700),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final err1 = _validateStep1(requirePhoto: false);
|
||||||
|
if (err1 != null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(err1), backgroundColor: Colors.red.shade700),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final amId = _dossier.user.id.trim();
|
||||||
|
if (amId.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('Identifiant AM manquant.'),
|
||||||
|
backgroundColor: Colors.red.shade700,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final ok = await showValidationValiderConfirmDialog(
|
||||||
|
context,
|
||||||
|
body: 'Enregistrer les modifications de la fiche assistante maternelle ?',
|
||||||
|
);
|
||||||
|
if (!mounted || !ok) return;
|
||||||
|
|
||||||
|
final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text);
|
||||||
|
final agrementIso = parseFrDateToIso(_dateAgrementCtrl.text);
|
||||||
|
final capa = int.tryParse(_capaciteCtrl.text.trim());
|
||||||
|
final places = int.tryParse(_placesCtrl.text.trim());
|
||||||
|
final biography = _presentationCtrl.text.trim();
|
||||||
|
|
||||||
|
setState(() => _submitting = true);
|
||||||
|
try {
|
||||||
|
await UserService.updateAmFiche(
|
||||||
|
amUserId: amId,
|
||||||
|
body: {
|
||||||
|
'nom': formatPersonNameCase(_nomCtrl.text),
|
||||||
|
'prenom': formatPersonNameCase(_prenomCtrl.text),
|
||||||
|
'email': normalizeEmailText(_emailCtrl.text),
|
||||||
|
'telephone': normalizePhone(_telCtrl.text),
|
||||||
|
'adresse': _adresseCtrl.text.trim(),
|
||||||
|
'ville': formatPersonNameCase(_villeCtrl.text),
|
||||||
|
'code_postal': _cpCtrl.text.trim(),
|
||||||
|
'approval_number': _agrementCtrl.text.trim(),
|
||||||
|
'nir': nirToRaw(_nirCtrl.text),
|
||||||
|
if (birthIso != null) 'date_naissance': birthIso,
|
||||||
|
'lieu_naissance_ville':
|
||||||
|
formatPersonNameCase(_lieuNaissanceVilleCtrl.text),
|
||||||
|
'lieu_naissance_pays':
|
||||||
|
formatPersonNameCase(_lieuNaissancePaysCtrl.text),
|
||||||
|
if (agrementIso != null) 'agreement_date': agrementIso,
|
||||||
|
if (capa != null) 'max_children': capa,
|
||||||
|
if (places != null) 'places_available': places,
|
||||||
|
'biography': biography,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Fiche AM enregistrée.'),
|
||||||
|
duration: 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_showRefusForm) {
|
if (_showRefusForm) {
|
||||||
@@ -587,7 +719,7 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStep0() {
|
Widget _buildStep0() {
|
||||||
if (_isCreate) {
|
if (_isEditable) {
|
||||||
return IdentityBlock.editable(
|
return IdentityBlock.editable(
|
||||||
title: 'Identité et coordonnées',
|
title: 'Identité et coordonnées',
|
||||||
nomController: _nomCtrl,
|
nomController: _nomCtrl,
|
||||||
@@ -620,7 +752,7 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
|||||||
.clamp(_photoColumnMinWidth, 360.0);
|
.clamp(_photoColumnMinWidth, 360.0);
|
||||||
if (photoW > maxPhotoW) photoW = maxPhotoW;
|
if (photoW > maxPhotoW) photoW = maxPhotoW;
|
||||||
|
|
||||||
final form = _isCreate
|
final form = _isEditable
|
||||||
? _buildCreateProFields()
|
? _buildCreateProFields()
|
||||||
: ValidationDetailSection(
|
: ValidationDetailSection(
|
||||||
title: 'Dossier professionnel',
|
title: 'Dossier professionnel',
|
||||||
@@ -628,20 +760,25 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
|||||||
rowLayout: _photoProRowLayout,
|
rowLayout: _photoProRowLayout,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final Widget photo;
|
||||||
|
if (_isCreate) {
|
||||||
|
photo = AdminAmPhotoFrame(
|
||||||
|
imageBytes: _photoBytes,
|
||||||
|
onTap: _pickPhoto,
|
||||||
|
onClear: _photoBytes != null ? _clearPhoto : null,
|
||||||
|
emptyLabel: 'Ajouter une photo',
|
||||||
|
);
|
||||||
|
} else if (_isEdit) {
|
||||||
|
// Édition : photo existante en lecture ; remplacement hors scope PATCH fiche.
|
||||||
|
photo = _buildPhotoSectionReview(_dossier.user);
|
||||||
|
} else {
|
||||||
|
photo = _buildPhotoSectionReview(_dossier.user);
|
||||||
|
}
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(width: photoW, child: photo),
|
||||||
width: photoW,
|
|
||||||
child: _isCreate
|
|
||||||
? AdminAmPhotoFrame(
|
|
||||||
imageBytes: _photoBytes,
|
|
||||||
onTap: _pickPhoto,
|
|
||||||
onClear: _photoBytes != null ? _clearPhoto : null,
|
|
||||||
emptyLabel: 'Ajouter une photo',
|
|
||||||
)
|
|
||||||
: _buildPhotoSectionReview(_dossier.user),
|
|
||||||
),
|
|
||||||
const SizedBox(width: _photoProGap),
|
const SizedBox(width: _photoProGap),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Align(
|
child: Align(
|
||||||
@@ -721,7 +858,7 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStep2() {
|
Widget _buildStep2() {
|
||||||
if (_isCreate) {
|
if (_isEditable) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
@@ -832,6 +969,12 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
|||||||
onPressed: _submitting ? null : _createAndValidate,
|
onPressed: _submitting ? null : _createAndValidate,
|
||||||
child: Text(_submitting ? 'Envoi...' : 'Créer et valider'),
|
child: Text(_submitting ? 'Envoi...' : 'Créer et valider'),
|
||||||
),
|
),
|
||||||
|
] else if (_isEdit) ...[
|
||||||
|
ElevatedButton(
|
||||||
|
style: ValidationModalTheme.primaryElevatedStyle,
|
||||||
|
onPressed: _submitting ? null : _saveEdit,
|
||||||
|
child: Text(_submitting ? 'Envoi...' : 'Enregistrer'),
|
||||||
|
),
|
||||||
] else if (_isEnAttente) ...[
|
] else if (_isEnAttente) ...[
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onPressed: _submitting ? null : _refuser,
|
onPressed: _submitting ? null : _refuser,
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
||||||
|
import 'package:p_tits_pas/models/dossier_list_item.dart';
|
||||||
import 'package:p_tits_pas/utils/am_vigilance.dart';
|
import 'package:p_tits_pas/utils/am_vigilance.dart';
|
||||||
|
import 'package:p_tits_pas/services/auth_service.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||||
|
|
||||||
class AssistanteMaternelleManagementWidget extends StatefulWidget {
|
class AssistanteMaternelleManagementWidget extends StatefulWidget {
|
||||||
@@ -26,16 +30,24 @@ class _AssistanteMaternelleManagementWidgetState
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
List<AssistanteMaternelleModel> _assistantes = [];
|
List<AssistanteMaternelleModel> _assistantes = [];
|
||||||
|
bool _canDelete = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_loadRights();
|
||||||
_loadAssistantes();
|
_loadAssistantes();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() => super.dispose();
|
void dispose() => super.dispose();
|
||||||
|
|
||||||
|
Future<void> _loadRights() async {
|
||||||
|
final user = await AuthService.getCurrentUser();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _canDelete = canDeleteMetier(user?.role));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadAssistantes() async {
|
Future<void> _loadAssistantes() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
@@ -57,6 +69,46 @@ class _AssistanteMaternelleManagementWidgetState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDelete(AssistanteMaternelleModel am) async {
|
||||||
|
final num = (am.user.numeroDossier ?? '').trim();
|
||||||
|
final name = formatDossierPersonLabel(
|
||||||
|
nom: am.user.nom,
|
||||||
|
prenom: am.user.prenom,
|
||||||
|
email: am.user.email,
|
||||||
|
);
|
||||||
|
final confirmed = await showSuppressionConfirmDialog(
|
||||||
|
context,
|
||||||
|
title: 'Supprimer l\'assistante maternelle',
|
||||||
|
subtitle: num.isEmpty ? null : 'Dossier $num',
|
||||||
|
people: [SuppressionPersonLine.am(name)],
|
||||||
|
footnotes: const [
|
||||||
|
'Le compte et le dossier AM seront supprimés.',
|
||||||
|
'Les enfants accueillis ne seront pas supprimés '
|
||||||
|
'(placements clos).',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (!confirmed || !mounted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await UserService.deleteUser(am.user.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
final msg = (result['message'] ?? 'AM supprimée.').toString();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||||
|
await _loadAssistantes();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
e is Exception
|
||||||
|
? e.toString().replaceFirst('Exception: ', '')
|
||||||
|
: 'Erreur suppression',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final query = widget.searchQuery.toLowerCase();
|
final query = widget.searchQuery.toLowerCase();
|
||||||
@@ -96,6 +148,10 @@ class _AssistanteMaternelleManagementWidgetState
|
|||||||
_openAssistanteDetails(assistante);
|
_openAssistanteDetails(assistante);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (_canDelete)
|
||||||
|
suppressionIconButton(
|
||||||
|
onPressed: () => _confirmDelete(assistante),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -69,29 +69,6 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
static const double _proTabHeight = 300;
|
static const double _proTabHeight = 300;
|
||||||
static const List<int> _photoProRowLayout = [2, 2, 2];
|
static const List<int> _photoProRowLayout = [2, 2, 2];
|
||||||
|
|
||||||
/// Hauteur onglet enfants : champs + titre + grille 2×2 (+ alerte places si besoin).
|
|
||||||
double _childrenTabHeight() {
|
|
||||||
const capacityFields = 72.0;
|
|
||||||
const titleSection = 40.0;
|
|
||||||
const inconsistencyExtra = 46.0;
|
|
||||||
var h = capacityFields +
|
|
||||||
titleSection +
|
|
||||||
AdminAmChildrenCapacityGrid.fixedHeight;
|
|
||||||
if (_placesInconsistent()) h += inconsistencyExtra;
|
|
||||||
return h + 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
double _tabViewHeight(int index) {
|
|
||||||
switch (index) {
|
|
||||||
case 1:
|
|
||||||
return _proTabHeight;
|
|
||||||
case 2:
|
|
||||||
return _childrenTabHeight();
|
|
||||||
default:
|
|
||||||
return 292;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -541,17 +518,15 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
|
|
||||||
|
|
||||||
Widget _identityTab() {
|
Widget _identityTab() {
|
||||||
return SingleChildScrollView(
|
return IdentityBlock.editable(
|
||||||
child: IdentityBlock.editable(
|
title: 'Identité et coordonnées',
|
||||||
title: 'Identité et coordonnées',
|
nomController: _nomCtrl,
|
||||||
nomController: _nomCtrl,
|
prenomController: _prenomCtrl,
|
||||||
prenomController: _prenomCtrl,
|
telephoneController: _telCtrl,
|
||||||
telephoneController: _telCtrl,
|
emailController: _emailCtrl,
|
||||||
emailController: _emailCtrl,
|
adresseController: _adresseCtrl,
|
||||||
adresseController: _adresseCtrl,
|
codePostalController: _cpCtrl,
|
||||||
codePostalController: _cpCtrl,
|
villeController: _villeCtrl,
|
||||||
villeController: _villeCtrl,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,7 +585,9 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, c) {
|
builder: (context, c) {
|
||||||
final maxRowW = c.maxWidth;
|
final maxRowW = c.maxWidth;
|
||||||
final maxRowH = c.maxHeight;
|
// Hauteur bornée : l’onglet pro a une hauteur fixe (photo ID).
|
||||||
|
final maxRowH =
|
||||||
|
c.maxHeight.isFinite ? c.maxHeight : _proTabHeight;
|
||||||
final bodyH = maxRowH;
|
final bodyH = maxRowH;
|
||||||
final idealPhotoW = bodyH * AdminAmPhotoFrame.idPhotoAspectRatio + 16;
|
final idealPhotoW = bodyH * AdminAmPhotoFrame.idPhotoAspectRatio + 16;
|
||||||
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
|
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
|
||||||
@@ -659,6 +636,18 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Corps de l’onglet actif — hauteur naturelle (pas de TabBarView).
|
||||||
|
Widget _buildActiveTabBody() {
|
||||||
|
switch (_tabCtrl.index) {
|
||||||
|
case 1:
|
||||||
|
return SizedBox(height: _proTabHeight, child: _proTab());
|
||||||
|
case 2:
|
||||||
|
return _childrenTab();
|
||||||
|
default:
|
||||||
|
return _identityTab();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _childrenCapacityFields() {
|
Widget _childrenCapacityFields() {
|
||||||
final inconsistent = _placesInconsistent();
|
final inconsistent = _placesInconsistent();
|
||||||
return Column(
|
return Column(
|
||||||
@@ -867,15 +856,13 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
|
||||||
child: SizedBox(
|
child: AnimatedSize(
|
||||||
height: _tabViewHeight(_tabCtrl.index),
|
duration: const Duration(milliseconds: 180),
|
||||||
child: TabBarView(
|
curve: Curves.easeInOut,
|
||||||
controller: _tabCtrl,
|
alignment: Alignment.topCenter,
|
||||||
children: [
|
child: KeyedSubtree(
|
||||||
_identityTab(),
|
key: ValueKey<int>(_tabCtrl.index),
|
||||||
_proTab(),
|
child: _buildActiveTabBody(),
|
||||||
_childrenTab(),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
||||||
|
import 'package:p_tits_pas/models/dossier_list_item.dart';
|
||||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||||
import 'package:p_tits_pas/services/auth_service.dart';
|
import 'package:p_tits_pas/services/auth_service.dart';
|
||||||
@@ -9,11 +10,13 @@ import 'package:p_tits_pas/services/user_service.dart';
|
|||||||
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
||||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||||
import 'package:p_tits_pas/utils/name_format_utils.dart';
|
import 'package:p_tits_pas/utils/name_format_utils.dart';
|
||||||
|
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_select_am_modal.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_select_am_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_select_famille_modal.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_select_famille_modal.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
||||||
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
||||||
@@ -97,8 +100,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
_isUnborn ? 'Date prévisionnelle' : 'Date de naissance';
|
_isUnborn ? 'Date prévisionnelle' : 'Date de naissance';
|
||||||
|
|
||||||
bool get _canDelete =>
|
bool get _canDelete =>
|
||||||
!widget.isCreating &&
|
!widget.isCreating && canDeleteMetier(_currentUserRole);
|
||||||
(_currentUserRole ?? '').toLowerCase() == 'super_admin';
|
|
||||||
|
|
||||||
bool get _busy => _saving || _deleting;
|
bool get _busy => _saving || _deleting;
|
||||||
|
|
||||||
@@ -506,37 +508,99 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
if (!_canDelete || _deleting) return;
|
if (!_canDelete || _deleting) return;
|
||||||
|
|
||||||
final name = _headerTitle();
|
final name = _headerTitle();
|
||||||
final confirmed = await showDialog<bool>(
|
final enfant = widget.enfant;
|
||||||
context: context,
|
if (enfant == null) return;
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
title: const Text('Supprimer l\'enfant'),
|
bool deleteDossierFlag = false;
|
||||||
content: Text(
|
String? numero;
|
||||||
'Supprimer définitivement la fiche de $name ?\n'
|
String familleLabel = '';
|
||||||
'Cette action est irréversible.',
|
bool isLast = false;
|
||||||
|
|
||||||
|
String? amLabel;
|
||||||
|
final linked = _linkedAm;
|
||||||
|
if (linked != null) {
|
||||||
|
final label = formatDossierPersonLabel(
|
||||||
|
nom: linked.user.nom,
|
||||||
|
prenom: linked.user.prenom,
|
||||||
|
email: linked.user.email,
|
||||||
|
);
|
||||||
|
if (label.isNotEmpty) amLabel = label;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
final am = await UserService.findAmForEnfant(enfant.id);
|
||||||
|
if (am != null) {
|
||||||
|
final label = formatDossierPersonLabel(
|
||||||
|
nom: am.user.nom,
|
||||||
|
prenom: am.user.prenom,
|
||||||
|
email: am.user.email,
|
||||||
|
);
|
||||||
|
if (label.isNotEmpty) amLabel = label;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
final parentId = enfant.parentLinks
|
||||||
|
.map((l) => l.parentId.trim())
|
||||||
|
.firstWhere((id) => id.isNotEmpty, orElse: () => '');
|
||||||
|
if (parentId.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final parent = await UserService.getParent(parentId);
|
||||||
|
final num = (parent.user.numeroDossier ?? '').trim();
|
||||||
|
numero = num;
|
||||||
|
familleLabel = parent.user.fullName.isNotEmpty
|
||||||
|
? parent.user.fullName
|
||||||
|
: parent.user.email;
|
||||||
|
if (num.isNotEmpty) {
|
||||||
|
final dossier = await UserService.getDossier(num);
|
||||||
|
if (dossier.isFamily) {
|
||||||
|
isLast = dossier.asFamily.enfants.length <= 1;
|
||||||
|
final names = dossier.asFamily.parents
|
||||||
|
.map((p) => formatDossierPersonLabel(
|
||||||
|
nom: p.nom,
|
||||||
|
prenom: p.prenom,
|
||||||
|
email: p.email,
|
||||||
|
))
|
||||||
|
.where((s) => s.isNotEmpty)
|
||||||
|
.join(' - ');
|
||||||
|
if (names.isNotEmpty) familleLabel = names;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (isLast && (numero ?? '').isNotEmpty) {
|
||||||
|
final choice = await showDernierEnfantSuppressionDialog(
|
||||||
|
context,
|
||||||
|
enfantName: name,
|
||||||
|
familleLabel: familleLabel,
|
||||||
|
numeroDossier: numero!,
|
||||||
|
amLabel: amLabel,
|
||||||
|
);
|
||||||
|
if (choice == null || !mounted) return;
|
||||||
|
deleteDossierFlag =
|
||||||
|
choice == DernierEnfantSuppressionChoice.dossierAussi;
|
||||||
|
} else {
|
||||||
|
final confirmed = await showSuppressionConfirmDialog(
|
||||||
|
context,
|
||||||
|
title: 'Supprimer l\'enfant',
|
||||||
|
subtitle: (numero ?? '').isEmpty ? null : 'Dossier $numero',
|
||||||
|
people: [SuppressionPersonLine.enfant(name)],
|
||||||
|
footnotes: enfantSuppressionFootnotes(
|
||||||
|
numeroDossier: numero,
|
||||||
|
familleLabel: familleLabel,
|
||||||
|
amLabel: amLabel,
|
||||||
),
|
),
|
||||||
actions: [
|
);
|
||||||
TextButton(
|
if (!confirmed || !mounted) return;
|
||||||
onPressed: () => Navigator.pop(ctx, false),
|
}
|
||||||
child: const Text('Annuler'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx, true),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.red.shade700,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
),
|
|
||||||
child: const Text('Supprimer'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (confirmed != true || !mounted) return;
|
|
||||||
|
|
||||||
setState(() => _deleting = true);
|
setState(() => _deleting = true);
|
||||||
try {
|
try {
|
||||||
final id = widget.enfant?.id;
|
final id = widget.enfant?.id;
|
||||||
if (id == null || id.isEmpty) return;
|
if (id == null || id.isEmpty) return;
|
||||||
await UserService.deleteEnfant(id);
|
await UserService.deleteEnfant(id, deleteDossier: deleteDossierFlag);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
widget.onDeleted?.call();
|
widget.onDeleted?.call();
|
||||||
widget.onSaved?.call();
|
widget.onSaved?.call();
|
||||||
@@ -548,7 +612,13 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _deleting = false);
|
setState(() => _deleting = false);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
e is Exception
|
||||||
|
? e.toString().replaceFirst('Exception: ', '')
|
||||||
|
: 'Erreur suppression',
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ class AdminUserCard extends StatefulWidget {
|
|||||||
final Color? backgroundColor;
|
final Color? backgroundColor;
|
||||||
final Color? titleColor;
|
final Color? titleColor;
|
||||||
final Color? infoColor;
|
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 String? vigilanceTooltip;
|
||||||
final VoidCallback? onCardTap;
|
final VoidCallback? onCardTap;
|
||||||
final EdgeInsetsGeometry? margin;
|
final EdgeInsetsGeometry? margin;
|
||||||
@@ -28,6 +32,8 @@ class AdminUserCard extends StatefulWidget {
|
|||||||
this.backgroundColor,
|
this.backgroundColor,
|
||||||
this.titleColor,
|
this.titleColor,
|
||||||
this.infoColor,
|
this.infoColor,
|
||||||
|
this.avatarBackgroundColor,
|
||||||
|
this.avatarIconColor,
|
||||||
this.vigilanceTooltip,
|
this.vigilanceTooltip,
|
||||||
this.onCardTap,
|
this.onCardTap,
|
||||||
this.margin,
|
this.margin,
|
||||||
@@ -159,8 +165,8 @@ class _AdminUserCardState extends State<AdminUserCard> {
|
|||||||
|
|
||||||
Widget _buildAvatar(String url) {
|
Widget _buildAvatar(String url) {
|
||||||
const size = 28.0;
|
const size = 28.0;
|
||||||
const bg = Color(0xFFEDE5FA);
|
final bg = widget.avatarBackgroundColor ?? const Color(0xFFEDE5FA);
|
||||||
const iconColor = Color(0xFF6B3FA0);
|
final iconColor = widget.avatarIconColor ?? const Color(0xFF6B3FA0);
|
||||||
|
|
||||||
if (url.isEmpty) {
|
if (url.isEmpty) {
|
||||||
return CircleAvatar(
|
return CircleAvatar(
|
||||||
|
|||||||
@@ -0,0 +1,467 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/models/dossier_list_item.dart';
|
||||||
|
|
||||||
|
/// Choix pour le dernier enfant d’un dossier famille (#160).
|
||||||
|
enum DernierEnfantSuppressionChoice {
|
||||||
|
enfantSeul,
|
||||||
|
dossierAussi,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ligne d’impact (une personne / une fiche).
|
||||||
|
class SuppressionPersonLine {
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final String? role;
|
||||||
|
|
||||||
|
const SuppressionPersonLine({
|
||||||
|
required this.label,
|
||||||
|
this.icon = Icons.person_outline,
|
||||||
|
this.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory SuppressionPersonLine.parent(String label) => SuppressionPersonLine(
|
||||||
|
label: label,
|
||||||
|
icon: Icons.supervisor_account_outlined,
|
||||||
|
role: 'Parent',
|
||||||
|
);
|
||||||
|
|
||||||
|
factory SuppressionPersonLine.enfant(String label) => SuppressionPersonLine(
|
||||||
|
label: label,
|
||||||
|
icon: Icons.child_care_outlined,
|
||||||
|
role: 'Enfant',
|
||||||
|
);
|
||||||
|
|
||||||
|
factory SuppressionPersonLine.am(String label) => SuppressionPersonLine(
|
||||||
|
label: label,
|
||||||
|
icon: Icons.face,
|
||||||
|
role: 'AM',
|
||||||
|
);
|
||||||
|
|
||||||
|
factory SuppressionPersonLine.gestionnaire(String label) =>
|
||||||
|
SuppressionPersonLine(
|
||||||
|
label: label,
|
||||||
|
icon: Icons.assignment_ind_outlined,
|
||||||
|
role: 'Gestionnaire',
|
||||||
|
);
|
||||||
|
|
||||||
|
factory SuppressionPersonLine.administrateur(String label) =>
|
||||||
|
SuppressionPersonLine(
|
||||||
|
label: label,
|
||||||
|
icon: Icons.manage_accounts_outlined,
|
||||||
|
role: 'Admin',
|
||||||
|
);
|
||||||
|
|
||||||
|
factory SuppressionPersonLine.relais(String label) => SuppressionPersonLine(
|
||||||
|
label: label,
|
||||||
|
icon: Icons.apartment_outlined,
|
||||||
|
role: 'Relais',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget unique pour toutes les boîtes de confirmation de suppression (#160).
|
||||||
|
class SuppressionConfirmDialog extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final String? message;
|
||||||
|
final List<SuppressionPersonLine> people;
|
||||||
|
final List<String> footnotes;
|
||||||
|
final List<Widget> actions;
|
||||||
|
|
||||||
|
const SuppressionConfirmDialog({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
this.message,
|
||||||
|
this.people = const [],
|
||||||
|
this.footnotes = const [],
|
||||||
|
required this.actions,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Variante oui/non standard (Annuler / Supprimer).
|
||||||
|
static SuppressionConfirmDialog yesNo({
|
||||||
|
required String title,
|
||||||
|
String? subtitle,
|
||||||
|
String? message,
|
||||||
|
List<SuppressionPersonLine> people = const [],
|
||||||
|
List<String> footnotes = const [],
|
||||||
|
String confirmLabel = 'Supprimer',
|
||||||
|
required VoidCallback onCancel,
|
||||||
|
required VoidCallback onConfirm,
|
||||||
|
}) {
|
||||||
|
return SuppressionConfirmDialog(
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitle,
|
||||||
|
message: message,
|
||||||
|
people: people,
|
||||||
|
footnotes: footnotes,
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: onCancel, child: const Text('Annuler')),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: onConfirm,
|
||||||
|
style: _dangerButtonStyle,
|
||||||
|
child: Text(confirmLabel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ButtonStyle get _dangerButtonStyle => FilledButton.styleFrom(
|
||||||
|
backgroundColor: Colors.red.shade700,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return AlertDialog(
|
||||||
|
backgroundColor: const Color(0xFFF7F2FB),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
titlePadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
|
||||||
|
contentPadding: const EdgeInsets.fromLTRB(24, 12, 24, 8),
|
||||||
|
actionsPadding: const EdgeInsets.fromLTRB(16, 4, 16, 14),
|
||||||
|
title: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.delete_outline,
|
||||||
|
color: Colors.red.shade700,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: const Color(0xFF2F2F2F),
|
||||||
|
fontSize: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if ((subtitle ?? '').trim().isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
subtitle!.trim(),
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: const Color(0xFF6D4EA1),
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 420,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if ((message ?? '').trim().isNotEmpty) ...[
|
||||||
|
Text(
|
||||||
|
message!.trim(),
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Colors.black87,
|
||||||
|
height: 1.35,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (people.isNotEmpty || footnotes.isNotEmpty)
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
if (people.isNotEmpty) ...[
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.9),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: const Color(0xFFE5D8F2)),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < people.length; i++) ...[
|
||||||
|
if (i > 0)
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
indent: 44,
|
||||||
|
endIndent: 12,
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
),
|
||||||
|
_SuppressionPersonRow(line: people[i]),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (footnotes.isNotEmpty) const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
for (final note in footnotes)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 6),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: Icon(
|
||||||
|
Icons.info_outline,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.orange.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
note,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.black54,
|
||||||
|
height: 1.35,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: actions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SuppressionPersonRow extends StatelessWidget {
|
||||||
|
final SuppressionPersonLine line;
|
||||||
|
|
||||||
|
const _SuppressionPersonRow({required this.line});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(line.icon, size: 18, color: const Color(0xFF6D4EA1)),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
line.label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 14,
|
||||||
|
color: Color(0xFF2F2F2F),
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if ((line.role ?? '').trim().isNotEmpty) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFEDE5FA),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
line.role!.trim(),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF6D4EA1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Affiche [SuppressionConfirmDialog] et renvoie `true` si confirmé.
|
||||||
|
Future<bool> showSuppressionConfirmDialog(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
String? subtitle,
|
||||||
|
String? message,
|
||||||
|
List<SuppressionPersonLine> people = const [],
|
||||||
|
List<String> footnotes = const [],
|
||||||
|
String confirmLabel = 'Supprimer',
|
||||||
|
}) async {
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => SuppressionConfirmDialog.yesNo(
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitle,
|
||||||
|
message: message,
|
||||||
|
people: people,
|
||||||
|
footnotes: footnotes,
|
||||||
|
confirmLabel: confirmLabel,
|
||||||
|
onCancel: () => Navigator.of(ctx).pop(false),
|
||||||
|
onConfirm: () => Navigator.of(ctx).pop(true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return result == true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirmation delete dossier famille / AM avec liste nominative.
|
||||||
|
Future<bool> showDossierSuppressionConfirmDialog(
|
||||||
|
BuildContext context, {
|
||||||
|
required String numeroDossier,
|
||||||
|
required bool isFamille,
|
||||||
|
required List<SuppressionPersonLine> people,
|
||||||
|
String? fallbackSummary,
|
||||||
|
}) {
|
||||||
|
final num = numeroDossier.trim();
|
||||||
|
if (isFamille) {
|
||||||
|
return showSuppressionConfirmDialog(
|
||||||
|
context,
|
||||||
|
title: 'Supprimer le dossier',
|
||||||
|
subtitle: 'Dossier famille $num',
|
||||||
|
people: people,
|
||||||
|
footnotes: people.isEmpty && (fallbackSummary ?? '').isNotEmpty
|
||||||
|
? [fallbackSummary!]
|
||||||
|
: const [
|
||||||
|
'Tous les comptes et fiches listés seront définitivement '
|
||||||
|
'supprimés.',
|
||||||
|
'Les placements AM des enfants seront clos.',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return showSuppressionConfirmDialog(
|
||||||
|
context,
|
||||||
|
title: 'Supprimer le dossier',
|
||||||
|
subtitle: 'Dossier AM $num',
|
||||||
|
people: people,
|
||||||
|
footnotes: const [
|
||||||
|
'Le compte et le dossier AM seront supprimés.',
|
||||||
|
'Les enfants accueillis ne seront pas supprimés '
|
||||||
|
'(placements clos).',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dialog dernier enfant : deux actions métier.
|
||||||
|
Future<DernierEnfantSuppressionChoice?> showDernierEnfantSuppressionDialog(
|
||||||
|
BuildContext context, {
|
||||||
|
required String enfantName,
|
||||||
|
required String familleLabel,
|
||||||
|
required String numeroDossier,
|
||||||
|
String? amLabel,
|
||||||
|
}) {
|
||||||
|
final am = (amLabel ?? '').trim();
|
||||||
|
return showDialog<DernierEnfantSuppressionChoice>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => SuppressionConfirmDialog(
|
||||||
|
title: 'Dernier enfant du dossier',
|
||||||
|
subtitle: 'Dossier $numeroDossier'
|
||||||
|
'${familleLabel.isEmpty ? '' : ' · $familleLabel'}',
|
||||||
|
people: [SuppressionPersonLine.enfant(enfantName)],
|
||||||
|
footnotes: [
|
||||||
|
'« Enfant seulement » : le dossier reste sans enfant.',
|
||||||
|
'« Dossier aussi » : parents et dossier sont également '
|
||||||
|
'supprimés.',
|
||||||
|
if (am.isNotEmpty) 'Le placement chez $am sera clos.',
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(
|
||||||
|
DernierEnfantSuppressionChoice.enfantSeul,
|
||||||
|
),
|
||||||
|
child: const Text('Enfant seulement'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(
|
||||||
|
DernierEnfantSuppressionChoice.dossierAussi,
|
||||||
|
),
|
||||||
|
style: SuppressionConfirmDialog._dangerButtonStyle,
|
||||||
|
child: const Text('Dossier aussi'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notes d’impact pour suppression d’un enfant (AM optionnelle).
|
||||||
|
List<String> enfantSuppressionFootnotes({
|
||||||
|
required String? numeroDossier,
|
||||||
|
required String familleLabel,
|
||||||
|
String? amLabel,
|
||||||
|
}) {
|
||||||
|
final notes = <String>[];
|
||||||
|
final num = (numeroDossier ?? '').trim();
|
||||||
|
final famille = familleLabel.trim();
|
||||||
|
final am = (amLabel ?? '').trim();
|
||||||
|
if (num.isEmpty) {
|
||||||
|
notes.add('Supprimer définitivement cette fiche enfant.');
|
||||||
|
} else {
|
||||||
|
notes.add(
|
||||||
|
'L’enfant sera retiré du dossier de '
|
||||||
|
'${famille.isEmpty ? 'la famille' : famille}.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (am.isNotEmpty) {
|
||||||
|
notes.add('Le placement chez $am sera clos.');
|
||||||
|
}
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bouton poubelle compact pour les cartes liste.
|
||||||
|
Widget suppressionIconButton({
|
||||||
|
required VoidCallback? onPressed,
|
||||||
|
String tooltip = 'Supprimer',
|
||||||
|
}) {
|
||||||
|
return IconButton(
|
||||||
|
icon: Icon(Icons.delete_outline, color: Colors.red.shade700),
|
||||||
|
tooltip: tooltip,
|
||||||
|
onPressed: onPressed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construit les lignes parents / enfants depuis un dossier unifié.
|
||||||
|
List<SuppressionPersonLine> suppressionPeopleFromDossier({
|
||||||
|
required bool isFamille,
|
||||||
|
required List<({String nom, String prenom, String email})> parents,
|
||||||
|
required List<({String nom, String prenom})> enfants,
|
||||||
|
String? amName,
|
||||||
|
}) {
|
||||||
|
final lines = <SuppressionPersonLine>[];
|
||||||
|
if (isFamille) {
|
||||||
|
for (final p in parents) {
|
||||||
|
final label = formatDossierPersonLabel(
|
||||||
|
nom: p.nom,
|
||||||
|
prenom: p.prenom,
|
||||||
|
email: p.email,
|
||||||
|
);
|
||||||
|
if (label.isEmpty) continue;
|
||||||
|
lines.add(SuppressionPersonLine.parent(label));
|
||||||
|
}
|
||||||
|
for (final e in enfants) {
|
||||||
|
final label = formatDossierPersonLabel(nom: e.nom, prenom: e.prenom);
|
||||||
|
if (label.isEmpty) continue;
|
||||||
|
lines.add(SuppressionPersonLine.enfant(label));
|
||||||
|
}
|
||||||
|
} else if ((amName ?? '').trim().isNotEmpty) {
|
||||||
|
lines.add(SuppressionPersonLine.am(amName!.trim()));
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
|||||||
final ValueChanged<int> onSubTabChange;
|
final ValueChanged<int> onSubTabChange;
|
||||||
final TextEditingController searchController;
|
final TextEditingController searchController;
|
||||||
final String searchHint;
|
final String searchHint;
|
||||||
|
/// Infobulle au survol de la barre de recherche (ex. critères de recherche).
|
||||||
|
final String? searchTooltip;
|
||||||
final Widget? filterControl;
|
final Widget? filterControl;
|
||||||
final VoidCallback? onAddPressed;
|
final VoidCallback? onAddPressed;
|
||||||
final String addLabel;
|
final String addLabel;
|
||||||
@@ -22,12 +24,16 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
|||||||
'Administrateurs',
|
'Administrateurs',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Aligné sur la taille des libellés d’onglets.
|
||||||
|
static const double _searchFontSize = 13;
|
||||||
|
|
||||||
const DashboardUserManagementSubBar({
|
const DashboardUserManagementSubBar({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.selectedSubIndex,
|
required this.selectedSubIndex,
|
||||||
required this.onSubTabChange,
|
required this.onSubTabChange,
|
||||||
required this.searchController,
|
required this.searchController,
|
||||||
required this.searchHint,
|
required this.searchHint,
|
||||||
|
this.searchTooltip,
|
||||||
this.filterControl,
|
this.filterControl,
|
||||||
this.onAddPressed,
|
this.onAddPressed,
|
||||||
this.addLabel = '+ Ajouter',
|
this.addLabel = '+ Ajouter',
|
||||||
@@ -54,22 +60,7 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
|||||||
_buildSubNavItem(context, labels[i], i),
|
_buildSubNavItem(context, labels[i], i),
|
||||||
],
|
],
|
||||||
const SizedBox(width: 36),
|
const SizedBox(width: 36),
|
||||||
_pillField(
|
_buildSearchField(),
|
||||||
width: 320,
|
|
||||||
child: TextField(
|
|
||||||
controller: searchController,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: searchHint,
|
|
||||||
prefixIcon: const Icon(Icons.search, size: 18),
|
|
||||||
border: InputBorder.none,
|
|
||||||
isDense: true,
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 10,
|
|
||||||
vertical: 8,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (filterControl != null) ...[
|
if (filterControl != null) ...[
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
_pillField(width: 150, child: filterControl!),
|
_pillField(width: 150, child: filterControl!),
|
||||||
@@ -81,6 +72,40 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildSearchField() {
|
||||||
|
final field = _pillField(
|
||||||
|
width: 320,
|
||||||
|
child: TextField(
|
||||||
|
controller: searchController,
|
||||||
|
style: const TextStyle(fontSize: _searchFontSize),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: searchHint,
|
||||||
|
hintStyle: TextStyle(
|
||||||
|
fontSize: _searchFontSize,
|
||||||
|
fontStyle: FontStyle.normal,
|
||||||
|
fontWeight: FontWeight.normal,
|
||||||
|
color: Colors.black45,
|
||||||
|
),
|
||||||
|
prefixIcon: const Icon(Icons.search, size: 18),
|
||||||
|
border: InputBorder.none,
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final tip = (searchTooltip ?? '').trim();
|
||||||
|
if (tip.isEmpty) return field;
|
||||||
|
return Tooltip(
|
||||||
|
message: tip,
|
||||||
|
preferBelow: false,
|
||||||
|
waitDuration: const Duration(milliseconds: 400),
|
||||||
|
child: field,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _pillField({required double width, required Widget child}) {
|
Widget _pillField({required double width, required Widget child}) {
|
||||||
return Container(
|
return Container(
|
||||||
width: width,
|
width: width,
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||||
|
|
||||||
|
/// Carte dossier unifiée (#153) — fond neutre, accent couleur sur l’icône.
|
||||||
|
class DossierListCard extends StatelessWidget {
|
||||||
|
final String numeroDossier;
|
||||||
|
final String namesLine;
|
||||||
|
final bool isFamille;
|
||||||
|
final VoidCallback onOpen;
|
||||||
|
/// Photo AM (si absente → icône fallback).
|
||||||
|
final String? photoUrl;
|
||||||
|
final VoidCallback? onDelete;
|
||||||
|
/// Warning vigilance (ex. dossier sans enfant #160).
|
||||||
|
final String? vigilanceTooltip;
|
||||||
|
/// Nombre d’enfants (famille) — affiché à côté des noms.
|
||||||
|
final int? enfantsCount;
|
||||||
|
final bool sansEnfant;
|
||||||
|
|
||||||
|
/// Lavande — Famille / Parents.
|
||||||
|
static const Color familleAccent = Color(0xFFB289C9);
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
this.onDelete,
|
||||||
|
this.vigilanceTooltip,
|
||||||
|
this.enfantsCount,
|
||||||
|
this.sansEnfant = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@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();
|
||||||
|
final emptyKids = isFamille && (sansEnfant || enfantsCount == 0);
|
||||||
|
final count = enfantsCount;
|
||||||
|
final countLabel = (!isFamille || count == null)
|
||||||
|
? null
|
||||||
|
: (count <= 1 ? '$count enfant' : '$count enfants');
|
||||||
|
|
||||||
|
final subtitle = <String>[
|
||||||
|
if (names.isNotEmpty) names,
|
||||||
|
if (emptyKids) 'Sans enfant',
|
||||||
|
if (!emptyKids && countLabel != null) countLabel,
|
||||||
|
];
|
||||||
|
|
||||||
|
return AdminUserCard(
|
||||||
|
title: num,
|
||||||
|
subtitleLines: subtitle,
|
||||||
|
avatarUrl: !isFamille && avatar.isNotEmpty ? avatar : null,
|
||||||
|
fallbackIcon:
|
||||||
|
isFamille ? Icons.family_restroom_outlined : Icons.face,
|
||||||
|
// N° = titre neutre (comme Parents / AM) ; accent = icône seule.
|
||||||
|
avatarIconColor: accent,
|
||||||
|
infoColor: emptyKids ? Colors.red.shade700 : Colors.black87,
|
||||||
|
onCardTap: onOpen,
|
||||||
|
vigilanceTooltip: emptyKids
|
||||||
|
? (vigilanceTooltip ??
|
||||||
|
'Aucun enfant rattaché à ce dossier famille')
|
||||||
|
: vigilanceTooltip,
|
||||||
|
borderColor: emptyKids ? Colors.red.shade300 : null,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Ouvrir',
|
||||||
|
icon: Icon(Icons.open_in_new, size: 20, color: accent),
|
||||||
|
onPressed: onOpen,
|
||||||
|
),
|
||||||
|
if (onDelete != null)
|
||||||
|
suppressionIconButton(onPressed: onDelete),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/models/dossier_list_item.dart';
|
||||||
|
import 'package:p_tits_pas/services/auth_service.dart';
|
||||||
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/dossier_list_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart';
|
||||||
|
|
||||||
|
/// Onglet permanent « Dossiers » (#153) : pending en haut + liste unifiée en bas.
|
||||||
|
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;
|
||||||
|
bool _canDelete = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadRights();
|
||||||
|
_loadAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadRights() async {
|
||||||
|
final user = await AuthService.getCurrentUser();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _canDelete = canDeleteMetier(user?.role));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadAll() async {
|
||||||
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final parents = await UserService.getParents();
|
||||||
|
final ams = await UserService.getAssistantesMaternelles();
|
||||||
|
Map<String, bool> sansEnfant = {};
|
||||||
|
try {
|
||||||
|
sansEnfant = await UserService.getSansEnfantByNumero();
|
||||||
|
} catch (_) {
|
||||||
|
// Flag optionnel : ne bloque pas la liste.
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
final items = <DossierListItem>[
|
||||||
|
...DossierListItem.fromParents(parents),
|
||||||
|
...DossierListItem.fromAssistantes(ams),
|
||||||
|
].map((item) {
|
||||||
|
if (!item.isFamille) return item;
|
||||||
|
final apiFlag = sansEnfant[item.numeroDossier] == true;
|
||||||
|
final localEmpty = (item.enfantsCount ?? 0) == 0;
|
||||||
|
return item.copyWith(
|
||||||
|
sansEnfant: apiFlag || localEmpty || item.sansEnfant,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
items.sort((a, b) {
|
||||||
|
// Dossiers sans enfant en tête (#160), comme orphelins #157.
|
||||||
|
final ae = a.sansEnfant ? 0 : 1;
|
||||||
|
final be = b.sansEnfant ? 0 : 1;
|
||||||
|
if (ae != be) return ae.compareTo(be);
|
||||||
|
final byNum = a.numeroDossier.compareTo(b.numeroDossier);
|
||||||
|
if (byNum != 0) return byNum;
|
||||||
|
return a.typeLabel.compareTo(b.typeLabel);
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
openAsEdit: true,
|
||||||
|
onClose: () => Navigator.of(context).pop(),
|
||||||
|
onSuccess: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_refreshEverything();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDeleteDossier(DossierListItem item) async {
|
||||||
|
final num = item.numeroDossier.trim();
|
||||||
|
if (num.isEmpty) return;
|
||||||
|
|
||||||
|
var people = <SuppressionPersonLine>[];
|
||||||
|
String? fallbackSummary;
|
||||||
|
try {
|
||||||
|
final dossier = await UserService.getDossier(num);
|
||||||
|
if (dossier.isFamily) {
|
||||||
|
final f = dossier.asFamily;
|
||||||
|
people = suppressionPeopleFromDossier(
|
||||||
|
isFamille: true,
|
||||||
|
parents: f.parents
|
||||||
|
.map((p) => (
|
||||||
|
nom: p.nom ?? '',
|
||||||
|
prenom: p.prenom ?? '',
|
||||||
|
email: p.email,
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
enfants: f.enfants
|
||||||
|
.map((e) => (
|
||||||
|
nom: e.lastName ?? '',
|
||||||
|
prenom: e.firstName ?? '',
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final am = dossier.asAm.user;
|
||||||
|
people = suppressionPeopleFromDossier(
|
||||||
|
isFamille: false,
|
||||||
|
parents: const [],
|
||||||
|
enfants: const [],
|
||||||
|
amName: formatDossierPersonLabel(
|
||||||
|
nom: am.nom,
|
||||||
|
prenom: am.prenom,
|
||||||
|
email: am.email,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
fallbackSummary = item.isFamille
|
||||||
|
? 'Tous les parents et enfants rattachés seront supprimés.'
|
||||||
|
: 'Le compte AM sera supprimé ; les enfants accueillis '
|
||||||
|
'seront conservés.';
|
||||||
|
if (item.namesLine.trim().isNotEmpty) {
|
||||||
|
for (final part in item.namesLine.split(' - ')) {
|
||||||
|
final label = part.trim();
|
||||||
|
if (label.isEmpty) continue;
|
||||||
|
people.add(SuppressionPersonLine(
|
||||||
|
label: label,
|
||||||
|
icon: item.isFamille
|
||||||
|
? Icons.supervisor_account_outlined
|
||||||
|
: Icons.face,
|
||||||
|
role: item.isFamille ? 'Parent' : 'AM',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
final confirmed = await showDossierSuppressionConfirmDialog(
|
||||||
|
context,
|
||||||
|
numeroDossier: num,
|
||||||
|
isFamille: item.isFamille,
|
||||||
|
people: people,
|
||||||
|
fallbackSummary: fallbackSummary,
|
||||||
|
);
|
||||||
|
if (!confirmed || !mounted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await UserService.deleteDossier(num);
|
||||||
|
if (!mounted) return;
|
||||||
|
final msg = (result['message'] ?? 'Dossier supprimé.').toString();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||||
|
await _refreshEverything();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
e is Exception
|
||||||
|
? e.toString().replaceFirst('Exception: ', '')
|
||||||
|
: 'Erreur suppression',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final query = widget.searchQuery;
|
||||||
|
// 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,
|
||||||
|
canDelete: _canDelete,
|
||||||
|
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,
|
||||||
|
sansEnfant: item.sansEnfant,
|
||||||
|
enfantsCount: item.enfantsCount,
|
||||||
|
vigilanceTooltip: item.sansEnfant
|
||||||
|
? 'Aucun enfant rattaché à ce dossier famille'
|
||||||
|
: null,
|
||||||
|
onOpen: () => _openDossier(item.numeroDossier),
|
||||||
|
onDelete: _canDelete
|
||||||
|
? () => _confirmDeleteDossier(item)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: filtered.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/models/dossier_list_item.dart';
|
||||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||||
|
import 'package:p_tits_pas/services/auth_service.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||||
|
|
||||||
/// Onglet liste globale des enfants (doc 28 §6.2, ticket #137).
|
/// Onglet liste globale des enfants (doc 28 §6.2, ticket #137).
|
||||||
@@ -25,13 +29,21 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
List<EnfantAdminModel> _enfants = [];
|
List<EnfantAdminModel> _enfants = [];
|
||||||
|
bool _canDelete = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_loadRights();
|
||||||
_loadEnfants();
|
_loadEnfants();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadRights() async {
|
||||||
|
final user = await AuthService.getCurrentUser();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _canDelete = canDeleteMetier(user?.role));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadEnfants() async {
|
Future<void> _loadEnfants() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
@@ -59,10 +71,133 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
|
|||||||
builder: (ctx) => AdminChildDetailModal(
|
builder: (ctx) => AdminChildDetailModal(
|
||||||
enfant: enfant,
|
enfant: enfant,
|
||||||
onSaved: _loadEnfants,
|
onSaved: _loadEnfants,
|
||||||
|
onDeleted: _loadEnfants,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<({String? numero, String famille, bool isLast, String? amLabel})>
|
||||||
|
_resolveContext(
|
||||||
|
EnfantAdminModel enfant,
|
||||||
|
) async {
|
||||||
|
String? amLabel;
|
||||||
|
try {
|
||||||
|
final am = await UserService.findAmForEnfant(enfant.id);
|
||||||
|
if (am != null) {
|
||||||
|
final label = formatDossierPersonLabel(
|
||||||
|
nom: am.user.nom,
|
||||||
|
prenom: am.user.prenom,
|
||||||
|
email: am.user.email,
|
||||||
|
);
|
||||||
|
if (label.isNotEmpty) amLabel = label;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
final parentId = enfant.parentLinks
|
||||||
|
.map((l) => l.parentId.trim())
|
||||||
|
.firstWhere((id) => id.isNotEmpty, orElse: () => '');
|
||||||
|
if (parentId.isEmpty) {
|
||||||
|
return (numero: null, famille: '', isLast: true, amLabel: amLabel);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final parent = await UserService.getParent(parentId);
|
||||||
|
final num = (parent.user.numeroDossier ?? '').trim();
|
||||||
|
final famille = parent.user.fullName.isNotEmpty
|
||||||
|
? parent.user.fullName
|
||||||
|
: parent.user.email;
|
||||||
|
if (num.isEmpty) {
|
||||||
|
return (
|
||||||
|
numero: null,
|
||||||
|
famille: famille,
|
||||||
|
isLast: true,
|
||||||
|
amLabel: amLabel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final dossier = await UserService.getDossier(num);
|
||||||
|
if (!dossier.isFamily) {
|
||||||
|
return (
|
||||||
|
numero: num,
|
||||||
|
famille: famille,
|
||||||
|
isLast: true,
|
||||||
|
amLabel: amLabel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final n = dossier.asFamily.enfants.length;
|
||||||
|
final names = dossier.asFamily.parents
|
||||||
|
.map((p) => formatDossierPersonLabel(
|
||||||
|
nom: p.nom,
|
||||||
|
prenom: p.prenom,
|
||||||
|
email: p.email,
|
||||||
|
))
|
||||||
|
.where((s) => s.isNotEmpty)
|
||||||
|
.join(' - ');
|
||||||
|
return (
|
||||||
|
numero: num,
|
||||||
|
famille: names.isNotEmpty ? names : famille,
|
||||||
|
isLast: n <= 1,
|
||||||
|
amLabel: amLabel,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return (numero: null, famille: '', isLast: false, amLabel: amLabel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDelete(EnfantAdminModel enfant) async {
|
||||||
|
final ctx = await _resolveContext(enfant);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
bool deleteDossier = false;
|
||||||
|
if (ctx.isLast && (ctx.numero ?? '').isNotEmpty) {
|
||||||
|
final choice = await showDernierEnfantSuppressionDialog(
|
||||||
|
context,
|
||||||
|
enfantName: enfant.fullName,
|
||||||
|
familleLabel: ctx.famille,
|
||||||
|
numeroDossier: ctx.numero!,
|
||||||
|
amLabel: ctx.amLabel,
|
||||||
|
);
|
||||||
|
if (choice == null || !mounted) return;
|
||||||
|
deleteDossier =
|
||||||
|
choice == DernierEnfantSuppressionChoice.dossierAussi;
|
||||||
|
} else {
|
||||||
|
final confirmed = await showSuppressionConfirmDialog(
|
||||||
|
context,
|
||||||
|
title: 'Supprimer l\'enfant',
|
||||||
|
subtitle: (ctx.numero ?? '').isEmpty
|
||||||
|
? null
|
||||||
|
: 'Dossier ${ctx.numero}',
|
||||||
|
people: [SuppressionPersonLine.enfant(enfant.fullName)],
|
||||||
|
footnotes: enfantSuppressionFootnotes(
|
||||||
|
numeroDossier: ctx.numero,
|
||||||
|
familleLabel: ctx.famille,
|
||||||
|
amLabel: ctx.amLabel,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!confirmed || !mounted) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await UserService.deleteEnfant(
|
||||||
|
enfant.id,
|
||||||
|
deleteDossier: deleteDossier,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
final msg = (result['message'] ?? 'Enfant supprimé.').toString();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||||
|
await _loadEnfants();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
e is Exception
|
||||||
|
? e.toString().replaceFirst('Exception: ', '')
|
||||||
|
: 'Erreur suppression',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final query = widget.searchQuery.toLowerCase();
|
final query = widget.searchQuery.toLowerCase();
|
||||||
@@ -98,6 +233,10 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
|
|||||||
tooltip: 'Voir / modifier',
|
tooltip: 'Voir / modifier',
|
||||||
onPressed: () => _openEnfant(enfant),
|
onPressed: () => _openEnfant(enfant),
|
||||||
),
|
),
|
||||||
|
if (_canDelete)
|
||||||
|
suppressionIconButton(
|
||||||
|
onPressed: () => _confirmDelete(enfant),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
|
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
|
||||||
|
import 'package:p_tits_pas/services/auth_service.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||||
|
|
||||||
class GestionnaireManagementWidget extends StatefulWidget {
|
class GestionnaireManagementWidget extends StatefulWidget {
|
||||||
@@ -23,16 +26,28 @@ class _GestionnaireManagementWidgetState
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
List<AppUser> _gestionnaires = [];
|
List<AppUser> _gestionnaires = [];
|
||||||
|
bool _canDelete = false;
|
||||||
|
String? _currentUserId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_loadRights();
|
||||||
_loadGestionnaires();
|
_loadGestionnaires();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() => super.dispose();
|
void dispose() => super.dispose();
|
||||||
|
|
||||||
|
Future<void> _loadRights() async {
|
||||||
|
final user = await AuthService.getCurrentUser();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_canDelete = canDeleteGestionnaire(user?.role);
|
||||||
|
_currentUserId = user?.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadGestionnaires() async {
|
Future<void> _loadGestionnaires() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
@@ -67,6 +82,46 @@ class _GestionnaireManagementWidgetState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDelete(AppUser user) async {
|
||||||
|
if (_currentUserId != null && _currentUserId == user.id) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Vous ne pouvez pas supprimer votre propre compte.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final name = user.fullName.isNotEmpty ? user.fullName : user.email;
|
||||||
|
final confirmed = await showSuppressionConfirmDialog(
|
||||||
|
context,
|
||||||
|
title: 'Supprimer le gestionnaire',
|
||||||
|
people: [SuppressionPersonLine.gestionnaire(name)],
|
||||||
|
footnotes: const [
|
||||||
|
'Le compte sera définitivement supprimé.',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (!confirmed || !mounted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await UserService.deleteUser(user.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
final msg = (result['message'] ?? 'Gestionnaire supprimé.').toString();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||||
|
await _loadGestionnaires();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
e is Exception
|
||||||
|
? e.toString().replaceFirst('Exception: ', '')
|
||||||
|
: 'Erreur suppression',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final query = widget.searchQuery.toLowerCase();
|
final query = widget.searchQuery.toLowerCase();
|
||||||
@@ -84,6 +139,8 @@ class _GestionnaireManagementWidgetState
|
|||||||
itemCount: filteredGestionnaires.length,
|
itemCount: filteredGestionnaires.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final user = filteredGestionnaires[index];
|
final user = filteredGestionnaires[index];
|
||||||
|
final isSelf =
|
||||||
|
_currentUserId != null && _currentUserId == user.id;
|
||||||
return AdminUserCard(
|
return AdminUserCard(
|
||||||
title: user.fullName,
|
title: user.fullName,
|
||||||
fallbackIcon: Icons.assignment_ind_outlined,
|
fallbackIcon: Icons.assignment_ind_outlined,
|
||||||
@@ -102,6 +159,8 @@ class _GestionnaireManagementWidgetState
|
|||||||
_openGestionnaireEditDialog(user);
|
_openGestionnaireEditDialog(user);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (_canDelete && !isSelf)
|
||||||
|
suppressionIconButton(onPressed: () => _confirmDelete(user)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,10 +22,15 @@ import 'package:p_tits_pas/widgets/admin/validation_valider_confirm_dialog.dart'
|
|||||||
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
||||||
import 'package:p_tits_pas/widgets/common/identity_block.dart';
|
import 'package:p_tits_pas/widgets/common/identity_block.dart';
|
||||||
|
|
||||||
enum ParentDossierWizardMode { review, create }
|
enum ParentDossierWizardMode { review, create, edit }
|
||||||
|
|
||||||
/// Enfant en cours de saisie (mode création). Contrôleurs propres, à disposer.
|
/// Enfant en cours de saisie (création / édition). Contrôleurs propres, à disposer.
|
||||||
class _CreateChild {
|
class _CreateChild {
|
||||||
|
/// Id existant en base (mode edit) — null = nouvel enfant → POST.
|
||||||
|
String? existingChildId;
|
||||||
|
String? existingPhotoUrl;
|
||||||
|
/// Statut d’origine (hors à naître) pour ne pas écraser garde/scolarise.
|
||||||
|
String? existingStatus;
|
||||||
final TextEditingController prenomCtrl = TextEditingController();
|
final TextEditingController prenomCtrl = TextEditingController();
|
||||||
final TextEditingController nomCtrl = TextEditingController();
|
final TextEditingController nomCtrl = TextEditingController();
|
||||||
final TextEditingController dateCtrl = TextEditingController();
|
final TextEditingController dateCtrl = TextEditingController();
|
||||||
@@ -34,6 +39,8 @@ class _CreateChild {
|
|||||||
Uint8List? photoBytes;
|
Uint8List? photoBytes;
|
||||||
String? photoFilename;
|
String? photoFilename;
|
||||||
|
|
||||||
|
bool get hasExistingId => (existingChildId ?? '').trim().isNotEmpty;
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
prenomCtrl.dispose();
|
prenomCtrl.dispose();
|
||||||
nomCtrl.dispose();
|
nomCtrl.dispose();
|
||||||
@@ -41,8 +48,7 @@ class _CreateChild {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wizard dossier famille — modes [review] (validation dossier en attente, ticket #107)
|
/// Wizard dossier famille — [review] (#107), [create] (#129), [edit] (#135).
|
||||||
/// et [create] (création dossier famille actif par le staff, ticket #129).
|
|
||||||
class ParentDossierWizard extends StatefulWidget {
|
class ParentDossierWizard extends StatefulWidget {
|
||||||
final ParentDossierWizardMode mode;
|
final ParentDossierWizardMode mode;
|
||||||
final DossierFamille? dossier;
|
final DossierFamille? dossier;
|
||||||
@@ -91,7 +97,25 @@ class ParentDossierWizard extends StatefulWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
factory ParentDossierWizard.edit({
|
||||||
|
Key? key,
|
||||||
|
required DossierFamille dossier,
|
||||||
|
required VoidCallback onClose,
|
||||||
|
required VoidCallback onSuccess,
|
||||||
|
void Function(int step, int total)? onStepChanged,
|
||||||
|
}) {
|
||||||
|
return ParentDossierWizard._(
|
||||||
|
key: key,
|
||||||
|
mode: ParentDossierWizardMode.edit,
|
||||||
|
dossier: dossier,
|
||||||
|
onClose: onClose,
|
||||||
|
onSuccess: onSuccess,
|
||||||
|
onStepChanged: onStepChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
bool get isCreate => mode == ParentDossierWizardMode.create;
|
bool get isCreate => mode == ParentDossierWizardMode.create;
|
||||||
|
bool get isEdit => mode == ParentDossierWizardMode.edit;
|
||||||
|
|
||||||
/// Hauteur corps modale famille — 4 lignes TF + marge pour le bandeau switch.
|
/// Hauteur corps modale famille — 4 lignes TF + marge pour le bandeau switch.
|
||||||
static double get shellBodyHeight =>
|
static double get shellBodyHeight =>
|
||||||
@@ -141,16 +165,27 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
final List<_CreateChild> _children = [];
|
final List<_CreateChild> _children = [];
|
||||||
late final TextEditingController _presentationCtrl;
|
late final TextEditingController _presentationCtrl;
|
||||||
|
|
||||||
|
/// Nb de parents déjà en base au moment de l’ouverture (mode edit).
|
||||||
|
int _initialParentCount = 0;
|
||||||
|
|
||||||
|
/// Enfants existants retirés en edit → DELETE au save.
|
||||||
|
final List<String> _removedEnfantIds = [];
|
||||||
|
|
||||||
bool get _isCreate => widget.isCreate;
|
bool get _isCreate => widget.isCreate;
|
||||||
|
bool get _isEdit => widget.isEdit;
|
||||||
|
bool get _isEditable => _isCreate || _isEdit;
|
||||||
DossierFamille get _dossier => widget.dossier!;
|
DossierFamille get _dossier => widget.dossier!;
|
||||||
|
|
||||||
bool get _isEnAttente => !_isCreate && _dossier.isEnAttente;
|
bool get _isEnAttente => !_isCreate && !_isEdit && _dossier.isEnAttente;
|
||||||
|
|
||||||
String? get _firstParentId {
|
String? get _firstParentId {
|
||||||
if (_isCreate) return null;
|
if (_isCreate) return null;
|
||||||
return _dossier.parents.isNotEmpty ? _dossier.parents.first.id : null;
|
return _dossier.parents.isNotEmpty ? _dossier.parents.first.id : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Co-parent déjà présent au chargement (edit) — pas un ajout via POST.
|
||||||
|
bool get _hadExistingCoParent => _isEdit && _initialParentCount >= 2;
|
||||||
|
|
||||||
static String _v(String? s) =>
|
static String _v(String? s) =>
|
||||||
(s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini';
|
(s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini';
|
||||||
|
|
||||||
@@ -184,11 +219,81 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
_presentationCtrl = TextEditingController();
|
_presentationCtrl = TextEditingController();
|
||||||
if (_isCreate) {
|
if (_isCreate) {
|
||||||
_children.add(_CreateChild());
|
_children.add(_CreateChild());
|
||||||
|
} else if (_isEdit) {
|
||||||
|
_prefillFromDossier();
|
||||||
}
|
}
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
|
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _prefillFromDossier() {
|
||||||
|
final parents = _dossier.parents;
|
||||||
|
_initialParentCount = parents.length;
|
||||||
|
|
||||||
|
if (parents.isNotEmpty) {
|
||||||
|
final p1 = parents.first;
|
||||||
|
_p1NomCtrl.text = (p1.nom ?? '').trim();
|
||||||
|
_p1PrenomCtrl.text = (p1.prenom ?? '').trim();
|
||||||
|
_p1TelCtrl.text = (p1.telephone ?? '').trim();
|
||||||
|
_p1EmailCtrl.text = (p1.email).trim();
|
||||||
|
_p1AdresseCtrl.text = (p1.adresse ?? '').trim();
|
||||||
|
_p1CpCtrl.text = (p1.codePostal ?? '').trim();
|
||||||
|
_p1VilleCtrl.text = (p1.ville ?? '').trim();
|
||||||
|
|
||||||
|
if (parents.length >= 2) {
|
||||||
|
_hasCoParent = true;
|
||||||
|
final p2 = parents[1];
|
||||||
|
_p2NomCtrl.text = (p2.nom ?? '').trim();
|
||||||
|
_p2PrenomCtrl.text = (p2.prenom ?? '').trim();
|
||||||
|
_p2TelCtrl.text = (p2.telephone ?? '').trim();
|
||||||
|
_p2EmailCtrl.text = (p2.email).trim();
|
||||||
|
_p2AdresseCtrl.text = (p2.adresse ?? '').trim();
|
||||||
|
_p2CpCtrl.text = (p2.codePostal ?? '').trim();
|
||||||
|
_p2VilleCtrl.text = (p2.ville ?? '').trim();
|
||||||
|
} else {
|
||||||
|
_hasCoParent = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_prefillChildrenFromDossier();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _prefillChildrenFromDossier() {
|
||||||
|
for (final c in _children) {
|
||||||
|
c.dispose();
|
||||||
|
}
|
||||||
|
_children.clear();
|
||||||
|
_removedEnfantIds.clear();
|
||||||
|
|
||||||
|
final enfants = _dossier.enfants;
|
||||||
|
if (enfants.isEmpty) {
|
||||||
|
_children.add(_CreateChild());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final e in enfants) {
|
||||||
|
final status = (e.status ?? '').trim().toLowerCase();
|
||||||
|
final id = e.id.trim();
|
||||||
|
final child = _CreateChild()
|
||||||
|
..existingChildId = id.isEmpty ? null : id
|
||||||
|
..existingPhotoUrl = e.photoUrl
|
||||||
|
..existingStatus = status.isEmpty ? null : status
|
||||||
|
..isUnborn = status == 'a_naitre';
|
||||||
|
child.prenomCtrl.text = (e.firstName ?? '').trim();
|
||||||
|
child.nomCtrl.text = (e.lastName ?? '').trim();
|
||||||
|
final g = (e.gender ?? '').trim();
|
||||||
|
final gUp = g.toUpperCase();
|
||||||
|
if (gUp == 'H' || gUp == 'F') {
|
||||||
|
child.genre = gUp;
|
||||||
|
} else if (g == 'Autre' || child.isUnborn) {
|
||||||
|
child.genre = child.isUnborn ? 'Autre' : g;
|
||||||
|
}
|
||||||
|
final dateSrc = child.isUnborn ? e.dueDate : e.birthDate;
|
||||||
|
child.dateCtrl.text = formatIsoDateFrInput(dateSrc);
|
||||||
|
_children.add(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_enfantsScrollController.removeListener(_syncEnfantsScrollFades);
|
_enfantsScrollController.removeListener(_syncEnfantsScrollFades);
|
||||||
@@ -291,7 +396,7 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStep0() {
|
Widget _buildStep0() {
|
||||||
if (_isCreate) {
|
if (_isEditable) {
|
||||||
return IdentityBlock.editable(
|
return IdentityBlock.editable(
|
||||||
title: 'Parent principal',
|
title: 'Parent principal',
|
||||||
nomController: _p1NomCtrl,
|
nomController: _p1NomCtrl,
|
||||||
@@ -310,13 +415,15 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStep1() {
|
Widget _buildStep1() {
|
||||||
if (_isCreate) {
|
if (_isEditable) {
|
||||||
return _buildCoParentStepCreate();
|
return _buildCoParentStepEditable(
|
||||||
|
allowToggle: !_hadExistingCoParent,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return _buildParent2Step();
|
return _buildParent2Step();
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCoParentStepCreate() {
|
Widget _buildCoParentStepEditable({required bool allowToggle}) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
@@ -330,19 +437,21 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
color: Colors.black87,
|
color: Colors.black87,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
if (allowToggle) ...[
|
||||||
const Text(
|
const Spacer(),
|
||||||
'Ajouter un co-parent',
|
const Text(
|
||||||
style: TextStyle(fontSize: 14, color: Colors.black87),
|
'Ajouter un co-parent',
|
||||||
),
|
style: TextStyle(fontSize: 14, color: Colors.black87),
|
||||||
Transform.scale(
|
|
||||||
scale: 0.75,
|
|
||||||
child: Switch(
|
|
||||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
||||||
value: _hasCoParent,
|
|
||||||
onChanged: (v) => setState(() => _hasCoParent = v),
|
|
||||||
),
|
),
|
||||||
),
|
Transform.scale(
|
||||||
|
scale: 0.75,
|
||||||
|
child: Switch(
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
value: _hasCoParent,
|
||||||
|
onChanged: (v) => setState(() => _hasCoParent = v),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -539,7 +648,7 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEnfantsStep() {
|
Widget _buildEnfantsStep() {
|
||||||
if (_isCreate) {
|
if (_isCreate || _isEdit) {
|
||||||
return _buildEnfantsStepCreate();
|
return _buildEnfantsStepCreate();
|
||||||
}
|
}
|
||||||
final enfants = _dossier.enfants;
|
final enfants = _dossier.enfants;
|
||||||
@@ -618,6 +727,10 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
if (_children.length <= 1) return;
|
if (_children.length <= 1) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
final removed = _children.removeAt(index);
|
final removed = _children.removeAt(index);
|
||||||
|
final existingId = (removed.existingChildId ?? '').trim();
|
||||||
|
if (existingId.isNotEmpty) {
|
||||||
|
_removedEnfantIds.add(existingId);
|
||||||
|
}
|
||||||
removed.dispose();
|
removed.dispose();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -706,7 +819,8 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 12, 14, 8),
|
// Réserve l’angle haut-droit pour la croix de suppression.
|
||||||
|
padding: EdgeInsets.fromLTRB(12, 12, canRemove ? 36 : 14, 8),
|
||||||
child: ValidationLabeledField(
|
child: ValidationLabeledField(
|
||||||
label: 'Prénom',
|
label: 'Prénom',
|
||||||
field: ValidationEditableField(
|
field: ValidationEditableField(
|
||||||
@@ -747,6 +861,9 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
width: pw,
|
width: pw,
|
||||||
height: ph,
|
height: ph,
|
||||||
child: AdminAmPhotoFrame(
|
child: AdminAmPhotoFrame(
|
||||||
|
photoUrl: child.photoBytes == null
|
||||||
|
? child.existingPhotoUrl
|
||||||
|
: null,
|
||||||
imageBytes: child.photoBytes,
|
imageBytes: child.photoBytes,
|
||||||
onTap: () => _pickChildPhoto(index),
|
onTap: () => _pickChildPhoto(index),
|
||||||
onClear: child.photoBytes != null
|
onClear: child.photoBytes != null
|
||||||
@@ -833,19 +950,28 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
),
|
),
|
||||||
if (canRemove)
|
if (canRemove)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 4,
|
top: 6,
|
||||||
right: 4,
|
right: 6,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.white,
|
||||||
child: IconButton(
|
elevation: 1,
|
||||||
tooltip: 'Retirer cet enfant',
|
shape: const CircleBorder(),
|
||||||
visualDensity: VisualDensity.compact,
|
clipBehavior: Clip.antiAlias,
|
||||||
padding: EdgeInsets.zero,
|
child: InkWell(
|
||||||
constraints:
|
customBorder: const CircleBorder(),
|
||||||
const BoxConstraints(minWidth: 32, minHeight: 32),
|
onTap: () => _removeChild(index),
|
||||||
icon: Icon(Icons.close,
|
child: Tooltip(
|
||||||
size: 18, color: Colors.grey.shade700),
|
message: 'Retirer cet enfant',
|
||||||
onPressed: () => _removeChild(index),
|
child: SizedBox(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
child: Icon(
|
||||||
|
Icons.close,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.grey.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1264,6 +1390,18 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String? _validateCurrentStep() {
|
String? _validateCurrentStep() {
|
||||||
|
if (_isEdit) {
|
||||||
|
switch (_step) {
|
||||||
|
case 0:
|
||||||
|
return _validateP1();
|
||||||
|
case 1:
|
||||||
|
return _validateP2();
|
||||||
|
case 2:
|
||||||
|
return _validateEnfants();
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!_isCreate) return null;
|
if (!_isCreate) return null;
|
||||||
switch (_step) {
|
switch (_step) {
|
||||||
case 0:
|
case 0:
|
||||||
@@ -1440,6 +1578,299 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _parentFicheBody({
|
||||||
|
required TextEditingController nom,
|
||||||
|
required TextEditingController prenom,
|
||||||
|
required TextEditingController email,
|
||||||
|
required TextEditingController tel,
|
||||||
|
required TextEditingController adresse,
|
||||||
|
required TextEditingController cp,
|
||||||
|
required TextEditingController ville,
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
'nom': formatPersonNameCase(nom.text),
|
||||||
|
'prenom': formatPersonNameCase(prenom.text),
|
||||||
|
'email': normalizeEmailText(email.text),
|
||||||
|
'telephone': normalizePhone(tel.text),
|
||||||
|
'adresse': adresse.text.trim(),
|
||||||
|
'ville': formatPersonNameCase(ville.text),
|
||||||
|
'code_postal': cp.text.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveEdit() async {
|
||||||
|
if (_submitting || !_isEdit) return;
|
||||||
|
final err0 = _validateP1();
|
||||||
|
if (err0 != null) {
|
||||||
|
_showError(err0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final err1 = _validateP2();
|
||||||
|
if (err1 != null) {
|
||||||
|
_showError(err1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final err2 = _validateEnfants();
|
||||||
|
if (err2 != null) {
|
||||||
|
_showError(err2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final pivotId = (_firstParentId ?? '').trim();
|
||||||
|
if (pivotId.isEmpty) {
|
||||||
|
_showError('Identifiant du parent principal manquant.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final addingCoParent = _hasCoParent && !_hadExistingCoParent;
|
||||||
|
final ok = await showValidationValiderConfirmDialog(
|
||||||
|
context,
|
||||||
|
body: addingCoParent
|
||||||
|
? 'Enregistrer le dossier et ajouter le co-parent ? Un e-mail de création de mot de passe lui sera envoyé.'
|
||||||
|
: 'Enregistrer les modifications du dossier famille ?',
|
||||||
|
);
|
||||||
|
if (!mounted || !ok) return;
|
||||||
|
|
||||||
|
setState(() => _submitting = true);
|
||||||
|
try {
|
||||||
|
await UserService.updateParentFiche(
|
||||||
|
parentUserId: pivotId,
|
||||||
|
body: _parentFicheBody(
|
||||||
|
nom: _p1NomCtrl,
|
||||||
|
prenom: _p1PrenomCtrl,
|
||||||
|
email: _p1EmailCtrl,
|
||||||
|
tel: _p1TelCtrl,
|
||||||
|
adresse: _p1AdresseCtrl,
|
||||||
|
cp: _p1CpCtrl,
|
||||||
|
ville: _p1VilleCtrl,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (_hadExistingCoParent) {
|
||||||
|
final coId = _dossier.parents[1].id.trim();
|
||||||
|
if (coId.isEmpty) {
|
||||||
|
throw Exception('Identifiant du co-parent manquant.');
|
||||||
|
}
|
||||||
|
await UserService.updateParentFiche(
|
||||||
|
parentUserId: coId,
|
||||||
|
body: _parentFicheBody(
|
||||||
|
nom: _p2NomCtrl,
|
||||||
|
prenom: _p2PrenomCtrl,
|
||||||
|
email: _p2EmailCtrl,
|
||||||
|
tel: _p2TelCtrl,
|
||||||
|
adresse: _p2AdresseCtrl,
|
||||||
|
cp: _p2CpCtrl,
|
||||||
|
ville: _p2VilleCtrl,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (addingCoParent) {
|
||||||
|
if (_sameAddress) _copyP1AddressToP2();
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
'email': normalizeEmailText(_p2EmailCtrl.text),
|
||||||
|
'prenom': formatPersonNameCase(_p2PrenomCtrl.text),
|
||||||
|
'nom': formatPersonNameCase(_p2NomCtrl.text),
|
||||||
|
'telephone': normalizePhone(_p2TelCtrl.text),
|
||||||
|
'meme_adresse': _sameAddress,
|
||||||
|
};
|
||||||
|
if (!_sameAddress) {
|
||||||
|
body['adresse'] = _p2AdresseCtrl.text.trim();
|
||||||
|
body['code_postal'] = _p2CpCtrl.text.trim();
|
||||||
|
body['ville'] = formatPersonNameCase(_p2VilleCtrl.text);
|
||||||
|
}
|
||||||
|
await UserService.addCoParent(pivotId, body: body);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _saveEnfantsEdit(pivotId);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
addingCoParent
|
||||||
|
? 'Dossier enregistré. Co-parent ajouté.'
|
||||||
|
: 'Dossier enregistré.',
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 4),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
widget.onSuccess();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showError(
|
||||||
|
e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _submitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _enfantStaffBody(_CreateChild c) {
|
||||||
|
final prenom = formatPersonNameCase(c.prenomCtrl.text);
|
||||||
|
final nomRaw = c.nomCtrl.text.trim();
|
||||||
|
final nom = nomRaw.isNotEmpty
|
||||||
|
? formatPersonNameCase(nomRaw)
|
||||||
|
: formatPersonNameCase(_p1NomCtrl.text);
|
||||||
|
final dateIso = parseFrDateToIso(c.dateCtrl.text);
|
||||||
|
final existingStatus = (c.existingStatus ?? '').trim().toLowerCase();
|
||||||
|
final status = c.isUnborn
|
||||||
|
? 'a_naitre'
|
||||||
|
: (existingStatus.isNotEmpty && existingStatus != 'a_naitre'
|
||||||
|
? existingStatus
|
||||||
|
: 'sans_garde');
|
||||||
|
final gender = c.genre ?? 'H';
|
||||||
|
|
||||||
|
final map = <String, dynamic>{
|
||||||
|
'status': status,
|
||||||
|
'gender': gender,
|
||||||
|
'consent_photo': true,
|
||||||
|
'is_multiple': false,
|
||||||
|
};
|
||||||
|
if (prenom.length >= 2) map['first_name'] = prenom;
|
||||||
|
if (nom.length >= 2) map['last_name'] = nom;
|
||||||
|
if (c.isUnborn) {
|
||||||
|
if (dateIso != null) map['due_date'] = dateIso;
|
||||||
|
} else if (dateIso != null) {
|
||||||
|
map['birth_date'] = dateIso;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empreinte identité pour rattacher un brouillon sans id à un enfant du GET.
|
||||||
|
String _enfantIdentityKey({
|
||||||
|
required String prenom,
|
||||||
|
required String nom,
|
||||||
|
required String? dateIso,
|
||||||
|
required bool isUnborn,
|
||||||
|
}) {
|
||||||
|
final pn = formatPersonNameCase(prenom).toLowerCase().trim();
|
||||||
|
final nm = formatPersonNameCase(nom).toLowerCase().trim();
|
||||||
|
final d = _normalizeDateKey(dateIso);
|
||||||
|
final kind = isUnborn ? 'due' : 'birth';
|
||||||
|
return '$pn|$nm|$kind|$d';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _normalizeDateKey(String? raw) {
|
||||||
|
final s = (raw ?? '').trim();
|
||||||
|
if (s.isEmpty) return '';
|
||||||
|
final fromFr = parseFrDateToIso(s);
|
||||||
|
if (fromFr != null) return fromFr;
|
||||||
|
try {
|
||||||
|
final dt = DateTime.parse(s);
|
||||||
|
final y = dt.year.toString().padLeft(4, '0');
|
||||||
|
final m = dt.month.toString().padLeft(2, '0');
|
||||||
|
final d = dt.day.toString().padLeft(2, '0');
|
||||||
|
return '$y-$m-$d';
|
||||||
|
} catch (_) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _enfantKeyFromCreate(_CreateChild c) {
|
||||||
|
final prenom = formatPersonNameCase(c.prenomCtrl.text);
|
||||||
|
final nomRaw = c.nomCtrl.text.trim();
|
||||||
|
final nom = nomRaw.isNotEmpty
|
||||||
|
? formatPersonNameCase(nomRaw)
|
||||||
|
: formatPersonNameCase(_p1NomCtrl.text);
|
||||||
|
return _enfantIdentityKey(
|
||||||
|
prenom: prenom,
|
||||||
|
nom: nom,
|
||||||
|
dateIso: parseFrDateToIso(c.dateCtrl.text),
|
||||||
|
isUnborn: c.isUnborn,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _enfantKeyFromDossier(EnfantDossier e) {
|
||||||
|
final isUnborn = (e.status ?? '').trim().toLowerCase() == 'a_naitre';
|
||||||
|
final nomRaw = (e.lastName ?? '').trim();
|
||||||
|
final nom = nomRaw.isNotEmpty ? nomRaw : (_p1NomCtrl.text.trim());
|
||||||
|
return _enfantIdentityKey(
|
||||||
|
prenom: e.firstName ?? '',
|
||||||
|
nom: nom,
|
||||||
|
dateIso: isUnborn ? e.dueDate : e.birthDate,
|
||||||
|
isUnborn: isUnborn,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isBlankChildDraft(_CreateChild c) {
|
||||||
|
if (c.hasExistingId) return false;
|
||||||
|
final prenom = c.prenomCtrl.text.trim();
|
||||||
|
final nom = c.nomCtrl.text.trim();
|
||||||
|
final date = c.dateCtrl.text.trim();
|
||||||
|
return prenom.isEmpty && nom.isEmpty && date.isEmpty && c.genre == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Résout l’id enfant : tracker edit, sinon match sur le GET dossier.
|
||||||
|
String? _resolveExistingChildId(
|
||||||
|
_CreateChild c,
|
||||||
|
Map<String, String> dossierIdByIdentity,
|
||||||
|
Set<String> alreadyUsedIds,
|
||||||
|
) {
|
||||||
|
final tracked = (c.existingChildId ?? '').trim();
|
||||||
|
if (tracked.isNotEmpty && !alreadyUsedIds.contains(tracked)) {
|
||||||
|
return tracked;
|
||||||
|
}
|
||||||
|
final key = _enfantKeyFromCreate(c);
|
||||||
|
final matched = (dossierIdByIdentity[key] ?? '').trim();
|
||||||
|
if (matched.isNotEmpty && !alreadyUsedIds.contains(matched)) {
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveEnfantsEdit(String pivotUserId) async {
|
||||||
|
// Index id connus du GET (ne jamais POST pour ceux-là).
|
||||||
|
final dossierIdByIdentity = <String, String>{};
|
||||||
|
for (final e in _dossier.enfants) {
|
||||||
|
final id = e.id.trim();
|
||||||
|
if (id.isEmpty) continue;
|
||||||
|
dossierIdByIdentity[_enfantKeyFromDossier(e)] = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final id in _removedEnfantIds) {
|
||||||
|
await UserService.deleteEnfant(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
final usedIds = <String>{..._removedEnfantIds};
|
||||||
|
for (final c in _children) {
|
||||||
|
if (_isBlankChildDraft(c)) continue;
|
||||||
|
|
||||||
|
final existingId = _resolveExistingChildId(
|
||||||
|
c,
|
||||||
|
dossierIdByIdentity,
|
||||||
|
usedIds,
|
||||||
|
);
|
||||||
|
final body = _enfantStaffBody(c);
|
||||||
|
|
||||||
|
if (existingId != null && existingId.isNotEmpty) {
|
||||||
|
// Ré-attache l’id au modèle (si match identité a récupéré un id perdu).
|
||||||
|
c.existingChildId = existingId;
|
||||||
|
final bytes = c.photoBytes;
|
||||||
|
await UserService.updateEnfant(
|
||||||
|
enfantId: existingId,
|
||||||
|
body: body,
|
||||||
|
photoBytes: (bytes != null && bytes.isNotEmpty) ? bytes : null,
|
||||||
|
photoFilename: c.photoFilename,
|
||||||
|
);
|
||||||
|
usedIds.add(existingId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uniquement les vrais nouveaux enfants (pas d’id dossier).
|
||||||
|
final bytes = c.photoBytes;
|
||||||
|
final created = await UserService.createEnfant(
|
||||||
|
parentUserId: pivotUserId,
|
||||||
|
body: body,
|
||||||
|
photoBytes: (bytes != null && bytes.isNotEmpty) ? bytes : null,
|
||||||
|
photoFilename: c.photoFilename,
|
||||||
|
);
|
||||||
|
final newId = created.id.trim();
|
||||||
|
if (newId.isNotEmpty) {
|
||||||
|
c.existingChildId = newId;
|
||||||
|
usedIds.add(newId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildNavigation() {
|
Widget _buildNavigation() {
|
||||||
if (_step == 3) {
|
if (_step == 3) {
|
||||||
return Row(
|
return Row(
|
||||||
@@ -1463,6 +1894,12 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
|||||||
onPressed: _submitting ? null : _createAndValidate,
|
onPressed: _submitting ? null : _createAndValidate,
|
||||||
child: Text(_submitting ? 'Envoi...' : 'Créer et valider'),
|
child: Text(_submitting ? 'Envoi...' : 'Créer et valider'),
|
||||||
),
|
),
|
||||||
|
] else if (_isEdit) ...[
|
||||||
|
ElevatedButton(
|
||||||
|
style: ValidationModalTheme.primaryElevatedStyle,
|
||||||
|
onPressed: _submitting ? null : _saveEdit,
|
||||||
|
child: Text(_submitting ? 'Envoi...' : 'Enregistrer'),
|
||||||
|
),
|
||||||
] else if (_isEnAttente && _firstParentId != null) ...[
|
] else if (_isEnAttente && _firstParentId != null) ...[
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onPressed: _submitting ? null : _refuser,
|
onPressed: _submitting ? null : _refuser,
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/models/dossier_list_item.dart';
|
||||||
import 'package:p_tits_pas/models/parent_model.dart';
|
import 'package:p_tits_pas/models/parent_model.dart';
|
||||||
|
import 'package:p_tits_pas/services/auth_service.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||||
|
|
||||||
class ParentManagementWidget extends StatefulWidget {
|
class ParentManagementWidget extends StatefulWidget {
|
||||||
@@ -23,16 +27,24 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
List<ParentModel> _parents = [];
|
List<ParentModel> _parents = [];
|
||||||
|
bool _canDelete = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_loadRights();
|
||||||
_loadParents();
|
_loadParents();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() => super.dispose();
|
void dispose() => super.dispose();
|
||||||
|
|
||||||
|
Future<void> _loadRights() async {
|
||||||
|
final user = await AuthService.getCurrentUser();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _canDelete = canDeleteMetier(user?.role));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadParents() async {
|
Future<void> _loadParents() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
@@ -54,6 +66,67 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isLastParent(ParentModel parent) {
|
||||||
|
final co = parent.coParent?.id.trim();
|
||||||
|
if (co != null && co.isNotEmpty) return false;
|
||||||
|
final num = (parent.user.numeroDossier ?? '').trim();
|
||||||
|
if (num.isEmpty) return true;
|
||||||
|
return !_parents.any((other) {
|
||||||
|
if (other.user.id == parent.user.id) return false;
|
||||||
|
return (other.user.numeroDossier ?? '').trim() == num;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDelete(ParentModel parent) async {
|
||||||
|
final num = (parent.user.numeroDossier ?? '').trim();
|
||||||
|
final name = formatDossierPersonLabel(
|
||||||
|
nom: parent.user.nom,
|
||||||
|
prenom: parent.user.prenom,
|
||||||
|
email: parent.user.email,
|
||||||
|
);
|
||||||
|
final last = _isLastParent(parent);
|
||||||
|
final enfants = ParentModel.foyerChildrenCount(parent, _parents);
|
||||||
|
final footnotes = last
|
||||||
|
? <String>[
|
||||||
|
if (num.isNotEmpty) 'Dernier parent du dossier $num.',
|
||||||
|
if (num.isEmpty) 'Dernier parent du dossier.',
|
||||||
|
'Les $enfants enfant(s) rattaché(s) seront aussi supprimés.',
|
||||||
|
]
|
||||||
|
: <String>[
|
||||||
|
if (num.isNotEmpty)
|
||||||
|
'Ce parent sera retiré du dossier $num.',
|
||||||
|
'Les enfants restent avec le co-parent.',
|
||||||
|
];
|
||||||
|
|
||||||
|
final confirmed = await showSuppressionConfirmDialog(
|
||||||
|
context,
|
||||||
|
title: 'Supprimer le parent',
|
||||||
|
subtitle: num.isEmpty ? null : 'Dossier $num',
|
||||||
|
people: [SuppressionPersonLine.parent(name)],
|
||||||
|
footnotes: footnotes,
|
||||||
|
);
|
||||||
|
if (!confirmed || !mounted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await UserService.deleteUser(parent.user.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
final msg = (result['message'] ?? 'Parent supprimé.').toString();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||||
|
await _loadParents();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
e is Exception
|
||||||
|
? e.toString().replaceFirst('Exception: ', '')
|
||||||
|
: 'Erreur suppression',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final query = widget.searchQuery.toLowerCase();
|
final query = widget.searchQuery.toLowerCase();
|
||||||
@@ -90,6 +163,8 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
|||||||
_openParentDetails(parent);
|
_openParentDetails(parent);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (_canDelete)
|
||||||
|
suppressionIconButton(onPressed: () => _confirmDelete(parent)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,19 +1,36 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/user.dart';
|
||||||
import 'package:p_tits_pas/models/pending_family.dart';
|
import 'package:p_tits_pas/models/pending_family.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
import 'package:p_tits_pas/widgets/admin/dossier_list_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart';
|
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 {
|
class PendingValidationWidget extends StatefulWidget {
|
||||||
final VoidCallback? onRefresh;
|
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;
|
||||||
|
/// Afficher la poubelle (#160) — mêmes règles que dossiers validés.
|
||||||
|
final bool canDelete;
|
||||||
|
|
||||||
const PendingValidationWidget({super.key, this.onRefresh});
|
const PendingValidationWidget({
|
||||||
|
super.key,
|
||||||
|
this.onRefresh,
|
||||||
|
this.searchQuery = '',
|
||||||
|
this.compactWhenEmpty = false,
|
||||||
|
this.onPendingNumerosChanged,
|
||||||
|
this.canDelete = false,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PendingValidationWidget> createState() => _PendingValidationWidgetState();
|
State<PendingValidationWidget> createState() =>
|
||||||
|
_PendingValidationWidgetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
||||||
@@ -21,6 +38,8 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
|||||||
String? _error;
|
String? _error;
|
||||||
List<AppUser> _pendingAM = [];
|
List<AppUser> _pendingAM = [];
|
||||||
List<PendingFamily> _pendingFamilies = [];
|
List<PendingFamily> _pendingFamilies = [];
|
||||||
|
/// Noms enrichis via GET /dossiers/:numero (libelle API = noms seuls).
|
||||||
|
final Map<String, String> _familyNamesByNumero = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -34,24 +53,77 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
|||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
final am = await UserService.getPendingUsers(role: 'assistante_maternelle');
|
final am =
|
||||||
|
await UserService.getPendingUsers(role: 'assistante_maternelle');
|
||||||
final families = await UserService.getPendingFamilies();
|
final families = await UserService.getPendingFamilies();
|
||||||
|
final namesByNumero = await _enrichFamilyNames(families);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_pendingAM = am;
|
_pendingAM = am;
|
||||||
_pendingFamilies = families;
|
_pendingFamilies = families;
|
||||||
|
_familyNamesByNumero
|
||||||
|
..clear()
|
||||||
|
..addAll(namesByNumero);
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
|
_emitPendingNumeros();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur inconnue';
|
_error = e is Exception
|
||||||
|
? e.toString().replaceFirst('Exception: ', '')
|
||||||
|
: 'Erreur inconnue';
|
||||||
_isLoading = false;
|
_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();
|
final num = numeroDossier?.trim();
|
||||||
if (num == null || num.isEmpty) {
|
if (num == null || num.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -73,9 +145,139 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDeletePending({
|
||||||
|
required String numeroDossier,
|
||||||
|
required String namesLine,
|
||||||
|
required bool isFamille,
|
||||||
|
}) async {
|
||||||
|
final num = numeroDossier.trim();
|
||||||
|
if (num.isEmpty) return;
|
||||||
|
|
||||||
|
var people = <SuppressionPersonLine>[];
|
||||||
|
String? fallbackSummary;
|
||||||
|
try {
|
||||||
|
final dossier = await UserService.getDossier(num);
|
||||||
|
if (dossier.isFamily) {
|
||||||
|
final f = dossier.asFamily;
|
||||||
|
people = suppressionPeopleFromDossier(
|
||||||
|
isFamille: true,
|
||||||
|
parents: f.parents
|
||||||
|
.map((p) => (
|
||||||
|
nom: p.nom ?? '',
|
||||||
|
prenom: p.prenom ?? '',
|
||||||
|
email: p.email,
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
enfants: f.enfants
|
||||||
|
.map((e) => (
|
||||||
|
nom: e.lastName ?? '',
|
||||||
|
prenom: e.firstName ?? '',
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final am = dossier.asAm.user;
|
||||||
|
people = suppressionPeopleFromDossier(
|
||||||
|
isFamille: false,
|
||||||
|
parents: const [],
|
||||||
|
enfants: const [],
|
||||||
|
amName: formatDossierPersonLabel(
|
||||||
|
nom: am.nom,
|
||||||
|
prenom: am.prenom,
|
||||||
|
email: am.email,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
fallbackSummary = isFamille
|
||||||
|
? 'Tous les parents et enfants rattachés seront supprimés.'
|
||||||
|
: 'Le compte AM sera supprimé ; les enfants accueillis '
|
||||||
|
'seront conservés.';
|
||||||
|
for (final part in namesLine.split(' - ')) {
|
||||||
|
final label = part.trim();
|
||||||
|
if (label.isEmpty) continue;
|
||||||
|
people.add(SuppressionPersonLine(
|
||||||
|
label: label,
|
||||||
|
icon: isFamille
|
||||||
|
? Icons.supervisor_account_outlined
|
||||||
|
: Icons.face,
|
||||||
|
role: isFamille ? 'Parent' : 'AM',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
final confirmed = await showDossierSuppressionConfirmDialog(
|
||||||
|
context,
|
||||||
|
numeroDossier: num,
|
||||||
|
isFamille: isFamille,
|
||||||
|
people: people,
|
||||||
|
fallbackSummary: fallbackSummary,
|
||||||
|
);
|
||||||
|
if (!confirmed || !mounted) return;
|
||||||
|
try {
|
||||||
|
final result = await UserService.deleteDossier(num);
|
||||||
|
if (!mounted) return;
|
||||||
|
final msg = (result['message'] ?? 'Dossier supprimé.').toString();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||||
|
await _load();
|
||||||
|
widget.onRefresh?.call();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
e is Exception
|
||||||
|
? e.toString().replaceFirst('Exception: ', '')
|
||||||
|
: 'Erreur suppression',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _matchesQuery(String haystack) {
|
||||||
|
final q = widget.searchQuery.trim().toLowerCase();
|
||||||
|
if (q.isEmpty) return true;
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_isLoading) {
|
if (_isLoading) {
|
||||||
|
if (widget.compactWhenEmpty) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.all(24),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
if (_error != null && _error!.isNotEmpty) {
|
if (_error != null && _error!.isNotEmpty) {
|
||||||
@@ -97,14 +299,32 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final hasAM = _pendingAM.isNotEmpty;
|
final pendingAM = _filteredAM;
|
||||||
final hasFamilies = _pendingFamilies.isNotEmpty;
|
final pendingFamilies = _filteredFamilies;
|
||||||
if (!hasAM && !hasFamilies) {
|
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(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Aucun dossier en attente de validation',
|
'Aucun dossier en attente de validation',
|
||||||
@@ -117,6 +337,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(
|
return RefreshIndicator(
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
await _load();
|
await _load();
|
||||||
@@ -125,275 +367,56 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
|||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: list,
|
||||||
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)),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _sectionTitle(String title) {
|
String _amNamesLine(AppUser user) {
|
||||||
return Text(
|
return formatDossierPersonLabel(
|
||||||
title,
|
nom: user.nom,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
prenom: user.prenom,
|
||||||
fontWeight: FontWeight.w600,
|
email: user.email,
|
||||||
color: Colors.black87,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// 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) {
|
Widget _buildAMCard(AppUser user) {
|
||||||
final numDossier = user.numeroDossier ?? '–';
|
final names = _amNamesLine(user);
|
||||||
final nameBold =
|
final num = user.numeroDossier ?? '';
|
||||||
user.fullName.isNotEmpty ? user.fullName : (user.email.isNotEmpty ? user.email : '–');
|
return DossierListCard(
|
||||||
return _PendingValidationRow(
|
numeroDossier: num,
|
||||||
icon: Icons.person_outline,
|
namesLine: names,
|
||||||
title: Text.rich(
|
isFamille: false,
|
||||||
TextSpan(
|
photoUrl: user.photoUrl,
|
||||||
style: const TextStyle(fontSize: 14, color: Colors.black87),
|
onOpen: () => _onOpenValidation(numeroDossier: user.numeroDossier),
|
||||||
children: [
|
onDelete: widget.canDelete
|
||||||
TextSpan(
|
? () => _confirmDeletePending(
|
||||||
text: nameBold,
|
numeroDossier: num,
|
||||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
namesLine: names,
|
||||||
),
|
isFamille: false,
|
||||||
TextSpan(
|
)
|
||||||
text: ' - $numDossier',
|
: null,
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `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) {
|
Widget _buildFamilyCard(PendingFamily family) {
|
||||||
final numDossier = family.numeroDossier ?? '–';
|
final num = (family.numeroDossier ?? '').trim();
|
||||||
final nameBold = family.libelle.isNotEmpty ? family.libelle : 'Famille';
|
final enriched = num.isNotEmpty ? _familyNamesByNumero[num] : null;
|
||||||
return _PendingValidationRow(
|
final names = (enriched != null && enriched.isNotEmpty)
|
||||||
icon: Icons.family_restroom_outlined,
|
? enriched
|
||||||
title: Text.rich(
|
: formatDossierFamilyNamesLine(family.libelle);
|
||||||
TextSpan(
|
return DossierListCard(
|
||||||
style: const TextStyle(fontSize: 14, color: Colors.black87),
|
numeroDossier: family.numeroDossier ?? '',
|
||||||
children: [
|
namesLine: names,
|
||||||
TextSpan(
|
isFamille: true,
|
||||||
text: nameBold,
|
onOpen: () => _onOpenValidation(numeroDossier: family.numeroDossier),
|
||||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
onDelete: widget.canDelete
|
||||||
),
|
? () => _confirmDeletePending(
|
||||||
TextSpan(
|
numeroDossier: num,
|
||||||
text: ' - $numDossier',
|
namesLine: names,
|
||||||
style: const TextStyle(fontWeight: FontWeight.w400),
|
isFamille: true,
|
||||||
),
|
)
|
||||||
],
|
: null,
|
||||||
),
|
|
||||||
),
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:p_tits_pas/models/relais_model.dart';
|
import 'package:p_tits_pas/models/relais_model.dart';
|
||||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||||
import 'package:p_tits_pas/services/relais_service.dart';
|
import 'package:p_tits_pas/services/relais_service.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||||
|
|
||||||
class RelaisManagementPanel extends StatefulWidget {
|
class RelaisManagementPanel extends StatefulWidget {
|
||||||
const RelaisManagementPanel({super.key});
|
const RelaisManagementPanel({super.key});
|
||||||
@@ -56,28 +57,16 @@ class _RelaisManagementPanelState extends State<RelaisManagementPanel> {
|
|||||||
try {
|
try {
|
||||||
if (result.action == _RelaisDialogAction.delete) {
|
if (result.action == _RelaisDialogAction.delete) {
|
||||||
if (relais == null) return;
|
if (relais == null) return;
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showSuppressionConfirmDialog(
|
||||||
context: context,
|
context,
|
||||||
builder: (ctx) => AlertDialog(
|
title: 'Supprimer le relais',
|
||||||
title: const Text('Supprimer le relais'),
|
people: [SuppressionPersonLine.relais(relais.nom)],
|
||||||
content: Text('Confirmer la suppression de "${relais.nom}" ?'),
|
footnotes: const [
|
||||||
actions: [
|
'Cette action est irréversible.',
|
||||||
TextButton(
|
],
|
||||||
onPressed: () => Navigator.of(ctx).pop(false),
|
|
||||||
child: const Text('Annuler'),
|
|
||||||
),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: () => Navigator.of(ctx).pop(true),
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: Colors.red.shade700,
|
|
||||||
),
|
|
||||||
child: const Text('Supprimer'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed != true) return;
|
if (!confirmed) return;
|
||||||
await RelaisService.deleteRelais(relais.id);
|
await RelaisService.deleteRelais(relais.id);
|
||||||
} else if (relais == null) {
|
} else if (relais == null) {
|
||||||
await RelaisService.createRelais(result.payload!);
|
await RelaisService.createRelais(result.payload!);
|
||||||
|
|||||||
@@ -1,24 +1,27 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.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/admin_management_widget.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/am_dossier_create_modal.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/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/common/admin_child_detail_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/dashboard_admin.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/enfant_management_widget.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/gestionnaire_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_dossier_create_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.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 {
|
class UserManagementPanel extends StatefulWidget {
|
||||||
/// Afficher l'onglet Administrateurs (sinon 3 onglets : Gestionnaires, Parents, AM).
|
/// Afficher l'onglet Administrateurs (sinon sans Administrateurs).
|
||||||
final bool showAdministrateursTab;
|
final bool showAdministrateursTab;
|
||||||
|
|
||||||
|
/// Création gestionnaire / admin (#161). False pour le dashboard gestionnaire.
|
||||||
|
final bool allowStaffAccountCreation;
|
||||||
|
|
||||||
const UserManagementPanel({
|
const UserManagementPanel({
|
||||||
super.key,
|
super.key,
|
||||||
this.showAdministrateursTab = true,
|
this.showAdministrateursTab = true,
|
||||||
|
this.allowStaffAccountCreation = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -32,45 +35,17 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
int _adminRefreshTick = 0;
|
int _adminRefreshTick = 0;
|
||||||
int _enfantRefreshTick = 0;
|
int _enfantRefreshTick = 0;
|
||||||
int _amRefreshTick = 0;
|
int _amRefreshTick = 0;
|
||||||
|
int _dossiersRefreshTick = 0;
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
final TextEditingController _amCapacityController = TextEditingController();
|
final TextEditingController _amCapacityController = TextEditingController();
|
||||||
String? _parentStatus;
|
String? _parentStatus;
|
||||||
String? _enfantStatus;
|
String? _enfantStatus;
|
||||||
bool _hasPending = false;
|
|
||||||
bool _pendingLoading = true;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_searchController.addListener(_onFilterChanged);
|
_searchController.addListener(_onFilterChanged);
|
||||||
_amCapacityController.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
|
@override
|
||||||
@@ -87,13 +62,19 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ordre #153 : Dossiers | Parents | Enfants | AM | Gestionnaires | (Admin).
|
||||||
List<String> get _tabLabels {
|
List<String> get _tabLabels {
|
||||||
const base = ['Parents', 'Enfants', 'Assistantes maternelles', 'Gestionnaires'];
|
const base = [
|
||||||
final withAdmin = [...base, 'Administrateurs'];
|
'Dossiers',
|
||||||
final list = widget.showAdministrateursTab ? withAdmin : base;
|
'Parents',
|
||||||
// Onglet « À valider » visible seulement s'il y a des dossiers en attente (ticket #107).
|
'Enfants',
|
||||||
if (!_pendingLoading && _hasPending) return ['À valider', ...list];
|
'Assistantes maternelles',
|
||||||
return list;
|
'Gestionnaires',
|
||||||
|
];
|
||||||
|
if (widget.showAdministrateursTab) {
|
||||||
|
return [...base, 'Administrateurs'];
|
||||||
|
}
|
||||||
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSubTabChange(int index) {
|
void _onSubTabChange(int index) {
|
||||||
@@ -107,31 +88,44 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Index du contenu : -1 = À valider, 0 = Parents, 1 = Enfants, 2 = AM, 3 = Gestionnaires, 4 = Admin.
|
bool get _isDossiersTab => _subIndex == 0;
|
||||||
int get _contentIndexOffset => (_hasPending && !_pendingLoading) ? 1 : 0;
|
|
||||||
|
bool get _isStaffAccountsTab =>
|
||||||
|
_subIndex == 4 || (widget.showAdministrateursTab && _subIndex == 5);
|
||||||
|
|
||||||
|
bool get _canShowAddButton {
|
||||||
|
if (_isDossiersTab) return false;
|
||||||
|
if (_isStaffAccountsTab && !widget.allowStaffAccountCreation) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
String _searchHintForTab() {
|
String _searchHintForTab() {
|
||||||
final contentIndex = _subIndex - _contentIndexOffset;
|
switch (_subIndex) {
|
||||||
switch (contentIndex) {
|
|
||||||
case -1:
|
|
||||||
return 'À valider (pas de recherche)';
|
|
||||||
case 0:
|
case 0:
|
||||||
return 'Rechercher un parent...';
|
return 'Rechercher un dossier';
|
||||||
case 1:
|
case 1:
|
||||||
return 'Rechercher un enfant...';
|
return 'Rechercher un parent...';
|
||||||
case 2:
|
case 2:
|
||||||
return 'Rechercher une assistante...';
|
return 'Rechercher un enfant...';
|
||||||
case 3:
|
case 3:
|
||||||
return 'Rechercher un gestionnaire...';
|
return 'Rechercher une assistante...';
|
||||||
case 4:
|
case 4:
|
||||||
|
return 'Rechercher un gestionnaire...';
|
||||||
|
case 5:
|
||||||
return 'Rechercher un administrateur...';
|
return 'Rechercher un administrateur...';
|
||||||
default:
|
default:
|
||||||
return 'Rechercher...';
|
return 'Rechercher...';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? _searchTooltipForTab() {
|
||||||
|
if (_subIndex != 0) return null;
|
||||||
|
return 'Recherche possible : n° de dossier, nom, prénom ou e-mail.';
|
||||||
|
}
|
||||||
|
|
||||||
Widget? _subBarFilterControl() {
|
Widget? _subBarFilterControl() {
|
||||||
if (_subIndex == _contentIndexOffset + 0) {
|
// Parents
|
||||||
|
if (_subIndex == 1) {
|
||||||
return DropdownButtonHideUnderline(
|
return DropdownButtonHideUnderline(
|
||||||
child: DropdownButton<String?>(
|
child: DropdownButton<String?>(
|
||||||
value: _parentStatus,
|
value: _parentStatus,
|
||||||
@@ -186,7 +180,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_subIndex == _contentIndexOffset + 1) {
|
// Enfants
|
||||||
|
if (_subIndex == 2) {
|
||||||
return DropdownButtonHideUnderline(
|
return DropdownButtonHideUnderline(
|
||||||
child: DropdownButton<String?>(
|
child: DropdownButton<String?>(
|
||||||
value: _enfantStatus,
|
value: _enfantStatus,
|
||||||
@@ -241,7 +236,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_subIndex == _contentIndexOffset + 2) {
|
// AM
|
||||||
|
if (_subIndex == 3) {
|
||||||
return TextField(
|
return TextField(
|
||||||
controller: _amCapacityController,
|
controller: _amCapacityController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
@@ -258,35 +254,36 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody() {
|
Widget _buildBody() {
|
||||||
final contentIndex = _subIndex - _contentIndexOffset;
|
switch (_subIndex) {
|
||||||
if (_hasPending && !_pendingLoading && contentIndex == -1) {
|
|
||||||
return PendingValidationWidget(onRefresh: _loadPending);
|
|
||||||
}
|
|
||||||
switch (contentIndex) {
|
|
||||||
case 0:
|
case 0:
|
||||||
|
return DossiersManagementWidget(
|
||||||
|
key: ValueKey('dossiers-$_dossiersRefreshTick'),
|
||||||
|
searchQuery: _searchController.text,
|
||||||
|
);
|
||||||
|
case 1:
|
||||||
return ParentManagementWidget(
|
return ParentManagementWidget(
|
||||||
key: ValueKey('parents-$_parentRefreshTick'),
|
key: ValueKey('parents-$_parentRefreshTick'),
|
||||||
searchQuery: _searchController.text,
|
searchQuery: _searchController.text,
|
||||||
statusFilter: _parentStatus,
|
statusFilter: _parentStatus,
|
||||||
);
|
);
|
||||||
case 1:
|
case 2:
|
||||||
return EnfantManagementWidget(
|
return EnfantManagementWidget(
|
||||||
key: ValueKey('enfants-$_enfantRefreshTick'),
|
key: ValueKey('enfants-$_enfantRefreshTick'),
|
||||||
searchQuery: _searchController.text,
|
searchQuery: _searchController.text,
|
||||||
statusFilter: _enfantStatus,
|
statusFilter: _enfantStatus,
|
||||||
);
|
);
|
||||||
case 2:
|
case 3:
|
||||||
return AssistanteMaternelleManagementWidget(
|
return AssistanteMaternelleManagementWidget(
|
||||||
key: ValueKey('ams-$_amRefreshTick'),
|
key: ValueKey('ams-$_amRefreshTick'),
|
||||||
searchQuery: _searchController.text,
|
searchQuery: _searchController.text,
|
||||||
capacityMin: int.tryParse(_amCapacityController.text),
|
capacityMin: int.tryParse(_amCapacityController.text),
|
||||||
);
|
);
|
||||||
case 3:
|
case 4:
|
||||||
return GestionnaireManagementWidget(
|
return GestionnaireManagementWidget(
|
||||||
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
|
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
|
||||||
searchQuery: _searchController.text,
|
searchQuery: _searchController.text,
|
||||||
);
|
);
|
||||||
case 4:
|
case 5:
|
||||||
return AdminManagementWidget(
|
return AdminManagementWidget(
|
||||||
key: ValueKey('admins-$_adminRefreshTick'),
|
key: ValueKey('admins-$_adminRefreshTick'),
|
||||||
searchQuery: _searchController.text,
|
searchQuery: _searchController.text,
|
||||||
@@ -299,7 +296,6 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final labels = _tabLabels;
|
final labels = _tabLabels;
|
||||||
final isAValiderTab = _hasPending && !_pendingLoading && _subIndex == 0;
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
DashboardUserManagementSubBar(
|
DashboardUserManagementSubBar(
|
||||||
@@ -307,8 +303,11 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
onSubTabChange: _onSubTabChange,
|
onSubTabChange: _onSubTabChange,
|
||||||
searchController: _searchController,
|
searchController: _searchController,
|
||||||
searchHint: _searchHintForTab(),
|
searchHint: _searchHintForTab(),
|
||||||
|
searchTooltip: _searchTooltipForTab(),
|
||||||
filterControl: _subBarFilterControl(),
|
filterControl: _subBarFilterControl(),
|
||||||
onAddPressed: isAValiderTab ? null : _handleAddPressed,
|
// Pas de « Créer » sur l’onglet Dossiers (#153).
|
||||||
|
// Pas de création staff pour le dashboard gestionnaire (#161).
|
||||||
|
onAddPressed: _canShowAddButton ? _handleAddPressed : null,
|
||||||
addLabel: 'Ajouter',
|
addLabel: 'Ajouter',
|
||||||
subTabCount: labels.length,
|
subTabCount: labels.length,
|
||||||
tabLabels: labels,
|
tabLabels: labels,
|
||||||
@@ -319,9 +318,11 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleAddPressed() async {
|
Future<void> _handleAddPressed() async {
|
||||||
final contentIndex = _subIndex - _contentIndexOffset;
|
// 1 Parents, 2 Enfants, 3 AM, 4 Gestionnaires, 5 Admin
|
||||||
|
if (_isStaffAccountsTab && !widget.allowStaffAccountCreation) {
|
||||||
if (contentIndex == 0) {
|
return;
|
||||||
|
}
|
||||||
|
if (_subIndex == 1) {
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
@@ -331,7 +332,10 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
onSuccess: () {
|
onSuccess: () {
|
||||||
Navigator.of(dialogContext).pop();
|
Navigator.of(dialogContext).pop();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _parentRefreshTick++);
|
setState(() {
|
||||||
|
_parentRefreshTick++;
|
||||||
|
_dossiersRefreshTick++;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -339,7 +343,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentIndex == 1) {
|
if (_subIndex == 2) {
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
@@ -355,7 +359,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentIndex == 2) {
|
if (_subIndex == 3) {
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
@@ -365,7 +369,10 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
onSuccess: () {
|
onSuccess: () {
|
||||||
Navigator.of(dialogContext).pop();
|
Navigator.of(dialogContext).pop();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _amRefreshTick++);
|
setState(() {
|
||||||
|
_amRefreshTick++;
|
||||||
|
_dossiersRefreshTick++;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -373,7 +380,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentIndex == 3) {
|
if (_subIndex == 4) {
|
||||||
final created = await showDialog<bool>(
|
final created = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
@@ -391,7 +398,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentIndex == 4) {
|
if (_subIndex == 5) {
|
||||||
final created = await showDialog<bool>(
|
final created = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
@@ -409,16 +416,6 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
_adminRefreshTick++;
|
_adminRefreshTick++;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'La création parent sera disponible avec le ticket #129.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,25 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart';
|
import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_am_wizard.dart';
|
import 'package:p_tits_pas/widgets/admin/validation_am_wizard.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_family_wizard.dart';
|
import 'package:p_tits_pas/widgets/admin/validation_family_wizard.dart';
|
||||||
|
|
||||||
/// Modale (dialog) : charge le dossier par numéro puis affiche le wizard AM ou Famille. Ticket #107, #119.
|
/// Modale (dialog) : charge le dossier par numéro puis affiche le wizard AM ou Famille.
|
||||||
|
/// Ticket #107 / #119 (review), #135 (`openAsEdit`).
|
||||||
class ValidationDossierModal extends StatefulWidget {
|
class ValidationDossierModal extends StatefulWidget {
|
||||||
final String numeroDossier;
|
final String numeroDossier;
|
||||||
final VoidCallback onClose;
|
final VoidCallback onClose;
|
||||||
final VoidCallback? onSuccess;
|
final VoidCallback? onSuccess;
|
||||||
|
/// Liste Dossiers actifs → mode edit (#135). Pending reste en review (défaut).
|
||||||
|
final bool openAsEdit;
|
||||||
|
|
||||||
const ValidationDossierModal({
|
const ValidationDossierModal({
|
||||||
super.key,
|
super.key,
|
||||||
required this.numeroDossier,
|
required this.numeroDossier,
|
||||||
required this.onClose,
|
required this.onClose,
|
||||||
this.onSuccess,
|
this.onSuccess,
|
||||||
|
this.openAsEdit = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -77,12 +82,12 @@ class _ValidationDossierModalState extends State<ValidationDossierModal> {
|
|||||||
|
|
||||||
/// Largeur modale = 1,5 × 620.
|
/// Largeur modale = 1,5 × 620.
|
||||||
static const double _modalWidth = 930; // 620 * 1.5
|
static const double _modalWidth = 930; // 620 * 1.5
|
||||||
static const double _familyBodyHeight = 435;
|
|
||||||
|
|
||||||
double get _bodyHeight {
|
double get _bodyHeight {
|
||||||
final d = _dossier;
|
final d = _dossier;
|
||||||
if (d != null && d.isAm) return AmDossierWizard.shellBodyHeight;
|
if (d != null && d.isAm) return AmDossierWizard.shellBodyHeight;
|
||||||
return _familyBodyHeight;
|
// Aligné create (#129) / edit (#135) — évite overflow IdentityBlock (8px).
|
||||||
|
return ParentDossierWizard.shellBodyHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -162,6 +167,14 @@ class _ValidationDossierModalState extends State<ValidationDossierModal> {
|
|||||||
}
|
}
|
||||||
final d = _dossier!;
|
final d = _dossier!;
|
||||||
if (d.isAm) {
|
if (d.isAm) {
|
||||||
|
if (widget.openAsEdit) {
|
||||||
|
return AmDossierWizard.edit(
|
||||||
|
dossier: d.asAm,
|
||||||
|
onClose: widget.onClose,
|
||||||
|
onSuccess: _onSuccess,
|
||||||
|
onStepChanged: _onStepChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
return ValidationAmWizard(
|
return ValidationAmWizard(
|
||||||
dossier: d.asAm,
|
dossier: d.asAm,
|
||||||
onClose: widget.onClose,
|
onClose: widget.onClose,
|
||||||
@@ -169,6 +182,14 @@ class _ValidationDossierModalState extends State<ValidationDossierModal> {
|
|||||||
onStepChanged: _onStepChanged,
|
onStepChanged: _onStepChanged,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (widget.openAsEdit) {
|
||||||
|
return ParentDossierWizard.edit(
|
||||||
|
dossier: d.asFamily,
|
||||||
|
onClose: widget.onClose,
|
||||||
|
onSuccess: _onSuccess,
|
||||||
|
onStepChanged: _onStepChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
return ValidationFamilyWizard(
|
return ValidationFamilyWizard(
|
||||||
dossier: d.asFamily,
|
dossier: d.asFamily,
|
||||||
onClose: widget.onClose,
|
onClose: widget.onClose,
|
||||||
|
|||||||
Reference in New Issue
Block a user