Compare commits
6 Commits
62b2466cce
...
d550e57cb8
| Author | SHA1 | Date | |
|---|---|---|---|
| d550e57cb8 | |||
| b4b44cb1b9 | |||
| 58607cdbc9 | |||
| 12bf85b7bd | |||
| 01c1be8f16 | |||
| cdae9b5f7c |
@ -1,27 +1,32 @@
|
||||
#!/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]
|
||||
# Exemple: ./scripts/test-register-am.sh https://app.ptits-pas.fr/api/v1
|
||||
# ./scripts/test-register-am.sh http://localhost:3000/api/v1
|
||||
# Exemple: ./scripts/test-register-am.sh 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 "---"
|
||||
|
||||
curl -s -w "\n\nHTTP %{http_code}\n" -X POST "$BASE_URL/auth/register/am" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "marie.dupont.test@ptits-pas.fr",
|
||||
"prenom": "Marie",
|
||||
"nom": "DUPONT",
|
||||
"email": "smoke.am.curl@ptits-pas.fr",
|
||||
"prenom": "Smoke",
|
||||
"nom": "CURLTEST",
|
||||
"telephone": "0612345678",
|
||||
"adresse": "1 rue Test",
|
||||
"code_postal": "75001",
|
||||
"ville": "Paris",
|
||||
"consentement_photo": true,
|
||||
"nir": "123456789012345",
|
||||
"numero_agrement": "AGR-2024-001",
|
||||
"capacite_accueil": 4,
|
||||
"adresse": "1 rue Smoke",
|
||||
"code_postal": "95870",
|
||||
"ville": "Bezons",
|
||||
"consentement_photo": false,
|
||||
"date_naissance": "1986-12-15",
|
||||
"nir": "186127500100279",
|
||||
"numero_agrement": "AGR-SMOKE-CURL-001",
|
||||
"capacite_accueil": 3,
|
||||
"places_disponibles": 2,
|
||||
"acceptation_cgu": true,
|
||||
"acceptation_privacy": true
|
||||
}'
|
||||
|
||||
@ -138,6 +138,12 @@ export class Users {
|
||||
@Column({ name: 'date_naissance', type: 'date', nullable: true })
|
||||
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' })
|
||||
cree_le: Date;
|
||||
|
||||
|
||||
@ -99,10 +99,10 @@ export class MailService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Accusé de réception inscription parent : demande enregistrée + n° de dossier.
|
||||
* L’utilisateur est en attente de validation ; pas de lien création MDP à ce stade.
|
||||
* Accusé de réception inscription (parent ou assistante maternelle) :
|
||||
* demande enregistrée + n° de dossier. En attente de validation ; pas de lien création MDP à ce stade.
|
||||
*/
|
||||
async sendParentRegistrationPendingEmail(
|
||||
async sendRegistrationPendingEmail(
|
||||
to: string,
|
||||
prenom: string,
|
||||
nom: string,
|
||||
|
||||
@ -5,6 +5,7 @@ import { Public } from 'src/common/decorators/public.decorator';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { RegisterParentCompletDto } from './dto/register-parent-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 { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
@ -62,10 +63,10 @@ export class AuthController {
|
||||
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: 409, description: 'Email déjà utilisé' })
|
||||
async inscrireAMComplet(@Body() dto: RegisterAMCompletDto) {
|
||||
async inscrireAMComplet(@Body() dto: RegisterAMCompletDto): Promise<RegisterAmResponseDto> {
|
||||
return this.authService.inscrireAMComplet(dto);
|
||||
}
|
||||
|
||||
|
||||
@ -376,14 +376,14 @@ export class AuthService {
|
||||
const numeroDossier = resultat.parent1.numero_dossier ?? '';
|
||||
|
||||
try {
|
||||
await this.mailService.sendParentRegistrationPendingEmail(
|
||||
await this.mailService.sendRegistrationPendingEmail(
|
||||
resultat.parent1.email,
|
||||
resultat.parent1.prenom ?? '',
|
||||
resultat.parent1.nom ?? '',
|
||||
numeroDossier,
|
||||
);
|
||||
if (resultat.parent2) {
|
||||
await this.mailService.sendParentRegistrationPendingEmail(
|
||||
await this.mailService.sendRegistrationPendingEmail(
|
||||
resultat.parent2.email,
|
||||
resultat.parent2.prenom ?? '',
|
||||
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 nirValidation = validateNir(nirNormalized, {
|
||||
dateNaissance: dto.date_naissance,
|
||||
@ -496,6 +502,8 @@ export class AuthService {
|
||||
consentement_photo: dto.consentement_photo,
|
||||
date_consentement_photo: dateConsentementPhoto,
|
||||
date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined,
|
||||
lieu_naissance_ville: dto.lieu_naissance_ville,
|
||||
lieu_naissance_pays: dto.lieu_naissance_pays,
|
||||
numero_dossier: numeroDossier,
|
||||
});
|
||||
const userEnregistre = await manager.save(Users, user);
|
||||
@ -506,6 +514,7 @@ export class AuthService {
|
||||
approval_number: dto.numero_agrement,
|
||||
nir: nirNormalized,
|
||||
max_children: dto.capacite_accueil,
|
||||
places_available: dto.capacite_accueil,
|
||||
biography: dto.biographie,
|
||||
residence_city: dto.ville ?? undefined,
|
||||
agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined,
|
||||
@ -523,11 +532,28 @@ export class AuthService {
|
||||
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 {
|
||||
message:
|
||||
'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
|
||||
user_id: resultat.user.id,
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
numero_dossier: numeroDossier,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
@ -96,17 +97,21 @@ export class RegisterAMCompletDto {
|
||||
@IsDateString()
|
||||
date_naissance?: string;
|
||||
|
||||
@ApiProperty({ example: 'Paris', required: false, description: 'Ville de naissance' })
|
||||
@IsOptional()
|
||||
@ApiProperty({ example: 'Paris', description: 'Ville de naissance (obligatoire)' })
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@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)
|
||||
lieu_naissance_ville?: string;
|
||||
lieu_naissance_ville: string;
|
||||
|
||||
@ApiProperty({ example: 'France', required: false, description: 'Pays de naissance' })
|
||||
@IsOptional()
|
||||
@ApiProperty({ example: 'France', description: 'Pays de naissance (obligatoire)' })
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@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)
|
||||
lieu_naissance_pays?: string;
|
||||
lieu_naissance_pays: string;
|
||||
|
||||
@ApiProperty({ example: '123456789012345', description: 'NIR 15 caractères (chiffres, ou 2A/2B pour la Corse)' })
|
||||
@IsString()
|
||||
@ -133,6 +138,17 @@ export class RegisterAMCompletDto {
|
||||
@Max(10, { message: 'La capacité ne peut pas dépasser 10' })
|
||||
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)
|
||||
// ============================================
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
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 {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
@ -74,7 +90,10 @@ export class DossiersService {
|
||||
code_postal: user.code_postal,
|
||||
profession: user.profession,
|
||||
date_naissance: user.date_naissance,
|
||||
lieu_naissance_ville: user.lieu_naissance_ville,
|
||||
lieu_naissance_pays: user.lieu_naissance_pays,
|
||||
photo_url: user.photo_url,
|
||||
consentement_photo: user.consentement_photo,
|
||||
statut: user.statut,
|
||||
};
|
||||
}
|
||||
|
||||
@ -23,8 +23,14 @@ export class DossierAmUserDto {
|
||||
profession?: string;
|
||||
@ApiProperty({ required: false })
|
||||
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 })
|
||||
photo_url?: string;
|
||||
@ApiProperty({ required: false, description: 'Consentement utilisation photo' })
|
||||
consentement_photo?: boolean;
|
||||
@ApiProperty({ enum: StatutUtilisateurType })
|
||||
statut: StatutUtilisateurType;
|
||||
}
|
||||
|
||||
@ -55,6 +55,8 @@ CREATE TABLE utilisateurs (
|
||||
telephone VARCHAR(20), -- Unifié (mobile privilégié)
|
||||
adresse TEXT,
|
||||
date_naissance DATE,
|
||||
lieu_naissance_ville VARCHAR(100),
|
||||
lieu_naissance_pays VARCHAR(100),
|
||||
photo_url TEXT, -- Obligatoire pour AM, non utilisé pour parents
|
||||
consentement_photo BOOLEAN DEFAULT false,
|
||||
date_consentement_photo TIMESTAMPTZ,
|
||||
@ -125,6 +127,33 @@ CREATE TABLE enfants_parents (
|
||||
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
|
||||
-- ==========================================================
|
||||
@ -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;
|
||||
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
|
||||
-- ==========================================================
|
||||
|
||||
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).
|
||||
-- - Marie Dubois : née en Corse à Ajaccio → NIR 2A (test exception Corse).
|
||||
-- - 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)
|
||||
-- ============================================================
|
||||
|
||||
|
||||
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
|
||||
|
||||
**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
|
||||
|
||||
| Nom | Prénom | Email | Téléphone | Mobile |
|
||||
@ -49,7 +51,14 @@ Fichier CSV contenant les utilisateurs de test pour valider le workflow de créa
|
||||
**Rôle** : `assistante_maternelle`
|
||||
**Spécialité** : Bébés 0-18 mois
|
||||
**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
|
||||
|
||||
@ -60,7 +69,14 @@ Fichier CSV contenant les utilisateurs de test pour valider le workflow de créa
|
||||
**Rôle** : `assistante_maternelle`
|
||||
**Spécialité** : 1-3 ans
|
||||
**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
|
||||
// Marie DUBOIS s'inscrit
|
||||
POST /api/v1/auth/register
|
||||
{
|
||||
"email": "marie.dubois@ptits-pas.fr",
|
||||
"password": "Test1234!",
|
||||
"prenom": "Marie",
|
||||
"nom": "DUBOIS",
|
||||
"telephone": "01 39 98 67 89",
|
||||
"mobile": "06 96 34 56 78",
|
||||
"role": "assistante_maternelle"
|
||||
}
|
||||
Inscription en un flux : `POST /api/v1/auth/register/am` (identité, NIR, agrément, photo optionnelle, CGU). Voir le DTO backend `RegisterAMCompletDto`.
|
||||
|
||||
```bash
|
||||
# Jeux alignés sur le seed (mêmes NIR / dates / agréments que 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]
|
||||
```
|
||||
|
||||
#### Scénario 3 : Inscription parent
|
||||
|
||||
@ -177,10 +177,12 @@
|
||||
</div>
|
||||
<div class="info-section">
|
||||
<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>Spécialité :</strong> Bébés 0-18 mois</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>
|
||||
@ -207,10 +209,12 @@
|
||||
</div>
|
||||
<div class="info-section">
|
||||
<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>Spécialité :</strong> Enfants 1-3 ans</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>
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
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 {
|
||||
// Step 1: Identity Info
|
||||
String firstName = '';
|
||||
@ -24,7 +27,11 @@ class AmRegistrationData extends ChangeNotifier {
|
||||
// String placeOfBirth = ''; // Remplacé par birthCity et birthCountry
|
||||
String nir = ''; // Numéro de Sécurité Sociale
|
||||
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
|
||||
/// Places libres actuellement (0 ≤ valeur ≤ capacité) — API `places_disponibles`.
|
||||
int? placesAvailable;
|
||||
|
||||
// Step 3: Presentation & CGU
|
||||
String presentationText = '';
|
||||
@ -68,7 +75,9 @@ class AmRegistrationData extends ChangeNotifier {
|
||||
// String? placeOfBirth, // Remplacé
|
||||
String? nir,
|
||||
String? agrementNumber,
|
||||
DateTime? agreementDate,
|
||||
int? capacity,
|
||||
int? placesAvailable,
|
||||
}) {
|
||||
this.photoPath = photoPath;
|
||||
this.photoBytes = photoBytes;
|
||||
@ -80,7 +89,9 @@ class AmRegistrationData extends ChangeNotifier {
|
||||
// this.placeOfBirth = placeOfBirth ?? this.placeOfBirth; // Remplacé
|
||||
this.nir = nir ?? this.nir;
|
||||
this.agrementNumber = agrementNumber ?? this.agrementNumber;
|
||||
this.agreementDate = agreementDate ?? this.agreementDate;
|
||||
this.capacity = capacity ?? this.capacity;
|
||||
this.placesAvailable = placesAvailable ?? this.placesAvailable;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@ -95,11 +106,11 @@ class AmRegistrationData extends ChangeNotifier {
|
||||
|
||||
// --- Getters for validation or display ---
|
||||
bool get isStep1Complete =>
|
||||
firstName.isNotEmpty &&
|
||||
lastName.isNotEmpty &&
|
||||
streetAddress.isNotEmpty && // Modifié
|
||||
postalCode.isNotEmpty && // Nouveau
|
||||
city.isNotEmpty && // Nouveau
|
||||
firstName.trim().length >= 2 &&
|
||||
lastName.trim().length >= 2 &&
|
||||
streetAddress.isNotEmpty &&
|
||||
postalCode.isNotEmpty &&
|
||||
city.isNotEmpty &&
|
||||
phone.isNotEmpty &&
|
||||
email.isNotEmpty;
|
||||
// password n'est pas requis à l'inscription (défini après validation par lien email)
|
||||
@ -112,13 +123,20 @@ class AmRegistrationData extends ChangeNotifier {
|
||||
!photoPath!.startsWith('assets/'));
|
||||
|
||||
bool get isStep2Complete =>
|
||||
(_hasUserPhoto ? photoConsent == true : true) &&
|
||||
_hasUserPhoto &&
|
||||
photoConsent == true &&
|
||||
dateOfBirth != null &&
|
||||
birthCity.isNotEmpty &&
|
||||
birthCountry.isNotEmpty &&
|
||||
nir.isNotEmpty && // Basic check, could add validation
|
||||
birthCity.trim().length >= 2 &&
|
||||
birthCountry.trim().length >= 2 &&
|
||||
nir.isNotEmpty &&
|
||||
agrementNumber.isNotEmpty &&
|
||||
capacity != null && capacity! > 0;
|
||||
agreementDate != null &&
|
||||
capacity != null &&
|
||||
capacity! >= 1 &&
|
||||
capacity! <= kAmCapaciteAccueilMax &&
|
||||
placesAvailable != null &&
|
||||
placesAvailable! >= 0 &&
|
||||
placesAvailable! <= capacity!;
|
||||
|
||||
bool get isStep3Complete =>
|
||||
// presentationText is optional as per CDC (message au gestionnaire)
|
||||
@ -135,7 +153,7 @@ class AmRegistrationData extends ChangeNotifier {
|
||||
'phone: $phone, email: $email, '
|
||||
// 'photoPath: $photoPath, photoConsent: $photoConsent, ' // Commenté car déplacé/modifié
|
||||
'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, '
|
||||
'presentationText: $presentationText, cguAccepted: $cguAccepted)';
|
||||
}
|
||||
|
||||
@ -28,6 +28,7 @@ class AmRegisterStep1Screen extends StatelessWidget {
|
||||
title: 'Vos informations personnelles',
|
||||
cardColor: CardColorHorizontal.blue,
|
||||
initialData: initialData,
|
||||
minPersonNameLength: 2,
|
||||
previousRoute: '/register-choice',
|
||||
onSubmit: (data, {hasSecondPerson, sameAddress}) {
|
||||
registrationData.updateIdentityInfo(
|
||||
|
||||
@ -23,7 +23,9 @@ class AmRegisterStep2Screen extends StatelessWidget {
|
||||
birthCountry: registrationData.birthCountry,
|
||||
nir: registrationData.nir,
|
||||
agrementNumber: registrationData.agrementNumber,
|
||||
agreementDate: registrationData.agreementDate,
|
||||
capacity: registrationData.capacity,
|
||||
placesAvailable: registrationData.placesAvailable,
|
||||
);
|
||||
|
||||
return ProfessionalInfoFormScreen(
|
||||
@ -43,7 +45,9 @@ class AmRegisterStep2Screen extends StatelessWidget {
|
||||
birthCountry: data.birthCountry,
|
||||
nir: data.nir,
|
||||
agrementNumber: data.agrementNumber,
|
||||
agreementDate: data.agreementDate,
|
||||
capacity: data.capacity,
|
||||
placesAvailable: data.placesAvailable,
|
||||
);
|
||||
context.go('/am-register-step3');
|
||||
},
|
||||
|
||||
@ -27,6 +27,19 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
|
||||
|
||||
Future<void> _submitAMRegistration(AmRegistrationData registrationData) async {
|
||||
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);
|
||||
try {
|
||||
await AuthService.registerAM(registrationData);
|
||||
@ -204,7 +217,9 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
|
||||
birthCountry: data.birthCountry,
|
||||
nir: data.nir,
|
||||
agrementNumber: data.agrementNumber,
|
||||
agreementDate: data.agreementDate,
|
||||
capacity: data.capacity,
|
||||
placesAvailable: data.placesAvailable,
|
||||
photoConsent: data.photoConsent,
|
||||
),
|
||||
onSubmit: (d) {},
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:http/http.dart' as http;
|
||||
@ -22,6 +23,17 @@ class AuthService {
|
||||
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
|
||||
/// Retourne l'utilisateur connecté avec le flag changement_mdp_obligatoire
|
||||
static Future<AppUser> login(String email, String password) async {
|
||||
@ -150,11 +162,25 @@ class AuthService {
|
||||
/// Inscription AM complète (POST /auth/register/am).
|
||||
/// En cas de succès (201), aucune donnée utilisateur retournée ; rediriger vers login.
|
||||
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? photoFilename;
|
||||
|
||||
if (data.photoBytes != null && data.photoBytes!.isNotEmpty) {
|
||||
photoBase64 = 'data:image/jpeg;base64,${base64Encode(data.photoBytes!)}';
|
||||
final mime = _imageMimeForBytes(data.photoBytes!);
|
||||
photoBase64 = 'data:$mime;base64,${base64Encode(data.photoBytes!)}';
|
||||
final fn = (data.photoFilename ?? '').trim();
|
||||
photoFilename = fn.isNotEmpty ? fn : 'photo_am.jpg';
|
||||
} else if (data.photoPath != null &&
|
||||
@ -165,7 +191,8 @@ class AuthService {
|
||||
final file = File(data.photoPath!);
|
||||
if (await file.exists()) {
|
||||
final bytes = await file.readAsBytes();
|
||||
photoBase64 = 'data:image/jpeg;base64,${base64Encode(bytes)}';
|
||||
final mime = _imageMimeForBytes(bytes);
|
||||
photoBase64 = 'data:$mime;base64,${base64Encode(bytes)}';
|
||||
photoFilename =
|
||||
_basenameFromPath(data.photoPath!) ?? 'photo_am.jpg';
|
||||
}
|
||||
@ -173,6 +200,10 @@ class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
if (photoBase64 == null || photoBase64.isEmpty) {
|
||||
throw Exception('Une photo de profil est requise pour finaliser l’inscription.');
|
||||
}
|
||||
|
||||
final body = <String, dynamic>{
|
||||
'email': data.email,
|
||||
'prenom': data.firstName,
|
||||
@ -181,17 +212,20 @@ class AuthService {
|
||||
'adresse': data.streetAddress.isNotEmpty ? data.streetAddress : null,
|
||||
'code_postal': data.postalCode.isNotEmpty ? data.postalCode : null,
|
||||
'ville': data.city.isNotEmpty ? data.city : null,
|
||||
if (photoBase64 != null) 'photo_base64': photoBase64,
|
||||
if (photoBase64 != null) 'photo_filename': photoFilename ?? 'photo_am.jpg',
|
||||
'photo_base64': photoBase64,
|
||||
'photo_filename': photoFilename ?? 'photo_am.jpg',
|
||||
'consentement_photo': data.photoConsent,
|
||||
'date_naissance': data.dateOfBirth != null
|
||||
? '${data.dateOfBirth!.year}-${data.dateOfBirth!.month.toString().padLeft(2, '0')}-${data.dateOfBirth!.day.toString().padLeft(2, '0')}'
|
||||
: null,
|
||||
'lieu_naissance_ville': data.birthCity.isNotEmpty ? data.birthCity : null,
|
||||
'lieu_naissance_pays': data.birthCountry.isNotEmpty ? data.birthCountry : null,
|
||||
'lieu_naissance_ville': lieuVille,
|
||||
'lieu_naissance_pays': lieuPays,
|
||||
'nir': normalizeNir(data.nir),
|
||||
'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,
|
||||
'places_disponibles': data.placesAvailable!,
|
||||
'biographie': data.presentationText.isNotEmpty ? data.presentationText : null,
|
||||
'acceptation_cgu': data.cguAccepted,
|
||||
'acceptation_privacy': data.cguAccepted,
|
||||
|
||||
@ -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)}';
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
if (raw.length != 15) return false;
|
||||
final dept = raw.substring(5, 7);
|
||||
final restDigits = raw.substring(0, 5) + (dept == '2A' ? '19' : dept == '2B' ? '18' : dept) + raw.substring(7, 15);
|
||||
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);
|
||||
final r = raw.toUpperCase();
|
||||
if (r.length != 15) return false;
|
||||
return RegExp(r'^[1-3]\d{4}(?:2A|2B|\d{2})\d{6}\d{2}$').hasMatch(r);
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
String n = raw13;
|
||||
if (raw13.length >= 7 && (raw13.substring(5, 7) == '2A' || raw13.substring(5, 7) == '2B')) {
|
||||
n = raw13.substring(0, 5) + (raw13.substring(5, 7) == '2A' ? '19' : '18') + raw13.substring(7);
|
||||
String n;
|
||||
if (raw13.length >= 7 && raw13.substring(5, 7) == '2A') {
|
||||
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);
|
||||
if (big == null) return -1;
|
||||
|
||||
@ -7,7 +7,7 @@ import '../models/user_registration_data.dart';
|
||||
import '../models/card_assets.dart';
|
||||
import 'custom_app_text_field.dart';
|
||||
import 'form_field_wrapper.dart';
|
||||
import 'hover_relief_widget.dart';
|
||||
import 'registration_photo_slot.dart';
|
||||
import '../config/display_config.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'
|
||||
'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) {
|
||||
final b = c.imageBytes;
|
||||
if (b != null && b.isNotEmpty) return true;
|
||||
return c.imageFile != null;
|
||||
return registrationPhotoSlotHasImage(
|
||||
imageBytes: c.imageBytes,
|
||||
imageFile: c.imageFile,
|
||||
imagePathOrAsset: null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChildPhotoImage(ChildData c, {required BoxFit fit}) {
|
||||
@ -194,11 +193,6 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
|
||||
final Color baseCardColorForShadow = widget.childData.cardColor == CardColorVertical.lavender
|
||||
? Colors.purple.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 editableCardWidth = config.isMobile
|
||||
? double.infinity
|
||||
@ -222,14 +216,15 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildEditablePhotoArea(
|
||||
context: context,
|
||||
config: config,
|
||||
RegistrationPhotoSlot(
|
||||
side: photoSide,
|
||||
scaleFactor: scaleFactor,
|
||||
photoSide: photoSide,
|
||||
childData: widget.childData,
|
||||
initialPhotoShadow: initialPhotoShadow,
|
||||
hoverPhotoShadow: hoverPhotoShadow,
|
||||
imageBytes: widget.childData.imageBytes,
|
||||
imageFile: widget.childData.imageFile,
|
||||
onTapPick: !config.isReadonly ? widget.onPickImage : null,
|
||||
onClear: !config.isReadonly ? widget.onClearImage : null,
|
||||
baseShadowColor: baseCardColorForShadow,
|
||||
isMobile: config.isMobile,
|
||||
),
|
||||
SizedBox(height: 8.0 * scaleFactor),
|
||||
_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)
|
||||
Widget _buildReadonlyDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) {
|
||||
// Convertir la couleur verticale (pour mobile) en couleur horizontale (pour desktop/récap)
|
||||
|
||||
@ -27,6 +27,8 @@ class CustomAppTextField extends StatefulWidget {
|
||||
final IconData? suffixIcon;
|
||||
final double labelFontSize;
|
||||
final double inputFontSize;
|
||||
/// Espace vertical entre le libellé et la zone de saisie.
|
||||
final double labelFieldSpacing;
|
||||
final bool showLabel;
|
||||
final Iterable<String>? autofillHints;
|
||||
final TextInputAction? textInputAction;
|
||||
@ -56,6 +58,7 @@ class CustomAppTextField extends StatefulWidget {
|
||||
this.suffixIcon,
|
||||
this.labelFontSize = 18.0,
|
||||
this.inputFontSize = 18.0,
|
||||
this.labelFieldSpacing = 6.0,
|
||||
this.autofillHints,
|
||||
this.textInputAction,
|
||||
this.onFieldSubmitted,
|
||||
@ -105,7 +108,7 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
SizedBox(height: widget.labelFieldSpacing),
|
||||
],
|
||||
// Pas de hauteur fixe sur le TextFormField : le message d’erreur du
|
||||
// validateur s’affiche en dessous ; un SizedBox fixe le masquait.
|
||||
|
||||
@ -18,12 +18,13 @@ class NirTextField extends StatelessWidget {
|
||||
final bool enabled;
|
||||
final bool readOnly;
|
||||
final CustomAppTextFieldStyle style;
|
||||
final double labelFieldSpacing;
|
||||
|
||||
const NirTextField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
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.fieldWidth = double.infinity,
|
||||
this.fieldHeight = 53.0,
|
||||
@ -32,6 +33,7 @@ class NirTextField extends StatelessWidget {
|
||||
this.enabled = true,
|
||||
this.readOnly = false,
|
||||
this.style = CustomAppTextFieldStyle.beige,
|
||||
this.labelFieldSpacing = 6.0,
|
||||
});
|
||||
|
||||
@override
|
||||
@ -50,6 +52,7 @@ class NirTextField extends StatelessWidget {
|
||||
enabled: enabled,
|
||||
readOnly: readOnly,
|
||||
style: style,
|
||||
labelFieldSpacing: labelFieldSpacing,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,6 +55,8 @@ class PersonalInfoFormScreen extends StatefulWidget {
|
||||
final PersonalInfoData? referenceAddressData; // Pour pré-remplir si "même adresse"
|
||||
final bool embedContentOnly; // Si true, affiche seulement la carte (sans scaffold/fond/titre)
|
||||
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({
|
||||
super.key,
|
||||
@ -72,6 +74,7 @@ class PersonalInfoFormScreen extends StatefulWidget {
|
||||
this.referenceAddressData,
|
||||
this.embedContentOnly = false,
|
||||
this.onEdit,
|
||||
this.minPersonNameLength,
|
||||
});
|
||||
|
||||
@override
|
||||
@ -214,6 +217,16 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
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é.
|
||||
String? _validateFrenchPhoneIfSecondParentNeeded(String? value) {
|
||||
if (widget.showSecondPersonToggle && !_fieldsEnabled) {
|
||||
@ -837,6 +850,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
enabled: _fieldsEnabled,
|
||||
focusOrder: _focusLastName,
|
||||
focusNode: _lastNameFocus,
|
||||
fieldValidator: widget.minPersonNameLength != null
|
||||
? _validatePersonNameWithMinLength
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
@ -849,6 +865,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
enabled: _fieldsEnabled,
|
||||
focusOrder: _focusFirstName,
|
||||
focusNode: _firstNameFocus,
|
||||
fieldValidator: widget.minPersonNameLength != null
|
||||
? _validatePersonNameWithMinLength
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -1106,6 +1125,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
enabled: _fieldsEnabled,
|
||||
focusOrder: _focusLastName,
|
||||
focusNode: _lastNameFocus,
|
||||
fieldValidator: widget.minPersonNameLength != null
|
||||
? _validatePersonNameWithMinLength
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
@ -1118,6 +1140,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
|
||||
enabled: _fieldsEnabled,
|
||||
focusOrder: _focusFirstName,
|
||||
focusNode: _firstNameFocus,
|
||||
fieldValidator: widget.minPersonNameLength != null
|
||||
? _validatePersonNameWithMinLength
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
|
||||
@ -7,15 +7,18 @@ import 'package:image_picker/image_picker.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:io';
|
||||
import '../models/am_registration_data.dart' show kAmCapaciteAccueilMax;
|
||||
import '../models/card_assets.dart';
|
||||
import '../config/display_config.dart';
|
||||
import '../utils/nir_utils.dart';
|
||||
import '../utils/name_format_utils.dart';
|
||||
import 'custom_app_text_field.dart';
|
||||
import 'nir_text_field.dart';
|
||||
import 'form_field_wrapper.dart';
|
||||
import 'app_custom_checkbox.dart';
|
||||
import 'hover_relief_widget.dart';
|
||||
import 'custom_navigation_button.dart';
|
||||
import 'registration_photo_slot.dart';
|
||||
|
||||
/// Données pour le formulaire d'informations professionnelles
|
||||
class ProfessionalInfoData {
|
||||
@ -29,7 +32,10 @@ class ProfessionalInfoData {
|
||||
final String birthCountry;
|
||||
final String nir;
|
||||
final String agrementNumber;
|
||||
/// Date d'obtention de l'agrément (obligatoire, API `date_agrement`).
|
||||
final DateTime? agreementDate;
|
||||
final int? capacity;
|
||||
final int? placesAvailable;
|
||||
|
||||
ProfessionalInfoData({
|
||||
this.photoPath,
|
||||
@ -42,7 +48,9 @@ class ProfessionalInfoData {
|
||||
this.birthCountry = '',
|
||||
this.nir = '',
|
||||
this.agrementNumber = '',
|
||||
this.agreementDate,
|
||||
this.capacity,
|
||||
this.placesAvailable,
|
||||
});
|
||||
}
|
||||
|
||||
@ -87,9 +95,15 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
||||
final _birthCountryController = TextEditingController();
|
||||
final _nirController = TextEditingController();
|
||||
final _agrementController = TextEditingController();
|
||||
final _agreementDateController = TextEditingController();
|
||||
final _capacityController = TextEditingController();
|
||||
final _placesAvailableController = TextEditingController();
|
||||
|
||||
FocusNode? _birthCityFocus;
|
||||
FocusNode? _birthCountryFocus;
|
||||
|
||||
DateTime? _selectedDate;
|
||||
DateTime? _selectedAgreementDate;
|
||||
String? _photoPathFramework;
|
||||
File? _photoFile;
|
||||
Uint8List? _photoBytes;
|
||||
@ -118,26 +132,87 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
||||
final nirRaw = nirToRaw(data.nir);
|
||||
_nirController.text = nirRaw.length == 15 ? formatNir(nirRaw) : data.nir;
|
||||
_agrementController.text = data.agrementNumber;
|
||||
_selectedAgreementDate = data.agreementDate;
|
||||
_agreementDateController.text = data.agreementDate != null
|
||||
? DateFormat('dd/MM/yyyy').format(data.agreementDate!)
|
||||
: '';
|
||||
_capacityController.text = data.capacity?.toString() ?? '';
|
||||
_placesAvailableController.text = data.placesAvailable?.toString() ?? '';
|
||||
_photoPathFramework = data.photoPath;
|
||||
_photoFile = data.photoFile;
|
||||
_photoBytes = data.photoBytes;
|
||||
_photoFilename = data.photoFilename;
|
||||
_photoConsent = data.photoConsent;
|
||||
}
|
||||
|
||||
if (widget.mode == DisplayMode.editable) {
|
||||
_birthCityFocus = FocusNode();
|
||||
_birthCountryFocus = FocusNode();
|
||||
_birthCityFocus!.addListener(_onBirthCityFocusChange);
|
||||
_birthCountryFocus!.addListener(_onBirthCountryFocusChange);
|
||||
}
|
||||
}
|
||||
|
||||
void _onBirthCityFocusChange() {
|
||||
if (_birthCityFocus == null || _birthCityFocus!.hasFocus) return;
|
||||
_applyPlaceNameFormat(_birthCityController);
|
||||
}
|
||||
|
||||
void _onBirthCountryFocusChange() {
|
||||
if (_birthCountryFocus == null || _birthCountryFocus!.hasFocus) return;
|
||||
_applyPlaceNameFormat(_birthCountryController);
|
||||
}
|
||||
|
||||
void _applyPlaceNameFormat(TextEditingController controller) {
|
||||
final formatted = formatPersonNameCase(controller.text);
|
||||
if (formatted == controller.text) return;
|
||||
controller.value = TextEditingValue(
|
||||
text: formatted,
|
||||
selection: TextSelection.collapsed(offset: formatted.length),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_birthCityFocus?.removeListener(_onBirthCityFocusChange);
|
||||
_birthCountryFocus?.removeListener(_onBirthCountryFocusChange);
|
||||
_birthCityFocus?.dispose();
|
||||
_birthCountryFocus?.dispose();
|
||||
_dateOfBirthController.dispose();
|
||||
_birthCityController.dispose();
|
||||
_birthCountryController.dispose();
|
||||
_nirController.dispose();
|
||||
_agrementController.dispose();
|
||||
_agreementDateController.dispose();
|
||||
_capacityController.dispose();
|
||||
_placesAvailableController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String? _validateBirthCity(String? v) {
|
||||
final t = (v ?? '').trim();
|
||||
if (t.isEmpty) return 'Ville requise';
|
||||
if (t.length < 2) return 'Au moins 2 caractères';
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateBirthCountry(String? v) {
|
||||
final t = (v ?? '').trim();
|
||||
if (t.isEmpty) return 'Pays requis';
|
||||
if (t.length < 2) return 'Au moins 2 caractères';
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validatePlacesAvailable(String? v) {
|
||||
if (v == null || v.isEmpty) return 'Places disponibles requises';
|
||||
final n = int.tryParse(v);
|
||||
if (n == null || n < 0) return 'Nombre entre 0 et la capacité';
|
||||
if (n > kAmCapaciteAccueilMax) return 'Maximum $kAmCapaciteAccueilMax';
|
||||
final cap = int.tryParse(_capacityController.text);
|
||||
if (cap != null && n > cap) return 'Ne peut pas dépasser la capacité';
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _selectDate(BuildContext context) async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
@ -154,6 +229,22 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _selectAgreementDate(BuildContext context) async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedAgreementDate ?? DateTime.now().subtract(const Duration(days: 365 * 5)),
|
||||
firstDate: DateTime(1970, 1),
|
||||
lastDate: DateTime.now(),
|
||||
locale: const Locale('fr', 'FR'),
|
||||
);
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_selectedAgreementDate = picked;
|
||||
_agreementDateController.text = DateFormat('dd/MM/yyyy').format(picked);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickPhoto() async {
|
||||
if (widget.onPickPhoto != null) {
|
||||
await widget.onPickPhoto!();
|
||||
@ -190,9 +281,27 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _clearRegistrationPhoto() {
|
||||
setState(() {
|
||||
_photoBytes = null;
|
||||
_photoFile = null;
|
||||
_photoPathFramework = null;
|
||||
_photoFilename = null;
|
||||
_photoConsent = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _submitForm() {
|
||||
_applyPlaceNameFormat(_birthCityController);
|
||||
_applyPlaceNameFormat(_birthCountryController);
|
||||
if (_formKey.currentState!.validate()) {
|
||||
if (_hasRealPhoto && !_photoConsent) {
|
||||
if (!_hasRealPhoto) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Veuillez ajouter une photo de profil.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!_photoConsent) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Veuillez accepter le consentement photo pour continuer.')),
|
||||
);
|
||||
@ -206,11 +315,13 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
||||
photoFilename: _photoFilename,
|
||||
photoConsent: _photoConsent,
|
||||
dateOfBirth: _selectedDate,
|
||||
birthCity: _birthCityController.text,
|
||||
birthCountry: _birthCountryController.text,
|
||||
birthCity: _birthCityController.text.trim(),
|
||||
birthCountry: _birthCountryController.text.trim(),
|
||||
nir: normalizeNir(_nirController.text),
|
||||
agrementNumber: _agrementController.text,
|
||||
agreementDate: _selectedAgreementDate,
|
||||
capacity: int.tryParse(_capacityController.text),
|
||||
placesAvailable: int.tryParse(_placesAvailableController.text),
|
||||
);
|
||||
|
||||
widget.onSubmit(data);
|
||||
@ -234,7 +345,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
||||
),
|
||||
Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40.0),
|
||||
padding: EdgeInsets.symmetric(vertical: config.isMobile ? 40.0 : 28.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
@ -255,7 +366,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: config.isMobile ? 16 : 30),
|
||||
SizedBox(height: config.isMobile ? 16 : 20),
|
||||
_buildCard(context, config, screenSize),
|
||||
|
||||
// Boutons mobile sous la carte
|
||||
@ -326,7 +437,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
||||
Container(
|
||||
width: config.isMobile ? screenSize.width * 0.9 : screenSize.width * 0.6,
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: config.isMobile ? 20 : (config.isReadonly ? 30 : 50),
|
||||
vertical: config.isMobile ? 20 : (config.isReadonly ? 24 : 28),
|
||||
horizontal: config.isMobile ? 24 : 50,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
@ -354,7 +465,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(height: config.isMobile ? 20.0 : 14.0),
|
||||
],
|
||||
config.isMobile
|
||||
? _buildMobileFields(context, config)
|
||||
@ -476,7 +587,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Contenu
|
||||
Expanded(
|
||||
@ -491,42 +602,31 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 5),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: _photoBytes != null && _photoBytes!.isNotEmpty
|
||||
? Image.memory(_photoBytes!, fit: BoxFit.cover)
|
||||
: _photoFile != null
|
||||
? Image.file(_photoFile!, fit: BoxFit.cover)
|
||||
: (_photoPathFramework != null &&
|
||||
_photoPathFramework!.startsWith('assets/')
|
||||
? Image.asset(_photoPathFramework!, fit: BoxFit.contain)
|
||||
: Image.asset('assets/images/photo.png', fit: BoxFit.contain)),
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final side = constraints.biggest.shortestSide;
|
||||
return RegistrationPhotoSlot(
|
||||
side: side,
|
||||
imageBytes: _photoBytes,
|
||||
imageFile: _photoFile,
|
||||
imagePathOrAsset: _photoPathFramework,
|
||||
baseShadowColor: Colors.green.shade300,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const SizedBox(height: 5),
|
||||
AppCustomCheckbox(
|
||||
label: 'J\'accepte l\'utilisation\nde ma photo.',
|
||||
value: _photoConsent,
|
||||
onChanged: (v) {}, // Readonly
|
||||
onChanged: (_) {}, // Readonly
|
||||
checkboxSize: 22.0,
|
||||
fontSize: 14.0,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
const SizedBox(width: 24),
|
||||
|
||||
// CHAMPS (2/3) - Layout optimisé compact
|
||||
Expanded(
|
||||
@ -534,32 +634,56 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Ligne 1 : Ville + Pays
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _buildReadonlyField('Ville de naissance', _birthCityController.text)),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: _buildReadonlyField('Pays de naissance', _birthCountryController.text)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Ligne 2 : Date + NIR (NIR prend plus de place si possible ou 50/50)
|
||||
// Ligne 1 : Date de naissance + Ville
|
||||
Row(
|
||||
children: [
|
||||
Expanded(flex: 2, child: _buildReadonlyField('Date de naissance', _dateOfBirthController.text)),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(flex: 3, child: _buildReadonlyField('Ville de naissance', _birthCityController.text)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
|
||||
// Ligne 2 : Pays + NIR
|
||||
Row(
|
||||
children: [
|
||||
Expanded(flex: 2, child: _buildReadonlyField('Pays de naissance', _birthCountryController.text)),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(flex: 3, child: _buildReadonlyField('NIR', _formatNirForDisplay(_nirController.text))),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 5),
|
||||
|
||||
// Ligne 3 : Agrément + Capacité
|
||||
// Ligne 3 : N° agrément + Date d'obtention
|
||||
Row(
|
||||
children: [
|
||||
Expanded(flex: 3, child: _buildReadonlyField('N° Agrément', _agrementController.text)),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: _buildReadonlyField('N° d\'agrément', _agrementController.text),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(flex: 2, child: _buildReadonlyField('Capacité', _capacityController.text)),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _buildReadonlyField(
|
||||
'Date d\'obtention de l\'agrément',
|
||||
_agreementDateController.text.isNotEmpty ? _agreementDateController.text : '—',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildReadonlyField('Capacité d\'accueil', _capacityController.text),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildReadonlyField(
|
||||
'Places disponibles',
|
||||
_placesAvailableController.text,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@ -588,10 +712,10 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: GoogleFonts.merienda(fontSize: 18.0, fontWeight: FontWeight.w600),
|
||||
style: GoogleFonts.merienda(fontSize: 16.0, fontWeight: FontWeight.w600),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(height: 3),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 45.0, // Hauteur réduite pour compacter
|
||||
@ -605,7 +729,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
||||
),
|
||||
child: Text(
|
||||
value.isNotEmpty ? value : '-',
|
||||
style: GoogleFonts.merienda(fontSize: 16.0),
|
||||
style: GoogleFonts.merienda(fontSize: 14.0),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
@ -615,7 +739,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
||||
|
||||
/// Layout DESKTOP : Photo à gauche, champs à droite
|
||||
Widget _buildDesktopFields(BuildContext context, DisplayConfig config) {
|
||||
final double verticalSpacing = config.isReadonly ? 16.0 : 32.0;
|
||||
final double verticalSpacing = config.isReadonly ? 8.0 : 14.0;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@ -628,27 +752,11 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
||||
width: 300,
|
||||
child: _buildPhotoSection(context, config),
|
||||
),
|
||||
const SizedBox(width: 30),
|
||||
const SizedBox(width: 22),
|
||||
// Champs à droite
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildField(
|
||||
config: config,
|
||||
label: 'Ville de naissance',
|
||||
controller: _birthCityController,
|
||||
hint: 'Votre ville de naissance',
|
||||
validator: (v) => v!.isEmpty ? 'Ville requise' : null,
|
||||
),
|
||||
SizedBox(height: verticalSpacing),
|
||||
_buildField(
|
||||
config: config,
|
||||
label: 'Pays de naissance',
|
||||
controller: _birthCountryController,
|
||||
hint: 'Votre pays de naissance',
|
||||
validator: (v) => v!.isEmpty ? 'Pays requis' : null,
|
||||
),
|
||||
SizedBox(height: verticalSpacing),
|
||||
_buildField(
|
||||
config: config,
|
||||
label: 'Date de naissance',
|
||||
@ -659,6 +767,24 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
||||
suffixIcon: Icons.calendar_today,
|
||||
validator: (v) => _selectedDate == null ? 'Date requise' : null,
|
||||
),
|
||||
SizedBox(height: verticalSpacing),
|
||||
_buildField(
|
||||
config: config,
|
||||
label: 'Ville de naissance',
|
||||
controller: _birthCityController,
|
||||
hint: 'Ex. Ajaccio, Paris, Casablanca…',
|
||||
validator: _validateBirthCity,
|
||||
focusNode: _birthCityFocus,
|
||||
),
|
||||
SizedBox(height: verticalSpacing),
|
||||
_buildField(
|
||||
config: config,
|
||||
label: 'Pays de naissance',
|
||||
controller: _birthCountryController,
|
||||
hint: 'Ex. France, Maroc…',
|
||||
validator: _validateBirthCountry,
|
||||
focusNode: _birthCountryFocus,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -669,11 +795,13 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
||||
controller: _nirController,
|
||||
fieldWidth: double.infinity,
|
||||
fieldHeight: config.isMobile ? 45.0 : 53.0,
|
||||
labelFontSize: config.isMobile ? 15.0 : 22.0,
|
||||
inputFontSize: config.isMobile ? 14.0 : 20.0,
|
||||
labelFontSize: config.isMobile ? 15.0 : 20.0,
|
||||
inputFontSize: config.isMobile ? 14.0 : 18.0,
|
||||
labelFieldSpacing: config.isMobile ? 6.0 : 3.0,
|
||||
),
|
||||
SizedBox(height: verticalSpacing),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildField(
|
||||
@ -684,22 +812,57 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
||||
validator: (v) => v!.isEmpty ? 'Agrément requis' : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: _buildField(
|
||||
config: config,
|
||||
label: 'Date d\'obtention de l\'agrément',
|
||||
controller: _agreementDateController,
|
||||
hint: 'JJ/MM/AAAA',
|
||||
readOnly: true,
|
||||
onTap: () => _selectAgreementDate(context),
|
||||
suffixIcon: Icons.calendar_today,
|
||||
validator: (_) =>
|
||||
_selectedAgreementDate == null ? 'Date d\'obtention requise' : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: verticalSpacing),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildField(
|
||||
config: config,
|
||||
label: 'Capacité d\'accueil',
|
||||
controller: _capacityController,
|
||||
hint: 'Ex: 3',
|
||||
hint: 'De 1 à $kAmCapaciteAccueilMax enfants',
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return 'Capacité requise';
|
||||
final n = int.tryParse(v);
|
||||
if (n == null || n <= 0) return 'Nombre invalide';
|
||||
if (n == null || n < 1) {
|
||||
return 'Entrez un nombre entre 1 et $kAmCapaciteAccueilMax';
|
||||
}
|
||||
if (n > kAmCapaciteAccueilMax) {
|
||||
return 'Maximum $kAmCapaciteAccueilMax';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: _buildField(
|
||||
config: config,
|
||||
label: 'Places disponibles',
|
||||
controller: _placesAvailableController,
|
||||
hint: 'Entre 0 et la capacité',
|
||||
keyboardType: TextInputType.number,
|
||||
validator: _validatePlacesAvailable,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@ -715,24 +878,6 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
||||
_buildPhotoSection(context, config),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
_buildField(
|
||||
config: config,
|
||||
label: 'Ville de naissance',
|
||||
controller: _birthCityController,
|
||||
hint: 'Votre ville de naissance',
|
||||
validator: (v) => v!.isEmpty ? 'Ville requise' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
_buildField(
|
||||
config: config,
|
||||
label: 'Pays de naissance',
|
||||
controller: _birthCountryController,
|
||||
hint: 'Votre pays de naissance',
|
||||
validator: (v) => v!.isEmpty ? 'Pays requis' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
_buildField(
|
||||
config: config,
|
||||
label: 'Date de naissance',
|
||||
@ -745,6 +890,26 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
_buildField(
|
||||
config: config,
|
||||
label: 'Ville de naissance',
|
||||
controller: _birthCityController,
|
||||
hint: 'Ex. Ajaccio, Paris, Casablanca…',
|
||||
validator: _validateBirthCity,
|
||||
focusNode: _birthCityFocus,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
_buildField(
|
||||
config: config,
|
||||
label: 'Pays de naissance',
|
||||
controller: _birthCountryController,
|
||||
hint: 'Ex. France, Maroc…',
|
||||
validator: _validateBirthCountry,
|
||||
focusNode: _birthCountryFocus,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
NirTextField(
|
||||
controller: _nirController,
|
||||
fieldWidth: double.infinity,
|
||||
@ -762,70 +927,76 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
||||
validator: (v) => v!.isEmpty ? 'Agrément requis' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
_buildField(
|
||||
config: config,
|
||||
label: 'Date d\'obtention de l\'agrément',
|
||||
controller: _agreementDateController,
|
||||
hint: 'JJ/MM/AAAA',
|
||||
readOnly: true,
|
||||
onTap: () => _selectAgreementDate(context),
|
||||
suffixIcon: Icons.calendar_today,
|
||||
validator: (_) =>
|
||||
_selectedAgreementDate == null ? 'Date d\'obtention requise' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildField(
|
||||
config: config,
|
||||
label: 'Capacité d\'accueil',
|
||||
controller: _capacityController,
|
||||
hint: 'Ex: 3',
|
||||
hint: 'De 1 à $kAmCapaciteAccueilMax enfants',
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return 'Capacité requise';
|
||||
final n = int.tryParse(v);
|
||||
if (n == null || n <= 0) return 'Nombre invalide';
|
||||
if (n == null || n < 1) {
|
||||
return 'Entrez un nombre entre 1 et $kAmCapaciteAccueilMax';
|
||||
}
|
||||
if (n > kAmCapaciteAccueilMax) {
|
||||
return 'Maximum $kAmCapaciteAccueilMax';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildField(
|
||||
config: config,
|
||||
label: 'Places disponibles',
|
||||
controller: _placesAvailableController,
|
||||
hint: 'Entre 0 et la capacité',
|
||||
keyboardType: TextInputType.number,
|
||||
validator: _validatePlacesAvailable,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Section photo + checkbox
|
||||
/// Section photo + checkbox (même logique visuelle que les enfants : cadre, croix).
|
||||
Widget _buildPhotoSection(BuildContext context, DisplayConfig config) {
|
||||
final Color baseCardColorForShadow = Colors.green.shade300;
|
||||
final Color initialPhotoShadow = baseCardColorForShadow.withAlpha(90);
|
||||
final Color hoverPhotoShadow = baseCardColorForShadow.withAlpha(130);
|
||||
|
||||
ImageProvider? currentImageProvider;
|
||||
if (_photoBytes != null && _photoBytes!.isNotEmpty) {
|
||||
currentImageProvider = MemoryImage(_photoBytes!);
|
||||
} else if (_photoFile != null) {
|
||||
currentImageProvider = FileImage(_photoFile!);
|
||||
} else if (_photoPathFramework != null && _photoPathFramework!.startsWith('assets/')) {
|
||||
currentImageProvider = AssetImage(_photoPathFramework!);
|
||||
}
|
||||
|
||||
final photoSize = config.isMobile ? 200.0 : 270.0;
|
||||
final scaleFactor = config.isMobile ? 0.9 : 1.1;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
HoverReliefWidget(
|
||||
onPressed: _pickPhoto,
|
||||
borderRadius: BorderRadius.circular(10.0),
|
||||
initialShadowColor: initialPhotoShadow,
|
||||
hoverShadowColor: hoverPhotoShadow,
|
||||
child: SizedBox(
|
||||
height: photoSize,
|
||||
width: photoSize,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: currentImageProvider != null
|
||||
? DecorationImage(image: currentImageProvider, fit: BoxFit.cover)
|
||||
: null,
|
||||
),
|
||||
child: currentImageProvider == null
|
||||
? Image.asset('assets/images/photo.png', fit: BoxFit.contain)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
RegistrationPhotoSlot(
|
||||
side: photoSize,
|
||||
scaleFactor: scaleFactor,
|
||||
imageBytes: _photoBytes,
|
||||
imageFile: _photoFile,
|
||||
imagePathOrAsset: _photoPathFramework,
|
||||
onTapPick: config.isReadonly ? null : _pickPhoto,
|
||||
onClear: config.isReadonly ? null : _clearRegistrationPhoto,
|
||||
baseShadowColor: Colors.green.shade300,
|
||||
isMobile: config.isMobile,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(height: config.isMobile ? 10.0 : 6.0),
|
||||
AppCustomCheckbox(
|
||||
label: 'J\'accepte l\'utilisation\nde ma photo.',
|
||||
value: _photoConsent,
|
||||
onChanged: (val) => setState(() => _photoConsent = val == true),
|
||||
onChanged: (val) {
|
||||
if (config.isReadonly) return;
|
||||
setState(() => _photoConsent = val == true);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
@ -843,6 +1014,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
||||
IconData? suffixIcon,
|
||||
String? Function(String?)? validator,
|
||||
List<TextInputFormatter>? inputFormatters,
|
||||
FocusNode? focusNode,
|
||||
}) {
|
||||
if (config.isReadonly) {
|
||||
return FormFieldWrapper(
|
||||
@ -853,12 +1025,14 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
||||
} else {
|
||||
return CustomAppTextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
labelText: label,
|
||||
hintText: hint ?? label,
|
||||
fieldWidth: double.infinity,
|
||||
fieldHeight: config.isMobile ? 45.0 : 53.0,
|
||||
labelFontSize: config.isMobile ? 15.0 : 22.0,
|
||||
inputFontSize: config.isMobile ? 14.0 : 20.0,
|
||||
labelFontSize: config.isMobile ? 15.0 : 20.0,
|
||||
inputFontSize: config.isMobile ? 14.0 : 18.0,
|
||||
labelFieldSpacing: config.isMobile ? 6.0 : 3.0,
|
||||
keyboardType: keyboardType ?? TextInputType.text,
|
||||
readOnly: readOnly,
|
||||
onTap: onTap,
|
||||
|
||||
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).
|
||||
* 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.
|
||||
*
|
||||
* 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';
|
||||
@ -17,8 +17,8 @@ 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(repoRoot, 'ressources', 'Photos');
|
||||
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`);
|
||||
@ -41,8 +41,8 @@ const presentationDossier =
|
||||
"Nous recherchons une assistante maternelle à Bezons pour accueillir nos enfants dans un cadre bienveillant et stable. " +
|
||||
"Merci pour l'étude de notre dossier.";
|
||||
|
||||
const chloeSrc = path.join(photosDir, 'G_Chloé.png');
|
||||
const hugoSrc = path.join(photosDir, 'G_Hugo.png');
|
||||
const chloeSrc = path.join(photosDir, 'rousseau-chloe.png');
|
||||
const hugoSrc = path.join(photosDir, 'rousseau-hugo.png');
|
||||
|
||||
let chloeJpg;
|
||||
let hugoJpg;
|
||||
@ -2,7 +2,7 @@
|
||||
* POST /api/v1/auth/register/parent — jeu de test officiel David LECOMTE (père isolé).
|
||||
* 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';
|
||||
@ -12,8 +12,8 @@ import http from 'http';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.join(__dirname, '..');
|
||||
const photosDir = path.join(repoRoot, 'ressources', 'Photos');
|
||||
const repoRoot = path.join(__dirname, '..', '..');
|
||||
const photosDir = path.join(__dirname, '..', 'ressources', 'photos');
|
||||
|
||||
function toDataUri(filePath) {
|
||||
const buf = fs.readFileSync(filePath);
|
||||
@ -40,7 +40,7 @@ const body = {
|
||||
nom: 'LECOMTE',
|
||||
date_naissance: '2023-04-15',
|
||||
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',
|
||||
grossesse_multiple: false,
|
||||
},
|
||||
@ -2,8 +2,8 @@
|
||||
* 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
|
||||
*
|
||||
* Usage : node scripts/register-parent-martin-test.mjs [BASE_URL]
|
||||
* Ex. : node scripts/register-parent-martin-test.mjs https://app.ptits-pas.fr
|
||||
* Usage : node tests/scripts/register-parent-martin-test.mjs [BASE_URL]
|
||||
* Ex. : node tests/scripts/register-parent-martin-test.mjs https://app.ptits-pas.fr
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
@ -13,8 +13,8 @@ import http from 'http';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.join(__dirname, '..');
|
||||
const photosDir = path.join(repoRoot, 'ressources', 'Photos');
|
||||
const repoRoot = path.join(__dirname, '..', '..');
|
||||
const photosDir = path.join(__dirname, '..', 'ressources', 'photos');
|
||||
|
||||
function toDataUri(filePath) {
|
||||
const buf = fs.readFileSync(filePath);
|
||||
@ -46,7 +46,7 @@ const body = {
|
||||
nom: 'MARTIN',
|
||||
date_naissance: '2023-02-15',
|
||||
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',
|
||||
grossesse_multiple: true,
|
||||
},
|
||||
@ -55,7 +55,7 @@ const body = {
|
||||
nom: 'MARTIN',
|
||||
date_naissance: '2023-02-15',
|
||||
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',
|
||||
grossesse_multiple: true,
|
||||
},
|
||||
@ -64,7 +64,7 @@ const body = {
|
||||
nom: 'MARTIN',
|
||||
date_naissance: '2023-02-15',
|
||||
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',
|
||||
grossesse_multiple: true,
|
||||
},
|
||||
Loading…
x
Reference in New Issue
Block a user