feat: alignement master sur develop (squash)

- Dossiers unifiés #119, pending-families enrichi, validation admin (wizards)
- Front: modèles dossier_unifie / pending_family, NIR, auth
- Migrations dossier_famille, scripts de test API
- Résolution conflits: parents.*, docs tickets, auth_service, nir_utils

Made-with: Cursor
This commit is contained in:
MARTIN Julien 2026-03-26 00:20:47 +01:00
parent 060e610a75
commit cde676c4f9
49 changed files with 3934 additions and 222 deletions

View File

@ -19,7 +19,8 @@
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json" "test:e2e": "jest --config ./test/jest-e2e.json",
"test:api-dossiers": "node scripts/test-api-dossiers.js"
}, },
"dependencies": { "dependencies": {
"@nestjs/common": "^11.1.6", "@nestjs/common": "^11.1.6",

View File

@ -0,0 +1,122 @@
#!/usr/bin/env node
/**
* Test API GET /dossiers/:numeroDossier (dossier unifié AM ou famille).
*
* Prérequis : backend démarré (npm run start:dev dans backend/).
*
* Usage:
* node scripts/test-api-dossiers.js
* NUMERO_DOSSIER=2026-000001 node scripts/test-api-dossiers.js
* BASE_URL=https://app.ptits-pas.fr/api/v1 TEST_EMAIL=xxx TEST_PASSWORD=yyy NUMERO_DOSSIER=2026-000001 node scripts/test-api-dossiers.js
*
* Sans TEST_EMAIL/TEST_PASSWORD : 401 sur les routes protégées.
* NUMERO_DOSSIER : optionnel ; si absent, utilise le premier numero_dossier de pending-families (avec token).
*/
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000/api/v1';
const TEST_EMAIL = process.env.TEST_EMAIL;
const TEST_PASSWORD = process.env.TEST_PASSWORD;
const NUMERO_DOSSIER = process.env.NUMERO_DOSSIER;
async function request(method, path, body = null, token = null) {
const url = path.startsWith('http') ? path : `${BASE_URL}${path}`;
const opts = {
method,
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
};
if (token) opts.headers.Authorization = `Bearer ${token}`;
if (body) opts.body = JSON.stringify(body);
const res = await fetch(url, opts);
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch (_) {
data = text;
}
return { status: res.status, data };
}
async function main() {
console.log('Base URL:', BASE_URL);
console.log('Numéro dossier (env):', NUMERO_DOSSIER ?? '(sera déduit si token fourni)');
console.log('');
let token = null;
if (TEST_EMAIL && TEST_PASSWORD) {
console.log('1. Login...');
const loginRes = await request('POST', '/auth/login', {
email: TEST_EMAIL,
password: TEST_PASSWORD,
});
if (loginRes.status !== 200 && loginRes.status !== 201) {
console.log(' Échec login:', loginRes.status, loginRes.data);
process.exit(1);
}
token = loginRes.data?.access_token ?? loginRes.data?.accessToken ?? null;
if (!token) {
console.log(' Réponse login sans token:', JSON.stringify(loginRes.data, null, 2));
process.exit(1);
}
console.log(' OK, token reçu.');
console.log('');
} else {
console.log('TEST_EMAIL / TEST_PASSWORD non définis : GET /dossiers/:numero nécessite un token (401 attendu).');
console.log('');
}
let numeroDossier = NUMERO_DOSSIER;
if (!numeroDossier && token) {
console.log('2. Récupération d\'un numéro de dossier (GET /parents/pending-families)...');
const pendingRes = await request('GET', '/parents/pending-families', null, token);
if (pendingRes.status === 200 && Array.isArray(pendingRes.data) && pendingRes.data.length > 0) {
numeroDossier = pendingRes.data[0].numero_dossier || null;
console.log(' Premier numero_dossier:', numeroDossier);
} else {
console.log(' Aucune famille en attente ou erreur. Utilisez NUMERO_DOSSIER=2026-000001');
}
console.log('');
}
if (!numeroDossier) {
numeroDossier = '2026-000001';
console.log('2. Pas de numéro fourni, test avec numéro par défaut:', numeroDossier);
} else {
console.log('2. GET /dossiers/' + encodeURIComponent(numeroDossier));
}
const dossierRes = await request(
'GET',
'/dossiers/' + encodeURIComponent(numeroDossier),
null,
token
);
console.log(' Status:', dossierRes.status);
if (dossierRes.status === 200 && dossierRes.data) {
const d = dossierRes.data;
console.log(' type:', d.type);
console.log(' dossier (clés):', d.dossier ? Object.keys(d.dossier) : '-');
if (d.dossier && Array.isArray(d.dossier.enfants)) {
console.log(' enfants:', d.dossier.enfants.length);
d.dossier.enfants.forEach((e, i) => {
console.log(
` [${i + 1}] id=${e.id} first_name=${e.first_name} last_name=${e.last_name} birth_date=${e.birth_date} gender=${e.gender} genre=${e.genre} status=${e.status}`
);
});
}
console.log('');
console.log('Réponse brute (dossier):');
console.log(JSON.stringify(d.dossier, null, 2));
} else {
console.log(' Réponse:', JSON.stringify(dossierRes.data, null, 2));
}
console.log('');
console.log('Fin du test.');
}
main().catch((err) => {
console.error('Erreur:', err.message || err);
process.exit(1);
});

View File

@ -0,0 +1,110 @@
#!/usr/bin/env node
/**
* Test des endpoints "comptes en attente" (ticket #107).
*
* Prérequis : backend démarré (npm run start:dev dans backend/).
*
* Usage:
* node scripts/test-pending-api.js
* TEST_EMAIL=xxx TEST_PASSWORD=yyy node scripts/test-pending-api.js
* BASE_URL=https://app.ptits-pas.fr/api/v1 TEST_EMAIL=xxx TEST_PASSWORD=yyy node scripts/test-pending-api.js
*
* Sans TEST_EMAIL/TEST_PASSWORD : les GET protégés renverront 401 (normal).
* Avec un compte gestionnaire ou admin : affiche les listes en attente.
*/
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000/api/v1';
const TEST_EMAIL = process.env.TEST_EMAIL;
const TEST_PASSWORD = process.env.TEST_PASSWORD;
async function request(method, path, body = null, token = null) {
const url = path.startsWith('http') ? path : `${BASE_URL}${path}`;
const opts = {
method,
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
};
if (token) opts.headers.Authorization = `Bearer ${token}`;
if (body) opts.body = JSON.stringify(body);
const res = await fetch(url, opts);
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch (_) {
data = text;
}
return { status: res.status, data };
}
async function main() {
console.log('Base URL:', BASE_URL);
console.log('');
let token = null;
if (TEST_EMAIL && TEST_PASSWORD) {
console.log('1. Login...');
const loginRes = await request('POST', '/auth/login', {
email: TEST_EMAIL,
password: TEST_PASSWORD,
});
if (loginRes.status !== 200 && loginRes.status !== 201) {
console.log(' Échec login:', loginRes.status, loginRes.data);
process.exit(1);
}
token = loginRes.data?.access_token ?? loginRes.data?.accessToken ?? null;
if (!token) {
console.log(' Réponse login sans token:', JSON.stringify(loginRes.data, null, 2));
process.exit(1);
}
console.log(' OK, token reçu.');
console.log('');
} else {
console.log('TEST_EMAIL / TEST_PASSWORD non définis : les appels protégés vont renvoyer 401.');
console.log('Exemple: TEST_EMAIL=admin@example.com TEST_PASSWORD=xxx node scripts/test-pending-api.js');
console.log('');
}
console.log('2. GET /users/pending?role=assistante_maternelle');
const pendingUsersRes = await request(
'GET',
'/users/pending?role=assistante_maternelle',
null,
token
);
console.log(' Status:', pendingUsersRes.status);
if (pendingUsersRes.status === 200) {
const list = Array.isArray(pendingUsersRes.data) ? pendingUsersRes.data : [];
console.log(' Nombre d\'utilisateurs en attente (AM):', list.length);
list.forEach((u, i) => {
console.log(
` [${i + 1}] id=${u.id} email=${u.email} role=${u.role} statut=${u.statut} numero_dossier=${u.numero_dossier ?? '-'}`
);
});
} else {
console.log(' Réponse:', JSON.stringify(pendingUsersRes.data, null, 2));
}
console.log('');
console.log('3. GET /parents/pending-families');
const pendingFamiliesRes = await request('GET', '/parents/pending-families', null, token);
console.log(' Status:', pendingFamiliesRes.status);
if (pendingFamiliesRes.status === 200) {
const list = Array.isArray(pendingFamiliesRes.data) ? pendingFamiliesRes.data : [];
console.log(' Nombre de familles en attente:', list.length);
list.forEach((f, i) => {
console.log(
` [${i + 1}] libelle=${f.libelle} parentIds=${JSON.stringify(f.parentIds)} numero_dossier=${f.numero_dossier ?? '-'}`
);
});
} else {
console.log(' Réponse:', JSON.stringify(pendingFamiliesRes.data, null, 2));
}
console.log('');
console.log('Fin du test.');
}
main().catch((err) => {
console.error('Erreur:', err.message || err);
process.exit(1);
});

View File

@ -0,0 +1,97 @@
/**
* Met à jour l'issue Gitea #119 : endpoint unifié GET /dossiers/:numeroDossier (option A)
* Usage: node backend/scripts/update-gitea-issue-119-dossiers.js
* Token : .gitea-token (racine), GITEA_TOKEN, ou docs/BRIEFING-FRONTEND.md
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
const repoRoot = path.join(__dirname, '../..');
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/BRIEFING-FRONTEND.md'), 'utf8');
const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/);
if (m) token = m[1].trim();
} catch (_) {}
}
if (!token) {
console.error('Token non trouvé');
process.exit(1);
}
const body = `## Besoin
Un **seul** endpoint **GET par numéro de dossier** qui renvoie le dossier complet, **AM ou famille** selon le numéro. Clé unique = numéro de dossier (usage : modale de validation, consultation gestionnaire, reprise, etc.).
**Option A Endpoint unifié**
- **Route** : \`GET /api/v1/dossiers/:numeroDossier\` (ou \`GET /dossiers/:numeroDossier\` selon préfixe API).
- Le backend détermine si le numéro appartient à une **AM** ou à une **famille** (ex. lookup \`users\` / \`parents\` / \`assistantes_maternelles\`).
- **Réponse** avec discriminent :
- \`{ type: 'family', dossier: { numero_dossier, parents, enfants, presentation } }\`
- \`{ type: 'am', dossier: { numero_dossier, user, ... } }\` (fiche AM complète, champs utiles sans secrets)
- **Rôles** : SUPER_ADMIN, ADMINISTRATEUR, GESTIONNAIRE.
- **Réponses** : 200 (dossier), 403, 404 (numéro inconnu).
Aucun filtre par statut : on renvoie le dossier s'il existe ; le front affiche Valider/Refuser selon le statut.
**Labels suggérés** : backend, api, dossiers, gestionnaire
---
## Implémentation
- **Nouveau module ou route** : \`GET /dossiers/:numeroDossier\`.
- **Service** : trouver qui possède ce \`numero_dossier\` (famille → \`parents\`, AM → \`users\` + \`assistantes_maternelles\`). Appeler la logique existante dossier-famille ou construire le payload AM, puis retourner \`{ type, dossier }\`.
- **Réutiliser** : la logique actuelle \`GET /parents/dossier-famille/:numeroDossier\` peut être appelée en interne pour \`type: 'family'\` ; ajouter une branche \`type: 'am'\` avec un DTO « dossier AM complet ».
- DTO(s) : garder \`DossierFamilleCompletDto\` pour la famille ; ajouter un DTO pour le dossier AM (user sans secrets + infos AM). Réponse unifiée : \`{ type: 'am' | 'family', dossier: ... }\`.`;
const payload = JSON.stringify({
title: 'Endpoint unifié GET /dossiers/:numeroDossier (AM ou famille)',
body,
});
const opts = {
hostname: 'git.ptits-pas.fr',
path: '/api/v1/repos/jmartin/petitspas/issues/119',
method: 'PATCH',
headers: {
Authorization: 'token ' + token,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
},
};
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 || o.id) {
console.log('Issue #119 mise à jour.');
console.log('URL:', o.html_url || 'https://git.ptits-pas.fr/jmartin/petitspas/issues/119');
} else {
console.error('Erreur API:', o.message || d);
process.exit(1);
}
} catch (e) {
console.error('Réponse:', d);
process.exit(1);
}
});
});
req.on('error', (e) => {
console.error(e);
process.exit(1);
});
req.write(payload);
req.end();

View File

@ -17,6 +17,7 @@ import { EnfantsModule } from './routes/enfants/enfants.module';
import { AppConfigModule } from './modules/config/config.module'; import { AppConfigModule } from './modules/config/config.module';
import { DocumentsLegauxModule } from './modules/documents-legaux'; import { DocumentsLegauxModule } from './modules/documents-legaux';
import { RelaisModule } from './routes/relais/relais.module'; import { RelaisModule } from './routes/relais/relais.module';
import { DossiersModule } from './routes/dossiers/dossiers.module';
@Module({ @Module({
imports: [ imports: [
@ -55,6 +56,7 @@ import { RelaisModule } from './routes/relais/relais.module';
AppConfigModule, AppConfigModule,
DocumentsLegauxModule, DocumentsLegauxModule,
RelaisModule, RelaisModule,
DossiersModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [

View File

@ -0,0 +1,65 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
OneToMany,
CreateDateColumn,
UpdateDateColumn,
JoinColumn,
} from 'typeorm';
import { Parents } from './parents.entity';
import { Children } from './children.entity';
import { StatutDossierType } from './dossiers.entity';
/** Un dossier = une famille, N enfants (texte de motivation unique, liste d'enfants). */
@Entity('dossier_famille')
export class DossierFamille {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'numero_dossier', length: 20 })
numero_dossier: string;
@ManyToOne(() => Parents, { onDelete: 'CASCADE', nullable: false })
@JoinColumn({ name: 'id_parent', referencedColumnName: 'user_id' })
parent: Parents;
@Column({ type: 'text', nullable: true })
presentation?: string;
@Column({
type: 'enum',
enum: StatutDossierType,
enumName: 'statut_dossier_type',
default: StatutDossierType.ENVOYE,
name: 'statut',
})
statut: StatutDossierType;
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
cree_le: Date;
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
modifie_le: Date;
@OneToMany(() => DossierFamilleEnfant, (dfe) => dfe.dossier_famille)
enfants: DossierFamilleEnfant[];
}
@Entity('dossier_famille_enfants')
export class DossierFamilleEnfant {
@Column({ name: 'id_dossier_famille', primary: true })
id_dossier_famille: string;
@Column({ name: 'id_enfant', primary: true })
id_enfant: string;
@ManyToOne(() => DossierFamille, (df) => df.enfants, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'id_dossier_famille' })
dossier_famille: DossierFamille;
@ManyToOne(() => Children, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'id_enfant' })
enfant: Children;
}

View File

@ -43,6 +43,8 @@ export class AuthService {
private readonly usersRepo: Repository<Users>, private readonly usersRepo: Repository<Users>,
@InjectRepository(Children) @InjectRepository(Children)
private readonly childrenRepo: Repository<Children>, private readonly childrenRepo: Repository<Children>,
@InjectRepository(AssistanteMaternelle)
private readonly assistantesMaternellesRepo: Repository<AssistanteMaternelle>,
) { } ) { }
/** /**
@ -189,6 +191,11 @@ export class AuthService {
} }
if (dto.co_parent_email) { if (dto.co_parent_email) {
if (dto.email.trim().toLowerCase() === dto.co_parent_email.trim().toLowerCase()) {
throw new BadRequestException(
'L\'email du parent et du co-parent doivent être différents.',
);
}
const coParentExiste = await this.usersService.findByEmailOrNull(dto.co_parent_email); const coParentExiste = await this.usersService.findByEmailOrNull(dto.co_parent_email);
if (coParentExiste) { if (coParentExiste) {
throw new ConflictException('L\'email du co-parent est déjà utilisé'); throw new ConflictException('L\'email du co-parent est déjà utilisé');
@ -360,6 +367,27 @@ export class AuthService {
throw new ConflictException('Un compte avec cet email existe déjà'); throw new ConflictException('Un compte avec cet email existe déjà');
} }
const nirDejaUtilise = await this.assistantesMaternellesRepo.findOne({
where: { nir: nirNormalized },
});
if (nirDejaUtilise) {
throw new ConflictException(
'Un compte assistante maternelle avec ce numéro NIR existe déjà.',
);
}
const numeroAgrement = (dto.numero_agrement || '').trim();
if (numeroAgrement) {
const agrementDejaUtilise = await this.assistantesMaternellesRepo.findOne({
where: { approval_number: numeroAgrement },
});
if (agrementDejaUtilise) {
throw new ConflictException(
'Un compte assistante maternelle avec ce numéro d\'agrément existe déjà.',
);
}
}
const joursExpirationToken = await this.appConfigService.get<number>( const joursExpirationToken = await this.appConfigService.get<number>(
'password_reset_token_expiry_days', 'password_reset_token_expiry_days',
7, 7,

View File

@ -0,0 +1,26 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Roles } from 'src/common/decorators/roles.decorator';
import { RoleType } from 'src/entities/users.entity';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { DossiersService } from './dossiers.service';
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
@ApiTags('Dossiers')
@Controller('dossiers')
@UseGuards(AuthGuard, RolesGuard)
export class DossiersController {
constructor(private readonly dossiersService: DossiersService) {}
@Get(':numeroDossier')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Dossier complet par numéro (AM ou famille) Ticket #119' })
@ApiParam({ name: 'numeroDossier', description: 'Numéro de dossier (ex: 2026-000001)' })
@ApiResponse({ status: 200, description: 'Dossier famille ou AM', type: DossierUnifieDto })
@ApiResponse({ status: 404, description: 'Aucun dossier pour ce numéro' })
@ApiResponse({ status: 403, description: 'Accès refusé' })
getDossier(@Param('numeroDossier') numeroDossier: string): Promise<DossierUnifieDto> {
return this.dossiersService.getDossierByNumero(numeroDossier);
}
}

View File

@ -1,4 +1,28 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
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 { DossiersController } from './dossiers.controller';
import { DossiersService } from './dossiers.service';
@Module({}) @Module({
imports: [
TypeOrmModule.forFeature([Parents, AssistanteMaternelle]),
ParentsModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
secret: config.get('jwt.accessSecret'),
signOptions: { expiresIn: config.get('jwt.accessExpiresIn') },
}),
inject: [ConfigService],
}),
],
controllers: [DossiersController],
providers: [DossiersService],
exports: [DossiersService],
})
export class DossiersModule {} export class DossiersModule {}

View File

@ -0,0 +1,81 @@
import { Injectable, NotFoundException } from '@nestjs/common';
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 { ParentsService } from '../parents/parents.service';
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto';
/**
* Endpoint unifié GET /dossiers/:numeroDossier AM ou famille. Ticket #119.
*/
@Injectable()
export class DossiersService {
constructor(
@InjectRepository(Parents)
private readonly parentsRepository: Repository<Parents>,
@InjectRepository(AssistanteMaternelle)
private readonly amRepository: Repository<AssistanteMaternelle>,
private readonly parentsService: ParentsService,
) {}
async getDossierByNumero(numeroDossier: string): Promise<DossierUnifieDto> {
const num = numeroDossier?.trim();
if (!num) {
throw new NotFoundException('Numéro de dossier requis.');
}
// 1) Famille : un parent a ce numéro ?
const parentWithNum = await this.parentsRepository.findOne({
where: { numero_dossier: num },
select: ['user_id'],
});
if (parentWithNum) {
const dossier = await this.parentsService.getDossierFamilleByNumero(num);
return { type: 'family', dossier };
}
// 2) AM : une assistante maternelle a ce numéro ?
const am = await this.amRepository.findOne({
where: { numero_dossier: num },
relations: ['user'],
});
if (am?.user) {
const dossier: DossierAmCompletDto = {
numero_dossier: num,
user: this.toDossierAmUserDto(am.user),
numero_agrement: am.approval_number,
nir: am.nir,
biographie: am.biography,
disponible: am.available,
ville_residence: am.residence_city,
date_agrement: am.agreement_date,
annees_experience: am.years_experience,
specialite: am.specialty,
nb_max_enfants: am.max_children,
place_disponible: am.places_available,
};
return { type: 'am', dossier };
}
throw new NotFoundException('Aucun dossier trouvé pour ce numéro.');
}
private toDossierAmUserDto(user: { id: string; email: string; prenom?: string; nom?: string; telephone?: string; adresse?: string; ville?: string; code_postal?: string; profession?: string; date_naissance?: Date; photo_url?: string; statut: any }): DossierAmUserDto {
return {
id: user.id,
email: user.email,
prenom: user.prenom,
nom: user.nom,
telephone: user.telephone,
adresse: user.adresse,
ville: user.ville,
code_postal: user.code_postal,
profession: user.profession,
date_naissance: user.date_naissance,
photo_url: user.photo_url,
statut: user.statut,
};
}
}

View File

@ -0,0 +1,58 @@
import { ApiProperty } from '@nestjs/swagger';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Utilisateur AM sans données sensibles (pour dossier AM complet). Ticket #119 */
export class DossierAmUserDto {
@ApiProperty()
id: string;
@ApiProperty()
email: string;
@ApiProperty({ required: false })
prenom?: string;
@ApiProperty({ required: false })
nom?: string;
@ApiProperty({ required: false })
telephone?: string;
@ApiProperty({ required: false })
adresse?: string;
@ApiProperty({ required: false })
ville?: string;
@ApiProperty({ required: false })
code_postal?: string;
@ApiProperty({ required: false })
profession?: string;
@ApiProperty({ required: false })
date_naissance?: Date;
@ApiProperty({ required: false })
photo_url?: string;
@ApiProperty({ enum: StatutUtilisateurType })
statut: StatutUtilisateurType;
}
/** Dossier AM complet (fiche AM sans secrets). Ticket #119 */
export class DossierAmCompletDto {
@ApiProperty({ example: '2026-000003', description: 'Numéro de dossier AM' })
numero_dossier: string;
@ApiProperty({ type: DossierAmUserDto, description: 'Utilisateur (sans mot de passe ni tokens)' })
user: DossierAmUserDto;
@ApiProperty({ required: false })
numero_agrement?: string;
@ApiProperty({ required: false })
nir?: string;
@ApiProperty({ required: false })
biographie?: string;
@ApiProperty({ required: false })
disponible?: boolean;
@ApiProperty({ required: false })
ville_residence?: string;
@ApiProperty({ required: false })
date_agrement?: Date;
@ApiProperty({ required: false })
annees_experience?: number;
@ApiProperty({ required: false })
specialite?: string;
@ApiProperty({ required: false })
nb_max_enfants?: number;
@ApiProperty({ required: false })
place_disponible?: number;
}

View File

@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { DossierFamilleCompletDto } from '../../parents/dto/dossier-famille-complet.dto';
import { DossierAmCompletDto } from './dossier-am-complet.dto';
/** Réponse unifiée GET /dossiers/:numeroDossier AM ou famille. Ticket #119 */
export class DossierUnifieDto {
@ApiProperty({ enum: ['family', 'am'], description: 'Type de dossier' })
type: 'family' | 'am';
@ApiProperty({
description: 'Dossier famille (si type=family) ou dossier AM (si type=am)',
})
dossier: DossierFamilleCompletDto | DossierAmCompletDto;
}

View File

@ -0,0 +1,57 @@
import { ApiProperty } from '@nestjs/swagger';
import { StatutUtilisateurType } from 'src/entities/users.entity';
import { StatutEnfantType, GenreType } from 'src/entities/children.entity';
/** Parent dans le dossier famille (infos utilisateur + parent) */
export class DossierFamilleParentDto {
@ApiProperty()
user_id: string;
@ApiProperty()
email: string;
@ApiProperty({ required: false })
prenom?: string;
@ApiProperty({ required: false })
nom?: string;
@ApiProperty({ required: false })
telephone?: string;
@ApiProperty({ required: false })
adresse?: string;
@ApiProperty({ required: false })
ville?: string;
@ApiProperty({ required: false })
code_postal?: string;
@ApiProperty({ enum: StatutUtilisateurType })
statut: StatutUtilisateurType;
@ApiProperty({ required: false, description: 'Id du co-parent si couple' })
co_parent_id?: string;
}
/** Enfant dans le dossier famille */
export class DossierFamilleEnfantDto {
@ApiProperty()
id: string;
@ApiProperty({ required: false })
first_name?: string;
@ApiProperty({ required: false })
last_name?: string;
@ApiProperty({ required: false, enum: GenreType })
genre?: GenreType;
@ApiProperty({ required: false })
birth_date?: Date;
@ApiProperty({ required: false })
due_date?: Date;
@ApiProperty({ enum: StatutEnfantType })
status: StatutEnfantType;
}
/** Réponse GET /parents/dossier-famille/:numeroDossier dossier famille complet. Ticket #119 */
export class DossierFamilleCompletDto {
@ApiProperty({ example: '2026-000001', description: 'Numéro de dossier famille' })
numero_dossier: string;
@ApiProperty({ type: [DossierFamilleParentDto] })
parents: DossierFamilleParentDto[];
@ApiProperty({ type: [DossierFamilleEnfantDto], description: 'Enfants de la famille' })
enfants: DossierFamilleEnfantDto[];
@ApiProperty({ required: false, description: 'Texte de présentation / motivation (un seul par famille)' })
texte_motivation?: string;
}

View File

@ -1,4 +1,21 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ParentPendingSummaryDto {
@ApiProperty({ description: 'UUID utilisateur' })
id: string;
@ApiProperty()
email: string;
@ApiPropertyOptional({ nullable: true })
telephone?: string | null;
@ApiPropertyOptional({ nullable: true })
code_postal?: string | null;
@ApiPropertyOptional({ nullable: true })
ville?: string | null;
}
export class PendingFamilyDto { export class PendingFamilyDto {
@ApiProperty({ example: 'Famille Dupont', description: 'Libellé affiché pour la famille' }) @ApiProperty({ example: 'Famille Dupont', description: 'Libellé affiché pour la famille' })
@ -17,4 +34,30 @@ export class PendingFamilyDto {
description: 'Numéro de dossier famille (format AAAA-NNNNNN)', description: 'Numéro de dossier famille (format AAAA-NNNNNN)',
}) })
numero_dossier: string | null; numero_dossier: string | null;
@ApiProperty({
nullable: true,
example: '2026-01-12T10:00:00.000Z',
description: 'Date de référence dossier soumis / en attente : MIN(cree_le) des parents en_attente du groupe (ISO 8601)',
})
date_soumission: string | null;
@ApiProperty({
example: 3,
description: 'Nombre denfants distincts liés aux parents de la famille (enfants_parents)',
})
nombre_enfants: number;
@ApiPropertyOptional({
type: [String],
example: ['parent1@example.com', 'parent2@example.com'],
description: 'Emails des parents du groupe (ordre stable : nom, prénom)',
})
emails?: string[];
@ApiPropertyOptional({
type: [ParentPendingSummaryDto],
description: 'Résumé des parents (ordre stable, aligné sur parentIds/emails)',
})
parents?: ParentPendingSummaryDto[];
} }

View File

@ -20,6 +20,7 @@ import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard'; import { RolesGuard } from 'src/common/guards/roles.guard';
import { User } from 'src/common/decorators/user.decorator'; import { User } from 'src/common/decorators/user.decorator';
import { PendingFamilyDto } from './dto/pending-family.dto'; import { PendingFamilyDto } from './dto/pending-family.dto';
import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto';
@ApiTags('Parents') @ApiTags('Parents')
@Controller('parents') @Controller('parents')
@ -33,12 +34,28 @@ export class ParentsController {
@Get('pending-families') @Get('pending-families')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' }) @ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' })
@ApiResponse({ status: 200, description: 'Liste des familles (libellé, parentIds, numero_dossier)', type: [PendingFamilyDto] }) @ApiResponse({
status: 200,
description:
'Liste des familles (libellé, parentIds, numero_dossier, date_soumission, nombre_enfants, emails, parents)',
type: [PendingFamilyDto],
})
@ApiResponse({ status: 403, description: 'Accès refusé' }) @ApiResponse({ status: 403, description: 'Accès refusé' })
getPendingFamilies(): Promise<PendingFamilyDto[]> { getPendingFamilies(): Promise<PendingFamilyDto[]> {
return this.parentsService.getPendingFamilies(); return this.parentsService.getPendingFamilies();
} }
@Get('dossier-famille/:numeroDossier')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Dossier famille complet par numéro de dossier (Ticket #119)' })
@ApiParam({ name: 'numeroDossier', description: 'Numéro de dossier (ex: 2026-000001)' })
@ApiResponse({ status: 200, description: 'Dossier famille (numero_dossier, parents, enfants, presentation)', type: DossierFamilleCompletDto })
@ApiResponse({ status: 404, description: 'Aucun dossier pour ce numéro' })
@ApiResponse({ status: 403, description: 'Accès refusé' })
getDossierFamille(@Param('numeroDossier') numeroDossier: string): Promise<DossierFamilleCompletDto> {
return this.parentsService.getDossierFamilleByNumero(numeroDossier);
}
@Post(':parentId/valider-dossier') @Post(':parentId/valider-dossier')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Valider tout le dossier famille (les 2 parents en une fois)' }) @ApiOperation({ summary: 'Valider tout le dossier famille (les 2 parents en une fois)' })

View File

@ -1,6 +1,9 @@
import { Module, forwardRef } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { Parents } from 'src/entities/parents.entity'; import { Parents } from 'src/entities/parents.entity';
import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity';
import { ParentsController } from './parents.controller'; import { ParentsController } from './parents.controller';
import { ParentsService } from './parents.service'; import { ParentsService } from './parents.service';
import { Users } from 'src/entities/users.entity'; import { Users } from 'src/entities/users.entity';
@ -8,8 +11,16 @@ import { UserModule } from '../user/user.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Parents, Users]), TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant]),
forwardRef(() => UserModule), forwardRef(() => UserModule),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
secret: config.get('jwt.accessSecret'),
signOptions: { expiresIn: config.get('jwt.accessExpiresIn') },
}),
inject: [ConfigService],
}),
], ],
controllers: [ParentsController], controllers: [ParentsController],
providers: [ParentsService], providers: [ParentsService],

View File

@ -5,12 +5,18 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { In, Repository } from 'typeorm';
import { Parents } from 'src/entities/parents.entity'; import { Parents } from 'src/entities/parents.entity';
import { DossierFamille } from 'src/entities/dossier_famille.entity';
import { RoleType, Users } from 'src/entities/users.entity'; import { RoleType, Users } from 'src/entities/users.entity';
import { CreateParentDto } from '../user/dto/create_parent.dto'; import { CreateParentDto } from '../user/dto/create_parent.dto';
import { UpdateParentsDto } from '../user/dto/update_parent.dto'; import { UpdateParentsDto } from '../user/dto/update_parent.dto';
import { PendingFamilyDto } from './dto/pending-family.dto'; import { PendingFamilyDto } from './dto/pending-family.dto';
import {
DossierFamilleCompletDto,
DossierFamilleParentDto,
DossierFamilleEnfantDto,
} from './dto/dossier-famille-complet.dto';
@Injectable() @Injectable()
export class ParentsService { export class ParentsService {
@ -19,6 +25,8 @@ export class ParentsService {
private readonly parentsRepository: Repository<Parents>, private readonly parentsRepository: Repository<Parents>,
@InjectRepository(Users) @InjectRepository(Users)
private readonly usersRepository: Repository<Users>, private readonly usersRepository: Repository<Users>,
@InjectRepository(DossierFamille)
private readonly dossierFamilleRepository: Repository<DossierFamille>,
) {} ) {}
// Création dun parent // Création dun parent
@ -79,7 +87,17 @@ export class ParentsService {
* Uniquement les parents dont l'utilisateur a statut = en_attente. * Uniquement les parents dont l'utilisateur a statut = en_attente.
*/ */
async getPendingFamilies(): Promise<PendingFamilyDto[]> { async getPendingFamilies(): Promise<PendingFamilyDto[]> {
const raw = await this.parentsRepository.query(` let raw: {
libelle: string;
parentIds: unknown;
numero_dossier: string | null;
date_soumission: Date | string | null;
nombre_enfants: string | number | null;
emails: unknown;
parents: unknown;
}[];
try {
raw = await this.parentsRepository.query(`
WITH RECURSIVE WITH RECURSIVE
links AS ( links AS (
SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
@ -104,8 +122,27 @@ export class ParentsService {
) )
SELECT SELECT
'Famille ' || string_agg(u.nom, ' - ' ORDER BY u.nom, u.prenom) AS libelle, 'Famille ' || string_agg(u.nom, ' - ' ORDER BY u.nom, u.prenom) AS libelle,
array_agg(DISTINCT p.id_utilisateur ORDER BY p.id_utilisateur) AS "parentIds", array_agg(p.id_utilisateur ORDER BY u.nom, u.prenom, u.id) AS "parentIds",
(array_agg(p.numero_dossier))[1] AS numero_dossier (array_agg(p.numero_dossier))[1] AS numero_dossier,
MIN(u.cree_le) AS date_soumission,
COALESCE((
SELECT COUNT(DISTINCT ep.id_enfant)::int
FROM enfants_parents ep
WHERE ep.id_parent IN (
SELECT frx.id FROM family_rep frx WHERE frx.rep = fr.rep
)
), 0) AS nombre_enfants,
array_agg(u.email ORDER BY u.nom, u.prenom, u.id) AS emails,
json_agg(
json_build_object(
'id', u.id::text,
'email', u.email,
'telephone', u.telephone,
'code_postal', u.code_postal,
'ville', u.ville
)
ORDER BY u.nom, u.prenom, u.id
) AS parents
FROM family_rep fr FROM family_rep fr
JOIN parents p ON p.id_utilisateur = fr.id JOIN parents p ON p.id_utilisateur = fr.id
JOIN utilisateurs u ON u.id = p.id_utilisateur JOIN utilisateurs u ON u.id = p.id_utilisateur
@ -113,13 +150,151 @@ export class ParentsService {
GROUP BY fr.rep GROUP BY fr.rep
ORDER BY libelle ORDER BY libelle
`); `);
return raw.map((r: { libelle: string; parentIds: unknown; numero_dossier: string | null }) => ({ } catch (err) {
libelle: r.libelle, throw err;
parentIds: Array.isArray(r.parentIds) ? r.parentIds.map(String) : [], }
if (!Array.isArray(raw)) return [];
return raw.map((r) => ({
libelle: r.libelle ?? '',
parentIds: this.normalizeParentIds(r.parentIds),
numero_dossier: r.numero_dossier ?? null, numero_dossier: r.numero_dossier ?? null,
date_soumission: this.toIsoDateTimeOrNull(r.date_soumission),
nombre_enfants: this.normalizeNombreEnfants(r.nombre_enfants),
emails: this.normalizeEmails(r.emails),
parents: this.normalizeParents(r.parents),
})); }));
} }
private toIsoDateTimeOrNull(value: Date | string | null | undefined): string | null {
if (value == null) return null;
if (value instanceof Date) return value.toISOString();
const d = new Date(value);
return Number.isNaN(d.getTime()) ? null : d.toISOString();
}
private normalizeNombreEnfants(v: string | number | null | undefined): number {
if (v == null) return 0;
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
return Number.isFinite(n) && n >= 0 ? n : 0;
}
private normalizeEmails(emails: unknown): string[] {
if (Array.isArray(emails)) return emails.map(String);
if (typeof emails === 'string') {
const s = emails.replace(/^\{|\}$/g, '').trim();
return s ? s.split(',').map((x) => x.trim()) : [];
}
return [];
}
private normalizeParents(parents: unknown): { id: string; email: string; 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 ?? ''),
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,
}));
}
if (typeof parents === 'string') {
try {
const parsed = JSON.parse(parents);
return this.normalizeParents(parsed);
} catch {
return [];
}
}
return [];
}
/** Convertit parentIds (array ou chaîne PG) en string[] pour éviter 500 si le driver renvoie une chaîne. */
private normalizeParentIds(parentIds: unknown): string[] {
if (Array.isArray(parentIds)) return parentIds.map(String);
if (typeof parentIds === 'string') {
const s = parentIds.replace(/^\{|\}$/g, '').trim();
return s ? s.split(',').map((x) => x.trim()) : [];
}
return [];
}
/**
* Dossier famille complet par numéro de dossier. Ticket #119.
* Rôles : admin, gestionnaire.
* @throws NotFoundException si aucun parent avec ce numéro de dossier
*/
async getDossierFamilleByNumero(numeroDossier: string): Promise<DossierFamilleCompletDto> {
const num = numeroDossier?.trim();
if (!num) {
throw new NotFoundException('Numéro de dossier requis.');
}
const firstParent = await this.parentsRepository.findOne({
where: { numero_dossier: num },
relations: ['user'],
});
if (!firstParent || !firstParent.user) {
throw new NotFoundException('Aucun dossier famille trouvé pour ce numéro.');
}
const familyUserIds = await this.getFamilyUserIds(firstParent.user_id);
const parents = await this.parentsRepository.find({
where: { user_id: In(familyUserIds) },
relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers', 'dossiers.child'],
});
const enfantsMap = new Map<string, DossierFamilleEnfantDto>();
let texte_motivation: string | undefined;
// Un dossier = une famille, un seul texte de motivation
const dossierFamille = await this.dossierFamilleRepository.findOne({
where: { numero_dossier: num },
relations: ['parent', 'enfants', 'enfants.enfant'],
});
if (dossierFamille?.presentation) {
texte_motivation = dossierFamille.presentation;
}
for (const p of parents) {
// Enfants via parentChildren
if (p.parentChildren) {
for (const pc of p.parentChildren) {
if (pc.child && !enfantsMap.has(pc.child.id)) {
enfantsMap.set(pc.child.id, {
id: pc.child.id,
first_name: pc.child.first_name,
last_name: pc.child.last_name,
genre: pc.child.gender,
birth_date: pc.child.birth_date,
due_date: pc.child.due_date,
status: pc.child.status,
});
}
}
}
// Fallback : anciens dossiers (un texte, on prend le premier)
if (texte_motivation == null && p.dossiers?.length) {
texte_motivation = p.dossiers[0].presentation ?? undefined;
}
}
const parentsDto: DossierFamilleParentDto[] = parents.map((p) => ({
user_id: p.user_id,
email: p.user.email,
prenom: p.user.prenom,
nom: p.user.nom,
telephone: p.user.telephone,
adresse: p.user.adresse,
ville: p.user.ville,
code_postal: p.user.code_postal,
statut: p.user.statut,
co_parent_id: p.co_parent?.id,
}));
return {
numero_dossier: num,
parents: parentsDto,
enfants: Array.from(enfantsMap.values()),
texte_motivation,
};
}
/** /**
* Retourne les user_id de tous les parents de la même famille (co_parent ou enfants partagés). * Retourne les user_id de tous les parents de la même famille (co_parent ou enfants partagés).
* @throws NotFoundException si parentId n'est pas un parent * @throws NotFoundException si parentId n'est pas un parent

View File

@ -0,0 +1,27 @@
-- Un dossier = une famille, N enfants. Ticket #119 évolution.
-- Table: un enregistrement par famille (lien via numero_dossier / id_parent).
CREATE TABLE IF NOT EXISTS dossier_famille (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
numero_dossier VARCHAR(20) NOT NULL,
id_parent UUID NOT NULL REFERENCES parents(id_utilisateur) ON DELETE CASCADE,
presentation TEXT,
type_contrat VARCHAR(50),
repas BOOLEAN NOT NULL DEFAULT false,
budget NUMERIC(10,2),
planning_souhaite JSONB,
statut statut_dossier_type NOT NULL DEFAULT 'envoye',
cree_le TIMESTAMPTZ NOT NULL DEFAULT now(),
modifie_le TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dossier_famille_numero ON dossier_famille(numero_dossier);
CREATE INDEX IF NOT EXISTS idx_dossier_famille_id_parent ON dossier_famille(id_parent);
-- Enfants concernés par ce dossier famille (N par dossier).
CREATE TABLE IF NOT EXISTS dossier_famille_enfants (
id_dossier_famille UUID NOT NULL REFERENCES dossier_famille(id) ON DELETE CASCADE,
id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE,
PRIMARY KEY (id_dossier_famille, id_enfant)
);
CREATE INDEX IF NOT EXISTS idx_dossier_famille_enfants_enfant ON dossier_famille_enfants(id_enfant);

View File

@ -0,0 +1,5 @@
-- Dossier famille = inscription uniquement, pas les données de dossier de garde (repas, type_contrat, budget, etc.)
ALTER TABLE dossier_famille DROP COLUMN IF EXISTS repas;
ALTER TABLE dossier_famille DROP COLUMN IF EXISTS type_contrat;
ALTER TABLE dossier_famille DROP COLUMN IF EXISTS budget;
ALTER TABLE dossier_famille DROP COLUMN IF EXISTS planning_souhaite;

View File

@ -33,13 +33,13 @@
| 20 | [Backend] API Inscription Parent (étape 3 - Enfants) | ✅ Terminé | | 20 | [Backend] API Inscription Parent (étape 3 - Enfants) | ✅ Terminé |
| 21 | [Backend] API Inscription Parent (étape 4-6 - Finalisation) | ✅ Terminé | | 21 | [Backend] API Inscription Parent (étape 4-6 - Finalisation) | ✅ Terminé |
| 24 | [Backend] API Création mot de passe | Ouvert | | 24 | [Backend] API Création mot de passe | Ouvert |
| 25 | [Backend] API Liste comptes en attente | Ouvert | | 25 | [Backend] API Liste comptes en attente | ✅ Fermé (obsolète, couvert #103-#111) |
| 26 | [Backend] API Validation/Refus comptes | Ouvert | | 26 | [Backend] API Validation/Refus comptes | ✅ Fermé (obsolète, couvert #103-#111) |
| 27 | [Backend] Service Email - Installation Nodemailer | Ouvert | | 27 | [Backend] Service Email - Installation Nodemailer | ✅ Fermé (obsolète, couvert #103-#111) |
| 28 | [Backend] Templates Email - Validation | Ouvert | | 28 | [Backend] Templates Email - Validation | Ouvert |
| 29 | [Backend] Templates Email - Refus | Ouvert | | 29 | [Backend] Templates Email - Refus | ✅ Fermé (obsolète, couvert #103-#111) |
| 30 | [Backend] Connexion - Vérification statut | Ouvert | | 30 | [Backend] Connexion - Vérification statut | ✅ Fermé (obsolète, couvert #103-#111) |
| 31 | [Backend] Changement MDP obligatoire première connexion | Ouvert | | 31 | [Backend] Changement MDP obligatoire première connexion | ✅ Terminé |
| 32 | [Backend] Service Documents Légaux | Ouvert | | 32 | [Backend] Service Documents Légaux | Ouvert |
| 33 | [Backend] API Documents Légaux | Ouvert | | 33 | [Backend] API Documents Légaux | Ouvert |
| 34 | [Backend] Traçabilité acceptations documents | Ouvert | | 34 | [Backend] Traçabilité acceptations documents | Ouvert |
@ -53,8 +53,8 @@
| 42 | [Frontend] Inscription AM - Finalisation | ✅ Terminé | | 42 | [Frontend] Inscription AM - Finalisation | ✅ Terminé |
| 43 | [Frontend] Écran Création Mot de Passe | Ouvert | | 43 | [Frontend] Écran Création Mot de Passe | Ouvert |
| 44 | [Frontend] Dashboard Gestionnaire - Structure | ✅ Terminé | | 44 | [Frontend] Dashboard Gestionnaire - Structure | ✅ Terminé |
| 45 | [Frontend] Dashboard Gestionnaire - Liste Parents | Ouvert | | 45 | [Frontend] Dashboard Gestionnaire - Liste Parents | ✅ Fermé (obsolète, couvert #103-#111) |
| 46 | [Frontend] Dashboard Gestionnaire - Liste AM | Ouvert | | 46 | [Frontend] Dashboard Gestionnaire - Liste AM | ✅ Fermé (obsolète, couvert #103-#111) |
| 47 | [Frontend] Écran Changement MDP Obligatoire | Ouvert | | 47 | [Frontend] Écran Changement MDP Obligatoire | Ouvert |
| 48 | [Frontend] Gestion Erreurs & Messages | Ouvert | | 48 | [Frontend] Gestion Erreurs & Messages | Ouvert |
| 49 | [Frontend] Écran Gestion Documents Légaux (Admin) | Ouvert | | 49 | [Frontend] Écran Gestion Documents Légaux (Admin) | Ouvert |
@ -103,7 +103,7 @@
*Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues* *Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues*
*Tickets #103 à #117 = périmètre « Validation des nouveaux comptes par le gestionnaire » (plan + sync via `backend/scripts/sync-gitea-validation-tickets.js`).* *Tickets #103 à #117 = périmètre « Validation des nouveaux comptes par le gestionnaire » (voir plan de spec).*
--- ---
@ -641,17 +641,18 @@ Modifier l'endpoint de connexion pour bloquer les comptes en attente ou suspendu
--- ---
### Ticket #31 : [Backend] Changement MDP obligatoire première connexion ### Ticket #31 : [Backend] Changement MDP obligatoire première connexion
**Estimation** : 2h **Estimation** : 2h
**Labels** : `backend`, `p2`, `auth`, `security` **Labels** : `backend`, `p2`, `auth`, `security`
**Statut** : ✅ TERMINÉ
**Description** : **Description** :
Implémenter le changement de mot de passe obligatoire pour les gestionnaires/admins à la première connexion. Implémenter le changement de mot de passe obligatoire pour les gestionnaires/admins à la première connexion.
**Tâches** : **Tâches** :
- [ ] Endpoint `POST /api/v1/auth/change-password-required` - [x] Endpoint `POST /api/v1/auth/change-password-required`
- [ ] Vérification flag `changement_mdp_obligatoire` - [x] Vérification flag `changement_mdp_obligatoire`
- [ ] Mise à jour flag après changement - [x] Mise à jour flag après changement
- [ ] Tests unitaires - [ ] Tests unitaires
--- ---

View File

@ -0,0 +1,210 @@
import 'package:p_tits_pas/models/user.dart';
/// Réponse unifiée GET /dossiers/:numeroDossier. Ticket #119, #107.
class DossierUnifie {
final String type; // 'am' | 'family'
final dynamic dossier; // DossierAM | DossierFamille
DossierUnifie({required this.type, required this.dossier});
bool get isAm => type == 'am';
bool get isFamily => type == 'family';
DossierAM get asAm => dossier as DossierAM;
DossierFamille get asFamily => dossier as DossierFamille;
factory DossierUnifie.fromJson(Map<String, dynamic> json) {
final t = json['type'];
final raw = t is String ? t : 'family';
final typeStr = raw.toLowerCase();
final d = json['dossier'];
if (d == null || d is! Map<String, dynamic>) {
throw FormatException('dossier manquant ou invalide');
}
final dossierMap = Map<String, dynamic>.from(d as Map);
// Seul `am` (casse tolérée) charge le dossier AM ; le reste = famille (API : type "family").
final isAm = typeStr == 'am';
final dossier = isAm ? DossierAM.fromJson(dossierMap) : DossierFamille.fromJson(dossierMap);
return DossierUnifie(type: isAm ? 'am' : 'family', dossier: dossier);
}
}
/// Dossier AM (type: 'am'). Champs alignés API.
class DossierAM {
final String? numeroDossier;
final AppUser user;
final String? numeroAgrement;
final String? nir;
final String? presentation;
final String? dateAgrement;
final int? nbMaxEnfants;
final int? placesDisponibles;
final String? villeResidence;
DossierAM({
this.numeroDossier,
required this.user,
this.numeroAgrement,
this.nir,
this.presentation,
this.dateAgrement,
this.nbMaxEnfants,
this.placesDisponibles,
this.villeResidence,
});
factory DossierAM.fromJson(Map<String, dynamic> json) {
final userJson = json['user'];
final userMap = userJson is Map<String, dynamic>
? userJson
: <String, dynamic>{};
final nbMax = json['nb_max_enfants'];
final places = json['place_disponible'];
return DossierAM(
numeroDossier: json['numero_dossier']?.toString(),
user: AppUser.fromJson(Map<String, dynamic>.from(userMap)),
numeroAgrement: json['numero_agrement']?.toString(),
nir: json['nir']?.toString(),
presentation: (json['biographie'] ?? json['presentation'])?.toString(),
dateAgrement: json['date_agrement']?.toString(),
nbMaxEnfants: nbMax is int ? nbMax : (nbMax is num ? nbMax.toInt() : null),
placesDisponibles: places is int ? places : (places is num ? places.toInt() : null),
villeResidence: json['ville_residence']?.toString(),
);
}
}
/// Dossier famille (type: 'family'). Champs alignés API.
class DossierFamille {
final String? numeroDossier;
final List<ParentDossier> parents;
final List<EnfantDossier> enfants;
final String? presentation;
DossierFamille({
this.numeroDossier,
required this.parents,
required this.enfants,
this.presentation,
});
factory DossierFamille.fromJson(Map<String, dynamic> json) {
final parentsRaw = json['parents'];
final parentsList = parentsRaw is List
? (parentsRaw)
.where((e) => e is Map)
.map((e) => ParentDossier.fromJson(Map<String, dynamic>.from(e as Map)))
.toList()
: <ParentDossier>[];
final enfantsRaw = json['enfants'];
final enfantsList = enfantsRaw is List
? (enfantsRaw)
.where((e) => e is Map)
.map((e) => EnfantDossier.fromJson(Map<String, dynamic>.from(e as Map)))
.toList()
: <EnfantDossier>[];
return DossierFamille(
numeroDossier: json['numero_dossier']?.toString(),
parents: parentsList,
enfants: enfantsList,
presentation: (json['texte_motivation'] ?? json['presentation'])?.toString(),
);
}
bool get isEnAttente =>
parents.any((p) => p.statut == 'en_attente');
}
/// Parent dans un dossier famille (champs user exposés).
class ParentDossier {
final String id;
final String email;
final String? prenom;
final String? nom;
final String? telephone;
final String? adresse;
final String? ville;
final String? codePostal;
final String? dateNaissance;
final String? genre;
final String? situationFamiliale;
final String? creeLe;
final String? statut;
ParentDossier({
required this.id,
required this.email,
this.prenom,
this.nom,
this.telephone,
this.adresse,
this.ville,
this.codePostal,
this.dateNaissance,
this.genre,
this.situationFamiliale,
this.creeLe,
this.statut,
});
String get fullName => '${prenom ?? ''} ${nom ?? ''}'.trim();
factory ParentDossier.fromJson(Map<String, dynamic> json) {
return ParentDossier(
id: json['id']?.toString() ?? '',
email: json['email']?.toString() ?? '',
prenom: json['prenom']?.toString(),
nom: json['nom']?.toString(),
telephone: json['telephone']?.toString(),
adresse: json['adresse']?.toString(),
ville: json['ville']?.toString(),
codePostal: json['code_postal']?.toString(),
dateNaissance: json['date_naissance']?.toString(),
genre: json['genre']?.toString(),
situationFamiliale: json['situation_familiale']?.toString(),
creeLe: json['cree_le']?.toString(),
statut: json['statut']?.toString(),
);
}
}
/// Enfant dans un dossier famille.
class EnfantDossier {
final String id;
final String? firstName;
final String? lastName;
final String? birthDate;
final String? gender;
final String? status;
final String? dueDate;
final String? photoUrl;
final bool consentPhoto;
EnfantDossier({
required this.id,
this.firstName,
this.lastName,
this.birthDate,
this.gender,
this.status,
this.dueDate,
this.photoUrl,
this.consentPhoto = false,
});
String get fullName => '${firstName ?? ''} ${lastName ?? ''}'.trim();
factory EnfantDossier.fromJson(Map<String, dynamic> json) {
return EnfantDossier(
id: json['id']?.toString() ?? '',
firstName: (json['first_name'] ?? json['prenom'])?.toString(),
lastName: (json['last_name'] ?? json['nom'])?.toString(),
birthDate: json['birth_date']?.toString(),
gender: (json['gender'] ?? json['genre'])?.toString(),
status: json['status']?.toString(),
dueDate: json['due_date']?.toString(),
photoUrl: json['photo_url']?.toString(),
consentPhoto: json['consent_photo'] == true,
);
}
}

View File

@ -0,0 +1,232 @@
/// Résumé affichable pour un parent (liste pending-families).
class PendingParentLine {
final String? email;
final String? telephone;
final String? codePostal;
final String? ville;
const PendingParentLine({
this.email,
this.telephone,
this.codePostal,
this.ville,
});
bool get isEmpty {
final e = email?.trim();
final t = telephone?.trim();
final loc = _locationTrimmed;
return (e == null || e.isEmpty) &&
(t == null || t.isEmpty) &&
(loc == null || loc.isEmpty);
}
String? get _locationTrimmed {
final cp = codePostal?.trim();
final v = ville?.trim();
final loc = [if (cp != null && cp.isNotEmpty) cp, if (v != null && v.isNotEmpty) v]
.join(' ')
.trim();
return loc.isEmpty ? null : loc;
}
}
/// Famille en attente de validation (GET /parents/pending-families). Ticket #107.
///
/// Contrat API : `libelle`, `parentIds`, `numero_dossier`, `date_soumission`,
/// `nombre_enfants`, `emails`, éventuellement `parents` / tableaux parallèles.
class PendingFamily {
final String libelle;
final List<String> parentIds;
final String? numeroDossier;
/// Date affichée : `date_soumission` (ISO), sinon alias `cree_le` / etc.
final DateTime? dateSoumission;
/// Emails seuls (API) le sous-titre utilise de préférence [parentLines].
final List<String> emails;
/// Une entrée par parent : email, tél., CP ville (si fournis par lAPI).
final List<PendingParentLine> parentLines;
final int nombreEnfants;
/// Compat : premier email.
final String? email;
PendingFamily({
required this.libelle,
required this.parentIds,
this.numeroDossier,
this.dateSoumission,
this.emails = const [],
this.parentLines = const [],
this.nombreEnfants = 0,
this.email,
});
static DateTime? _parseDate(dynamic v) {
if (v == null) return null;
if (v is DateTime) return v;
if (v is String) {
return DateTime.tryParse(v);
}
return null;
}
static List<String> _parseStringList(dynamic raw) {
if (raw is! List) return [];
return raw
.map((e) => e?.toString().trim() ?? '')
.where((s) => s.isNotEmpty)
.toList();
}
static List<PendingParentLine> _parseParentLinesFromMaps(dynamic raw) {
if (raw is! List) return [];
final out = <PendingParentLine>[];
for (final e in raw) {
if (e is! Map) continue;
final m = Map<String, dynamic>.from(e);
final em = m['email']?.toString().trim();
final tel = m['telephone']?.toString().trim();
final cp = (m['code_postal'] ?? m['codePostal'])?.toString().trim();
final ville = m['ville']?.toString().trim();
out.add(PendingParentLine(
email: em != null && em.isNotEmpty ? em : null,
telephone: tel != null && tel.isNotEmpty ? tel : null,
codePostal: cp != null && cp.isNotEmpty ? cp : null,
ville: ville != null && ville.isNotEmpty ? ville : null,
));
}
return out;
}
/// Construit [parentLines] : objets `parents`, tableaux parallèles, ou emails + champs racine.
static List<PendingParentLine> _buildParentLines(
Map<String, dynamic> json,
List<String> emails,
) {
final fromMaps = _parseParentLinesFromMaps(
json['parents'] ?? json['resume_parents'] ?? json['parent_summaries'] ?? json['parent_lines'],
);
if (fromMaps.isNotEmpty) {
return fromMaps;
}
List<String>? parallel(dynamic keySingular, dynamic keyPlural) {
final pl = json[keyPlural];
if (pl is List) return _parseStringList(pl);
final s = json[keySingular];
if (s is String && s.trim().isNotEmpty) return [s.trim()];
return null;
}
final tels = parallel('telephone', 'telephones');
final cps = parallel('code_postal', 'code_postaux') ?? parallel('codePostal', 'codes_postaux');
final villes = parallel('ville', 'villes');
if (emails.isNotEmpty &&
((tels?.isNotEmpty ?? false) ||
(cps?.isNotEmpty ?? false) ||
(villes?.isNotEmpty ?? false))) {
return List.generate(emails.length, (i) {
return PendingParentLine(
email: emails[i],
telephone: tels != null && i < tels.length ? tels[i] : null,
codePostal: cps != null && i < cps.length ? cps[i] : null,
ville: villes != null && i < villes.length ? villes[i] : null,
);
});
}
final rootTel = json['telephone']?.toString().trim();
final rootTelOk = rootTel != null && rootTel.isNotEmpty ? rootTel : null;
final rootCp = (json['code_postal'] ?? json['codePostal'])?.toString().trim();
final rootCpOk = rootCp != null && rootCp.isNotEmpty ? rootCp : null;
final rootVille = json['ville']?.toString().trim();
final rootVilleOk = rootVille != null && rootVille.isNotEmpty ? rootVille : null;
if (emails.isNotEmpty) {
return List.generate(emails.length, (i) {
return PendingParentLine(
email: emails[i],
telephone: i == 0 ? rootTelOk : null,
codePostal: i == 0 ? rootCpOk : null,
ville: i == 0 ? rootVilleOk : null,
);
});
}
if (rootTelOk != null || rootCpOk != null || rootVilleOk != null) {
final em = json['email']?.toString().trim();
return [
PendingParentLine(
email: em != null && em.isNotEmpty ? em : null,
telephone: rootTelOk,
codePostal: rootCpOk,
ville: rootVilleOk,
),
];
}
return [];
}
factory PendingFamily.fromJson(Map<String, dynamic> json) {
final parentIdsRaw = json['parentIds'] ?? json['parent_ids'];
final List<String> ids = parentIdsRaw is List
? (parentIdsRaw).map((e) => e?.toString() ?? '').where((s) => s.isNotEmpty).toList()
: [];
final libelle = json['libelle'];
final libelleStr = libelle is String ? libelle : (libelle?.toString() ?? 'Famille');
final nd = json['numero_dossier'] ?? json['numeroDossier'];
final numeroDossier = (nd is String && nd.isNotEmpty) ? nd : null;
DateTime? dateSoumission = _parseDate(
json['date_soumission'] ?? json['dateSoumission'],
);
dateSoumission ??= _parseDate(
json['cree_le'] ?? json['creeLe'] ?? json['date_inscription'],
);
List<String> emails = _parseStringList(json['emails']);
if (emails.isEmpty) {
final emailRaw = json['email'];
if (emailRaw is String && emailRaw.trim().isNotEmpty) {
emails = [emailRaw.trim()];
}
}
final String? emailCompat = emails.isNotEmpty
? emails.first
: (json['email'] is String && (json['email'] as String).trim().isNotEmpty
? (json['email'] as String).trim()
: null);
final nbRaw = json['nombre_enfants'] ?? json['nombreEnfants'];
int nombreEnfants = 0;
if (nbRaw is int) {
nombreEnfants = nbRaw;
} else if (nbRaw is num) {
nombreEnfants = nbRaw.toInt();
} else if (nbRaw != null) {
nombreEnfants = int.tryParse(nbRaw.toString()) ?? 0;
}
var parentLines = _buildParentLines(json, emails);
if (parentLines.isEmpty && emails.isNotEmpty) {
parentLines = emails.map((e) => PendingParentLine(email: e)).toList();
}
return PendingFamily(
libelle: libelleStr.isEmpty ? 'Famille' : libelleStr,
parentIds: ids,
numeroDossier: numeroDossier,
dateSoumission: dateSoumission,
emails: emails,
parentLines: parentLines,
nombreEnfants: nombreEnfants,
email: emailCompat,
);
}
}

View File

@ -15,6 +15,7 @@ class AppUser {
final String? codePostal; final String? codePostal;
final String? relaisId; final String? relaisId;
final String? relaisNom; final String? relaisNom;
final String? numeroDossier;
AppUser({ AppUser({
required this.id, required this.id,
@ -33,40 +34,50 @@ class AppUser {
this.codePostal, this.codePostal,
this.relaisId, this.relaisId,
this.relaisNom, this.relaisNom,
this.numeroDossier,
}); });
static String _str(dynamic v) {
if (v == null) return '';
if (v is String) return v;
return v.toString();
}
static DateTime _date(dynamic v) {
if (v == null) return DateTime.now();
if (v is DateTime) return v;
try {
return DateTime.parse(v.toString());
} catch (_) {
return DateTime.now();
}
}
factory AppUser.fromJson(Map<String, dynamic> json) { factory AppUser.fromJson(Map<String, dynamic> json) {
final relaisJson = json['relais']; final relaisJson = json['relais'];
final relaisMap = final relaisMap =
relaisJson is Map<String, dynamic> ? relaisJson : <String, dynamic>{}; relaisJson is Map<String, dynamic> ? relaisJson : <String, dynamic>{};
return AppUser( return AppUser(
id: (json['id'] as String?) ?? '', id: _str(json['id']),
email: (json['email'] as String?) ?? '', email: _str(json['email']),
role: (json['role'] as String?) ?? '', role: _str(json['role']),
createdAt: json['cree_le'] != null createdAt: _date(json['cree_le'] ?? json['createdAt']),
? DateTime.parse(json['cree_le'] as String) updatedAt: _date(json['modifie_le'] ?? json['updatedAt']),
: (json['createdAt'] != null
? DateTime.parse(json['createdAt'] as String)
: DateTime.now()),
updatedAt: json['modifie_le'] != null
? DateTime.parse(json['modifie_le'] as String)
: (json['updatedAt'] != null
? DateTime.parse(json['updatedAt'] as String)
: DateTime.now()),
changementMdpObligatoire: changementMdpObligatoire:
json['changement_mdp_obligatoire'] as bool? ?? false, json['changement_mdp_obligatoire'] == true,
nom: json['nom'] as String?, nom: json['nom'] is String ? json['nom'] as String : null,
prenom: json['prenom'] as String?, prenom: json['prenom'] is String ? json['prenom'] as String : null,
statut: json['statut'] as String?, statut: json['statut'] is String ? json['statut'] as String : null,
telephone: json['telephone'] as String?, telephone: json['telephone'] is String ? json['telephone'] as String : null,
photoUrl: json['photo_url'] as String?, photoUrl: json['photo_url'] is String ? json['photo_url'] as String : null,
adresse: json['adresse'] as String?, adresse: json['adresse'] is String ? json['adresse'] as String : null,
ville: json['ville'] as String?, ville: json['ville'] is String ? json['ville'] as String : null,
codePostal: json['code_postal'] as String?, codePostal: json['code_postal'] is String ? json['code_postal'] as String : null,
relaisId: (json['relaisId'] ?? json['relais_id'] ?? relaisMap['id']) relaisId: (json['relaisId'] ?? json['relais_id'] ?? relaisMap['id'])
?.toString(), ?.toString(),
relaisNom: relaisMap['nom']?.toString(), relaisNom: relaisMap['nom']?.toString(),
numeroDossier: json['numero_dossier'] is String ? json['numero_dossier'] as String : null,
); );
} }
@ -88,6 +99,7 @@ class AppUser {
'code_postal': codePostal, 'code_postal': codePostal,
'relais_id': relaisId, 'relais_id': relaisId,
'relais_nom': relaisNom, 'relais_nom': relaisNom,
'numero_dossier': numeroDossier,
}; };
} }

View File

@ -1,4 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
@ -34,7 +36,7 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
_nomController.text = user.nom ?? ''; _nomController.text = user.nom ?? '';
_prenomController.text = user.prenom ?? ''; _prenomController.text = user.prenom ?? '';
_emailController.text = user.email; _emailController.text = user.email;
_telephoneController.text = user.telephone ?? ''; _telephoneController.text = formatPhoneForDisplay(user.telephone ?? '');
// En édition, on ne préremplit jamais le mot de passe. // En édition, on ne préremplit jamais le mot de passe.
_passwordController.clear(); _passwordController.clear();
} }
@ -91,7 +93,7 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
nom: _nomController.text.trim(), nom: _nomController.text.trim(),
prenom: _prenomController.text.trim(), prenom: _prenomController.text.trim(),
email: _emailController.text.trim(), email: _emailController.text.trim(),
telephone: _telephoneController.text.trim(), telephone: normalizePhone(_telephoneController.text),
password: _passwordController.text.trim().isEmpty password: _passwordController.text.trim().isEmpty
? null ? null
: _passwordController.text, : _passwordController.text,
@ -102,7 +104,7 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
prenom: _prenomController.text.trim(), prenom: _prenomController.text.trim(),
email: _emailController.text.trim(), email: _emailController.text.trim(),
password: _passwordController.text, password: _passwordController.text,
telephone: _telephoneController.text.trim(), telephone: normalizePhone(_telephoneController.text),
); );
} }
if (!mounted) return; if (!mounted) return;
@ -347,8 +349,13 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
return TextFormField( return TextFormField(
controller: _telephoneController, controller: _telephoneController,
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
FrenchPhoneNumberFormatter(),
],
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Téléphone', labelText: 'Téléphone (ex: 06 12 34 56 78)',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
validator: (v) => _required(v, 'Téléphone'), validator: (v) => _required(v, 'Téléphone'),

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:p_tits_pas/models/relais_model.dart'; import 'package:p_tits_pas/models/relais_model.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/relais_service.dart'; import 'package:p_tits_pas/services/relais_service.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
@ -40,12 +41,12 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
String? _selectedRelaisId; String? _selectedRelaisId;
bool get _isEditMode => widget.initialUser != null; bool get _isEditMode => widget.initialUser != null;
bool get _isSuperAdminTarget => bool get _isSuperAdminTarget =>
widget.initialUser?.role.toLowerCase() == 'super_admin'; (widget.initialUser?.role ?? '').toLowerCase() == 'super_admin';
bool get _isLockedAdminIdentity => bool get _isLockedAdminIdentity =>
_isEditMode && widget.adminMode && _isSuperAdminTarget; _isEditMode && widget.adminMode && _isSuperAdminTarget;
String get _targetRoleKey { String get _targetRoleKey {
if (widget.initialUser != null) { if (widget.initialUser != null) {
return widget.initialUser!.role.toLowerCase(); return (widget.initialUser!.role).toLowerCase();
} }
return widget.adminMode ? 'administrateur' : 'gestionnaire'; return widget.adminMode ? 'administrateur' : 'gestionnaire';
} }
@ -92,7 +93,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
_nomController.text = user.nom ?? ''; _nomController.text = user.nom ?? '';
_prenomController.text = user.prenom ?? ''; _prenomController.text = user.prenom ?? '';
_emailController.text = user.email; _emailController.text = user.email;
_telephoneController.text = _formatPhoneForDisplay(user.telephone ?? ''); _telephoneController.text = formatPhoneForDisplay(user.telephone ?? '');
// En édition, on ne préremplit jamais le mot de passe. // En édition, on ne préremplit jamais le mot de passe.
_passwordController.clear(); _passwordController.clear();
final initialRelaisId = user.relaisId?.trim(); final initialRelaisId = user.relaisId?.trim();
@ -185,7 +186,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
} }
final base = _required(value, 'Téléphone'); final base = _required(value, 'Téléphone');
if (base != null) return base; if (base != null) return base;
final digits = _normalizePhone(value!); final digits = normalizePhone(value!);
if (digits.length != 10) { if (digits.length != 10) {
return 'Le téléphone doit contenir 10 chiffres'; return 'Le téléphone doit contenir 10 chiffres';
} }
@ -195,22 +196,6 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
return null; return null;
} }
String _normalizePhone(String raw) {
return raw.replaceAll(RegExp(r'\D'), '');
}
String _formatPhoneForDisplay(String raw) {
final normalized = _normalizePhone(raw);
final digits =
normalized.length > 10 ? normalized.substring(0, 10) : normalized;
final buffer = StringBuffer();
for (var i = 0; i < digits.length; i++) {
if (i > 0 && i.isEven) buffer.write(' ');
buffer.write(digits[i]);
}
return buffer.toString();
}
String _toTitleCase(String raw) { String _toTitleCase(String raw) {
final trimmed = raw.trim(); final trimmed = raw.trim();
if (trimmed.isEmpty) return trimmed; if (trimmed.isEmpty) return trimmed;
@ -251,7 +236,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
try { try {
final normalizedNom = _toTitleCase(_nomController.text); final normalizedNom = _toTitleCase(_nomController.text);
final normalizedPrenom = _toTitleCase(_prenomController.text); final normalizedPrenom = _toTitleCase(_prenomController.text);
final normalizedPhone = _normalizePhone(_telephoneController.text); final normalizedPhone = normalizePhone(_telephoneController.text);
final passwordProvided = _passwordController.text.trim().isNotEmpty; final passwordProvided = _passwordController.text.trim().isNotEmpty;
if (_isEditMode) { if (_isEditMode) {
@ -264,7 +249,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
prenom: _isLockedAdminIdentity ? lockedPrenom : normalizedPrenom, prenom: _isLockedAdminIdentity ? lockedPrenom : normalizedPrenom,
email: _emailController.text.trim(), email: _emailController.text.trim(),
telephone: normalizedPhone.isEmpty telephone: normalizedPhone.isEmpty
? _normalizePhone(widget.initialUser!.telephone ?? '') ? normalizePhone(widget.initialUser!.telephone ?? '')
: normalizedPhone, : normalizedPhone,
password: passwordProvided ? _passwordController.text : null, password: passwordProvided ? _passwordController.text : null,
); );
@ -273,7 +258,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
final initialNom = _toTitleCase(currentUser.nom ?? ''); final initialNom = _toTitleCase(currentUser.nom ?? '');
final initialPrenom = _toTitleCase(currentUser.prenom ?? ''); final initialPrenom = _toTitleCase(currentUser.prenom ?? '');
final initialEmail = currentUser.email.trim(); final initialEmail = currentUser.email.trim();
final initialPhone = _normalizePhone(currentUser.telephone ?? ''); final initialPhone = normalizePhone(currentUser.telephone ?? '');
final onlyRelaisChanged = final onlyRelaisChanged =
normalizedNom == initialNom && normalizedNom == initialNom &&
@ -306,7 +291,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
prenom: normalizedPrenom, prenom: normalizedPrenom,
email: _emailController.text.trim(), email: _emailController.text.trim(),
password: _passwordController.text, password: _passwordController.text,
telephone: _normalizePhone(_telephoneController.text), telephone: normalizePhone(_telephoneController.text),
); );
} else { } else {
await UserService.createGestionnaire( await UserService.createGestionnaire(
@ -314,7 +299,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
prenom: normalizedPrenom, prenom: normalizedPrenom,
email: _emailController.text.trim(), email: _emailController.text.trim(),
password: _passwordController.text, password: _passwordController.text,
telephone: _normalizePhone(_telephoneController.text), telephone: normalizePhone(_telephoneController.text),
relaisId: _selectedRelaisId, relaisId: _selectedRelaisId,
); );
} }
@ -608,7 +593,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
: [ : [
FilteringTextInputFormatter.digitsOnly, FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10), LengthLimitingTextInputFormatter(10),
_FrenchPhoneNumberFormatter(), FrenchPhoneNumberFormatter(),
], ],
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Téléphone (ex: 06 12 34 56 78)', labelText: 'Téléphone (ex: 06 12 34 56 78)',
@ -662,27 +647,3 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
); );
} }
} }
class _FrenchPhoneNumberFormatter extends TextInputFormatter {
const _FrenchPhoneNumberFormatter();
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
final normalized = digits.length > 10 ? digits.substring(0, 10) : digits;
final buffer = StringBuffer();
for (var i = 0; i < normalized.length; i++) {
if (i > 0 && i.isEven) buffer.write(' ');
buffer.write(normalized[i]);
}
final formatted = buffer.toString();
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}
}

View File

@ -17,7 +17,7 @@ class LoginScreen extends StatefulWidget {
State<LoginScreen> createState() => _LoginPageState(); State<LoginScreen> createState() => _LoginPageState();
} }
class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver { class _LoginPageState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController(); final _emailController = TextEditingController();
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
@ -27,31 +27,30 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
static const double _mobileBreakpoint = 900.0; static const double _mobileBreakpoint = 900.0;
/// Une seule fois : évite de relancer `_getImageDimensions()` à chaque rebuild (sinon sur web,
/// tout événement de layout / métriques recréait un Future et pouvait provoquer des erreurs DWDS
/// ou des comportements bizarres pendant la saisie dans les champs).
late final Future<ImageDimensions> _desktopRiverLogoDimensionsFuture;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); _desktopRiverLogoDimensionsFuture = _getImageDimensions();
} }
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this);
_emailController.dispose(); _emailController.dispose();
_passwordController.dispose(); _passwordController.dispose();
super.dispose(); super.dispose();
} }
@override
void didChangeMetrics() {
super.didChangeMetrics();
if (mounted) setState(() {});
}
String? _validateEmail(String? value) { String? _validateEmail(String? value) {
if (value == null || value.isEmpty) { final v = value ?? '';
if (v.isEmpty) {
return 'Veuillez entrer votre email'; return 'Veuillez entrer votre email';
} }
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) { if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(v)) {
return 'Veuillez entrer un email valide'; return 'Veuillez entrer un email valide';
} }
return null; return null;
@ -116,7 +115,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
TextInput.finishAutofillContext(shouldSave: true); TextInput.finishAutofillContext(shouldSave: true);
// Rediriger selon le rôle de l'utilisateur // Rediriger selon le rôle de l'utilisateur
_redirectUserByRole(user.role); _redirectUserByRole(user.role.isEmpty ? null : user.role);
} catch (e) { } catch (e) {
setState(() { setState(() {
_isLoading = false; _isLoading = false;
@ -126,9 +125,10 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
} }
/// Redirige l'utilisateur selon son rôle (GoRouter : context.go). /// Redirige l'utilisateur selon son rôle (GoRouter : context.go).
void _redirectUserByRole(String role) { void _redirectUserByRole(String? role) {
setState(() => _isLoading = false); setState(() => _isLoading = false);
switch (role.toLowerCase()) { final r = (role ?? '').toLowerCase();
switch (r) {
case 'super_admin': case 'super_admin':
case 'administrateur': case 'administrateur':
context.go('/admin-dashboard'); context.go('/admin-dashboard');
@ -162,8 +162,8 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
builder: (context, constraints) { builder: (context, constraints) {
final w = constraints.maxWidth; final w = constraints.maxWidth;
final h = constraints.maxHeight; final h = constraints.maxHeight;
return FutureBuilder( return FutureBuilder<ImageDimensions>(
future: _getImageDimensions(), future: _desktopRiverLogoDimensionsFuture,
builder: (context, snapshot) { builder: (context, snapshot) {
if (!snapshot.hasData) { if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
@ -240,7 +240,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
hintText: 'Votre mot de passe', hintText: 'Votre mot de passe',
obscureText: true, obscureText: true,
autofillHints: const [ autofillHints: const [
AutofillHints.password AutofillHints.password,
], ],
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
onFieldSubmitted: onFieldSubmitted:

View File

@ -19,6 +19,7 @@ class ApiConfig {
static const String parents = '/parents'; static const String parents = '/parents';
static const String assistantesMaternelles = '/assistantes-maternelles'; static const String assistantesMaternelles = '/assistantes-maternelles';
static const String relais = '/relais'; static const String relais = '/relais';
static const String dossiers = '/dossiers';
// Configuration (admin) // Configuration (admin)
static const String configuration = '/configuration'; static const String configuration = '/configuration';

View File

@ -193,7 +193,10 @@ class AuthService {
const fallback = 'Erreur lors de l\'inscription'; const fallback = 'Erreur lors de l\'inscription';
if (decoded == null || decoded is! Map) return '$fallback ($statusCode)'; if (decoded == null || decoded is! Map) return '$fallback ($statusCode)';
final msg = decoded['message']; final msg = decoded['message'];
if (msg == null) return decoded['error'] as String? ?? '$fallback ($statusCode)'; if (msg == null) {
final err = decoded['error'];
return (err is String ? err : err?.toString()) ?? '$fallback ($statusCode)';
}
if (msg is String) return msg; if (msg is String) return msg;
if (msg is List) return msg.map((e) => e.toString()).join('. ').trim(); if (msg is List) return msg.map((e) => e.toString()).join('. ').trim();
if (msg is Map && msg['message'] != null) return msg['message'].toString(); if (msg is Map && msg['message'] != null) return msg['message'].toString();

View File

@ -3,6 +3,8 @@ import 'package:http/http.dart' as http;
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/models/parent_model.dart'; import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
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/api_config.dart';
import 'package:p_tits_pas/services/api/tokenService.dart'; import 'package:p_tits_pas/services/api/tokenService.dart';
@ -20,6 +22,145 @@ class UserService {
return v.toString(); return v.toString();
} }
static String _errMessage(dynamic err) {
if (err == null) return 'Erreur inconnue';
if (err is String) return err;
if (err is Map) {
final m = err['message'];
if (m is String) return m;
if (m is Map && m['message'] is String) return m['message'] as String;
if (m != null) return _toStr(m) ?? 'Erreur inconnue';
}
return _toStr(err) ?? 'Erreur inconnue';
}
/// Utilisateurs en attente de validation (GET /users/pending). Ticket #107.
static Future<List<AppUser>> getPendingUsers({String? role}) async {
final query = role != null ? '?role=$role' : '';
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/pending$query'),
headers: await _headers(),
);
if (response.statusCode != 200) {
try {
final err = jsonDecode(response.body);
throw Exception(_errMessage(err is Map ? err['message'] : err));
} catch (e) {
if (e is Exception) rethrow;
throw Exception('Erreur chargement comptes en attente (${response.statusCode})');
}
}
try {
final decoded = jsonDecode(response.body);
final data = decoded is List ? decoded as List<dynamic> : <dynamic>[];
return data
.where((e) => e is Map<String, dynamic>)
.map((e) => AppUser.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
} catch (e) {
throw Exception('Réponse invalide (comptes en attente): ${e is Exception ? e.toString() : "format inattendu"}');
}
}
/// Familles en attente (une entrée par famille). GET /parents/pending-families. Ticket #107.
static Future<List<PendingFamily>> getPendingFamilies() async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/pending-families'),
headers: await _headers(),
);
if (response.statusCode != 200) {
try {
final err = jsonDecode(response.body);
throw Exception(_errMessage(err is Map ? err['message'] : err));
} catch (e) {
if (e is Exception) rethrow;
throw Exception('Erreur chargement familles en attente (${response.statusCode})');
}
}
try {
final decoded = jsonDecode(response.body);
final data = decoded is List ? decoded as List<dynamic> : <dynamic>[];
return data
.where((e) => e is Map)
.map((e) => PendingFamily.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
} catch (e) {
throw Exception('Réponse invalide (familles en attente): ${e is Exception ? e.toString() : "format inattendu"}');
}
}
/// Dossier unifié par numéro (AM ou famille). GET /dossiers/:numeroDossier. Ticket #119, #107.
static Future<DossierUnifie> getDossier(String numeroDossier) async {
final encoded = Uri.encodeComponent(numeroDossier);
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'),
headers: await _headers(),
);
if (response.statusCode == 404) {
throw Exception('Aucun dossier avec ce numéro.');
}
if (response.statusCode != 200) {
try {
final err = jsonDecode(response.body);
throw Exception(_errMessage(err is Map ? err['message'] : err));
} catch (e) {
if (e is Exception) rethrow;
throw Exception('Erreur chargement dossier (${response.statusCode})');
}
}
try {
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw FormatException('Réponse invalide');
}
return DossierUnifie.fromJson(Map<String, dynamic>.from(decoded));
} catch (e) {
if (e is FormatException) rethrow;
throw Exception('Réponse invalide (dossier): ${e is Exception ? e.toString() : "format inattendu"}');
}
}
/// Valider un utilisateur (AM). PATCH /users/:id/valider. Ticket #108.
static Future<AppUser> validateUser(String userId, {String? comment}) async {
final response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId/valider'),
headers: await _headers(),
body: jsonEncode(comment != null ? {'comment': comment} : {}),
);
if (response.statusCode != 200) {
try {
final err = jsonDecode(response.body);
throw Exception(_errMessage(err is Map ? err['message'] : err));
} catch (e) {
if (e is Exception) rethrow;
throw Exception('Erreur validation (${response.statusCode})');
}
}
final data = jsonDecode(response.body);
return AppUser.fromJson(Map<String, dynamic>.from(data is Map ? data : {}));
}
/// Valider tout le dossier famille. POST /parents/:parentId/valider-dossier. Ticket #108.
static Future<List<AppUser>> validerDossierFamille(String parentId, {String? comment}) async {
final response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$parentId/valider-dossier'),
headers: await _headers(),
body: jsonEncode(comment != null ? {'comment': comment} : {}),
);
if (response.statusCode != 200) {
try {
final err = jsonDecode(response.body);
throw Exception(_errMessage(err is Map ? err['message'] : err));
} catch (e) {
if (e is Exception) rethrow;
throw Exception('Erreur validation dossier famille (${response.statusCode})');
}
}
final data = jsonDecode(response.body);
final list = data is List ? data : [];
return list.map((e) => AppUser.fromJson(Map<String, dynamic>.from(e as Map))).toList();
}
// Récupérer la liste des gestionnaires (endpoint dédié) // Récupérer la liste des gestionnaires (endpoint dédié)
static Future<List<AppUser>> getGestionnaires() async { static Future<List<AppUser>> getGestionnaires() async {
final response = await http.get( final response = await http.get(

View File

@ -49,12 +49,12 @@ String nirToRaw(String normalized) {
return s; return s;
} }
/// Formate pour affichage : 1 12 34 56 789 012-34 ou 1 12 34 2A 789 012-34 (Corse). /// Formate pour affichage : 1 12 34 56 789 012 - 34 ou 1 12 34 2A 789 012 - 34 (Corse).
String formatNir(String raw) { String formatNir(String raw) {
final r = nirToRaw(raw); final r = nirToRaw(raw);
if (r.length < 15) return r; if (r.length < 15) return r;
// Même structure pour tous : sexe + année + mois + département + commune + ordre-clé. // 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)}'; 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)}';
} }
/// Vérifie le format : 15 caractères, structure 1+2+2+2+3+3+2, département 2A/2B autorisé. /// Vérifie le format : 15 caractères, structure 1+2+2+2+3+3+2, département 2A/2B autorisé.
@ -82,7 +82,7 @@ String? validateNir(String? value) {
if (value == null || value.isEmpty) return 'NIR requis'; if (value == null || value.isEmpty) return 'NIR requis';
final raw = nirToRaw(value).toUpperCase(); final raw = nirToRaw(value).toUpperCase();
if (raw.length != 15) return 'Le NIR doit contenir 15 caractères (chiffres, ou 2A/2B pour la Corse)'; if (raw.length != 15) return 'Le NIR doit contenir 15 caractères (chiffres, ou 2A/2B pour la Corse)';
if (!_isFormatValid(raw)) return 'Format NIR invalide (ex. 1 12 34 56 789 012-34 ou 2A pour la Corse)'; if (!_isFormatValid(raw)) return 'Format NIR invalide (ex. 1 12 34 56 789 012 - 34 ou 2A pour la Corse)';
final key = _controlKey(raw.substring(0, 13)); final key = _controlKey(raw.substring(0, 13));
final keyStr = key >= 0 && key <= 99 ? key.toString().padLeft(2, '0') : ''; final keyStr = key >= 0 && key <= 99 ? key.toString().padLeft(2, '0') : '';
final expectedKey = raw.substring(13, 15); final expectedKey = raw.substring(13, 15);
@ -90,7 +90,7 @@ String? validateNir(String? value) {
return null; return null;
} }
/// Formateur de saisie : affiche le NIR formaté (1 12 34 56 789 012-34) et limite à 15 caractères utiles. /// Formateur de saisie : affiche le NIR formaté (1 12 34 56 789 012 - 34) et limite à 15 caractères utiles.
class NirInputFormatter extends TextInputFormatter { class NirInputFormatter extends TextInputFormatter {
@override @override
TextEditingValue formatEditUpdate( TextEditingValue formatEditUpdate(

View File

@ -0,0 +1,57 @@
import 'package:flutter/services.dart';
/// Format français : 10 chiffres affichés par paires (ex. 06 12 34 56 78).
/// Ne garde que les chiffres (max 10).
String normalizePhone(String raw) {
final digits = raw.replaceAll(RegExp(r'\D'), '');
return digits.length > 10 ? digits.substring(0, 10) : digits;
}
/// Retourne le numéro formaté pour l'affichage (ex. "06 12 34 56 78").
/// Si [raw] est vide après normalisation, retourne [raw] tel quel (pour afficher "" etc.).
String formatPhoneForDisplay(String raw) {
if (raw.trim().isEmpty) return raw;
final normalized = normalizePhone(raw);
if (normalized.isEmpty) return raw;
final buffer = StringBuffer();
for (var i = 0; i < normalized.length; i++) {
if (i > 0 && i.isEven) buffer.write(' ');
buffer.write(normalized[i]);
}
return buffer.toString();
}
/// Formatter de saisie : uniquement chiffres, espaces automatiques toutes les 2 chiffres, max 10 chiffres.
class FrenchPhoneNumberFormatter extends TextInputFormatter {
const FrenchPhoneNumberFormatter();
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
final normalized = digits.length > 10 ? digits.substring(0, 10) : digits;
final buffer = StringBuffer();
for (var i = 0; i < normalized.length; i++) {
if (i > 0 && i.isEven) buffer.write(' ');
buffer.write(normalized[i]);
}
final formatted = buffer.toString();
// Conserver la position du curseur : compter les chiffres avant la sélection
final sel = newValue.selection;
final digitsBeforeCursor = newValue.text
.substring(0, sel.start.clamp(0, newValue.text.length))
.replaceAll(RegExp(r'\D'), '')
.length;
final newOffset = digitsBeforeCursor + (digitsBeforeCursor > 0 ? digitsBeforeCursor ~/ 2 : 0);
final clampedOffset = newOffset.clamp(0, formatted.length);
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: clampedOffset),
);
}
}

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart'; import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
@ -60,18 +61,18 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
if (!mounted) return; if (!mounted) return;
if (cached != null) { if (cached != null) {
setState(() { setState(() {
_currentUserRole = cached.role.toLowerCase(); _currentUserRole = (cached.role).toLowerCase();
}); });
return; return;
} }
final refreshed = await AuthService.refreshCurrentUser(); final refreshed = await AuthService.refreshCurrentUser();
if (!mounted || refreshed == null) return; if (!mounted || refreshed == null) return;
setState(() { setState(() {
_currentUserRole = refreshed.role.toLowerCase(); _currentUserRole = (refreshed.role).toLowerCase();
}); });
} }
bool _isSuperAdmin(AppUser user) => user.role.toLowerCase() == 'super_admin'; bool _isSuperAdmin(AppUser user) => (user.role).toLowerCase() == 'super_admin';
bool _canEditAdmin(AppUser target) { bool _canEditAdmin(AppUser target) {
if (!_isSuperAdmin(target)) return true; if (!_isSuperAdmin(target)) return true;
@ -123,7 +124,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
: Icons.manage_accounts_outlined, : Icons.manage_accounts_outlined,
subtitleLines: [ subtitleLines: [
user.email, user.email,
'Téléphone : ${user.telephone?.trim().isNotEmpty == true ? user.telephone : 'Non renseigné'}', 'Téléphone : ${user.telephone?.trim().isNotEmpty == true ? formatPhoneForDisplay(user.telephone!) : 'Non renseigné'}',
], ],
avatarUrl: user.photoUrl, avatarUrl: user.photoUrl,
borderColor: isSuperAdmin borderColor: isSuperAdmin

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
@ -126,7 +127,7 @@ class _AssistanteMaternelleManagementWidgetState
), ),
AdminDetailField( AdminDetailField(
label: 'Telephone', label: 'Telephone',
value: _v(assistante.user.telephone), value: _v(assistante.user.telephone) != '' ? formatPhoneForDisplay(_v(assistante.user.telephone)) : '',
), ),
AdminDetailField(label: 'Adresse', value: _v(assistante.user.adresse)), AdminDetailField(label: 'Adresse', value: _v(assistante.user.adresse)),
AdminDetailField(label: 'Ville', value: _v(assistante.user.ville)), AdminDetailField(label: 'Ville', value: _v(assistante.user.ville)),

View File

@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import 'admin_detail_modal.dart';
/// 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).
class ValidationDetailSection extends StatelessWidget {
final String title;
final List<AdminDetailField> fields;
/// Nombre de champs par ligne (1 = plein largeur, 2 = deux côte à côte). Ex. [2, 2, 1, 2] pour identité.
final List<int>? rowLayout;
/// Flex par ligne (index de ligne -> [flex1, flex2, ...]). Ex. {3: [2, 5]} pour Code postal | Ville.
final Map<int, List<int>>? rowFlex;
const ValidationDetailSection({
super.key,
required this.title,
required this.fields,
this.rowLayout,
this.rowFlex,
});
@override
Widget build(BuildContext context) {
final layout = rowLayout ?? List.filled(fields.length, 1);
int index = 0;
int rowIndex = 0;
final rows = <Widget>[];
for (final count in layout) {
if (index >= fields.length) break;
final rowFields = fields.skip(index).take(count).toList();
index += count;
if (rowFields.isEmpty) continue;
final flexForRow = rowFlex?[rowIndex];
rowIndex++;
if (count == 1) {
rows.add(Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildFieldCell(rowFields.first),
));
} else {
rows.add(Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (int i = 0; i < rowFields.length; i++) ...[
if (i > 0) const SizedBox(width: 16),
Expanded(
flex: (flexForRow != null && i < flexForRow.length)
? flexForRow[i]
: 1,
child: _buildFieldCell(rowFields[i]),
),
],
],
),
));
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 12),
...rows,
],
);
}
Widget _buildFieldCell(AdminDetailField field) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
field.label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
),
),
const SizedBox(height: 4),
ValidationReadOnlyField(value: field.value),
],
);
}
}
/// Champ texte en lecture seule, style formulaire (fond gris léger, bordure). Réutilisable en éditable plus tard.
class ValidationReadOnlyField extends StatelessWidget {
final String value;
final int? maxLines;
const ValidationReadOnlyField({
super.key,
required this.value,
this.maxLines = 1,
});
@override
Widget build(BuildContext context) {
return 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: Text(
value,
style: const TextStyle(color: Colors.black87, fontSize: 14),
maxLines: maxLines,
overflow: TextOverflow.ellipsis,
),
);
}
}

View File

@ -1,7 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Sous-barre : Gestionnaires | Parents | Assistantes maternelles | [Administrateurs]. /// Sous-barre : [À valider] | Gestionnaires | Parents | Assistantes maternelles | [Administrateurs].
/// [subTabCount] = 3 pour masquer l'onglet Administrateurs (dashboard gestionnaire). /// [tabLabels] : liste des libellés d'onglets (ex. avec « À valider » en premier si dossiers en attente).
/// [subTabCount] = 3 pour masquer Administrateurs (dashboard gestionnaire).
class DashboardUserManagementSubBar extends StatelessWidget { class DashboardUserManagementSubBar extends StatelessWidget {
final int selectedSubIndex; final int selectedSubIndex;
final ValueChanged<int> onSubTabChange; final ValueChanged<int> onSubTabChange;
@ -11,8 +12,10 @@ class DashboardUserManagementSubBar extends StatelessWidget {
final VoidCallback? onAddPressed; final VoidCallback? onAddPressed;
final String addLabel; final String addLabel;
final int subTabCount; final int subTabCount;
/// Si non null, utilisé à la place des labels par défaut (ex. ['À valider', 'Parents', ...]).
final List<String>? tabLabels;
static const List<String> _tabLabels = [ static const List<String> _defaultTabLabels = [
'Gestionnaires', 'Gestionnaires',
'Parents', 'Parents',
'Assistantes maternelles', 'Assistantes maternelles',
@ -29,10 +32,14 @@ class DashboardUserManagementSubBar extends StatelessWidget {
this.onAddPressed, this.onAddPressed,
this.addLabel = '+ Ajouter', this.addLabel = '+ Ajouter',
this.subTabCount = 4, this.subTabCount = 4,
this.tabLabels,
}) : super(key: key); }) : super(key: key);
List<String> get _labels => tabLabels ?? _defaultTabLabels.sublist(0, subTabCount.clamp(1, _defaultTabLabels.length));
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final labels = _labels;
return Container( return Container(
height: 56, height: 56,
decoration: BoxDecoration( decoration: BoxDecoration(
@ -42,9 +49,9 @@ class DashboardUserManagementSubBar extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6),
child: Row( child: Row(
children: [ children: [
for (int i = 0; i < subTabCount; i++) ...[ for (int i = 0; i < labels.length; i++) ...[
if (i > 0) const SizedBox(width: 12), if (i > 0) const SizedBox(width: 12),
_buildSubNavItem(context, _tabLabels[i], i), _buildSubNavItem(context, labels[i], i),
], ],
const SizedBox(width: 36), const SizedBox(width: 36),
_pillField( _pillField(
@ -68,7 +75,7 @@ class DashboardUserManagementSubBar extends StatelessWidget {
_pillField(width: 150, child: filterControl!), _pillField(width: 150, child: filterControl!),
], ],
const Spacer(), const Spacer(),
_buildAddButton(), if (onAddPressed != null) _buildAddButton(),
], ],
), ),
); );

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/parent_model.dart'; import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
@ -122,7 +123,7 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
), ),
AdminDetailField( AdminDetailField(
label: 'Telephone', label: 'Telephone',
value: _v(parent.user.telephone), value: _v(parent.user.telephone) != '' ? formatPhoneForDisplay(_v(parent.user.telephone)) : '',
), ),
AdminDetailField(label: 'Adresse', value: _v(parent.user.adresse)), AdminDetailField(label: 'Adresse', value: _v(parent.user.adresse)),
AdminDetailField(label: 'Ville', value: _v(parent.user.ville)), AdminDetailField(label: 'Ville', value: _v(parent.user.ville)),

View File

@ -0,0 +1,354 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.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/validation_dossier_modal.dart';
/// Onglet « À valider » : deux listes (AM en attente, familles en attente). Ticket #107.
class PendingValidationWidget extends StatefulWidget {
final VoidCallback? onRefresh;
const PendingValidationWidget({super.key, this.onRefresh});
@override
State<PendingValidationWidget> createState() => _PendingValidationWidgetState();
}
class _PendingValidationWidgetState extends State<PendingValidationWidget> {
bool _isLoading = true;
String? _error;
List<AppUser> _pendingAM = [];
List<PendingFamily> _pendingFamilies = [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final am = await UserService.getPendingUsers(role: 'assistante_maternelle');
final families = await UserService.getPendingFamilies();
if (!mounted) return;
setState(() {
_pendingAM = am;
_pendingFamilies = families;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur inconnue';
_isLoading = false;
});
}
}
void _onOpenValidation({String? type, String? id, String? numeroDossier}) {
final num = numeroDossier?.trim();
if (num == null || num.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Numéro de dossier manquant.')),
);
return;
}
showDialog<void>(
context: context,
builder: (context) => ValidationDossierModal(
numeroDossier: num,
onClose: () => Navigator.of(context).pop(),
onSuccess: () {
Navigator.of(context).pop();
_load();
widget.onRefresh?.call();
},
),
);
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null && _error!.isNotEmpty) {
return 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: _load,
child: const Text('Réessayer'),
),
],
),
),
);
}
final hasAM = _pendingAM.isNotEmpty;
final hasFamilies = _pendingFamilies.isNotEmpty;
if (!hasAM && !hasFamilies) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle_outline, size: 64, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(
'Aucun dossier en attente de validation',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.grey.shade600,
),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () async {
await _load();
widget.onRefresh?.call();
},
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)),
],
],
),
),
);
}
Widget _sectionTitle(String title) {
return Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Colors.black87,
),
);
}
/// Ligne commune : icône | titre (+ sous-titre) | bouton Ouvrir.
/// [titleWidget] remplace [title] si les deux sont fournis : priorité à [titleWidget].
Widget _buildPendingRow({
required IconData icon,
String? title,
Widget? titleWidget,
String? subtitle,
TextStyle? subtitleStyle,
required VoidCallback onOpen,
}) {
assert(title != null || titleWidget != null);
final titleChild = titleWidget ??
Text(
title!,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14,
),
);
final subStyle = subtitleStyle ??
TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
);
return 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.start,
children: [
Icon(icon, color: Colors.grey.shade600, size: 28),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
titleChild,
if (subtitle != null && subtitle.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
subtitle,
style: subStyle,
),
],
],
),
),
ElevatedButton.icon(
onPressed: onOpen,
icon: const Icon(Icons.open_in_new, size: 18),
label: const Text('Ouvrir'),
),
],
),
),
);
}
/// 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 _buildPendingRow(
icon: Icons.person_outline,
titleWidget: 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,
),
);
}
/// `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 _buildPendingRow(
icon: Icons.family_restroom_outlined,
titleWidget: 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,
),
);
}
}

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:p_tits_pas/models/relais_model.dart'; import 'package:p_tits_pas/models/relais_model.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/services/relais_service.dart'; import 'package:p_tits_pas/services/relais_service.dart';
class RelaisManagementPanel extends StatefulWidget { class RelaisManagementPanel extends StatefulWidget {
@ -723,7 +724,7 @@ class _RelaisFormDialogState extends State<_RelaisFormDialog> {
inputFormatters: [ inputFormatters: [
FilteringTextInputFormatter.digitsOnly, FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10), LengthLimitingTextInputFormatter(10),
_FrenchPhoneNumberFormatter(), FrenchPhoneNumberFormatter(),
], ],
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Ligne fixe', labelText: 'Ligne fixe',
@ -991,30 +992,6 @@ class _RelaisFormDialogState extends State<_RelaisFormDialog> {
} }
} }
class _FrenchPhoneNumberFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
final buffer = StringBuffer();
for (var i = 0; i < digits.length; i++) {
if (i > 0 && i.isEven) {
buffer.write(' ');
}
buffer.write(digits[i]);
}
final formatted = buffer.toString();
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}
}
class _RelaisAddressFields extends StatelessWidget { class _RelaisAddressFields extends StatelessWidget {
final TextEditingController streetController; final TextEditingController streetController;
final TextEditingController postalCodeController; final TextEditingController postalCodeController;

View File

@ -1,10 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart'; import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart'; import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart';
import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart';
class UserManagementPanel extends StatefulWidget { class UserManagementPanel extends StatefulWidget {
/// Afficher l'onglet Administrateurs (sinon 3 onglets : Gestionnaires, Parents, AM). /// Afficher l'onglet Administrateurs (sinon 3 onglets : Gestionnaires, Parents, AM).
@ -26,12 +28,41 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final TextEditingController _amCapacityController = TextEditingController(); final TextEditingController _amCapacityController = TextEditingController();
String? _parentStatus; String? _parentStatus;
bool _hasPending = false;
bool _pendingLoading = true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_searchController.addListener(_onFilterChanged); _searchController.addListener(_onFilterChanged);
_amCapacityController.addListener(_onFilterChanged); _amCapacityController.addListener(_onFilterChanged);
_loadPending();
}
Future<void> _loadPending() async {
try {
final am = await UserService.getPendingUsers(role: 'assistante_maternelle');
final families = await UserService.getPendingFamilies();
if (!mounted) return;
final hasPending = am.isNotEmpty || families.isNotEmpty;
setState(() {
final hadPending = _hasPending;
_hasPending = hasPending;
_pendingLoading = false;
// Si on passe à "plus de dossiers", recaler l'index (onglet À valider disparaît).
if (hadPending && !hasPending) {
_subIndex = (_subIndex > 0 ? _subIndex - 1 : 0).clamp(0, _tabLabels.length - 1);
} else if (!hadPending && hasPending) {
_subIndex = 0; // Afficher l'onglet À valider
}
});
} catch (_) {
if (!mounted) return;
setState(() {
_hasPending = false;
_pendingLoading = false;
});
}
} }
@override @override
@ -48,8 +79,17 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
setState(() {}); setState(() {});
} }
List<String> get _tabLabels {
const base = ['Parents', '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;
}
void _onSubTabChange(int index) { void _onSubTabChange(int index) {
final maxIndex = widget.showAdministrateursTab ? 3 : 2; final maxIndex = _tabLabels.length - 1;
setState(() { setState(() {
_subIndex = index.clamp(0, maxIndex); _subIndex = index.clamp(0, maxIndex);
_searchController.clear(); _searchController.clear();
@ -58,14 +98,20 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
}); });
} }
/// Index du contenu : -1 = À valider (si visible), 0 = Parents, 1 = AM, 2 = Gestionnaires, 3 = Admin.
int get _contentIndexOffset => (_hasPending && !_pendingLoading) ? 1 : 0;
String _searchHintForTab() { String _searchHintForTab() {
switch (_subIndex) { final contentIndex = _subIndex - _contentIndexOffset;
switch (contentIndex) {
case -1:
return 'À valider (pas de recherche)';
case 0: case 0:
return 'Rechercher un gestionnaire...';
case 1:
return 'Rechercher un parent...'; return 'Rechercher un parent...';
case 2: case 1:
return 'Rechercher une assistante...'; return 'Rechercher une assistante...';
case 2:
return 'Rechercher un gestionnaire...';
case 3: case 3:
return 'Rechercher un administrateur...'; return 'Rechercher un administrateur...';
default: default:
@ -74,7 +120,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
} }
Widget? _subBarFilterControl() { Widget? _subBarFilterControl() {
if (_subIndex == 1) { if (_subIndex == _contentIndexOffset + 0) {
return DropdownButtonHideUnderline( return DropdownButtonHideUnderline(
child: DropdownButton<String?>( child: DropdownButton<String?>(
value: _parentStatus, value: _parentStatus,
@ -122,7 +168,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
); );
} }
if (_subIndex == 2) { if (_subIndex == _contentIndexOffset + 1) {
return TextField( return TextField(
controller: _amCapacityController, controller: _amCapacityController,
decoration: const InputDecoration( decoration: const InputDecoration(
@ -139,22 +185,26 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
} }
Widget _buildBody() { Widget _buildBody() {
switch (_subIndex) { final contentIndex = _subIndex - _contentIndexOffset;
if (_hasPending && !_pendingLoading && contentIndex == -1) {
return PendingValidationWidget(onRefresh: _loadPending);
}
switch (contentIndex) {
case 0: case 0:
return GestionnaireManagementWidget(
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
searchQuery: _searchController.text,
);
case 1:
return ParentManagementWidget( return ParentManagementWidget(
searchQuery: _searchController.text, searchQuery: _searchController.text,
statusFilter: _parentStatus, statusFilter: _parentStatus,
); );
case 2: case 1:
return AssistanteMaternelleManagementWidget( return AssistanteMaternelleManagementWidget(
searchQuery: _searchController.text, searchQuery: _searchController.text,
capacityMin: int.tryParse(_amCapacityController.text), capacityMin: int.tryParse(_amCapacityController.text),
); );
case 2:
return GestionnaireManagementWidget(
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
searchQuery: _searchController.text,
);
case 3: case 3:
return AdminManagementWidget( return AdminManagementWidget(
key: ValueKey('admins-$_adminRefreshTick'), key: ValueKey('admins-$_adminRefreshTick'),
@ -167,7 +217,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final subTabCount = widget.showAdministrateursTab ? 4 : 3; final labels = _tabLabels;
final isAValiderTab = _hasPending && !_pendingLoading && _subIndex == 0;
return Column( return Column(
children: [ children: [
DashboardUserManagementSubBar( DashboardUserManagementSubBar(
@ -176,9 +227,10 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
searchController: _searchController, searchController: _searchController,
searchHint: _searchHintForTab(), searchHint: _searchHintForTab(),
filterControl: _subBarFilterControl(), filterControl: _subBarFilterControl(),
onAddPressed: _handleAddPressed, onAddPressed: isAValiderTab ? null : _handleAddPressed,
addLabel: 'Ajouter', addLabel: 'Ajouter',
subTabCount: subTabCount, subTabCount: labels.length,
tabLabels: labels,
), ),
Expanded(child: _buildBody()), Expanded(child: _buildBody()),
], ],
@ -186,7 +238,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
} }
Future<void> _handleAddPressed() async { Future<void> _handleAddPressed() async {
if (_subIndex == 0) { final contentIndex = _subIndex - _contentIndexOffset;
if (contentIndex == 2) {
final created = await showDialog<bool>( final created = await showDialog<bool>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
@ -204,7 +257,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
return; return;
} }
if (_subIndex == 3) { if (contentIndex == 3) {
final created = await showDialog<bool>( final created = await showDialog<bool>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,

View File

@ -0,0 +1,507 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/utils/phone_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 'validation_modal_theme.dart';
import 'validation_refus_form.dart';
import 'validation_valider_confirm_dialog.dart';
/// Wizard de validation dossier AM : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107.
class ValidationAmWizard extends StatefulWidget {
final DossierAM dossier;
final VoidCallback onClose;
final VoidCallback onSuccess;
final void Function(int step, int total)? onStepChanged;
const ValidationAmWizard({
super.key,
required this.dossier,
required this.onClose,
required this.onSuccess,
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);
/// Même ordre et disposition que le formulaire de création de compte (Nom/Prénom, Tél/Email, Adresse, CP/Ville).
List<AdminDetailField> _personalFields(AppUser u) => [
AdminDetailField(label: 'Nom', value: _v(u.nom)),
AdminDetailField(label: 'Prénom', value: _v(u.prenom)),
AdminDetailField(
label: 'Téléphone',
value: _v(u.telephone) != ''
? formatPhoneForDisplay(_v(u.telephone))
: ''),
AdminDetailField(label: 'Email', value: _v(u.email)),
AdminDetailField(label: 'Adresse (N° et Rue)', value: _v(u.adresse)),
AdminDetailField(label: 'Code postal', value: _v(u.codePostal)),
AdminDetailField(label: 'Ville', value: _v(u.ville)),
];
/// Informations professionnelles : N° Agrément|Date agrément, NIR, Capacité|Places, Ville.
List<AdminDetailField> _proFields(DossierAM d) => [
AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)),
AdminDetailField(
label: 'Date dagrément',
value: d.dateAgrement != null && d.dateAgrement!.trim().isNotEmpty
? d.dateAgrement!.trim()
: '',
),
AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)),
AdminDetailField(
label: 'Capacité max (enfants)',
value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '',
),
AdminDetailField(
label: 'Places disponibles',
value: d.placesDisponibles != null
? d.placesDisponibles.toString()
: '',
),
AdminDetailField(
label: 'Ville de résidence', value: _v(d.villeResidence)),
];
static const List<int> _personalRowLayout = [2, 2, 1, 2];
static const Map<int, List<int>> _personalRowFlex = {
3: [2, 5]
}; // Code postal étroit, Ville large
/// Proportion photo didentité (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, on préfixe par lorigine de lAPI.
static String _fullPhotoUrl(String? url) {
if (url == null || url.trim().isEmpty) return '';
final u = url.trim();
if (u.startsWith('http://') || u.startsWith('https://')) return u;
final base = ApiConfig.baseUrl;
final origin = base.replaceAll(RegExp(r'/api/v1.*'), '');
return u.startsWith('/') ? '$origin$u' : '$origin/$u';
}
Widget _buildPhotoSection(AppUser u) {
final photoUrl = _fullPhotoUrl(u.photoUrl);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Photo de profil',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 12),
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),
),
],
),
)
: Image.network(
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(),
],
),
);
}
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: ValidationDetailSection(
title: 'Informations personnelles',
fields: _personalFields(u),
rowLayout: _personalRowLayout,
rowFlex: _personalRowFlex,
),
),
);
},
);
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;
// Titre « Photo de profil » + espacement (~52 px) : hauteur dispo pour le cadre photo.
const photoHeaderH = 52.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: 'Informations professionnelles',
fields: _proFields(d),
rowLayout: const [
2,
1,
2,
1
], // N° Agrément|Date agrément, NIR, Capacité|Places, Ville
),
),
);
},
),
),
],
);
},
);
case 2:
final presentation =
(d.presentation != null && d.presentation!.trim().isNotEmpty)
? d.presentation!
: '';
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Présentation',
style: const 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(
onCancel: widget.onClose,
onPrevious: () => setState(() => _showRefusForm = false),
onSubmit: (comment) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Refus (à brancher sur lAPI refus)')),
);
widget.onClose();
},
),
),
],
),
);
}
}

View File

@ -0,0 +1,173 @@
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/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.
class ValidationDossierModal extends StatefulWidget {
final String numeroDossier;
final VoidCallback onClose;
final VoidCallback? onSuccess;
const ValidationDossierModal({
super.key,
required this.numeroDossier,
required this.onClose,
this.onSuccess,
});
@override
State<ValidationDossierModal> createState() => _ValidationDossierModalState();
}
class _ValidationDossierModalState extends State<ValidationDossierModal> {
bool _loading = true;
String? _error;
DossierUnifie? _dossier;
int? _stepIndex;
int? _stepTotal;
void _onStepChanged(int step, int total) {
// step = 0-based dans les wizards, affichage 1-based dans l'en-tête.
if (!mounted) return;
setState(() {
_stepIndex = step;
_stepTotal = total;
});
}
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
_dossier = null;
_stepIndex = null;
_stepTotal = null;
});
try {
final d = await UserService.getDossier(widget.numeroDossier);
if (!mounted) return;
setState(() {
_dossier = d;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur inconnue';
_loading = false;
});
}
}
void _onSuccess() {
widget.onSuccess?.call();
// La modale est fermée par lappelant dans onSuccess (Navigator.pop).
}
/// 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;
@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: [
Padding(
padding: const EdgeInsets.fromLTRB(18, 18, 0, 12),
child: Text(
'Dossier ${widget.numeroDossier}',
style: const 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: _buildBody(),
),
],
),
),
);
}
Widget _buildBody() {
if (_loading) {
return const Padding(
padding: EdgeInsets.all(48),
child: Center(child: CircularProgressIndicator()),
);
}
if (_error != null && _error!.isNotEmpty) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!,
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton(onPressed: _load, child: const Text('Réessayer')),
const SizedBox(height: 8),
TextButton(onPressed: widget.onClose, child: const Text('Fermer')),
],
),
);
}
final d = _dossier!;
if (d.isAm) {
return ValidationAmWizard(
dossier: d.asAm,
onClose: widget.onClose,
onSuccess: _onSuccess,
onStepChanged: _onStepChanged,
);
}
return ValidationFamilyWizard(
dossier: d.asFamily,
onClose: widget.onClose,
onSuccess: _onSuccess,
onStepChanged: _onStepChanged,
);
}
}

View File

@ -0,0 +1,730 @@
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:intl/intl.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/services/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 'validation_modal_theme.dart';
import 'validation_refus_form.dart';
import 'validation_valider_confirm_dialog.dart';
/// Wizard de validation dossier famille : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107.
class ValidationFamilyWizard extends StatefulWidget {
final DossierFamille dossier;
final VoidCallback onClose;
final VoidCallback onSuccess;
final void Function(int step, int total)? onStepChanged;
const ValidationFamilyWizard({
super.key,
required this.dossier,
required this.onClose,
required this.onSuccess,
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) {
if (s == null || s.trim().isEmpty) return 'Non défini';
try {
final d = DateTime.parse(s.trim());
return DateFormat('dd/MM/yyyy').format(d);
} catch (_) {
return s.trim();
}
}
/// Même ordre et disposition que le formulaire de création (Nom/Prénom, Tél/Email, Adresse, CP/Ville).
List<AdminDetailField> _parentFields(ParentDossier p) => [
AdminDetailField(label: 'Nom', value: _v(p.nom)),
AdminDetailField(label: 'Prénom', value: _v(p.prenom)),
AdminDetailField(
label: 'Téléphone',
value: _v(p.telephone) != 'Non défini'
? formatPhoneForDisplay(_v(p.telephone))
: 'Non défini'),
AdminDetailField(label: 'Email', value: _v(p.email)),
AdminDetailField(label: 'Adresse (N° et Rue)', value: _v(p.adresse)),
AdminDetailField(label: 'Code postal', value: _v(p.codePostal)),
AdminDetailField(label: 'Ville', value: _v(p.ville)),
];
static const List<int> _parentRowLayout = [2, 2, 1, 2];
static const Map<int, List<int>> _parentRowFlex = {
3: [2, 5]
}; // Code postal étroit, Ville large
static String _fullPhotoUrl(String? url) {
if (url == null || url.trim().isEmpty) return '';
final u = url.trim();
if (u.startsWith('http://') || u.startsWith('https://')) return u;
final base = ApiConfig.baseUrl;
final origin = base.replaceAll(RegExp(r'/api/v1.*'), '');
return u.startsWith('/') ? '$origin$u' : '$origin/$u';
}
@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(),
],
),
);
}
Widget _buildStepContent() {
final d = widget.dossier;
switch (_step) {
case 0:
return ValidationDetailSection(
title: 'Parent principal',
fields: _parentFields(d.parents.first),
rowLayout: _parentRowLayout,
rowFlex: _parentRowFlex,
);
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 ValidationDetailSection(
title: 'Deuxième parent',
fields: _parentFields(widget.dossier.parents[1]),
rowLayout: _parentRowLayout,
rowFlex: _parentRowFlex,
);
}
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.withValues(alpha: 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,
),
),
),
),
],
),
),
),
],
),
),
],
),
),
);
}
/// « Scolarisé » / « Scolarisée » selon le genre enfant (`F` / sinon masculin par défaut).
static String _scolariseAccordeAuGenre(String? gender) {
final g = (gender ?? '').trim().toUpperCase();
if (g == 'F') return 'Scolarisée';
return 'Scolarisé';
}
/// Statut dans la colonne 2/3 uniquement (pas de [ValidationReadOnlyField]) : scolarisé·e ou « À naître ».
/// `actif` : pas de ligne statut.
String? _enfantColumnStatusLabel(EnfantDossier e) {
final s = (e.status ?? '').trim().toLowerCase();
if (s == 'a_naitre') return 'À naître';
if (s == 'scolarise') return _scolariseAccordeAuGenre(e.gender);
return null;
}
/// 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) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.black.withValues(alpha: 0.08)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 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),
),
)
: Image.network(
photoUrl,
fit: BoxFit.contain,
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 / Motivation',
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(
onCancel: widget.onClose,
onPrevious: () => setState(() => _showRefusForm = false),
onSubmit: (comment) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Refus (à brancher sur lAPI refus ticket #110)')),
);
widget.onClose();
},
),
),
],
),
);
}
}

View File

@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
/// Couleurs / styles communs aux modales de validation (cohérent avec le violet / lavande admin).
abstract final class ValidationModalTheme {
/// Violet pastel foncé (proche des cartes admin, ex. `0xFF6D4EA1`).
static const Color primaryActionBackground = Color(0xFF6D4EA1);
static const Color primaryActionForeground = Colors.white;
static ButtonStyle get primaryElevatedStyle {
return ElevatedButton.styleFrom(
backgroundColor: primaryActionBackground,
foregroundColor: primaryActionForeground,
disabledBackgroundColor: primaryActionBackground.withOpacity(0.45),
disabledForegroundColor: primaryActionForeground.withOpacity(0.7),
);
}
}

View File

@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import 'validation_modal_theme.dart';
/// Page « Motifs du refus » : champ libre + Annuler (ferme la modale), Précédent (retour au choix Valider/Refuser), Envoyer. Ticket #107.
class ValidationRefusForm extends StatefulWidget {
/// Ferme la modale (abandon du flux).
final VoidCallback onCancel;
/// Retour à létape précédente du wizard (écran avec Valider / Refuser).
final VoidCallback onPrevious;
final ValueChanged<String?> onSubmit;
const ValidationRefusForm({
super.key,
required this.onCancel,
required this.onPrevious,
required this.onSubmit,
});
@override
State<ValidationRefusForm> createState() => _ValidationRefusFormState();
}
class _ValidationRefusFormState extends State<ValidationRefusForm> {
final _controller = TextEditingController();
final _formKey = GlobalKey<FormState>();
static const int _minLength = 20;
String? _validateMotifs(String? value) {
final t = value?.trim() ?? '';
if (t.isEmpty) return 'Les motifs du refus sont obligatoires.';
if (t.length < _minLength) {
return 'Veuillez indiquer au moins $_minLength caractères (${t.length}/$_minLength).';
}
return null;
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Indiquez les motifs du refus',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
return Container(
constraints: BoxConstraints.tight(Size(constraints.maxWidth, constraints.maxHeight)),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.grey.shade400),
),
child: TextFormField(
controller: _controller,
maxLines: null,
minLines: 1,
validator: _validateMotifs,
decoration: InputDecoration(
hintText: 'Saisissez les raisons du refus (minimum $_minLength caractères)',
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
alignLabelWithHint: true,
filled: false,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
),
),
);
},
),
),
const SizedBox(height: 24),
Row(
children: [
TextButton(
onPressed: widget.onCancel,
child: const Text('Annuler'),
),
const Spacer(),
Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: widget.onPrevious,
child: const Text('Précédent'),
),
const SizedBox(width: 8),
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: () {
if (_formKey.currentState!.validate()) {
widget.onSubmit(_controller.text.trim());
}
},
child: const Text('Envoyer'),
),
],
),
],
),
],
),
);
}
}

View File

@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'validation_modal_theme.dart';
/// Affiche une confirmation avant dappeler lAPI de validation du dossier.
/// Retourne `true` si lutilisateur confirme.
Future<bool> showValidationValiderConfirmDialog(
BuildContext context, {
required String body,
}) async {
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Confirmer la validation'),
content: Text(body),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Annuler'),
),
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Confirmer'),
),
],
);
},
);
return result == true;
}

View File

@ -15,8 +15,8 @@ class DashboardTabItem {
/// Icône associée au rôle utilisateur (alignée sur le panneau admin). /// Icône associée au rôle utilisateur (alignée sur le panneau admin).
IconData _iconForRole(String? role) { IconData _iconForRole(String? role) {
if (role == null || role.isEmpty) return Icons.person_outline; final r = (role ?? '').trim().toLowerCase();
final r = role.toLowerCase(); if (r.isEmpty) return Icons.person_outline;
if (r == 'super_admin') return Icons.verified_user_outlined; if (r == 'super_admin') return Icons.verified_user_outlined;
if (r == 'admin' || r == 'administrateur') return Icons.manage_accounts_outlined; if (r == 'admin' || r == 'administrateur') return Icons.manage_accounts_outlined;
if (r == 'gestionnaire') return Icons.assignment_ind_outlined; if (r == 'gestionnaire') return Icons.assignment_ind_outlined;

View File

@ -4,7 +4,7 @@ import '../utils/nir_utils.dart';
import 'custom_app_text_field.dart'; import 'custom_app_text_field.dart';
/// Champ de saisie dédié au NIR (Numéro d'Inscription au Répertoire 15 caractères). /// Champ de saisie dédié au NIR (Numéro d'Inscription au Répertoire 15 caractères).
/// Format affiché : 1 12 34 56 789 012-34 ou 1 12 34 2A 789 012-34 pour la Corse. /// Format affiché : 1 12 34 56 789 012 - 34 ou 1 12 34 2A 789 012 - 34 pour la Corse.
/// La valeur envoyée au [controller] est formatée ; utiliser [normalizeNir](controller.text) à la soumission. /// La valeur envoyée au [controller] est formatée ; utiliser [normalizeNir](controller.text) à la soumission.
class NirTextField extends StatelessWidget { class NirTextField extends StatelessWidget {
final TextEditingController controller; final TextEditingController controller;
@ -23,7 +23,7 @@ class NirTextField extends StatelessWidget {
super.key, super.key,
required this.controller, required this.controller,
this.labelText = 'N° Sécurité Sociale (NIR)', this.labelText = 'N° Sécurité Sociale (NIR)',
this.hintText = '15 car. (ex. 1 12 34 56 789 012-34 ou 2A Corse)', this.hintText = '15 car. (ex. 1 12 34 56 789 012 - 34 ou 2A Corse)',
this.validator, this.validator,
this.fieldWidth = double.infinity, this.fieldWidth = double.infinity,
this.fieldHeight = 53.0, this.fieldHeight = 53.0,

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'dart:math' as math; import 'dart:math' as math;
@ -92,7 +94,7 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
super.initState(); super.initState();
_lastNameController = TextEditingController(text: widget.initialData.lastName); _lastNameController = TextEditingController(text: widget.initialData.lastName);
_firstNameController = TextEditingController(text: widget.initialData.firstName); _firstNameController = TextEditingController(text: widget.initialData.firstName);
_phoneController = TextEditingController(text: widget.initialData.phone); _phoneController = TextEditingController(text: formatPhoneForDisplay(widget.initialData.phone));
_emailController = TextEditingController(text: widget.initialData.email); _emailController = TextEditingController(text: widget.initialData.email);
_addressController = TextEditingController(text: widget.initialData.address); _addressController = TextEditingController(text: widget.initialData.address);
_postalCodeController = TextEditingController(text: widget.initialData.postalCode); _postalCodeController = TextEditingController(text: widget.initialData.postalCode);
@ -134,7 +136,7 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
final data = PersonalInfoData( final data = PersonalInfoData(
firstName: _firstNameController.text, firstName: _firstNameController.text,
lastName: _lastNameController.text, lastName: _lastNameController.text,
phone: _phoneController.text, phone: normalizePhone(_phoneController.text),
email: _emailController.text, email: _emailController.text,
address: _addressController.text, address: _addressController.text,
postalCode: _postalCodeController.text, postalCode: _postalCodeController.text,
@ -631,6 +633,7 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
hint: 'Votre numéro de téléphone', hint: 'Votre numéro de téléphone',
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
inputFormatters: _phoneInputFormatters,
), ),
), ),
const SizedBox(width: 20), const SizedBox(width: 20),
@ -732,7 +735,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
child: _buildDisplayFieldValue( child: _buildDisplayFieldValue(
context, context,
'Téléphone :', 'Téléphone :',
_phoneController.text, _phoneController.text.trim().isNotEmpty
? formatPhoneForDisplay(_phoneController.text)
: _phoneController.text,
labelFontSize: labelFontSize, labelFontSize: labelFontSize,
valueFontSize: valueFontSize, valueFontSize: valueFontSize,
), ),
@ -868,6 +873,7 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
hint: 'Votre numéro de téléphone', hint: 'Votre numéro de téléphone',
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
inputFormatters: _phoneInputFormatters,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -923,13 +929,17 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
String? hint, String? hint,
TextInputType? keyboardType, TextInputType? keyboardType,
bool enabled = true, bool enabled = true,
List<TextInputFormatter>? inputFormatters,
}) { }) {
if (config.isReadonly) { if (config.isReadonly) {
// Mode readonly : utiliser FormFieldWrapper // Mode readonly : utiliser FormFieldWrapper (téléphone formaté pour affichage)
final displayValue = label == 'Téléphone' && controller.text.trim().isNotEmpty
? formatPhoneForDisplay(controller.text)
: controller.text;
return FormFieldWrapper( return FormFieldWrapper(
config: config, config: config,
label: label, label: label,
value: controller.text, value: displayValue,
); );
} else { } else {
// Mode éditable : style adapté mobile/desktop // Mode éditable : style adapté mobile/desktop
@ -944,10 +954,17 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
inputFontSize: config.isMobile ? 14.0 : 20.0, inputFontSize: config.isMobile ? 14.0 : 20.0,
keyboardType: keyboardType ?? TextInputType.text, keyboardType: keyboardType ?? TextInputType.text,
enabled: enabled, enabled: enabled,
inputFormatters: inputFormatters,
); );
} }
} }
static final _phoneInputFormatters = [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
FrenchPhoneNumberFormatter(),
];
/// Retourne l'asset de carte vertical correspondant à la couleur /// Retourne l'asset de carte vertical correspondant à la couleur
String _getVerticalCardAsset() { String _getVerticalCardAsset() {
switch (widget.cardColor) { switch (widget.cardColor) {

View File

@ -529,7 +529,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
); );
} }
/// NIR formaté pour affichage (1 12 34 56 789 012-34 ou 2A pour la Corse). /// NIR formaté pour affichage (1 12 34 56 789 012 - 34 ou 2A pour la Corse).
String _formatNirForDisplay(String value) { String _formatNirForDisplay(String value) {
final raw = nirToRaw(value); final raw = nirToRaw(value);
return raw.length == 15 ? formatNir(raw) : value; return raw.length == 15 ? formatNir(raw) : value;