Compare commits
84
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67336c64fe | ||
|
|
97afbbcf9a | ||
|
|
e235b30140 | ||
|
|
7f23356273 | ||
|
|
c8c9cfbc4d | ||
|
|
530e896b66 | ||
|
|
0029c5ab86 | ||
|
|
2ce9e9215f | ||
|
|
3fdd913367 | ||
|
|
6708f73b06 | ||
|
|
f596f062a6 | ||
|
|
86701731e3 | ||
|
|
b4abb7d6de | ||
|
|
cb5c1a5518 | ||
|
|
30ca99fb65 | ||
|
|
8ee2ca8ea6 | ||
|
|
e3552667bc | ||
|
|
4ae334b247 | ||
|
|
471a62ddb7 | ||
|
|
59afeb0a8d | ||
|
|
d2172eafdb | ||
|
|
d247867fa0 | ||
|
|
93912d1374 | ||
|
|
925b6d5cd4 | ||
|
|
b0dddd6695 | ||
|
|
ef7512dc1e | ||
|
|
2ececa711b | ||
|
|
7a3d997ff9 | ||
|
|
6b89d7b405 | ||
|
|
f7ac628445 | ||
|
|
26e9738ec7 | ||
|
|
0ec3410457 | ||
|
|
c3acf1970d | ||
|
|
6fe2b89a61 | ||
|
|
d6a9b3fd66 | ||
|
|
134b9781c8 | ||
|
|
b936d27445 | ||
|
|
18c1d1eba7 | ||
|
|
f74b14c203 | ||
|
|
f194b5f9e8 | ||
|
|
5ab8ae3423 | ||
|
|
3277f77846 | ||
|
|
2d80ad0d7e | ||
|
|
09386f8aa6 | ||
|
|
faa50f637c | ||
|
|
267fe63aec | ||
|
|
90b185740c | ||
|
|
b903dbf60b | ||
|
|
291ea26b34 | ||
|
|
59919834b9 | ||
|
|
cf6acbae7c | ||
|
|
7951c38d4d | ||
|
|
77d952a6f7 | ||
|
|
b4b546044d | ||
|
|
6788351070 | ||
|
|
7b69f27ca5 | ||
|
|
8ed68797aa | ||
|
|
9ad371a342 | ||
|
|
18718670e9 | ||
|
|
fbf22f2540 | ||
|
|
43a2cd213b | ||
|
|
c865d11dc3 | ||
|
|
d03a8e6c8b | ||
|
|
66c7f22280 | ||
|
|
53721ffbb3 | ||
|
|
52e40d0001 | ||
|
|
4985726bc6 | ||
|
|
479a32b4bf | ||
|
|
2fd97ddecb | ||
|
|
ce474797c4 | ||
|
|
ebf794e1ac | ||
|
|
d55f240f56 | ||
|
|
966f4a9c9c | ||
|
|
8494341b56 | ||
|
|
1dddc67933 | ||
|
|
df776d8200 | ||
|
|
c26ed00374 | ||
|
|
9d54d9b19b | ||
|
|
c438009286 | ||
|
|
9b7231f1da | ||
|
|
f300505225 | ||
|
|
25c10c885a | ||
|
|
d70577b1c3 | ||
|
|
671da71752 |
@@ -89,6 +89,9 @@
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"^src/(.*)$": "<rootDir>/$1"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Crée l'issue Gitea — gestionnaire ne doit pas pouvoir se supprimer.
|
||||
* Usage: node backend/scripts/create-gitea-issue-gestionnaire-self-delete.js
|
||||
*/
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.join(__dirname, '../..');
|
||||
const MILESTONE_0_1_0 = 10;
|
||||
|
||||
let token = process.env.GITEA_TOKEN;
|
||||
if (!token) {
|
||||
try {
|
||||
const tokenFile = path.join(repoRoot, '.gitea-token');
|
||||
if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!token) {
|
||||
try {
|
||||
const briefing = fs.readFileSync(
|
||||
path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'),
|
||||
'utf8',
|
||||
);
|
||||
const m = briefing.match(/Token:\s*(gitebu_[a-f0-9]+)/);
|
||||
if (m) token = m[1].trim();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!token) {
|
||||
console.error('Token non trouvé : .gitea-token ou GITEA_TOKEN');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const body = `## Contexte
|
||||
|
||||
Quand un **gestionnaire** connecté ouvre sa propre fiche dans l'onglet **Gestionnaires** (Gestion des utilisateurs), la modale **Modifier un "Gestionnaire"** affiche le bouton **Supprimer**.
|
||||
|
||||
Comportement actuel : le gestionnaire voit et peut tenter de supprimer son propre compte.
|
||||
|
||||
## Comportement attendu
|
||||
|
||||
Comme pour le **super administrateur** (bouton Supprimer masqué sur la fiche super admin) :
|
||||
|
||||
- **Pas de bouton Supprimer** quand l'utilisateur édite **sa propre fiche**
|
||||
- **Modification** des informations (prénom, nom, email, téléphone, relais, mot de passe) **toujours autorisée**
|
||||
|
||||
## Périmètre
|
||||
|
||||
- Frontend : \`AdminUserFormDialog\` (\`gestionnaires_create.dart\`)
|
||||
- Comparer \`initialUser.id\` avec l'utilisateur connecté (\`AuthService.getCurrentUser\`)
|
||||
- Masquer Supprimer si édition de soi-même ; conserver garde existante super admin
|
||||
|
||||
## Critères d'acceptation
|
||||
|
||||
- [ ] Gestionnaire connecté → ouvre sa fiche → **pas** de bouton Supprimer
|
||||
- [ ] Gestionnaire connecté → peut **Modifier** ses informations
|
||||
- [ ] Super admin / admin → peut toujours supprimer **un autre** gestionnaire (si droits API)
|
||||
- [ ] Pas de régression sur fiche super administrateur (Supprimer toujours masqué)
|
||||
|
||||
## Fichiers clés
|
||||
|
||||
- \`frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart\`
|
||||
- \`frontend/lib/widgets/admin/gestionnaire_management_widget.dart\`
|
||||
|
||||
## Milestone
|
||||
|
||||
**0.1.0** — correction UX / sécurité gestion utilisateurs.`;
|
||||
|
||||
const payloadClean = JSON.stringify({
|
||||
title: '[Bug] Gestionnaire peut voir Supprimer sur sa propre fiche',
|
||||
body,
|
||||
milestone: MILESTONE_0_1_0,
|
||||
});
|
||||
|
||||
const opts = {
|
||||
hostname: 'git.ptits-pas.fr',
|
||||
path: '/api/v1/repos/jmartin/petitspas/issues',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'token ' + token,
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payloadClean),
|
||||
},
|
||||
};
|
||||
|
||||
const req = https.request(opts, (res) => {
|
||||
let d = '';
|
||||
res.on('data', (c) => (d += c));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const o = JSON.parse(d);
|
||||
if (o.number) {
|
||||
console.log('NUMBER:', o.number);
|
||||
console.log('URL:', o.html_url);
|
||||
console.log('MILESTONE:', o.milestone?.title ?? '(aucun)');
|
||||
} else {
|
||||
console.error('Réponse:', d);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(d);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', (e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
req.write(payloadClean);
|
||||
req.end();
|
||||
@@ -18,6 +18,7 @@ import { AppConfigModule } from './modules/config/config.module';
|
||||
import { DocumentsLegauxModule } from './modules/documents-legaux';
|
||||
import { RelaisModule } from './routes/relais/relais.module';
|
||||
import { DossiersModule } from './routes/dossiers/dossiers.module';
|
||||
import { SuppressionsModule } from './routes/suppressions/suppressions.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -57,6 +58,7 @@ import { DossiersModule } from './routes/dossiers/dossiers.module';
|
||||
DocumentsLegauxModule,
|
||||
RelaisModule,
|
||||
DossiersModule,
|
||||
SuppressionsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
+51
-2
@@ -1,20 +1,69 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AssistantesMaternellesController } from './assistantes_maternelles.controller';
|
||||
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
|
||||
describe('AssistantesMaternellesController', () => {
|
||||
let controller: AssistantesMaternellesController;
|
||||
const authServiceMock = {
|
||||
createAmDossierStaff: jest.fn(),
|
||||
};
|
||||
const amServiceMock = {};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AssistantesMaternellesController],
|
||||
providers: [AssistantesMaternellesService],
|
||||
}).compile();
|
||||
providers: [
|
||||
{ provide: AssistantesMaternellesService, useValue: amServiceMock },
|
||||
{ provide: AuthService, useValue: authServiceMock },
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.overrideGuard(RolesGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<AssistantesMaternellesController>(AssistantesMaternellesController);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
it('createDossier delegates to authService.createAmDossierStaff with CGU accepted', async () => {
|
||||
authServiceMock.createAmDossierStaff.mockResolvedValue({
|
||||
message: 'ok',
|
||||
user_id: 'u1',
|
||||
statut: 'actif',
|
||||
numero_dossier: '2026-000001',
|
||||
});
|
||||
|
||||
const body = {
|
||||
email: 'am.staff@test.fr',
|
||||
prenom: 'Marie',
|
||||
nom: 'TEST',
|
||||
telephone: '0689567890',
|
||||
consentement_photo: false,
|
||||
lieu_naissance_ville: 'Paris',
|
||||
lieu_naissance_pays: 'France',
|
||||
nir: '285017512345678',
|
||||
numero_agrement: 'AGR-TEST-001',
|
||||
capacite_accueil: 3,
|
||||
places_disponibles: 2,
|
||||
};
|
||||
|
||||
const res = await controller.createDossier(body as any);
|
||||
expect(authServiceMock.createAmDossierStaff).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email: body.email,
|
||||
acceptation_cgu: true,
|
||||
acceptation_privacy: true,
|
||||
}),
|
||||
);
|
||||
expect(res.numero_dossier).toBe('2026-000001');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
Param,
|
||||
Delete,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
@@ -16,17 +18,49 @@ import { RoleType, Users } from 'src/entities/users.entity';
|
||||
import { CreateAssistanteDto } from '../user/dto/create_assistante.dto';
|
||||
import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto';
|
||||
import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto';
|
||||
import { StaffCreateAmDossierDto } from './dto/staff-create-am-dossier.dto';
|
||||
import { StaffCreateAmDossierResponseDto } from './dto/staff-create-am-dossier-response.dto';
|
||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { User } from 'src/common/decorators/user.decorator';
|
||||
import { mapAmForApi, mapAmsForApi } from './assistantes_maternelles.mapper';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { RegisterAMCompletDto } from '../auth/dto/register-am-complet.dto';
|
||||
|
||||
@ApiTags("Assistantes Maternelles")
|
||||
@ApiBearerAuth('access-token')
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
@Controller('assistantes-maternelles')
|
||||
export class AssistantesMaternellesController {
|
||||
constructor(private readonly assistantesMaternellesService: AssistantesMaternellesService) { }
|
||||
constructor(
|
||||
private readonly assistantesMaternellesService: AssistantesMaternellesService,
|
||||
private readonly authService: AuthService,
|
||||
) { }
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@Post('dossier')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: 'Créer un dossier AM complet (staff) — ticket #156',
|
||||
description:
|
||||
'Crée user + fiche AM avec statut actif, n° dossier, et envoie l’e-mail de création de mot de passe. ' +
|
||||
'Ne pas utiliser POST /auth/register/am depuis le dashboard.',
|
||||
})
|
||||
@ApiBody({ type: StaffCreateAmDossierDto })
|
||||
@ApiResponse({ status: 201, type: StaffCreateAmDossierResponseDto })
|
||||
@ApiResponse({ status: 400, description: 'Validation métier / NIR' })
|
||||
@ApiResponse({ status: 403, description: 'Rôle non autorisé' })
|
||||
@ApiResponse({ status: 409, description: 'Email / NIR / agrément déjà pris' })
|
||||
async createDossier(
|
||||
@Body() dto: StaffCreateAmDossierDto,
|
||||
): Promise<StaffCreateAmDossierResponseDto> {
|
||||
const registerDto = {
|
||||
...dto,
|
||||
acceptation_cgu: true,
|
||||
acceptation_privacy: true,
|
||||
} as RegisterAMCompletDto;
|
||||
return this.authService.createAmDossierStaff(registerDto);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({ summary: 'Créer nounou' })
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
|
||||
/** Réponse 201 POST /assistantes-maternelles/dossier (#156). */
|
||||
export class StaffCreateAmDossierResponseDto {
|
||||
@ApiProperty()
|
||||
message: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
user_id: string;
|
||||
|
||||
@ApiProperty({
|
||||
enum: StatutUtilisateurType,
|
||||
example: StatutUtilisateurType.ACTIF,
|
||||
})
|
||||
statut: StatutUtilisateurType;
|
||||
|
||||
@ApiProperty({
|
||||
example: '2026-000042',
|
||||
description: 'Numéro de dossier attribué',
|
||||
})
|
||||
numero_dossier: string;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ApiPropertyOptional, OmitType } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
import { RegisterAMCompletDto } from 'src/routes/auth/dto/register-am-complet.dto';
|
||||
|
||||
/**
|
||||
* Création dossier AM par staff (#156).
|
||||
* Mêmes champs que l'inscription publique, sans CGU/privacy obligatoires
|
||||
* (acceptées côté serveur pour le compte du gestionnaire).
|
||||
*/
|
||||
export class StaffCreateAmDossierDto extends OmitType(RegisterAMCompletDto, [
|
||||
'acceptation_cgu',
|
||||
'acceptation_privacy',
|
||||
] as const) {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Ignoré côté staff (CGU acceptées serveur). Conservé pour compat éventuelle.',
|
||||
default: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
acceptation_cgu?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Ignoré côté staff (privacy acceptée serveur).',
|
||||
default: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
acceptation_privacy?: boolean;
|
||||
}
|
||||
@@ -29,7 +29,7 @@ import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||
ParentsChildren,
|
||||
]),
|
||||
forwardRef(() => UserModule),
|
||||
ParentsModule,
|
||||
forwardRef(() => ParentsModule),
|
||||
DossiersModule,
|
||||
AppConfigModule,
|
||||
MailModule,
|
||||
|
||||
@@ -36,6 +36,7 @@ import { MailService } from 'src/modules/mail/mail.service';
|
||||
import { ParentsService } from '../parents/parents.service';
|
||||
import { DossiersService } from '../dossiers/dossiers.service';
|
||||
import { DossierAmCompletDto } from '../dossiers/dto/dossier-am-complet.dto';
|
||||
import { StaffAddCoParentDto } from '../parents/dto/staff-add-co-parent.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -416,11 +417,20 @@ export class AuthService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Inscription Parent COMPLÈTE - Workflow CDC 6 étapes en 1 transaction
|
||||
* Gère : Parent 1 + Parent 2 (opt) + Enfants + Présentation + CGU
|
||||
* Cœur partagé création dossier parent (#129).
|
||||
* - public : statut en_attente + mails pending
|
||||
* - staff : statut actif + mails création MDP (pas de mail « dossier en attente »)
|
||||
*/
|
||||
async inscrireParentComplet(dto: RegisterParentCompletDto) {
|
||||
if (!dto.acceptation_cgu || !dto.acceptation_privacy) {
|
||||
async createParentDossier(
|
||||
dto: RegisterParentCompletDto,
|
||||
options: {
|
||||
statut: StatutUtilisateurType;
|
||||
sendPendingEmail: boolean;
|
||||
sendPasswordSetupEmail: boolean;
|
||||
requireCgu: boolean;
|
||||
},
|
||||
) {
|
||||
if (options.requireCgu && (!dto.acceptation_cgu || !dto.acceptation_privacy)) {
|
||||
throw new BadRequestException('L\'acceptation des CGU et de la politique de confidentialité est obligatoire');
|
||||
}
|
||||
|
||||
@@ -471,7 +481,7 @@ export class AuthService {
|
||||
prenom: dto.prenom,
|
||||
nom: dto.nom,
|
||||
role: RoleType.PARENT,
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
statut: options.statut,
|
||||
telephone: dto.telephone,
|
||||
adresse: dto.adresse,
|
||||
code_postal: dto.code_postal,
|
||||
@@ -496,7 +506,7 @@ export class AuthService {
|
||||
prenom: dto.co_parent_prenom,
|
||||
nom: dto.co_parent_nom,
|
||||
role: RoleType.PARENT,
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
statut: options.statut,
|
||||
telephone: dto.co_parent_telephone,
|
||||
adresse: dto.co_parent_meme_adresse ? dto.adresse : dto.co_parent_adresse,
|
||||
code_postal: dto.co_parent_meme_adresse ? dto.code_postal : dto.co_parent_code_postal,
|
||||
@@ -552,7 +562,8 @@ export class AuthService {
|
||||
: undefined;
|
||||
enfant.photo_url = urlPhoto || undefined;
|
||||
enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE;
|
||||
enfant.consent_photo = false;
|
||||
enfant.consent_photo = !!enfantDto.consent_photo;
|
||||
enfant.consent_photo_at = enfant.consent_photo ? new Date() : null!;
|
||||
enfant.is_multiple = enfantDto.grossesse_multiple || false;
|
||||
|
||||
const enfantEnregistre = await manager.save(Children, enfant);
|
||||
@@ -611,44 +622,287 @@ export class AuthService {
|
||||
|
||||
const numeroDossier = resultat.parent1.numero_dossier ?? '';
|
||||
|
||||
try {
|
||||
await this.mailService.sendRegistrationPendingEmail(
|
||||
resultat.parent1.email,
|
||||
resultat.parent1.prenom ?? '',
|
||||
resultat.parent1.nom ?? '',
|
||||
numeroDossier,
|
||||
);
|
||||
if (resultat.parent2) {
|
||||
if (options.sendPendingEmail) {
|
||||
try {
|
||||
await this.mailService.sendRegistrationPendingEmail(
|
||||
resultat.parent2.email,
|
||||
resultat.parent2.prenom ?? '',
|
||||
resultat.parent2.nom ?? '',
|
||||
resultat.parent1.email,
|
||||
resultat.parent1.prenom ?? '',
|
||||
resultat.parent1.nom ?? '',
|
||||
numeroDossier,
|
||||
);
|
||||
if (resultat.parent2) {
|
||||
await this.mailService.sendRegistrationPendingEmail(
|
||||
resultat.parent2.email,
|
||||
resultat.parent2.prenom ?? '',
|
||||
resultat.parent2.nom ?? '',
|
||||
numeroDossier,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
"[createParentDossier] Échec envoi email d'accusé de réception (inscription conservée)",
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
"[inscrireParentComplet] Échec envoi email d'accusé de réception (inscription conservée)",
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
|
||||
if (options.sendPasswordSetupEmail) {
|
||||
try {
|
||||
await this.mailService.sendValidatedAccountPasswordSetupEmail(
|
||||
{
|
||||
email: resultat.parent1.email,
|
||||
prenom: resultat.parent1.prenom ?? '',
|
||||
nom: resultat.parent1.nom ?? '',
|
||||
token: resultat.tokenCreationMdp,
|
||||
numeroDossier,
|
||||
},
|
||||
'parent',
|
||||
);
|
||||
if (resultat.parent2 && resultat.tokenCoParent) {
|
||||
await this.mailService.sendValidatedAccountPasswordSetupEmail(
|
||||
{
|
||||
email: resultat.parent2.email,
|
||||
prenom: resultat.parent2.prenom ?? '',
|
||||
nom: resultat.parent2.nom ?? '',
|
||||
token: resultat.tokenCoParent,
|
||||
numeroDossier,
|
||||
},
|
||||
'parent',
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'[createParentDossier] Échec envoi email création MDP (dossier conservé)',
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const message = options.sendPasswordSetupEmail
|
||||
? 'Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.'
|
||||
: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.';
|
||||
|
||||
return {
|
||||
message: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
|
||||
message,
|
||||
parent_id: resultat.parent1.id,
|
||||
co_parent_id: resultat.parent2?.id,
|
||||
parent_user_id: resultat.parent1.id,
|
||||
co_parent_id: resultat.parent2?.id ?? null,
|
||||
co_parent_user_id: resultat.parent2?.id ?? null,
|
||||
enfants_ids: resultat.enfants.map(e => e.id),
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
enfant_ids: resultat.enfants.map(e => e.id),
|
||||
statut: options.statut,
|
||||
numero_dossier: numeroDossier,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inscription Assistante Maternelle COMPLÈTE - Un seul endpoint (identité + pro + photo + CGU)
|
||||
* Crée User (role AM) + entrée assistantes_maternelles, token création MDP
|
||||
* Inscription Parent publique — CDC (statut en_attente + mail pending).
|
||||
*/
|
||||
async inscrireAMComplet(dto: RegisterAMCompletDto) {
|
||||
if (!dto.acceptation_cgu || !dto.acceptation_privacy) {
|
||||
async inscrireParentComplet(dto: RegisterParentCompletDto) {
|
||||
return this.createParentDossier(dto, {
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
sendPendingEmail: true,
|
||||
sendPasswordSetupEmail: false,
|
||||
requireCgu: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Création dossier parent par le staff (#129) — actif + mail création MDP.
|
||||
*/
|
||||
async createParentDossierStaff(dto: RegisterParentCompletDto) {
|
||||
return this.createParentDossier(dto, {
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
sendPendingEmail: false,
|
||||
sendPasswordSetupEmail: true,
|
||||
requireCgu: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* - public : statut en_attente + mail pending
|
||||
* - staff : statut actif + mail création MDP
|
||||
*/
|
||||
async createAmDossier(
|
||||
dto: RegisterAMCompletDto,
|
||||
options: {
|
||||
statut: StatutUtilisateurType;
|
||||
sendPendingEmail: boolean;
|
||||
sendPasswordSetupEmail: boolean;
|
||||
requireCgu: boolean;
|
||||
logContext?: string;
|
||||
},
|
||||
): Promise<{
|
||||
message: string;
|
||||
user_id: string;
|
||||
statut: StatutUtilisateurType;
|
||||
numero_dossier: string;
|
||||
}> {
|
||||
const logCtx = options.logContext ?? 'createAmDossier';
|
||||
|
||||
if (options.requireCgu && (!dto.acceptation_cgu || !dto.acceptation_privacy)) {
|
||||
throw new BadRequestException(
|
||||
"L'acceptation des CGU et de la politique de confidentialité est obligatoire",
|
||||
);
|
||||
@@ -668,8 +922,7 @@ export class AuthService {
|
||||
throw new BadRequestException(nirValidation.error || 'NIR invalide');
|
||||
}
|
||||
if (nirValidation.warning) {
|
||||
// Warning uniquement : on ne bloque pas (AM souvent étrangères, DOM-TOM, Corse)
|
||||
console.warn('[inscrireAMComplet] NIR warning:', nirValidation.warning, 'email=', dto.email);
|
||||
console.warn(`[${logCtx}] NIR warning:`, nirValidation.warning, 'email=', dto.email);
|
||||
}
|
||||
|
||||
const existe = await this.usersService.findByEmailOrNull(dto.email);
|
||||
@@ -720,79 +973,142 @@ export class AuthService {
|
||||
let resultat: { user: Users };
|
||||
try {
|
||||
resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
||||
const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager);
|
||||
const { numero: numeroDossier } =
|
||||
await this.numeroDossierService.getNextNumeroDossier(manager);
|
||||
|
||||
const user = manager.create(Users, {
|
||||
email: dto.email,
|
||||
prenom: dto.prenom,
|
||||
nom: dto.nom,
|
||||
role: RoleType.ASSISTANTE_MATERNELLE,
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
telephone: dto.telephone,
|
||||
adresse: dto.adresse,
|
||||
code_postal: dto.code_postal,
|
||||
ville: dto.ville,
|
||||
token_creation_mdp: tokenCreationMdp,
|
||||
token_creation_mdp_expire_le: dateExpiration,
|
||||
photo_url: urlPhoto ?? undefined,
|
||||
consentement_photo: dto.consentement_photo,
|
||||
date_consentement_photo: dateConsentementPhoto,
|
||||
date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined,
|
||||
lieu_naissance_ville: dto.lieu_naissance_ville,
|
||||
lieu_naissance_pays: dto.lieu_naissance_pays,
|
||||
numero_dossier: numeroDossier,
|
||||
const user = manager.create(Users, {
|
||||
email: dto.email,
|
||||
prenom: dto.prenom,
|
||||
nom: dto.nom,
|
||||
role: RoleType.ASSISTANTE_MATERNELLE,
|
||||
statut: options.statut,
|
||||
telephone: dto.telephone,
|
||||
adresse: dto.adresse,
|
||||
code_postal: dto.code_postal,
|
||||
ville: dto.ville,
|
||||
token_creation_mdp: tokenCreationMdp,
|
||||
token_creation_mdp_expire_le: dateExpiration,
|
||||
photo_url: urlPhoto ?? undefined,
|
||||
consentement_photo: dto.consentement_photo,
|
||||
date_consentement_photo: dateConsentementPhoto,
|
||||
date_naissance: dto.date_naissance
|
||||
? new Date(dto.date_naissance)
|
||||
: undefined,
|
||||
lieu_naissance_ville: dto.lieu_naissance_ville,
|
||||
lieu_naissance_pays: dto.lieu_naissance_pays,
|
||||
numero_dossier: numeroDossier,
|
||||
});
|
||||
const userEnregistre = await manager.save(Users, user);
|
||||
|
||||
const amRepo = manager.getRepository(AssistanteMaternelle);
|
||||
const am = amRepo.create({
|
||||
user_id: userEnregistre.id,
|
||||
approval_number: dto.numero_agrement,
|
||||
nir: nirNormalized,
|
||||
max_children: dto.capacite_accueil,
|
||||
places_available: dto.places_disponibles,
|
||||
biography: dto.biographie,
|
||||
residence_city: dto.ville ?? undefined,
|
||||
agreement_date: dto.date_agrement
|
||||
? new Date(dto.date_agrement)
|
||||
: undefined,
|
||||
available: true,
|
||||
numero_dossier: numeroDossier,
|
||||
});
|
||||
await amRepo.save(am);
|
||||
|
||||
return { user: userEnregistre };
|
||||
});
|
||||
const userEnregistre = await manager.save(Users, user);
|
||||
|
||||
const amRepo = manager.getRepository(AssistanteMaternelle);
|
||||
const am = amRepo.create({
|
||||
user_id: userEnregistre.id,
|
||||
approval_number: dto.numero_agrement,
|
||||
nir: nirNormalized,
|
||||
max_children: dto.capacite_accueil,
|
||||
places_available: dto.places_disponibles,
|
||||
biography: dto.biographie,
|
||||
residence_city: dto.ville ?? undefined,
|
||||
agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined,
|
||||
available: true,
|
||||
numero_dossier: numeroDossier,
|
||||
});
|
||||
await amRepo.save(am);
|
||||
|
||||
return { user: userEnregistre };
|
||||
});
|
||||
} catch (err) {
|
||||
if (this.isPostgresUniqueViolation(err)) {
|
||||
throw new ConflictException('Un compte avec cet email existe déjà (contrainte unique en base).');
|
||||
throw new ConflictException(
|
||||
'Un compte avec cet email existe déjà (contrainte unique en base).',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const numeroDossier = resultat.user.numero_dossier ?? '';
|
||||
|
||||
try {
|
||||
await this.mailService.sendRegistrationPendingEmail(
|
||||
resultat.user.email,
|
||||
resultat.user.prenom ?? '',
|
||||
resultat.user.nom ?? '',
|
||||
numeroDossier,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
"[inscrireAMComplet] Échec envoi email d'accusé de réception (inscription conservée)",
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
if (options.sendPendingEmail) {
|
||||
try {
|
||||
await this.mailService.sendRegistrationPendingEmail(
|
||||
resultat.user.email,
|
||||
resultat.user.prenom ?? '',
|
||||
resultat.user.nom ?? '',
|
||||
numeroDossier,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`[${logCtx}] Échec envoi email d'accusé de réception (inscription conservée)`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.sendPasswordSetupEmail) {
|
||||
try {
|
||||
await this.mailService.sendValidatedAccountPasswordSetupEmail(
|
||||
{
|
||||
email: resultat.user.email,
|
||||
prenom: resultat.user.prenom ?? '',
|
||||
nom: resultat.user.nom ?? '',
|
||||
token: tokenCreationMdp,
|
||||
numeroDossier,
|
||||
},
|
||||
'am',
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`[${logCtx}] Échec envoi email création MDP (dossier conservé)`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const message =
|
||||
options.statut === StatutUtilisateurType.ACTIF
|
||||
? 'Dossier AM créé et validé. Un e-mail de création de mot de passe a été envoyé.'
|
||||
: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.';
|
||||
|
||||
return {
|
||||
message:
|
||||
'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
|
||||
message,
|
||||
user_id: resultat.user.id,
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
statut: options.statut,
|
||||
numero_dossier: numeroDossier,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inscription Assistante Maternelle COMPLÈTE - Un seul endpoint (identité + pro + photo + CGU)
|
||||
* Crée User (role AM) + entrée assistantes_maternelles, token création MDP
|
||||
*/
|
||||
async inscrireAMComplet(dto: RegisterAMCompletDto) {
|
||||
return this.createAmDossier(dto, {
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
sendPendingEmail: true,
|
||||
sendPasswordSetupEmail: false,
|
||||
requireCgu: true,
|
||||
logContext: 'inscrireAMComplet',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Création dossier AM par staff (#156) — statut actif + e-mail création MDP.
|
||||
*/
|
||||
async createAmDossierStaff(dto: RegisterAMCompletDto) {
|
||||
return this.createAmDossier(dto, {
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
sendPendingEmail: false,
|
||||
sendPasswordSetupEmail: true,
|
||||
requireCgu: false,
|
||||
logContext: 'createAmDossierStaff',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarde une photo depuis base64 vers le système de fichiers
|
||||
*/
|
||||
/**
|
||||
* Sauvegarde une photo depuis base64 vers le système de fichiers
|
||||
*/
|
||||
@@ -1074,6 +1390,12 @@ export class AuthService {
|
||||
if (enfantDto.grossesse_multiple !== undefined) {
|
||||
enfant.is_multiple = enfantDto.grossesse_multiple;
|
||||
}
|
||||
if (enfantDto.consent_photo !== undefined) {
|
||||
enfant.consent_photo = !!enfantDto.consent_photo;
|
||||
enfant.consent_photo_at = enfant.consent_photo
|
||||
? new Date()
|
||||
: null!;
|
||||
}
|
||||
|
||||
if (enfantDto.photo_base64 && enfantDto.photo_filename) {
|
||||
enfant.photo_url = await this.sauvegarderPhotoDepuisBase64(
|
||||
|
||||
@@ -59,5 +59,14 @@ export class EnfantInscriptionDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
grossesse_multiple?: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
example: true,
|
||||
required: false,
|
||||
description: 'Consentement affichage / stockage photo (colonne enfants.consentement_photo)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
consent_photo?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
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 { RoleType } from 'src/entities/users.entity';
|
||||
import { RoleType, Users } from 'src/entities/users.entity';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
import { User } from 'src/common/decorators/user.decorator';
|
||||
import { DossiersService } from './dossiers.service';
|
||||
import { SuppressionService } from '../suppressions/suppression.service';
|
||||
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
||||
import { DossierListItemDto } from './dto/dossier-list-item.dto';
|
||||
|
||||
@ApiTags('Dossiers')
|
||||
@ApiBearerAuth('access-token')
|
||||
@Controller('dossiers')
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
export class DossiersController {
|
||||
constructor(private readonly dossiersService: DossiersService) {}
|
||||
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')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@@ -23,4 +64,21 @@ export class DossiersController {
|
||||
getDossier(@Param('numeroDossier') numeroDossier: string): Promise<DossierUnifieDto> {
|
||||
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 { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||
import { ParentsModule } from '../parents/parents.module';
|
||||
import { SuppressionsModule } from '../suppressions/suppressions.module';
|
||||
import { DossiersController } from './dossiers.controller';
|
||||
import { DossiersService } from './dossiers.service';
|
||||
|
||||
@@ -12,6 +13,7 @@ import { DossiersService } from './dossiers.service';
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Parents, AssistanteMaternelle]),
|
||||
ParentsModule,
|
||||
SuppressionsModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
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 { Parents } from 'src/entities/parents.entity';
|
||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||
import { StatutUtilisateurType, Users } from 'src/entities/users.entity';
|
||||
import { ParentsService } from '../parents/parents.service';
|
||||
import { SuppressionService } from '../suppressions/suppression.service';
|
||||
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
||||
import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto';
|
||||
import { DossierListItemDto } from './dto/dossier-list-item.dto';
|
||||
|
||||
/**
|
||||
* Endpoint unifié GET /dossiers/:numeroDossier – AM ou famille. Ticket #119.
|
||||
* Dossiers unifiés — détail (#119) + liste (#153) + sans_enfant (#159).
|
||||
*/
|
||||
@Injectable()
|
||||
export class DossiersService {
|
||||
@@ -18,8 +21,173 @@ export class DossiersService {
|
||||
@InjectRepository(AssistanteMaternelle)
|
||||
private readonly amRepository: Repository<AssistanteMaternelle>,
|
||||
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> {
|
||||
const num = numeroDossier?.trim();
|
||||
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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
@@ -6,11 +7,23 @@ import {
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { GenreType, StatutEnfantType } from 'src/entities/children.entity';
|
||||
|
||||
/** Multipart envoie des strings ("true"/"false") — JSON envoie déjà des booleans. */
|
||||
function toBoolean({ value }: { value: unknown }): boolean | unknown {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'string') {
|
||||
const v = value.trim().toLowerCase();
|
||||
if (v === 'true' || v === '1') return true;
|
||||
if (v === 'false' || v === '0' || v === '') return false;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export class CreateEnfantsDto {
|
||||
@ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE })
|
||||
@IsEnum(StatutEnfantType)
|
||||
@@ -52,6 +65,7 @@ export class CreateEnfantsDto {
|
||||
photo_url?: string;
|
||||
|
||||
@ApiProperty({ default: false })
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
consent_photo: boolean;
|
||||
|
||||
@@ -61,6 +75,20 @@ export class CreateEnfantsDto {
|
||||
consent_photo_at?: string;
|
||||
|
||||
@ApiProperty({ default: false })
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
is_multiple: boolean;
|
||||
|
||||
/**
|
||||
* Parent pivot du foyer — obligatoire pour staff (gestionnaire/admin).
|
||||
* Ignoré / interdit en externe pour un PARENT (ticket #132).
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'UUID du parent pivot (staff only). Obligatoire pour GESTIONNAIRE / ADMIN / SUPER_ADMIN.',
|
||||
format: 'uuid',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
parent_user_id?: string;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,35 @@
|
||||
import {
|
||||
Body,
|
||||
CallHandler,
|
||||
Controller,
|
||||
Delete,
|
||||
ExecutionContext,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBearerAuth, ApiTags, ApiConsumes } from '@nestjs/swagger';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { diskStorage } from 'multer';
|
||||
import { extname } from 'path';
|
||||
import { Observable } from 'rxjs';
|
||||
import { EnfantsService } from './enfants.service';
|
||||
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
||||
import { UpdateEnfantsDto } from './dto/update_enfants.dto';
|
||||
@@ -23,38 +38,80 @@ import { User } from 'src/common/decorators/user.decorator';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
import { SuppressionService } from '../suppressions/suppression.service';
|
||||
|
||||
const photoMulterOptions = {
|
||||
storage: diskStorage({
|
||||
destination: './uploads/photos',
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const ext = extname(file.originalname);
|
||||
cb(null, `enfant-${uniqueSuffix}${ext}`);
|
||||
},
|
||||
}),
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) {
|
||||
return cb(new Error('Seules les images sont autorisées'), false);
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Multer uniquement si Content-Type multipart (parent ou staff + photo).
|
||||
* JSON sans photo (#132) passe sans interceptor fichier.
|
||||
*/
|
||||
@Injectable()
|
||||
class OptionalEnfantPhotoInterceptor implements NestInterceptor {
|
||||
private readonly multipart = new (FileInterceptor(
|
||||
'photo',
|
||||
photoMulterOptions,
|
||||
))();
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> | Promise<Observable<unknown>> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const ct = String(req.headers['content-type'] ?? '');
|
||||
if (!ct.includes('multipart/form-data')) {
|
||||
return next.handle();
|
||||
}
|
||||
return this.multipart.intercept(context, next);
|
||||
}
|
||||
}
|
||||
|
||||
@ApiBearerAuth('access-token')
|
||||
@ApiTags('Enfants')
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
@Controller('enfants')
|
||||
export class EnfantsController {
|
||||
constructor(private readonly enfantsService: EnfantsService) { }
|
||||
constructor(
|
||||
private readonly enfantsService: EnfantsService,
|
||||
private readonly suppressionService: SuppressionService,
|
||||
) { }
|
||||
|
||||
@Roles(RoleType.PARENT)
|
||||
@Post()
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('photo', {
|
||||
storage: diskStorage({
|
||||
destination: './uploads/photos',
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const ext = extname(file.originalname);
|
||||
cb(null, `enfant-${uniqueSuffix}${ext}`);
|
||||
},
|
||||
}),
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) {
|
||||
return cb(new Error('Seules les images sont autorisées'), false);
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024,
|
||||
},
|
||||
}),
|
||||
@Roles(
|
||||
RoleType.PARENT,
|
||||
RoleType.GESTIONNAIRE,
|
||||
RoleType.ADMINISTRATEUR,
|
||||
RoleType.SUPER_ADMIN,
|
||||
)
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: 'Créer un enfant',
|
||||
description:
|
||||
'PARENT : multipart éventuel, rattache au compte connecté. ' +
|
||||
'Staff : parent_user_id obligatoire ; JSON sans photo OK ; avec photo → multipart (champ fichier `photo`, max 5 Mo). Ticket #132.',
|
||||
})
|
||||
@ApiConsumes('application/json', 'multipart/form-data')
|
||||
@ApiBody({
|
||||
description:
|
||||
'Champs métier (+ parent_user_id côté staff). Fichier optionnel `photo` en multipart.',
|
||||
type: CreateEnfantsDto,
|
||||
})
|
||||
@UseInterceptors(OptionalEnfantPhotoInterceptor)
|
||||
create(
|
||||
@Body() dto: CreateEnfantsDto,
|
||||
@UploadedFile() photo: Express.Multer.File,
|
||||
@@ -90,17 +147,47 @@ export class EnfantsController {
|
||||
RoleType.GESTIONNAIRE,
|
||||
)
|
||||
@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(
|
||||
@Param('id', new ParseUUIDPipe()) id: string,
|
||||
@Body() dto: UpdateEnfantsDto,
|
||||
@UploadedFile() photo: Express.Multer.File,
|
||||
@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')
|
||||
remove(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||
return this.enfantsService.remove(id);
|
||||
@ApiOperation({
|
||||
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 { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { SuppressionsModule } from '../suppressions/suppressions.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Children, Parents, ParentsChildren]),
|
||||
AuthModule
|
||||
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Children, Parents, ParentsChildren]),
|
||||
AuthModule,
|
||||
SuppressionsModule,
|
||||
],
|
||||
controllers: [EnfantsController],
|
||||
providers: [EnfantsService]
|
||||
providers: [EnfantsService],
|
||||
exports: [EnfantsService],
|
||||
})
|
||||
export class EnfantsModule { }
|
||||
|
||||
@@ -13,6 +13,12 @@ import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||
import { RoleType, Users } from 'src/entities/users.entity';
|
||||
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
||||
|
||||
const STAFF_ROLES: RoleType[] = [
|
||||
RoleType.GESTIONNAIRE,
|
||||
RoleType.ADMINISTRATEUR,
|
||||
RoleType.SUPER_ADMIN,
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class EnfantsService {
|
||||
constructor(
|
||||
@@ -24,20 +30,35 @@ export class EnfantsService {
|
||||
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
||||
) { }
|
||||
|
||||
// Création d'un enfant
|
||||
async create(dto: CreateEnfantsDto, currentUser: Users, photoFile?: Express.Multer.File): Promise<Children> {
|
||||
private isStaff(user: Users): boolean {
|
||||
return STAFF_ROLES.includes(user.role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Création d'un enfant.
|
||||
* - PARENT : rattache au parent connecté (multipart photo optionnel).
|
||||
* - Staff : `parent_user_id` obligatoire ; JSON sans photo OK ;
|
||||
* avec photo → multipart (même stockage `/uploads/photos/...`). Ticket #132.
|
||||
*/
|
||||
async create(
|
||||
dto: CreateEnfantsDto,
|
||||
currentUser: Users,
|
||||
photoFile?: Express.Multer.File,
|
||||
): Promise<Children> {
|
||||
const pivotUserId = this.resolvePivotParentUserId(dto, currentUser);
|
||||
|
||||
const parent = await this.parentsRepository.findOne({
|
||||
where: { user_id: currentUser.id },
|
||||
where: { user_id: pivotUserId },
|
||||
relations: ['co_parent'],
|
||||
});
|
||||
if (!parent) throw new NotFoundException('Parent introuvable');
|
||||
|
||||
// Vérif métier simple
|
||||
// Vérif métier simple (aligné comportement historique parent)
|
||||
if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) {
|
||||
throw new BadRequestException('Un enfant né doit avoir une date de naissance');
|
||||
}
|
||||
|
||||
// Vérif doublon éventuel (ex: même prénom + date de naissance pour ce parent)
|
||||
// Vérif doublon éventuel (ex: même prénom + date de naissance)
|
||||
const exist = await this.childrenRepository.findOne({
|
||||
where: {
|
||||
first_name: dto.first_name,
|
||||
@@ -47,50 +68,108 @@ export class EnfantsService {
|
||||
});
|
||||
if (exist) throw new ConflictException('Cet enfant existe déjà');
|
||||
|
||||
// Gestion de la photo uploadée
|
||||
// Gestion de la photo uploadée (multipart parent ou staff)
|
||||
let photoUrl = dto.photo_url;
|
||||
let consentAt: Date | undefined;
|
||||
if (photoFile) {
|
||||
dto.photo_url = `/uploads/photos/${photoFile.filename}`;
|
||||
photoUrl = `/uploads/photos/${photoFile.filename}`;
|
||||
if (dto.consent_photo) {
|
||||
dto.consent_photo_at = new Date().toISOString();
|
||||
consentAt = new Date();
|
||||
}
|
||||
} else if (dto.consent_photo) {
|
||||
consentAt = dto.consent_photo_at
|
||||
? new Date(dto.consent_photo_at)
|
||||
: new Date();
|
||||
}
|
||||
|
||||
// Création
|
||||
const child = this.childrenRepository.create(dto);
|
||||
const child = this.childrenRepository.create({
|
||||
status: dto.status,
|
||||
first_name: dto.first_name,
|
||||
last_name: dto.last_name,
|
||||
gender: dto.gender,
|
||||
birth_date: dto.birth_date ? new Date(dto.birth_date) : undefined,
|
||||
due_date: dto.due_date ? new Date(dto.due_date) : undefined,
|
||||
photo_url: photoUrl,
|
||||
consent_photo: !!dto.consent_photo,
|
||||
consent_photo_at: consentAt,
|
||||
is_multiple: !!dto.is_multiple,
|
||||
});
|
||||
await this.childrenRepository.save(child);
|
||||
|
||||
// Lien parent-enfant (Parent 1)
|
||||
const parentLink = this.parentsChildrenRepository.create({
|
||||
parentId: parent.user_id,
|
||||
enfantId: child.id,
|
||||
});
|
||||
await this.parentsChildrenRepository.save(parentLink);
|
||||
// Lien parent-enfant (pivot)
|
||||
await this.parentsChildrenRepository.save(
|
||||
this.parentsChildrenRepository.create({
|
||||
parentId: parent.user_id,
|
||||
enfantId: child.id,
|
||||
}),
|
||||
);
|
||||
|
||||
// Rattachement automatique au co-parent s'il existe
|
||||
if (parent.co_parent) {
|
||||
const coParentLink = this.parentsChildrenRepository.create({
|
||||
parentId: parent.co_parent.id,
|
||||
enfantId: child.id,
|
||||
});
|
||||
await this.parentsChildrenRepository.save(coParentLink);
|
||||
await this.parentsChildrenRepository.save(
|
||||
this.parentsChildrenRepository.create({
|
||||
parentId: parent.co_parent.id,
|
||||
enfantId: child.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return this.findOne(child.id, currentUser);
|
||||
}
|
||||
|
||||
// Liste des enfants (admin/gestionnaire)
|
||||
async findAll(): Promise<Children[]> {
|
||||
return this.childrenRepository.find({
|
||||
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
||||
order: { last_name: 'ASC', first_name: 'ASC' },
|
||||
private resolvePivotParentUserId(
|
||||
dto: CreateEnfantsDto,
|
||||
currentUser: Users,
|
||||
): string {
|
||||
if (this.isStaff(currentUser)) {
|
||||
const id = dto.parent_user_id?.trim();
|
||||
if (!id) {
|
||||
throw new BadRequestException(
|
||||
'parent_user_id est obligatoire pour créer un enfant (staff)',
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
if (currentUser.role === RoleType.PARENT) {
|
||||
if (
|
||||
dto.parent_user_id &&
|
||||
dto.parent_user_id.trim() !== currentUser.id
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
'Un parent ne peut pas créer un enfant pour un autre compte',
|
||||
);
|
||||
}
|
||||
return currentUser.id;
|
||||
}
|
||||
|
||||
throw new ForbiddenException('Accès interdit');
|
||||
}
|
||||
|
||||
/** Flag API #157 — true si aucun lien enfants_parents. */
|
||||
private withSansResponsable(child: Children): Children & { sans_responsable: boolean } {
|
||||
return Object.assign(child, {
|
||||
sans_responsable: !child.parentLinks || child.parentLinks.length === 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Liste des enfants (admin/gestionnaire) — inclut les orphelins (parentLinks: [])
|
||||
async findAll(): Promise<Array<Children & { sans_responsable: boolean }>> {
|
||||
const children = await this.childrenRepository.find({
|
||||
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
||||
order: { last_name: 'ASC', first_name: 'ASC' },
|
||||
});
|
||||
return children.map((c) => this.withSansResponsable(c));
|
||||
}
|
||||
|
||||
// Récupérer un enfant par id
|
||||
async findOne(id: string, currentUser: Users): Promise<Children> {
|
||||
async findOne(
|
||||
id: string,
|
||||
currentUser: Users,
|
||||
): Promise<Children & { sans_responsable: boolean }> {
|
||||
const child = await this.childrenRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['parentLinks'],
|
||||
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
||||
});
|
||||
if (!child) throw new NotFoundException('Enfant introuvable');
|
||||
|
||||
@@ -104,23 +183,42 @@ export class EnfantsService {
|
||||
case RoleType.ADMINISTRATEUR:
|
||||
case RoleType.SUPER_ADMIN:
|
||||
case RoleType.GESTIONNAIRE:
|
||||
// accès complet
|
||||
// accès complet (y compris orphelins)
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ForbiddenException('Accès interdit');
|
||||
}
|
||||
|
||||
return child;
|
||||
return this.withSansResponsable(child);
|
||||
}
|
||||
|
||||
|
||||
// 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 } });
|
||||
if (!child) throw new NotFoundException('Enfant introuvable');
|
||||
|
||||
await this.childrenRepository.update(id, dto);
|
||||
const { parent_user_id: _ignored, ...rest } = dto;
|
||||
const patch: Partial<Children> = { ...rest } as Partial<Children>;
|
||||
if (dto.consent_photo !== undefined) {
|
||||
patch.consent_photo = dto.consent_photo;
|
||||
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);
|
||||
return this.findOne(id, currentUser);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@ export class ParentPendingSummaryDto {
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
nom?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
prenom?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
telephone?: string | null;
|
||||
|
||||
@@ -18,7 +24,10 @@ export class ParentPendingSummaryDto {
|
||||
}
|
||||
|
||||
export class PendingFamilyDto {
|
||||
@ApiProperty({ example: 'Famille Dupont', description: 'Libellé affiché pour la famille' })
|
||||
@ApiProperty({
|
||||
example: 'MARTIN Claire - MARTIN Thomas',
|
||||
description: 'Libellé affiché : NOM Prénom (séparés par « - » si co-parent)',
|
||||
})
|
||||
libelle: string;
|
||||
|
||||
@ApiProperty({
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
|
||||
/** Réponse 201 POST /parents/dossier (#129). */
|
||||
export class StaffCreateParentDossierResponseDto {
|
||||
@ApiProperty()
|
||||
message: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: '2026-000043',
|
||||
description: 'Numéro de dossier famille attribué',
|
||||
})
|
||||
numero_dossier: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid', description: 'UUID user du parent pivot' })
|
||||
parent_user_id: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
nullable: true,
|
||||
description: 'UUID user du co-parent, ou null',
|
||||
})
|
||||
co_parent_user_id: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
enum: StatutUtilisateurType,
|
||||
example: StatutUtilisateurType.ACTIF,
|
||||
})
|
||||
statut: StatutUtilisateurType;
|
||||
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description: 'IDs des enfants créés',
|
||||
})
|
||||
enfant_ids: string[];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ApiPropertyOptional, OmitType } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
import { RegisterParentCompletDto } from 'src/routes/auth/dto/register-parent-complet.dto';
|
||||
|
||||
/**
|
||||
* Création dossier parent/famille par staff (#129).
|
||||
* Mêmes champs que l'inscription publique, sans CGU/privacy obligatoires
|
||||
* (acceptées côté serveur pour le compte du gestionnaire).
|
||||
*/
|
||||
export class StaffCreateParentDossierDto extends OmitType(RegisterParentCompletDto, [
|
||||
'acceptation_cgu',
|
||||
'acceptation_privacy',
|
||||
] as const) {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Ignoré côté staff (CGU acceptées serveur). Conservé pour compat éventuelle.',
|
||||
default: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
acceptation_cgu?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Ignoré côté staff (privacy acceptée serveur).',
|
||||
default: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
acceptation_privacy?: boolean;
|
||||
}
|
||||
@@ -1,18 +1,104 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ParentsController } from './parents.controller';
|
||||
import { ParentsService } from './parents.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
|
||||
describe('ParentsController', () => {
|
||||
let controller: ParentsController;
|
||||
const authServiceMock = {
|
||||
createParentDossierStaff: jest.fn(),
|
||||
addCoParentStaff: jest.fn(),
|
||||
};
|
||||
const parentsServiceMock = {};
|
||||
const userServiceMock = {};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [ParentsController],
|
||||
}).compile();
|
||||
providers: [
|
||||
{ provide: ParentsService, useValue: parentsServiceMock },
|
||||
{ provide: UserService, useValue: userServiceMock },
|
||||
{ provide: AuthService, useValue: authServiceMock },
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.overrideGuard(RolesGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<ParentsController>(ParentsController);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
it('createDossier delegates to authService.createParentDossierStaff with CGU accepted', async () => {
|
||||
authServiceMock.createParentDossierStaff.mockResolvedValue({
|
||||
message: 'Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.',
|
||||
parent_user_id: 'p1',
|
||||
co_parent_user_id: 'p2',
|
||||
enfant_ids: ['e1'],
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
numero_dossier: '2026-000043',
|
||||
});
|
||||
|
||||
const body = {
|
||||
email: 'parent.staff@test.fr',
|
||||
prenom: 'Claire',
|
||||
nom: 'MARTIN',
|
||||
telephone: '0689567890',
|
||||
enfants: [
|
||||
{
|
||||
prenom: 'Emma',
|
||||
nom: 'MARTIN',
|
||||
date_naissance: '2023-02-15',
|
||||
genre: 'F',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const res = await controller.createDossier(body as any);
|
||||
expect(authServiceMock.createParentDossierStaff).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email: body.email,
|
||||
acceptation_cgu: true,
|
||||
acceptation_privacy: true,
|
||||
}),
|
||||
);
|
||||
expect(res.numero_dossier).toBe('2026-000043');
|
||||
expect(res.parent_user_id).toBe('p1');
|
||||
expect(res.co_parent_user_id).toBe('p2');
|
||||
expect(res.enfant_ids).toEqual(['e1']);
|
||||
expect(res.statut).toBe(StatutUtilisateurType.ACTIF);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
@@ -10,14 +12,27 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ParentsService } from './parents.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { Users } from 'src/entities/users.entity';
|
||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||
import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { CreateParentDto } from '../user/dto/create_parent.dto';
|
||||
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
|
||||
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
|
||||
import { StaffCreateParentDossierDto } from './dto/staff-create-parent-dossier.dto';
|
||||
import { StaffCreateParentDossierResponseDto } from './dto/staff-create-parent-dossier-response.dto';
|
||||
import { 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 { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
import { User } from 'src/common/decorators/user.decorator';
|
||||
@@ -26,14 +41,50 @@ import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto';
|
||||
import { mapParentForApi, mapParentsForApi } from './parents.mapper';
|
||||
|
||||
@ApiTags('Parents')
|
||||
@ApiBearerAuth('access-token')
|
||||
@Controller('parents')
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
export class ParentsController {
|
||||
constructor(
|
||||
private readonly parentsService: ParentsService,
|
||||
private readonly userService: UserService,
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@Post('dossier')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: 'Créer un dossier famille/parent complet (staff) — ticket #129',
|
||||
description:
|
||||
'Crée parent (+ co-parent optionnel) + enfants + n° dossier avec statut actif, ' +
|
||||
'et envoie l’e-mail de création de mot de passe. ' +
|
||||
'Ne pas utiliser POST /auth/register/parent depuis le dashboard.',
|
||||
})
|
||||
@ApiBody({ type: StaffCreateParentDossierDto })
|
||||
@ApiResponse({ status: 201, type: StaffCreateParentDossierResponseDto })
|
||||
@ApiResponse({ status: 400, description: 'Validation DTO / métier' })
|
||||
@ApiResponse({ status: 403, description: 'Rôle non autorisé' })
|
||||
@ApiResponse({ status: 409, description: 'Email pivot et/ou co-parent déjà pris' })
|
||||
async createDossier(
|
||||
@Body() dto: StaffCreateParentDossierDto,
|
||||
): Promise<StaffCreateParentDossierResponseDto> {
|
||||
const registerDto = {
|
||||
...dto,
|
||||
acceptation_cgu: true,
|
||||
acceptation_privacy: true,
|
||||
} as RegisterParentCompletDto;
|
||||
const result = await this.authService.createParentDossierStaff(registerDto);
|
||||
return {
|
||||
message: result.message,
|
||||
numero_dossier: result.numero_dossier,
|
||||
parent_user_id: result.parent_user_id,
|
||||
co_parent_user_id: result.co_parent_user_id ?? null,
|
||||
statut: result.statut,
|
||||
enfant_ids: result.enfant_ids,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('pending-families')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' })
|
||||
@@ -127,6 +178,28 @@ export class ParentsController {
|
||||
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)
|
||||
@Post(':id/enfants/:enfantId')
|
||||
@ApiOperation({ summary: 'Rattacher un enfant à un parent — ticket #115' })
|
||||
|
||||
@@ -9,11 +9,13 @@ import { ParentsController } from './parents.controller';
|
||||
import { ParentsService } from './parents.service';
|
||||
import { Users } from 'src/entities/users.entity';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant, ParentsChildren]),
|
||||
forwardRef(() => UserModule),
|
||||
forwardRef(() => AuthModule),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
|
||||
@@ -114,34 +114,86 @@ export class ParentsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rattacher un enfant existant à un parent (enfants_parents). Ticket #115 / doc 28 §6.2.
|
||||
* Membres du foyer (user ids) pour affiliation enfant.
|
||||
* Pivot + co-parent (A→B et B→A) + même numero_dossier. Ticket #158.
|
||||
*/
|
||||
async attachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
||||
await this.findOne(parentUserId);
|
||||
private async resolveFoyerParentUserIds(parent: Parents): Promise<string[]> {
|
||||
const ids = new Set<string>([parent.user_id]);
|
||||
|
||||
const existing = await this.parentsChildrenRepository.findOne({
|
||||
where: { parentId: parentUserId, enfantId },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('Cet enfant est déjà rattaché à ce parent');
|
||||
if (parent.co_parent?.id) {
|
||||
ids.add(parent.co_parent.id);
|
||||
}
|
||||
|
||||
const child = await this.parentsRepository.manager.findOne(Children, { where: { id: enfantId } });
|
||||
// Sens inverse : parents qui déclarent ce user comme co-parent
|
||||
const reverseLinks = await this.parentsRepository.find({
|
||||
where: { co_parent: { id: parent.user_id } },
|
||||
relations: ['co_parent'],
|
||||
});
|
||||
for (const p of reverseLinks) {
|
||||
ids.add(p.user_id);
|
||||
if (p.co_parent?.id) ids.add(p.co_parent.id);
|
||||
}
|
||||
|
||||
const dossier = parent.numero_dossier?.trim();
|
||||
if (dossier) {
|
||||
const sameDossier = await this.parentsRepository.find({
|
||||
where: { numero_dossier: dossier },
|
||||
relations: ['co_parent'],
|
||||
});
|
||||
for (const p of sameDossier) {
|
||||
ids.add(p.user_id);
|
||||
if (p.co_parent?.id) ids.add(p.co_parent.id);
|
||||
}
|
||||
}
|
||||
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rattacher un enfant au foyer du parent (tous les responsables). Ticket #158.
|
||||
* Un seul POST suffit : liens créés pour pivot + co-parent / même dossier.
|
||||
*/
|
||||
async attachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
||||
const parent = await this.findOne(parentUserId);
|
||||
|
||||
const child = await this.parentsRepository.manager.findOne(Children, {
|
||||
where: { id: enfantId },
|
||||
});
|
||||
if (!child) {
|
||||
throw new NotFoundException('Enfant introuvable');
|
||||
}
|
||||
|
||||
await this.parentsChildrenRepository.save(
|
||||
this.parentsChildrenRepository.create({ parentId: parentUserId, enfantId }),
|
||||
);
|
||||
const foyerIds = await this.resolveFoyerParentUserIds(parent);
|
||||
let created = 0;
|
||||
|
||||
for (const memberId of foyerIds) {
|
||||
const existing = await this.parentsChildrenRepository.findOne({
|
||||
where: { parentId: memberId, enfantId },
|
||||
});
|
||||
if (existing) continue;
|
||||
|
||||
await this.parentsChildrenRepository.save(
|
||||
this.parentsChildrenRepository.create({
|
||||
parentId: memberId,
|
||||
enfantId,
|
||||
}),
|
||||
);
|
||||
created += 1;
|
||||
}
|
||||
|
||||
if (created === 0) {
|
||||
throw new ConflictException('Cet enfant est déjà rattaché à ce foyer');
|
||||
}
|
||||
|
||||
return this.findOne(parentUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Détacher un enfant d'un parent sans supprimer l'enfant. Ticket #115 / doc 28 §6.2.
|
||||
* Détacher un enfant du foyer du parent (tous les responsables). Ticket #158.
|
||||
* Si plus aucun lien ensuite → enfant orphelin (#157).
|
||||
*/
|
||||
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
||||
await this.findOne(parentUserId);
|
||||
const parent = await this.findOne(parentUserId);
|
||||
|
||||
const link = await this.parentsChildrenRepository.findOne({
|
||||
where: { parentId: parentUserId, enfantId },
|
||||
@@ -150,12 +202,12 @@ export class ParentsService {
|
||||
throw new NotFoundException('Lien parent-enfant introuvable');
|
||||
}
|
||||
|
||||
const totalLinks = await this.parentsChildrenRepository.count({ where: { enfantId } });
|
||||
if (totalLinks <= 1) {
|
||||
throw new BadRequestException('Un enfant doit rester rattaché à au moins un responsable');
|
||||
}
|
||||
const foyerIds = await this.resolveFoyerParentUserIds(parent);
|
||||
await this.parentsChildrenRepository.delete({
|
||||
parentId: In(foyerIds),
|
||||
enfantId,
|
||||
});
|
||||
|
||||
await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId });
|
||||
return this.findOne(parentUserId);
|
||||
}
|
||||
|
||||
@@ -199,7 +251,15 @@ export class ParentsService {
|
||||
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
|
||||
)
|
||||
SELECT
|
||||
'Famille ' || string_agg(u.nom, ' - ' ORDER BY u.nom, u.prenom) AS libelle,
|
||||
string_agg(
|
||||
UPPER(TRIM(u.nom))
|
||||
|| CASE
|
||||
WHEN u.prenom IS NOT NULL AND TRIM(u.prenom) <> ''
|
||||
THEN ' ' || INITCAP(TRIM(u.prenom))
|
||||
ELSE ''
|
||||
END,
|
||||
' - ' ORDER BY u.nom, u.prenom, u.id
|
||||
) AS libelle,
|
||||
array_agg(p.id_utilisateur ORDER BY u.nom, u.prenom, u.id) AS "parentIds",
|
||||
(array_agg(p.numero_dossier))[1] AS numero_dossier,
|
||||
MIN(u.cree_le) AS date_soumission,
|
||||
@@ -215,6 +275,8 @@ export class ParentsService {
|
||||
json_build_object(
|
||||
'id', u.id::text,
|
||||
'email', u.email,
|
||||
'nom', u.nom,
|
||||
'prenom', u.prenom,
|
||||
'telephone', u.telephone,
|
||||
'code_postal', u.code_postal,
|
||||
'ville', u.ville
|
||||
@@ -265,11 +327,21 @@ export class ParentsService {
|
||||
return [];
|
||||
}
|
||||
|
||||
private normalizeParents(parents: unknown): { id: string; email: string; telephone: string | null; code_postal: string | null; ville: string | null }[] {
|
||||
private normalizeParents(parents: unknown): {
|
||||
id: string;
|
||||
email: string;
|
||||
nom: string | null;
|
||||
prenom: string | null;
|
||||
telephone: string | null;
|
||||
code_postal: string | null;
|
||||
ville: string | null;
|
||||
}[] {
|
||||
if (Array.isArray(parents)) {
|
||||
return parents.map((p: any) => ({
|
||||
id: String(p?.id ?? ''),
|
||||
email: String(p?.email ?? ''),
|
||||
nom: p?.nom != null ? String(p.nom) : null,
|
||||
prenom: p?.prenom != null ? String(p.prenom) : null,
|
||||
telephone: p?.telephone != null ? String(p.telephone) : null,
|
||||
code_postal: p?.code_postal != null ? String(p.code_postal) : null,
|
||||
ville: p?.ville != null ? String(p.ville) : null,
|
||||
|
||||
@@ -24,15 +24,19 @@ export class RelaisController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||
@ApiOperation({ summary: 'Lister tous les relais' })
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({
|
||||
summary: 'Lister tous les relais',
|
||||
description:
|
||||
'Lecture ouverte aux gestionnaires (combobox fiches). CRUD write reste admin-only. Ticket #151.',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Liste des relais.' })
|
||||
findAll() {
|
||||
return this.relaisService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({ summary: 'Récupérer un relais par ID' })
|
||||
@ApiResponse({ status: 200, description: 'Le relais trouvé.' })
|
||||
findOne(@Param('id') id: string) {
|
||||
|
||||
@@ -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 {}
|
||||
@@ -10,13 +10,17 @@ import { CreateUserDto } from './dto/create_user.dto';
|
||||
import { CreateAdminDto } from './dto/create_admin.dto';
|
||||
import { UpdateUserDto } from './dto/update_user.dto';
|
||||
import { AffecterNumeroDossierDto } from './dto/affecter-numero-dossier.dto';
|
||||
import { SuppressionService } from '../suppressions/suppression.service';
|
||||
|
||||
@ApiTags('Utilisateurs')
|
||||
@ApiBearerAuth('access-token')
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
@Controller('users')
|
||||
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)
|
||||
@Post('admin')
|
||||
@@ -146,12 +150,20 @@ export class UserController {
|
||||
return this.userService.suspendUser(id, currentUser, comment);
|
||||
}
|
||||
|
||||
// Supprimer un utilisateur (super_admin uniquement)
|
||||
// Supprimer un utilisateur — cascades métier #159
|
||||
@Delete(':id')
|
||||
@Roles(RoleType.SUPER_ADMIN)
|
||||
@ApiOperation({ summary: 'Supprimer un utilisateur' })
|
||||
@Roles(
|
||||
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" })
|
||||
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 { MailModule } from 'src/modules/mail/mail.module';
|
||||
import { AppConfigModule } from 'src/modules/config/config.module';
|
||||
import { SuppressionsModule } from '../suppressions/suppressions.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature(
|
||||
@@ -26,6 +27,7 @@ import { AppConfigModule } from 'src/modules/config/config.module';
|
||||
GestionnairesModule,
|
||||
MailModule,
|
||||
AppConfigModule,
|
||||
SuppressionsModule,
|
||||
],
|
||||
controllers: [UserController],
|
||||
providers: [UserService],
|
||||
|
||||
@@ -520,6 +520,7 @@ export class UserService {
|
||||
}
|
||||
|
||||
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) {
|
||||
throw new ForbiddenException('Accès réservé aux super admins');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# Analyse d’empreinte — P'titsPas
|
||||
|
||||
**Date :** 2026-07-22
|
||||
**Contexte :** snapshot pour mémoire (après alerte disque plein + purge cache Gitea)
|
||||
**Périmètre :** application `jmartin/petitspas` déployée sur le VPS (`/home/deploy/dev/ptitspas-app`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Lignes de code
|
||||
|
||||
Comptage brut (`wc -l`), hors `node_modules` / builds / `.dart_tool`.
|
||||
|
||||
| Zone | Lignes | Fichiers |
|
||||
|------|--------|----------|
|
||||
| **Frontend** (Dart `frontend/lib/`) | ~29 800 | 141 |
|
||||
| **Backend** (TypeScript `backend/src/`) | ~10 000 | 141 |
|
||||
| **BDD** (SQL sous `database/`) | ~1 250 | ~15 |
|
||||
| **Total** | **~41 000** | |
|
||||
|
||||
### Frontend (détail)
|
||||
|
||||
| Dossier | Lignes |
|
||||
|---------|--------|
|
||||
| `widgets/` | ~18 400 |
|
||||
| `screens/` | ~5 000 |
|
||||
| `services/` | ~2 300 |
|
||||
| `models/` | ~2 200 |
|
||||
| `utils/` | ~1 400 |
|
||||
| reste | ~500 |
|
||||
|
||||
### Backend (détail)
|
||||
|
||||
| Dossier | Lignes |
|
||||
|---------|--------|
|
||||
| `routes/` | ~6 500 |
|
||||
| `modules/` | ~1 700 |
|
||||
| `entities/` | ~1 000 |
|
||||
| `common/` + `config/` | ~400 |
|
||||
|
||||
### BDD (détail)
|
||||
|
||||
| Élément | Lignes |
|
||||
|---------|--------|
|
||||
| `BDD.sql` (schéma) | ~470 |
|
||||
| seeds | ~300 |
|
||||
| migrations / patches | ~280 |
|
||||
| tests SQL | ~205 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Empreinte disque — runtime (Docker)
|
||||
|
||||
| Composant | Taille | Notes |
|
||||
|-----------|--------|--------|
|
||||
| Image `ptitspas-app-backend` | ~300 Mo | NestJS |
|
||||
| Image `ptitspas-app-frontend` | ~122 Mo | Flutter web + Nginx |
|
||||
| Image `postgres:17` | ~454 Mo | |
|
||||
| Image `dpage/pgadmin4` | ~534 Mo | optionnel |
|
||||
| Volume `postgres_data` | ~49 Mo | données BDD live |
|
||||
| Volume `backend_uploads` | ~20 Mo | photos |
|
||||
| Volume `backend_documents_legaux` | ~0 | |
|
||||
| **Stack complète (avec pgAdmin)** | **~1,5 Go** | |
|
||||
| **Stack prod sans pgAdmin** | **~945 Mo** | |
|
||||
|
||||
---
|
||||
|
||||
## 3. Empreinte disque — code source
|
||||
|
||||
| Élément | Taille |
|
||||
|---------|--------|
|
||||
| Clone `/home/deploy/dev/ptitspas-app` | ~701 Mo |
|
||||
| dont `backend/node_modules` | ~354 Mo |
|
||||
| dont `frontend` (+ `.dart_tool`) | ~114 Mo |
|
||||
| **Code utile** (hors deps / `.git` / builds) | **~51 Mo** |
|
||||
| Repo Gitea `petitspas.git` | ~109 Mo |
|
||||
| Dump `database/BDD.sql` | ~19 Ko |
|
||||
|
||||
---
|
||||
|
||||
## 4. Pour (re)déployer
|
||||
|
||||
Minimum requis :
|
||||
|
||||
1. Images custom backend + frontend (~422 Mo), ou code + build Docker
|
||||
2. Image `postgres:17` (~454 Mo)
|
||||
3. Volumes persistants (~70 Mo au snapshot)
|
||||
4. Optionnel : pgAdmin (~534 Mo)
|
||||
|
||||
**Ordre de grandeur :** ~1 Go pour faire tourner l’app en prod (sans pgAdmin).
|
||||
|
||||
---
|
||||
|
||||
## 5. Notes infra du jour (lié)
|
||||
|
||||
- Disque VPS passé de **100 %** à ~**44 %** après :
|
||||
- purge cache Docker / journals
|
||||
- purge officielle Gitea `delete_repo_archives` (~24 Go de zip/bundle `petitspas`)
|
||||
- Crons Gitea activés :
|
||||
- `archive_cleanup` @midnight (`OLDER_THAN = 24h`)
|
||||
- `delete_repo_archives` @weekly
|
||||
|
||||
---
|
||||
|
||||
*Fichier généré pour historique projet — ne pas considérer comme métrique CI automatisée.*
|
||||
@@ -0,0 +1,89 @@
|
||||
# Mini-spec API — POST /parents/dossier (#129)
|
||||
|
||||
Contrat pour le **plan front** (wizard création dossier famille staff).
|
||||
|
||||
Miroir de **#156** (`POST /assistantes-maternelles/dossier`).
|
||||
|
||||
## Endpoint
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **Méthode** | `POST` |
|
||||
| **URL** | `{base}/parents/dossier` |
|
||||
| **Auth** | Bearer JWT |
|
||||
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
|
||||
| **Content-Type** | `application/json` |
|
||||
|
||||
Ne **pas** appeler `POST /auth/register/parent` depuis le dashboard.
|
||||
|
||||
## Body (JSON)
|
||||
|
||||
Aligné `RegisterParentCompletDto`, **sans** CGU/privacy obligatoires (acceptées serveur).
|
||||
|
||||
### Parent 1 (obligatoire)
|
||||
|
||||
| Champ | Type | Obligatoire | Notes |
|
||||
|-------|------|-------------|--------|
|
||||
| `email` | string | oui | unique |
|
||||
| `prenom` | string | oui | |
|
||||
| `nom` | string | oui | |
|
||||
| `telephone` | string | oui | `0X…` ou `+33…` |
|
||||
| `adresse` | string | non | |
|
||||
| `code_postal` | string | non | |
|
||||
| `ville` | string | non | |
|
||||
|
||||
### Co-parent (optionnel)
|
||||
|
||||
`co_parent_email`, `co_parent_prenom`, `co_parent_nom`, `co_parent_telephone`,
|
||||
`co_parent_meme_adresse`, `co_parent_adresse`, `co_parent_code_postal`, `co_parent_ville`.
|
||||
|
||||
Si co-parent fourni : e-mail distinct ; mêmes règles téléphone / adresse que register.
|
||||
|
||||
### Enfants (≥ 1)
|
||||
|
||||
| Champ | Type | Notes |
|
||||
|-------|------|--------|
|
||||
| `enfants` | `EnfantInscriptionDto[]` | `prenom`, `nom`, `date_naissance` / `date_previsionnelle_naissance`, `genre`, `photo_base64`, `photo_filename`, etc. |
|
||||
|
||||
### Présentation
|
||||
|
||||
| Champ | Type | Obligatoire |
|
||||
|-------|------|-------------|
|
||||
| `presentation_dossier` | string | non (max 2000) |
|
||||
|
||||
## Réponses
|
||||
|
||||
### 201 Created
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.",
|
||||
"numero_dossier": "2026-000043",
|
||||
"parent_user_id": "uuid-pivot",
|
||||
"co_parent_user_id": "uuid-ou-null",
|
||||
"statut": "actif",
|
||||
"enfant_ids": ["uuid", "..."]
|
||||
}
|
||||
```
|
||||
|
||||
Effets serveur : user(s) parent **actif**, fiches `parents`, enfants + foyer, n° dossier,
|
||||
**e-mail création MDP** pour chaque compte sans MDP (pas d’accusé « en attente »).
|
||||
|
||||
### Erreurs
|
||||
|
||||
| Code | Cas |
|
||||
|------|-----|
|
||||
| 400 | Validation DTO / métier (enfants vides, dates, etc.) |
|
||||
| 401 | Token manquant / invalide |
|
||||
| 403 | Rôle non staff |
|
||||
| 409 | Conflit e-mail (pivot et/ou co-parent) |
|
||||
|
||||
## Front
|
||||
|
||||
- `UserService.createParentDossier(body)` → cet endpoint
|
||||
- Wizard create basé sur `ValidationFamilyWizard`
|
||||
- Ne pas envoyer `acceptation_cgu` / `acceptation_privacy` (optionnels)
|
||||
|
||||
## Branche
|
||||
|
||||
`feature/129-creation-dossier-parent`
|
||||
@@ -0,0 +1,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,75 @@
|
||||
# Mini-spec API — POST /assistantes-maternelles/dossier (#156)
|
||||
|
||||
Contrat pour le **plan front** (wizard création AM staff).
|
||||
|
||||
## Endpoint
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **Méthode** | `POST` |
|
||||
| **URL** | `{base}/assistantes-maternelles/dossier` |
|
||||
| **Auth** | Bearer JWT |
|
||||
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
|
||||
| **Content-Type** | `application/json` |
|
||||
|
||||
Ne **pas** appeler `POST /auth/register/am` depuis le dashboard.
|
||||
|
||||
## Body (JSON)
|
||||
|
||||
Aligné inscription AM publique, **sans** CGU/privacy obligatoires (acceptées serveur).
|
||||
|
||||
| Champ | Type | Obligatoire | Notes |
|
||||
|-------|------|-------------|--------|
|
||||
| `email` | string | oui | unique |
|
||||
| `prenom` | string | oui | |
|
||||
| `nom` | string | oui | |
|
||||
| `telephone` | string | oui | `0X…` ou `+33…` |
|
||||
| `adresse` | string | non | |
|
||||
| `code_postal` | string | non | |
|
||||
| `ville` | string | non | |
|
||||
| `photo_base64` | string | non | data-URL `data:image/…;base64,…` |
|
||||
| `photo_filename` | string | non | hint nom fichier |
|
||||
| `consentement_photo` | bool | oui | |
|
||||
| `date_naissance` | date ISO | non | `YYYY-MM-DD` |
|
||||
| `lieu_naissance_ville` | string | oui | |
|
||||
| `lieu_naissance_pays` | string | oui | |
|
||||
| `nir` | string | oui | 15 car. (Corse 2A/2B OK) |
|
||||
| `numero_agrement` | string | oui | unique |
|
||||
| `date_agrement` | date ISO | non | |
|
||||
| `capacite_accueil` | int | oui | 1–10 |
|
||||
| `places_disponibles` | int | oui | 0–10, ≤ capacité |
|
||||
| `biographie` | string | non | max 2000 |
|
||||
|
||||
## Réponses
|
||||
|
||||
### 201 Created
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Dossier AM créé et validé. Un e-mail de création de mot de passe a été envoyé.",
|
||||
"user_id": "uuid",
|
||||
"statut": "actif",
|
||||
"numero_dossier": "2026-000042"
|
||||
}
|
||||
```
|
||||
|
||||
Effets serveur : user AM **actif**, fiche `assistantes_maternelles`, n° dossier, **e-mail création MDP** (pas d’accusé « en attente »).
|
||||
|
||||
### Erreurs
|
||||
|
||||
| Code | Cas |
|
||||
|------|-----|
|
||||
| 400 | Validation / NIR / places > capacité |
|
||||
| 403 | Rôle non staff |
|
||||
| 409 | Email, NIR ou agrément déjà pris |
|
||||
| 401 | Token manquant / invalide |
|
||||
|
||||
## Front
|
||||
|
||||
- `UserService.createAmDossier(body)` → cet endpoint
|
||||
- Après 201 : refresh liste AM ; snackbar OK
|
||||
- Wizard create : ne pas envoyer `acceptation_cgu` / `acceptation_privacy` (optionnels)
|
||||
|
||||
## Branche
|
||||
|
||||
`feature/156-creation-dossier-am`
|
||||
@@ -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) {
|
||||
final rawPhoto = json['photo_url'] ?? json['photoUrl'];
|
||||
final resolvedPhoto = _optionalPhotoUrl(rawPhoto);
|
||||
final rawId = json['id'] ??
|
||||
json['enfant_id'] ??
|
||||
json['enfantId'] ??
|
||||
json['child_id'] ??
|
||||
json['childId'];
|
||||
return EnfantDossier(
|
||||
id: json['id']?.toString() ?? '',
|
||||
id: rawId?.toString().trim() ?? '',
|
||||
firstName: (json['first_name'] ?? json['prenom'])?.toString(),
|
||||
lastName: (json['last_name'] ?? json['nom'])?.toString(),
|
||||
birthDate: json['birth_date']?.toString(),
|
||||
|
||||
@@ -14,6 +14,8 @@ class EnfantAdminModel {
|
||||
final bool consentPhoto;
|
||||
final bool isMultiple;
|
||||
final List<EnfantParentLink> parentLinks;
|
||||
/// Flag API #157 (sinon déduit de [parentLinks]).
|
||||
final bool? sansResponsable;
|
||||
|
||||
EnfantAdminModel({
|
||||
required this.id,
|
||||
@@ -27,6 +29,7 @@ class EnfantAdminModel {
|
||||
this.consentPhoto = false,
|
||||
this.isMultiple = false,
|
||||
this.parentLinks = const [],
|
||||
this.sansResponsable,
|
||||
});
|
||||
|
||||
String get fullName {
|
||||
@@ -37,6 +40,42 @@ class EnfantAdminModel {
|
||||
return '$fn $ln';
|
||||
}
|
||||
|
||||
/// Aucun lien parent valide — ticket #157.
|
||||
bool get hasNoResponsable {
|
||||
if (sansResponsable != null) return sansResponsable!;
|
||||
return !parentLinks.any((l) => l.parentId.trim().isNotEmpty);
|
||||
}
|
||||
|
||||
EnfantAdminModel copyWith({
|
||||
String? id,
|
||||
String? firstName,
|
||||
String? lastName,
|
||||
String? gender,
|
||||
String? birthDate,
|
||||
String? dueDate,
|
||||
String? status,
|
||||
String? photoUrl,
|
||||
bool? consentPhoto,
|
||||
bool? isMultiple,
|
||||
List<EnfantParentLink>? parentLinks,
|
||||
bool? sansResponsable,
|
||||
}) {
|
||||
return EnfantAdminModel(
|
||||
id: id ?? this.id,
|
||||
firstName: firstName ?? this.firstName,
|
||||
lastName: lastName ?? this.lastName,
|
||||
gender: gender ?? this.gender,
|
||||
birthDate: birthDate ?? this.birthDate,
|
||||
dueDate: dueDate ?? this.dueDate,
|
||||
status: status ?? this.status,
|
||||
photoUrl: photoUrl ?? this.photoUrl,
|
||||
consentPhoto: consentPhoto ?? this.consentPhoto,
|
||||
isMultiple: isMultiple ?? this.isMultiple,
|
||||
parentLinks: parentLinks ?? this.parentLinks,
|
||||
sansResponsable: sansResponsable ?? this.sansResponsable,
|
||||
);
|
||||
}
|
||||
|
||||
factory EnfantAdminModel.fromJson(Map<String, dynamic> json) {
|
||||
final linksRaw = json['parentLinks'] as List?;
|
||||
final links = <EnfantParentLink>[];
|
||||
@@ -48,18 +87,34 @@ class EnfantAdminModel {
|
||||
}
|
||||
}
|
||||
|
||||
final photoUrl = json['photo_url'] as String? ?? json['photoUrl'] as String?;
|
||||
final consentPhoto = _parseBool(json['consent_photo']) ||
|
||||
_parseBool(json['consentement_photo']) ||
|
||||
_parseBool(json['consentPhoto']);
|
||||
|
||||
bool? sansResponsable;
|
||||
if (json.containsKey('sans_responsable') ||
|
||||
json.containsKey('sansResponsable')) {
|
||||
sansResponsable = _parseBool(json['sans_responsable']) ||
|
||||
_parseBool(json['sansResponsable']);
|
||||
}
|
||||
|
||||
return EnfantAdminModel(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
firstName: json['first_name'] as String?,
|
||||
lastName: json['last_name'] as String?,
|
||||
gender: json['gender'] as String?,
|
||||
birthDate: _dateString(json['birth_date']),
|
||||
dueDate: _dateString(json['due_date']),
|
||||
status: normalizeEnfantStatus(json['status']?.toString()),
|
||||
photoUrl: json['photo_url'] as String?,
|
||||
consentPhoto: json['consent_photo'] == true,
|
||||
isMultiple: json['is_multiple'] == true,
|
||||
firstName: json['first_name'] as String? ?? json['prenom'] as String?,
|
||||
lastName: json['last_name'] as String? ?? json['nom'] as String?,
|
||||
gender: json['gender'] as String? ?? json['genre'] as String?,
|
||||
birthDate: _dateString(json['birth_date'] ?? json['date_naissance']),
|
||||
dueDate: _dateString(json['due_date'] ?? json['date_prevue_naissance']),
|
||||
status: normalizeEnfantStatus(
|
||||
(json['status'] ?? json['statut'])?.toString(),
|
||||
),
|
||||
photoUrl: photoUrl,
|
||||
consentPhoto: consentPhoto,
|
||||
isMultiple: _parseBool(json['is_multiple']) ||
|
||||
_parseBool(json['est_multiple']),
|
||||
parentLinks: links,
|
||||
sansResponsable: sansResponsable,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,6 +131,15 @@ class EnfantAdminModel {
|
||||
};
|
||||
}
|
||||
|
||||
static bool _parseBool(dynamic value) {
|
||||
if (value == true || value == 1) return true;
|
||||
if (value is String) {
|
||||
final s = value.trim().toLowerCase();
|
||||
return s == 'true' || s == '1' || s == 't' || s == 'yes';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static String? _dateString(dynamic v) {
|
||||
if (v == null) return null;
|
||||
if (v is String) return v.split('T').first;
|
||||
@@ -89,6 +153,13 @@ class EnfantParentLink {
|
||||
|
||||
EnfantParentLink({required this.parentId, this.parentName});
|
||||
|
||||
EnfantParentLink copyWith({String? parentId, String? parentName}) {
|
||||
return EnfantParentLink(
|
||||
parentId: parentId ?? this.parentId,
|
||||
parentName: parentName ?? this.parentName,
|
||||
);
|
||||
}
|
||||
|
||||
factory EnfantParentLink.fromJson(Map<String, dynamic> json) {
|
||||
final parentId =
|
||||
(json['parentId'] ?? json['id_parent'] ?? '').toString();
|
||||
@@ -99,6 +170,12 @@ class EnfantParentLink {
|
||||
if (user is Map<String, dynamic>) {
|
||||
final u = AppUser.fromJson(user);
|
||||
name = u.fullName.isNotEmpty ? u.fullName : u.email;
|
||||
} else {
|
||||
// Parfois le parent est aplati (prenom/nom) sans nested user.
|
||||
final prenom = (parent['prenom'] ?? parent['first_name'] ?? '').toString().trim();
|
||||
final nom = (parent['nom'] ?? parent['last_name'] ?? '').toString().trim();
|
||||
final flat = '$prenom $nom'.trim();
|
||||
if (flat.isNotEmpty) name = flat;
|
||||
}
|
||||
}
|
||||
return EnfantParentLink(
|
||||
|
||||
@@ -62,4 +62,56 @@ class ParentModel {
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
/// Nombre d’enfants distincts du foyer (ce parent + co-parent / même dossier).
|
||||
/// Évite Claire=7 / Thomas=6 quand un lien n’est que sur un des deux (#157).
|
||||
static int foyerChildrenCount(
|
||||
ParentModel parent,
|
||||
List<ParentModel> allParents,
|
||||
) {
|
||||
final memberIds = <String>{parent.user.id};
|
||||
final coId = parent.coParent?.id.trim();
|
||||
if (coId != null && coId.isNotEmpty) memberIds.add(coId);
|
||||
|
||||
final dossier = (parent.user.numeroDossier ?? '').trim();
|
||||
|
||||
for (final other in allParents) {
|
||||
if (memberIds.contains(other.user.id)) continue;
|
||||
final otherCo = other.coParent?.id.trim();
|
||||
if (otherCo != null && memberIds.contains(otherCo)) {
|
||||
memberIds.add(other.user.id);
|
||||
continue;
|
||||
}
|
||||
if (dossier.isNotEmpty &&
|
||||
(other.user.numeroDossier ?? '').trim() == dossier) {
|
||||
memberIds.add(other.user.id);
|
||||
final oc = other.coParent?.id.trim();
|
||||
if (oc != null && oc.isNotEmpty) memberIds.add(oc);
|
||||
}
|
||||
}
|
||||
|
||||
final childIds = <String>{};
|
||||
for (final p in allParents) {
|
||||
if (!memberIds.contains(p.user.id)) continue;
|
||||
for (final c in p.children) {
|
||||
final id = c.id.trim();
|
||||
if (id.isNotEmpty) childIds.add(id);
|
||||
}
|
||||
// Repli si la liste enfants n’est pas hydratée.
|
||||
if (p.children.isEmpty && p.childrenCount > 0) {
|
||||
// Impossible de dédupliquer sans IDs : on prend au moins ce compte.
|
||||
// (évite d’afficher 0 si l’API n’envoie que childrenCount)
|
||||
}
|
||||
}
|
||||
|
||||
if (childIds.isNotEmpty) return childIds.length;
|
||||
|
||||
var maxCount = 0;
|
||||
for (final p in allParents) {
|
||||
if (!memberIds.contains(p.user.id)) continue;
|
||||
final n = p.children.isNotEmpty ? p.children.length : p.childrenCount;
|
||||
if (n > maxCount) maxCount = n;
|
||||
}
|
||||
return maxCount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/utils/email_utils.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||
import 'package:p_tits_pas/widgets/email_text_field.dart';
|
||||
import 'package:p_tits_pas/widgets/french_phone_field.dart';
|
||||
|
||||
@@ -151,30 +152,19 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
|
||||
Future<void> _delete() async {
|
||||
if (!_isEditMode || _isSubmitting) return;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
return AlertDialog(
|
||||
title: const Text('Confirmer la suppression'),
|
||||
content: Text(
|
||||
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
|
||||
child: const Text('Supprimer'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
final name = widget.initialUser!.fullName.isEmpty
|
||||
? widget.initialUser!.email
|
||||
: widget.initialUser!.fullName;
|
||||
final confirmed = await showSuppressionConfirmDialog(
|
||||
context,
|
||||
title: 'Supprimer l\'administrateur',
|
||||
people: [SuppressionPersonLine.administrateur(name)],
|
||||
footnotes: const [
|
||||
'Le compte sera définitivement supprimé.',
|
||||
],
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
if (!confirmed) return;
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
|
||||
@@ -5,8 +5,11 @@ import 'package:p_tits_pas/utils/email_utils.dart';
|
||||
import 'package:p_tits_pas/widgets/email_text_field.dart';
|
||||
import 'package:p_tits_pas/widgets/french_phone_field.dart';
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
import 'package:p_tits_pas/services/auth_service.dart';
|
||||
import 'package:p_tits_pas/services/relais_service.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
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 {
|
||||
final AppUser? initialUser;
|
||||
@@ -41,9 +44,21 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
bool _isLoadingRelais = true;
|
||||
List<RelaisModel> _relais = [];
|
||||
String? _selectedRelaisId;
|
||||
String? _currentUserId;
|
||||
String? _currentUserRole;
|
||||
bool get _isEditMode => widget.initialUser != null;
|
||||
bool get _isSuperAdminTarget =>
|
||||
(widget.initialUser?.role ?? '').toLowerCase() == 'super_admin';
|
||||
bool get _isSelfTarget =>
|
||||
_isEditMode &&
|
||||
_currentUserId != null &&
|
||||
widget.initialUser!.id == _currentUserId;
|
||||
/// Gestionnaire : pas de delete staff. Admin+ seulement (#154 / #160).
|
||||
bool get _canDeleteTarget {
|
||||
if (!_isEditMode || widget.readOnly) return false;
|
||||
if (_isSelfTarget || _isSuperAdminTarget) return false;
|
||||
return canDeleteGestionnaire(_currentUserRole);
|
||||
}
|
||||
bool get _isLockedAdminIdentity =>
|
||||
_isEditMode && widget.adminMode && _isSuperAdminTarget;
|
||||
String get _targetRoleKey {
|
||||
@@ -109,6 +124,25 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
} else {
|
||||
_isLoadingRelais = false;
|
||||
}
|
||||
_loadCurrentUserId();
|
||||
}
|
||||
|
||||
Future<void> _loadCurrentUserId() async {
|
||||
final cached = await AuthService.getCurrentUser();
|
||||
if (!mounted) return;
|
||||
if (cached != null) {
|
||||
setState(() {
|
||||
_currentUserId = cached.id;
|
||||
_currentUserRole = cached.role;
|
||||
});
|
||||
return;
|
||||
}
|
||||
final refreshed = await AuthService.refreshCurrentUser();
|
||||
if (!mounted || refreshed == null) return;
|
||||
setState(() {
|
||||
_currentUserId = refreshed.id;
|
||||
_currentUserRole = refreshed.role;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -122,6 +156,21 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Fallback si GET /relais échoue : conserve le relais déjà connu sur l'utilisateur.
|
||||
List<RelaisModel> _fallbackRelaisFromUser() {
|
||||
final id = _selectedRelaisId?.trim();
|
||||
if (id == null || id.isEmpty) return const [];
|
||||
final nom = (widget.initialUser?.relaisNom ?? '').trim();
|
||||
return [
|
||||
RelaisModel(
|
||||
id: id,
|
||||
nom: nom.isNotEmpty ? nom : 'Relais actuel',
|
||||
adresse: '',
|
||||
actif: true,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _loadRelais() async {
|
||||
try {
|
||||
final list = await RelaisService.getRelais();
|
||||
@@ -138,7 +187,8 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
if (selected != null) {
|
||||
filtered.add(selected);
|
||||
} else {
|
||||
_selectedRelaisId = null;
|
||||
// Garder l'id sélectionné et afficher un item de secours (nom carte).
|
||||
filtered.addAll(_fallbackRelaisFromUser());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,9 +198,9 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
// Ne pas nullifier _selectedRelaisId (#151) — la carte a déjà le bon libellé.
|
||||
setState(() {
|
||||
_selectedRelaisId = null;
|
||||
_relais = [];
|
||||
_relais = _fallbackRelaisFromUser();
|
||||
_isLoadingRelais = false;
|
||||
});
|
||||
}
|
||||
@@ -335,33 +385,28 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
|
||||
Future<void> _delete() async {
|
||||
if (widget.readOnly) return;
|
||||
if (_isSuperAdminTarget) return;
|
||||
if (!_canDeleteTarget) return;
|
||||
if (!_isEditMode || _isSubmitting) return;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
return AlertDialog(
|
||||
title: const Text('Confirmer la suppression'),
|
||||
content: Text(
|
||||
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
|
||||
child: const Text('Supprimer'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
final name = widget.initialUser!.fullName.isEmpty
|
||||
? widget.initialUser!.email
|
||||
: widget.initialUser!.fullName;
|
||||
final confirmed = await showSuppressionConfirmDialog(
|
||||
context,
|
||||
title: widget.adminMode
|
||||
? 'Supprimer l\'administrateur'
|
||||
: 'Supprimer le gestionnaire',
|
||||
people: [
|
||||
widget.adminMode
|
||||
? SuppressionPersonLine.administrateur(name)
|
||||
: SuppressionPersonLine.gestionnaire(name),
|
||||
],
|
||||
footnotes: const [
|
||||
'Le compte sera définitivement supprimé.',
|
||||
],
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
if (!confirmed) return;
|
||||
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
@@ -462,7 +507,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
] else if (_isEditMode) ...[
|
||||
if (!_isSuperAdminTarget)
|
||||
if (_canDeleteTarget)
|
||||
OutlinedButton(
|
||||
onPressed: _isSubmitting ? null : _delete,
|
||||
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
|
||||
|
||||
@@ -60,7 +60,12 @@ class ApiConfig {
|
||||
static const String userChildren = '/users/children';
|
||||
static const String gestionnaires = '/gestionnaires';
|
||||
static const String parents = '/parents';
|
||||
/// Création dossier famille actif par le staff (#129) — body type register parent.
|
||||
static const String parentsDossier = '/parents/dossier';
|
||||
static const String assistantesMaternelles = '/assistantes-maternelles';
|
||||
/// Création dossier AM actif par le staff (#156) — body type register AM.
|
||||
static const String assistantesMaternellesDossier =
|
||||
'/assistantes-maternelles/dossier';
|
||||
static const String enfants = '/enfants';
|
||||
static const String relais = '/relais';
|
||||
static const String dossiers = '/dossiers';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||
import 'package:p_tits_pas/models/parent_model.dart';
|
||||
@@ -8,6 +9,7 @@ import 'package:p_tits_pas/models/pending_family.dart';
|
||||
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||
import 'package:p_tits_pas/services/api/tokenService.dart';
|
||||
import 'package:p_tits_pas/utils/am_vigilance.dart';
|
||||
|
||||
class DocumentActifInfo {
|
||||
final String id;
|
||||
@@ -56,6 +58,33 @@ class UserService {
|
||||
return v.toString();
|
||||
}
|
||||
|
||||
/// MIME pour upload photo enfant (filtre Nest : jpg/jpeg/png/gif).
|
||||
static MediaType _imageMediaType(String filename, List<int> bytes) {
|
||||
if (bytes.length >= 3 &&
|
||||
bytes[0] == 0xFF &&
|
||||
bytes[1] == 0xD8 &&
|
||||
bytes[2] == 0xFF) {
|
||||
return MediaType('image', 'jpeg');
|
||||
}
|
||||
if (bytes.length >= 8 &&
|
||||
bytes[0] == 0x89 &&
|
||||
bytes[1] == 0x50 &&
|
||||
bytes[2] == 0x4E &&
|
||||
bytes[3] == 0x47) {
|
||||
return MediaType('image', 'png');
|
||||
}
|
||||
if (bytes.length >= 6 &&
|
||||
bytes[0] == 0x47 &&
|
||||
bytes[1] == 0x49 &&
|
||||
bytes[2] == 0x46) {
|
||||
return MediaType('image', 'gif');
|
||||
}
|
||||
final lower = filename.toLowerCase();
|
||||
if (lower.endsWith('.png')) return MediaType('image', 'png');
|
||||
if (lower.endsWith('.gif')) return MediaType('image', 'gif');
|
||||
return MediaType('image', 'jpeg');
|
||||
}
|
||||
|
||||
static String _errMessage(dynamic err) {
|
||||
if (err == null) return 'Erreur inconnue';
|
||||
if (err is String) return err;
|
||||
@@ -460,22 +489,259 @@ class UserService {
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception(_extractErrorMessage(response.body, 'Erreur chargement enfant'));
|
||||
}
|
||||
return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
|
||||
final enfant = EnfantAdminModel.fromJson(
|
||||
jsonDecode(response.body) as Map<String, dynamic>,
|
||||
);
|
||||
// GET /enfants/:id ne joint pas toujours parent.user → noms manquants.
|
||||
return enrichEnfantParentNames(enfant);
|
||||
}
|
||||
|
||||
/// Complète [parentName] via GET parent quand l'API n'a pas fourni le nested user.
|
||||
static Future<EnfantAdminModel> enrichEnfantParentNames(
|
||||
EnfantAdminModel enfant,
|
||||
) async {
|
||||
final links = enfant.parentLinks;
|
||||
if (links.isEmpty) return enfant;
|
||||
if (links.every((l) => (l.parentName ?? '').trim().isNotEmpty)) {
|
||||
return enfant;
|
||||
}
|
||||
|
||||
final enriched = await Future.wait(
|
||||
links.map((link) async {
|
||||
if ((link.parentName ?? '').trim().isNotEmpty) return link;
|
||||
final id = link.parentId.trim();
|
||||
if (id.isEmpty) return link;
|
||||
try {
|
||||
final parent = await getParent(id);
|
||||
final name = parent.user.fullName.trim();
|
||||
if (name.isEmpty) return link;
|
||||
return link.copyWith(parentName: name);
|
||||
} catch (_) {
|
||||
return link;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return enfant.copyWith(parentLinks: enriched);
|
||||
}
|
||||
|
||||
/// Mise à jour enfant. Avec [photoBytes] : multipart (champ `photo`), sinon JSON.
|
||||
static Future<EnfantAdminModel> updateEnfant({
|
||||
required String enfantId,
|
||||
required Map<String, dynamic> body,
|
||||
List<int>? photoBytes,
|
||||
String? photoFilename,
|
||||
}) async {
|
||||
final response = await http.patch(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
|
||||
headers: await _headers(),
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant'));
|
||||
final id = enfantId.trim();
|
||||
if (id.isEmpty) {
|
||||
throw Exception('Identifiant enfant manquant.');
|
||||
}
|
||||
return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
|
||||
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) {
|
||||
throw Exception(
|
||||
_extractErrorMessage(response.body, 'Erreur mise à jour enfant'),
|
||||
);
|
||||
}
|
||||
final enfant = EnfantAdminModel.fromJson(
|
||||
jsonDecode(response.body) as Map<String, dynamic>,
|
||||
);
|
||||
return enrichEnfantParentNames(enfant);
|
||||
}
|
||||
|
||||
/// Création enfant côté staff (#132) — nécessite `POST /enfants` étendu (voir mini-spec).
|
||||
/// [parentUserId] : parent pivot du foyer (`parent_user_id`).
|
||||
/// Avec [photoBytes] : `multipart/form-data` (champ fichier `photo`), sinon JSON.
|
||||
static Future<EnfantAdminModel> createEnfant({
|
||||
required String parentUserId,
|
||||
required Map<String, dynamic> body,
|
||||
List<int>? photoBytes,
|
||||
String? photoFilename,
|
||||
}) async {
|
||||
final hasPhoto = photoBytes != null && photoBytes.isNotEmpty;
|
||||
final http.Response response;
|
||||
if (hasPhoto) {
|
||||
final token = await TokenService.getToken();
|
||||
final req = http.MultipartRequest(
|
||||
'POST',
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
|
||||
);
|
||||
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();
|
||||
});
|
||||
req.fields['parent_user_id'] = parentUserId;
|
||||
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 {
|
||||
final payload = <String, dynamic>{
|
||||
...body,
|
||||
'parent_user_id': parentUserId,
|
||||
};
|
||||
response = await http.post(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
|
||||
headers: await _headers(),
|
||||
body: jsonEncode(payload),
|
||||
);
|
||||
}
|
||||
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||
throw Exception(
|
||||
_extractErrorMessage(response.body, 'Erreur création enfant'),
|
||||
);
|
||||
}
|
||||
final enfant = EnfantAdminModel.fromJson(
|
||||
jsonDecode(response.body) as Map<String, dynamic>,
|
||||
);
|
||||
return enrichEnfantParentNames(enfant);
|
||||
}
|
||||
|
||||
/// DELETE /enfants/:id?deleteDossier= — ticket #159 / #160.
|
||||
static Future<Map<String, dynamic>> deleteEnfant(
|
||||
String enfantId, {
|
||||
bool deleteDossier = false,
|
||||
}) async {
|
||||
final uri = Uri.parse(
|
||||
'${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId',
|
||||
).replace(
|
||||
queryParameters: {
|
||||
'deleteDossier': deleteDossier ? 'true' : 'false',
|
||||
},
|
||||
);
|
||||
final response = await http.delete(uri, headers: await _headers());
|
||||
if (response.statusCode != 200 && response.statusCode != 204) {
|
||||
throw Exception(
|
||||
_extractErrorMessage(response.body, 'Erreur suppression enfant'),
|
||||
);
|
||||
}
|
||||
return _parseSuppressionBody(response.body);
|
||||
}
|
||||
|
||||
/// DELETE /dossiers/:numeroDossier — ticket #159 / #160.
|
||||
static Future<Map<String, dynamic>> deleteDossier(
|
||||
String numeroDossier,
|
||||
) async {
|
||||
final num = numeroDossier.trim();
|
||||
if (num.isEmpty) {
|
||||
throw Exception('Numéro de dossier manquant.');
|
||||
}
|
||||
final encoded = Uri.encodeComponent(num);
|
||||
final response = await http.delete(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'),
|
||||
headers: await _headers(),
|
||||
);
|
||||
if (response.statusCode != 200 && response.statusCode != 204) {
|
||||
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).
|
||||
static Future<AssistanteMaternelleModel?> findAmForEnfant(String enfantId) async {
|
||||
final ams = await getAssistantesMaternelles();
|
||||
for (final am in ams) {
|
||||
if (am.children.any((c) => c.id == enfantId)) return am;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static ParentModel _parentModelFromBody(String body) {
|
||||
@@ -499,6 +765,155 @@ class UserService {
|
||||
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).
|
||||
/// `POST /parents/dossier` — body aligné sur register parent complet.
|
||||
/// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur, par parent créé).
|
||||
/// Ne pas utiliser `POST /auth/register/parent` (public / en_attente).
|
||||
static Future<Map<String, dynamic>> createParentDossier(
|
||||
Map<String, dynamic> body,
|
||||
) async {
|
||||
final http.Response response;
|
||||
try {
|
||||
response = await http.post(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parentsDossier}'),
|
||||
headers: await _headers(),
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
} on http.ClientException {
|
||||
throw Exception(
|
||||
'Connexion au serveur impossible. Vérifiez votre réseau puis réessayez.',
|
||||
);
|
||||
}
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||
if (response.body.trim().isEmpty) return <String, dynamic>{};
|
||||
try {
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is Map) {
|
||||
return Map<String, dynamic>.from(decoded);
|
||||
}
|
||||
} catch (_) {}
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
|
||||
final message = _extractErrorMessage(
|
||||
response.body,
|
||||
'Erreur création dossier famille',
|
||||
);
|
||||
if (response.statusCode == 409) {
|
||||
throw Exception(
|
||||
message.isNotEmpty ? message : 'Conflit : e-mail déjà utilisé.',
|
||||
);
|
||||
}
|
||||
if (response.statusCode == 400) {
|
||||
throw Exception(
|
||||
message.isNotEmpty ? message : 'Données invalides (400).',
|
||||
);
|
||||
}
|
||||
throw Exception(message);
|
||||
}
|
||||
|
||||
/// Création dossier AM actif côté staff (#156).
|
||||
/// `POST /assistantes-maternelles/dossier` — body aligné sur register AM.
|
||||
/// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur).
|
||||
/// Ne pas utiliser `POST /auth/register/am` (public / en_attente).
|
||||
static Future<Map<String, dynamic>> createAmDossier(
|
||||
Map<String, dynamic> body,
|
||||
) async {
|
||||
final http.Response response;
|
||||
try {
|
||||
response = await http.post(
|
||||
Uri.parse(
|
||||
'${ApiConfig.baseUrl}${ApiConfig.assistantesMaternellesDossier}',
|
||||
),
|
||||
headers: await _headers(),
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
} on http.ClientException {
|
||||
throw Exception(
|
||||
'Connexion au serveur impossible. Vérifiez votre réseau puis réessayez.',
|
||||
);
|
||||
}
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||
if (response.body.trim().isEmpty) return <String, dynamic>{};
|
||||
try {
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is Map) {
|
||||
return Map<String, dynamic>.from(decoded);
|
||||
}
|
||||
} catch (_) {}
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
|
||||
final message = _extractErrorMessage(
|
||||
response.body,
|
||||
'Erreur création dossier AM',
|
||||
);
|
||||
if (response.statusCode == 409) {
|
||||
throw Exception(message.isNotEmpty
|
||||
? message
|
||||
: 'Conflit : e-mail, NIR ou agrément déjà utilisé.');
|
||||
}
|
||||
if (response.statusCode == 400) {
|
||||
throw Exception(
|
||||
message.isNotEmpty ? message : 'Données invalides (400).',
|
||||
);
|
||||
}
|
||||
throw Exception(message);
|
||||
}
|
||||
|
||||
// Récupérer la liste des assistantes maternelles
|
||||
static Future<List<AssistanteMaternelleModel>>
|
||||
getAssistantesMaternelles() async {
|
||||
@@ -633,7 +1048,24 @@ class UserService {
|
||||
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||
throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant'));
|
||||
}
|
||||
return _amModelFromBody(response.body);
|
||||
final am = _amModelFromBody(response.body);
|
||||
// Recalcule toujours places_available (capacité − enfants) après attachement.
|
||||
return syncAmPlacesAvailable(am);
|
||||
}
|
||||
|
||||
/// Aligne `places_available` sur capacité − enfants rattachés.
|
||||
static Future<AssistanteMaternelleModel> syncAmPlacesAvailable(
|
||||
AssistanteMaternelleModel am,
|
||||
) async {
|
||||
final expected = amExpectedPlacesAvailable(
|
||||
maxChildren: am.maxChildren,
|
||||
childrenCount: am.children.length,
|
||||
);
|
||||
if (expected == null || am.placesAvailable == expected) return am;
|
||||
return updateAmFiche(
|
||||
amUserId: am.user.id,
|
||||
body: {'places_available': expected},
|
||||
);
|
||||
}
|
||||
|
||||
static Future<AssistanteMaternelleModel> detachEnfantFromAm({
|
||||
@@ -871,22 +1303,18 @@ class UserService {
|
||||
return AppUser.fromJson(data);
|
||||
}
|
||||
|
||||
static Future<void> deleteUser(String userId) async {
|
||||
/// DELETE /users/:id — cascades métier #159 / #160.
|
||||
static Future<Map<String, dynamic>> deleteUser(String userId) async {
|
||||
final response = await http.delete(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'),
|
||||
headers: await _headers(),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200 && response.statusCode != 204) {
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
final message = decoded['message'];
|
||||
if (message is List && message.isNotEmpty) {
|
||||
throw Exception(message.join(' - '));
|
||||
}
|
||||
throw Exception(_toStr(message) ?? 'Erreur suppression utilisateur');
|
||||
}
|
||||
throw Exception('Erreur suppression utilisateur');
|
||||
throw Exception(
|
||||
_extractErrorMessage(response.body, 'Erreur suppression utilisateur'),
|
||||
);
|
||||
}
|
||||
return _parseSuppressionBody(response.body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,18 @@ int? amExpectedPlacesAvailable({
|
||||
return (maxChildren - childrenCount).clamp(0, maxChildren);
|
||||
}
|
||||
|
||||
/// True s'il reste au moins une place d'accueil (capacité − enfants).
|
||||
bool amHasFreePlace(AssistanteMaternelleModel am) {
|
||||
final expected = amExpectedPlacesAvailable(
|
||||
maxChildren: am.maxChildren,
|
||||
childrenCount: am.children.length,
|
||||
);
|
||||
if (expected != null) return expected > 0;
|
||||
final places = am.placesAvailable;
|
||||
if (places != null) return places > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// True si la valeur déclarée par l'AM ne correspond pas au calcul métier.
|
||||
bool amHasPlacesInconsistency({
|
||||
required int? maxChildren,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||
|
||||
@@ -11,10 +12,50 @@ String formatIsoDateFr(String? s, {String ifEmpty = '–'}) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit `dd/MM/yyyy` (ou ISO) en `yyyy-MM-dd` pour l'API.
|
||||
/// Affiche une date ISO en saisie guidée `jj / mm / aaaa`.
|
||||
String formatIsoDateFrInput(String? s, {String ifEmpty = ''}) {
|
||||
if (s == null || s.trim().isEmpty) return ifEmpty;
|
||||
try {
|
||||
final dt = DateTime.parse(s.trim());
|
||||
return formatFrenchDateDigits(
|
||||
DateFormat('ddMMyyyy').format(dt),
|
||||
);
|
||||
} catch (_) {
|
||||
return formatFrenchDateDigits(s.replaceAll(RegExp(r'\D'), ''));
|
||||
}
|
||||
}
|
||||
|
||||
/// Formate jusqu’à 8 chiffres en `jj / mm / aaaa`.
|
||||
String formatFrenchDateDigits(String digits) {
|
||||
final d = digits.replaceAll(RegExp(r'\D'), '');
|
||||
final limited = d.length > 8 ? d.substring(0, 8) : d;
|
||||
final buf = StringBuffer();
|
||||
for (var i = 0; i < limited.length; i++) {
|
||||
if (i == 2 || i == 4) buf.write(' / ');
|
||||
buf.write(limited[i]);
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
/// Convertit `dd/MM/yyyy`, `dd / MM / yyyy`, 8 chiffres ou ISO en `yyyy-MM-dd`.
|
||||
String? parseFrDateToIso(String text) {
|
||||
final t = text.trim();
|
||||
if (t.isEmpty) return null;
|
||||
|
||||
final digits = t.replaceAll(RegExp(r'\D'), '');
|
||||
if (digits.length == 8) {
|
||||
final dd = digits.substring(0, 2);
|
||||
final mm = digits.substring(2, 4);
|
||||
final yyyy = digits.substring(4, 8);
|
||||
try {
|
||||
return DateFormat('dd/MM/yyyy')
|
||||
.parseStrict('$dd/$mm/$yyyy')
|
||||
.toIso8601String()
|
||||
.split('T')
|
||||
.first;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
try {
|
||||
return DateFormat('dd/MM/yyyy')
|
||||
.parseStrict(t)
|
||||
@@ -96,3 +137,43 @@ String formatChildAgeLabel({
|
||||
return 'Né le ${formatIsoDateFr(birthDate)}';
|
||||
}
|
||||
}
|
||||
|
||||
/// Saisie date FR : 8 chiffres → `jj / mm / aaaa`.
|
||||
class FrenchDateInputFormatter extends TextInputFormatter {
|
||||
const FrenchDateInputFormatter();
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
||||
final limited = digits.length > 8 ? digits.substring(0, 8) : digits;
|
||||
final formatted = formatFrenchDateDigits(limited);
|
||||
|
||||
final digitsBeforeCursor = newValue.text
|
||||
.substring(0, newValue.selection.start.clamp(0, newValue.text.length))
|
||||
.replaceAll(RegExp(r'\D'), '')
|
||||
.length
|
||||
.clamp(0, limited.length);
|
||||
|
||||
return TextEditingValue(
|
||||
text: formatted,
|
||||
selection: TextSelection.collapsed(
|
||||
offset: _cursorOffset(formatted, digitsBeforeCursor),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static int _cursorOffset(String formatted, int digitsBeforeCursor) {
|
||||
if (digitsBeforeCursor <= 0) return 0;
|
||||
var seen = 0;
|
||||
for (var i = 0; i < formatted.length; i++) {
|
||||
if (RegExp(r'\d').hasMatch(formatted[i])) {
|
||||
seen++;
|
||||
if (seen >= digitsBeforeCursor) return i + 1;
|
||||
}
|
||||
}
|
||||
return formatted.length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,10 @@ String normalizeEnfantStatus(String? raw) {
|
||||
return s;
|
||||
}
|
||||
|
||||
String _scolariseAccordeAuGenre(String? gender) {
|
||||
String _accordeAuGenre(String masculin, String feminin, String? gender) {
|
||||
final g = (gender ?? '').trim().toUpperCase();
|
||||
if (g == 'F') return 'Scolarisée';
|
||||
return 'Scolarisé';
|
||||
if (g == 'F') return feminin;
|
||||
return masculin;
|
||||
}
|
||||
|
||||
/// Libellé affiché pour un statut enfant.
|
||||
@@ -28,9 +28,9 @@ String enfantStatusLabel(String? status, {String? gender}) {
|
||||
case 'sans_garde':
|
||||
return 'Sans garde';
|
||||
case 'garde':
|
||||
return 'En garde';
|
||||
return _accordeAuGenre('Gardé', 'Gardée', gender);
|
||||
case 'scolarise':
|
||||
return _scolariseAccordeAuGenre(gender);
|
||||
return _accordeAuGenre('Scolarisé', 'Scolarisée', gender);
|
||||
default:
|
||||
return status?.trim().isNotEmpty == true ? status!.trim() : '–';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||
|
||||
/// Enfant sans lien parent (orphelins d’affiliation) — ticket #157.
|
||||
bool enfantHasNoResponsable(EnfantAdminModel enfant) => enfant.hasNoResponsable;
|
||||
|
||||
/// Message vigilance liste Enfants (même usage que [amPlacesVigilanceMessage]).
|
||||
String? enfantSansResponsableVigilanceMessage(EnfantAdminModel enfant) {
|
||||
if (!enfantHasNoResponsable(enfant)) return null;
|
||||
return 'Aucun responsable rattaché — à rattacher à un foyer';
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Formatage affichage prénom / nom (capitalisation par mot et segments après `-` ou `'`).
|
||||
|
||||
String formatPersonNameCase(String raw) {
|
||||
@@ -9,6 +11,22 @@ String formatPersonNameCase(String raw) {
|
||||
return words.map(_capitalizeComposedWord).join(' ');
|
||||
}
|
||||
|
||||
/// Variante saisie live : pas de majuscule tant que le mot n’a qu’une lettre ;
|
||||
/// dès la 2ᵉ lettre, capitalisation comme [formatPersonNameCase].
|
||||
String formatPersonNameCaseTyping(String raw) {
|
||||
if (raw.isEmpty) return raw;
|
||||
final trailingMatch = RegExp(r'(\s*)$').firstMatch(raw);
|
||||
final trailing = trailingMatch?.group(1) ?? '';
|
||||
final core = raw.substring(0, raw.length - trailing.length);
|
||||
if (core.isEmpty) return raw;
|
||||
final words = core.split(RegExp(r'\s+'));
|
||||
final formatted = words.map((w) {
|
||||
if (w.length < 2) return w;
|
||||
return _capitalizeComposedWord(w);
|
||||
}).join(' ');
|
||||
return formatted + trailing;
|
||||
}
|
||||
|
||||
String _capitalizeComposedWord(String word) {
|
||||
if (word.isEmpty) {
|
||||
return word;
|
||||
@@ -30,3 +48,26 @@ String _capitalizeComposedWord(String word) {
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// Formateur de saisie prénom / nom (capitalisation progressive).
|
||||
class PersonNameInputFormatter extends TextInputFormatter {
|
||||
const PersonNameInputFormatter();
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
final formatted = formatPersonNameCaseTyping(newValue.text);
|
||||
if (formatted == newValue.text) return newValue;
|
||||
|
||||
final sel = newValue.selection;
|
||||
final offset = sel.isValid
|
||||
? sel.baseOffset.clamp(0, formatted.length)
|
||||
: formatted.length;
|
||||
return TextEditingValue(
|
||||
text: formatted,
|
||||
selection: TextSelection.collapsed(offset: offset),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,12 +49,21 @@ String nirToRaw(String normalized) {
|
||||
return s;
|
||||
}
|
||||
|
||||
/// Formate pour affichage : 1 12 34 56 789 012 - 34 ou 1 12 34 2A 789 012 - 34 (Corse).
|
||||
/// Formate pour affichage (complet ou en cours de saisie) :
|
||||
/// `1 12 34 56 789 012 - 34` ou `1 12 34 2A 789 012 - 34` (Corse).
|
||||
String formatNir(String raw) {
|
||||
final r = nirToRaw(raw);
|
||||
if (r.length < 15) return r;
|
||||
// Même structure pour tous : sexe + année + mois + département + commune + ordre-clé.
|
||||
return '${r.substring(0, 1)} ${r.substring(1, 3)} ${r.substring(3, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)} - ${r.substring(13, 15)}';
|
||||
final r = nirToRaw(raw).toUpperCase();
|
||||
if (r.isEmpty) return '';
|
||||
final buf = StringBuffer();
|
||||
for (var i = 0; i < r.length && i < 15; i++) {
|
||||
if (i == 1 || i == 3 || i == 5 || i == 7 || i == 10) {
|
||||
buf.write(' ');
|
||||
} else if (i == 13) {
|
||||
buf.write(' - ');
|
||||
}
|
||||
buf.write(r[i]);
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
/// Aligné sur le backend (NIR côté API / nir.util.ts) : sexe 1–3, département 2A ou 2B pour la Corse.
|
||||
@@ -92,20 +101,39 @@ String? validateNir(String? value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Formateur de saisie : affiche le NIR formaté (1 12 34 56 789 012 - 34) et limite à 15 caractères utiles.
|
||||
/// Validation pendant la saisie : pas d’erreur si incomplet encore plausible ;
|
||||
/// dès 15 caractères, même contrôles que [validateNir].
|
||||
String? validateNirTyping(String? value) {
|
||||
if (value == null || value.trim().isEmpty) return null;
|
||||
final raw = nirToRaw(value).toUpperCase();
|
||||
if (raw.isEmpty) return null;
|
||||
if (raw[0] != '1' && raw[0] != '2' && raw[0] != '3') {
|
||||
return 'Format NIR invalide (ex. 1 12 34 56 789 012 - 34 ou 2A pour la Corse)';
|
||||
}
|
||||
if (raw.length < 15) return null;
|
||||
return validateNir(value);
|
||||
}
|
||||
|
||||
/// Formateur de saisie : affiche le NIR formaté au fil de la frappe et limite à 15 caractères utiles.
|
||||
class NirInputFormatter extends TextInputFormatter {
|
||||
const NirInputFormatter();
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
final raw = normalizeNir(newValue.text);
|
||||
if (raw.isEmpty) return newValue;
|
||||
if (raw.isEmpty) {
|
||||
return const TextEditingValue(
|
||||
text: '',
|
||||
selection: TextSelection.collapsed(offset: 0),
|
||||
);
|
||||
}
|
||||
final formatted = formatNir(raw);
|
||||
final offset = formatted.length;
|
||||
return TextEditingValue(
|
||||
text: formatted,
|
||||
selection: TextSelection.collapsed(offset: offset),
|
||||
selection: TextSelection.collapsed(offset: formatted.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +167,7 @@ class ParentRegistrationPayload {
|
||||
final map = <String, dynamic>{
|
||||
'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre',
|
||||
'grossesse_multiple': c.multipleBirth,
|
||||
'consent_photo': c.photoConsent,
|
||||
};
|
||||
|
||||
final prenom = c.firstName.trim();
|
||||
|
||||
@@ -38,15 +38,12 @@ class RepriseMapper {
|
||||
? isoToDdMmYyyy(e.dueDate)
|
||||
: isoToDdMmYyyy(e.birthDate);
|
||||
final photo = e.photoUrl?.trim();
|
||||
final hasPhoto = photo != null && photo.isNotEmpty;
|
||||
return ChildData(
|
||||
firstName: e.firstName ?? '',
|
||||
lastName: e.lastName ?? '',
|
||||
dob: dob,
|
||||
genre: e.gender ?? '',
|
||||
// Inscription initiale exigeait la coche pour envoyer la photo ; le back
|
||||
// ne persistait pas toujours consent_photo — on pré-coche si photo en base.
|
||||
photoConsent: e.consentPhoto || hasPhoto,
|
||||
photoConsent: e.consentPhoto,
|
||||
multipleBirth: e.estMultiple,
|
||||
isUnbornChild: isUnborn,
|
||||
cardColor: _childCardColors[index % _childCardColors.length],
|
||||
@@ -200,7 +197,7 @@ class RepriseMapper {
|
||||
postalCode: dossier.codePostal ?? '',
|
||||
city: dossier.ville ?? '',
|
||||
existingPhotoUrl: displayPhoto,
|
||||
consentementPhoto: dossier.consentementPhoto || hasPhoto,
|
||||
consentementPhoto: dossier.consentementPhoto,
|
||||
dateOfBirth: parseIsoDate(dossier.dateNaissance),
|
||||
birthCity: dossier.lieuNaissanceVille ?? '',
|
||||
birthCountry: dossier.lieuNaissancePays ?? '',
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/// 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/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/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';
|
||||
|
||||
class AdminManagementWidget extends StatefulWidget {
|
||||
@@ -24,6 +26,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
||||
String? _error;
|
||||
List<AppUser> _admins = [];
|
||||
String? _currentUserRole;
|
||||
String? _currentUserId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -62,6 +65,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
||||
if (cached != null) {
|
||||
setState(() {
|
||||
_currentUserRole = (cached.role).toLowerCase();
|
||||
_currentUserId = cached.id;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -69,6 +73,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
||||
if (!mounted || refreshed == null) return;
|
||||
setState(() {
|
||||
_currentUserRole = (refreshed.role).toLowerCase();
|
||||
_currentUserId = refreshed.id;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -79,6 +84,16 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
||||
return _currentUserRole == 'super_admin';
|
||||
}
|
||||
|
||||
bool _canDeleteAdmin(AppUser target) {
|
||||
return canDeleteAdministrateur(
|
||||
currentRole: _currentUserRole,
|
||||
currentUserId: _currentUserId,
|
||||
targetUserId: target.id,
|
||||
targetRole: target.role,
|
||||
adminCount: _admins.length,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openAdminEditDialog(AppUser user) async {
|
||||
final canEdit = _canEditAdmin(user);
|
||||
final changed = await showDialog<bool>(
|
||||
@@ -98,6 +113,56 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(AppUser user) async {
|
||||
final blocked = adminDeleteBlockedReason(
|
||||
currentRole: _currentUserRole,
|
||||
currentUserId: _currentUserId,
|
||||
targetUserId: user.id,
|
||||
targetRole: user.role,
|
||||
adminCount: _admins.length,
|
||||
);
|
||||
if (blocked != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(blocked)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final name = user.fullName.isNotEmpty ? user.fullName : user.email;
|
||||
final isLast = _admins.length <= 1;
|
||||
final confirmed = await showSuppressionConfirmDialog(
|
||||
context,
|
||||
title: 'Supprimer l\'administrateur',
|
||||
people: [SuppressionPersonLine.administrateur(name)],
|
||||
footnotes: [
|
||||
if (isLast)
|
||||
'Attention : 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
|
||||
Widget build(BuildContext context) {
|
||||
final query = widget.searchQuery.toLowerCase();
|
||||
@@ -117,6 +182,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
||||
final user = filteredAdmins[index];
|
||||
final isSuperAdmin = _isSuperAdmin(user);
|
||||
final canEdit = _canEditAdmin(user);
|
||||
final canDelete = _canDeleteAdmin(user);
|
||||
return AdminUserCard(
|
||||
title: user.fullName,
|
||||
fallbackIcon: isSuperAdmin
|
||||
@@ -148,6 +214,8 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
||||
_openAdminEditDialog(user);
|
||||
},
|
||||
),
|
||||
if (canDelete)
|
||||
suppressionIconButton(onPressed: () => _confirmDelete(user)),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart';
|
||||
|
||||
/// Modale de création dossier AM (#156) — même shell que [ValidationDossierModal].
|
||||
class AmDossierCreateModal extends StatefulWidget {
|
||||
final VoidCallback onClose;
|
||||
final VoidCallback? onSuccess;
|
||||
|
||||
const AmDossierCreateModal({
|
||||
super.key,
|
||||
required this.onClose,
|
||||
this.onSuccess,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AmDossierCreateModal> createState() => _AmDossierCreateModalState();
|
||||
}
|
||||
|
||||
class _AmDossierCreateModalState extends State<AmDossierCreateModal> {
|
||||
int? _stepIndex;
|
||||
int? _stepTotal;
|
||||
|
||||
void _onStepChanged(int step, int total) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_stepIndex = step;
|
||||
_stepTotal = total;
|
||||
});
|
||||
}
|
||||
|
||||
void _onSuccess() {
|
||||
widget.onSuccess?.call();
|
||||
}
|
||||
|
||||
static const double _modalWidth = 930;
|
||||
/// Hauteur calculée depuis 4 lignes de TF (voir [AmDossierWizard.shellBodyHeight]).
|
||||
static double get _bodyHeight => AmDossierWizard.shellBodyHeight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maxH = MediaQuery.of(context).size.height * 0.85;
|
||||
final showStep =
|
||||
_stepIndex != null && _stepTotal != null && (_stepTotal ?? 0) > 0;
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: _modalWidth, maxHeight: maxH),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(18, 18, 0, 12),
|
||||
child: Text(
|
||||
'Nouvelle assistante maternelle',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (showStep) ...[
|
||||
Text(
|
||||
'Étape ${(_stepIndex ?? 0) + 1}/${_stepTotal ?? 1}',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Colors.black54,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: widget.onClose,
|
||||
tooltip: 'Fermer',
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SizedBox(
|
||||
height: _bodyHeight,
|
||||
child: AmDossierWizard.create(
|
||||
onClose: widget.onClose,
|
||||
onSuccess: _onSuccess,
|
||||
onStepChanged: _onStepChanged,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
||||
import 'package:p_tits_pas/models/dossier_list_item.dart';
|
||||
import 'package:p_tits_pas/utils/am_vigilance.dart';
|
||||
import 'package:p_tits_pas/services/auth_service.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/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_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';
|
||||
|
||||
class AssistanteMaternelleManagementWidget extends StatefulWidget {
|
||||
@@ -26,16 +30,24 @@ class _AssistanteMaternelleManagementWidgetState
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
List<AssistanteMaternelleModel> _assistantes = [];
|
||||
bool _canDelete = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadRights();
|
||||
_loadAssistantes();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() => super.dispose();
|
||||
|
||||
Future<void> _loadRights() async {
|
||||
final user = await AuthService.getCurrentUser();
|
||||
if (!mounted) return;
|
||||
setState(() => _canDelete = canDeleteMetier(user?.role));
|
||||
}
|
||||
|
||||
Future<void> _loadAssistantes() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
@@ -57,6 +69,46 @@ class _AssistanteMaternelleManagementWidgetState
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(AssistanteMaternelleModel am) async {
|
||||
final num = (am.user.numeroDossier ?? '').trim();
|
||||
final name = formatDossierPersonLabel(
|
||||
nom: am.user.nom,
|
||||
prenom: am.user.prenom,
|
||||
email: am.user.email,
|
||||
);
|
||||
final confirmed = await showSuppressionConfirmDialog(
|
||||
context,
|
||||
title: 'Supprimer l\'assistante maternelle',
|
||||
subtitle: num.isEmpty ? null : 'Dossier $num',
|
||||
people: [SuppressionPersonLine.am(name)],
|
||||
footnotes: const [
|
||||
'Le compte et le dossier AM seront supprimés.',
|
||||
'Les enfants accueillis ne seront pas supprimés '
|
||||
'(placements clos).',
|
||||
],
|
||||
);
|
||||
if (!confirmed || !mounted) return;
|
||||
|
||||
try {
|
||||
final result = await UserService.deleteUser(am.user.id);
|
||||
if (!mounted) return;
|
||||
final msg = (result['message'] ?? 'AM supprimée.').toString();
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||
await _loadAssistantes();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
e is Exception
|
||||
? e.toString().replaceFirst('Exception: ', '')
|
||||
: 'Erreur suppression',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final query = widget.searchQuery.toLowerCase();
|
||||
@@ -96,6 +148,10 @@ class _AssistanteMaternelleManagementWidgetState
|
||||
_openAssistanteDetails(assistante);
|
||||
},
|
||||
),
|
||||
if (_canDelete)
|
||||
suppressionIconButton(
|
||||
onPressed: () => _confirmDelete(assistante),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -20,6 +20,8 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
|
||||
final int capacity;
|
||||
final void Function(ParentChildSummary child) onOpen;
|
||||
final void Function(ParentChildSummary child) onDetach;
|
||||
/// Clic sur une case libre → même flux que « Rattacher un enfant » (#149).
|
||||
final VoidCallback? onAttachEmpty;
|
||||
|
||||
const AdminAmChildrenCapacityGrid({
|
||||
super.key,
|
||||
@@ -27,6 +29,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
|
||||
required this.capacity,
|
||||
required this.onOpen,
|
||||
required this.onDetach,
|
||||
this.onAttachEmpty,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -78,7 +81,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
if (index < maxSlots) {
|
||||
return const _EmptySlot();
|
||||
return _EmptySlot(onTap: onAttachEmpty);
|
||||
}
|
||||
return const _UnavailableSlot();
|
||||
}
|
||||
@@ -129,18 +132,52 @@ class _UnavailableSlot extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptySlot extends StatelessWidget {
|
||||
const _EmptySlot();
|
||||
class _EmptySlot extends StatefulWidget {
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _EmptySlot({this.onTap});
|
||||
|
||||
@override
|
||||
State<_EmptySlot> createState() => _EmptySlotState();
|
||||
}
|
||||
|
||||
class _EmptySlotState extends State<_EmptySlot> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _SlotShell(
|
||||
backgroundColor: Colors.grey.shade50,
|
||||
borderColor: Colors.grey.shade300,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Place libre',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
|
||||
final clickable = widget.onTap != null;
|
||||
return MouseRegion(
|
||||
onEnter: clickable ? (_) => setState(() => _hovered = true) : null,
|
||||
onExit: clickable ? (_) => setState(() => _hovered = false) : null,
|
||||
cursor: clickable ? SystemMouseCursors.click : MouseCursor.defer,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
hoverColor: const Color(0x149CC5C0),
|
||||
child: _SlotShell(
|
||||
backgroundColor: _hovered
|
||||
? const Color(0xFFF3F0FA)
|
||||
: Colors.grey.shade50,
|
||||
borderColor: _hovered
|
||||
? const Color(0xFFB8A4D4)
|
||||
: Colors.grey.shade300,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Place libre',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _hovered
|
||||
? const Color(0xFF6B3FA0)
|
||||
: Colors.grey.shade500,
|
||||
fontWeight: _hovered ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
||||
import 'package:p_tits_pas/utils/am_vigilance.dart';
|
||||
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
||||
@@ -11,6 +10,7 @@ import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_children_capacity_grid.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
||||
import 'package:p_tits_pas/widgets/common/identity_block.dart';
|
||||
@@ -55,6 +55,10 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
||||
late List<ParentChildSummary> _children;
|
||||
late Set<String> _baselineChildIds;
|
||||
|
||||
/// Enfants ajoutés localement qui étaient déjà chez une autre AM
|
||||
/// (enfantId → amUserId d'origine). Au save : détacher puis rattacher.
|
||||
final Map<String, String> _transferFromAmIds = {};
|
||||
|
||||
bool _saving = false;
|
||||
bool _dirty = false;
|
||||
|
||||
@@ -65,29 +69,6 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
||||
static const double _proTabHeight = 300;
|
||||
static const List<int> _photoProRowLayout = [2, 2, 2];
|
||||
|
||||
/// Hauteur onglet enfants : champs + titre + grille 2×2 (+ alerte places si besoin).
|
||||
double _childrenTabHeight() {
|
||||
const capacityFields = 72.0;
|
||||
const titleSection = 40.0;
|
||||
const inconsistencyExtra = 46.0;
|
||||
var h = capacityFields +
|
||||
titleSection +
|
||||
AdminAmChildrenCapacityGrid.fixedHeight;
|
||||
if (_placesInconsistent()) h += inconsistencyExtra;
|
||||
return h + 4;
|
||||
}
|
||||
|
||||
double _tabViewHeight(int index) {
|
||||
switch (index) {
|
||||
case 1:
|
||||
return _proTabHeight;
|
||||
case 2:
|
||||
return _childrenTabHeight();
|
||||
default:
|
||||
return 292;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -234,6 +215,13 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
||||
|
||||
int? _capaciteMax() => _parseIntField(_capaciteCtrl);
|
||||
|
||||
/// True si plus aucune place d'accueil (enfants ≥ capacité max).
|
||||
bool get _capacityFull {
|
||||
final max = _capaciteMax();
|
||||
if (max == null) return false;
|
||||
return _children.length >= max;
|
||||
}
|
||||
|
||||
int? _computedPlacesAvailable() => amExpectedPlacesAvailable(
|
||||
maxChildren: _capaciteMax(),
|
||||
childrenCount: _children.length,
|
||||
@@ -311,6 +299,17 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
||||
);
|
||||
}
|
||||
for (final id in currentIds.difference(_baselineChildIds)) {
|
||||
// Transfert : détacher l'ancienne AM sans recalculer ses places
|
||||
// (le ! de vigilance apparaît côté liste AM).
|
||||
final previousAmId = _transferFromAmIds[id] ??
|
||||
(await UserService.findAmForEnfant(id))?.user.id;
|
||||
if (previousAmId != null &&
|
||||
previousAmId != widget.assistante.user.id) {
|
||||
await UserService.detachEnfantFromAm(
|
||||
amUserId: previousAmId,
|
||||
enfantId: id,
|
||||
);
|
||||
}
|
||||
await UserService.attachEnfantToAm(
|
||||
amUserId: widget.assistante.user.id,
|
||||
enfantId: id,
|
||||
@@ -346,6 +345,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
||||
_dirty = false;
|
||||
_saving = false;
|
||||
_baselineChildIds = currentIds;
|
||||
_transferFromAmIds.clear();
|
||||
});
|
||||
widget.onSaved?.call();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -377,6 +377,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
||||
setState(() {
|
||||
_children = kids;
|
||||
_baselineChildIds = kids.map((c) => c.id).toSet();
|
||||
_transferFromAmIds.clear();
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -422,8 +423,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Détacher l\'enfant'),
|
||||
content: Text(
|
||||
'Retirer ${child.fullName} de la fiche de cette assistante ?\n'
|
||||
'(L\'enfant ne sera pas supprimé.)',
|
||||
'Retirer ${child.fullName} de la fiche de cette assistante ?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -442,55 +442,75 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
||||
|
||||
setState(() {
|
||||
_children = _children.where((c) => c.id != child.id).toList();
|
||||
_transferFromAmIds.remove(child.id);
|
||||
_syncPlacesAfterChildrenChange();
|
||||
_dirty = true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _attachChild() async {
|
||||
List<EnfantAdminModel> all;
|
||||
try {
|
||||
all = await UserService.getEnfants();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final linkedIds = _children.map((c) => c.id).toSet();
|
||||
final candidates = all.where((e) => !linkedIds.contains(e.id)).toList();
|
||||
if (candidates.isEmpty) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Aucun enfant disponible à rattacher')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
final selected = await showDialog<EnfantAdminModel>(
|
||||
context: context,
|
||||
builder: (ctx) => SimpleDialog(
|
||||
title: const Text('Rattacher un enfant'),
|
||||
children: candidates
|
||||
.map(
|
||||
(e) => SimpleDialogOption(
|
||||
onPressed: () => Navigator.pop(ctx, e),
|
||||
child: Text(e.fullName),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
if (_capacityFull || !mounted) return;
|
||||
final selected = await AdminSelectEnfantModal.show(
|
||||
context,
|
||||
excludeIds: _children.map((c) => c.id).toSet(),
|
||||
title: 'Rattacher un enfant',
|
||||
showSansGardeFilter: true,
|
||||
);
|
||||
if (selected == null || !mounted) return;
|
||||
|
||||
AssistanteMaternelleModel? previousAm;
|
||||
try {
|
||||
previousAm = await UserService.findAmForEnfant(selected.id);
|
||||
} catch (_) {
|
||||
previousAm = null;
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
final previousAmId = previousAm?.user.id;
|
||||
final isTransfer = previousAmId != null &&
|
||||
previousAmId != widget.assistante.user.id;
|
||||
|
||||
if (isTransfer) {
|
||||
final amName = previousAm!.user.fullName.trim().isNotEmpty
|
||||
? previousAm.user.fullName.trim()
|
||||
: 'une autre assistante maternelle';
|
||||
final gardeLabel =
|
||||
(selected.gender ?? '').trim().toUpperCase() == 'F'
|
||||
? 'déjà gardée'
|
||||
: 'déjà gardé';
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Changer d\'affectation'),
|
||||
content: Text(
|
||||
'${selected.fullName} est $gardeLabel par $amName.\n\n'
|
||||
'Confirmer le transfert vers cette assistante ? '
|
||||
'L\'enfant sera détaché de $amName.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: ValidationModalTheme.primaryElevatedStyle,
|
||||
child: const Text('Confirmer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_children = [
|
||||
..._children,
|
||||
ParentChildSummary.fromEnfant(selected),
|
||||
];
|
||||
if (isTransfer) {
|
||||
_transferFromAmIds[selected.id] = previousAmId!;
|
||||
}
|
||||
_syncPlacesAfterChildrenChange();
|
||||
_dirty = true;
|
||||
});
|
||||
@@ -498,17 +518,15 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
||||
|
||||
|
||||
Widget _identityTab() {
|
||||
return SingleChildScrollView(
|
||||
child: IdentityBlock.editable(
|
||||
title: 'Identité et coordonnées',
|
||||
nomController: _nomCtrl,
|
||||
prenomController: _prenomCtrl,
|
||||
telephoneController: _telCtrl,
|
||||
emailController: _emailCtrl,
|
||||
adresseController: _adresseCtrl,
|
||||
codePostalController: _cpCtrl,
|
||||
villeController: _villeCtrl,
|
||||
),
|
||||
return IdentityBlock.editable(
|
||||
title: 'Identité et coordonnées',
|
||||
nomController: _nomCtrl,
|
||||
prenomController: _prenomCtrl,
|
||||
telephoneController: _telCtrl,
|
||||
emailController: _emailCtrl,
|
||||
adresseController: _adresseCtrl,
|
||||
codePostalController: _cpCtrl,
|
||||
villeController: _villeCtrl,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -567,7 +585,9 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
||||
return LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
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 idealPhotoW = bodyH * AdminAmPhotoFrame.idPhotoAspectRatio + 16;
|
||||
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
|
||||
@@ -616,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() {
|
||||
final inconsistent = _placesInconsistent();
|
||||
return Column(
|
||||
@@ -701,6 +733,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
||||
capacity: capacity,
|
||||
onOpen: _openChild,
|
||||
onDetach: _detachChild,
|
||||
onAttachEmpty: _capacityFull ? null : _attachChild,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -708,6 +741,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
||||
|
||||
Widget _buildFooter() {
|
||||
final isChildrenTab = _tabCtrl.index == 2;
|
||||
final canAttachChild = !_saving && !_capacityFull;
|
||||
return Row(
|
||||
children: [
|
||||
TextButton(
|
||||
@@ -716,10 +750,15 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
||||
),
|
||||
const Spacer(),
|
||||
if (isChildrenTab)
|
||||
TextButton.icon(
|
||||
onPressed: _attachChild,
|
||||
icon: const Icon(Icons.link, size: 18),
|
||||
label: const Text('Rattacher un enfant'),
|
||||
Tooltip(
|
||||
message: _capacityFull
|
||||
? 'Capacité maximale atteinte'
|
||||
: 'Rattacher un enfant',
|
||||
child: TextButton.icon(
|
||||
onPressed: canAttachChild ? _attachChild : null,
|
||||
icon: const Icon(Icons.link, size: 18),
|
||||
label: const Text('Rattacher un enfant'),
|
||||
),
|
||||
),
|
||||
if (isChildrenTab) const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
@@ -817,15 +856,13 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
|
||||
child: SizedBox(
|
||||
height: _tabViewHeight(_tabCtrl.index),
|
||||
child: TabBarView(
|
||||
controller: _tabCtrl,
|
||||
children: [
|
||||
_identityTab(),
|
||||
_proTab(),
|
||||
_childrenTab(),
|
||||
],
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeInOut,
|
||||
alignment: Alignment.topCenter,
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey<int>(_tabCtrl.index),
|
||||
child: _buildActiveTabBody(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
||||
|
||||
/// Cadre photo identité AM (35×45 mm) — même logique que [ValidationAmWizard].
|
||||
/// Cadre photo identité AM / enfant (35×45 mm) — même logique que [ValidationAmWizard].
|
||||
class AdminAmPhotoFrame extends StatelessWidget {
|
||||
final String? photoUrl;
|
||||
final Uint8List? imageBytes;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onClear;
|
||||
final String emptyLabel;
|
||||
|
||||
static const double idPhotoAspectRatio = 35 / 45;
|
||||
|
||||
const AdminAmPhotoFrame({super.key, this.photoUrl});
|
||||
const AdminAmPhotoFrame({
|
||||
super.key,
|
||||
this.photoUrl,
|
||||
this.imageBytes,
|
||||
this.onTap,
|
||||
this.onClear,
|
||||
this.emptyLabel = 'Aucune photo',
|
||||
});
|
||||
|
||||
/// Largeur colonne photo pour remplir [height] (cadre inclus).
|
||||
static double columnWidthForHeight(double height) {
|
||||
@@ -36,9 +49,12 @@ class AdminAmPhotoFrame extends StatelessWidget {
|
||||
ph = pw / ar;
|
||||
}
|
||||
|
||||
final hasLocal = imageBytes != null && imageBytes!.isNotEmpty;
|
||||
final showClear = onClear != null && hasLocal;
|
||||
|
||||
// Cadre gris = taille photo + padding uniforme ; centré dans la colonne
|
||||
// (évite le vide blanc en bas quand le conteneur parent est plus haut).
|
||||
return Align(
|
||||
Widget frame = Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
@@ -60,22 +76,81 @@ class AdminAmPhotoFrame extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (onTap != null) {
|
||||
frame = MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: frame,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!showClear) return frame;
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
frame,
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: IconButton(
|
||||
tooltip: 'Retirer la photo',
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 32,
|
||||
minHeight: 32,
|
||||
),
|
||||
icon: Icon(
|
||||
Icons.cancel,
|
||||
size: 22,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
onPressed: onClear,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _photoContent(String fullUrl, double pw, double ph) {
|
||||
if (imageBytes != null && imageBytes!.isNotEmpty) {
|
||||
return Image.memory(
|
||||
imageBytes!,
|
||||
width: pw,
|
||||
height: ph,
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.topCenter,
|
||||
);
|
||||
}
|
||||
if (fullUrl.isEmpty) {
|
||||
return ColoredBox(
|
||||
color: Colors.grey.shade200,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.person_off_outlined, size: 36, color: Colors.grey.shade400),
|
||||
Icon(
|
||||
onTap != null ? Icons.add_a_photo_outlined : Icons.person_off_outlined,
|
||||
size: 36,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Aucune photo',
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 11),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Text(
|
||||
emptyLabel,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 11),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -108,7 +183,11 @@ class AdminAmPhotoFrame extends StatelessWidget {
|
||||
},
|
||||
errorBuilder: (_, __, ___) => ColoredBox(
|
||||
color: Colors.grey.shade200,
|
||||
child: Icon(Icons.broken_image_outlined, size: 36, color: Colors.grey.shade400),
|
||||
child: Icon(
|
||||
Icons.broken_image_outlined,
|
||||
size: 36,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,7 @@ class AdminChildrenAffiliationPanel extends StatelessWidget {
|
||||
final c = children[i];
|
||||
return AdminEnfantUserCard.fromSummary(
|
||||
c,
|
||||
onCardTap: () => onOpen(c),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.visibility_outlined),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
||||
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||
import 'package:p_tits_pas/utils/enfant_vigilance.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||
|
||||
List<String> enfantAdminSubtitleLines({
|
||||
@@ -34,6 +35,10 @@ class AdminEnfantUserCard extends StatelessWidget {
|
||||
final List<String> subtitleLines;
|
||||
final List<Widget> actions;
|
||||
final VoidCallback? onCardTap;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final EdgeInsetsGeometry? contentPadding;
|
||||
final Color? borderColor;
|
||||
final String? vigilanceTooltip;
|
||||
|
||||
const AdminEnfantUserCard({
|
||||
super.key,
|
||||
@@ -42,6 +47,10 @@ class AdminEnfantUserCard extends StatelessWidget {
|
||||
required this.subtitleLines,
|
||||
this.actions = const [],
|
||||
this.onCardTap,
|
||||
this.margin,
|
||||
this.contentPadding,
|
||||
this.borderColor,
|
||||
this.vigilanceTooltip,
|
||||
});
|
||||
|
||||
factory AdminEnfantUserCard.fromEnfant(
|
||||
@@ -49,10 +58,15 @@ class AdminEnfantUserCard extends StatelessWidget {
|
||||
List<String> extraSubtitleLines = const [],
|
||||
List<Widget> actions = const [],
|
||||
VoidCallback? onCardTap,
|
||||
EdgeInsetsGeometry? margin,
|
||||
EdgeInsetsGeometry? contentPadding,
|
||||
Color? borderColor,
|
||||
String? vigilanceTooltip,
|
||||
}) {
|
||||
final parents = enfant.parentLinks
|
||||
.map((l) => l.parentName ?? 'Parent')
|
||||
.join(', ');
|
||||
final orphan = enfantHasNoResponsable(enfant);
|
||||
return AdminEnfantUserCard(
|
||||
title: enfant.fullName,
|
||||
photoUrl: enfant.photoUrl,
|
||||
@@ -63,11 +77,18 @@ class AdminEnfantUserCard extends StatelessWidget {
|
||||
gender: enfant.gender,
|
||||
extra: [
|
||||
if (parents.isNotEmpty) 'Responsables : $parents',
|
||||
if (orphan) 'Aucun responsable rattaché',
|
||||
...extraSubtitleLines,
|
||||
],
|
||||
),
|
||||
actions: actions,
|
||||
onCardTap: onCardTap,
|
||||
margin: margin,
|
||||
contentPadding: contentPadding,
|
||||
borderColor: borderColor ??
|
||||
(orphan ? Colors.red.shade300 : null),
|
||||
vigilanceTooltip: vigilanceTooltip ??
|
||||
enfantSansResponsableVigilanceMessage(enfant),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,6 +97,8 @@ class AdminEnfantUserCard extends StatelessWidget {
|
||||
List<String> extraSubtitleLines = const [],
|
||||
List<Widget> actions = const [],
|
||||
VoidCallback? onCardTap,
|
||||
EdgeInsetsGeometry? margin,
|
||||
EdgeInsetsGeometry? contentPadding,
|
||||
}) {
|
||||
return AdminEnfantUserCard(
|
||||
title: child.fullName,
|
||||
@@ -88,6 +111,8 @@ class AdminEnfantUserCard extends StatelessWidget {
|
||||
),
|
||||
actions: actions,
|
||||
onCardTap: onCardTap,
|
||||
margin: margin,
|
||||
contentPadding: contentPadding,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,6 +125,10 @@ class AdminEnfantUserCard extends StatelessWidget {
|
||||
subtitleLines: subtitleLines,
|
||||
actions: actions,
|
||||
onCardTap: onCardTap,
|
||||
margin: margin,
|
||||
contentPadding: contentPadding,
|
||||
borderColor: borderColor,
|
||||
vigilanceTooltip: vigilanceTooltip,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
||||
import 'package:p_tits_pas/models/parent_model.dart';
|
||||
@@ -7,6 +6,7 @@ import 'package:p_tits_pas/models/user.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
|
||||
import 'package:p_tits_pas/widgets/common/identity_block.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
||||
@@ -113,10 +113,73 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
||||
return '$fn $ln'.trim();
|
||||
}
|
||||
|
||||
String? _coParentSubtitle() {
|
||||
String? _coParentName() {
|
||||
final name = _coParent?.fullName.trim() ?? '';
|
||||
if (name.isEmpty) return null;
|
||||
return 'Co-parent : $name';
|
||||
return name.isEmpty ? null : name;
|
||||
}
|
||||
|
||||
Future<void> _openCoParent() async {
|
||||
if (_saving) return;
|
||||
final co = _coParent;
|
||||
final id = (co?.id ?? '').trim();
|
||||
if (co == null || id.isEmpty) return;
|
||||
|
||||
try {
|
||||
final parent = await UserService.getParent(id);
|
||||
if (!mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AdminParentEditModal(
|
||||
parent: parent,
|
||||
onSaved: () async {
|
||||
try {
|
||||
final refreshed = await UserService.getParent(widget.parent.user.id);
|
||||
if (!mounted) return;
|
||||
setState(() => _coParent = refreshed.coParent);
|
||||
} catch (_) {}
|
||||
widget.onSaved?.call();
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget? _coParentSubtitle() {
|
||||
final name = _coParentName();
|
||||
if (name == null) return null;
|
||||
|
||||
return Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Co-parent : ',
|
||||
style: TextStyle(fontSize: 13, color: Colors.black54),
|
||||
),
|
||||
InkWell(
|
||||
onTap: _saving ? null : _openCoParent,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 2),
|
||||
child: Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ValidationModalTheme.primaryActionBackground,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: ValidationModalTheme.primaryActionBackground
|
||||
.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
@@ -220,8 +283,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Détacher l\'enfant'),
|
||||
content: Text(
|
||||
'Retirer ${child.fullName} de la fiche de ce parent ?\n'
|
||||
'(L\'enfant ne sera pas supprimé.)',
|
||||
'Retirer ${child.fullName} du foyer (tous les responsables) ?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -246,8 +308,9 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
||||
if (!mounted) return;
|
||||
await _reloadChildren();
|
||||
if (!mounted) return;
|
||||
widget.onSaved?.call();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Enfant détaché')),
|
||||
const SnackBar(content: Text('Enfant détaché du foyer')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
@@ -258,41 +321,11 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
||||
}
|
||||
|
||||
Future<void> _attachChild() async {
|
||||
List<EnfantAdminModel> all;
|
||||
try {
|
||||
all = await UserService.getEnfants();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final linkedIds = _children.map((c) => c.id).toSet();
|
||||
final candidates = all.where((e) => !linkedIds.contains(e.id)).toList();
|
||||
if (candidates.isEmpty) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Aucun enfant disponible à rattacher')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
final selected = await showDialog<EnfantAdminModel>(
|
||||
context: context,
|
||||
builder: (ctx) => SimpleDialog(
|
||||
title: const Text('Rattacher un enfant'),
|
||||
children: candidates
|
||||
.map(
|
||||
(e) => SimpleDialogOption(
|
||||
onPressed: () => Navigator.pop(ctx, e),
|
||||
child: Text(e.fullName),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
final selected = await AdminSelectEnfantModal.show(
|
||||
context,
|
||||
excludeIds: _children.map((c) => c.id).toSet(),
|
||||
title: 'Rattacher un enfant',
|
||||
);
|
||||
if (selected == null || !mounted) return;
|
||||
|
||||
@@ -304,8 +337,9 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
||||
if (!mounted) return;
|
||||
await _reloadChildren();
|
||||
if (!mounted) return;
|
||||
widget.onSaved?.call();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Enfant rattaché')),
|
||||
const SnackBar(content: Text('Enfant rattaché au foyer')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
@@ -395,13 +429,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
||||
),
|
||||
if (_coParentSubtitle() != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_coParentSubtitle()!,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
_coParentSubtitle()!,
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/utils/am_vigilance.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
||||
|
||||
List<String> _amSelectSubtitleLines(AssistanteMaternelleModel am) {
|
||||
final lines = <String>[];
|
||||
final zone = (am.residenceCity ?? '').trim();
|
||||
if (zone.isNotEmpty) lines.add('Zone : $zone');
|
||||
final agrement = (am.approvalNumber ?? '').trim();
|
||||
if (agrement.isNotEmpty) lines.add('Agrément : $agrement');
|
||||
final max = am.maxChildren;
|
||||
final free = amExpectedPlacesAvailable(
|
||||
maxChildren: max,
|
||||
childrenCount: am.children.length,
|
||||
) ??
|
||||
am.placesAvailable;
|
||||
if (free != null || max != null) {
|
||||
lines.add('Places libres : ${free ?? '–'} / capa. ${max ?? '–'}');
|
||||
}
|
||||
lines.add('${am.children.length} enfant(s)');
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// Sélection d'une AM à rattacher (fiche enfant) — ticket #147.
|
||||
/// S'appuie sur [AdminSelectListModal] (shell partagé avec #146).
|
||||
class AdminSelectAmModal {
|
||||
AdminSelectAmModal._();
|
||||
|
||||
static Future<AssistanteMaternelleModel?> show(
|
||||
BuildContext context, {
|
||||
Set<String> excludeIds = const {},
|
||||
String title = 'Choisir une assistante maternelle',
|
||||
}) {
|
||||
return AdminSelectListModal.show<AssistanteMaternelleModel>(
|
||||
context,
|
||||
title: title,
|
||||
searchHint: 'Rechercher par nom, prénom ou zone…',
|
||||
emptyMessage: 'Aucune assistante maternelle disponible',
|
||||
noResultsMessage: 'Aucune AM avec place libre pour cette recherche',
|
||||
toggleFilter: const AdminSelectToggleFilter<AssistanteMaternelleModel>(
|
||||
label: 'Libre',
|
||||
initialValue: true,
|
||||
whenEnabled: amHasFreePlace,
|
||||
),
|
||||
loadItems: () async {
|
||||
final list = await UserService.getAssistantesMaternelles();
|
||||
return list
|
||||
.where((am) => !excludeIds.contains(am.user.id))
|
||||
.toList()
|
||||
..sort(
|
||||
(a, b) => a.user.fullName
|
||||
.toLowerCase()
|
||||
.compareTo(b.user.fullName.toLowerCase()),
|
||||
);
|
||||
},
|
||||
matchesQuery: (am, q) {
|
||||
final u = am.user;
|
||||
final name = u.fullName.toLowerCase();
|
||||
final fn = (u.prenom ?? '').toLowerCase();
|
||||
final ln = (u.nom ?? '').toLowerCase();
|
||||
final zone = (am.residenceCity ?? '').toLowerCase();
|
||||
final agrement = (am.approvalNumber ?? '').toLowerCase();
|
||||
return name.contains(q) ||
|
||||
fn.contains(q) ||
|
||||
ln.contains(q) ||
|
||||
zone.contains(q) ||
|
||||
agrement.contains(q);
|
||||
},
|
||||
resolveSelect: (ctx, am, reload) =>
|
||||
_resolveAmSelection(ctx, am, reload),
|
||||
itemBuilder: (context, am, onSelect) {
|
||||
final full = !amHasFreePlace(am);
|
||||
return AdminUserCard(
|
||||
title: am.user.fullName,
|
||||
avatarUrl: am.user.photoUrl,
|
||||
fallbackIcon: Icons.face,
|
||||
subtitleLines: _amSelectSubtitleLines(am),
|
||||
vigilanceTooltip: amPlacesVigilanceMessage(am),
|
||||
onCardTap: onSelect,
|
||||
margin: const EdgeInsets.only(bottom: 4),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 5,
|
||||
),
|
||||
backgroundColor: full ? const Color(0xFFFFEBEE) : null,
|
||||
borderColor: full ? Colors.red.shade200 : null,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_link),
|
||||
tooltip: 'Rattacher',
|
||||
onPressed: onSelect,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Future<AssistanteMaternelleModel?> _resolveAmSelection(
|
||||
BuildContext context,
|
||||
AssistanteMaternelleModel am,
|
||||
Future<void> Function() reloadList,
|
||||
) async {
|
||||
if (amHasFreePlace(am)) return am;
|
||||
|
||||
final selected = await showDialog<AssistanteMaternelleModel>(
|
||||
context: context,
|
||||
builder: (ctx) => _AmNoPlaceWarningDialog(initialAm: am),
|
||||
);
|
||||
await reloadList();
|
||||
return selected;
|
||||
}
|
||||
}
|
||||
|
||||
/// Avertissement AM saturée + lien vers la fiche pour ajuster les places.
|
||||
class _AmNoPlaceWarningDialog extends StatefulWidget {
|
||||
final AssistanteMaternelleModel initialAm;
|
||||
|
||||
const _AmNoPlaceWarningDialog({required this.initialAm});
|
||||
|
||||
@override
|
||||
State<_AmNoPlaceWarningDialog> createState() =>
|
||||
_AmNoPlaceWarningDialogState();
|
||||
}
|
||||
|
||||
class _AmNoPlaceWarningDialogState extends State<_AmNoPlaceWarningDialog> {
|
||||
late AssistanteMaternelleModel _am;
|
||||
TapGestureRecognizer? _linkRecognizer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_am = widget.initialAm;
|
||||
_linkRecognizer = TapGestureRecognizer()..onTap = _openAmFiche;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_linkRecognizer?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _openAmFiche() async {
|
||||
if (!mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AdminAmEditModal(
|
||||
assistante: _am,
|
||||
onSaved: () async {
|
||||
try {
|
||||
final fresh =
|
||||
await UserService.getAssistanteMaternelle(_am.user.id);
|
||||
if (mounted) setState(() => _am = fresh);
|
||||
} catch (_) {}
|
||||
},
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
final fresh = await UserService.getAssistanteMaternelle(_am.user.id);
|
||||
if (!mounted) return;
|
||||
setState(() => _am = fresh);
|
||||
if (amHasFreePlace(fresh)) {
|
||||
Navigator.of(context).pop(fresh);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final name = _am.user.fullName.trim().isNotEmpty
|
||||
? _am.user.fullName.trim()
|
||||
: 'Cette assistante maternelle';
|
||||
const linkColor = ValidationModalTheme.primaryActionBackground;
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('Plus de place disponible'),
|
||||
content: Text.rich(
|
||||
TextSpan(
|
||||
style: const TextStyle(fontSize: 14, color: Colors.black87, height: 1.4),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '$name n\'a plus de place libre pour accueillir '
|
||||
'un enfant supplémentaire.\n\n',
|
||||
),
|
||||
const TextSpan(text: 'Vous pouvez '),
|
||||
TextSpan(
|
||||
text: 'ouvrir sa fiche',
|
||||
style: TextStyle(
|
||||
color: linkColor,
|
||||
decoration: TextDecoration.underline,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
recognizer: _linkRecognizer,
|
||||
),
|
||||
const TextSpan(
|
||||
text: ' pour modifier la capacité ou les places, '
|
||||
'puis la sélectionner si une place se libère.',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
|
||||
|
||||
/// Sélection d'un enfant à rattacher (fiche AM / fiche parent) — ticket #146.
|
||||
/// S'appuie sur [AdminSelectListModal] (shell partagé avec #147).
|
||||
class AdminSelectEnfantModal {
|
||||
AdminSelectEnfantModal._();
|
||||
|
||||
static Future<EnfantAdminModel?> show(
|
||||
BuildContext context, {
|
||||
Set<String> excludeIds = const {},
|
||||
String title = 'Rattacher un enfant',
|
||||
/// Affiché uniquement depuis la fiche AM : filtre les enfants déjà en garde.
|
||||
bool showSansGardeFilter = false,
|
||||
}) {
|
||||
return AdminSelectListModal.show<EnfantAdminModel>(
|
||||
context,
|
||||
title: title,
|
||||
searchHint: 'Rechercher par nom ou prénom…',
|
||||
emptyMessage: 'Aucun enfant disponible à rattacher',
|
||||
noResultsMessage: showSansGardeFilter
|
||||
? 'Aucun enfant sans garde pour cette recherche'
|
||||
: 'Aucun résultat pour cette recherche',
|
||||
toggleFilter: showSansGardeFilter
|
||||
? AdminSelectToggleFilter<EnfantAdminModel>(
|
||||
label: 'Sans garde',
|
||||
initialValue: true,
|
||||
whenEnabled: (e) =>
|
||||
normalizeEnfantStatus(e.status) == 'sans_garde',
|
||||
)
|
||||
: null,
|
||||
loadItems: () async {
|
||||
final list = await UserService.getEnfants();
|
||||
return list
|
||||
.where((e) => !excludeIds.contains(e.id))
|
||||
.toList()
|
||||
..sort(
|
||||
(a, b) =>
|
||||
a.fullName.toLowerCase().compareTo(b.fullName.toLowerCase()),
|
||||
);
|
||||
},
|
||||
matchesQuery: (e, q) {
|
||||
final name = e.fullName.toLowerCase();
|
||||
final fn = (e.firstName ?? '').toLowerCase();
|
||||
final ln = (e.lastName ?? '').toLowerCase();
|
||||
return name.contains(q) || fn.contains(q) || ln.contains(q);
|
||||
},
|
||||
itemBuilder: (context, e, onSelect) {
|
||||
return AdminEnfantUserCard.fromEnfant(
|
||||
e,
|
||||
onCardTap: onSelect,
|
||||
margin: const EdgeInsets.only(bottom: 4),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 5,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_link),
|
||||
tooltip: 'Rattacher',
|
||||
onPressed: onSelect,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/parent_model.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||
|
||||
/// Foyer / famille sélectionnable pour rattacher un nouvel enfant (#132 / #157).
|
||||
class AdminFamilleFoyer {
|
||||
/// Parent pivot pour `POST /enfants` (`parent_user_id`).
|
||||
final String pivotParentUserId;
|
||||
/// Co-parent éventuel (rattachement foyer #157).
|
||||
final String? coParentUserId;
|
||||
final String? numeroDossier;
|
||||
final String displayTitle;
|
||||
final List<String> parentNames;
|
||||
|
||||
const AdminFamilleFoyer({
|
||||
required this.pivotParentUserId,
|
||||
required this.displayTitle,
|
||||
required this.parentNames,
|
||||
this.coParentUserId,
|
||||
this.numeroDossier,
|
||||
});
|
||||
|
||||
/// Parents du foyer à lier à l’enfant (pivot puis co-parent).
|
||||
List<String> get parentUserIds {
|
||||
final ids = <String>[pivotParentUserId];
|
||||
final co = (coParentUserId ?? '').trim();
|
||||
if (co.isNotEmpty && co != pivotParentUserId) ids.add(co);
|
||||
return ids;
|
||||
}
|
||||
|
||||
String get subtitle {
|
||||
final parts = <String>[];
|
||||
final dossier = (numeroDossier ?? '').trim();
|
||||
if (dossier.isNotEmpty) parts.add('Dossier $dossier');
|
||||
if (parentNames.isNotEmpty) {
|
||||
parts.add('Responsables : ${parentNames.join(', ')}');
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
/// Construit la liste des foyers uniques à partir de `GET /parents`.
|
||||
List<AdminFamilleFoyer> buildFamilleFoyers(List<ParentModel> parents) {
|
||||
final seenDossiers = <String>{};
|
||||
final seenUserIds = <String>{};
|
||||
final foyers = <AdminFamilleFoyer>[];
|
||||
|
||||
for (final p in parents) {
|
||||
final dossier = (p.user.numeroDossier ?? '').trim();
|
||||
if (dossier.isNotEmpty) {
|
||||
if (seenDossiers.contains(dossier)) continue;
|
||||
seenDossiers.add(dossier);
|
||||
} else if (seenUserIds.contains(p.user.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenUserIds.add(p.user.id);
|
||||
final co = p.coParent;
|
||||
if (co != null) seenUserIds.add(co.id);
|
||||
|
||||
final names = <String>[
|
||||
if (p.user.fullName.trim().isNotEmpty) p.user.fullName.trim(),
|
||||
if (co != null && co.fullName.trim().isNotEmpty) co.fullName.trim(),
|
||||
];
|
||||
|
||||
final title = dossier.isNotEmpty
|
||||
? 'Dossier $dossier'
|
||||
: (names.isNotEmpty ? names.first : 'Famille');
|
||||
|
||||
foyers.add(
|
||||
AdminFamilleFoyer(
|
||||
pivotParentUserId: p.user.id,
|
||||
coParentUserId: co?.id,
|
||||
numeroDossier: dossier.isNotEmpty ? dossier : null,
|
||||
displayTitle: title,
|
||||
parentNames: names,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
foyers.sort(
|
||||
(a, b) => a.displayTitle.toLowerCase().compareTo(b.displayTitle.toLowerCase()),
|
||||
);
|
||||
return foyers;
|
||||
}
|
||||
|
||||
/// Sélection d'une famille / dossier pour créer un enfant — ticket #132.
|
||||
class AdminSelectFamilleModal {
|
||||
AdminSelectFamilleModal._();
|
||||
|
||||
static Future<AdminFamilleFoyer?> show(
|
||||
BuildContext context, {
|
||||
String title = 'Choisir une famille',
|
||||
}) {
|
||||
return AdminSelectListModal.show<AdminFamilleFoyer>(
|
||||
context,
|
||||
title: title,
|
||||
searchHint: 'Rechercher par dossier, nom…',
|
||||
emptyMessage: 'Aucune famille disponible',
|
||||
noResultsMessage: 'Aucun résultat pour cette recherche',
|
||||
loadItems: () async {
|
||||
final parents = await UserService.getParents();
|
||||
return buildFamilleFoyers(parents);
|
||||
},
|
||||
matchesQuery: (f, q) {
|
||||
final dossier = (f.numeroDossier ?? '').toLowerCase();
|
||||
final title = f.displayTitle.toLowerCase();
|
||||
final names = f.parentNames.join(' ').toLowerCase();
|
||||
return dossier.contains(q) || title.contains(q) || names.contains(q);
|
||||
},
|
||||
itemBuilder: (context, f, onSelect) {
|
||||
return AdminUserCard(
|
||||
title: f.displayTitle,
|
||||
fallbackIcon: Icons.family_restroom,
|
||||
subtitleLines: [
|
||||
if (f.parentNames.isNotEmpty) f.parentNames.join(', '),
|
||||
if ((f.numeroDossier ?? '').isNotEmpty)
|
||||
'Dossier ${f.numeroDossier}',
|
||||
],
|
||||
onCardTap: onSelect,
|
||||
margin: const EdgeInsets.only(bottom: 4),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 5,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.check_circle_outline),
|
||||
tooltip: 'Choisir',
|
||||
onPressed: onSelect,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
||||
|
||||
/// Filtre optionnel (switch) sur la même ligne que la barre de recherche.
|
||||
class AdminSelectToggleFilter<T> {
|
||||
final String label;
|
||||
final bool initialValue;
|
||||
|
||||
/// Si le switch est activé, ne garde que les éléments pour lesquels
|
||||
/// [whenEnabled] renvoie `true`.
|
||||
final bool Function(T item) whenEnabled;
|
||||
|
||||
const AdminSelectToggleFilter({
|
||||
required this.label,
|
||||
required this.whenEnabled,
|
||||
this.initialValue = true,
|
||||
});
|
||||
}
|
||||
|
||||
/// Shell générique « rechercher + liste + sélection » pour les modales admin.
|
||||
/// Utilisé par la sélection d'enfant (#146) et la sélection d'AM (#147).
|
||||
class AdminSelectListModal<T> extends StatefulWidget {
|
||||
final String title;
|
||||
final String searchHint;
|
||||
final Future<List<T>> Function() loadItems;
|
||||
final bool Function(T item, String query) matchesQuery;
|
||||
final Widget Function(
|
||||
BuildContext context,
|
||||
T item,
|
||||
VoidCallback onSelect,
|
||||
) itemBuilder;
|
||||
final String emptyMessage;
|
||||
final String noResultsMessage;
|
||||
final double modalWidth;
|
||||
|
||||
/// Hauteur d'une carte (pour dimensionner la liste à ≥ [minVisibleCards]).
|
||||
final double cardExtent;
|
||||
|
||||
/// Nombre minimum de cartes visibles dans la zone scrollable.
|
||||
final int minVisibleCards;
|
||||
|
||||
/// Switch optionnel à droite du champ de recherche (ex. « Sans garde », « Libre »).
|
||||
final AdminSelectToggleFilter<T>? toggleFilter;
|
||||
|
||||
/// Si fourni, appelé avant de valider la sélection.
|
||||
/// Retourne l'élément à pop (éventuellement rafraîchi), ou `null` pour annuler.
|
||||
final Future<T?> Function(
|
||||
BuildContext context,
|
||||
T item,
|
||||
Future<void> Function() reload,
|
||||
)? resolveSelect;
|
||||
|
||||
const AdminSelectListModal({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.loadItems,
|
||||
required this.matchesQuery,
|
||||
required this.itemBuilder,
|
||||
this.searchHint = 'Rechercher…',
|
||||
this.emptyMessage = 'Aucun élément disponible',
|
||||
this.noResultsMessage = 'Aucun résultat pour cette recherche',
|
||||
this.modalWidth = 930,
|
||||
this.cardExtent = 52,
|
||||
this.minVisibleCards = 8,
|
||||
this.toggleFilter,
|
||||
this.resolveSelect,
|
||||
});
|
||||
|
||||
static Future<T?> show<T>(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required Future<List<T>> Function() loadItems,
|
||||
required bool Function(T item, String query) matchesQuery,
|
||||
required Widget Function(
|
||||
BuildContext context,
|
||||
T item,
|
||||
VoidCallback onSelect,
|
||||
) itemBuilder,
|
||||
String searchHint = 'Rechercher…',
|
||||
String emptyMessage = 'Aucun élément disponible',
|
||||
String noResultsMessage = 'Aucun résultat pour cette recherche',
|
||||
double modalWidth = 930,
|
||||
double cardExtent = 52,
|
||||
int minVisibleCards = 8,
|
||||
AdminSelectToggleFilter<T>? toggleFilter,
|
||||
Future<T?> Function(
|
||||
BuildContext context,
|
||||
T item,
|
||||
Future<void> Function() reload,
|
||||
)? resolveSelect,
|
||||
}) {
|
||||
return showDialog<T>(
|
||||
context: context,
|
||||
builder: (ctx) => AdminSelectListModal<T>(
|
||||
title: title,
|
||||
loadItems: loadItems,
|
||||
matchesQuery: matchesQuery,
|
||||
itemBuilder: itemBuilder,
|
||||
searchHint: searchHint,
|
||||
emptyMessage: emptyMessage,
|
||||
noResultsMessage: noResultsMessage,
|
||||
modalWidth: modalWidth,
|
||||
cardExtent: cardExtent,
|
||||
minVisibleCards: minVisibleCards,
|
||||
toggleFilter: toggleFilter,
|
||||
resolveSelect: resolveSelect,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<AdminSelectListModal<T>> createState() =>
|
||||
_AdminSelectListModalState<T>();
|
||||
}
|
||||
|
||||
class _AdminSelectListModalState<T> extends State<AdminSelectListModal<T>> {
|
||||
final _searchCtrl = TextEditingController();
|
||||
List<T> _all = [];
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
late bool _toggleOn;
|
||||
bool _resolving = false;
|
||||
|
||||
double get _listHeight =>
|
||||
widget.cardExtent * widget.minVisibleCards;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_toggleOn = widget.toggleFilter?.initialValue ?? false;
|
||||
_searchCtrl.addListener(() => setState(() {}));
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final list = await widget.loadItems();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_all = list;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = e.toString().replaceFirst('Exception: ', '');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleSelect(T item) async {
|
||||
if (_resolving) return;
|
||||
final resolve = widget.resolveSelect;
|
||||
if (resolve == null) {
|
||||
Navigator.of(context).pop(item);
|
||||
return;
|
||||
}
|
||||
setState(() => _resolving = true);
|
||||
try {
|
||||
final chosen = await resolve(context, item, _load);
|
||||
if (!mounted || chosen == null) return;
|
||||
Navigator.of(context).pop(chosen);
|
||||
} finally {
|
||||
if (mounted) setState(() => _resolving = false);
|
||||
}
|
||||
}
|
||||
|
||||
List<T> get _filtered {
|
||||
var list = _all;
|
||||
final toggle = widget.toggleFilter;
|
||||
if (toggle != null && _toggleOn) {
|
||||
list = list.where(toggle.whenEnabled).toList();
|
||||
}
|
||||
final q = _searchCtrl.text.trim().toLowerCase();
|
||||
if (q.isEmpty) return list;
|
||||
return list.where((item) => widget.matchesQuery(item, q)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final toggle = widget.toggleFilter;
|
||||
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: widget.modalWidth),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 16, 4, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: 'Fermer',
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchCtrl,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
hintText: widget.searchHint,
|
||||
prefixIcon: const Icon(Icons.search, size: 20),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (toggle != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
toggle.label,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Switch(
|
||||
value: _toggleOn,
|
||||
activeColor:
|
||||
ValidationModalTheme.primaryActionBackground,
|
||||
onChanged: (v) => setState(() => _toggleOn = v),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: _listHeight,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: _buildBody(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_loading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: ValidationModalTheme.primaryActionBackground,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 40, color: Colors.red.shade400),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.red.shade700),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton.icon(
|
||||
onPressed: _load,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final items = _filtered;
|
||||
if (_all.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
widget.emptyMessage,
|
||||
style: const TextStyle(fontSize: 14, color: Colors.black54),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (items.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
widget.noResultsMessage,
|
||||
style: const TextStyle(fontSize: 14, color: Colors.black54),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
|
||||
itemExtent: widget.cardExtent,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, i) {
|
||||
final item = items[i];
|
||||
return widget.itemBuilder(
|
||||
context,
|
||||
item,
|
||||
() => _handleSelect(item),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,14 @@ class AdminUserCard extends StatefulWidget {
|
||||
final Color? backgroundColor;
|
||||
final Color? titleColor;
|
||||
final Color? infoColor;
|
||||
/// Fond du cercle avatar / icône (défaut lavande admin).
|
||||
final Color? avatarBackgroundColor;
|
||||
/// Couleur de l’icône fallback (défaut violet admin).
|
||||
final Color? avatarIconColor;
|
||||
final String? vigilanceTooltip;
|
||||
final VoidCallback? onCardTap;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final EdgeInsetsGeometry? contentPadding;
|
||||
|
||||
const AdminUserCard({
|
||||
super.key,
|
||||
@@ -26,8 +32,12 @@ class AdminUserCard extends StatefulWidget {
|
||||
this.backgroundColor,
|
||||
this.titleColor,
|
||||
this.infoColor,
|
||||
this.avatarBackgroundColor,
|
||||
this.avatarIconColor,
|
||||
this.vigilanceTooltip,
|
||||
this.onCardTap,
|
||||
this.margin,
|
||||
this.contentPadding,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -59,7 +69,7 @@ class _AdminUserCardState extends State<AdminUserCard> {
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
hoverColor: const Color(0x149CC5C0),
|
||||
child: Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
margin: widget.margin ?? const EdgeInsets.only(bottom: 12),
|
||||
elevation: 0,
|
||||
color: widget.backgroundColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -67,7 +77,8 @@ class _AdminUserCardState extends State<AdminUserCard> {
|
||||
side: BorderSide(color: widget.borderColor ?? Colors.grey.shade300),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
padding: widget.contentPadding ??
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildAvatar(avatarUrl),
|
||||
@@ -86,7 +97,10 @@ class _AdminUserCardState extends State<AdminUserCard> {
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
// flex: 0 → largeur du nom ; le reste va aux infos
|
||||
// (évite le partage 50/50 qui tronque « Responsables »).
|
||||
Flexible(
|
||||
flex: 0,
|
||||
fit: FlexFit.loose,
|
||||
child: Text(
|
||||
widget.title.isNotEmpty ? widget.title : 'Sans nom',
|
||||
@@ -151,8 +165,8 @@ class _AdminUserCardState extends State<AdminUserCard> {
|
||||
|
||||
Widget _buildAvatar(String url) {
|
||||
const size = 28.0;
|
||||
const bg = Color(0xFFEDE5FA);
|
||||
const iconColor = Color(0xFF6B3FA0);
|
||||
final bg = widget.avatarBackgroundColor ?? const Color(0xFFEDE5FA);
|
||||
final iconColor = widget.avatarIconColor ?? const Color(0xFF6B3FA0);
|
||||
|
||||
if (url.isEmpty) {
|
||||
return CircleAvatar(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,7 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:p_tits_pas/utils/email_utils.dart';
|
||||
import 'package:p_tits_pas/utils/nir_utils.dart';
|
||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||
import 'package:p_tits_pas/utils/postal_utils.dart';
|
||||
import 'admin_detail_modal.dart';
|
||||
|
||||
/// Réglages des formulaires validation / wizard AM — **jouer sur ces 3 leviers**.
|
||||
class ValidationFormMetrics {
|
||||
ValidationFormMetrics._();
|
||||
|
||||
// --- 1. Titres de section ---
|
||||
static const double sectionTitleFontSize = 16;
|
||||
static const double sectionTitleGapBelow = 12;
|
||||
|
||||
// --- 2. TF : texte intérieur + padding vertical (= hauteur) ---
|
||||
static const double fieldTextFontSize = 14;
|
||||
static const double fieldContentPaddingV = 12;
|
||||
static const double fieldContentPaddingH = 12;
|
||||
/// Hauteur estimée du TF (texte + padding haut/bas + bordure).
|
||||
static const double fieldHeight =
|
||||
fieldTextFontSize + fieldContentPaddingV * 2 + 4;
|
||||
|
||||
// --- 3. Espace entre les lignes de TF ---
|
||||
static const double rowGapBelow = 12;
|
||||
|
||||
// Libellé au-dessus du TF (titre du champ)
|
||||
static const double fieldLabelFontSize = 13;
|
||||
static const double fieldLabelGapBelow = 4;
|
||||
|
||||
static const TextStyle fieldTextStyle = TextStyle(
|
||||
color: Colors.black87,
|
||||
fontSize: fieldTextFontSize,
|
||||
);
|
||||
|
||||
static double get sectionTitleBlockHeight =>
|
||||
sectionTitleFontSize * 1.25 + sectionTitleGapBelow;
|
||||
|
||||
/// Libellé : marge au-dessus de [fieldLabelFontSize] (métriques police).
|
||||
static double get labeledRowHeight =>
|
||||
fieldLabelFontSize * 1.25 +
|
||||
fieldLabelGapBelow +
|
||||
fieldHeight +
|
||||
rowGapBelow;
|
||||
|
||||
/// Corps modale AM / famille : padding wizard + titre + [rows] lignes + nav.
|
||||
static double shellBodyHeightForRows(int rows) =>
|
||||
20 * 2 + // padding wizard
|
||||
4 + // espace haut
|
||||
sectionTitleBlockHeight +
|
||||
rows * labeledRowHeight +
|
||||
24 + // avant nav
|
||||
48; // boutons (+ marge anti-overflow)
|
||||
}
|
||||
|
||||
/// Bloc type formulaire (titre de section + champs read-only) pour les modales de validation.
|
||||
/// [rowLayout] : même disposition que la création de compte, ex. [2, 2, 1, 2] = ligne de 2, ligne de 2, plein largeur, ligne de 2.
|
||||
/// [rowFlex] : flex par index de ligne (optionnel). Ex. {3: [2, 5]} = 4e ligne : code postal étroit (2), ville large (5).
|
||||
@@ -16,12 +68,16 @@ class ValidationDetailSection extends StatelessWidget {
|
||||
/// Flex par ligne (index de ligne -> [flex1, flex2, ...]). Ex. {3: [2, 5]} pour Code postal | Ville.
|
||||
final Map<int, List<int>>? rowFlex;
|
||||
|
||||
/// Remplit la hauteur disponible (wizard AM étapes 1–2).
|
||||
final bool expandVertically;
|
||||
|
||||
const ValidationDetailSection({
|
||||
super.key,
|
||||
this.title,
|
||||
required this.fields,
|
||||
this.rowLayout,
|
||||
this.rowFlex,
|
||||
this.expandVertically = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -30,6 +86,7 @@ class ValidationDetailSection extends StatelessWidget {
|
||||
title: title,
|
||||
rowLayout: rowLayout,
|
||||
rowFlex: rowFlex,
|
||||
expandVertically: expandVertically,
|
||||
fields: fields
|
||||
.map(
|
||||
(f) => ValidationLabeledField(
|
||||
@@ -49,6 +106,8 @@ class ValidationFormGrid extends StatelessWidget {
|
||||
final List<int>? rowLayout;
|
||||
final Map<int, List<int>>? rowFlex;
|
||||
final bool compact;
|
||||
/// Répartit la hauteur dispo entre les lignes (remplit le blanc sans scroll).
|
||||
final bool expandVertically;
|
||||
|
||||
const ValidationFormGrid({
|
||||
super.key,
|
||||
@@ -57,6 +116,7 @@ class ValidationFormGrid extends StatelessWidget {
|
||||
this.rowLayout,
|
||||
this.rowFlex,
|
||||
this.compact = false,
|
||||
this.expandVertically = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -64,7 +124,7 @@ class ValidationFormGrid extends StatelessWidget {
|
||||
final layout = rowLayout ?? List.filled(fields.length, 1);
|
||||
int index = 0;
|
||||
int rowIndex = 0;
|
||||
final rows = <Widget>[];
|
||||
final rowWidgets = <Widget>[];
|
||||
for (final count in layout) {
|
||||
if (index >= fields.length) break;
|
||||
final rowFields = fields.skip(index).take(count).toList();
|
||||
@@ -72,48 +132,74 @@ class ValidationFormGrid extends StatelessWidget {
|
||||
if (rowFields.isEmpty) continue;
|
||||
final flexForRow = rowFlex?[rowIndex];
|
||||
rowIndex++;
|
||||
final labeled = rowFields
|
||||
.map(
|
||||
(f) => ValidationLabeledField(
|
||||
label: f.label,
|
||||
field: f.field,
|
||||
expand: expandVertically,
|
||||
labelTrailing: f.labelTrailing,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
Widget row;
|
||||
if (count == 1) {
|
||||
rows.add(Padding(
|
||||
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
|
||||
child: rowFields.first,
|
||||
));
|
||||
row = labeled.first;
|
||||
} else {
|
||||
rows.add(Padding(
|
||||
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (int i = 0; i < rowFields.length; i++) ...[
|
||||
if (i > 0) SizedBox(width: compact ? 12 : 16),
|
||||
Expanded(
|
||||
flex: (flexForRow != null && i < flexForRow.length)
|
||||
? flexForRow[i]
|
||||
: 1,
|
||||
child: rowFields[i],
|
||||
),
|
||||
],
|
||||
row = Row(
|
||||
crossAxisAlignment: expandVertically
|
||||
? CrossAxisAlignment.stretch
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (int i = 0; i < labeled.length; i++) ...[
|
||||
if (i > 0) SizedBox(width: compact ? 12 : 16),
|
||||
Expanded(
|
||||
flex: (flexForRow != null && i < flexForRow.length)
|
||||
? flexForRow[i]
|
||||
: 1,
|
||||
child: labeled[i],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (expandVertically) {
|
||||
rowWidgets.add(Expanded(child: row));
|
||||
} else {
|
||||
rowWidgets.add(Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: compact ? 8 : ValidationFormMetrics.rowGapBelow,
|
||||
),
|
||||
child: row,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
final showTitle = title != null && title!.trim().isNotEmpty;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisSize: expandVertically ? MainAxisSize.max : MainAxisSize.min,
|
||||
children: [
|
||||
if (showTitle) ...[
|
||||
Text(
|
||||
title!.trim(),
|
||||
style: TextStyle(
|
||||
fontSize: compact ? 15 : 16,
|
||||
fontSize: compact
|
||||
? ValidationFormMetrics.sectionTitleFontSize - 1
|
||||
: ValidationFormMetrics.sectionTitleFontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
SizedBox(height: compact ? 8 : 12),
|
||||
SizedBox(
|
||||
height: compact
|
||||
? 8
|
||||
: ValidationFormMetrics.sectionTitleGapBelow,
|
||||
),
|
||||
],
|
||||
...rows,
|
||||
...rowWidgets,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -130,8 +216,8 @@ class ValidationFieldDecoration {
|
||||
fillColor: Colors.grey.shade50,
|
||||
hintText: hint,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: compact ? 10 : 12,
|
||||
vertical: compact ? 7 : 10,
|
||||
horizontal: compact ? 10 : ValidationFormMetrics.fieldContentPaddingH,
|
||||
vertical: compact ? 7 : ValidationFormMetrics.fieldContentPaddingV,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
@@ -180,29 +266,40 @@ class ValidationFieldDecoration {
|
||||
class ValidationLabeledField extends StatelessWidget {
|
||||
final String label;
|
||||
final Widget field;
|
||||
final bool expand;
|
||||
/// Widget aligné à droite sur la ligne du libellé (ex. switch « Même adresse »).
|
||||
final Widget? labelTrailing;
|
||||
|
||||
const ValidationLabeledField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.field,
|
||||
this.expand = false,
|
||||
this.labelTrailing,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final labelStyle = TextStyle(
|
||||
fontSize: ValidationFormMetrics.fieldLabelFontSize,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey.shade700,
|
||||
);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey.shade700,
|
||||
if (labelTrailing == null)
|
||||
Text(label, style: labelStyle)
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text(label, style: labelStyle)),
|
||||
labelTrailing!,
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
field,
|
||||
SizedBox(height: ValidationFormMetrics.fieldLabelGapBelow),
|
||||
if (expand) Expanded(child: field) else field,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -216,6 +313,7 @@ class ValidationEditableField extends StatelessWidget {
|
||||
final String? hintText;
|
||||
final int maxLines;
|
||||
final bool compact;
|
||||
final bool enabled;
|
||||
|
||||
const ValidationEditableField({
|
||||
super.key,
|
||||
@@ -225,6 +323,7 @@ class ValidationEditableField extends StatelessWidget {
|
||||
this.hintText,
|
||||
this.maxLines = 1,
|
||||
this.compact = false,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
static const double _compactFieldHeight = 34;
|
||||
@@ -256,6 +355,7 @@ class ValidationEditableField extends StatelessWidget {
|
||||
if (maxLines > 1) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
enabled: enabled,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLines: maxLines,
|
||||
@@ -264,13 +364,17 @@ class ValidationEditableField extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
if (!compact) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(color: Colors.black87, fontSize: 14),
|
||||
decoration: ValidationFieldDecoration.input(hint: hintText),
|
||||
return _validationFieldFillHeight(
|
||||
TextField(
|
||||
controller: controller,
|
||||
enabled: enabled,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLines: 1,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
style: ValidationFormMetrics.fieldTextStyle,
|
||||
decoration: ValidationFieldDecoration.input(hint: hintText),
|
||||
),
|
||||
);
|
||||
}
|
||||
return SizedBox(
|
||||
@@ -279,6 +383,7 @@ class ValidationEditableField extends StatelessWidget {
|
||||
decoration: _compactDecoration(),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
enabled: enabled,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLines: 1,
|
||||
@@ -295,6 +400,352 @@ class ValidationEditableField extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hauteur TF fixe ; en grille [expandVertically], étire jusqu’à la hauteur dispo.
|
||||
Widget _validationFieldFillHeight(Widget field) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
final h = (c.hasBoundedHeight && c.maxHeight.isFinite)
|
||||
? c.maxHeight
|
||||
: ValidationFormMetrics.fieldHeight;
|
||||
return SizedBox(
|
||||
height: h,
|
||||
width: double.infinity,
|
||||
child: field,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// E-mail style validation — même supervision que login / création de compte :
|
||||
/// [EmailMaxLengthFormatter] + à la perte de focus : trim/minuscules + validation.
|
||||
class ValidationEmailField extends StatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final String? hintText;
|
||||
final bool allowEmpty;
|
||||
|
||||
const ValidationEmailField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.hintText,
|
||||
this.allowEmpty = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ValidationEmailField> createState() => _ValidationEmailFieldState();
|
||||
}
|
||||
|
||||
class _ValidationEmailFieldState extends State<ValidationEmailField> {
|
||||
final GlobalKey<FormFieldState<String>> _fieldKey =
|
||||
GlobalKey<FormFieldState<String>>();
|
||||
late final FocusNode _focusNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode = FocusNode();
|
||||
_focusNode.addListener(_onFocusChange);
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
if (_focusNode.hasFocus) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _focusNode.hasFocus) return;
|
||||
final c = widget.controller;
|
||||
final normalized = normalizeEmailText(c.text);
|
||||
if (normalized != c.text) {
|
||||
c.value = TextEditingValue(
|
||||
text: normalized,
|
||||
selection: TextSelection.collapsed(offset: normalized.length),
|
||||
);
|
||||
}
|
||||
_fieldKey.currentState?.validate();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.removeListener(_onFocusChange);
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _validationFieldFillHeight(
|
||||
TextFormField(
|
||||
key: _fieldKey,
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
textInputAction: TextInputAction.next,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
inputFormatters: const [EmailMaxLengthFormatter()],
|
||||
style: ValidationFormMetrics.fieldTextStyle,
|
||||
decoration:
|
||||
ValidationFieldDecoration.input(hint: widget.hintText).copyWith(
|
||||
errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11),
|
||||
errorMaxLines: 2,
|
||||
),
|
||||
validator: (value) =>
|
||||
validateEmail(value, allowEmpty: widget.allowEmpty),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Code postal FR — même supervision que création de compte :
|
||||
/// chiffres uniquement (max 5) + validation à la perte de focus.
|
||||
class ValidationPostalCodeField extends StatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final String? hintText;
|
||||
final bool allowEmpty;
|
||||
final bool enabled;
|
||||
|
||||
const ValidationPostalCodeField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.hintText,
|
||||
this.allowEmpty = false,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ValidationPostalCodeField> createState() =>
|
||||
_ValidationPostalCodeFieldState();
|
||||
}
|
||||
|
||||
class _ValidationPostalCodeFieldState extends State<ValidationPostalCodeField> {
|
||||
final GlobalKey<FormFieldState<String>> _fieldKey =
|
||||
GlobalKey<FormFieldState<String>>();
|
||||
late final FocusNode _focusNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode = FocusNode();
|
||||
_focusNode.addListener(_onFocusChange);
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
if (_focusNode.hasFocus) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _focusNode.hasFocus) return;
|
||||
final c = widget.controller;
|
||||
final trimmed = c.text.trim();
|
||||
if (trimmed != c.text) {
|
||||
c.value = TextEditingValue(
|
||||
text: trimmed,
|
||||
selection: TextSelection.collapsed(offset: trimmed.length),
|
||||
);
|
||||
}
|
||||
_fieldKey.currentState?.validate();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.removeListener(_onFocusChange);
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _validationFieldFillHeight(
|
||||
TextFormField(
|
||||
key: _fieldKey,
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
enabled: widget.enabled,
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.next,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
inputFormatters: kFrenchPostalCodeInputFormatters,
|
||||
style: ValidationFormMetrics.fieldTextStyle,
|
||||
decoration: ValidationFieldDecoration.input(
|
||||
hint: widget.hintText ?? '5 chiffres',
|
||||
).copyWith(
|
||||
errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11),
|
||||
errorMaxLines: 2,
|
||||
),
|
||||
validator: (value) =>
|
||||
validateFrenchPostalCode(value, allowEmpty: widget.allowEmpty),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Téléphone FR — formatters live + [validateFrenchNationalPhone] à la perte de focus.
|
||||
class ValidationPhoneField extends StatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final String? hintText;
|
||||
final bool allowEmpty;
|
||||
|
||||
const ValidationPhoneField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.hintText,
|
||||
this.allowEmpty = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ValidationPhoneField> createState() => _ValidationPhoneFieldState();
|
||||
}
|
||||
|
||||
class _ValidationPhoneFieldState extends State<ValidationPhoneField> {
|
||||
final GlobalKey<FormFieldState<String>> _fieldKey =
|
||||
GlobalKey<FormFieldState<String>>();
|
||||
late final FocusNode _focusNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode = FocusNode();
|
||||
_focusNode.addListener(_onFocusChange);
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
if (_focusNode.hasFocus) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _focusNode.hasFocus) return;
|
||||
final c = widget.controller;
|
||||
final digits = normalizePhone(c.text);
|
||||
final formatted = digits.isEmpty ? '' : formatPhoneForDisplay(digits);
|
||||
if (formatted != c.text) {
|
||||
c.value = TextEditingValue(
|
||||
text: formatted,
|
||||
selection: TextSelection.collapsed(offset: formatted.length),
|
||||
);
|
||||
}
|
||||
_fieldKey.currentState?.validate();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.removeListener(_onFocusChange);
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _validationFieldFillHeight(
|
||||
TextFormField(
|
||||
key: _fieldKey,
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
keyboardType: TextInputType.phone,
|
||||
textInputAction: TextInputAction.next,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
inputFormatters: frenchPhoneInputFormatters,
|
||||
style: ValidationFormMetrics.fieldTextStyle,
|
||||
decoration:
|
||||
ValidationFieldDecoration.input(hint: widget.hintText).copyWith(
|
||||
errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11),
|
||||
errorMaxLines: 2,
|
||||
),
|
||||
validator: (value) =>
|
||||
validateFrenchNationalPhone(value, allowEmpty: widget.allowEmpty),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// NIR — formatage live ([NirInputFormatter]) + validation au fil de la saisie / blur.
|
||||
class ValidationNirField extends StatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final String? hintText;
|
||||
final bool allowEmpty;
|
||||
|
||||
const ValidationNirField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.hintText,
|
||||
this.allowEmpty = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ValidationNirField> createState() => _ValidationNirFieldState();
|
||||
}
|
||||
|
||||
class _ValidationNirFieldState extends State<ValidationNirField> {
|
||||
final GlobalKey<FormFieldState<String>> _fieldKey =
|
||||
GlobalKey<FormFieldState<String>>();
|
||||
late final FocusNode _focusNode;
|
||||
bool _blurred = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode = FocusNode();
|
||||
_focusNode.addListener(_onFocusChange);
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
if (_focusNode.hasFocus) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _focusNode.hasFocus) return;
|
||||
setState(() => _blurred = true);
|
||||
final c = widget.controller;
|
||||
final raw = nirToRaw(c.text).toUpperCase();
|
||||
final formatted = raw.isEmpty ? '' : formatNir(raw);
|
||||
if (formatted != c.text) {
|
||||
c.value = TextEditingValue(
|
||||
text: formatted,
|
||||
selection: TextSelection.collapsed(offset: formatted.length),
|
||||
);
|
||||
}
|
||||
_fieldKey.currentState?.validate();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.removeListener(_onFocusChange);
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String? _validator(String? value) {
|
||||
if (_blurred) {
|
||||
if (widget.allowEmpty && (value == null || value.trim().isEmpty)) {
|
||||
return null;
|
||||
}
|
||||
return validateNir(value);
|
||||
}
|
||||
return validateNirTyping(value);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _validationFieldFillHeight(
|
||||
TextFormField(
|
||||
key: _fieldKey,
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
keyboardType: TextInputType.text,
|
||||
textInputAction: TextInputAction.next,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
inputFormatters: const [NirInputFormatter()],
|
||||
style: ValidationFormMetrics.fieldTextStyle,
|
||||
decoration: ValidationFieldDecoration.input(
|
||||
hint: widget.hintText ?? '1 12 34 56 789 012 - 34',
|
||||
).copyWith(
|
||||
errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11),
|
||||
errorMaxLines: 2,
|
||||
),
|
||||
onChanged: (_) => _fieldKey.currentState?.validate(),
|
||||
validator: _validator,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Grille label/champ éditable (délègue à [ValidationFormGrid]).
|
||||
class ValidationEditableSection extends StatelessWidget {
|
||||
final List<ValidationLabeledField> fields;
|
||||
@@ -368,16 +819,19 @@ class _ValidationReadOnlyFieldState extends State<ValidationReadOnlyField> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!widget.compact && widget.maxLines == 1) {
|
||||
return TextField(
|
||||
controller: _controller,
|
||||
readOnly: true,
|
||||
enableInteractiveSelection: false,
|
||||
style: TextStyle(
|
||||
color: widget.error ? Colors.red.shade800 : Colors.black87,
|
||||
fontSize: 14,
|
||||
fontWeight: widget.error ? FontWeight.w600 : null,
|
||||
return _validationFieldFillHeight(
|
||||
TextField(
|
||||
controller: _controller,
|
||||
readOnly: true,
|
||||
enableInteractiveSelection: false,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
style: TextStyle(
|
||||
color: widget.error ? Colors.red.shade800 : Colors.black87,
|
||||
fontSize: ValidationFormMetrics.fieldTextFontSize,
|
||||
fontWeight: widget.error ? FontWeight.w600 : null,
|
||||
),
|
||||
decoration: ValidationFieldDecoration.readOnly(error: widget.error),
|
||||
),
|
||||
decoration: ValidationFieldDecoration.readOnly(error: widget.error),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
||||
final ValueChanged<int> onSubTabChange;
|
||||
final TextEditingController searchController;
|
||||
final String searchHint;
|
||||
/// Infobulle au survol de la barre de recherche (ex. critères de recherche).
|
||||
final String? searchTooltip;
|
||||
final Widget? filterControl;
|
||||
final VoidCallback? onAddPressed;
|
||||
final String addLabel;
|
||||
@@ -22,12 +24,16 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
||||
'Administrateurs',
|
||||
];
|
||||
|
||||
/// Aligné sur la taille des libellés d’onglets.
|
||||
static const double _searchFontSize = 13;
|
||||
|
||||
const DashboardUserManagementSubBar({
|
||||
Key? key,
|
||||
required this.selectedSubIndex,
|
||||
required this.onSubTabChange,
|
||||
required this.searchController,
|
||||
required this.searchHint,
|
||||
this.searchTooltip,
|
||||
this.filterControl,
|
||||
this.onAddPressed,
|
||||
this.addLabel = '+ Ajouter',
|
||||
@@ -54,22 +60,7 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
||||
_buildSubNavItem(context, labels[i], i),
|
||||
],
|
||||
const SizedBox(width: 36),
|
||||
_pillField(
|
||||
width: 320,
|
||||
child: TextField(
|
||||
controller: searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: searchHint,
|
||||
prefixIcon: const Icon(Icons.search, size: 18),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildSearchField(),
|
||||
if (filterControl != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
_pillField(width: 150, child: filterControl!),
|
||||
@@ -81,6 +72,40 @@ class DashboardUserManagementSubBar extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchField() {
|
||||
final field = _pillField(
|
||||
width: 320,
|
||||
child: TextField(
|
||||
controller: searchController,
|
||||
style: const TextStyle(fontSize: _searchFontSize),
|
||||
decoration: InputDecoration(
|
||||
hintText: searchHint,
|
||||
hintStyle: TextStyle(
|
||||
fontSize: _searchFontSize,
|
||||
fontStyle: FontStyle.normal,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: Colors.black45,
|
||||
),
|
||||
prefixIcon: const Icon(Icons.search, size: 18),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final tip = (searchTooltip ?? '').trim();
|
||||
if (tip.isEmpty) return field;
|
||||
return Tooltip(
|
||||
message: tip,
|
||||
preferBelow: false,
|
||||
waitDuration: const Duration(milliseconds: 400),
|
||||
child: field,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _pillField({required double width, required Widget child}) {
|
||||
return Container(
|
||||
width: width,
|
||||
|
||||
@@ -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:p_tits_pas/models/dossier_list_item.dart';
|
||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||
import 'package:p_tits_pas/services/auth_service.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
|
||||
import 'package:p_tits_pas/widgets/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/suppression_confirm_dialog.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||
|
||||
/// Onglet liste globale des enfants (doc 28 §6.2, ticket #137).
|
||||
@@ -25,13 +29,21 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
List<EnfantAdminModel> _enfants = [];
|
||||
bool _canDelete = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadRights();
|
||||
_loadEnfants();
|
||||
}
|
||||
|
||||
Future<void> _loadRights() async {
|
||||
final user = await AuthService.getCurrentUser();
|
||||
if (!mounted) return;
|
||||
setState(() => _canDelete = canDeleteMetier(user?.role));
|
||||
}
|
||||
|
||||
Future<void> _loadEnfants() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
@@ -59,10 +71,133 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
|
||||
builder: (ctx) => AdminChildDetailModal(
|
||||
enfant: enfant,
|
||||
onSaved: _loadEnfants,
|
||||
onDeleted: _loadEnfants,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<({String? numero, String famille, bool isLast, String? amLabel})>
|
||||
_resolveContext(
|
||||
EnfantAdminModel enfant,
|
||||
) async {
|
||||
String? amLabel;
|
||||
try {
|
||||
final am = await UserService.findAmForEnfant(enfant.id);
|
||||
if (am != null) {
|
||||
final label = formatDossierPersonLabel(
|
||||
nom: am.user.nom,
|
||||
prenom: am.user.prenom,
|
||||
email: am.user.email,
|
||||
);
|
||||
if (label.isNotEmpty) amLabel = label;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
final parentId = enfant.parentLinks
|
||||
.map((l) => l.parentId.trim())
|
||||
.firstWhere((id) => id.isNotEmpty, orElse: () => '');
|
||||
if (parentId.isEmpty) {
|
||||
return (numero: null, famille: '', isLast: true, amLabel: amLabel);
|
||||
}
|
||||
try {
|
||||
final parent = await UserService.getParent(parentId);
|
||||
final num = (parent.user.numeroDossier ?? '').trim();
|
||||
final famille = parent.user.fullName.isNotEmpty
|
||||
? parent.user.fullName
|
||||
: parent.user.email;
|
||||
if (num.isEmpty) {
|
||||
return (
|
||||
numero: null,
|
||||
famille: famille,
|
||||
isLast: true,
|
||||
amLabel: amLabel,
|
||||
);
|
||||
}
|
||||
final dossier = await UserService.getDossier(num);
|
||||
if (!dossier.isFamily) {
|
||||
return (
|
||||
numero: num,
|
||||
famille: famille,
|
||||
isLast: true,
|
||||
amLabel: amLabel,
|
||||
);
|
||||
}
|
||||
final n = dossier.asFamily.enfants.length;
|
||||
final names = dossier.asFamily.parents
|
||||
.map((p) => formatDossierPersonLabel(
|
||||
nom: p.nom,
|
||||
prenom: p.prenom,
|
||||
email: p.email,
|
||||
))
|
||||
.where((s) => s.isNotEmpty)
|
||||
.join(' - ');
|
||||
return (
|
||||
numero: num,
|
||||
famille: names.isNotEmpty ? names : famille,
|
||||
isLast: n <= 1,
|
||||
amLabel: amLabel,
|
||||
);
|
||||
} catch (_) {
|
||||
return (numero: null, famille: '', isLast: false, amLabel: amLabel);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(EnfantAdminModel enfant) async {
|
||||
final ctx = await _resolveContext(enfant);
|
||||
if (!mounted) return;
|
||||
|
||||
bool deleteDossier = false;
|
||||
if (ctx.isLast && (ctx.numero ?? '').isNotEmpty) {
|
||||
final choice = await showDernierEnfantSuppressionDialog(
|
||||
context,
|
||||
enfantName: enfant.fullName,
|
||||
familleLabel: ctx.famille,
|
||||
numeroDossier: ctx.numero!,
|
||||
amLabel: ctx.amLabel,
|
||||
);
|
||||
if (choice == null || !mounted) return;
|
||||
deleteDossier =
|
||||
choice == DernierEnfantSuppressionChoice.dossierAussi;
|
||||
} else {
|
||||
final confirmed = await showSuppressionConfirmDialog(
|
||||
context,
|
||||
title: 'Supprimer l\'enfant',
|
||||
subtitle: (ctx.numero ?? '').isEmpty
|
||||
? null
|
||||
: 'Dossier ${ctx.numero}',
|
||||
people: [SuppressionPersonLine.enfant(enfant.fullName)],
|
||||
footnotes: enfantSuppressionFootnotes(
|
||||
numeroDossier: ctx.numero,
|
||||
familleLabel: ctx.famille,
|
||||
amLabel: ctx.amLabel,
|
||||
),
|
||||
);
|
||||
if (!confirmed || !mounted) return;
|
||||
}
|
||||
|
||||
try {
|
||||
final result = await UserService.deleteEnfant(
|
||||
enfant.id,
|
||||
deleteDossier: deleteDossier,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final msg = (result['message'] ?? 'Enfant supprimé.').toString();
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||
await _loadEnfants();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
e is Exception
|
||||
? e.toString().replaceFirst('Exception: ', '')
|
||||
: 'Erreur suppression',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final query = widget.searchQuery.toLowerCase();
|
||||
@@ -72,7 +207,14 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
|
||||
normalizeEnfantStatus(e.status) ==
|
||||
normalizeEnfantStatus(widget.statusFilter);
|
||||
return matchesName && matchesStatus;
|
||||
}).toList();
|
||||
}).toList()
|
||||
..sort((a, b) {
|
||||
// Orphelins (#157) en tête, puis ordre alphabétique.
|
||||
final ao = a.hasNoResponsable ? 0 : 1;
|
||||
final bo = b.hasNoResponsable ? 0 : 1;
|
||||
if (ao != bo) return ao.compareTo(bo);
|
||||
return a.fullName.toLowerCase().compareTo(b.fullName.toLowerCase());
|
||||
});
|
||||
|
||||
return UserList(
|
||||
isLoading: _isLoading,
|
||||
@@ -91,6 +233,10 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
|
||||
tooltip: 'Voir / modifier',
|
||||
onPressed: () => _openEnfant(enfant),
|
||||
),
|
||||
if (_canDelete)
|
||||
suppressionIconButton(
|
||||
onPressed: () => _confirmDelete(enfant),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
|
||||
import 'package:p_tits_pas/services/auth_service.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/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/suppression_confirm_dialog.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||
|
||||
class GestionnaireManagementWidget extends StatefulWidget {
|
||||
@@ -23,16 +26,28 @@ class _GestionnaireManagementWidgetState
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
List<AppUser> _gestionnaires = [];
|
||||
bool _canDelete = false;
|
||||
String? _currentUserId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadRights();
|
||||
_loadGestionnaires();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() => super.dispose();
|
||||
|
||||
Future<void> _loadRights() async {
|
||||
final user = await AuthService.getCurrentUser();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_canDelete = canDeleteGestionnaire(user?.role);
|
||||
_currentUserId = user?.id;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadGestionnaires() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
@@ -67,6 +82,46 @@ class _GestionnaireManagementWidgetState
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(AppUser user) async {
|
||||
if (_currentUserId != null && _currentUserId == user.id) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Vous ne pouvez pas supprimer votre propre compte.'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final name = user.fullName.isNotEmpty ? user.fullName : user.email;
|
||||
final confirmed = await showSuppressionConfirmDialog(
|
||||
context,
|
||||
title: 'Supprimer le gestionnaire',
|
||||
people: [SuppressionPersonLine.gestionnaire(name)],
|
||||
footnotes: const [
|
||||
'Le compte sera définitivement supprimé.',
|
||||
],
|
||||
);
|
||||
if (!confirmed || !mounted) return;
|
||||
|
||||
try {
|
||||
final result = await UserService.deleteUser(user.id);
|
||||
if (!mounted) return;
|
||||
final msg = (result['message'] ?? 'Gestionnaire supprimé.').toString();
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||
await _loadGestionnaires();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
e is Exception
|
||||
? e.toString().replaceFirst('Exception: ', '')
|
||||
: 'Erreur suppression',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final query = widget.searchQuery.toLowerCase();
|
||||
@@ -84,6 +139,8 @@ class _GestionnaireManagementWidgetState
|
||||
itemCount: filteredGestionnaires.length,
|
||||
itemBuilder: (context, index) {
|
||||
final user = filteredGestionnaires[index];
|
||||
final isSelf =
|
||||
_currentUserId != null && _currentUserId == user.id;
|
||||
return AdminUserCard(
|
||||
title: user.fullName,
|
||||
fallbackIcon: Icons.assignment_ind_outlined,
|
||||
@@ -102,6 +159,8 @@ class _GestionnaireManagementWidgetState
|
||||
_openGestionnaireEditDialog(user);
|
||||
},
|
||||
),
|
||||
if (_canDelete && !isSelf)
|
||||
suppressionIconButton(onPressed: () => _confirmDelete(user)),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart';
|
||||
|
||||
/// Modale de création dossier famille (#129) — même shell que [AmDossierCreateModal].
|
||||
class ParentDossierCreateModal extends StatefulWidget {
|
||||
final VoidCallback onClose;
|
||||
final VoidCallback? onSuccess;
|
||||
|
||||
const ParentDossierCreateModal({
|
||||
super.key,
|
||||
required this.onClose,
|
||||
this.onSuccess,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ParentDossierCreateModal> createState() =>
|
||||
_ParentDossierCreateModalState();
|
||||
}
|
||||
|
||||
class _ParentDossierCreateModalState extends State<ParentDossierCreateModal> {
|
||||
int? _stepIndex;
|
||||
int? _stepTotal;
|
||||
|
||||
void _onStepChanged(int step, int total) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_stepIndex = step;
|
||||
_stepTotal = total;
|
||||
});
|
||||
}
|
||||
|
||||
void _onSuccess() {
|
||||
widget.onSuccess?.call();
|
||||
}
|
||||
|
||||
static const double _modalWidth = 930;
|
||||
/// Hauteur calculée depuis 4 lignes de TF (voir [ParentDossierWizard.shellBodyHeight]).
|
||||
static double get _bodyHeight => ParentDossierWizard.shellBodyHeight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maxH = MediaQuery.of(context).size.height * 0.85;
|
||||
final showStep =
|
||||
_stepIndex != null && _stepTotal != null && (_stepTotal ?? 0) > 0;
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: _modalWidth, maxHeight: maxH),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(18, 18, 0, 12),
|
||||
child: Text(
|
||||
'Nouveau dossier famille',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (showStep) ...[
|
||||
Text(
|
||||
'Étape ${(_stepIndex ?? 0) + 1}/${_stepTotal ?? 1}',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Colors.black54,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: widget.onClose,
|
||||
tooltip: 'Fermer',
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SizedBox(
|
||||
height: _bodyHeight,
|
||||
child: ParentDossierWizard.create(
|
||||
onClose: widget.onClose,
|
||||
onSuccess: _onSuccess,
|
||||
onStepChanged: _onStepChanged,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/dossier_list_item.dart';
|
||||
import 'package:p_tits_pas/models/parent_model.dart';
|
||||
import 'package:p_tits_pas/services/auth_service.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/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_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';
|
||||
|
||||
class ParentManagementWidget extends StatefulWidget {
|
||||
@@ -23,16 +27,24 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
List<ParentModel> _parents = [];
|
||||
bool _canDelete = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadRights();
|
||||
_loadParents();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() => super.dispose();
|
||||
|
||||
Future<void> _loadRights() async {
|
||||
final user = await AuthService.getCurrentUser();
|
||||
if (!mounted) return;
|
||||
setState(() => _canDelete = canDeleteMetier(user?.role));
|
||||
}
|
||||
|
||||
Future<void> _loadParents() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
@@ -54,6 +66,67 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
bool _isLastParent(ParentModel parent) {
|
||||
final co = parent.coParent?.id.trim();
|
||||
if (co != null && co.isNotEmpty) return false;
|
||||
final num = (parent.user.numeroDossier ?? '').trim();
|
||||
if (num.isEmpty) return true;
|
||||
return !_parents.any((other) {
|
||||
if (other.user.id == parent.user.id) return false;
|
||||
return (other.user.numeroDossier ?? '').trim() == num;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(ParentModel parent) async {
|
||||
final num = (parent.user.numeroDossier ?? '').trim();
|
||||
final name = formatDossierPersonLabel(
|
||||
nom: parent.user.nom,
|
||||
prenom: parent.user.prenom,
|
||||
email: parent.user.email,
|
||||
);
|
||||
final last = _isLastParent(parent);
|
||||
final enfants = ParentModel.foyerChildrenCount(parent, _parents);
|
||||
final footnotes = last
|
||||
? <String>[
|
||||
if (num.isNotEmpty) 'Dernier parent du dossier $num.',
|
||||
if (num.isEmpty) 'Dernier parent du dossier.',
|
||||
'Les $enfants enfant(s) rattaché(s) seront aussi supprimés.',
|
||||
]
|
||||
: <String>[
|
||||
if (num.isNotEmpty)
|
||||
'Ce parent sera retiré du dossier $num.',
|
||||
'Les enfants restent avec le co-parent.',
|
||||
];
|
||||
|
||||
final confirmed = await showSuppressionConfirmDialog(
|
||||
context,
|
||||
title: 'Supprimer le parent',
|
||||
subtitle: num.isEmpty ? null : 'Dossier $num',
|
||||
people: [SuppressionPersonLine.parent(name)],
|
||||
footnotes: footnotes,
|
||||
);
|
||||
if (!confirmed || !mounted) return;
|
||||
|
||||
try {
|
||||
final result = await UserService.deleteUser(parent.user.id);
|
||||
if (!mounted) return;
|
||||
final msg = (result['message'] ?? 'Parent supprimé.').toString();
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||
await _loadParents();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
e is Exception
|
||||
? e.toString().replaceFirst('Exception: ', '')
|
||||
: 'Erreur suppression',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final query = widget.searchQuery.toLowerCase();
|
||||
@@ -80,7 +153,7 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
||||
onCardTap: () => _openParentDetails(parent),
|
||||
subtitleLines: [
|
||||
parent.user.email,
|
||||
'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.children.isNotEmpty ? parent.children.length : parent.childrenCount}',
|
||||
'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${ParentModel.foyerChildrenCount(parent, _parents)}',
|
||||
],
|
||||
actions: [
|
||||
IconButton(
|
||||
@@ -90,6 +163,8 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
||||
_openParentDetails(parent);
|
||||
},
|
||||
),
|
||||
if (_canDelete)
|
||||
suppressionIconButton(onPressed: () => _confirmDelete(parent)),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,19 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:p_tits_pas/models/dossier_list_item.dart';
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
import 'package:p_tits_pas/models/pending_family.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/dossier_list_card.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart';
|
||||
|
||||
/// Onglet « À valider » : deux listes (AM en attente, familles en attente). Ticket #107.
|
||||
/// Section « dossiers à valider » (liste unifiée AM + familles). Ticket #107 / #153.
|
||||
class PendingValidationWidget extends StatefulWidget {
|
||||
final VoidCallback? onRefresh;
|
||||
/// Filtre client (n°, nom, email) — onglet Dossiers (#153).
|
||||
final String searchQuery;
|
||||
/// Si true et liste vide : message court (pas de grand vide centré).
|
||||
final bool compactWhenEmpty;
|
||||
/// Numéros des dossiers pending (pour exclure de « Tous les dossiers »).
|
||||
final ValueChanged<Set<String>>? onPendingNumerosChanged;
|
||||
/// 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
|
||||
State<PendingValidationWidget> createState() => _PendingValidationWidgetState();
|
||||
State<PendingValidationWidget> createState() =>
|
||||
_PendingValidationWidgetState();
|
||||
}
|
||||
|
||||
class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
||||
@@ -21,6 +38,8 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
||||
String? _error;
|
||||
List<AppUser> _pendingAM = [];
|
||||
List<PendingFamily> _pendingFamilies = [];
|
||||
/// Noms enrichis via GET /dossiers/:numero (libelle API = noms seuls).
|
||||
final Map<String, String> _familyNamesByNumero = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -34,24 +53,77 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final am = await UserService.getPendingUsers(role: 'assistante_maternelle');
|
||||
final am =
|
||||
await UserService.getPendingUsers(role: 'assistante_maternelle');
|
||||
final families = await UserService.getPendingFamilies();
|
||||
final namesByNumero = await _enrichFamilyNames(families);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_pendingAM = am;
|
||||
_pendingFamilies = families;
|
||||
_familyNamesByNumero
|
||||
..clear()
|
||||
..addAll(namesByNumero);
|
||||
_isLoading = false;
|
||||
});
|
||||
_emitPendingNumeros();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur inconnue';
|
||||
_error = e is Exception
|
||||
? e.toString().replaceFirst('Exception: ', '')
|
||||
: 'Erreur inconnue';
|
||||
_isLoading = false;
|
||||
});
|
||||
widget.onPendingNumerosChanged?.call(const {});
|
||||
}
|
||||
}
|
||||
|
||||
void _onOpenValidation({String? type, String? id, String? numeroDossier}) {
|
||||
/// Complète `NOM Prénom` via le détail dossier (sans changer le back).
|
||||
Future<Map<String, String>> _enrichFamilyNames(
|
||||
List<PendingFamily> families,
|
||||
) async {
|
||||
final out = <String, String>{};
|
||||
await Future.wait(families.map((f) async {
|
||||
final num = (f.numeroDossier ?? '').trim();
|
||||
if (num.isEmpty) return;
|
||||
try {
|
||||
final dossier = await UserService.getDossier(num);
|
||||
if (!dossier.isFamily) return;
|
||||
final labels = <String>[];
|
||||
final seen = <String>{};
|
||||
for (final p in dossier.asFamily.parents) {
|
||||
final id = p.id.trim();
|
||||
if (id.isNotEmpty && !seen.add(id)) continue;
|
||||
final label = formatDossierPersonLabel(
|
||||
nom: p.nom,
|
||||
prenom: p.prenom,
|
||||
email: p.email,
|
||||
);
|
||||
if (label.isNotEmpty) labels.add(label);
|
||||
}
|
||||
if (labels.isNotEmpty) out[num] = labels.join(' - ');
|
||||
} catch (_) {
|
||||
// Repli libellé API ci-dessous.
|
||||
}
|
||||
}));
|
||||
return out;
|
||||
}
|
||||
|
||||
void _emitPendingNumeros() {
|
||||
final nums = <String>{};
|
||||
for (final u in _pendingAM) {
|
||||
final n = (u.numeroDossier ?? '').trim();
|
||||
if (n.isNotEmpty) nums.add(n);
|
||||
}
|
||||
for (final f in _pendingFamilies) {
|
||||
final n = (f.numeroDossier ?? '').trim();
|
||||
if (n.isNotEmpty) nums.add(n);
|
||||
}
|
||||
widget.onPendingNumerosChanged?.call(nums);
|
||||
}
|
||||
|
||||
void _onOpenValidation({String? numeroDossier}) {
|
||||
final num = numeroDossier?.trim();
|
||||
if (num == null || num.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -73,9 +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
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
if (widget.compactWhenEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_error != null && _error!.isNotEmpty) {
|
||||
@@ -97,14 +299,32 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
final hasAM = _pendingAM.isNotEmpty;
|
||||
final hasFamilies = _pendingFamilies.isNotEmpty;
|
||||
if (!hasAM && !hasFamilies) {
|
||||
final pendingAM = _filteredAM;
|
||||
final pendingFamilies = _filteredFamilies;
|
||||
final cards = <Widget>[
|
||||
...pendingAM.map(_buildAMCard),
|
||||
...pendingFamilies.map(_buildFamilyCard),
|
||||
];
|
||||
|
||||
if (cards.isEmpty) {
|
||||
if (widget.compactWhenEmpty) {
|
||||
final searching = widget.searchQuery.trim().isNotEmpty;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Text(
|
||||
searching
|
||||
? 'Aucun dossier en attente ne correspond à la recherche.'
|
||||
: 'Aucun dossier en attente.',
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 13),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_circle_outline, size: 64, color: Colors.grey.shade400),
|
||||
Icon(Icons.check_circle_outline,
|
||||
size: 64, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Aucun dossier en attente de validation',
|
||||
@@ -117,6 +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(
|
||||
onRefresh: () async {
|
||||
await _load();
|
||||
@@ -125,275 +367,56 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (hasAM) ...[
|
||||
_sectionTitle('Assistantes maternelles en attente'),
|
||||
const SizedBox(height: 8),
|
||||
..._pendingAM.map((u) => _buildAMCard(u)),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
if (hasFamilies) ...[
|
||||
_sectionTitle('Familles en attente'),
|
||||
const SizedBox(height: 8),
|
||||
..._pendingFamilies.map((f) => _buildFamilyCard(f)),
|
||||
],
|
||||
],
|
||||
),
|
||||
child: list,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionTitle(String title) {
|
||||
return Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
String _amNamesLine(AppUser user) {
|
||||
return formatDossierPersonLabel(
|
||||
nom: user.nom,
|
||||
prenom: user.prenom,
|
||||
email: user.email,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// Sous-titre AM : `email - date • tél. • CP ville` (plan affichage lignes À valider).
|
||||
String _amSubtitleLine(AppUser user) {
|
||||
final email = user.email.trim();
|
||||
final bits = <String>[];
|
||||
bits.add(DateFormat('dd/MM/yyyy').format(user.createdAt.toLocal()));
|
||||
final tel = user.telephone?.trim();
|
||||
if (tel != null && tel.isNotEmpty) {
|
||||
bits.add(formatPhoneForDisplay(tel));
|
||||
}
|
||||
final cp = user.codePostal?.trim();
|
||||
final ville = user.ville?.trim();
|
||||
final loc = [if (cp != null && cp.isNotEmpty) cp, if (ville != null && ville.isNotEmpty) ville]
|
||||
.join(' ')
|
||||
.trim();
|
||||
if (loc.isNotEmpty) bits.add(loc);
|
||||
final infos = bits.join(' • ');
|
||||
if (email.isEmpty) return infos;
|
||||
return '$email - $infos';
|
||||
}
|
||||
|
||||
Widget _buildAMCard(AppUser user) {
|
||||
final numDossier = user.numeroDossier ?? '–';
|
||||
final nameBold =
|
||||
user.fullName.isNotEmpty ? user.fullName : (user.email.isNotEmpty ? user.email : '–');
|
||||
return _PendingValidationRow(
|
||||
icon: Icons.person_outline,
|
||||
title: Text.rich(
|
||||
TextSpan(
|
||||
style: const TextStyle(fontSize: 14, color: Colors.black87),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: nameBold,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' - $numDossier',
|
||||
style: const TextStyle(fontWeight: FontWeight.w400),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
subtitle: _amSubtitleLine(user),
|
||||
subtitleStyle: TextStyle(
|
||||
fontSize: 12,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
onOpen: () => _onOpenValidation(
|
||||
type: 'AM',
|
||||
id: user.id,
|
||||
numeroDossier: user.numeroDossier,
|
||||
),
|
||||
final names = _amNamesLine(user);
|
||||
final num = user.numeroDossier ?? '';
|
||||
return DossierListCard(
|
||||
numeroDossier: num,
|
||||
namesLine: names,
|
||||
isFamille: false,
|
||||
photoUrl: user.photoUrl,
|
||||
onOpen: () => _onOpenValidation(numeroDossier: user.numeroDossier),
|
||||
onDelete: widget.canDelete
|
||||
? () => _confirmDeletePending(
|
||||
numeroDossier: num,
|
||||
namesLine: names,
|
||||
isFamille: false,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// `email, tél., localisation` par parent, puis `date soumission`, puis `nb enfants`.
|
||||
String _familyParentSegment(PendingParentLine p) {
|
||||
final parts = <String>[];
|
||||
final e = p.email?.trim();
|
||||
if (e != null && e.isNotEmpty) parts.add(e);
|
||||
final t = p.telephone?.trim();
|
||||
if (t != null && t.isNotEmpty) parts.add(formatPhoneForDisplay(t));
|
||||
final cp = p.codePostal?.trim();
|
||||
final v = p.ville?.trim();
|
||||
final loc = [if (cp != null && cp.isNotEmpty) cp, if (v != null && v.isNotEmpty) v]
|
||||
.join(' ')
|
||||
.trim();
|
||||
if (loc.isNotEmpty) parts.add(loc);
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
String _familySubtitleLine(PendingFamily family) {
|
||||
final blocks = family.parentLines
|
||||
.map(_familyParentSegment)
|
||||
.where((s) => s.isNotEmpty)
|
||||
.join(' - ');
|
||||
|
||||
final tail = <String>[];
|
||||
final date = family.dateSoumission;
|
||||
if (date != null) {
|
||||
tail.add(DateFormat('dd/MM/yyyy').format(date.toLocal()));
|
||||
}
|
||||
if (family.nombreEnfants > 0) {
|
||||
tail.add(
|
||||
family.nombreEnfants > 1
|
||||
? '${family.nombreEnfants} enfants'
|
||||
: '1 enfant',
|
||||
);
|
||||
}
|
||||
final right = tail.join(' - ');
|
||||
|
||||
if (blocks.isEmpty && right.isEmpty) return '';
|
||||
if (blocks.isEmpty) return right;
|
||||
if (right.isEmpty) return blocks;
|
||||
return '$blocks - $right';
|
||||
}
|
||||
|
||||
Widget _buildFamilyCard(PendingFamily family) {
|
||||
final numDossier = family.numeroDossier ?? '–';
|
||||
final nameBold = family.libelle.isNotEmpty ? family.libelle : 'Famille';
|
||||
return _PendingValidationRow(
|
||||
icon: Icons.family_restroom_outlined,
|
||||
title: Text.rich(
|
||||
TextSpan(
|
||||
style: const TextStyle(fontSize: 14, color: Colors.black87),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: nameBold,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' - $numDossier',
|
||||
style: const TextStyle(fontWeight: FontWeight.w400),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
subtitle: _familySubtitleLine(family),
|
||||
subtitleStyle: TextStyle(
|
||||
fontSize: 12,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
onOpen: () => _onOpenValidation(
|
||||
type: 'famille',
|
||||
id: family.parentIds.isNotEmpty ? family.parentIds.first : null,
|
||||
numeroDossier: family.numeroDossier,
|
||||
),
|
||||
final num = (family.numeroDossier ?? '').trim();
|
||||
final enriched = num.isNotEmpty ? _familyNamesByNumero[num] : null;
|
||||
final names = (enriched != null && enriched.isNotEmpty)
|
||||
? enriched
|
||||
: formatDossierFamilyNamesLine(family.libelle);
|
||||
return DossierListCard(
|
||||
numeroDossier: family.numeroDossier ?? '',
|
||||
namesLine: names,
|
||||
isFamille: true,
|
||||
onOpen: () => _onOpenValidation(numeroDossier: family.numeroDossier),
|
||||
onDelete: widget.canDelete
|
||||
? () => _confirmDeletePending(
|
||||
numeroDossier: num,
|
||||
namesLine: names,
|
||||
isFamille: true,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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/utils/phone_utils.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 {
|
||||
const RelaisManagementPanel({super.key});
|
||||
@@ -56,28 +57,16 @@ class _RelaisManagementPanelState extends State<RelaisManagementPanel> {
|
||||
try {
|
||||
if (result.action == _RelaisDialogAction.delete) {
|
||||
if (relais == null) return;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Supprimer le relais'),
|
||||
content: Text('Confirmer la suppression de "${relais.nom}" ?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.red.shade700,
|
||||
),
|
||||
child: const Text('Supprimer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
final confirmed = await showSuppressionConfirmDialog(
|
||||
context,
|
||||
title: 'Supprimer le relais',
|
||||
people: [SuppressionPersonLine.relais(relais.nom)],
|
||||
footnotes: const [
|
||||
'Cette action est irréversible.',
|
||||
],
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
if (!confirmed) return;
|
||||
await RelaisService.deleteRelais(relais.id);
|
||||
} else if (relais == null) {
|
||||
await RelaisService.createRelais(result.payload!);
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/am_dossier_create_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/dossiers_management_widget.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/parent_dossier_create_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart';
|
||||
|
||||
class UserManagementPanel extends StatefulWidget {
|
||||
/// Afficher l'onglet Administrateurs (sinon 3 onglets : Gestionnaires, Parents, AM).
|
||||
/// Afficher l'onglet Administrateurs (sinon sans Administrateurs).
|
||||
final bool showAdministrateursTab;
|
||||
|
||||
const UserManagementPanel({
|
||||
@@ -25,46 +27,21 @@ class UserManagementPanel extends StatefulWidget {
|
||||
class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
int _subIndex = 0;
|
||||
int _gestionnaireRefreshTick = 0;
|
||||
int _parentRefreshTick = 0;
|
||||
int _adminRefreshTick = 0;
|
||||
int _enfantRefreshTick = 0;
|
||||
int _amRefreshTick = 0;
|
||||
int _dossiersRefreshTick = 0;
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final TextEditingController _amCapacityController = TextEditingController();
|
||||
String? _parentStatus;
|
||||
String? _enfantStatus;
|
||||
bool _hasPending = false;
|
||||
bool _pendingLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchController.addListener(_onFilterChanged);
|
||||
_amCapacityController.addListener(_onFilterChanged);
|
||||
_loadPending();
|
||||
}
|
||||
|
||||
Future<void> _loadPending() async {
|
||||
try {
|
||||
final am = await UserService.getPendingUsers(role: 'assistante_maternelle');
|
||||
final families = await UserService.getPendingFamilies();
|
||||
if (!mounted) return;
|
||||
final hasPending = am.isNotEmpty || families.isNotEmpty;
|
||||
setState(() {
|
||||
final hadPending = _hasPending;
|
||||
_hasPending = hasPending;
|
||||
_pendingLoading = false;
|
||||
// Si on passe à "plus de dossiers", recaler l'index (onglet À valider disparaît).
|
||||
if (hadPending && !hasPending) {
|
||||
_subIndex = (_subIndex > 0 ? _subIndex - 1 : 0).clamp(0, _tabLabels.length - 1);
|
||||
} else if (!hadPending && hasPending) {
|
||||
_subIndex = 0; // Afficher l'onglet À valider
|
||||
}
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_hasPending = false;
|
||||
_pendingLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -81,13 +58,19 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
/// Ordre #153 : Dossiers | Parents | Enfants | AM | Gestionnaires | (Admin).
|
||||
List<String> get _tabLabels {
|
||||
const base = ['Parents', 'Enfants', 'Assistantes maternelles', 'Gestionnaires'];
|
||||
final withAdmin = [...base, 'Administrateurs'];
|
||||
final list = widget.showAdministrateursTab ? withAdmin : base;
|
||||
// Onglet « À valider » visible seulement s'il y a des dossiers en attente (ticket #107).
|
||||
if (!_pendingLoading && _hasPending) return ['À valider', ...list];
|
||||
return list;
|
||||
const base = [
|
||||
'Dossiers',
|
||||
'Parents',
|
||||
'Enfants',
|
||||
'Assistantes maternelles',
|
||||
'Gestionnaires',
|
||||
];
|
||||
if (widget.showAdministrateursTab) {
|
||||
return [...base, 'Administrateurs'];
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
void _onSubTabChange(int index) {
|
||||
@@ -101,31 +84,35 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Index du contenu : -1 = À valider, 0 = Parents, 1 = Enfants, 2 = AM, 3 = Gestionnaires, 4 = Admin.
|
||||
int get _contentIndexOffset => (_hasPending && !_pendingLoading) ? 1 : 0;
|
||||
bool get _isDossiersTab => _subIndex == 0;
|
||||
|
||||
String _searchHintForTab() {
|
||||
final contentIndex = _subIndex - _contentIndexOffset;
|
||||
switch (contentIndex) {
|
||||
case -1:
|
||||
return 'À valider (pas de recherche)';
|
||||
switch (_subIndex) {
|
||||
case 0:
|
||||
return 'Rechercher un parent...';
|
||||
return 'Rechercher un dossier';
|
||||
case 1:
|
||||
return 'Rechercher un enfant...';
|
||||
return 'Rechercher un parent...';
|
||||
case 2:
|
||||
return 'Rechercher une assistante...';
|
||||
return 'Rechercher un enfant...';
|
||||
case 3:
|
||||
return 'Rechercher un gestionnaire...';
|
||||
return 'Rechercher une assistante...';
|
||||
case 4:
|
||||
return 'Rechercher un gestionnaire...';
|
||||
case 5:
|
||||
return 'Rechercher un administrateur...';
|
||||
default:
|
||||
return 'Rechercher...';
|
||||
}
|
||||
}
|
||||
|
||||
String? _searchTooltipForTab() {
|
||||
if (_subIndex != 0) return null;
|
||||
return 'Recherche possible : n° de dossier, nom, prénom ou e-mail.';
|
||||
}
|
||||
|
||||
Widget? _subBarFilterControl() {
|
||||
if (_subIndex == _contentIndexOffset + 0) {
|
||||
// Parents
|
||||
if (_subIndex == 1) {
|
||||
return DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String?>(
|
||||
value: _parentStatus,
|
||||
@@ -180,7 +167,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
);
|
||||
}
|
||||
|
||||
if (_subIndex == _contentIndexOffset + 1) {
|
||||
// Enfants
|
||||
if (_subIndex == 2) {
|
||||
return DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String?>(
|
||||
value: _enfantStatus,
|
||||
@@ -215,7 +203,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
value: 'garde',
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: 10),
|
||||
child: Text('En garde', style: TextStyle(fontSize: 12)),
|
||||
child: Text('Gardé', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
),
|
||||
DropdownMenuItem<String?>(
|
||||
@@ -235,7 +223,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
);
|
||||
}
|
||||
|
||||
if (_subIndex == _contentIndexOffset + 2) {
|
||||
// AM
|
||||
if (_subIndex == 3) {
|
||||
return TextField(
|
||||
controller: _amCapacityController,
|
||||
decoration: const InputDecoration(
|
||||
@@ -252,32 +241,36 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
final contentIndex = _subIndex - _contentIndexOffset;
|
||||
if (_hasPending && !_pendingLoading && contentIndex == -1) {
|
||||
return PendingValidationWidget(onRefresh: _loadPending);
|
||||
}
|
||||
switch (contentIndex) {
|
||||
switch (_subIndex) {
|
||||
case 0:
|
||||
return DossiersManagementWidget(
|
||||
key: ValueKey('dossiers-$_dossiersRefreshTick'),
|
||||
searchQuery: _searchController.text,
|
||||
);
|
||||
case 1:
|
||||
return ParentManagementWidget(
|
||||
key: ValueKey('parents-$_parentRefreshTick'),
|
||||
searchQuery: _searchController.text,
|
||||
statusFilter: _parentStatus,
|
||||
);
|
||||
case 1:
|
||||
case 2:
|
||||
return EnfantManagementWidget(
|
||||
key: ValueKey('enfants-$_enfantRefreshTick'),
|
||||
searchQuery: _searchController.text,
|
||||
statusFilter: _enfantStatus,
|
||||
);
|
||||
case 2:
|
||||
case 3:
|
||||
return AssistanteMaternelleManagementWidget(
|
||||
key: ValueKey('ams-$_amRefreshTick'),
|
||||
searchQuery: _searchController.text,
|
||||
capacityMin: int.tryParse(_amCapacityController.text),
|
||||
);
|
||||
case 3:
|
||||
case 4:
|
||||
return GestionnaireManagementWidget(
|
||||
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
|
||||
searchQuery: _searchController.text,
|
||||
);
|
||||
case 4:
|
||||
case 5:
|
||||
return AdminManagementWidget(
|
||||
key: ValueKey('admins-$_adminRefreshTick'),
|
||||
searchQuery: _searchController.text,
|
||||
@@ -290,7 +283,6 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final labels = _tabLabels;
|
||||
final isAValiderTab = _hasPending && !_pendingLoading && _subIndex == 0;
|
||||
return Column(
|
||||
children: [
|
||||
DashboardUserManagementSubBar(
|
||||
@@ -298,8 +290,10 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
onSubTabChange: _onSubTabChange,
|
||||
searchController: _searchController,
|
||||
searchHint: _searchHintForTab(),
|
||||
searchTooltip: _searchTooltipForTab(),
|
||||
filterControl: _subBarFilterControl(),
|
||||
onAddPressed: isAValiderTab ? null : _handleAddPressed,
|
||||
// Pas de « Créer » sur l’onglet Dossiers (#153).
|
||||
onAddPressed: _isDossiersTab ? null : _handleAddPressed,
|
||||
addLabel: 'Ajouter',
|
||||
subTabCount: labels.length,
|
||||
tabLabels: labels,
|
||||
@@ -310,8 +304,66 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
}
|
||||
|
||||
Future<void> _handleAddPressed() async {
|
||||
final contentIndex = _subIndex - _contentIndexOffset;
|
||||
if (contentIndex == 3) {
|
||||
// 1 Parents, 2 Enfants, 3 AM, 4 Gestionnaires, 5 Admin
|
||||
if (_subIndex == 1) {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) {
|
||||
return ParentDossierCreateModal(
|
||||
onClose: () => Navigator.of(dialogContext).pop(),
|
||||
onSuccess: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_parentRefreshTick++;
|
||||
_dossiersRefreshTick++;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_subIndex == 2) {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) {
|
||||
return AdminChildDetailModal.create(
|
||||
onSaved: () {
|
||||
if (!mounted) return;
|
||||
setState(() => _enfantRefreshTick++);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_subIndex == 3) {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) {
|
||||
return AmDossierCreateModal(
|
||||
onClose: () => Navigator.of(dialogContext).pop(),
|
||||
onSuccess: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_amRefreshTick++;
|
||||
_dossiersRefreshTick++;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_subIndex == 4) {
|
||||
final created = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
@@ -329,7 +381,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (contentIndex == 4) {
|
||||
if (_subIndex == 5) {
|
||||
final created = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
@@ -347,16 +399,6 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
_adminRefreshTick++;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'La création parent / enfant / AM sera disponible avec le ticket #129.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||||
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
||||
import 'package:p_tits_pas/utils/nir_utils.dart';
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
||||
import 'package:p_tits_pas/widgets/common/identity_block.dart';
|
||||
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
||||
import 'validation_modal_theme.dart';
|
||||
import 'validation_refus_form.dart';
|
||||
import 'validation_valider_confirm_dialog.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart';
|
||||
|
||||
/// Wizard de validation dossier AM : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107.
|
||||
class ValidationAmWizard extends StatefulWidget {
|
||||
/// Wrapper historique (#107) — délègue à [AmDossierWizard.review].
|
||||
class ValidationAmWizard extends StatelessWidget {
|
||||
final DossierAM dossier;
|
||||
final VoidCallback onClose;
|
||||
final VoidCallback onSuccess;
|
||||
@@ -28,480 +17,13 @@ class ValidationAmWizard extends StatefulWidget {
|
||||
this.onStepChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ValidationAmWizard> createState() => _ValidationAmWizardState();
|
||||
}
|
||||
|
||||
class _ValidationAmWizardState extends State<ValidationAmWizard> {
|
||||
int _step = 0;
|
||||
bool _showRefusForm = false;
|
||||
bool _submitting = false;
|
||||
|
||||
static const int _stepCount = 3;
|
||||
|
||||
bool get _isEnAttente => widget.dossier.user.statut == 'en_attente';
|
||||
|
||||
static String _v(String? s) =>
|
||||
(s != null && s.trim().isNotEmpty) ? s.trim() : '–';
|
||||
|
||||
/// Présentation lisible : `1 12 34 56 789 012 - 34` (15 caractères utiles requis).
|
||||
static String _formatNirForDisplay(String? nir) {
|
||||
final v = _v(nir);
|
||||
if (v == '–') return v;
|
||||
final raw = nirToRaw(v).toUpperCase();
|
||||
return raw.length == 15 ? formatNir(raw) : v;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
|
||||
}
|
||||
|
||||
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
|
||||
|
||||
/// Panneau photo + grille droite : NIR|naissance, ville|pays, agrément|date, capa|places.
|
||||
List<AdminDetailField> _photoProFields(DossierAM d) {
|
||||
final u = d.user;
|
||||
return [
|
||||
AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)),
|
||||
AdminDetailField(
|
||||
label: 'Date de naissance',
|
||||
value: formatIsoDateFr(u.dateNaissance),
|
||||
),
|
||||
AdminDetailField(
|
||||
label: 'Ville de naissance',
|
||||
value: _v(u.lieuNaissanceVille),
|
||||
),
|
||||
AdminDetailField(
|
||||
label: 'Pays de naissance',
|
||||
value: _v(u.lieuNaissancePays),
|
||||
),
|
||||
AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)),
|
||||
AdminDetailField(
|
||||
label: 'Date d’agrément',
|
||||
value: formatIsoDateFr(d.dateAgrement),
|
||||
),
|
||||
AdminDetailField(
|
||||
label: 'Capacité max (enfants)',
|
||||
value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '–',
|
||||
),
|
||||
AdminDetailField(
|
||||
label: 'Places disponibles',
|
||||
value: d.placesDisponibles != null
|
||||
? d.placesDisponibles.toString()
|
||||
: '–',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static const List<int> _photoProRowLayout = [2, 2, 2, 2];
|
||||
|
||||
/// Proportion photo d’identité (35×45 mm).
|
||||
static const double _idPhotoAspectRatio = 35 / 45;
|
||||
|
||||
static const double _photoProGap = 24;
|
||||
/// Largeur mini réservée aux champs (évite une colonne photo trop gourmande).
|
||||
static const double _proColumnMinWidth = 260;
|
||||
static const double _photoColumnMinWidth = 160;
|
||||
|
||||
/// URL complète pour la photo : si relatif, préfixe [ApiConfig.baseUrl] (ex. `/api/v1/uploads/...`).
|
||||
static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url);
|
||||
|
||||
Widget _buildPhotoSection(AppUser u) {
|
||||
final photoUrl = _fullPhotoUrl(u.photoUrl);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
// Cadre clair : une seule épaisseur partout (photo + padding identique haut/bas/gauche/droite).
|
||||
const uniformFrame = 8.0;
|
||||
final maxPhotoW =
|
||||
(c.maxWidth - 2 * uniformFrame).clamp(0.0, double.infinity);
|
||||
final maxPhotoH =
|
||||
(c.maxHeight - 2 * uniformFrame).clamp(0.0, double.infinity);
|
||||
const ar = _idPhotoAspectRatio;
|
||||
double ph = maxPhotoH;
|
||||
double pw = ph * ar;
|
||||
if (pw > maxPhotoW) {
|
||||
pw = maxPhotoW;
|
||||
ph = pw / ar;
|
||||
}
|
||||
return Align(
|
||||
alignment: Alignment.center,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(uniformFrame),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: SizedBox(
|
||||
width: pw,
|
||||
height: ph,
|
||||
child: photoUrl.isEmpty
|
||||
? ColoredBox(
|
||||
color: Colors.grey.shade200,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.person_off_outlined,
|
||||
size: 40,
|
||||
color: Colors.grey.shade400),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Aucune photo fournie',
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade600,
|
||||
fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: AuthNetworkImage(
|
||||
url: photoUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: pw,
|
||||
height: ph,
|
||||
loadingBuilder: (_, child, progress) {
|
||||
if (progress == null) return child;
|
||||
return ColoredBox(
|
||||
color: Colors.grey.shade200,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
value: progress.expectedTotalBytes !=
|
||||
null
|
||||
? progress.cumulativeBytesLoaded /
|
||||
(progress.expectedTotalBytes!)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (_, __, ___) => ColoredBox(
|
||||
color: Colors.grey.shade200,
|
||||
child: Column(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.broken_image_outlined,
|
||||
size: 40,
|
||||
color: Colors.grey.shade400),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Impossible de charger la photo',
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade600,
|
||||
fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_showRefusForm) {
|
||||
return _buildRefusPage();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Expanded(child: _buildStepContent()),
|
||||
const SizedBox(height: 24),
|
||||
_buildNavigation(),
|
||||
],
|
||||
),
|
||||
return AmDossierWizard.review(
|
||||
dossier: dossier,
|
||||
onClose: onClose,
|
||||
onSuccess: onSuccess,
|
||||
onStepChanged: onStepChanged,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStepContent() {
|
||||
final d = widget.dossier;
|
||||
final u = d.user;
|
||||
switch (_step) {
|
||||
case 0:
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
||||
child: IdentityBlock.readOnlyFromUser(
|
||||
u,
|
||||
title: 'Identité et coordonnées',
|
||||
emptyLabel: '–',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
case 1:
|
||||
// Pas de SingleChildScrollView sur la Row (hauteur non bornée). Défilement à droite.
|
||||
// Largeur photo ≈ ratio × hauteur utile, plafonnée pour laisser au moins [_proColumnMinWidth] aux champs.
|
||||
return LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
final maxRowW = c.maxWidth;
|
||||
final maxRowH = c.maxHeight;
|
||||
const photoHeaderH = 0.0;
|
||||
final bodyH = (maxRowH - photoHeaderH).clamp(0.0, double.infinity);
|
||||
final idealPhotoW =
|
||||
bodyH * _idPhotoAspectRatio + 16; // marge approx. cadre clair
|
||||
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
|
||||
.clamp(0.0, double.infinity);
|
||||
var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 360.0);
|
||||
if (photoW > maxPhotoW) photoW = maxPhotoW;
|
||||
photoW = photoW.clamp(0.0, maxRowW - _photoProGap);
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: photoW,
|
||||
child: _buildPhotoSection(u),
|
||||
),
|
||||
const SizedBox(width: _photoProGap),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minWidth: constraints.maxWidth),
|
||||
child: ValidationDetailSection(
|
||||
title: 'Dossier professionnel',
|
||||
fields: _photoProFields(d),
|
||||
rowLayout: _photoProRowLayout,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
case 2:
|
||||
final presentation =
|
||||
(d.presentation != null && d.presentation!.trim().isNotEmpty)
|
||||
? d.presentation!
|
||||
: '–';
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Présentation',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints:
|
||||
BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: SelectableText(
|
||||
presentation,
|
||||
style: const TextStyle(
|
||||
color: Colors.black87, fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
default:
|
||||
return const SizedBox();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildNavigation() {
|
||||
if (_step == 2) {
|
||||
return Row(
|
||||
children: [
|
||||
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
|
||||
const Spacer(),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() => _step = 1);
|
||||
_emitStep();
|
||||
},
|
||||
child: const Text('Précédent'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (_isEnAttente) ...[
|
||||
OutlinedButton(
|
||||
onPressed: _submitting ? null : _refuser,
|
||||
child: const Text('Refuser')),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
style: ValidationModalTheme.primaryElevatedStyle,
|
||||
onPressed: _submitting ? null : _onValiderPressed,
|
||||
child: Text(_submitting ? 'Envoi...' : 'Valider'),
|
||||
),
|
||||
] else
|
||||
ElevatedButton(
|
||||
style: ValidationModalTheme.primaryElevatedStyle,
|
||||
onPressed: widget.onClose,
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
children: [
|
||||
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
|
||||
const Spacer(),
|
||||
if (_step > 0) ...[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() => _step--);
|
||||
_emitStep();
|
||||
},
|
||||
child: const Text('Précédent'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
ElevatedButton(
|
||||
style: ValidationModalTheme.primaryElevatedStyle,
|
||||
onPressed: () {
|
||||
setState(() => _step++);
|
||||
_emitStep();
|
||||
},
|
||||
child: const Text('Suivant'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onValiderPressed() async {
|
||||
if (_submitting) return;
|
||||
final ok = await showValidationValiderConfirmDialog(
|
||||
context,
|
||||
body:
|
||||
'Voulez-vous valider le dossier de cette assistante maternelle ? Cette action confirme le compte.',
|
||||
);
|
||||
if (!mounted || !ok) return;
|
||||
await _valider();
|
||||
}
|
||||
|
||||
Future<void> _valider() async {
|
||||
if (_submitting) return;
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
await UserService.validateUser(widget.dossier.user.id);
|
||||
if (!mounted) return;
|
||||
widget.onSuccess();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e is Exception
|
||||
? e.toString().replaceFirst('Exception: ', '')
|
||||
: 'Erreur'),
|
||||
backgroundColor: Colors.red.shade700,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _refuser() => setState(() => _showRefusForm = true);
|
||||
|
||||
Widget _buildRefusPage() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ValidationRefusForm(
|
||||
isSubmitting: _submitting,
|
||||
onCancel: widget.onClose,
|
||||
onPrevious: () => setState(() => _showRefusForm = false),
|
||||
onSubmit: (comment) {
|
||||
if (comment == null || comment.trim().isEmpty) return;
|
||||
_refuserEnvoyer(comment.trim());
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _refuserEnvoyer(String comment) async {
|
||||
if (_submitting) return;
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
await UserService.refuseUser(widget.dossier.user.id, comment: comment);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text(
|
||||
'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.',
|
||||
),
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
widget.onSuccess();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e is Exception
|
||||
? e.toString().replaceFirst('Exception: ', '')
|
||||
: 'Erreur'),
|
||||
backgroundColor: Colors.red.shade700,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/validation_am_wizard.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/validation_family_wizard.dart';
|
||||
|
||||
/// 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 {
|
||||
final String numeroDossier;
|
||||
final VoidCallback onClose;
|
||||
final VoidCallback? onSuccess;
|
||||
/// Liste Dossiers actifs → mode edit (#135). Pending reste en review (défaut).
|
||||
final bool openAsEdit;
|
||||
|
||||
const ValidationDossierModal({
|
||||
super.key,
|
||||
required this.numeroDossier,
|
||||
required this.onClose,
|
||||
this.onSuccess,
|
||||
this.openAsEdit = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -76,8 +82,13 @@ class _ValidationDossierModalState extends State<ValidationDossierModal> {
|
||||
|
||||
/// Largeur modale = 1,5 × 620.
|
||||
static const double _modalWidth = 930; // 620 * 1.5
|
||||
// Hauteur uniforme (ajustée +5px pour éviter l'overflow des étapes parents sans scroll).
|
||||
static const double _bodyHeight = 435;
|
||||
|
||||
double get _bodyHeight {
|
||||
final d = _dossier;
|
||||
if (d != null && d.isAm) return AmDossierWizard.shellBodyHeight;
|
||||
// Aligné create (#129) / edit (#135) — évite overflow IdentityBlock (8px).
|
||||
return ParentDossierWizard.shellBodyHeight;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -156,6 +167,14 @@ class _ValidationDossierModalState extends State<ValidationDossierModal> {
|
||||
}
|
||||
final d = _dossier!;
|
||||
if (d.isAm) {
|
||||
if (widget.openAsEdit) {
|
||||
return AmDossierWizard.edit(
|
||||
dossier: d.asAm,
|
||||
onClose: widget.onClose,
|
||||
onSuccess: _onSuccess,
|
||||
onStepChanged: _onStepChanged,
|
||||
);
|
||||
}
|
||||
return ValidationAmWizard(
|
||||
dossier: d.asAm,
|
||||
onClose: widget.onClose,
|
||||
@@ -163,6 +182,14 @@ class _ValidationDossierModalState extends State<ValidationDossierModal> {
|
||||
onStepChanged: _onStepChanged,
|
||||
);
|
||||
}
|
||||
if (widget.openAsEdit) {
|
||||
return ParentDossierWizard.edit(
|
||||
dossier: d.asFamily,
|
||||
onClose: widget.onClose,
|
||||
onSuccess: _onSuccess,
|
||||
onStepChanged: _onStepChanged,
|
||||
);
|
||||
}
|
||||
return ValidationFamilyWizard(
|
||||
dossier: d.asFamily,
|
||||
onClose: widget.onClose,
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||||
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
||||
import 'package:p_tits_pas/widgets/common/identity_block.dart';
|
||||
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
||||
import 'validation_modal_theme.dart';
|
||||
import 'validation_refus_form.dart';
|
||||
import 'validation_valider_confirm_dialog.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart';
|
||||
|
||||
/// Wizard de validation dossier famille : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107.
|
||||
class ValidationFamilyWizard extends StatefulWidget {
|
||||
/// Wrapper historique (#107) — délègue à [ParentDossierWizard.review].
|
||||
class ValidationFamilyWizard extends StatelessWidget {
|
||||
final DossierFamille dossier;
|
||||
final VoidCallback onClose;
|
||||
final VoidCallback onSuccess;
|
||||
@@ -28,697 +17,13 @@ class ValidationFamilyWizard extends StatefulWidget {
|
||||
this.onStepChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ValidationFamilyWizard> createState() => _ValidationFamilyWizardState();
|
||||
}
|
||||
|
||||
class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
|
||||
int _step = 0;
|
||||
bool _showRefusForm = false;
|
||||
bool _submitting = false;
|
||||
final ScrollController _enfantsScrollController = ScrollController();
|
||||
|
||||
/// Même logique que [ParentRegisterStep3Screen] : masque alpha sur les bords (ShaderMask dstIn).
|
||||
bool _enfantsIsScrollable = false;
|
||||
bool _enfantsFadeLeft = false;
|
||||
bool _enfantsFadeRight = false;
|
||||
|
||||
/// Fraction de la largeur du viewport pour le fondu (identique inscription étape 3).
|
||||
static const double _enfantsFadeExtent = 0.05;
|
||||
|
||||
int get _stepCount => 4;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_enfantsScrollController.addListener(_syncEnfantsScrollFades);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_enfantsScrollController.removeListener(_syncEnfantsScrollFades);
|
||||
_enfantsScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
|
||||
|
||||
void _syncEnfantsScrollFades() {
|
||||
if (!mounted) return;
|
||||
if (!_enfantsScrollController.hasClients) {
|
||||
if (_enfantsFadeLeft || _enfantsFadeRight || _enfantsIsScrollable) {
|
||||
setState(() {
|
||||
_enfantsIsScrollable = false;
|
||||
_enfantsFadeLeft = false;
|
||||
_enfantsFadeRight = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
final p = _enfantsScrollController.position;
|
||||
final scrollable = p.maxScrollExtent > 0;
|
||||
final left = scrollable &&
|
||||
p.pixels > (p.viewportDimension * _enfantsFadeExtent / 2);
|
||||
final right = scrollable &&
|
||||
p.pixels <
|
||||
(p.maxScrollExtent -
|
||||
(p.viewportDimension * _enfantsFadeExtent / 2));
|
||||
if (scrollable != _enfantsIsScrollable ||
|
||||
left != _enfantsFadeLeft ||
|
||||
right != _enfantsFadeRight) {
|
||||
setState(() {
|
||||
_enfantsIsScrollable = scrollable;
|
||||
_enfantsFadeLeft = left;
|
||||
_enfantsFadeRight = right;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bool get _isEnAttente => widget.dossier.isEnAttente;
|
||||
|
||||
String? get _firstParentId => widget.dossier.parents.isNotEmpty
|
||||
? widget.dossier.parents.first.id
|
||||
: null;
|
||||
|
||||
static String _v(String? s) =>
|
||||
(s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini';
|
||||
|
||||
/// Date de naissance en jour/mois/année (dd/MM/yyyy).
|
||||
static String _formatBirthDate(String? s) =>
|
||||
formatIsoDateFr(s, ifEmpty: 'Non défini');
|
||||
|
||||
static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_showRefusForm) {
|
||||
return _buildRefusPage();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Expanded(child: _buildStepContent()),
|
||||
const SizedBox(height: 24),
|
||||
_buildNavigation(),
|
||||
],
|
||||
),
|
||||
return ParentDossierWizard.review(
|
||||
dossier: dossier,
|
||||
onClose: onClose,
|
||||
onSuccess: onSuccess,
|
||||
onStepChanged: onStepChanged,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStepContent() {
|
||||
final d = widget.dossier;
|
||||
switch (_step) {
|
||||
case 0:
|
||||
return IdentityBlock.readOnlyFromParentDossier(
|
||||
d.parents.first,
|
||||
title: 'Parent principal',
|
||||
);
|
||||
case 1:
|
||||
return _buildParent2Step();
|
||||
case 2:
|
||||
return _buildEnfantsStep();
|
||||
case 3:
|
||||
return _buildPresentationStep();
|
||||
default:
|
||||
return const SizedBox();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildParent2Step() {
|
||||
if (widget.dossier.parents.length < 2) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text('Un seul parent pour ce dossier.',
|
||||
style: TextStyle(color: Colors.black87)),
|
||||
);
|
||||
}
|
||||
return IdentityBlock.readOnlyFromParentDossier(
|
||||
widget.dossier.parents[1],
|
||||
title: 'Deuxième parent',
|
||||
);
|
||||
}
|
||||
|
||||
static const double _idPhotoAspectRatio = 35 / 45;
|
||||
|
||||
Widget _buildEnfantsStep() {
|
||||
final enfants = widget.dossier.enfants;
|
||||
if (enfants.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Text('Aucun enfant renseigné.',
|
||||
style: TextStyle(color: Colors.black87)),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Enfants',
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final cardHeight = constraints.maxHeight;
|
||||
// Carte large : 1/3 photo + 2/3 champs (scroll horizontal si plusieurs enfants).
|
||||
final cardWidth = (cardHeight * 1.72).clamp(500.0, 700.0);
|
||||
return NotificationListener<ScrollMetricsNotification>(
|
||||
onNotification: (_) {
|
||||
_syncEnfantsScrollFades();
|
||||
return false;
|
||||
},
|
||||
child: ShaderMask(
|
||||
blendMode: BlendMode.dstIn,
|
||||
shaderCallback: (Rect bounds) {
|
||||
final stops = <double>[
|
||||
0.0,
|
||||
_enfantsFadeExtent,
|
||||
1.0 - _enfantsFadeExtent,
|
||||
1.0,
|
||||
];
|
||||
if (!_enfantsIsScrollable) {
|
||||
return LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: const <Color>[
|
||||
Colors.black,
|
||||
Colors.black,
|
||||
Colors.black,
|
||||
Colors.black,
|
||||
],
|
||||
stops: stops,
|
||||
).createShader(bounds);
|
||||
}
|
||||
final leftMask =
|
||||
_enfantsFadeLeft ? Colors.transparent : Colors.black;
|
||||
final rightMask =
|
||||
_enfantsFadeRight ? Colors.transparent : Colors.black;
|
||||
return LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: <Color>[
|
||||
leftMask,
|
||||
Colors.black,
|
||||
Colors.black,
|
||||
rightMask,
|
||||
],
|
||||
stops: stops,
|
||||
).createShader(bounds);
|
||||
},
|
||||
child: Listener(
|
||||
onPointerSignal: (event) {
|
||||
if (event is PointerScrollEvent &&
|
||||
_enfantsScrollController.hasClients) {
|
||||
final offset = _enfantsScrollController.offset +
|
||||
event.scrollDelta.dy;
|
||||
_enfantsScrollController.jumpTo(offset.clamp(
|
||||
_enfantsScrollController.position.minScrollExtent,
|
||||
_enfantsScrollController.position.maxScrollExtent,
|
||||
));
|
||||
}
|
||||
},
|
||||
child: ListView.builder(
|
||||
controller: _enfantsScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: enfants.length,
|
||||
itemBuilder: (_, i) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: i < enfants.length - 1 ? 16 : 0),
|
||||
child: SizedBox(
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
child: _buildEnfantCard(enfants[i]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Fond carte enfant : teintes très pastel ; bordure discrète ; accent léger (barre).
|
||||
static const Color _enfantCardBoyBg = Color(0xFFF0F7FB);
|
||||
static const Color _enfantCardBoyBorder = Color(0xFFE3EDF4);
|
||||
static const Color _enfantCardGirlBg = Color(0xFFFCF5F8);
|
||||
static const Color _enfantCardGirlBorder = Color(0xFFEAE3E7);
|
||||
|
||||
static const double _enfantCardRadius = 12;
|
||||
|
||||
static List<BoxShadow> _enfantCardShadows() => [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.06),
|
||||
blurRadius: 14,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
];
|
||||
|
||||
static BoxDecoration _enfantCardDecoration(String? gender) {
|
||||
final g = (gender ?? '').trim().toUpperCase();
|
||||
if (g == 'H') {
|
||||
return BoxDecoration(
|
||||
color: _enfantCardBoyBg,
|
||||
borderRadius: BorderRadius.circular(_enfantCardRadius),
|
||||
border: Border.all(color: _enfantCardBoyBorder, width: 1),
|
||||
boxShadow: _enfantCardShadows(),
|
||||
);
|
||||
}
|
||||
if (g == 'F') {
|
||||
return BoxDecoration(
|
||||
color: _enfantCardGirlBg,
|
||||
borderRadius: BorderRadius.circular(_enfantCardRadius),
|
||||
border: Border.all(color: _enfantCardGirlBorder, width: 1),
|
||||
boxShadow: _enfantCardShadows(),
|
||||
);
|
||||
}
|
||||
return BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(_enfantCardRadius),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
boxShadow: _enfantCardShadows(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Carte enfant : prénom pleine largeur, puis ligne photo 1/3 + colonne 2/3 (champs + statut hors TF si besoin).
|
||||
Widget _buildEnfantCard(EnfantDossier e) {
|
||||
final photoUrl = _fullPhotoUrl(e.photoUrl);
|
||||
final columnStatusLabel = _enfantColumnStatusLabel(e);
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(_enfantCardRadius),
|
||||
child: Container(
|
||||
decoration: _enfantCardDecoration(e.gender),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||
child: _enfantLabeledField('Prénom', _v(e.firstName)),
|
||||
),
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
// Même marge gauche que le bloc « Prénom » (12) ; droite / haut / bas 8.
|
||||
const padL = 12.0;
|
||||
const padR = 8.0;
|
||||
const padV = 8.0;
|
||||
final maxW =
|
||||
(c.maxWidth - padL - padR).clamp(0.0, double.infinity);
|
||||
final maxH =
|
||||
(c.maxHeight - 2 * padV).clamp(0.0, double.infinity);
|
||||
const ar = _idPhotoAspectRatio;
|
||||
double ph = maxH;
|
||||
double pw = ph * ar;
|
||||
if (pw > maxW) {
|
||||
pw = maxW;
|
||||
ph = pw / ar;
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(padL, padV, padR, padV),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: _buildEnfantPhotoSlot(photoUrl, pw, ph),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 4, 14, 12),
|
||||
child: columnStatusLabel == null
|
||||
? SingleChildScrollView(
|
||||
child: _buildEnfantInfoFields(e),
|
||||
)
|
||||
: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: _buildEnfantInfoFields(e),
|
||||
),
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: Text(
|
||||
columnStatusLabel,
|
||||
textAlign: TextAlign.center,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 14,
|
||||
fontStyle: FontStyle.italic,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey.shade800,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Statut dans la colonne 2/3 (scolarisé·e, à naître, sans garde, en garde).
|
||||
String? _enfantColumnStatusLabel(EnfantDossier e) {
|
||||
return enfantColumnStatusLabel(status: e.status, gender: e.gender);
|
||||
}
|
||||
|
||||
/// Nom ; date de naissance et genre sur une ligne (prénom au-dessus, pleine largeur).
|
||||
Widget _buildEnfantInfoFields(EnfantDossier e) {
|
||||
final isANaitre = (e.status ?? '').trim().toLowerCase() == 'a_naitre';
|
||||
final dueDateRenseignee = e.dueDate != null && e.dueDate!.trim().isNotEmpty;
|
||||
final dateValue = isANaitre
|
||||
? (dueDateRenseignee ? '${_formatBirthDate(e.dueDate)} (P)' : '– (P)')
|
||||
: _formatBirthDate(e.birthDate);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _enfantLabeledField('Nom', _formatNom(e.lastName)),
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: _enfantLabeledField('Date de naissance', dateValue),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _enfantLabeledField(
|
||||
'Genre',
|
||||
_genreEnfantLabel(e.gender, e.status),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _enfantLabeledField(String label, String value) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ValidationReadOnlyField(value: value),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEnfantPhotoSlot(String photoUrl, double width, double height) {
|
||||
const photoRadius = 8.0;
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(photoRadius),
|
||||
border: Border.all(color: Colors.black.withOpacity(0.08)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: photoUrl.isEmpty
|
||||
? ColoredBox(
|
||||
color: Colors.grey.shade100,
|
||||
child: Center(
|
||||
child: Icon(Icons.person_outline, size: 32, color: Colors.grey.shade400),
|
||||
),
|
||||
)
|
||||
: AuthNetworkImage(
|
||||
url: photoUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: width,
|
||||
height: height,
|
||||
errorBuilder: (_, __, ___) => ColoredBox(
|
||||
color: Colors.grey.shade100,
|
||||
child: Center(
|
||||
child: Icon(Icons.broken_image_outlined, size: 32, color: Colors.grey.shade400),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _formatNom(String? lastName) {
|
||||
final n = (lastName ?? '').trim().toUpperCase();
|
||||
return n.isEmpty ? 'Non défini' : n;
|
||||
}
|
||||
|
||||
/// Genre enfant : Garçon, Fille, ou "Non connu" (uniquement si l'enfant est à naître).
|
||||
static String _genreEnfantLabel(String? gender, String? status) {
|
||||
final g = (gender ?? '').trim().toUpperCase();
|
||||
final isANaitre = (status ?? '').trim().toLowerCase() == 'a_naitre';
|
||||
if (g == 'H') return 'Garçon';
|
||||
if (g == 'F') return 'Fille';
|
||||
if (isANaitre) return 'Non connu';
|
||||
if (g.isEmpty) return 'Non défini';
|
||||
return (gender ?? '').trim();
|
||||
}
|
||||
|
||||
Widget _buildPresentationStep() {
|
||||
final p = widget.dossier.presentation ?? '';
|
||||
final text = p.trim().isEmpty ? 'Non défini' : p;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Présentation',
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: SelectableText(
|
||||
text,
|
||||
style:
|
||||
const TextStyle(color: Colors.black87, fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNavigation() {
|
||||
if (_step == 3) {
|
||||
return Row(
|
||||
children: [
|
||||
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
|
||||
const Spacer(),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() => _step = 2);
|
||||
_emitStep();
|
||||
},
|
||||
child: const Text('Précédent'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (_isEnAttente && _firstParentId != null) ...[
|
||||
OutlinedButton(
|
||||
onPressed: _submitting ? null : _refuser,
|
||||
child: const Text('Refuser')),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
style: ValidationModalTheme.primaryElevatedStyle,
|
||||
onPressed: _submitting ? null : _onValiderPressed,
|
||||
child: Text(_submitting ? 'Envoi...' : 'Valider'),
|
||||
),
|
||||
] else if (!_isEnAttente)
|
||||
ElevatedButton(
|
||||
style: ValidationModalTheme.primaryElevatedStyle,
|
||||
onPressed: widget.onClose,
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
children: [
|
||||
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
|
||||
const Spacer(),
|
||||
if (_step > 0) ...[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() => _step--);
|
||||
_emitStep();
|
||||
},
|
||||
child: const Text('Précédent'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
ElevatedButton(
|
||||
style: ValidationModalTheme.primaryElevatedStyle,
|
||||
onPressed: () {
|
||||
setState(() => _step++);
|
||||
_emitStep();
|
||||
},
|
||||
child: const Text('Suivant'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onValiderPressed() async {
|
||||
if (_submitting || _firstParentId == null) return;
|
||||
final ok = await showValidationValiderConfirmDialog(
|
||||
context,
|
||||
body:
|
||||
'Voulez-vous valider ce dossier famille ? Les comptes parents concernés seront confirmés.',
|
||||
);
|
||||
if (!mounted || !ok) return;
|
||||
await _valider();
|
||||
}
|
||||
|
||||
Future<void> _valider() async {
|
||||
if (_submitting || _firstParentId == null) return;
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
await UserService.validerDossierFamille(_firstParentId!);
|
||||
if (!mounted) return;
|
||||
widget.onSuccess();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e is Exception
|
||||
? e.toString().replaceFirst('Exception: ', '')
|
||||
: 'Erreur'),
|
||||
backgroundColor: Colors.red.shade700,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _refuser() => setState(() => _showRefusForm = true);
|
||||
|
||||
Widget _buildRefusPage() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ValidationRefusForm(
|
||||
isSubmitting: _submitting,
|
||||
onCancel: widget.onClose,
|
||||
onPrevious: () => setState(() => _showRefusForm = false),
|
||||
onSubmit: (comment) {
|
||||
if (comment == null || comment.trim().isEmpty) return;
|
||||
_refuserEnvoyer(comment.trim());
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Un seul appel : le back refuse tout le dossier famille (co-parents + mails). Ticket #110.
|
||||
Future<void> _refuserEnvoyer(String comment) async {
|
||||
if (_submitting) return;
|
||||
final parentId = _firstParentId?.trim();
|
||||
if (parentId == null || parentId.isEmpty || !_isEnAttente) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Aucun compte en attente à refuser pour ce dossier.'),
|
||||
backgroundColor: Colors.red.shade700,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
await UserService.refuseUser(parentId, comment: comment);
|
||||
if (!mounted) return;
|
||||
final nbEnAttente = widget.dossier.parents
|
||||
.where((p) => p.statut == 'en_attente')
|
||||
.length;
|
||||
final msg = nbEnAttente > 1
|
||||
? 'Refus enregistré. Un e-mail de reprise a été envoyé à chaque parent du dossier.'
|
||||
: 'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(msg),
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
widget.onSuccess();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e is Exception
|
||||
? e.toString().replaceFirst('Exception: ', '')
|
||||
: 'Erreur'),
|
||||
backgroundColor: Colors.red.shade700,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
import 'package:p_tits_pas/utils/name_format_utils.dart';
|
||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
||||
|
||||
@@ -67,7 +68,9 @@ class IdentityValues {
|
||||
|
||||
/// Bloc identité : Nom/Prénom, Tél/Email, Adresse, CP/Ville.
|
||||
/// Grille partagée (création de compte, validation AM/famille, fiches admin, etc.).
|
||||
class IdentityBlock extends StatelessWidget { final String? title;
|
||||
class IdentityBlock extends StatelessWidget {
|
||||
final String? title;
|
||||
final bool expandVertically;
|
||||
|
||||
final String? _nom;
|
||||
final String? _prenom;
|
||||
@@ -91,6 +94,7 @@ class IdentityBlock extends StatelessWidget { final String? title;
|
||||
const IdentityBlock.readOnly({
|
||||
super.key,
|
||||
this.title,
|
||||
this.expandVertically = false,
|
||||
required String nom,
|
||||
required String prenom,
|
||||
required String telephone,
|
||||
@@ -116,6 +120,7 @@ class IdentityBlock extends StatelessWidget { final String? title;
|
||||
const IdentityBlock.editable({
|
||||
super.key,
|
||||
this.title,
|
||||
this.expandVertically = false,
|
||||
required TextEditingController nomController,
|
||||
required TextEditingController prenomController,
|
||||
required TextEditingController telephoneController,
|
||||
@@ -142,11 +147,13 @@ class IdentityBlock extends StatelessWidget { final String? title;
|
||||
factory IdentityBlock.readOnlyValues({
|
||||
Key? key,
|
||||
String? title,
|
||||
bool expandVertically = false,
|
||||
required IdentityValues values,
|
||||
}) {
|
||||
return IdentityBlock.readOnly(
|
||||
key: key,
|
||||
title: title,
|
||||
expandVertically: expandVertically,
|
||||
nom: values.nom,
|
||||
prenom: values.prenom,
|
||||
telephone: values.telephone,
|
||||
@@ -163,10 +170,12 @@ class IdentityBlock extends StatelessWidget { final String? title;
|
||||
Key? key,
|
||||
String? title,
|
||||
String emptyLabel = 'Non défini',
|
||||
bool expandVertically = false,
|
||||
}) {
|
||||
return IdentityBlock.readOnlyValues(
|
||||
key: key,
|
||||
title: title,
|
||||
expandVertically: expandVertically,
|
||||
values: IdentityValues.fromUser(user, emptyLabel: emptyLabel),
|
||||
);
|
||||
}
|
||||
@@ -196,29 +205,29 @@ class IdentityBlock extends StatelessWidget { final String? title;
|
||||
title: title,
|
||||
rowLayout: rowLayout,
|
||||
rowFlex: rowFlex,
|
||||
expandVertically: expandVertically,
|
||||
fields: [
|
||||
ValidationLabeledField(
|
||||
label: 'Nom',
|
||||
field: ValidationEditableField(controller: _nomCtrl!),
|
||||
field: ValidationEditableField(
|
||||
controller: _nomCtrl!,
|
||||
inputFormatters: const [PersonNameInputFormatter()],
|
||||
),
|
||||
),
|
||||
ValidationLabeledField(
|
||||
label: 'Prénom',
|
||||
field: ValidationEditableField(controller: _prenomCtrl!),
|
||||
field: ValidationEditableField(
|
||||
controller: _prenomCtrl!,
|
||||
inputFormatters: const [PersonNameInputFormatter()],
|
||||
),
|
||||
),
|
||||
ValidationLabeledField(
|
||||
label: 'Téléphone',
|
||||
field: ValidationEditableField(
|
||||
controller: _telCtrl!,
|
||||
keyboardType: TextInputType.phone,
|
||||
inputFormatters: frenchPhoneInputFormatters,
|
||||
),
|
||||
field: ValidationPhoneField(controller: _telCtrl!),
|
||||
),
|
||||
ValidationLabeledField(
|
||||
label: 'Email',
|
||||
field: ValidationEditableField(
|
||||
controller: _emailCtrl!,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
),
|
||||
field: ValidationEmailField(controller: _emailCtrl!),
|
||||
),
|
||||
ValidationLabeledField(
|
||||
label: 'Adresse (N° et Rue)',
|
||||
@@ -226,14 +235,14 @@ class IdentityBlock extends StatelessWidget { final String? title;
|
||||
),
|
||||
ValidationLabeledField(
|
||||
label: 'Code postal',
|
||||
field: ValidationEditableField(
|
||||
controller: _cpCtrl!,
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
field: ValidationPostalCodeField(controller: _cpCtrl!),
|
||||
),
|
||||
ValidationLabeledField(
|
||||
label: 'Ville',
|
||||
field: ValidationEditableField(controller: _villeCtrl!),
|
||||
field: ValidationEditableField(
|
||||
controller: _villeCtrl!,
|
||||
inputFormatters: const [PersonNameInputFormatter()],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -243,6 +252,7 @@ class IdentityBlock extends StatelessWidget { final String? title;
|
||||
title: title,
|
||||
rowLayout: rowLayout,
|
||||
rowFlex: rowFlex,
|
||||
expandVertically: expandVertically,
|
||||
fields: [
|
||||
ValidationLabeledField(
|
||||
label: 'Nom',
|
||||
|
||||
@@ -54,7 +54,7 @@ class NirTextField extends StatelessWidget {
|
||||
inputFontSize: inputFontSize,
|
||||
keyboardType: TextInputType.text,
|
||||
validator: validator ?? validateNir,
|
||||
inputFormatters: [NirInputFormatter()],
|
||||
inputFormatters: const [NirInputFormatter()],
|
||||
enabled: enabled,
|
||||
readOnly: readOnly,
|
||||
style: style,
|
||||
|
||||
@@ -206,7 +206,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
|
||||
|
||||
@@ -21,6 +21,7 @@ dependencies:
|
||||
js: ^0.6.7
|
||||
url_launcher: ^6.2.4
|
||||
http: ^1.2.2
|
||||
http_parser: ^4.0.2
|
||||
# flutter_secure_storage: ^9.0.0
|
||||
pdfx: ^2.5.0
|
||||
universal_platform: ^1.1.0
|
||||
|
||||
Reference in New Issue
Block a user