Compare commits
115 Commits
4dedd9b87a
...
0cc6fa3a9d
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cc6fa3a9d | |||
| 0befd4eb2a | |||
| f442c85cb6 | |||
| ab7845347b | |||
| 011ab26828 | |||
| a9ce4a6756 | |||
| d550e57cb8 | |||
| b4b44cb1b9 | |||
| 58607cdbc9 | |||
| 12bf85b7bd | |||
| 01c1be8f16 | |||
| cdae9b5f7c | |||
| 73efac482f | |||
| 62b2466cce | |||
| d69768806c | |||
| eac108c9f4 | |||
| f1c9db2e7b | |||
| ccfcfa3287 | |||
| dbca12d5d1 | |||
| 4fe29799cd | |||
| 3bfdadd301 | |||
| 09289882d0 | |||
| e7f0037ac3 | |||
| 4969be5809 | |||
| d7ccbfbe35 | |||
| 2b3128ce4a | |||
| 077a1ca3ed | |||
| bf05b1d7d7 | |||
| 110240b682 | |||
| a723412043 | |||
| 1e21fe25f3 | |||
| cd2bf63b8a | |||
| de60a001cc | |||
| 481fa66630 | |||
| 0a07fade40 | |||
| 0e8a999e41 | |||
| 2e02f60601 | |||
| 0e6e473e84 | |||
| cc3639b19a | |||
| cf9cd495bf | |||
| 76ba28a7ce | |||
| 1772744c81 | |||
| 5465117238 | |||
| 6e2343087e | |||
| f6fabc521e | |||
| 5390276ecd | |||
| 7e9306de01 | |||
| d832559027 | |||
| 54ad1d2aa1 | |||
| 86b28abe51 | |||
| 86d8189038 | |||
| dbcb3611d4 | |||
| 1fa70f4052 | |||
| 393a527c37 | |||
| dfd58d9b6c | |||
| 34a36b069e | |||
| 2fa546e6b7 | |||
| 8636b16659 | |||
| 7e17e5ff8d | |||
| e8b6d906e6 | |||
| ae0be04964 | |||
| 447f3d4137 | |||
| 721f40599b | |||
| a9c6b9e15b | |||
| 38c003ef6f | |||
| 3dbddbb8c4 | |||
| f46740c6ab | |||
| 85bfef7a6b | |||
| 3c2ecdff7a | |||
| 8b83702bd2 | |||
| 19b8be684f | |||
| 5950d85876 | |||
| 4339e1e53d | |||
| defa438edf | |||
| e990d576cf | |||
| e8c6665a06 | |||
| a4e6cfc50e | |||
| 80d69a5463 | |||
| 0579fda553 | |||
| d14550a1cf | |||
| 2645cf1cd6 | |||
| e2ebc6a0a1 | |||
| 090ce6e13b | |||
| d66bdd04be | |||
| d8572e7fd6 | |||
| 222d7c702f | |||
| 537c46127f | |||
| ed18dcab10 | |||
| bb92f010bd | |||
| 42bb872c41 | |||
| fac3ae9baa | |||
| 5c28981ac5 | |||
| 57ce5af0f4 | |||
| c1204a3050 | |||
| 9d4363b2a7 | |||
| af06ab1e66 | |||
| aa148354ec | |||
| a10dc5a195 | |||
| 04c0b05aae | |||
| d0b730c8ab | |||
| bc8362bdb7 | |||
| ac3178903d | |||
| aec1990ec9 | |||
| 5da2ab9005 | |||
| b2d6414fab | |||
| fbafef8f2c | |||
| 135c7c2255 | |||
| 9cce326046 | |||
| d697083f54 | |||
| ae786426fd | |||
| e4f7a35f0f | |||
| 8a6768b316 | |||
| 3892a8beab | |||
| d39bc55be3 | |||
| e0debf0394 |
104
backend/scripts/close-gitea-issue-120.js
Normal file
104
backend/scripts/close-gitea-issue-120.js
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Commentaire de clôture + fermeture issue Gitea #120.
|
||||||
|
* Usage: node backend/scripts/close-gitea-issue-120.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, '../..');
|
||||||
|
const ISSUE = 120;
|
||||||
|
const REPO = 'jmartin/petitspas';
|
||||||
|
|
||||||
|
const body = `## Fermeture ticket #120 — livré sur \`develop\`
|
||||||
|
|
||||||
|
Branche **\`feature/120-inscription-am-photo\`** mergée dans **\`develop\`** (livraison : inscription AM alignée parents + panneau validation gestionnaire).
|
||||||
|
|
||||||
|
### Inscription AM (alignement parents)
|
||||||
|
- Photo, consentement, lieux de naissance : parcours et API alignés sur le modèle parents (DTO, entité user, migration SQL, écrans inscription AM, \`registration_photo_slot\`, scripts de test Node).
|
||||||
|
|
||||||
|
### Panneau gestionnaire — onglet « À valider »
|
||||||
|
- Bouton **Ouvrir** : visible au **survol** (même principe que les cartes admin), **icône centrée** sur la ligne et **taille doublée** (\`iconSize\` 34).
|
||||||
|
|
||||||
|
### Wizard validation dossier AM
|
||||||
|
- Titres : **Identité et coordonnées** · **Dossier professionnel** (au-dessus des champs à droite, pas de la photo) · **Présentation**.
|
||||||
|
- **Photo** à gauche (ratio identité 35×45) ; **grille droite** \`[2,2,2,2]\` : NIR | date de naissance, ville | pays de naissance, n° agrément | date d'agrément, capacité | places.
|
||||||
|
- **\`AppUser\`** : \`date_naissance\`, \`lieu_naissance_ville\`, \`lieu_naissance_pays\` ; affichage dates **\`dd/MM/yyyy\`** (\`formatIsoDateFr\`).
|
||||||
|
- **\`ValidationDetailSection\`** : titre **optionnel** (wizard AM).
|
||||||
|
|
||||||
|
### Wizard validation famille
|
||||||
|
- Étape 4 : titre **« Présentation »** (plus « Présentation / Motivation »).
|
||||||
|
|
||||||
|
### Nettoyage
|
||||||
|
- Suppression des **\`debugPrint\`** liés aux médias / images / chargement dossier (\`api_config\`, \`auth_network_image\`, \`dossier_unifie\`, \`user_service\`, carte enfant validation).
|
||||||
|
|
||||||
|
---
|
||||||
|
*Issue fermée après merge sur \`develop\` et recette.*`;
|
||||||
|
|
||||||
|
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é : .gitea-token ou GITEA_TOKEN');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(method, apiPath, payloadObj) {
|
||||||
|
const payload = payloadObj ? JSON.stringify(payloadObj) : null;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const opts = {
|
||||||
|
hostname: 'git.ptits-pas.fr',
|
||||||
|
path: `/api/v1/repos/${REPO}${apiPath}`,
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Authorization: 'token ' + token,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const req = https.request(opts, (res) => {
|
||||||
|
let d = '';
|
||||||
|
res.on('data', (c) => (d += c));
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode !== 200 && res.statusCode !== 201) {
|
||||||
|
reject(new Error(`HTTP ${res.statusCode}: ${d}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve(d ? JSON.parse(d) : {});
|
||||||
|
} catch (_) {
|
||||||
|
resolve({});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
if (payload) req.write(payload);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
console.log(`POST commentaire issue #${ISSUE}...`);
|
||||||
|
await request('POST', `/issues/${ISSUE}/comments`, { body });
|
||||||
|
console.log('Commentaire publié.');
|
||||||
|
console.log(`PATCH fermeture issue #${ISSUE}...`);
|
||||||
|
await request('PATCH', `/issues/${ISSUE}`, { state: 'closed' });
|
||||||
|
console.log(`Issue #${ISSUE} fermée.`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e.message || e);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@ -1,27 +1,32 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Test POST /auth/register/am (ticket #90)
|
# Inscription AM complète : jeux officiels alignés sur database/seed/03_seed_test_data.sql
|
||||||
|
# node tests/scripts/register-am-dubois-test.mjs [BASE_URL]
|
||||||
|
# node tests/scripts/register-am-mansouri-test.mjs [BASE_URL]
|
||||||
|
#
|
||||||
|
# Smoke curl (email + NIR jetables, hors seed — ne pas mélanger avec Marie / Fatima) :
|
||||||
# Usage: ./scripts/test-register-am.sh [BASE_URL]
|
# Usage: ./scripts/test-register-am.sh [BASE_URL]
|
||||||
# Exemple: ./scripts/test-register-am.sh https://app.ptits-pas.fr/api/v1
|
# Exemple: ./scripts/test-register-am.sh http://localhost:3000/api/v1
|
||||||
# ./scripts/test-register-am.sh http://localhost:3000/api/v1
|
|
||||||
|
|
||||||
BASE_URL="${1:-http://localhost:3000/api/v1}"
|
BASE_URL="${1:-http://localhost:3000/api/v1}"
|
||||||
echo "Testing POST $BASE_URL/auth/register/am"
|
echo "POST $BASE_URL/auth/register/am (profil smoke, pas les AM du seed)"
|
||||||
echo "---"
|
echo "---"
|
||||||
|
|
||||||
curl -s -w "\n\nHTTP %{http_code}\n" -X POST "$BASE_URL/auth/register/am" \
|
curl -s -w "\n\nHTTP %{http_code}\n" -X POST "$BASE_URL/auth/register/am" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"email": "marie.dupont.test@ptits-pas.fr",
|
"email": "smoke.am.curl@ptits-pas.fr",
|
||||||
"prenom": "Marie",
|
"prenom": "Smoke",
|
||||||
"nom": "DUPONT",
|
"nom": "CURLTEST",
|
||||||
"telephone": "0612345678",
|
"telephone": "0612345678",
|
||||||
"adresse": "1 rue Test",
|
"adresse": "1 rue Smoke",
|
||||||
"code_postal": "75001",
|
"code_postal": "95870",
|
||||||
"ville": "Paris",
|
"ville": "Bezons",
|
||||||
"consentement_photo": true,
|
"consentement_photo": false,
|
||||||
"nir": "123456789012345",
|
"date_naissance": "1986-12-15",
|
||||||
"numero_agrement": "AGR-2024-001",
|
"nir": "186127500100279",
|
||||||
"capacite_accueil": 4,
|
"numero_agrement": "AGR-SMOKE-CURL-001",
|
||||||
|
"capacite_accueil": 3,
|
||||||
|
"places_disponibles": 2,
|
||||||
"acceptation_cgu": true,
|
"acceptation_cgu": true,
|
||||||
"acceptation_privacy": true
|
"acceptation_privacy": true
|
||||||
}'
|
}'
|
||||||
|
|||||||
@ -138,6 +138,12 @@ export class Users {
|
|||||||
@Column({ name: 'date_naissance', type: 'date', nullable: true })
|
@Column({ name: 'date_naissance', type: 'date', nullable: true })
|
||||||
date_naissance?: Date;
|
date_naissance?: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'lieu_naissance_ville', length: 100, nullable: true })
|
||||||
|
lieu_naissance_ville?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'lieu_naissance_pays', length: 100, nullable: true })
|
||||||
|
lieu_naissance_pays?: string;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
|
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
|
||||||
cree_le: Date;
|
cree_le: Date;
|
||||||
|
|
||||||
|
|||||||
@ -99,10 +99,10 @@ export class MailService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accusé de réception inscription parent : demande enregistrée + n° de dossier.
|
* Accusé de réception inscription (parent ou assistante maternelle) :
|
||||||
* L’utilisateur est en attente de validation ; pas de lien création MDP à ce stade.
|
* demande enregistrée + n° de dossier. En attente de validation ; pas de lien création MDP à ce stade.
|
||||||
*/
|
*/
|
||||||
async sendParentRegistrationPendingEmail(
|
async sendRegistrationPendingEmail(
|
||||||
to: string,
|
to: string,
|
||||||
prenom: string,
|
prenom: string,
|
||||||
nom: string,
|
nom: string,
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { Public } from 'src/common/decorators/public.decorator';
|
|||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { RegisterParentCompletDto } from './dto/register-parent-complet.dto';
|
import { RegisterParentCompletDto } from './dto/register-parent-complet.dto';
|
||||||
import { RegisterAMCompletDto } from './dto/register-am-complet.dto';
|
import { RegisterAMCompletDto } from './dto/register-am-complet.dto';
|
||||||
|
import { RegisterAmResponseDto } from './dto/register-am-response.dto';
|
||||||
import { ChangePasswordRequiredDto } from './dto/change-password.dto';
|
import { ChangePasswordRequiredDto } from './dto/change-password.dto';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
@ -59,12 +60,13 @@ export class AuthController {
|
|||||||
@Post('register/am')
|
@Post('register/am')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Inscription Assistante Maternelle COMPLÈTE',
|
summary: 'Inscription Assistante Maternelle COMPLÈTE',
|
||||||
description: 'Crée User AM + entrée assistantes_maternelles (identité + infos pro + photo + CGU) en une transaction',
|
description:
|
||||||
|
'Crée User AM + entrée assistantes_maternelles (identité + infos pro + photo + CGU) en une transaction. Si photo_base64 est fourni sans photo_filename, le serveur utilise le nom par défaut photo_am.jpg (extension du fichier stocké = type du data-URL).',
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 201, description: 'Inscription réussie - Dossier en attente de validation' })
|
@ApiResponse({ status: 201, description: 'Inscription réussie - Dossier en attente de validation', type: RegisterAmResponseDto })
|
||||||
@ApiResponse({ status: 400, description: 'Données invalides ou CGU non acceptées' })
|
@ApiResponse({ status: 400, description: 'Données invalides ou CGU non acceptées' })
|
||||||
@ApiResponse({ status: 409, description: 'Email déjà utilisé' })
|
@ApiResponse({ status: 409, description: 'Email déjà utilisé' })
|
||||||
async inscrireAMComplet(@Body() dto: RegisterAMCompletDto) {
|
async inscrireAMComplet(@Body() dto: RegisterAMCompletDto): Promise<RegisterAmResponseDto> {
|
||||||
return this.authService.inscrireAMComplet(dto);
|
return this.authService.inscrireAMComplet(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -376,14 +376,14 @@ export class AuthService {
|
|||||||
const numeroDossier = resultat.parent1.numero_dossier ?? '';
|
const numeroDossier = resultat.parent1.numero_dossier ?? '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.mailService.sendParentRegistrationPendingEmail(
|
await this.mailService.sendRegistrationPendingEmail(
|
||||||
resultat.parent1.email,
|
resultat.parent1.email,
|
||||||
resultat.parent1.prenom ?? '',
|
resultat.parent1.prenom ?? '',
|
||||||
resultat.parent1.nom ?? '',
|
resultat.parent1.nom ?? '',
|
||||||
numeroDossier,
|
numeroDossier,
|
||||||
);
|
);
|
||||||
if (resultat.parent2) {
|
if (resultat.parent2) {
|
||||||
await this.mailService.sendParentRegistrationPendingEmail(
|
await this.mailService.sendRegistrationPendingEmail(
|
||||||
resultat.parent2.email,
|
resultat.parent2.email,
|
||||||
resultat.parent2.prenom ?? '',
|
resultat.parent2.prenom ?? '',
|
||||||
resultat.parent2.nom ?? '',
|
resultat.parent2.nom ?? '',
|
||||||
@ -418,6 +418,12 @@ export class AuthService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dto.places_disponibles > dto.capacite_accueil) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Le nombre de places disponibles ne peut pas dépasser la capacité d\'accueil.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const nirNormalized = (dto.nir || '').replace(/\s/g, '').toUpperCase();
|
const nirNormalized = (dto.nir || '').replace(/\s/g, '').toUpperCase();
|
||||||
const nirValidation = validateNir(nirNormalized, {
|
const nirValidation = validateNir(nirNormalized, {
|
||||||
dateNaissance: dto.date_naissance,
|
dateNaissance: dto.date_naissance,
|
||||||
@ -465,8 +471,11 @@ export class AuthService {
|
|||||||
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken);
|
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken);
|
||||||
|
|
||||||
let urlPhoto: string | null = null;
|
let urlPhoto: string | null = null;
|
||||||
if (dto.photo_base64 && dto.photo_filename) {
|
const photoB64 = (dto.photo_base64 ?? '').trim();
|
||||||
urlPhoto = await this.sauvegarderPhotoDepuisBase64(dto.photo_base64, dto.photo_filename);
|
if (photoB64) {
|
||||||
|
const nomFichier =
|
||||||
|
(dto.photo_filename ?? '').trim() || 'photo_am.jpg';
|
||||||
|
urlPhoto = await this.sauvegarderPhotoDepuisBase64(photoB64, nomFichier);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateConsentementPhoto =
|
const dateConsentementPhoto =
|
||||||
@ -493,6 +502,8 @@ export class AuthService {
|
|||||||
consentement_photo: dto.consentement_photo,
|
consentement_photo: dto.consentement_photo,
|
||||||
date_consentement_photo: dateConsentementPhoto,
|
date_consentement_photo: dateConsentementPhoto,
|
||||||
date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined,
|
date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined,
|
||||||
|
lieu_naissance_ville: dto.lieu_naissance_ville,
|
||||||
|
lieu_naissance_pays: dto.lieu_naissance_pays,
|
||||||
numero_dossier: numeroDossier,
|
numero_dossier: numeroDossier,
|
||||||
});
|
});
|
||||||
const userEnregistre = await manager.save(Users, user);
|
const userEnregistre = await manager.save(Users, user);
|
||||||
@ -503,6 +514,7 @@ export class AuthService {
|
|||||||
approval_number: dto.numero_agrement,
|
approval_number: dto.numero_agrement,
|
||||||
nir: nirNormalized,
|
nir: nirNormalized,
|
||||||
max_children: dto.capacite_accueil,
|
max_children: dto.capacite_accueil,
|
||||||
|
places_available: dto.places_disponibles,
|
||||||
biography: dto.biographie,
|
biography: dto.biographie,
|
||||||
residence_city: dto.ville ?? undefined,
|
residence_city: dto.ville ?? undefined,
|
||||||
agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined,
|
agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined,
|
||||||
@ -520,11 +532,28 @@ export class AuthService {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const numeroDossier = resultat.user.numero_dossier ?? '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.mailService.sendRegistrationPendingEmail(
|
||||||
|
resultat.user.email,
|
||||||
|
resultat.user.prenom ?? '',
|
||||||
|
resultat.user.nom ?? '',
|
||||||
|
numeroDossier,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
"[inscrireAMComplet] Échec envoi email d'accusé de réception (inscription conservée)",
|
||||||
|
err instanceof Error ? err.stack : String(err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message:
|
message:
|
||||||
'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
|
'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
|
||||||
user_id: resultat.user.id,
|
user_id: resultat.user.id,
|
||||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||||
|
numero_dossier: numeroDossier,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsEmail,
|
IsEmail,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
@ -76,7 +77,12 @@ export class RegisterAMCompletDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
photo_base64?: string;
|
photo_base64?: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'photo_profil.jpg', required: false })
|
@ApiProperty({
|
||||||
|
example: 'photo_profil.jpg',
|
||||||
|
required: false,
|
||||||
|
description:
|
||||||
|
'Nom du fichier photo (optionnel si photo_base64 est fourni ; défaut serveur : photo_am.jpg). L’extension réelle du fichier stocké suit le type MIME du data-URL.',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
photo_filename?: string;
|
photo_filename?: string;
|
||||||
@ -91,17 +97,21 @@ export class RegisterAMCompletDto {
|
|||||||
@IsDateString()
|
@IsDateString()
|
||||||
date_naissance?: string;
|
date_naissance?: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'Paris', required: false, description: 'Ville de naissance' })
|
@ApiProperty({ example: 'Paris', description: 'Ville de naissance (obligatoire)' })
|
||||||
@IsOptional()
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: 'La ville de naissance est requise' })
|
||||||
|
@MinLength(2, { message: 'La ville de naissance doit contenir au moins 2 caractères' })
|
||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
lieu_naissance_ville?: string;
|
lieu_naissance_ville: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'France', required: false, description: 'Pays de naissance' })
|
@ApiProperty({ example: 'France', description: 'Pays de naissance (obligatoire)' })
|
||||||
@IsOptional()
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: 'Le pays de naissance est requis' })
|
||||||
|
@MinLength(2, { message: 'Le pays de naissance doit contenir au moins 2 caractères' })
|
||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
lieu_naissance_pays?: string;
|
lieu_naissance_pays: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '123456789012345', description: 'NIR 15 caractères (chiffres, ou 2A/2B pour la Corse)' })
|
@ApiProperty({ example: '123456789012345', description: 'NIR 15 caractères (chiffres, ou 2A/2B pour la Corse)' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -128,6 +138,17 @@ export class RegisterAMCompletDto {
|
|||||||
@Max(10, { message: 'La capacité ne peut pas dépasser 10' })
|
@Max(10, { message: 'La capacité ne peut pas dépasser 10' })
|
||||||
capacite_accueil: number;
|
capacite_accueil: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: 2,
|
||||||
|
description: 'Nombre de places libres actuellement (≤ capacité d\'accueil)',
|
||||||
|
minimum: 0,
|
||||||
|
maximum: 10,
|
||||||
|
})
|
||||||
|
@IsInt()
|
||||||
|
@Min(0, { message: 'Les places disponibles ne peuvent pas être négatives' })
|
||||||
|
@Max(10, { message: 'Les places disponibles ne peuvent pas dépasser 10' })
|
||||||
|
places_disponibles: number;
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// ÉTAPE 3 : PRÉSENTATION (Optionnel)
|
// ÉTAPE 3 : PRÉSENTATION (Optionnel)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
17
backend/src/routes/auth/dto/register-am-response.dto.ts
Normal file
17
backend/src/routes/auth/dto/register-am-response.dto.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||||
|
|
||||||
|
/** Réponse 201 POST /auth/register/am (alignée sur les champs clés de /auth/register/parent). */
|
||||||
|
export class RegisterAmResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
user_id: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: StatutUtilisateurType, example: StatutUtilisateurType.EN_ATTENTE })
|
||||||
|
statut: StatutUtilisateurType;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2026-000015', description: 'Numéro de dossier attribué à l’inscription' })
|
||||||
|
numero_dossier: string;
|
||||||
|
}
|
||||||
@ -62,7 +62,23 @@ export class DossiersService {
|
|||||||
throw new NotFoundException('Aucun dossier trouvé pour ce numéro.');
|
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 {
|
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;
|
||||||
|
lieu_naissance_ville?: string;
|
||||||
|
lieu_naissance_pays?: string;
|
||||||
|
photo_url?: string;
|
||||||
|
consentement_photo?: boolean;
|
||||||
|
statut: any;
|
||||||
|
}): DossierAmUserDto {
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@ -74,7 +90,10 @@ export class DossiersService {
|
|||||||
code_postal: user.code_postal,
|
code_postal: user.code_postal,
|
||||||
profession: user.profession,
|
profession: user.profession,
|
||||||
date_naissance: user.date_naissance,
|
date_naissance: user.date_naissance,
|
||||||
|
lieu_naissance_ville: user.lieu_naissance_ville,
|
||||||
|
lieu_naissance_pays: user.lieu_naissance_pays,
|
||||||
photo_url: user.photo_url,
|
photo_url: user.photo_url,
|
||||||
|
consentement_photo: user.consentement_photo,
|
||||||
statut: user.statut,
|
statut: user.statut,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,8 +23,14 @@ export class DossierAmUserDto {
|
|||||||
profession?: string;
|
profession?: string;
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
date_naissance?: Date;
|
date_naissance?: Date;
|
||||||
|
@ApiProperty({ required: false, description: 'Ville de naissance' })
|
||||||
|
lieu_naissance_ville?: string;
|
||||||
|
@ApiProperty({ required: false, description: 'Pays de naissance' })
|
||||||
|
lieu_naissance_pays?: string;
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
photo_url?: string;
|
photo_url?: string;
|
||||||
|
@ApiProperty({ required: false, description: 'Consentement utilisation photo' })
|
||||||
|
consentement_photo?: boolean;
|
||||||
@ApiProperty({ enum: StatutUtilisateurType })
|
@ApiProperty({ enum: StatutUtilisateurType })
|
||||||
statut: StatutUtilisateurType;
|
statut: StatutUtilisateurType;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -55,6 +55,8 @@ CREATE TABLE utilisateurs (
|
|||||||
telephone VARCHAR(20), -- Unifié (mobile privilégié)
|
telephone VARCHAR(20), -- Unifié (mobile privilégié)
|
||||||
adresse TEXT,
|
adresse TEXT,
|
||||||
date_naissance DATE,
|
date_naissance DATE,
|
||||||
|
lieu_naissance_ville VARCHAR(100),
|
||||||
|
lieu_naissance_pays VARCHAR(100),
|
||||||
photo_url TEXT, -- Obligatoire pour AM, non utilisé pour parents
|
photo_url TEXT, -- Obligatoire pour AM, non utilisé pour parents
|
||||||
consentement_photo BOOLEAN DEFAULT false,
|
consentement_photo BOOLEAN DEFAULT false,
|
||||||
date_consentement_photo TIMESTAMPTZ,
|
date_consentement_photo TIMESTAMPTZ,
|
||||||
@ -125,6 +127,33 @@ CREATE TABLE enfants_parents (
|
|||||||
PRIMARY KEY (id_parent, id_enfant)
|
PRIMARY KEY (id_parent, id_enfant)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- ==========================================================
|
||||||
|
-- Table : dossier_famille (inscription parent, schéma simplifié — ticket #119)
|
||||||
|
-- ==========================================================
|
||||||
|
CREATE TABLE 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,
|
||||||
|
statut statut_dossier_type NOT NULL DEFAULT 'envoye',
|
||||||
|
cree_le TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
modifie_le TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_dossier_famille_numero ON dossier_famille(numero_dossier);
|
||||||
|
CREATE INDEX idx_dossier_famille_id_parent ON dossier_famille(id_parent);
|
||||||
|
|
||||||
|
-- ==========================================================
|
||||||
|
-- Table : dossier_famille_enfants
|
||||||
|
-- ==========================================================
|
||||||
|
CREATE TABLE 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 idx_dossier_famille_enfants_enfant ON dossier_famille_enfants(id_enfant);
|
||||||
|
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
-- Table : dossiers
|
-- Table : dossiers
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
@ -376,6 +405,10 @@ ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise VARCHAR(255) NUL
|
|||||||
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL;
|
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL;
|
||||||
CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise ON utilisateurs(token_reprise) WHERE token_reprise IS NOT NULL;
|
CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise ON utilisateurs(token_reprise) WHERE token_reprise IS NOT NULL;
|
||||||
|
|
||||||
|
-- Lieu de naissance (aligné CREATE TABLE utilisateurs — idempotent si colonnes déjà présentes)
|
||||||
|
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_ville VARCHAR(100) NULL;
|
||||||
|
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_pays VARCHAR(100) NULL;
|
||||||
|
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
-- Seed : Documents légaux génériques v1
|
-- Seed : Documents légaux génériques v1
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
|
|||||||
4
database/migrations/2026_lieu_naissance_utilisateurs.sql
Normal file
4
database/migrations/2026_lieu_naissance_utilisateurs.sql
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
-- Lieu de naissance (inscription AM, affichage dossier / validation). Nullable pour l'historique.
|
||||||
|
ALTER TABLE utilisateurs
|
||||||
|
ADD COLUMN IF NOT EXISTS lieu_naissance_ville VARCHAR(100) NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS lieu_naissance_pays VARCHAR(100) NULL;
|
||||||
@ -5,6 +5,7 @@
|
|||||||
-- NIR : numéros de test (non réels), cohérents avec les données (date naissance, genre).
|
-- NIR : numéros de test (non réels), cohérents avec les données (date naissance, genre).
|
||||||
-- - Marie Dubois : née en Corse à Ajaccio → NIR 2A (test exception Corse).
|
-- - Marie Dubois : née en Corse à Ajaccio → NIR 2A (test exception Corse).
|
||||||
-- - Fatima El Mansouri : née à l'étranger → NIR 99.
|
-- - Fatima El Mansouri : née à l'étranger → NIR 99.
|
||||||
|
-- Même jeu : docs/test-data/README.md, utilisateurs-test.csv/.html, tests/scripts/register-am-*-test.mjs
|
||||||
-- À exécuter après BDD.sql (init DB)
|
-- À exécuter après BDD.sql (init DB)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|
||||||
|
|||||||
@ -101,6 +101,7 @@
|
|||||||
| 116 | Rattachement parent – frontend | Ouvert |
|
| 116 | Rattachement parent – frontend | Ouvert |
|
||||||
| 117 | Évolution du cahier des charges | Ouvert |
|
| 117 | Évolution du cahier des charges | Ouvert |
|
||||||
| 119 | Endpoint unifié GET /dossiers/:numeroDossier (AM ou famille) | ✅ Fermé |
|
| 119 | Endpoint unifié GET /dossiers/:numeroDossier (AM ou famille) | ✅ Fermé |
|
||||||
|
| 120 | [Full-stack] Inscription AM — photo, API et UX alignés sur les parents (suite #91) | ✅ Fermé |
|
||||||
|
|
||||||
*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*
|
||||||
|
|
||||||
|
|||||||
44
docs/TEMP_FRONT_alignement_AM.md
Normal file
44
docs/TEMP_FRONT_alignement_AM.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# TEMP — Alignement front / API (inscription AM & validation gestionnaire)
|
||||||
|
|
||||||
|
> **Fichier temporaire** : à supprimer ou renommer une fois le front livré.
|
||||||
|
|
||||||
|
Ce document décrit les changements **côté API** et ce que **Flutter** doit faire pour rester aligné. Aucune modification front n’a été faite dans le chantier backend associé.
|
||||||
|
|
||||||
|
## 1. `POST /auth/register/am` — lieu de naissance obligatoire
|
||||||
|
|
||||||
|
- **`lieu_naissance_ville`** et **`lieu_naissance_pays`** sont **obligatoires** (non vides après trim, min. **2 caractères** chacun, max 100).
|
||||||
|
- Réponses **400** si manquants ou invalides (messages class-validator).
|
||||||
|
- **Action front** : champs obligatoires dans le parcours AM (étapes identité / naissance), validation UI avant envoi ; afficher les erreurs renvoyées par l’API.
|
||||||
|
|
||||||
|
## 2. Réponse `GET /dossiers/:numeroDossier` (type `am`)
|
||||||
|
|
||||||
|
Sous `dossier.user`, l’API peut inclure :
|
||||||
|
|
||||||
|
| Clé JSON | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `date_naissance` | Date (si renseignée à l’inscription) |
|
||||||
|
| `lieu_naissance_ville` | Ville de naissance |
|
||||||
|
| `lieu_naissance_pays` | Pays de naissance |
|
||||||
|
| `consentement_photo` | Booléen (exposé dans `dossier.user`) |
|
||||||
|
|
||||||
|
À la **racine** de `dossier` (objet AM), champs déjà renvoyés par le backend : `disponible`, `annees_experience`, `specialite`, `nb_max_enfants`, `place_disponible`, etc.
|
||||||
|
|
||||||
|
**Action front** :
|
||||||
|
|
||||||
|
- Étendre **`AppUser.fromJson` / `toJson`** (`lib/models/user.dart`) pour mapper `date_naissance`, `lieu_naissance_ville`, `lieu_naissance_pays`, `consentement_photo`.
|
||||||
|
- Étendre **`DossierAM.fromJson`** (`lib/models/dossier_unifie.dart`) pour parser `disponible`, `annees_experience`, `specialite` à la racine du dossier (noms snake_case comme dans la réponse JSON Nest).
|
||||||
|
|
||||||
|
## 3. `ValidationAmWizard` (admin)
|
||||||
|
|
||||||
|
Afficher pour cohérence avec le formulaire d’inscription :
|
||||||
|
|
||||||
|
- **Informations personnelles** : date de naissance, ville / pays de naissance, consentement photo (Oui/Non).
|
||||||
|
- **Informations professionnelles** : disponibilité, années d’expérience, spécialité (afficher « – » si `null`).
|
||||||
|
|
||||||
|
## 4. `place_disponible` à l’inscription
|
||||||
|
|
||||||
|
- Le backend initialise **`place_disponible`** sur la fiche AM à la **même valeur** que **`capacite_accueil`** à la création. Le wizard peut donc afficher une valeur cohérente avec la capacité sans champ séparé côté public.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Dernière mise à jour : alignement backend branche `feature/120-inscription-am-photo-backend`.*
|
||||||
@ -16,6 +16,8 @@ Fichier CSV contenant les utilisateurs de test pour valider le workflow de créa
|
|||||||
|
|
||||||
## 👥 Utilisateurs de test
|
## 👥 Utilisateurs de test
|
||||||
|
|
||||||
|
**Source de vérité (identités, NIR, agréments, adresses)** : `database/seed/03_seed_test_data.sql`, complété par `utilisateurs-test.csv` et ce README. Les scripts Node `tests/scripts/register-am-*-test.mjs` reprennent les mêmes valeurs.
|
||||||
|
|
||||||
### 1. Administrateur
|
### 1. Administrateur
|
||||||
|
|
||||||
| Nom | Prénom | Email | Téléphone | Mobile |
|
| Nom | Prénom | Email | Téléphone | Mobile |
|
||||||
@ -50,6 +52,13 @@ Fichier CSV contenant les utilisateurs de test pour valider le workflow de créa
|
|||||||
**Spécialité** : Bébés 0-18 mois
|
**Spécialité** : Bébés 0-18 mois
|
||||||
**Agrément** : 4 enfants
|
**Agrément** : 4 enfants
|
||||||
**Places disponibles** : 2
|
**Places disponibles** : 2
|
||||||
|
**Date de naissance** : 1980-06-08
|
||||||
|
**Lieu de naissance (inscription)** : Ajaccio (Corse, France) — NIR de test avec code département **2A**
|
||||||
|
**NIR (fictif, clé valide)** : `280062A00100191`
|
||||||
|
**N° d'agrément** : `AGR-2019-095001`
|
||||||
|
**Date d'agrément** : 2019-09-01
|
||||||
|
**Adresse** : 25 Rue de la République, 95870 Bezons
|
||||||
|
**Script d'inscription** : `tests/scripts/register-am-dubois-test.mjs`
|
||||||
|
|
||||||
#### Fatima EL MANSOURI
|
#### Fatima EL MANSOURI
|
||||||
|
|
||||||
@ -61,6 +70,13 @@ Fichier CSV contenant les utilisateurs de test pour valider le workflow de créa
|
|||||||
**Spécialité** : 1-3 ans
|
**Spécialité** : 1-3 ans
|
||||||
**Agrément** : 3 enfants
|
**Agrément** : 3 enfants
|
||||||
**Places disponibles** : 1
|
**Places disponibles** : 1
|
||||||
|
**Date de naissance** : 1975-11-12
|
||||||
|
**Lieu de naissance (inscription)** : exemple Casablanca (Maroc) — NIR de test avec code **99** (naissance à l'étranger)
|
||||||
|
**NIR (fictif, clé valide)** : `275119900100102`
|
||||||
|
**N° d'agrément** : `AGR-2017-095002`
|
||||||
|
**Date d'agrément** : 2017-06-15
|
||||||
|
**Adresse** : 17 Boulevard Aristide Briand, 95870 Bezons
|
||||||
|
**Script d'inscription** : `tests/scripts/register-am-mansouri-test.mjs`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -160,20 +176,14 @@ POST /api/v1/gestionnaires
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Scénario 2 : Inscription assistante maternelle
|
#### Scénario 2 : Inscription assistante maternelle (complète)
|
||||||
|
|
||||||
```typescript
|
Inscription en un flux : `POST /api/v1/auth/register/am` (identité, NIR, agrément, photo optionnelle, CGU). Voir le DTO backend `RegisterAMCompletDto`.
|
||||||
// Marie DUBOIS s'inscrit
|
|
||||||
POST /api/v1/auth/register
|
```bash
|
||||||
{
|
# Jeux alignés sur le seed (mêmes NIR / dates / agréments que 03_seed_test_data.sql) :
|
||||||
"email": "marie.dubois@ptits-pas.fr",
|
node tests/scripts/register-am-dubois-test.mjs [BASE_URL]
|
||||||
"password": "Test1234!",
|
node tests/scripts/register-am-mansouri-test.mjs [BASE_URL]
|
||||||
"prenom": "Marie",
|
|
||||||
"nom": "DUBOIS",
|
|
||||||
"telephone": "01 39 98 67 89",
|
|
||||||
"mobile": "06 96 34 56 78",
|
|
||||||
"role": "assistante_maternelle"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Scénario 3 : Inscription parent
|
#### Scénario 3 : Inscription parent
|
||||||
|
|||||||
@ -177,10 +177,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="info-section">
|
<div class="info-section">
|
||||||
<div class="info-title">Profession</div>
|
<div class="info-title">Profession</div>
|
||||||
<p><strong>Agrément :</strong> 4 enfants max</p>
|
<p><strong>Agrément :</strong> 4 enfants max (n° AGR-2019-095001, depuis le 01/09/2019)</p>
|
||||||
<p><strong>Expérience :</strong> 12 ans</p>
|
<p><strong>Expérience :</strong> 12 ans</p>
|
||||||
<p><strong>Spécialité :</strong> Bébés 0-18 mois</p>
|
<p><strong>Spécialité :</strong> Bébés 0-18 mois</p>
|
||||||
<p><strong>Places disponibles :</strong> 2</p>
|
<p><strong>Places disponibles :</strong> 2</p>
|
||||||
|
<p><strong>NIR (test, fictif) :</strong> 280062A00100191</p>
|
||||||
|
<p><strong>Lieu de naissance :</strong> Ajaccio (Corse)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -207,10 +209,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="info-section">
|
<div class="info-section">
|
||||||
<div class="info-title">Profession</div>
|
<div class="info-title">Profession</div>
|
||||||
<p><strong>Agrément :</strong> 3 enfants max</p>
|
<p><strong>Agrément :</strong> 3 enfants max (n° AGR-2017-095002, depuis le 15/06/2017)</p>
|
||||||
<p><strong>Expérience :</strong> 15 ans</p>
|
<p><strong>Expérience :</strong> 15 ans</p>
|
||||||
<p><strong>Spécialité :</strong> Enfants 1-3 ans</p>
|
<p><strong>Spécialité :</strong> Enfants 1-3 ans</p>
|
||||||
<p><strong>Places disponibles :</strong> 1</p>
|
<p><strong>Places disponibles :</strong> 1</p>
|
||||||
|
<p><strong>NIR (test, fictif) :</strong> 275119900100102</p>
|
||||||
|
<p><strong>Lieu de naissance :</strong> à l'étranger (ex. Casablanca, Maroc — inscription)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// Accueil simultané : plafond légal courant en France pour une AM agréée (enfants de moins de 3 ans).
|
||||||
|
const int kAmCapaciteAccueilMax = 4;
|
||||||
|
|
||||||
class AmRegistrationData extends ChangeNotifier {
|
class AmRegistrationData extends ChangeNotifier {
|
||||||
// Step 1: Identity Info
|
// Step 1: Identity Info
|
||||||
String firstName = '';
|
String firstName = '';
|
||||||
@ -14,7 +17,9 @@ class AmRegistrationData extends ChangeNotifier {
|
|||||||
// bool photoConsent = false; // Déplacé ou géré à l'étape 2
|
// bool photoConsent = false; // Déplacé ou géré à l'étape 2
|
||||||
|
|
||||||
// Step 2: Professional Info
|
// Step 2: Professional Info
|
||||||
String? photoPath; // Ajouté pour l'étape 2
|
String? photoPath; // Chemin fichier local (hors web) si pas d’octets en mémoire
|
||||||
|
Uint8List? photoBytes; // Galerie / web (ImagePicker)
|
||||||
|
String? photoFilename; // Nom pour l’API (ex. image.jpg)
|
||||||
bool photoConsent = false; // Ajouté pour l'étape 2
|
bool photoConsent = false; // Ajouté pour l'étape 2
|
||||||
DateTime? dateOfBirth;
|
DateTime? dateOfBirth;
|
||||||
String birthCity = ''; // Nouveau
|
String birthCity = ''; // Nouveau
|
||||||
@ -22,7 +27,11 @@ class AmRegistrationData extends ChangeNotifier {
|
|||||||
// String placeOfBirth = ''; // Remplacé par birthCity et birthCountry
|
// String placeOfBirth = ''; // Remplacé par birthCity et birthCountry
|
||||||
String nir = ''; // Numéro de Sécurité Sociale
|
String nir = ''; // Numéro de Sécurité Sociale
|
||||||
String agrementNumber = ''; // Numéro d'agrément
|
String agrementNumber = ''; // Numéro d'agrément
|
||||||
|
/// Date d'obtention de l'agrément — obligatoire (API `date_agrement`).
|
||||||
|
DateTime? agreementDate;
|
||||||
int? capacity; // Number of children the AM can look after
|
int? capacity; // Number of children the AM can look after
|
||||||
|
/// Places libres actuellement (0 ≤ valeur ≤ capacité) — API `places_disponibles`.
|
||||||
|
int? placesAvailable;
|
||||||
|
|
||||||
// Step 3: Presentation & CGU
|
// Step 3: Presentation & CGU
|
||||||
String presentationText = '';
|
String presentationText = '';
|
||||||
@ -57,6 +66,8 @@ class AmRegistrationData extends ChangeNotifier {
|
|||||||
|
|
||||||
void updateProfessionalInfo({
|
void updateProfessionalInfo({
|
||||||
String? photoPath,
|
String? photoPath,
|
||||||
|
Uint8List? photoBytes,
|
||||||
|
String? photoFilename,
|
||||||
bool? photoConsent,
|
bool? photoConsent,
|
||||||
DateTime? dateOfBirth,
|
DateTime? dateOfBirth,
|
||||||
String? birthCity, // Nouveau
|
String? birthCity, // Nouveau
|
||||||
@ -64,12 +75,13 @@ class AmRegistrationData extends ChangeNotifier {
|
|||||||
// String? placeOfBirth, // Remplacé
|
// String? placeOfBirth, // Remplacé
|
||||||
String? nir,
|
String? nir,
|
||||||
String? agrementNumber,
|
String? agrementNumber,
|
||||||
|
DateTime? agreementDate,
|
||||||
int? capacity,
|
int? capacity,
|
||||||
|
int? placesAvailable,
|
||||||
}) {
|
}) {
|
||||||
// Allow setting photoPath to null explicitly
|
this.photoPath = photoPath;
|
||||||
if (photoPath != null || this.photoPath != null) {
|
this.photoBytes = photoBytes;
|
||||||
this.photoPath = photoPath;
|
this.photoFilename = photoFilename;
|
||||||
}
|
|
||||||
this.photoConsent = photoConsent ?? this.photoConsent;
|
this.photoConsent = photoConsent ?? this.photoConsent;
|
||||||
this.dateOfBirth = dateOfBirth ?? this.dateOfBirth;
|
this.dateOfBirth = dateOfBirth ?? this.dateOfBirth;
|
||||||
this.birthCity = birthCity ?? this.birthCity; // Nouveau
|
this.birthCity = birthCity ?? this.birthCity; // Nouveau
|
||||||
@ -77,7 +89,9 @@ class AmRegistrationData extends ChangeNotifier {
|
|||||||
// this.placeOfBirth = placeOfBirth ?? this.placeOfBirth; // Remplacé
|
// this.placeOfBirth = placeOfBirth ?? this.placeOfBirth; // Remplacé
|
||||||
this.nir = nir ?? this.nir;
|
this.nir = nir ?? this.nir;
|
||||||
this.agrementNumber = agrementNumber ?? this.agrementNumber;
|
this.agrementNumber = agrementNumber ?? this.agrementNumber;
|
||||||
|
this.agreementDate = agreementDate ?? this.agreementDate;
|
||||||
this.capacity = capacity ?? this.capacity;
|
this.capacity = capacity ?? this.capacity;
|
||||||
|
this.placesAvailable = placesAvailable ?? this.placesAvailable;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,27 +106,37 @@ class AmRegistrationData extends ChangeNotifier {
|
|||||||
|
|
||||||
// --- Getters for validation or display ---
|
// --- Getters for validation or display ---
|
||||||
bool get isStep1Complete =>
|
bool get isStep1Complete =>
|
||||||
firstName.isNotEmpty &&
|
firstName.trim().length >= 2 &&
|
||||||
lastName.isNotEmpty &&
|
lastName.trim().length >= 2 &&
|
||||||
streetAddress.isNotEmpty && // Modifié
|
streetAddress.isNotEmpty &&
|
||||||
postalCode.isNotEmpty && // Nouveau
|
postalCode.isNotEmpty &&
|
||||||
city.isNotEmpty && // Nouveau
|
city.isNotEmpty &&
|
||||||
phone.isNotEmpty &&
|
phone.isNotEmpty &&
|
||||||
email.isNotEmpty;
|
email.isNotEmpty;
|
||||||
// password n'est pas requis à l'inscription (défini après validation par lien email)
|
// password n'est pas requis à l'inscription (défini après validation par lien email)
|
||||||
|
|
||||||
|
/// Photo réelle (pas seulement un placeholder asset).
|
||||||
|
bool get _hasUserPhoto =>
|
||||||
|
(photoBytes != null && photoBytes!.isNotEmpty) ||
|
||||||
|
(photoPath != null &&
|
||||||
|
photoPath!.isNotEmpty &&
|
||||||
|
!photoPath!.startsWith('assets/'));
|
||||||
|
|
||||||
bool get isStep2Complete =>
|
bool get isStep2Complete =>
|
||||||
// photoConsent is mandatory if a photo is system-required, otherwise optional.
|
_hasUserPhoto &&
|
||||||
// For now, let's assume if photoPath is present, consent should ideally be true.
|
photoConsent == true &&
|
||||||
// Or, make consent always mandatory if photo section exists.
|
|
||||||
// Based on new mockup, photo is present, so consent might be implicitly or explicitly needed.
|
|
||||||
(photoPath != null ? photoConsent == true : true) && // Ajuster selon la logique de consentement désirée
|
|
||||||
dateOfBirth != null &&
|
dateOfBirth != null &&
|
||||||
birthCity.isNotEmpty &&
|
birthCity.trim().length >= 2 &&
|
||||||
birthCountry.isNotEmpty &&
|
birthCountry.trim().length >= 2 &&
|
||||||
nir.isNotEmpty && // Basic check, could add validation
|
nir.isNotEmpty &&
|
||||||
agrementNumber.isNotEmpty &&
|
agrementNumber.isNotEmpty &&
|
||||||
capacity != null && capacity! > 0;
|
agreementDate != null &&
|
||||||
|
capacity != null &&
|
||||||
|
capacity! >= 1 &&
|
||||||
|
capacity! <= kAmCapaciteAccueilMax &&
|
||||||
|
placesAvailable != null &&
|
||||||
|
placesAvailable! >= 0 &&
|
||||||
|
placesAvailable! <= capacity!;
|
||||||
|
|
||||||
bool get isStep3Complete =>
|
bool get isStep3Complete =>
|
||||||
// presentationText is optional as per CDC (message au gestionnaire)
|
// presentationText is optional as per CDC (message au gestionnaire)
|
||||||
@ -129,7 +153,7 @@ class AmRegistrationData extends ChangeNotifier {
|
|||||||
'phone: $phone, email: $email, '
|
'phone: $phone, email: $email, '
|
||||||
// 'photoPath: $photoPath, photoConsent: $photoConsent, ' // Commenté car déplacé/modifié
|
// 'photoPath: $photoPath, photoConsent: $photoConsent, ' // Commenté car déplacé/modifié
|
||||||
'dateOfBirth: $dateOfBirth, birthCity: $birthCity, birthCountry: $birthCountry, '
|
'dateOfBirth: $dateOfBirth, birthCity: $birthCity, birthCountry: $birthCountry, '
|
||||||
'nir: $nir, agrementNumber: $agrementNumber, capacity: $capacity, '
|
'nir: $nir, agrementNumber: $agrementNumber, agreementDate: $agreementDate, capacity: $capacity, placesAvailable: $placesAvailable, '
|
||||||
'photoPath (step2): $photoPath, photoConsent (step2): $photoConsent, '
|
'photoPath (step2): $photoPath, photoConsent (step2): $photoConsent, '
|
||||||
'presentationText: $presentationText, cguAccepted: $cguAccepted)';
|
'presentationText: $presentationText, cguAccepted: $cguAccepted)';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
|
||||||
/// Réponse unifiée GET /dossiers/:numeroDossier. Ticket #119, #107.
|
/// Réponse unifiée GET /dossiers/:numeroDossier. Ticket #119, #107.
|
||||||
@ -213,14 +212,6 @@ class EnfantDossier {
|
|||||||
factory EnfantDossier.fromJson(Map<String, dynamic> json) {
|
factory EnfantDossier.fromJson(Map<String, dynamic> json) {
|
||||||
final rawPhoto = json['photo_url'] ?? json['photoUrl'];
|
final rawPhoto = json['photo_url'] ?? json['photoUrl'];
|
||||||
final resolvedPhoto = _optionalPhotoUrl(rawPhoto);
|
final resolvedPhoto = _optionalPhotoUrl(rawPhoto);
|
||||||
if (kDebugMode) {
|
|
||||||
debugPrint(
|
|
||||||
'[PetitsPas/dossier] EnfantDossier.fromJson id=${json['id']} '
|
|
||||||
'prénom=${json['first_name'] ?? json['prenom']} | '
|
|
||||||
'photo_url brute=$rawPhoto (${rawPhoto?.runtimeType}) → '
|
|
||||||
'résolu=${resolvedPhoto ?? "∅"}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return EnfantDossier(
|
return EnfantDossier(
|
||||||
id: json['id']?.toString() ?? '',
|
id: json['id']?.toString() ?? '',
|
||||||
firstName: (json['first_name'] ?? json['prenom'])?.toString(),
|
firstName: (json['first_name'] ?? json['prenom'])?.toString(),
|
||||||
|
|||||||
@ -16,6 +16,9 @@ class AppUser {
|
|||||||
final String? relaisId;
|
final String? relaisId;
|
||||||
final String? relaisNom;
|
final String? relaisNom;
|
||||||
final String? numeroDossier;
|
final String? numeroDossier;
|
||||||
|
final String? dateNaissance;
|
||||||
|
final String? lieuNaissanceVille;
|
||||||
|
final String? lieuNaissancePays;
|
||||||
|
|
||||||
AppUser({
|
AppUser({
|
||||||
required this.id,
|
required this.id,
|
||||||
@ -35,6 +38,9 @@ class AppUser {
|
|||||||
this.relaisId,
|
this.relaisId,
|
||||||
this.relaisNom,
|
this.relaisNom,
|
||||||
this.numeroDossier,
|
this.numeroDossier,
|
||||||
|
this.dateNaissance,
|
||||||
|
this.lieuNaissanceVille,
|
||||||
|
this.lieuNaissancePays,
|
||||||
});
|
});
|
||||||
|
|
||||||
static String _str(dynamic v) {
|
static String _str(dynamic v) {
|
||||||
@ -78,9 +84,24 @@ class AppUser {
|
|||||||
?.toString(),
|
?.toString(),
|
||||||
relaisNom: relaisMap['nom']?.toString(),
|
relaisNom: relaisMap['nom']?.toString(),
|
||||||
numeroDossier: json['numero_dossier'] is String ? json['numero_dossier'] as String : null,
|
numeroDossier: json['numero_dossier'] is String ? json['numero_dossier'] as String : null,
|
||||||
|
dateNaissance: _optionalDateString(json['date_naissance']),
|
||||||
|
lieuNaissanceVille: json['lieu_naissance_ville'] is String
|
||||||
|
? json['lieu_naissance_ville'] as String
|
||||||
|
: null,
|
||||||
|
lieuNaissancePays: json['lieu_naissance_pays'] is String
|
||||||
|
? json['lieu_naissance_pays'] as String
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String? _optionalDateString(dynamic v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v is String) return v.isEmpty ? null : v;
|
||||||
|
if (v is DateTime) return v.toIso8601String().split('T').first;
|
||||||
|
final s = v.toString();
|
||||||
|
return s.isEmpty ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return {
|
return {
|
||||||
'id': id,
|
'id': id,
|
||||||
@ -100,6 +121,9 @@ class AppUser {
|
|||||||
'relais_id': relaisId,
|
'relais_id': relaisId,
|
||||||
'relais_nom': relaisNom,
|
'relais_nom': relaisNom,
|
||||||
'numero_dossier': numeroDossier,
|
'numero_dossier': numeroDossier,
|
||||||
|
'date_naissance': dateNaissance,
|
||||||
|
'lieu_naissance_ville': lieuNaissanceVille,
|
||||||
|
'lieu_naissance_pays': lieuNaissancePays,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,7 @@ class AmRegisterStep1Screen extends StatelessWidget {
|
|||||||
title: 'Vos informations personnelles',
|
title: 'Vos informations personnelles',
|
||||||
cardColor: CardColorHorizontal.blue,
|
cardColor: CardColorHorizontal.blue,
|
||||||
initialData: initialData,
|
initialData: initialData,
|
||||||
|
minPersonNameLength: 2,
|
||||||
previousRoute: '/register-choice',
|
previousRoute: '/register-choice',
|
||||||
onSubmit: (data, {hasSecondPerson, sameAddress}) {
|
onSubmit: (data, {hasSecondPerson, sameAddress}) {
|
||||||
registrationData.updateIdentityInfo(
|
registrationData.updateIdentityInfo(
|
||||||
|
|||||||
@ -6,34 +6,26 @@ import '../../models/am_registration_data.dart';
|
|||||||
import '../../models/card_assets.dart';
|
import '../../models/card_assets.dart';
|
||||||
import '../../widgets/professional_info_form_screen.dart';
|
import '../../widgets/professional_info_form_screen.dart';
|
||||||
|
|
||||||
class AmRegisterStep2Screen extends StatefulWidget {
|
class AmRegisterStep2Screen extends StatelessWidget {
|
||||||
const AmRegisterStep2Screen({super.key});
|
const AmRegisterStep2Screen({super.key});
|
||||||
|
|
||||||
@override
|
|
||||||
State<AmRegisterStep2Screen> createState() => _AmRegisterStep2ScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AmRegisterStep2ScreenState extends State<AmRegisterStep2Screen> {
|
|
||||||
String? _photoPathFramework;
|
|
||||||
|
|
||||||
Future<void> _pickPhoto() async {
|
|
||||||
// TODO: brancher ImagePicker ; ne pas préremplir de chemin factice
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final registrationData = Provider.of<AmRegistrationData>(context, listen: false);
|
final registrationData = Provider.of<AmRegistrationData>(context, listen: false);
|
||||||
|
|
||||||
// Préparer les données initiales
|
final initialData = ProfessionalInfoData(
|
||||||
ProfessionalInfoData initialData = ProfessionalInfoData(
|
|
||||||
photoPath: registrationData.photoPath,
|
photoPath: registrationData.photoPath,
|
||||||
|
photoBytes: registrationData.photoBytes,
|
||||||
|
photoFilename: registrationData.photoFilename,
|
||||||
photoConsent: registrationData.photoConsent,
|
photoConsent: registrationData.photoConsent,
|
||||||
dateOfBirth: registrationData.dateOfBirth,
|
dateOfBirth: registrationData.dateOfBirth,
|
||||||
birthCity: registrationData.birthCity,
|
birthCity: registrationData.birthCity,
|
||||||
birthCountry: registrationData.birthCountry,
|
birthCountry: registrationData.birthCountry,
|
||||||
nir: registrationData.nir,
|
nir: registrationData.nir,
|
||||||
agrementNumber: registrationData.agrementNumber,
|
agrementNumber: registrationData.agrementNumber,
|
||||||
|
agreementDate: registrationData.agreementDate,
|
||||||
capacity: registrationData.capacity,
|
capacity: registrationData.capacity,
|
||||||
|
placesAvailable: registrationData.placesAvailable,
|
||||||
);
|
);
|
||||||
|
|
||||||
return ProfessionalInfoFormScreen(
|
return ProfessionalInfoFormScreen(
|
||||||
@ -42,17 +34,20 @@ class _AmRegisterStep2ScreenState extends State<AmRegisterStep2Screen> {
|
|||||||
cardColor: CardColorHorizontal.green,
|
cardColor: CardColorHorizontal.green,
|
||||||
initialData: initialData,
|
initialData: initialData,
|
||||||
previousRoute: '/am-register-step1',
|
previousRoute: '/am-register-step1',
|
||||||
onPickPhoto: _pickPhoto,
|
|
||||||
onSubmit: (data) {
|
onSubmit: (data) {
|
||||||
registrationData.updateProfessionalInfo(
|
registrationData.updateProfessionalInfo(
|
||||||
photoPath: _photoPathFramework ?? data.photoPath,
|
photoPath: data.photoPath,
|
||||||
|
photoBytes: data.photoBytes,
|
||||||
|
photoFilename: data.photoFilename,
|
||||||
photoConsent: data.photoConsent,
|
photoConsent: data.photoConsent,
|
||||||
dateOfBirth: data.dateOfBirth,
|
dateOfBirth: data.dateOfBirth,
|
||||||
birthCity: data.birthCity,
|
birthCity: data.birthCity,
|
||||||
birthCountry: data.birthCountry,
|
birthCountry: data.birthCountry,
|
||||||
nir: data.nir,
|
nir: data.nir,
|
||||||
agrementNumber: data.agrementNumber,
|
agrementNumber: data.agrementNumber,
|
||||||
|
agreementDate: data.agreementDate,
|
||||||
capacity: data.capacity,
|
capacity: data.capacity,
|
||||||
|
placesAvailable: data.placesAvailable,
|
||||||
);
|
);
|
||||||
context.go('/am-register-step3');
|
context.go('/am-register-step3');
|
||||||
},
|
},
|
||||||
|
|||||||
@ -27,6 +27,19 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
|
|||||||
|
|
||||||
Future<void> _submitAMRegistration(AmRegistrationData registrationData) async {
|
Future<void> _submitAMRegistration(AmRegistrationData registrationData) async {
|
||||||
if (_isSubmitting) return;
|
if (_isSubmitting) return;
|
||||||
|
if (!registrationData.isRegistrationComplete) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Le dossier est incomplet. Vérifiez chaque étape (photo, consentement, lieux de naissance, etc.).',
|
||||||
|
style: GoogleFonts.merienda(fontSize: 14),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.red.shade700,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState(() => _isSubmitting = true);
|
setState(() => _isSubmitting = true);
|
||||||
try {
|
try {
|
||||||
await AuthService.registerAM(registrationData);
|
await AuthService.registerAM(registrationData);
|
||||||
@ -196,14 +209,17 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
|
|||||||
title: 'Informations professionnelles',
|
title: 'Informations professionnelles',
|
||||||
cardColor: CardColorHorizontal.green,
|
cardColor: CardColorHorizontal.green,
|
||||||
initialData: ProfessionalInfoData(
|
initialData: ProfessionalInfoData(
|
||||||
// TODO: Gérer photoPath vs photoFile correctement
|
photoPath: data.photoPath,
|
||||||
photoPath: null, // Pas d'accès facile au fichier ici, on verra
|
photoBytes: data.photoBytes,
|
||||||
|
photoFilename: data.photoFilename,
|
||||||
dateOfBirth: data.dateOfBirth,
|
dateOfBirth: data.dateOfBirth,
|
||||||
birthCity: data.birthCity,
|
birthCity: data.birthCity,
|
||||||
birthCountry: data.birthCountry,
|
birthCountry: data.birthCountry,
|
||||||
nir: data.nir,
|
nir: data.nir,
|
||||||
agrementNumber: data.agrementNumber,
|
agrementNumber: data.agrementNumber,
|
||||||
|
agreementDate: data.agreementDate,
|
||||||
capacity: data.capacity,
|
capacity: data.capacity,
|
||||||
|
placesAvailable: data.placesAvailable,
|
||||||
photoConsent: data.photoConsent,
|
photoConsent: data.photoConsent,
|
||||||
),
|
),
|
||||||
onSubmit: (d) {},
|
onSubmit: (d) {},
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:p_tits_pas/config/env.dart';
|
import 'package:p_tits_pas/config/env.dart';
|
||||||
|
|
||||||
class ApiConfig {
|
class ApiConfig {
|
||||||
@ -21,26 +20,14 @@ class ApiConfig {
|
|||||||
/// On préfixe avec [baseUrl] (…/api/v1), pas seulement l’hôte : Traefik n’expose souvent que `/api`.
|
/// On préfixe avec [baseUrl] (…/api/v1), pas seulement l’hôte : Traefik n’expose souvent que `/api`.
|
||||||
static String absoluteMediaUrl(String? pathOrUrl) {
|
static String absoluteMediaUrl(String? pathOrUrl) {
|
||||||
if (pathOrUrl == null || pathOrUrl.trim().isEmpty) {
|
if (pathOrUrl == null || pathOrUrl.trim().isEmpty) {
|
||||||
if (kDebugMode) {
|
|
||||||
debugPrint('[PetitsPas/media] absoluteMediaUrl: entrée vide (null ou "")');
|
|
||||||
}
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
final u = pathOrUrl.trim();
|
final u = pathOrUrl.trim();
|
||||||
if (u.startsWith('http://') || u.startsWith('https://')) {
|
if (u.startsWith('http://') || u.startsWith('https://')) {
|
||||||
if (kDebugMode) {
|
|
||||||
debugPrint('[PetitsPas/media] absoluteMediaUrl: déjà absolu → $u');
|
|
||||||
}
|
|
||||||
return u;
|
return u;
|
||||||
}
|
}
|
||||||
final base = baseUrl.replaceAll(RegExp(r'/+$'), '');
|
final base = baseUrl.replaceAll(RegExp(r'/+$'), '');
|
||||||
final out = u.startsWith('/') ? '$base$u' : '$base/$u';
|
return u.startsWith('/') ? '$base$u' : '$base/$u';
|
||||||
if (kDebugMode) {
|
|
||||||
debugPrint(
|
|
||||||
'[PetitsPas/media] absoluteMediaUrl: base=$base | chemin brut="$u" → "$out"',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth endpoints
|
// Auth endpoints
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../models/user.dart';
|
import '../models/user.dart';
|
||||||
@ -13,6 +16,24 @@ import '../utils/nir_utils.dart';
|
|||||||
class AuthService {
|
class AuthService {
|
||||||
static const String _currentUserKey = 'current_user';
|
static const String _currentUserKey = 'current_user';
|
||||||
|
|
||||||
|
static String? _basenameFromPath(String path) {
|
||||||
|
final s = path.replaceAll(r'\', '/');
|
||||||
|
final i = s.lastIndexOf('/');
|
||||||
|
final name = i >= 0 ? s.substring(i + 1) : s;
|
||||||
|
return name.isEmpty ? null : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _imageMimeForBytes(Uint8List bytes) {
|
||||||
|
if (bytes.length >= 8 &&
|
||||||
|
bytes[0] == 0x89 &&
|
||||||
|
bytes[1] == 0x50 &&
|
||||||
|
bytes[2] == 0x4E &&
|
||||||
|
bytes[3] == 0x47) {
|
||||||
|
return 'image/png';
|
||||||
|
}
|
||||||
|
return 'image/jpeg';
|
||||||
|
}
|
||||||
|
|
||||||
/// Connexion de l'utilisateur
|
/// Connexion de l'utilisateur
|
||||||
/// Retourne l'utilisateur connecté avec le flag changement_mdp_obligatoire
|
/// Retourne l'utilisateur connecté avec le flag changement_mdp_obligatoire
|
||||||
static Future<AppUser> login(String email, String password) async {
|
static Future<AppUser> login(String email, String password) async {
|
||||||
@ -141,18 +162,49 @@ class AuthService {
|
|||||||
/// Inscription AM complète (POST /auth/register/am).
|
/// Inscription AM complète (POST /auth/register/am).
|
||||||
/// En cas de succès (201), aucune donnée utilisateur retournée ; rediriger vers login.
|
/// En cas de succès (201), aucune donnée utilisateur retournée ; rediriger vers login.
|
||||||
static Future<void> registerAM(AmRegistrationData data) async {
|
static Future<void> registerAM(AmRegistrationData data) async {
|
||||||
|
if (data.agreementDate == null) {
|
||||||
|
throw Exception('La date d\'obtention de l\'agrément est requise.');
|
||||||
|
}
|
||||||
|
if (data.placesAvailable == null) {
|
||||||
|
throw Exception('Le nombre de places disponibles est requis.');
|
||||||
|
}
|
||||||
|
final lieuVille = data.birthCity.trim();
|
||||||
|
final lieuPays = data.birthCountry.trim();
|
||||||
|
if (lieuVille.length < 2 || lieuPays.length < 2) {
|
||||||
|
throw Exception(
|
||||||
|
'La ville et le pays de naissance sont requis (au moins 2 caractères chacun).',
|
||||||
|
);
|
||||||
|
}
|
||||||
String? photoBase64;
|
String? photoBase64;
|
||||||
if (data.photoPath != null && data.photoPath!.isNotEmpty && !data.photoPath!.startsWith('assets/')) {
|
String? photoFilename;
|
||||||
try {
|
|
||||||
final file = File(data.photoPath!);
|
if (data.photoBytes != null && data.photoBytes!.isNotEmpty) {
|
||||||
if (await file.exists()) {
|
final mime = _imageMimeForBytes(data.photoBytes!);
|
||||||
final bytes = await file.readAsBytes();
|
photoBase64 = 'data:$mime;base64,${base64Encode(data.photoBytes!)}';
|
||||||
photoBase64 = 'data:image/jpeg;base64,${base64Encode(bytes)}';
|
final fn = (data.photoFilename ?? '').trim();
|
||||||
}
|
photoFilename = fn.isNotEmpty ? fn : 'photo_am.jpg';
|
||||||
} catch (_) {}
|
} else if (data.photoPath != null &&
|
||||||
|
data.photoPath!.isNotEmpty &&
|
||||||
|
!data.photoPath!.startsWith('assets/')) {
|
||||||
|
if (!kIsWeb) {
|
||||||
|
try {
|
||||||
|
final file = File(data.photoPath!);
|
||||||
|
if (await file.exists()) {
|
||||||
|
final bytes = await file.readAsBytes();
|
||||||
|
final mime = _imageMimeForBytes(bytes);
|
||||||
|
photoBase64 = 'data:$mime;base64,${base64Encode(bytes)}';
|
||||||
|
photoFilename =
|
||||||
|
_basenameFromPath(data.photoPath!) ?? 'photo_am.jpg';
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final body = {
|
if (photoBase64 == null || photoBase64.isEmpty) {
|
||||||
|
throw Exception('Une photo de profil est requise pour finaliser l’inscription.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final body = <String, dynamic>{
|
||||||
'email': data.email,
|
'email': data.email,
|
||||||
'prenom': data.firstName,
|
'prenom': data.firstName,
|
||||||
'nom': data.lastName,
|
'nom': data.lastName,
|
||||||
@ -160,26 +212,48 @@ class AuthService {
|
|||||||
'adresse': data.streetAddress.isNotEmpty ? data.streetAddress : null,
|
'adresse': data.streetAddress.isNotEmpty ? data.streetAddress : null,
|
||||||
'code_postal': data.postalCode.isNotEmpty ? data.postalCode : null,
|
'code_postal': data.postalCode.isNotEmpty ? data.postalCode : null,
|
||||||
'ville': data.city.isNotEmpty ? data.city : null,
|
'ville': data.city.isNotEmpty ? data.city : null,
|
||||||
if (photoBase64 != null) 'photo_base64': photoBase64,
|
'photo_base64': photoBase64,
|
||||||
|
'photo_filename': photoFilename ?? 'photo_am.jpg',
|
||||||
'consentement_photo': data.photoConsent,
|
'consentement_photo': data.photoConsent,
|
||||||
'date_naissance': data.dateOfBirth != null
|
'date_naissance': data.dateOfBirth != null
|
||||||
? '${data.dateOfBirth!.year}-${data.dateOfBirth!.month.toString().padLeft(2, '0')}-${data.dateOfBirth!.day.toString().padLeft(2, '0')}'
|
? '${data.dateOfBirth!.year}-${data.dateOfBirth!.month.toString().padLeft(2, '0')}-${data.dateOfBirth!.day.toString().padLeft(2, '0')}'
|
||||||
: null,
|
: null,
|
||||||
'lieu_naissance_ville': data.birthCity.isNotEmpty ? data.birthCity : null,
|
'lieu_naissance_ville': lieuVille,
|
||||||
'lieu_naissance_pays': data.birthCountry.isNotEmpty ? data.birthCountry : null,
|
'lieu_naissance_pays': lieuPays,
|
||||||
'nir': normalizeNir(data.nir),
|
'nir': normalizeNir(data.nir),
|
||||||
'numero_agrement': data.agrementNumber,
|
'numero_agrement': data.agrementNumber,
|
||||||
|
'date_agrement':
|
||||||
|
'${data.agreementDate!.year}-${data.agreementDate!.month.toString().padLeft(2, '0')}-${data.agreementDate!.day.toString().padLeft(2, '0')}',
|
||||||
'capacite_accueil': data.capacity ?? 1,
|
'capacite_accueil': data.capacity ?? 1,
|
||||||
|
'places_disponibles': data.placesAvailable!,
|
||||||
'biographie': data.presentationText.isNotEmpty ? data.presentationText : null,
|
'biographie': data.presentationText.isNotEmpty ? data.presentationText : null,
|
||||||
'acceptation_cgu': data.cguAccepted,
|
'acceptation_cgu': data.cguAccepted,
|
||||||
'acceptation_privacy': data.cguAccepted,
|
'acceptation_privacy': data.cguAccepted,
|
||||||
};
|
};
|
||||||
|
|
||||||
final response = await http.post(
|
final String encodedBody;
|
||||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.registerAM}'),
|
try {
|
||||||
headers: ApiConfig.headers,
|
encodedBody = jsonEncode(body);
|
||||||
body: jsonEncode(body),
|
} catch (_) {
|
||||||
);
|
throw Exception(
|
||||||
|
'Impossible de préparer l’envoi (données trop volumineuses ou invalides). '
|
||||||
|
'Réessayez avec une photo plus légère.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
late final http.Response response;
|
||||||
|
try {
|
||||||
|
response = await http.post(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.registerAM}'),
|
||||||
|
headers: ApiConfig.headers,
|
||||||
|
body: encodedBody,
|
||||||
|
);
|
||||||
|
} on http.ClientException {
|
||||||
|
throw Exception(
|
||||||
|
'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau, '
|
||||||
|
'un pare-feu ou un bloqueur, puis réessayez.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:http/http.dart' as http;
|
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';
|
||||||
@ -94,9 +93,6 @@ class UserService {
|
|||||||
static Future<DossierUnifie> getDossier(String numeroDossier) async {
|
static Future<DossierUnifie> getDossier(String numeroDossier) async {
|
||||||
final encoded = Uri.encodeComponent(numeroDossier);
|
final encoded = Uri.encodeComponent(numeroDossier);
|
||||||
final uri = Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded');
|
final uri = Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded');
|
||||||
if (kDebugMode) {
|
|
||||||
debugPrint('[PetitsPas/dossier] GET $uri');
|
|
||||||
}
|
|
||||||
final response = await http.get(
|
final response = await http.get(
|
||||||
uri,
|
uri,
|
||||||
headers: await _headers(),
|
headers: await _headers(),
|
||||||
@ -119,19 +115,6 @@ class UserService {
|
|||||||
throw FormatException('Réponse invalide');
|
throw FormatException('Réponse invalide');
|
||||||
}
|
}
|
||||||
final dossier = DossierUnifie.fromJson(Map<String, dynamic>.from(decoded));
|
final dossier = DossierUnifie.fromJson(Map<String, dynamic>.from(decoded));
|
||||||
if (kDebugMode) {
|
|
||||||
debugPrint(
|
|
||||||
'[PetitsPas/dossier] réponse OK type=${dossier.type} | '
|
|
||||||
'ApiConfig.baseUrl=${ApiConfig.baseUrl} | apiOrigin=${ApiConfig.apiOrigin}',
|
|
||||||
);
|
|
||||||
if (dossier.isFamily) {
|
|
||||||
final f = dossier.asFamily;
|
|
||||||
debugPrint(
|
|
||||||
'[PetitsPas/dossier] famille ${f.numeroDossier} | '
|
|
||||||
'${f.enfants.length} enfant(s)',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return dossier;
|
return dossier;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e is FormatException) rethrow;
|
if (e is FormatException) rethrow;
|
||||||
|
|||||||
11
frontend/lib/utils/date_display_utils.dart
Normal file
11
frontend/lib/utils/date_display_utils.dart
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
/// Affiche une date ISO / parseable en `dd/MM/yyyy`, avec repli sur la chaîne ou [ifEmpty].
|
||||||
|
String formatIsoDateFr(String? s, {String ifEmpty = '–'}) {
|
||||||
|
if (s == null || s.trim().isEmpty) return ifEmpty;
|
||||||
|
try {
|
||||||
|
return DateFormat('dd/MM/yyyy').format(DateTime.parse(s.trim()));
|
||||||
|
} catch (_) {
|
||||||
|
return s.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -57,20 +57,22 @@ String formatNir(String raw) {
|
|||||||
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é.
|
/// Aligné sur le backend (NIR côté API / nir.util.ts) : sexe 1–3, département 2A ou 2B pour la Corse.
|
||||||
bool _isFormatValid(String raw) {
|
bool _isFormatValid(String raw) {
|
||||||
if (raw.length != 15) return false;
|
final r = raw.toUpperCase();
|
||||||
final dept = raw.substring(5, 7);
|
if (r.length != 15) return false;
|
||||||
final restDigits = raw.substring(0, 5) + (dept == '2A' ? '19' : dept == '2B' ? '18' : dept) + raw.substring(7, 15);
|
return RegExp(r'^[1-3]\d{4}(?:2A|2B|\d{2})\d{6}\d{2}$').hasMatch(r);
|
||||||
if (!RegExp(r'^[12]\d{12}\d{2}$').hasMatch(restDigits)) return false;
|
|
||||||
return RegExp(r'^[12]\d{4}(?:\d{2}|2A|2B)\d{8}$').hasMatch(raw);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calcule la clé de contrôle (97 - (NIR13 mod 97)). Pour 2A→19, 2B→18.
|
/// Clé INSEE : 97 - (NIR13 mod 97). Corse : 2A→19, 2B→20 (aligné backend).
|
||||||
int _controlKey(String raw13) {
|
int _controlKey(String raw13) {
|
||||||
String n = raw13;
|
String n;
|
||||||
if (raw13.length >= 7 && (raw13.substring(5, 7) == '2A' || raw13.substring(5, 7) == '2B')) {
|
if (raw13.length >= 7 && raw13.substring(5, 7) == '2A') {
|
||||||
n = raw13.substring(0, 5) + (raw13.substring(5, 7) == '2A' ? '19' : '18') + raw13.substring(7);
|
n = '${raw13.substring(0, 5)}19${raw13.substring(7)}';
|
||||||
|
} else if (raw13.length >= 7 && raw13.substring(5, 7) == '2B') {
|
||||||
|
n = '${raw13.substring(0, 5)}20${raw13.substring(7)}';
|
||||||
|
} else {
|
||||||
|
n = '${raw13.substring(0, 5)}${raw13.substring(5, 7)}${raw13.substring(7)}';
|
||||||
}
|
}
|
||||||
final big = int.tryParse(n);
|
final big = int.tryParse(n);
|
||||||
if (big == null) return -1;
|
if (big == null) return -1;
|
||||||
|
|||||||
@ -5,7 +5,8 @@ import 'admin_detail_modal.dart';
|
|||||||
/// [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.
|
/// [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).
|
/// [rowFlex] : flex par index de ligne (optionnel). Ex. {3: [2, 5]} = 4e ligne : code postal étroit (2), ville large (5).
|
||||||
class ValidationDetailSection extends StatelessWidget {
|
class ValidationDetailSection extends StatelessWidget {
|
||||||
final String title;
|
/// Si null ou vide, pas de bandeau titre (gain de place vertical, ex. wizard AM).
|
||||||
|
final String? title;
|
||||||
final List<AdminDetailField> fields;
|
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é.
|
/// Nombre de champs par ligne (1 = plein largeur, 2 = deux côte à côte). Ex. [2, 2, 1, 2] pour identité.
|
||||||
@ -16,7 +17,7 @@ class ValidationDetailSection extends StatelessWidget {
|
|||||||
|
|
||||||
const ValidationDetailSection({
|
const ValidationDetailSection({
|
||||||
super.key,
|
super.key,
|
||||||
required this.title,
|
this.title,
|
||||||
required this.fields,
|
required this.fields,
|
||||||
this.rowLayout,
|
this.rowLayout,
|
||||||
this.rowFlex,
|
this.rowFlex,
|
||||||
@ -60,19 +61,22 @@ class ValidationDetailSection extends StatelessWidget {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
final showTitle = title != null && title!.trim().isNotEmpty;
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
if (showTitle) ...[
|
||||||
title,
|
Text(
|
||||||
style: const TextStyle(
|
title!.trim(),
|
||||||
fontSize: 16,
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.w600,
|
fontSize: 16,
|
||||||
color: Colors.black87,
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 12),
|
||||||
const SizedBox(height: 12),
|
],
|
||||||
...rows,
|
...rows,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -155,70 +155,6 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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).
|
/// Sous-titre AM : `email - date • tél. • CP ville` (plan affichage lignes À valider).
|
||||||
String _amSubtitleLine(AppUser user) {
|
String _amSubtitleLine(AppUser user) {
|
||||||
@ -244,9 +180,9 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
|||||||
final numDossier = user.numeroDossier ?? '–';
|
final numDossier = user.numeroDossier ?? '–';
|
||||||
final nameBold =
|
final nameBold =
|
||||||
user.fullName.isNotEmpty ? user.fullName : (user.email.isNotEmpty ? user.email : '–');
|
user.fullName.isNotEmpty ? user.fullName : (user.email.isNotEmpty ? user.email : '–');
|
||||||
return _buildPendingRow(
|
return _PendingValidationRow(
|
||||||
icon: Icons.person_outline,
|
icon: Icons.person_outline,
|
||||||
titleWidget: Text.rich(
|
title: Text.rich(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
style: const TextStyle(fontSize: 14, color: Colors.black87),
|
style: const TextStyle(fontSize: 14, color: Colors.black87),
|
||||||
children: [
|
children: [
|
||||||
@ -320,9 +256,9 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
|||||||
Widget _buildFamilyCard(PendingFamily family) {
|
Widget _buildFamilyCard(PendingFamily family) {
|
||||||
final numDossier = family.numeroDossier ?? '–';
|
final numDossier = family.numeroDossier ?? '–';
|
||||||
final nameBold = family.libelle.isNotEmpty ? family.libelle : 'Famille';
|
final nameBold = family.libelle.isNotEmpty ? family.libelle : 'Famille';
|
||||||
return _buildPendingRow(
|
return _PendingValidationRow(
|
||||||
icon: Icons.family_restroom_outlined,
|
icon: Icons.family_restroom_outlined,
|
||||||
titleWidget: Text.rich(
|
title: Text.rich(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
style: const TextStyle(fontSize: 14, color: Colors.black87),
|
style: const TextStyle(fontSize: 14, color: Colors.black87),
|
||||||
children: [
|
children: [
|
||||||
@ -352,3 +288,111 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ligne « À valider » : survol comme [AdminUserCard], icône « Ouvrir » visible au hover uniquement.
|
||||||
|
class _PendingValidationRow extends StatefulWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final Widget title;
|
||||||
|
final String? subtitle;
|
||||||
|
final TextStyle? subtitleStyle;
|
||||||
|
final VoidCallback onOpen;
|
||||||
|
|
||||||
|
const _PendingValidationRow({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
this.subtitleStyle,
|
||||||
|
required this.onOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_PendingValidationRow> createState() => _PendingValidationRowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PendingValidationRowState extends State<_PendingValidationRow> {
|
||||||
|
bool _isHovered = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final subStyle = widget.subtitleStyle ??
|
||||||
|
TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
);
|
||||||
|
return MouseRegion(
|
||||||
|
onEnter: (_) => setState(() => _isHovered = true),
|
||||||
|
onExit: (_) => setState(() => _isHovered = false),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {},
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
hoverColor: const Color(0x149CC5C0),
|
||||||
|
child: Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
side: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(widget.icon, color: Colors.grey.shade600, size: 28),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
widget.title,
|
||||||
|
if (widget.subtitle != null &&
|
||||||
|
widget.subtitle!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
widget.subtitle!,
|
||||||
|
style: subStyle,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 52,
|
||||||
|
child: Center(
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
duration: const Duration(milliseconds: 120),
|
||||||
|
opacity: _isHovered ? 1 : 0,
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: !_isHovered,
|
||||||
|
child: IconButtonTheme(
|
||||||
|
data: IconButtonThemeData(
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.all(0),
|
||||||
|
minimumSize: const Size(48, 48),
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: widget.onOpen,
|
||||||
|
icon: const Icon(Icons.open_in_new),
|
||||||
|
iconSize: 34,
|
||||||
|
tooltip: 'Ouvrir',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||||||
|
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
||||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||||
import 'package:p_tits_pas/utils/nir_utils.dart';
|
import 'package:p_tits_pas/utils/nir_utils.dart';
|
||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
@ -74,29 +75,40 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
|
|||||||
AdminDetailField(label: 'Ville', value: _v(u.ville)),
|
AdminDetailField(label: 'Ville', value: _v(u.ville)),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Informations professionnelles : N° Agrément|Date agrément, NIR, Capacité|Places, Ville.
|
/// Panneau photo + grille droite : NIR|naissance, ville|pays, agrément|date, capa|places.
|
||||||
List<AdminDetailField> _proFields(DossierAM d) => [
|
List<AdminDetailField> _photoProFields(DossierAM d) {
|
||||||
AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)),
|
final u = d.user;
|
||||||
AdminDetailField(
|
return [
|
||||||
label: 'Date d’agrément',
|
AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)),
|
||||||
value: d.dateAgrement != null && d.dateAgrement!.trim().isNotEmpty
|
AdminDetailField(
|
||||||
? d.dateAgrement!.trim()
|
label: 'Date de naissance',
|
||||||
: '–',
|
value: formatIsoDateFr(u.dateNaissance),
|
||||||
),
|
),
|
||||||
AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)),
|
AdminDetailField(
|
||||||
AdminDetailField(
|
label: 'Ville de naissance',
|
||||||
label: 'Capacité max (enfants)',
|
value: _v(u.lieuNaissanceVille),
|
||||||
value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '–',
|
),
|
||||||
),
|
AdminDetailField(
|
||||||
AdminDetailField(
|
label: 'Pays de naissance',
|
||||||
label: 'Places disponibles',
|
value: _v(u.lieuNaissancePays),
|
||||||
value: d.placesDisponibles != null
|
),
|
||||||
? d.placesDisponibles.toString()
|
AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)),
|
||||||
: '–',
|
AdminDetailField(
|
||||||
),
|
label: 'Date d’agrément',
|
||||||
AdminDetailField(
|
value: formatIsoDateFr(d.dateAgrement),
|
||||||
label: 'Ville de résidence', value: _v(d.villeResidence)),
|
),
|
||||||
];
|
AdminDetailField(
|
||||||
|
label: 'Capacité max (enfants)',
|
||||||
|
value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '–',
|
||||||
|
),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Places disponibles',
|
||||||
|
value: d.placesDisponibles != null
|
||||||
|
? d.placesDisponibles.toString()
|
||||||
|
: '–',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
static const List<int> _personalRowLayout = [2, 2, 1, 2];
|
static const List<int> _personalRowLayout = [2, 2, 1, 2];
|
||||||
static const Map<int, List<int>> _personalRowFlex = {
|
static const Map<int, List<int>> _personalRowFlex = {
|
||||||
@ -119,15 +131,6 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
|
||||||
'Photo de profil',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Colors.black87,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(right: 8),
|
padding: const EdgeInsets.only(right: 8),
|
||||||
@ -264,7 +267,7 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
|
|||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
||||||
child: ValidationDetailSection(
|
child: ValidationDetailSection(
|
||||||
title: 'Informations personnelles',
|
title: 'Identité et coordonnées',
|
||||||
fields: _personalFields(u),
|
fields: _personalFields(u),
|
||||||
rowLayout: _personalRowLayout,
|
rowLayout: _personalRowLayout,
|
||||||
rowFlex: _personalRowFlex,
|
rowFlex: _personalRowFlex,
|
||||||
@ -280,8 +283,7 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
|
|||||||
builder: (context, c) {
|
builder: (context, c) {
|
||||||
final maxRowW = c.maxWidth;
|
final maxRowW = c.maxWidth;
|
||||||
final maxRowH = c.maxHeight;
|
final maxRowH = c.maxHeight;
|
||||||
// Titre « Photo de profil » + espacement (~52 px) : hauteur dispo pour le cadre photo.
|
const photoHeaderH = 0.0;
|
||||||
const photoHeaderH = 52.0;
|
|
||||||
final bodyH = (maxRowH - photoHeaderH).clamp(0.0, double.infinity);
|
final bodyH = (maxRowH - photoHeaderH).clamp(0.0, double.infinity);
|
||||||
final idealPhotoW =
|
final idealPhotoW =
|
||||||
bodyH * _idPhotoAspectRatio + 16; // marge approx. cadre clair
|
bodyH * _idPhotoAspectRatio + 16; // marge approx. cadre clair
|
||||||
@ -307,14 +309,9 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
|
|||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
minWidth: constraints.maxWidth),
|
minWidth: constraints.maxWidth),
|
||||||
child: ValidationDetailSection(
|
child: ValidationDetailSection(
|
||||||
title: 'Informations professionnelles',
|
title: 'Dossier professionnel',
|
||||||
fields: _proFields(d),
|
fields: _photoProFields(d),
|
||||||
rowLayout: const [
|
rowLayout: const [2, 2, 2, 2],
|
||||||
2,
|
|
||||||
1,
|
|
||||||
2,
|
|
||||||
1
|
|
||||||
], // N° Agrément|Date agrément, NIR, Capacité|Places, Ville
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -333,12 +330,13 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
const Text(
|
||||||
'Présentation',
|
'Présentation',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Colors.black87),
|
color: Colors.black87,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:google_fonts/google_fonts.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/models/dossier_unifie.dart';
|
||||||
|
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
||||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||||
@ -106,15 +105,8 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
|
|||||||
(s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini';
|
(s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini';
|
||||||
|
|
||||||
/// Date de naissance en jour/mois/année (dd/MM/yyyy).
|
/// Date de naissance en jour/mois/année (dd/MM/yyyy).
|
||||||
static String _formatBirthDate(String? s) {
|
static String _formatBirthDate(String? s) =>
|
||||||
if (s == null || s.trim().isEmpty) return 'Non défini';
|
formatIsoDateFr(s, ifEmpty: '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).
|
/// 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) => [
|
List<AdminDetailField> _parentFields(ParentDossier p) => [
|
||||||
@ -344,12 +336,6 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
|
|||||||
/// Carte enfant : prénom pleine largeur, puis ligne photo 1/3 + colonne 2/3 (champs + statut hors TF si besoin).
|
/// Carte enfant : prénom pleine largeur, puis ligne photo 1/3 + colonne 2/3 (champs + statut hors TF si besoin).
|
||||||
Widget _buildEnfantCard(EnfantDossier e) {
|
Widget _buildEnfantCard(EnfantDossier e) {
|
||||||
final photoUrl = _fullPhotoUrl(e.photoUrl);
|
final photoUrl = _fullPhotoUrl(e.photoUrl);
|
||||||
if (kDebugMode) {
|
|
||||||
debugPrint(
|
|
||||||
'[PetitsPas/validation-famille] carte enfant id=${e.id} prénom=${e.firstName} | '
|
|
||||||
'photoUrl modèle=${e.photoUrl ?? "∅"} | url affichée=${photoUrl.isEmpty ? "∅" : photoUrl}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final columnStatusLabel = _enfantColumnStatusLabel(e);
|
final columnStatusLabel = _enfantColumnStatusLabel(e);
|
||||||
return ClipRRect(
|
return ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(_enfantCardRadius),
|
borderRadius: BorderRadius.circular(_enfantCardRadius),
|
||||||
@ -569,7 +555,7 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const Text(
|
||||||
'Présentation / Motivation',
|
'Présentation',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87),
|
fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import '../models/user_registration_data.dart';
|
|||||||
import '../models/card_assets.dart';
|
import '../models/card_assets.dart';
|
||||||
import 'custom_app_text_field.dart';
|
import 'custom_app_text_field.dart';
|
||||||
import 'form_field_wrapper.dart';
|
import 'form_field_wrapper.dart';
|
||||||
import 'hover_relief_widget.dart';
|
import 'registration_photo_slot.dart';
|
||||||
import '../config/display_config.dart';
|
import '../config/display_config.dart';
|
||||||
import 'package:p_tits_pas/utils/name_format_utils.dart';
|
import 'package:p_tits_pas/utils/name_format_utils.dart';
|
||||||
|
|
||||||
@ -16,13 +16,12 @@ const String _photoConsentTooltip =
|
|||||||
'Suivi du dossier et organisation de l’accueil (affichage interne, outils pédagogiques).\n'
|
'Suivi du dossier et organisation de l’accueil (affichage interne, outils pédagogiques).\n'
|
||||||
'Dans le respect de la politique de confidentialité.';
|
'Dans le respect de la politique de confidentialité.';
|
||||||
|
|
||||||
/// Cadre photo (centre transparent), même dossier que `photo.png`.
|
|
||||||
const String _photoSketchFrameAsset = 'assets/images/photo_frame.png';
|
|
||||||
|
|
||||||
bool _hasChildPhoto(ChildData c) {
|
bool _hasChildPhoto(ChildData c) {
|
||||||
final b = c.imageBytes;
|
return registrationPhotoSlotHasImage(
|
||||||
if (b != null && b.isNotEmpty) return true;
|
imageBytes: c.imageBytes,
|
||||||
return c.imageFile != null;
|
imageFile: c.imageFile,
|
||||||
|
imagePathOrAsset: null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildChildPhotoImage(ChildData c, {required BoxFit fit}) {
|
Widget _buildChildPhotoImage(ChildData c, {required BoxFit fit}) {
|
||||||
@ -194,11 +193,6 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
|
|||||||
final Color baseCardColorForShadow = widget.childData.cardColor == CardColorVertical.lavender
|
final Color baseCardColorForShadow = widget.childData.cardColor == CardColorVertical.lavender
|
||||||
? Colors.purple.shade200
|
? Colors.purple.shade200
|
||||||
: (widget.childData.cardColor == CardColorVertical.pink ? Colors.pink.shade200 : Colors.grey.shade200);
|
: (widget.childData.cardColor == CardColorVertical.pink ? Colors.pink.shade200 : Colors.grey.shade200);
|
||||||
final Color initialPhotoShadow =
|
|
||||||
Color.alphaBlend(Colors.black.withOpacity(0.22), baseCardColorForShadow);
|
|
||||||
final Color hoverPhotoShadow =
|
|
||||||
Color.alphaBlend(Colors.black.withOpacity(0.32), baseCardColorForShadow);
|
|
||||||
|
|
||||||
final double photoSide = 200.0 * (config.isMobile ? 0.8 : 1.0);
|
final double photoSide = 200.0 * (config.isMobile ? 0.8 : 1.0);
|
||||||
final double editableCardWidth = config.isMobile
|
final double editableCardWidth = config.isMobile
|
||||||
? double.infinity
|
? double.infinity
|
||||||
@ -222,14 +216,15 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
|
|||||||
Column(
|
Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_buildEditablePhotoArea(
|
RegistrationPhotoSlot(
|
||||||
context: context,
|
side: photoSide,
|
||||||
config: config,
|
|
||||||
scaleFactor: scaleFactor,
|
scaleFactor: scaleFactor,
|
||||||
photoSide: photoSide,
|
imageBytes: widget.childData.imageBytes,
|
||||||
childData: widget.childData,
|
imageFile: widget.childData.imageFile,
|
||||||
initialPhotoShadow: initialPhotoShadow,
|
onTapPick: !config.isReadonly ? widget.onPickImage : null,
|
||||||
hoverPhotoShadow: hoverPhotoShadow,
|
onClear: !config.isReadonly ? widget.onClearImage : null,
|
||||||
|
baseShadowColor: baseCardColorForShadow,
|
||||||
|
isMobile: config.isMobile,
|
||||||
),
|
),
|
||||||
SizedBox(height: 8.0 * scaleFactor),
|
SizedBox(height: 8.0 * scaleFactor),
|
||||||
_buildPhotoConsentRow(
|
_buildPhotoConsentRow(
|
||||||
@ -321,96 +316,6 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEditablePhotoArea({
|
|
||||||
required BuildContext context,
|
|
||||||
required DisplayConfig config,
|
|
||||||
required double scaleFactor,
|
|
||||||
required double photoSide,
|
|
||||||
required ChildData childData,
|
|
||||||
required Color initialPhotoShadow,
|
|
||||||
required Color hoverPhotoShadow,
|
|
||||||
}) {
|
|
||||||
final canInteract = !config.isReadonly;
|
|
||||||
final hasPhoto = _hasChildPhoto(childData);
|
|
||||||
final outerRadius = BorderRadius.circular(10 * scaleFactor);
|
|
||||||
// Arrondi photo (sous le cadre) : proportionnel au carré, plus fort qu’avant (~10×scaleFactor).
|
|
||||||
final photoClipRadius = BorderRadius.circular(photoSide * 0.14);
|
|
||||||
|
|
||||||
return Stack(
|
|
||||||
clipBehavior: Clip.none,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
HoverReliefWidget(
|
|
||||||
onPressed: canInteract ? widget.onPickImage : null,
|
|
||||||
borderRadius: outerRadius,
|
|
||||||
initialElevation: 10,
|
|
||||||
hoverElevation: 16,
|
|
||||||
initialShadowColor: initialPhotoShadow,
|
|
||||||
hoverShadowColor: hoverPhotoShadow,
|
|
||||||
// Pas de clip Material sur la pile : l’arrondi de la photo est géré par ClipRRect (plus marqué).
|
|
||||||
clipBehavior: hasPhoto ? Clip.none : Clip.antiAlias,
|
|
||||||
child: SizedBox(
|
|
||||||
width: photoSide,
|
|
||||||
height: photoSide,
|
|
||||||
child: !hasPhoto
|
|
||||||
? ClipRRect(
|
|
||||||
borderRadius: outerRadius,
|
|
||||||
child: Center(
|
|
||||||
child: Image.asset(
|
|
||||||
'assets/images/photo.png',
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
width: photoSide,
|
|
||||||
height: photoSide,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Stack(
|
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [
|
|
||||||
// Pas de fond opaque : les coins hors ClipRRect laissent voir la carte (aquarelle).
|
|
||||||
Positioned.fill(
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: photoClipRadius,
|
|
||||||
child: _buildChildPhotoImage(childData, fit: BoxFit.cover),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned.fill(
|
|
||||||
child: Image.asset(
|
|
||||||
_photoSketchFrameAsset,
|
|
||||||
fit: BoxFit.fill,
|
|
||||||
errorBuilder: (context, error, stackTrace) =>
|
|
||||||
const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (hasPhoto && canInteract)
|
|
||||||
Positioned(
|
|
||||||
top: 8 * scaleFactor,
|
|
||||||
right: 8 * scaleFactor,
|
|
||||||
child: Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: widget.onClearImage,
|
|
||||||
customBorder: const CircleBorder(),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(4),
|
|
||||||
child: Image.asset(
|
|
||||||
'assets/images/cross.png',
|
|
||||||
width: config.isMobile ? 26 : 30,
|
|
||||||
height: config.isMobile ? 26 : 30,
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Layout SPÉCIAL Readonly Desktop (Ancien Design Horizontal)
|
/// Layout SPÉCIAL Readonly Desktop (Ancien Design Horizontal)
|
||||||
Widget _buildReadonlyDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) {
|
Widget _buildReadonlyDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) {
|
||||||
// Convertir la couleur verticale (pour mobile) en couleur horizontale (pour desktop/récap)
|
// Convertir la couleur verticale (pour mobile) en couleur horizontale (pour desktop/récap)
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/services/api/tokenService.dart';
|
import 'package:p_tits_pas/services/api/tokenService.dart';
|
||||||
|
|
||||||
@ -43,12 +42,6 @@ class _AuthNetworkImageState extends State<AuthNetworkImage> {
|
|||||||
final isPublic = AuthNetworkImage.isPublicUploadUrl(widget.url);
|
final isPublic = AuthNetworkImage.isPublicUploadUrl(widget.url);
|
||||||
_headersFuture =
|
_headersFuture =
|
||||||
isPublic ? Future<Map<String, String>?>.value(null) : _loadHeaders();
|
isPublic ? Future<Map<String, String>?>.value(null) : _loadHeaders();
|
||||||
if (kDebugMode) {
|
|
||||||
debugPrint(
|
|
||||||
'[PetitsPas/image] préparation chargement url=${widget.url} | '
|
|
||||||
'uploadPublic=$isPublic | avecBearer=${!isPublic}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, String>?> _loadHeaders() async {
|
static Future<Map<String, String>?> _loadHeaders() async {
|
||||||
@ -59,11 +52,6 @@ class _AuthNetworkImageState extends State<AuthNetworkImage> {
|
|||||||
|
|
||||||
ImageErrorWidgetBuilder _wrapErrorBuilder() {
|
ImageErrorWidgetBuilder _wrapErrorBuilder() {
|
||||||
return (BuildContext context, Object error, StackTrace? stackTrace) {
|
return (BuildContext context, Object error, StackTrace? stackTrace) {
|
||||||
if (kDebugMode) {
|
|
||||||
debugPrint(
|
|
||||||
'[PetitsPas/image] ❌ échec chargement url=${widget.url} | erreur=$error',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final inner = widget.errorBuilder;
|
final inner = widget.errorBuilder;
|
||||||
if (inner != null) {
|
if (inner != null) {
|
||||||
return inner(context, error, stackTrace);
|
return inner(context, error, stackTrace);
|
||||||
@ -112,9 +100,6 @@ class _AuthNetworkImageState extends State<AuthNetworkImage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
final headers = snapshot.data;
|
final headers = snapshot.data;
|
||||||
if (kDebugMode && headers != null) {
|
|
||||||
debugPrint('[PetitsPas/image] requête avec en-tête Authorization (Bearer présent)');
|
|
||||||
}
|
|
||||||
return Image.network(
|
return Image.network(
|
||||||
widget.url,
|
widget.url,
|
||||||
width: widget.width,
|
width: widget.width,
|
||||||
|
|||||||
@ -27,10 +27,14 @@ class CustomAppTextField extends StatefulWidget {
|
|||||||
final IconData? suffixIcon;
|
final IconData? suffixIcon;
|
||||||
final double labelFontSize;
|
final double labelFontSize;
|
||||||
final double inputFontSize;
|
final double inputFontSize;
|
||||||
|
/// Espace vertical entre le libellé et la zone de saisie.
|
||||||
|
final double labelFieldSpacing;
|
||||||
final bool showLabel;
|
final bool showLabel;
|
||||||
final Iterable<String>? autofillHints;
|
final Iterable<String>? autofillHints;
|
||||||
final TextInputAction? textInputAction;
|
final TextInputAction? textInputAction;
|
||||||
final ValueChanged<String>? onFieldSubmitted;
|
final ValueChanged<String>? onFieldSubmitted;
|
||||||
|
/// Souvent appelé par la touche « suivant » du clavier (flèche), là où [onFieldSubmitted] ne l’est pas.
|
||||||
|
final VoidCallback? onEditingComplete;
|
||||||
final List<TextInputFormatter>? inputFormatters;
|
final List<TextInputFormatter>? inputFormatters;
|
||||||
final bool autocorrect;
|
final bool autocorrect;
|
||||||
final bool enableSuggestions;
|
final bool enableSuggestions;
|
||||||
@ -56,9 +60,11 @@ class CustomAppTextField extends StatefulWidget {
|
|||||||
this.suffixIcon,
|
this.suffixIcon,
|
||||||
this.labelFontSize = 18.0,
|
this.labelFontSize = 18.0,
|
||||||
this.inputFontSize = 18.0,
|
this.inputFontSize = 18.0,
|
||||||
|
this.labelFieldSpacing = 6.0,
|
||||||
this.autofillHints,
|
this.autofillHints,
|
||||||
this.textInputAction,
|
this.textInputAction,
|
||||||
this.onFieldSubmitted,
|
this.onFieldSubmitted,
|
||||||
|
this.onEditingComplete,
|
||||||
this.inputFormatters,
|
this.inputFormatters,
|
||||||
this.autocorrect = true,
|
this.autocorrect = true,
|
||||||
this.enableSuggestions = true,
|
this.enableSuggestions = true,
|
||||||
@ -105,7 +111,7 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
|
|||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
SizedBox(height: widget.labelFieldSpacing),
|
||||||
],
|
],
|
||||||
// Pas de hauteur fixe sur le TextFormField : le message d’erreur du
|
// Pas de hauteur fixe sur le TextFormField : le message d’erreur du
|
||||||
// validateur s’affiche en dessous ; un SizedBox fixe le masquait.
|
// validateur s’affiche en dessous ; un SizedBox fixe le masquait.
|
||||||
@ -141,6 +147,7 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
|
|||||||
autofillHints: widget.autofillHints,
|
autofillHints: widget.autofillHints,
|
||||||
textInputAction: widget.textInputAction,
|
textInputAction: widget.textInputAction,
|
||||||
onFieldSubmitted: widget.onFieldSubmitted,
|
onFieldSubmitted: widget.onFieldSubmitted,
|
||||||
|
onEditingComplete: widget.onEditingComplete,
|
||||||
enabled: widget.enabled,
|
enabled: widget.enabled,
|
||||||
readOnly: widget.readOnly,
|
readOnly: widget.readOnly,
|
||||||
onTap: widget.onTap,
|
onTap: widget.onTap,
|
||||||
|
|||||||
@ -8,6 +8,9 @@ import 'custom_app_text_field.dart';
|
|||||||
/// 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;
|
||||||
|
final FocusNode? focusNode;
|
||||||
|
/// Si non null, impose l’ordre Tab parmi les descendants (ex. après « Pays de naissance »).
|
||||||
|
final double? focusTraversalOrder;
|
||||||
final String labelText;
|
final String labelText;
|
||||||
final String hintText;
|
final String hintText;
|
||||||
final String? Function(String?)? validator;
|
final String? Function(String?)? validator;
|
||||||
@ -18,12 +21,15 @@ class NirTextField extends StatelessWidget {
|
|||||||
final bool enabled;
|
final bool enabled;
|
||||||
final bool readOnly;
|
final bool readOnly;
|
||||||
final CustomAppTextFieldStyle style;
|
final CustomAppTextFieldStyle style;
|
||||||
|
final double labelFieldSpacing;
|
||||||
|
|
||||||
const NirTextField({
|
const NirTextField({
|
||||||
super.key,
|
super.key,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
|
this.focusNode,
|
||||||
|
this.focusTraversalOrder,
|
||||||
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 = '13 chiffres + clé',
|
||||||
this.validator,
|
this.validator,
|
||||||
this.fieldWidth = double.infinity,
|
this.fieldWidth = double.infinity,
|
||||||
this.fieldHeight = 53.0,
|
this.fieldHeight = 53.0,
|
||||||
@ -32,12 +38,14 @@ class NirTextField extends StatelessWidget {
|
|||||||
this.enabled = true,
|
this.enabled = true,
|
||||||
this.readOnly = false,
|
this.readOnly = false,
|
||||||
this.style = CustomAppTextFieldStyle.beige,
|
this.style = CustomAppTextFieldStyle.beige,
|
||||||
|
this.labelFieldSpacing = 6.0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return CustomAppTextField(
|
final field = CustomAppTextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
|
focusNode: focusNode,
|
||||||
labelText: labelText,
|
labelText: labelText,
|
||||||
hintText: hintText,
|
hintText: hintText,
|
||||||
fieldWidth: fieldWidth,
|
fieldWidth: fieldWidth,
|
||||||
@ -50,6 +58,15 @@ class NirTextField extends StatelessWidget {
|
|||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
readOnly: readOnly,
|
readOnly: readOnly,
|
||||||
style: style,
|
style: style,
|
||||||
|
labelFieldSpacing: labelFieldSpacing,
|
||||||
);
|
);
|
||||||
|
final o = focusTraversalOrder;
|
||||||
|
if (o != null) {
|
||||||
|
return FocusTraversalOrder(
|
||||||
|
order: NumericFocusOrder(o),
|
||||||
|
child: field,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return field;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -55,6 +55,8 @@ class PersonalInfoFormScreen extends StatefulWidget {
|
|||||||
final PersonalInfoData? referenceAddressData; // Pour pré-remplir si "même adresse"
|
final PersonalInfoData? referenceAddressData; // Pour pré-remplir si "même adresse"
|
||||||
final bool embedContentOnly; // Si true, affiche seulement la carte (sans scaffold/fond/titre)
|
final bool embedContentOnly; // Si true, affiche seulement la carte (sans scaffold/fond/titre)
|
||||||
final VoidCallback? onEdit; // Callback pour le bouton d'édition (si affiché)
|
final VoidCallback? onEdit; // Callback pour le bouton d'édition (si affiché)
|
||||||
|
/// Si non null (ex. 2 pour inscription AM), nom/prénom : trim + longueur minimale (aligné API).
|
||||||
|
final int? minPersonNameLength;
|
||||||
|
|
||||||
const PersonalInfoFormScreen({
|
const PersonalInfoFormScreen({
|
||||||
super.key,
|
super.key,
|
||||||
@ -72,6 +74,7 @@ class PersonalInfoFormScreen extends StatefulWidget {
|
|||||||
this.referenceAddressData,
|
this.referenceAddressData,
|
||||||
this.embedContentOnly = false,
|
this.embedContentOnly = false,
|
||||||
this.onEdit,
|
this.onEdit,
|
||||||
|
this.minPersonNameLength,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -214,6 +217,16 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
|||||||
return validateEmail(value, allowEmpty: true);
|
return validateEmail(value, allowEmpty: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prénom / nom avec longueur minimale (ex. inscription AM, aligné `MinLength` API).
|
||||||
|
String? _validatePersonNameWithMinLength(String? value) {
|
||||||
|
final min = widget.minPersonNameLength;
|
||||||
|
if (min == null) return null;
|
||||||
|
final t = (value ?? '').trim();
|
||||||
|
if (t.isEmpty) return 'Ce champ est obligatoire';
|
||||||
|
if (t.length < min) return 'Au moins $min caractères';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Téléphone / e-mail du parent 2 : pas de validation si « Ajouter Parent 2 » est désactivé.
|
/// Téléphone / e-mail du parent 2 : pas de validation si « Ajouter Parent 2 » est désactivé.
|
||||||
String? _validateFrenchPhoneIfSecondParentNeeded(String? value) {
|
String? _validateFrenchPhoneIfSecondParentNeeded(String? value) {
|
||||||
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
|
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
|
||||||
@ -837,6 +850,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
|||||||
enabled: _fieldsEnabled,
|
enabled: _fieldsEnabled,
|
||||||
focusOrder: _focusLastName,
|
focusOrder: _focusLastName,
|
||||||
focusNode: _lastNameFocus,
|
focusNode: _lastNameFocus,
|
||||||
|
fieldValidator: widget.minPersonNameLength != null
|
||||||
|
? _validatePersonNameWithMinLength
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 20),
|
const SizedBox(width: 20),
|
||||||
@ -849,6 +865,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
|||||||
enabled: _fieldsEnabled,
|
enabled: _fieldsEnabled,
|
||||||
focusOrder: _focusFirstName,
|
focusOrder: _focusFirstName,
|
||||||
focusNode: _firstNameFocus,
|
focusNode: _firstNameFocus,
|
||||||
|
fieldValidator: widget.minPersonNameLength != null
|
||||||
|
? _validatePersonNameWithMinLength
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -1106,6 +1125,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
|||||||
enabled: _fieldsEnabled,
|
enabled: _fieldsEnabled,
|
||||||
focusOrder: _focusLastName,
|
focusOrder: _focusLastName,
|
||||||
focusNode: _lastNameFocus,
|
focusNode: _lastNameFocus,
|
||||||
|
fieldValidator: widget.minPersonNameLength != null
|
||||||
|
? _validatePersonNameWithMinLength
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
@ -1118,6 +1140,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
|||||||
enabled: _fieldsEnabled,
|
enabled: _fieldsEnabled,
|
||||||
focusOrder: _focusFirstName,
|
focusOrder: _focusFirstName,
|
||||||
focusNode: _firstNameFocus,
|
focusNode: _firstNameFocus,
|
||||||
|
fieldValidator: widget.minPersonNameLength != null
|
||||||
|
? _validatePersonNameWithMinLength
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
169
frontend/lib/widgets/registration_photo_slot.dart
Normal file
169
frontend/lib/widgets/registration_photo_slot.dart
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'hover_relief_widget.dart';
|
||||||
|
|
||||||
|
/// Cadre « croquis » par-dessus la photo (même ressource que les cartes enfant).
|
||||||
|
const String kRegistrationPhotoFrameAsset = 'assets/images/photo_frame.png';
|
||||||
|
|
||||||
|
/// Indique si une photo « réelle » est présente (hors seul placeholder asset).
|
||||||
|
bool registrationPhotoSlotHasImage({
|
||||||
|
Uint8List? imageBytes,
|
||||||
|
File? imageFile,
|
||||||
|
String? imagePathOrAsset,
|
||||||
|
}) {
|
||||||
|
if (imageBytes != null && imageBytes.isNotEmpty) return true;
|
||||||
|
if (imageFile != null) return true;
|
||||||
|
if (imagePathOrAsset == null || imagePathOrAsset.isEmpty) return false;
|
||||||
|
return !imagePathOrAsset.startsWith('assets/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zone photo uniforme : cadre gris / placeholder, tap pour choisir, croix pour retirer, cadre dessin si photo.
|
||||||
|
///
|
||||||
|
/// Utilisé inscription **enfants** ([ChildCardWidget]) et **AM** ([ProfessionalInfoFormScreen]).
|
||||||
|
class RegistrationPhotoSlot extends StatelessWidget {
|
||||||
|
final double side;
|
||||||
|
final double scaleFactor;
|
||||||
|
final Uint8List? imageBytes;
|
||||||
|
final File? imageFile;
|
||||||
|
/// Chemin fichier local (hors `assets/`) ou URI asset `assets/...`.
|
||||||
|
final String? imagePathOrAsset;
|
||||||
|
final VoidCallback? onTapPick;
|
||||||
|
final VoidCallback? onClear;
|
||||||
|
final Color baseShadowColor;
|
||||||
|
final String placeholderAsset;
|
||||||
|
final bool isMobile;
|
||||||
|
|
||||||
|
const RegistrationPhotoSlot({
|
||||||
|
super.key,
|
||||||
|
required this.side,
|
||||||
|
this.scaleFactor = 1.0,
|
||||||
|
this.imageBytes,
|
||||||
|
this.imageFile,
|
||||||
|
this.imagePathOrAsset,
|
||||||
|
this.onTapPick,
|
||||||
|
this.onClear,
|
||||||
|
required this.baseShadowColor,
|
||||||
|
this.placeholderAsset = 'assets/images/photo.png',
|
||||||
|
this.isMobile = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get _hasImage => registrationPhotoSlotHasImage(
|
||||||
|
imageBytes: imageBytes,
|
||||||
|
imageFile: imageFile,
|
||||||
|
imagePathOrAsset: imagePathOrAsset,
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _buildImage({required BoxFit fit}) {
|
||||||
|
final b = imageBytes;
|
||||||
|
if (b != null && b.isNotEmpty) {
|
||||||
|
return Image.memory(b, fit: fit);
|
||||||
|
}
|
||||||
|
final f = imageFile;
|
||||||
|
if (f != null) {
|
||||||
|
return kIsWeb ? Image.network(f.path, fit: fit) : Image.file(f, fit: fit);
|
||||||
|
}
|
||||||
|
final p = imagePathOrAsset;
|
||||||
|
if (p != null && p.isNotEmpty) {
|
||||||
|
if (p.startsWith('assets/')) {
|
||||||
|
return Image.asset(p, fit: fit);
|
||||||
|
}
|
||||||
|
if (!kIsWeb) {
|
||||||
|
try {
|
||||||
|
final file = File(p);
|
||||||
|
if (file.existsSync()) {
|
||||||
|
return Image.file(file, fit: fit);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Image.asset(placeholderAsset, fit: BoxFit.contain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final canPick = onTapPick != null;
|
||||||
|
final canClear = onClear != null && _hasImage;
|
||||||
|
final outerRadius = BorderRadius.circular(10 * scaleFactor);
|
||||||
|
final photoClipRadius = BorderRadius.circular(side * 0.14);
|
||||||
|
final initialPhotoShadow =
|
||||||
|
Color.alphaBlend(Colors.black.withOpacity(0.22), baseShadowColor);
|
||||||
|
final hoverPhotoShadow =
|
||||||
|
Color.alphaBlend(Colors.black.withOpacity(0.32), baseShadowColor);
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
HoverReliefWidget(
|
||||||
|
onPressed: canPick ? onTapPick : null,
|
||||||
|
borderRadius: outerRadius,
|
||||||
|
initialElevation: 10,
|
||||||
|
hoverElevation: 16,
|
||||||
|
initialShadowColor: initialPhotoShadow,
|
||||||
|
hoverShadowColor: hoverPhotoShadow,
|
||||||
|
clipBehavior: _hasImage ? Clip.none : Clip.antiAlias,
|
||||||
|
child: SizedBox(
|
||||||
|
width: side,
|
||||||
|
height: side,
|
||||||
|
child: !_hasImage
|
||||||
|
? ClipRRect(
|
||||||
|
borderRadius: outerRadius,
|
||||||
|
child: Center(
|
||||||
|
child: Image.asset(
|
||||||
|
placeholderAsset,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
width: side,
|
||||||
|
height: side,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: photoClipRadius,
|
||||||
|
child: _buildImage(fit: BoxFit.cover),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned.fill(
|
||||||
|
child: Image.asset(
|
||||||
|
kRegistrationPhotoFrameAsset,
|
||||||
|
fit: BoxFit.fill,
|
||||||
|
errorBuilder: (context, error, stackTrace) =>
|
||||||
|
const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (canClear)
|
||||||
|
Positioned(
|
||||||
|
top: 8 * scaleFactor,
|
||||||
|
right: 8 * scaleFactor,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onClear,
|
||||||
|
customBorder: const CircleBorder(),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
child: Image.asset(
|
||||||
|
'assets/images/cross.png',
|
||||||
|
width: isMobile ? 26 : 30,
|
||||||
|
height: isMobile ? 26 : 30,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
128
tests/scripts/register-am-dubois-test.mjs
Normal file
128
tests/scripts/register-am-dubois-test.mjs
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/v1/auth/register/am — jeu de test officiel Marie DUBOIS (docs/test-data + seed).
|
||||||
|
* Email : marie.dubois@ptits-pas.fr
|
||||||
|
*
|
||||||
|
* Photo attendue : tests/ressources/photos/dubois-marie.png
|
||||||
|
* Données alignées sur database/seed/03_seed_test_data.sql (NIR, dates, agrément, adresse).
|
||||||
|
* (réduction JPEG locale via sharp-cli si besoin, comme les autres scripts lourds)
|
||||||
|
*
|
||||||
|
* Usage : node tests/scripts/register-am-dubois-test.mjs [BASE_URL]
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
import https from 'https';
|
||||||
|
import http from 'http';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repoRoot = path.join(__dirname, '..', '..');
|
||||||
|
const photosDir = path.join(__dirname, '..', 'ressources', 'photos');
|
||||||
|
|
||||||
|
function shrinkToJpeg(inputPath, label) {
|
||||||
|
const out = path.join(os.tmpdir(), `ptitspas-${label}-${Date.now()}.jpg`);
|
||||||
|
const cmd = `npx --yes sharp-cli -i ${JSON.stringify(inputPath)} -o ${JSON.stringify(out)} -mq80 resize 600 600`;
|
||||||
|
execSync(cmd, { cwd: repoRoot, stdio: 'inherit', shell: true });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDataUri(filePath) {
|
||||||
|
const buf = fs.readFileSync(filePath);
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
const mime =
|
||||||
|
ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : 'image/png';
|
||||||
|
return `data:${mime};base64,${buf.toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const photoSrc = path.join(photosDir, 'dubois-marie.png');
|
||||||
|
|
||||||
|
let photoPath;
|
||||||
|
try {
|
||||||
|
console.error('Préparation photo (sharp-cli)…');
|
||||||
|
photoPath = shrinkToJpeg(photoSrc, 'am-dubois');
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
email: 'marie.dubois@ptits-pas.fr',
|
||||||
|
prenom: 'Marie',
|
||||||
|
nom: 'DUBOIS',
|
||||||
|
telephone: '0696345678',
|
||||||
|
adresse: '25 Rue de la République',
|
||||||
|
code_postal: '95870',
|
||||||
|
ville: 'Bezons',
|
||||||
|
photo_base64: toDataUri(photoPath),
|
||||||
|
photo_filename: 'marie_dubois.jpg',
|
||||||
|
consentement_photo: true,
|
||||||
|
date_naissance: '1980-06-08',
|
||||||
|
lieu_naissance_ville: 'Ajaccio',
|
||||||
|
lieu_naissance_pays: 'France',
|
||||||
|
nir: '280062A00100191',
|
||||||
|
numero_agrement: 'AGR-2019-095001',
|
||||||
|
date_agrement: '2019-09-01',
|
||||||
|
capacite_accueil: 4,
|
||||||
|
places_disponibles: 2,
|
||||||
|
biographie:
|
||||||
|
"Assistante maternelle agréée depuis 2019. Née en Corse à Ajaccio. Spécialité bébés 0-18 mois. " +
|
||||||
|
'Accueil bienveillant et cadre sécurisant. 2 places disponibles.',
|
||||||
|
acceptation_cgu: true,
|
||||||
|
acceptation_privacy: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const json = JSON.stringify(body);
|
||||||
|
const baseArg = process.argv[2] || 'https://app.ptits-pas.fr';
|
||||||
|
const base = new URL(baseArg.endsWith('/') ? baseArg.slice(0, -1) : baseArg);
|
||||||
|
const url = new URL('/api/v1/auth/register/am', `${base.protocol}//${base.host}`);
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||||
|
path: url.pathname,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(json, 'utf8'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const lib = url.protocol === 'https:' ? https : http;
|
||||||
|
|
||||||
|
console.error(`POST ${url.href} (payload ~${Math.round(json.length / 1024)} Ko)`);
|
||||||
|
|
||||||
|
const req = lib.request(opts, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (c) => {
|
||||||
|
data += c;
|
||||||
|
});
|
||||||
|
res.on('end', () => {
|
||||||
|
console.log('HTTP', res.statusCode);
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(data);
|
||||||
|
console.log(JSON.stringify(j, null, 2));
|
||||||
|
} catch {
|
||||||
|
console.log(data.slice(0, 4000));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (e) => {
|
||||||
|
console.error('Erreur réseau:', e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.setTimeout(120000, () => {
|
||||||
|
req.destroy();
|
||||||
|
console.error('Timeout 120s');
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.write(json);
|
||||||
|
req.end();
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
if (photoPath && fs.existsSync(photoPath)) fs.unlinkSync(photoPath);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
127
tests/scripts/register-am-mansouri-test.mjs
Normal file
127
tests/scripts/register-am-mansouri-test.mjs
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/v1/auth/register/am — jeu de test officiel Fatima EL MANSOURI (docs/test-data + seed).
|
||||||
|
* Email : fatima.elmansouri@ptits-pas.fr
|
||||||
|
*
|
||||||
|
* Photo attendue : tests/ressources/photos/mansouri-fatima.png
|
||||||
|
* Données alignées sur database/seed/03_seed_test_data.sql (NIR, dates, agrément, adresse).
|
||||||
|
*
|
||||||
|
* Usage : node tests/scripts/register-am-mansouri-test.mjs [BASE_URL]
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
import https from 'https';
|
||||||
|
import http from 'http';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repoRoot = path.join(__dirname, '..', '..');
|
||||||
|
const photosDir = path.join(__dirname, '..', 'ressources', 'photos');
|
||||||
|
|
||||||
|
function shrinkToJpeg(inputPath, label) {
|
||||||
|
const out = path.join(os.tmpdir(), `ptitspas-${label}-${Date.now()}.jpg`);
|
||||||
|
const cmd = `npx --yes sharp-cli -i ${JSON.stringify(inputPath)} -o ${JSON.stringify(out)} -mq80 resize 600 600`;
|
||||||
|
execSync(cmd, { cwd: repoRoot, stdio: 'inherit', shell: true });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDataUri(filePath) {
|
||||||
|
const buf = fs.readFileSync(filePath);
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
const mime =
|
||||||
|
ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : 'image/png';
|
||||||
|
return `data:${mime};base64,${buf.toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const photoSrc = path.join(photosDir, 'mansouri-fatima.png');
|
||||||
|
|
||||||
|
let photoPath;
|
||||||
|
try {
|
||||||
|
console.error('Préparation photo (sharp-cli)…');
|
||||||
|
photoPath = shrinkToJpeg(photoSrc, 'am-mansouri');
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
email: 'fatima.elmansouri@ptits-pas.fr',
|
||||||
|
prenom: 'Fatima',
|
||||||
|
nom: 'EL MANSOURI',
|
||||||
|
telephone: '0675456789',
|
||||||
|
adresse: '17 Boulevard Aristide Briand',
|
||||||
|
code_postal: '95870',
|
||||||
|
ville: 'Bezons',
|
||||||
|
photo_base64: toDataUri(photoPath),
|
||||||
|
photo_filename: 'fatima_elmansouri.jpg',
|
||||||
|
consentement_photo: true,
|
||||||
|
date_naissance: '1975-11-12',
|
||||||
|
lieu_naissance_ville: 'Casablanca',
|
||||||
|
lieu_naissance_pays: 'Maroc',
|
||||||
|
nir: '275119900100102',
|
||||||
|
numero_agrement: 'AGR-2017-095002',
|
||||||
|
date_agrement: '2017-06-15',
|
||||||
|
capacite_accueil: 3,
|
||||||
|
places_disponibles: 1,
|
||||||
|
biographie:
|
||||||
|
"Assistante maternelle expérimentée. Née à l'étranger. Spécialité 1-3 ans. " +
|
||||||
|
'Accueil à la journée. 1 place disponible.',
|
||||||
|
acceptation_cgu: true,
|
||||||
|
acceptation_privacy: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const json = JSON.stringify(body);
|
||||||
|
const baseArg = process.argv[2] || 'https://app.ptits-pas.fr';
|
||||||
|
const base = new URL(baseArg.endsWith('/') ? baseArg.slice(0, -1) : baseArg);
|
||||||
|
const url = new URL('/api/v1/auth/register/am', `${base.protocol}//${base.host}`);
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||||
|
path: url.pathname,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(json, 'utf8'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const lib = url.protocol === 'https:' ? https : http;
|
||||||
|
|
||||||
|
console.error(`POST ${url.href} (payload ~${Math.round(json.length / 1024)} Ko)`);
|
||||||
|
|
||||||
|
const req = lib.request(opts, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (c) => {
|
||||||
|
data += c;
|
||||||
|
});
|
||||||
|
res.on('end', () => {
|
||||||
|
console.log('HTTP', res.statusCode);
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(data);
|
||||||
|
console.log(JSON.stringify(j, null, 2));
|
||||||
|
} catch {
|
||||||
|
console.log(data.slice(0, 4000));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (e) => {
|
||||||
|
console.error('Erreur réseau:', e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.setTimeout(120000, () => {
|
||||||
|
req.destroy();
|
||||||
|
console.error('Timeout 120s');
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.write(json);
|
||||||
|
req.end();
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
if (photoPath && fs.existsSync(photoPath)) fs.unlinkSync(photoPath);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,10 +2,10 @@
|
|||||||
* POST /api/v1/auth/register/parent — jeu de test officiel couple DURAND / ROUSSEAU (docs/test-data + seed).
|
* POST /api/v1/auth/register/parent — jeu de test officiel couple DURAND / ROUSSEAU (docs/test-data + seed).
|
||||||
* Emails : amelie.durand@ptits-pas.fr, julien.rousseau@ptits-pas.fr
|
* Emails : amelie.durand@ptits-pas.fr, julien.rousseau@ptits-pas.fr
|
||||||
*
|
*
|
||||||
* Les PNG dans ressources/Photos dépassent la limite JSON (~15 Mo) du serveur : réduction locale
|
* Les PNG dans tests/ressources/photos dépassent la limite JSON (~15 Mo) du serveur : réduction locale
|
||||||
* via npx sharp-cli (resize 600 + JPEG q80) avant encodage base64.
|
* via npx sharp-cli (resize 600 + JPEG q80) avant encodage base64.
|
||||||
*
|
*
|
||||||
* Usage : node scripts/register-parent-durand-rousseau-test.mjs [BASE_URL]
|
* Usage : node tests/scripts/register-parent-durand-rousseau-test.mjs [BASE_URL]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
@ -17,8 +17,8 @@ import { execSync } from 'child_process';
|
|||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const repoRoot = path.join(__dirname, '..');
|
const repoRoot = path.join(__dirname, '..', '..');
|
||||||
const photosDir = path.join(repoRoot, 'ressources', 'Photos');
|
const photosDir = path.join(__dirname, '..', 'ressources', 'photos');
|
||||||
|
|
||||||
function shrinkToJpeg(inputPath, label) {
|
function shrinkToJpeg(inputPath, label) {
|
||||||
const out = path.join(os.tmpdir(), `ptitspas-${label}-${Date.now()}.jpg`);
|
const out = path.join(os.tmpdir(), `ptitspas-${label}-${Date.now()}.jpg`);
|
||||||
@ -41,8 +41,8 @@ const presentationDossier =
|
|||||||
"Nous recherchons une assistante maternelle à Bezons pour accueillir nos enfants dans un cadre bienveillant et stable. " +
|
"Nous recherchons une assistante maternelle à Bezons pour accueillir nos enfants dans un cadre bienveillant et stable. " +
|
||||||
"Merci pour l'étude de notre dossier.";
|
"Merci pour l'étude de notre dossier.";
|
||||||
|
|
||||||
const chloeSrc = path.join(photosDir, 'G_Chloé.png');
|
const chloeSrc = path.join(photosDir, 'rousseau-chloe.png');
|
||||||
const hugoSrc = path.join(photosDir, 'G_Hugo.png');
|
const hugoSrc = path.join(photosDir, 'rousseau-hugo.png');
|
||||||
|
|
||||||
let chloeJpg;
|
let chloeJpg;
|
||||||
let hugoJpg;
|
let hugoJpg;
|
||||||
@ -2,7 +2,7 @@
|
|||||||
* POST /api/v1/auth/register/parent — jeu de test officiel David LECOMTE (père isolé).
|
* POST /api/v1/auth/register/parent — jeu de test officiel David LECOMTE (père isolé).
|
||||||
* Email : david.lecomte@ptits-pas.fr
|
* Email : david.lecomte@ptits-pas.fr
|
||||||
*
|
*
|
||||||
* Usage : node scripts/register-parent-lecomte-test.mjs [BASE_URL]
|
* Usage : node tests/scripts/register-parent-lecomte-test.mjs [BASE_URL]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
@ -12,8 +12,7 @@ import http from 'http';
|
|||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const repoRoot = path.join(__dirname, '..');
|
const photosDir = path.join(__dirname, '..', 'ressources', 'photos');
|
||||||
const photosDir = path.join(repoRoot, 'ressources', 'Photos');
|
|
||||||
|
|
||||||
function toDataUri(filePath) {
|
function toDataUri(filePath) {
|
||||||
const buf = fs.readFileSync(filePath);
|
const buf = fs.readFileSync(filePath);
|
||||||
@ -40,7 +39,7 @@ const body = {
|
|||||||
nom: 'LECOMTE',
|
nom: 'LECOMTE',
|
||||||
date_naissance: '2023-04-15',
|
date_naissance: '2023-04-15',
|
||||||
genre: 'H',
|
genre: 'H',
|
||||||
photo_base64: toDataUri(path.join(photosDir, 'C_Maxime.png')),
|
photo_base64: toDataUri(path.join(photosDir, 'lecomte-maxime.png')),
|
||||||
photo_filename: 'maxime_lecomte.png',
|
photo_filename: 'maxime_lecomte.png',
|
||||||
grossesse_multiple: false,
|
grossesse_multiple: false,
|
||||||
},
|
},
|
||||||
@ -2,8 +2,8 @@
|
|||||||
* POST /api/v1/auth/register/parent — jeu de test officiel famille MARTIN (docs/test-data + seed).
|
* POST /api/v1/auth/register/parent — jeu de test officiel famille MARTIN (docs/test-data + seed).
|
||||||
* Emails canoniques : claire.martin@ptits-pas.fr, thomas.martin@ptits-pas.fr
|
* Emails canoniques : claire.martin@ptits-pas.fr, thomas.martin@ptits-pas.fr
|
||||||
*
|
*
|
||||||
* Usage : node scripts/register-parent-martin-test.mjs [BASE_URL]
|
* Usage : node tests/scripts/register-parent-martin-test.mjs [BASE_URL]
|
||||||
* Ex. : node scripts/register-parent-martin-test.mjs https://app.ptits-pas.fr
|
* Ex. : node tests/scripts/register-parent-martin-test.mjs https://app.ptits-pas.fr
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
@ -13,8 +13,7 @@ import http from 'http';
|
|||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const repoRoot = path.join(__dirname, '..');
|
const photosDir = path.join(__dirname, '..', 'ressources', 'photos');
|
||||||
const photosDir = path.join(repoRoot, 'ressources', 'Photos');
|
|
||||||
|
|
||||||
function toDataUri(filePath) {
|
function toDataUri(filePath) {
|
||||||
const buf = fs.readFileSync(filePath);
|
const buf = fs.readFileSync(filePath);
|
||||||
@ -46,7 +45,7 @@ const body = {
|
|||||||
nom: 'MARTIN',
|
nom: 'MARTIN',
|
||||||
date_naissance: '2023-02-15',
|
date_naissance: '2023-02-15',
|
||||||
genre: 'F',
|
genre: 'F',
|
||||||
photo_base64: toDataUri(path.join(photosDir, 'C_Emma MARTIN.png')),
|
photo_base64: toDataUri(path.join(photosDir, 'martin-emma.png')),
|
||||||
photo_filename: 'emma_martin.png',
|
photo_filename: 'emma_martin.png',
|
||||||
grossesse_multiple: true,
|
grossesse_multiple: true,
|
||||||
},
|
},
|
||||||
@ -55,7 +54,7 @@ const body = {
|
|||||||
nom: 'MARTIN',
|
nom: 'MARTIN',
|
||||||
date_naissance: '2023-02-15',
|
date_naissance: '2023-02-15',
|
||||||
genre: 'H',
|
genre: 'H',
|
||||||
photo_base64: toDataUri(path.join(photosDir, 'C_Noah MARTIN_2.png')),
|
photo_base64: toDataUri(path.join(photosDir, 'martin-noah.png')),
|
||||||
photo_filename: 'noah_martin.png',
|
photo_filename: 'noah_martin.png',
|
||||||
grossesse_multiple: true,
|
grossesse_multiple: true,
|
||||||
},
|
},
|
||||||
@ -64,7 +63,7 @@ const body = {
|
|||||||
nom: 'MARTIN',
|
nom: 'MARTIN',
|
||||||
date_naissance: '2023-02-15',
|
date_naissance: '2023-02-15',
|
||||||
genre: 'F',
|
genre: 'F',
|
||||||
photo_base64: toDataUri(path.join(photosDir, 'C_Léa MMARTIN.png')),
|
photo_base64: toDataUri(path.join(photosDir, 'martin-lea.png')),
|
||||||
photo_filename: 'lea_martin.png',
|
photo_filename: 'lea_martin.png',
|
||||||
grossesse_multiple: true,
|
grossesse_multiple: true,
|
||||||
},
|
},
|
||||||
Loading…
x
Reference in New Issue
Block a user