Compare commits

...

115 Commits

Author SHA1 Message Date
0cc6fa3a9d docs: ticket #120 fermé — liste tickets + script clôture Gitea
- 23_LISTE-TICKETS : #120 marqué fermé.
- backend/scripts/close-gitea-issue-120.js : commentaire de livraison + PATCH state closed (réutilisable / traçabilité).

Made-with: Cursor
2026-04-13 17:40:58 +02:00
0befd4eb2a feat(admin): validation AM/famille, À valider au survol, nettoyage logs
- À valider : ligne avec survol type AdminUserCard, icône Ouvrir centrée et plus grande (iconSize 34).
- Wizard AM : titres Identité et coordonnées / Dossier professionnel / Présentation ; photo à gauche, grille droite [2,2,2,2] (NIR, naissance, agrément, capacité) ; AppUser champs naissance ; dates dd/MM/yyyy via formatIsoDateFr ; ValidationDetailSection titre optionnel.
- Wizard famille : titre étape 4 « Présentation ».
- Suppression des debugPrint liés médias / images / dossier (api_config, auth_network_image, dossier_unifie, user_service, validation_family).

Made-with: Cursor
2026-04-13 17:22:08 +02:00
f442c85cb6 fix(auth): persister places_available depuis places_disponibles (inscription AM)
Made-with: Cursor
2026-04-13 16:08:20 +02:00
ab7845347b merge: résolution conflits frontend + scripts parent (#120)
- Conserver le parcours AM complet (photo obligatoire, consentement, MIME, lieux naissance, focus Tab).

- Scripts parent : chemin tests/ressources/photos unifié.

Made-with: Cursor
2026-04-13 16:02:03 +02:00
011ab26828 merge: branche backend inscription AM photo (#120) 2026-04-13 15:59:35 +02:00
a9ce4a6756 fix(am): ordre Tab formulaire pro et chevrons accessibles
- Ordre de focus explicite (NumericFocusOrder) pour l’inscription AM étape 2 : chaîne des champs jusqu’aux chevrons, Pays → NIR, places → Suivant (10) puis Précédent (11).

- FocusTraversalGroup avec OrderedTraversalPolicy sur le Scaffold pour respecter cet ordre (desktop / web).

- Champ pays : TextInputAction.next, onFieldSubmitted et onEditingComplete vers le NIR ; CustomAppTextField expose onEditingComplete.

- NirTextField : focusNode et focusTraversalOrder optionnels.

Made-with: Cursor
2026-04-13 14:09:21 +02:00
d550e57cb8 fix(inscription-am): étape 1/2 obligatoires alignées API (min 2 car., photo)
- Étape 1 : prénom/nom min 2 caractères (minPersonNameLength sur PersonalInfoFormScreen pour AM)

- Étape 2 : ville/pays naissance min 2 ; photo réelle + consentement obligatoires ; isStep2Complete strict

- registerAM : lieu naissance trim + toujours envoyer photo_base64 ; lieu_* non null si valide

- Étape 4 : blocage envoi si isRegistrationComplete faux

Made-with: Cursor
2026-04-13 13:32:25 +02:00
b4b44cb1b9 feat(inscription-am): places disponibles, UI étape 2, plafond capacité 4 côté app
- POST /auth/register/am : places_disponibles, enregistrement place_disponible, contrôle ≤ capacité

- Formulaire pro AM : places sur la ligne capacité, mobile un champ par ligne, carte desktop plus compacte (espacements, polices, labelFieldSpacing)

- Capacité et places validées jusqu’à 4 dans l’app (kAmCapaciteAccueilMax) ; hint NIR « 13 chiffres + clé »

- Scripts d’inscription AM (Node + smoke shell) : places_disponibles

Made-with: Cursor
2026-04-13 13:20:26 +02:00
58607cdbc9 feat(inscription AM #120): UI étape pro, photo unifiée, scripts et données test
- Panneau AM : validation NIR alignée API, date d’agrément requise côté app,
  capacité 1–10, n° agrément + date sur une ligne, ville/pays formatés au blur.
- Widget RegistrationPhotoSlot (cadre, croix) partagé avec les cartes enfant.
- AuthService : MIME PNG/JPEG pour la photo ; payload date_agrement.
- Scripts register-am-dubois / mansouri ; chemins tests/ressources/photos ;
  doc test-data + seed ; smoke curl inscription AM.

Made-with: Cursor
2026-04-13 13:19:32 +02:00
12bf85b7bd chore: déplacer les scripts d'inscription parent vers tests/scripts
repoRoot corrigé (../..) ; usage: node tests/scripts/register-parent-*.mjs

Made-with: Cursor
2026-04-13 13:19:31 +02:00
01c1be8f16 feat(am): inscription photo (picker, bytes web, photo_filename) (#120)
- ProfessionalInfoFormScreen : ImagePicker par défaut, MemoryImage, consentement si photo réelle
- AmRegistrationData : photoBytes + photoFilename ; récap étape 4
- registerAM : base64 + filename, erreurs réseau/JSON comme parents
- docs: 23_LISTE-TICKETS — issue #120

Made-with: Cursor
2026-04-13 13:19:31 +02:00
cdae9b5f7c feat(am): alignement inscription/validation + BDD init
- Lieu naissance obligatoire (DTO), persistance Users + migration SQL
- BDD.sql: colonnes lieu_naissance_*, tables dossier_famille / dossier_famille_enfants
- inscrireAMComplet: places_available = capacite_accueil
- GET dossier AM: user.lieu_naissance_*, consentement_photo
- docs/TEMP_FRONT_alignement_AM.md pour équipe Flutter
- Parité: email accusé AM, RegisterAmResponseDto (numero_dossier)

Made-with: Cursor
2026-04-13 13:14:38 +02:00
73efac482f chore: déplacer les scripts d'inscription parent vers tests/scripts
repoRoot corrigé (../..) ; usage: node tests/scripts/register-parent-*.mjs

Made-with: Cursor
2026-04-13 11:40:41 +02:00
62b2466cce feat(am): inscription photo (picker, bytes web, photo_filename) (#120)
- ProfessionalInfoFormScreen : ImagePicker par défaut, MemoryImage, consentement si photo réelle
- AmRegistrationData : photoBytes + photoFilename ; récap étape 4
- registerAM : base64 + filename, erreurs réseau/JSON comme parents
- docs: 23_LISTE-TICKETS — issue #120

Made-with: Cursor
2026-04-12 21:41:19 +02:00
d69768806c fix(auth): inscription AM — persister photo si photo_base64 sans photo_filename (#120)
- Défaut nom fichier photo_am.jpg ; extension finale dérivée du data-URL
- Swagger DTO + description endpoint register/am

Made-with: Cursor
2026-04-12 21:37:47 +02:00
eac108c9f4 Merge branch 'master' into develop — alignement avec correctifs master (withOpacity, formatters, script Gitea)
Made-with: Cursor
2026-04-12 21:15:07 +02:00
f1c9db2e7b fix(admin): vignettes enfants du wizard validation (cover, sans fond blanc)
- BoxFit.cover pour remplir le cadre photo
- Suppression du fond blanc du slot, rayon factorisé

Made-with: Cursor
2026-04-11 18:02:52 +02:00
ccfcfa3287 chore: ignorer le dossier .cursor (config IDE Cursor)
Made-with: Cursor
2026-04-11 18:02:52 +02:00
dbca12d5d1 scripts: ajout des inscriptions parent test (Martin, Durand/Rousseau, Lecomte)
Scripts Node POST /api/v1/auth/register/parent alignés sur docs/test-data et le seed, avec photos ressources/Photos (réduction JPEG via sharp-cli pour Durand/Rousseau).

Made-with: Cursor
2026-04-11 18:02:52 +02:00
4fe29799cd fix(api): CORS + CORP sur réponses statiques /uploads (Flutter web)
- enableCors avant les statics
- Access-Control-Allow-Origin * + Cross-Origin-Resource-Policy sur fichiers
- OPTIONS préflight pour /uploads et /api/v1/uploads

Made-with: Cursor
2026-04-11 17:11:23 +02:00
3bfdadd301 fix(front): images validation dossier, traces debug, baseUrl via Env
- ApiConfig.baseUrl dérivé de Env.apiBaseUrl (alignement API / origine médias).
- AuthNetworkImage : uploads publics sans Bearer (CORS web) ; log erreurs en debug.
- debugPrint kDebugMode : GET dossier, EnfantDossier.fromJson, absoluteMediaUrl, carte enfant.
- ParentDossier id depuis user_id ; fallbacks présentation / photo.

Made-with: Cursor
2026-04-11 17:08:34 +02:00
09289882d0 fix: photos admin — URL via /api/v1/uploads + static Express
- Nest: servir le volume uploads aussi sous /api/v1/uploads (Traefik /api)
- Flutter: _fullPhotoUrl = base API + chemin /uploads/... (plus d’origine site seul)
- EnfantDossier: tolérer photoUrl camelCase

Made-with: Cursor
2026-04-11 16:47:29 +02:00
e7f0037ac3 fix: motivation dossier famille à l’inscription + servir /uploads + Traefik
- Inscription parent: créer dossier_famille + dossier_famille_enfants (presentation_dossier)
- Nest: useStaticAssets sur le volume uploads (chemins /uploads/photos/…)
- docker-compose: route Traefik PathPrefix /uploads → API (priorité 18)
- Front: ParentDossier.id depuis user_id (aligné API)

Made-with: Cursor
2026-04-11 16:37:44 +02:00
4969be5809 feat(inscription): payload enfant/photo et limite JSON 15 Mo côté API
- Express/Nest : body parser json et urlencoded à 15 Mo pour les photos base64.
- Front : envoi d’inscription parent (authService, payload, étape 3).

Made-with: Cursor
2026-04-11 16:36:15 +02:00
d7ccbfbe35 feat(api): dossier famille — photo_url et consent_photo par enfant
- DossierFamilleEnfantDto: champs optionnels Swagger
- Mapping getDossierFamilleByNumero + fallback dossier_famille_enfants
- GET dossiers/:num (type family) enrichi via délégation existante

Made-with: Cursor
2026-04-11 16:31:11 +02:00
2b3128ce4a fix(api): inscription parent — body 15mb, uploads persistants, 409 si unique PG
- NestExpressApplication + useBodyParser json/urlencoded 15mb
- UPLOAD_PHOTOS_DIR: résolution chemin absolu/relatif + doc .env.example / proxy
- docker-compose: volume backend_uploads + UPLOAD_PHOTOS_DIR par défaut
- Map erreur PostgreSQL 23505 → ConflictException (parent + AM)
- Logs Nest si échec SMTP après inscription (email déjà géré)

Made-with: Cursor
2026-04-10 17:40:23 +02:00
077a1ca3ed fix(bdd): genre_type inclut Autre (alignement API inscription enfants)
Made-with: Cursor
2026-03-31 00:08:41 +02:00
bf05b1d7d7 feat(inscription): email accusé réception, photos enfants, formulaires identité
- Backend: mail après inscription parent avec n° dossier, UPLOAD_PHOTOS_DIR, réponse API
- Frontend: imageBytes + payload photo, utils email/code postal/téléphone, champs dédiés
- Formulaires admin, login (focus), personal_info, child_card, custom_app_text_field

Made-with: Cursor
2026-03-31 00:04:38 +02:00
110240b682 feat(frontend): inscription — UI cartes enfant, formatage noms, focus Tab
- Formulaire infos perso : ordre de tabulation explicite, Checkbox Material
  pour le consentement photo, formatage nom/prénom/ville à la perte de focus
  et à la soumission (name_format_utils).
- Carte enfant : cadre photo, croix, ombres, consentement ; formatage
  prénom/nom enfant ; normalisation avant passage étape 4.
- Asset photo_frame.png ; HoverReliefWidget clipBehavior.
- Ajustements payload / modèle / étapes inscription liés au parcours.

Made-with: Cursor
2026-03-28 20:40:27 +01:00
a723412043 revert(ui): ChildCardWidget identique au commit b18d5c8 (7 fév. 2026)
- Restauration fichier tel que dans feat(frontend): Refonte multi-modes (#78)
- Retrait onGenreChanged (step3/step5) ; genre API: défaut Autre si non renseigné
  jusqu’à nouvelle UI genre

Made-with: Cursor
2026-03-28 18:22:22 +01:00
1e21fe25f3 fix(front): genre enfant — pastilles au lieu du dropdown sur fond beige
Le commit cd2bf63 ajoutait un Dropdown dans un bloc bg_beige, lourd sur
les cartes aquarellées. Remplacement par trois choix tactiles discrets
(Garçon / Fille / Autre), espacements alignés sur prénom/nom.

Made-with: Cursor
2026-03-28 18:15:02 +01:00
cd2bf63b8a feat(front): inscription parent — genre obligatoire H/F/Autre (#101)
- ChildData.genre + copyWith ; liste déroulante sur la carte enfant
- Validation et payload API alignés sur GenreType
- Étape 3 : mises à jour via copyWith (préservation du genre)

Made-with: Cursor
2026-03-28 18:08:32 +01:00
de60a001cc feat(front): inscription parent — soumission réelle vers POST /auth/register/parent (#101)
- ParentRegistrationPayload : validation client et mapping UserRegistrationData → JSON API
- AuthService.registerParent
- Étape 5 : appel API, chargement, erreurs en dialogue, succès inchangé puis /login

Made-with: Cursor
2026-03-28 18:07:52 +01:00
481fa66630 chore(front): supprimer le préremplissage et les données aléatoires à l’inscription
- Parcours AM : plus de jeu de test Marie DUBOIS ni photo factice
- Parcours parent : plus de DataGenerator ; enfants vides à l’ajout
- Suppression de data_generator.dart (inutilisé)

Made-with: Cursor
2026-03-28 18:07:46 +01:00
0a07fade40 docs: liste tickets — fermeture #106 #107 #109 #119 (Gitea)
Made-with: Cursor
2026-03-26 00:20:32 +01:00
0e8a999e41 Merge branch 'feature/107-onglet-a-valider' into develop
Conflits pending-families : conservation du DTO/SQL develop (date_soumission, emails, parents).

Made-with: Cursor
2026-03-26 00:16:53 +01:00
2e02f60601 feat(admin): onglet À valider, dossier unifié et modales de validation
- Onglet « À valider » (AM + familles), pending-families et détail dossier par numéro.
- Wizards validation AM et famille, modale commune, chargement via GET /dossiers/:num.
- UI : cartes enfants, photo AM (cadre uniforme, ratio), NIR affiché formaté (espaces autour du tiret).
- Backend : DTO / routes parents pending ; scripts de test et mise à jour issue Gitea.

Tickets #107, #119.

Made-with: Cursor
2026-03-26 00:15:18 +01:00
0e6e473e84 feat: pending-families ajoute parents[] (email/tel/ville/cp) + ordre stable
Made-with: Cursor
2026-03-25 10:58:29 +01:00
cc3639b19a fix: dossier AM expose texte_motivation (alias biographie) pour le front
Made-with: Cursor
2026-03-23 23:43:00 +01:00
cf9cd495bf feat: enrichissement GET pending-families (date_soumission, nombre_enfants, emails)
Made-with: Cursor
2026-03-23 23:37:50 +01:00
76ba28a7ce fix: dossier famille - un seul texte_motivation, plus de tableau presentation
Made-with: Cursor
2026-03-18 01:56:58 +01:00
1772744c81 feat(#119): dossier famille = 1 par famille, N enfants; retrait repas/type_contrat/budget; adresse dans API parents
Made-with: Cursor
2026-03-18 01:52:28 +01:00
5465117238 fix: renvoyer le genre des enfants dans le dossier famille (GET dossiers/parents)
Made-with: Cursor
2026-03-18 01:35:27 +01:00
6e2343087e feat(#119): GET /dossiers/:numeroDossier unifié AM ou famille (type + dossier)
Made-with: Cursor
2026-03-17 23:11:53 +01:00
f6fabc521e feat(#119): GET /parents/dossier-famille/:numeroDossier - dossier famille complet (admin/gestionnaire)
Made-with: Cursor
2026-03-17 22:40:48 +01:00
5390276ecd fix: ParentsModule - import JwtModule pour AuthGuard (évite restart loop au déploiement)
Made-with: Cursor
2026-03-13 16:49:39 +01:00
7e9306de01 fix: GET /parents/pending-families 500 + #113 doublons inscription
- parents.service: normaliser parentIds (array ou string PG) pour éviter 500
- auth.service: doublons à l'inscription (#113) - parent/co-parent même email, NIR et numéro agrément AM
- docs: mise à jour statuts tickets

Made-with: Cursor
2026-03-13 16:30:12 +01:00
d832559027 Merge branch 'develop' of https://git.ptits-pas.fr/jmartin/petitspas into develop 2026-03-13 10:52:28 +01:00
54ad1d2aa1 docs: retirer référence aux scripts Gitea validation supprimés
Made-with: Cursor
2026-03-13 10:52:15 +01:00
86b28abe51 feat(#111): Reprise après refus – backend
- GET /auth/reprise-dossier?token= : dossier pour préremplir (token seul)
- PATCH /auth/reprise-resoumettre : token + champs modifiables → en_attente, token invalidé
- POST /auth/reprise-identify : numero_dossier + email → type + token
- UserService: findByTokenReprise, resoumettreReprise, findByNumeroDossierAndEmailForReprise

Made-with: Cursor
2026-03-12 23:04:45 +01:00
86d8189038 feat(#110): Refus sans suppression – token reprise + email
- Colonnes token_reprise, token_reprise_expire_le (migration + BDD.sql)
- refuser: génère token (7j), enregistre, trace validations, envoie email (template refus + lien reprise)
- MailService.sendRefusEmail ; échec email ne bloque pas le refus

Made-with: Cursor
2026-03-12 22:56:27 +01:00
dbcb3611d4 feat(#108): Validation dossier famille – POST /parents/:parentId/valider-dossier
- getFamilyUserIds(parentId) : tous les user_id de la famille (co_parent + enfants partagés)
- Valide en une fois tous les comptes en_attente/refuse de la famille (validateUser)
- Réponse : liste des Users validés

Made-with: Cursor
2026-03-12 22:46:17 +01:00
1fa70f4052 feat(#106): Liste familles en attente – GET /parents/pending-families
- Une entrée par famille (co_parent + enfants partagés, même logique que backfill #103)
- libelle, parentIds, numero_dossier ; filtre statut en_attente
- AuthGuard + RolesGuard sur controller parents

Made-with: Cursor
2026-03-12 22:36:16 +01:00
393a527c37 feat(#105): Statut « refusé » – enum, migration, pending/reprise, refuser, connexion
- Enum statut_utilisateur_type + valeur 'refuse' (migration + BDD.sql)
- GET /users/reprise, PATCH /users/:id/refuser (refus_compte en validations)
- PATCH /users/:id/valider accepte en_attente et refuse (reprise)
- Connexion refusée si statut refuse

Made-with: Cursor
2026-03-12 22:21:12 +01:00
dfd58d9b6c feat(#103): Numéro de dossier – backend
- Colonne numero_dossier (utilisateurs, assistantes_maternelles, parents)
- Table numero_dossier_sequence, format AAAA-NNNNNN, séquence par année
- Génération à la soumission AM et parent (famille)
- Backfill existants (famille = co_parent ou enfants partagés)
- API PATCH /users/:id/numero-dossier (gestionnaire/admin)
- Garde-fous: max 2 parents/dossier, pas de mélange AM/parent

Made-with: Cursor
2026-03-12 22:12:33 +01:00
34a36b069e docs: liste tickets complète (23_LISTE-TICKETS) + script gitea-close-issue
Made-with: Cursor
2026-03-11 22:19:33 +01:00
2fa546e6b7 fix(inscription AM): format NIR Corse + extraction message erreur API
Made-with: Cursor
2026-02-26 21:02:40 +01:00
8636b16659 feat(inscription AM): câblage API step 4 + AuthService.registerAM avec nir_utils
Made-with: Cursor
2026-02-26 20:52:37 +01:00
7e17e5ff8d Merge branch 'feature/91-cablage-inscription-am' into develop
Made-with: Cursor
2026-02-26 19:11:38 +01:00
e8b6d906e6 Merge branch 'feature/prefill-am-marie-dubois' into develop
Made-with: Cursor
2026-02-26 19:10:47 +01:00
ae0be04964 test(inscription AM): Préremplissage données de test Marie DUBOIS
Étapes 1 à 3 du formulaire d'inscription AM : remplacer les données
aléatoires par le jeu de test officiel (03_seed_test_data.sql).

Made-with: Cursor
2026-02-26 19:10:04 +01:00
447f3d4137 fix(#102): 02_seed - ajouter nir_chiffre à l'INSERT assistantes_maternelles (NOT NULL)
Made-with: Cursor
2026-02-26 13:53:41 +01:00
721f40599b feat(frontend): NIR 15 car., formatage, validation, widget dédié (#102)
- nir_utils: normalizeNir, formatNir, validateNir (format + clé), Corse 2A/2B
- NirInputFormatter: formatage auto à la saisie (espaces + tiret)
- NirTextField: widget réutilisable pour champ NIR
- professional_info_form_screen: NIR 15 car., affichage formaté à l'init
- custom_app_text_field: paramètre inputFormatters

Refs: #102
Made-with: Cursor
2026-02-26 13:49:57 +01:00
a9c6b9e15b feat(#102): BDD nir_chiffre NOT NULL + migration pour bases existantes
Made-with: Cursor
2026-02-26 12:56:15 +01:00
38c003ef6f feat(#102): mock préremplissage AM étape 2 - NIR Marie Dubois 2A (Ajaccio)
Made-with: Cursor
2026-02-26 11:24:27 +01:00
3dbddbb8c4 feat(#102): seed NIR Marie 2A (Corse Ajaccio), Fatima 99 (étranger), doc
Made-with: Cursor
2026-02-26 11:23:59 +01:00
f46740c6ab feat(#102): validation NIR (format + clé 2A/2B) + warning cohérence
Made-with: Cursor
2026-02-26 11:23:22 +01:00
85bfef7a6b feat(#102): DTO NIR - accepter 2A/2B Corse (15 caractères)
Made-with: Cursor
2026-02-26 11:21:45 +01:00
3c2ecdff7a Merge branch 'feature/25-backend-pending-users' - feat(#25): API GET /users/pending 2026-02-26 10:43:09 +01:00
8b83702bd2 feat(#25): API GET /users/pending - liste comptes en attente
- UserController: endpoint GET /users/pending (rôles SUPER_ADMIN, ADMINISTRATEUR, GESTIONNAIRE)
- UserService: findPendingUsers(role?) avec filtre statut EN_ATTENTE
- GestionnairesService: retrait date_consentement_photo (non présent dans DTO)

Made-with: Cursor
2026-02-26 10:37:22 +01:00
19b8be684f test(inscription AM): Données de test Marie DUBOIS pour le parcours d'inscription
En vue du câblage de l'inscription AM sur l'API (#91), remplacement des
données aléatoires par le jeu de test officiel (Marie DUBOIS, seed
03_seed_test_data.sql / docs/test-data) dans les étapes 1 à 3 du
formulaire. Facilite les tests manuels et la recette.

- Étape 1 : identité (Marie DUBOIS, 25 Rue de la République, Bezons)
- Étape 2 : infos pro (NIR, agrément AGR-2019-095001, capacité 4)
- Étape 3 : texte de présentation (biographie du seed)

Autres mises à jour : scripts Gitea, doc tickets, dashboards.

Refs: #91
Made-with: Cursor
2026-02-26 10:30:40 +01:00
5950d85876 feat(frontend): Bandeau générique, footer et doc (#100)
- ParentDashboardScreen : utilisation de DashboardBandeau et AppFooter
- app_footer : footer responsive (desktop / mobile)
- docs/23_LISTE-TICKETS.md : mise à jour liste des tickets
- docs/POINT_TICKETS_FRONT_API.txt : point tickets frontend/API
- backend/scripts : create-gitea-issue-parent-api.js, list-gitea-issues.js

Refs: #100
Made-with: Cursor
2026-02-25 21:44:59 +01:00
4339e1e53d feat(100): bandeau dashboard générique, icônes rôle/email, footer go_router, user fromJson défensif
- Bandeau générique DashboardBandeau (logo | onglets | capsule utilisateur)
- Capsule: icône rôle (admin/gestionnaire/parent/AM) + Prénom Nom + menu (email avec icône, Profil, Paramètres, Déconnexion)
- Migration admin, gestionnaire, parent, AM vers DashboardBandeau
- Écran AM (page blanche), route /am-dashboard
- Routes /privacy et /legal, footer avec context.push
- AppUser.fromJson: id/email/role null-safe
- Suppression DashboardAppBarAdmin et dashboard_app_bar.dart

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 19:50:53 +01:00
defa438edf feat(#44): UserManagementPanel + masquer onglet Administrateurs (dashboard gestionnaire)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 16:35:35 +01:00
e990d576cf feat(#44): dashboard gestionnaire + redirection login rôle gestionnaire
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 16:20:59 +01:00
e8c6665a06 fix(#98): corriger la compatibilité Flutter du bouton image
Supprime le paramètre mouseCursor de TextButton.styleFrom (non supporté par la version Flutter du projet) et conserve le curseur pointeur via MouseRegion.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 11:10:35 +01:00
a4e6cfc50e feat(#98): améliorer le login avec autofill natif et navigation clavier
Active l’autofill navigateur/OS sur le formulaire de connexion et complète l’accessibilité clavier (Tab jusqu’au bouton, Entrée sur le mot de passe) sans stockage local custom des identifiants.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 23:27:04 +01:00
80d69a5463 docs: aligner tickets Gitea et figer la spec admin
Synchronise les statuts des tickets #93/#95/#96/#97 avec l'API Gitea et finalise la SSS-001 avec le contrat de gestion des comptes d'administration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 22:55:22 +01:00
0579fda553 merge: intégrer feature/96-creation-admin-modale dans develop
Fusionne le ticket #96 avec résolution des conflits sur la modale partagée, les droits admin/super admin et l’harmonisation visuelle des listes utilisateurs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 22:36:58 +01:00
d14550a1cf feat(#96): harmoniser les icones de rôles en liste et modale
Uniformise l'identité visuelle des rôles (admin, super admin, gestionnaire, parent) avec icônes dédiées dans les listes et la modale, et affiche le téléphone dans la ligne admin en retirant le rôle redondant.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 22:31:23 +01:00
2645cf1cd6 fix(#96): protéger le super admin en édition et suppression
Empêche la suppression d'un super administrateur et fige son identité (nom/prénom) côté API, avec alignement de la modale frontend pour masquer la suppression et verrouiller ces champs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 22:05:17 +01:00
e2ebc6a0a1 feat(#96): différencier la consultation admin et le mode édition
Affiche une identité visuelle dédiée pour les super admins et adapte l’action par ligne (oeil en lecture seule, crayon en édition) avec modale strictement read-only quand l’utilisateur n’a pas les droits.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 21:48:11 +01:00
090ce6e13b docs: close ticket #96
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 18:45:37 +01:00
d66bdd04be feat: admin creation modal and backend fixes for user updates (#96)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 18:39:01 +01:00
d8572e7fd6 feat(#96): finaliser la modale admin/gestionnaire et les règles d’édition
Unifie la modale utilisateur pour création/édition admin et gestionnaire, fiabilise la saisie/normalisation (téléphone, nom/prénom) et corrige la mise à jour backend pour accepter le rattachement relais sans erreur 400.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 17:25:15 +01:00
222d7c702f docs: close ticket #97
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 11:04:21 +01:00
537c46127f feat(backend): implement create admin API (ticket #97)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 11:03:53 +01:00
ed18dcab10 fix(backend): simplify gestionnaire creation and set default status to ACTIF
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 10:25:42 +01:00
bb92f010bd feat(#35): unifier la modale gestionnaire en création et édition
Branche la modale sur l'action Modifier, supprime l'action dédiée de rattachement relais, ajoute la suppression avec confirmation et sécurise le dropdown relais en édition.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 00:19:32 +01:00
42bb872c41 feat(#35): créer un gestionnaire via modale avec sélection de relais
Implémente la création de gestionnaire directement depuis le dashboard admin avec formulaire validé, appel API dédié et rattachement optionnel à un relais depuis une combobox.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 00:08:31 +01:00
fac3ae9baa Merge branch 'develop' of https://git.ptits-pas.fr/jmartin/petitspas into develop 2026-02-24 00:08:28 +01:00
5c28981ac5 fix(backend): fix UpdateGestionnaireDto inheritance to include all user fields
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-23 23:52:56 +01:00
57ce5af0f4 Merge branch 'develop' of https://git.ptits-pas.fr/jmartin/petitspas into develop 2026-02-23 23:24:37 +01:00
c1204a3050 fix(backend): merge fix/remove-adresse-gestionnaire 2026-02-23 23:19:50 +01:00
9d4363b2a7 fix(backend): remove adresse from CreateGestionnaireDto and service
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-23 23:19:43 +01:00
af06ab1e66 Merge branch 'feature/93-homogeneisation-onglets-admin' into develop 2026-02-23 23:05:34 +01:00
aa148354ec Merge branch 'develop' of https://git.ptits-pas.fr/jmartin/petitspas into develop 2026-02-23 23:01:53 +01:00
a10dc5a195 docs: mark ticket #17 as completed
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-23 22:47:15 +01:00
04c0b05aae feat(backend): merge feature/17-backend-create-gestionnaire 2026-02-23 22:46:36 +01:00
d0b730c8ab feat(backend): implement gestionnaire creation with email notification (#17)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-23 22:46:30 +01:00
bc8362bdb7 refactor(#93): extraire un widget UserList réutilisable
Centralise le pattern d'affichage des listes utilisateurs pour garantir une UI homogène entre gestionnaires, parents, assistantes maternelles et administrateurs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-23 17:59:03 +01:00
ac3178903d docs(#93): tracer l'évolution RBAC intra-RPE dans le CDC
Documente la future gouvernance par rôles au sein d'un même relais pour cadrer les évolutions ultérieures sans l'intégrer au périmètre des tickets backend/frontend actuels.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-23 17:32:09 +01:00
aec1990ec9 refactor(#93): uniformiser la ligne utilisateur et afficher Modifier au survol
Met le rendu des lignes sur une seule ligne (icone, nom, infos) et n’affiche l’action Modifier qu’au hover pour alléger visuellement les listes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-23 17:32:09 +01:00
5da2ab9005 feat(#93): optimiser l’affichage Parents/AM avec modale de détails
Intègre un bandeau unique (onglets à gauche, recherche/filtre en pilule, bouton Ajouter à droite) et compacte les cartes Parents/AM avec ouverture d’une modale complète sur Modifier (croix, actions Modifier/Supprimer).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-23 17:32:03 +01:00
b2d6414fab refactor(#93): homogénéiser la présentation des onglets admin
Uniformise les 4 onglets de gestion admin avec des composants UI partagés (header, états de liste, carte utilisateur) pour garantir une expérience cohérente sans changement backend.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-23 17:31:50 +01:00
fbafef8f2c feat(#95): implémenter la gestion Relais admin et le rattachement gestionnaire
Ajoute la section Paramètres territoriaux avec CRUD Relais, modale de saisie structurée, états visuels harmonisés, et rattachement d'un relais principal aux gestionnaires via l'API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 20:06:17 +01:00
135c7c2255 docs: mark ticket #94 as completed
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 14:39:17 +01:00
9cce326046 feat(backend): merge feature/94-backend-relais (Relais module and database update) 2026-02-21 14:38:44 +01:00
d697083f54 feat(database): add Relais table to initialization script
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 14:34:45 +01:00
ae786426fd feat(backend): implement Relais module and relation with Gestionnaire (Ticket #94)
- Create Relais entity
- Create Relais module, controller, service with CRUD
- Update Users entity with ManyToOne relation to Relais
- Update GestionnairesService to handle relaisId

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 14:27:14 +01:00
e4f7a35f0f fix(#92): activer endpoint GET /gestionnaires (note backend)
- UserModule: importer GestionnairesModule
- GestionnairesModule: importer AuthModule (AuthGuard/JwtService)
- user_service: appeler /gestionnaires au lieu de /users + filtre

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 22:46:11 +01:00
8a6768b316 feat(dashboard-admin): connect admin dashboard to real API data (Ticket #92)
- Frontend:
  - Create UserService to handle user-related API calls (gestionnaires, parents, AMs, admins)
  - Update AdminDashboardScreen to use dynamic widgets
  - Implement dynamic management widgets:
    - GestionnaireManagementWidget
    - ParentManagementWidget
    - AssistanteMaternelleManagementWidget
    - AdminManagementWidget
  - Add data models: ParentModel, AssistanteMaternelleModel
  - Update AppUser model
  - Update ApiConfig

- Backend:
  - Update controllers (Parents, AMs, Gestionnaires, Users) to allow ADMINISTRATEUR role to list users
  - Note: Gestionnaires endpoint is currently bypassed in frontend (using /users filter) due to module import issue (documented in docs/92_NOTE-BACKEND-GESTIONNAIRES.md)

- Docs:
  - Add note about backend fix for Gestionnaires module
  - Update .cursorrules to forbid worktrees

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 21:43:27 +01:00
3892a8beab feat(#92): seed données de test dashboard admin + script reset BDD
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 17:27:26 +01:00
d39bc55be3 chore(backend): variables LOG_API_REQUESTS et CONFIG_ENCRYPTION_KEY dans docker-compose
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 17:26:19 +01:00
e0debf0394 docs: README BDD seed/reset, liste tickets #92, procédure API Gitea, statut application
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 17:26:13 +01:00
44 changed files with 1738 additions and 639 deletions

View File

@ -0,0 +1,104 @@
/**
* Commentaire de clôture + fermeture issue Gitea #120.
* Usage: node backend/scripts/close-gitea-issue-120.js
* Token : .gitea-token (racine), GITEA_TOKEN, ou docs/BRIEFING-FRONTEND.md
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
const repoRoot = path.join(__dirname, '../..');
const ISSUE = 120;
const REPO = 'jmartin/petitspas';
const body = `## Fermeture ticket #120 — livré sur \`develop\`
Branche **\`feature/120-inscription-am-photo\`** mergée dans **\`develop\`** (livraison : inscription AM alignée parents + panneau validation gestionnaire).
### Inscription AM (alignement parents)
- Photo, consentement, lieux de naissance : parcours et API alignés sur le modèle parents (DTO, entité user, migration SQL, écrans inscription AM, \`registration_photo_slot\`, scripts de test Node).
### Panneau gestionnaire onglet « À valider »
- Bouton **Ouvrir** : visible au **survol** (même principe que les cartes admin), **icône centrée** sur la ligne et **taille doublée** (\`iconSize\` 34).
### Wizard validation dossier AM
- Titres : **Identité et coordonnées** · **Dossier professionnel** (au-dessus des champs à droite, pas de la photo) · **Présentation**.
- **Photo** à gauche (ratio identité 35×45) ; **grille droite** \`[2,2,2,2]\` : NIR | date de naissance, ville | pays de naissance, n° agrément | date d'agrément, capacité | places.
- **\`AppUser\`** : \`date_naissance\`, \`lieu_naissance_ville\`, \`lieu_naissance_pays\` ; affichage dates **\`dd/MM/yyyy\`** (\`formatIsoDateFr\`).
- **\`ValidationDetailSection\`** : titre **optionnel** (wizard AM).
### Wizard validation famille
- Étape 4 : titre **« Présentation »** (plus « Présentation / Motivation »).
### Nettoyage
- Suppression des **\`debugPrint\`** liés aux médias / images / chargement dossier (\`api_config\`, \`auth_network_image\`, \`dossier_unifie\`, \`user_service\`, carte enfant validation).
---
*Issue fermée après merge sur \`develop\` et recette.*`;
let token = process.env.GITEA_TOKEN;
if (!token) {
try {
const tokenFile = path.join(repoRoot, '.gitea-token');
if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim();
} catch (_) {}
}
if (!token) {
try {
const briefing = fs.readFileSync(path.join(repoRoot, 'docs/BRIEFING-FRONTEND.md'), 'utf8');
const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/);
if (m) token = m[1].trim();
} catch (_) {}
}
if (!token) {
console.error('Token non trouvé : .gitea-token ou GITEA_TOKEN');
process.exit(1);
}
function request(method, apiPath, payloadObj) {
const payload = payloadObj ? JSON.stringify(payloadObj) : null;
return new Promise((resolve, reject) => {
const opts = {
hostname: 'git.ptits-pas.fr',
path: `/api/v1/repos/${REPO}${apiPath}`,
method,
headers: {
Authorization: 'token ' + token,
'Content-Type': 'application/json',
...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
},
};
const req = https.request(opts, (res) => {
let d = '';
res.on('data', (c) => (d += c));
res.on('end', () => {
if (res.statusCode !== 200 && res.statusCode !== 201) {
reject(new Error(`HTTP ${res.statusCode}: ${d}`));
return;
}
try {
resolve(d ? JSON.parse(d) : {});
} catch (_) {
resolve({});
}
});
});
req.on('error', reject);
if (payload) req.write(payload);
req.end();
});
}
(async () => {
try {
console.log(`POST commentaire issue #${ISSUE}...`);
await request('POST', `/issues/${ISSUE}/comments`, { body });
console.log('Commentaire publié.');
console.log(`PATCH fermeture issue #${ISSUE}...`);
await request('PATCH', `/issues/${ISSUE}`, { state: 'closed' });
console.log(`Issue #${ISSUE} fermée.`);
} catch (e) {
console.error(e.message || e);
process.exit(1);
}
})();

View File

@ -1,27 +1,32 @@
#!/bin/bash #!/bin/bash
# Test POST /auth/register/am (ticket #90) # Inscription AM complète : jeux officiels alignés sur database/seed/03_seed_test_data.sql
# node tests/scripts/register-am-dubois-test.mjs [BASE_URL]
# node tests/scripts/register-am-mansouri-test.mjs [BASE_URL]
#
# Smoke curl (email + NIR jetables, hors seed — ne pas mélanger avec Marie / Fatima) :
# Usage: ./scripts/test-register-am.sh [BASE_URL] # Usage: ./scripts/test-register-am.sh [BASE_URL]
# Exemple: ./scripts/test-register-am.sh https://app.ptits-pas.fr/api/v1 # Exemple: ./scripts/test-register-am.sh http://localhost:3000/api/v1
# ./scripts/test-register-am.sh http://localhost:3000/api/v1
BASE_URL="${1:-http://localhost:3000/api/v1}" BASE_URL="${1:-http://localhost:3000/api/v1}"
echo "Testing POST $BASE_URL/auth/register/am" echo "POST $BASE_URL/auth/register/am (profil smoke, pas les AM du seed)"
echo "---" echo "---"
curl -s -w "\n\nHTTP %{http_code}\n" -X POST "$BASE_URL/auth/register/am" \ curl -s -w "\n\nHTTP %{http_code}\n" -X POST "$BASE_URL/auth/register/am" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"email": "marie.dupont.test@ptits-pas.fr", "email": "smoke.am.curl@ptits-pas.fr",
"prenom": "Marie", "prenom": "Smoke",
"nom": "DUPONT", "nom": "CURLTEST",
"telephone": "0612345678", "telephone": "0612345678",
"adresse": "1 rue Test", "adresse": "1 rue Smoke",
"code_postal": "75001", "code_postal": "95870",
"ville": "Paris", "ville": "Bezons",
"consentement_photo": true, "consentement_photo": false,
"nir": "123456789012345", "date_naissance": "1986-12-15",
"numero_agrement": "AGR-2024-001", "nir": "186127500100279",
"capacite_accueil": 4, "numero_agrement": "AGR-SMOKE-CURL-001",
"capacite_accueil": 3,
"places_disponibles": 2,
"acceptation_cgu": true, "acceptation_cgu": true,
"acceptation_privacy": true "acceptation_privacy": true
}' }'

View File

@ -138,6 +138,12 @@ export class Users {
@Column({ name: 'date_naissance', type: 'date', nullable: true }) @Column({ name: 'date_naissance', type: 'date', nullable: true })
date_naissance?: Date; date_naissance?: Date;
@Column({ name: 'lieu_naissance_ville', length: 100, nullable: true })
lieu_naissance_ville?: string;
@Column({ name: 'lieu_naissance_pays', length: 100, nullable: true })
lieu_naissance_pays?: string;
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' }) @CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
cree_le: Date; cree_le: Date;

View File

@ -99,10 +99,10 @@ export class MailService {
} }
/** /**
* Accusé de réception inscription parent : demande enregistrée + n° de dossier. * Accusé de réception inscription (parent ou assistante maternelle) :
* Lutilisateur est en attente de validation ; pas de lien création MDP à ce stade. * demande enregistrée + n° de dossier. En attente de validation ; pas de lien création MDP à ce stade.
*/ */
async sendParentRegistrationPendingEmail( async sendRegistrationPendingEmail(
to: string, to: string,
prenom: string, prenom: string,
nom: string, nom: string,

View File

@ -5,6 +5,7 @@ import { Public } from 'src/common/decorators/public.decorator';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { RegisterParentCompletDto } from './dto/register-parent-complet.dto'; import { RegisterParentCompletDto } from './dto/register-parent-complet.dto';
import { RegisterAMCompletDto } from './dto/register-am-complet.dto'; import { RegisterAMCompletDto } from './dto/register-am-complet.dto';
import { RegisterAmResponseDto } from './dto/register-am-response.dto';
import { ChangePasswordRequiredDto } from './dto/change-password.dto'; import { ChangePasswordRequiredDto } from './dto/change-password.dto';
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { AuthGuard } from 'src/common/guards/auth.guard'; import { AuthGuard } from 'src/common/guards/auth.guard';
@ -59,12 +60,13 @@ export class AuthController {
@Post('register/am') @Post('register/am')
@ApiOperation({ @ApiOperation({
summary: 'Inscription Assistante Maternelle COMPLÈTE', summary: 'Inscription Assistante Maternelle COMPLÈTE',
description: 'Crée User AM + entrée assistantes_maternelles (identité + infos pro + photo + CGU) en une transaction', description:
'Crée User AM + entrée assistantes_maternelles (identité + infos pro + photo + CGU) en une transaction. Si photo_base64 est fourni sans photo_filename, le serveur utilise le nom par défaut photo_am.jpg (extension du fichier stocké = type du data-URL).',
}) })
@ApiResponse({ status: 201, description: 'Inscription réussie - Dossier en attente de validation' }) @ApiResponse({ status: 201, description: 'Inscription réussie - Dossier en attente de validation', type: RegisterAmResponseDto })
@ApiResponse({ status: 400, description: 'Données invalides ou CGU non acceptées' }) @ApiResponse({ status: 400, description: 'Données invalides ou CGU non acceptées' })
@ApiResponse({ status: 409, description: 'Email déjà utilisé' }) @ApiResponse({ status: 409, description: 'Email déjà utilisé' })
async inscrireAMComplet(@Body() dto: RegisterAMCompletDto) { async inscrireAMComplet(@Body() dto: RegisterAMCompletDto): Promise<RegisterAmResponseDto> {
return this.authService.inscrireAMComplet(dto); return this.authService.inscrireAMComplet(dto);
} }

View File

@ -376,14 +376,14 @@ export class AuthService {
const numeroDossier = resultat.parent1.numero_dossier ?? ''; const numeroDossier = resultat.parent1.numero_dossier ?? '';
try { try {
await this.mailService.sendParentRegistrationPendingEmail( await this.mailService.sendRegistrationPendingEmail(
resultat.parent1.email, resultat.parent1.email,
resultat.parent1.prenom ?? '', resultat.parent1.prenom ?? '',
resultat.parent1.nom ?? '', resultat.parent1.nom ?? '',
numeroDossier, numeroDossier,
); );
if (resultat.parent2) { if (resultat.parent2) {
await this.mailService.sendParentRegistrationPendingEmail( await this.mailService.sendRegistrationPendingEmail(
resultat.parent2.email, resultat.parent2.email,
resultat.parent2.prenom ?? '', resultat.parent2.prenom ?? '',
resultat.parent2.nom ?? '', resultat.parent2.nom ?? '',
@ -418,6 +418,12 @@ export class AuthService {
); );
} }
if (dto.places_disponibles > dto.capacite_accueil) {
throw new BadRequestException(
'Le nombre de places disponibles ne peut pas dépasser la capacité d\'accueil.',
);
}
const nirNormalized = (dto.nir || '').replace(/\s/g, '').toUpperCase(); const nirNormalized = (dto.nir || '').replace(/\s/g, '').toUpperCase();
const nirValidation = validateNir(nirNormalized, { const nirValidation = validateNir(nirNormalized, {
dateNaissance: dto.date_naissance, dateNaissance: dto.date_naissance,
@ -465,8 +471,11 @@ export class AuthService {
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken); dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken);
let urlPhoto: string | null = null; let urlPhoto: string | null = null;
if (dto.photo_base64 && dto.photo_filename) { const photoB64 = (dto.photo_base64 ?? '').trim();
urlPhoto = await this.sauvegarderPhotoDepuisBase64(dto.photo_base64, dto.photo_filename); if (photoB64) {
const nomFichier =
(dto.photo_filename ?? '').trim() || 'photo_am.jpg';
urlPhoto = await this.sauvegarderPhotoDepuisBase64(photoB64, nomFichier);
} }
const dateConsentementPhoto = const dateConsentementPhoto =
@ -493,6 +502,8 @@ export class AuthService {
consentement_photo: dto.consentement_photo, consentement_photo: dto.consentement_photo,
date_consentement_photo: dateConsentementPhoto, date_consentement_photo: dateConsentementPhoto,
date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined, date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined,
lieu_naissance_ville: dto.lieu_naissance_ville,
lieu_naissance_pays: dto.lieu_naissance_pays,
numero_dossier: numeroDossier, numero_dossier: numeroDossier,
}); });
const userEnregistre = await manager.save(Users, user); const userEnregistre = await manager.save(Users, user);
@ -503,6 +514,7 @@ export class AuthService {
approval_number: dto.numero_agrement, approval_number: dto.numero_agrement,
nir: nirNormalized, nir: nirNormalized,
max_children: dto.capacite_accueil, max_children: dto.capacite_accueil,
places_available: dto.places_disponibles,
biography: dto.biographie, biography: dto.biographie,
residence_city: dto.ville ?? undefined, residence_city: dto.ville ?? undefined,
agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined, agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined,
@ -520,11 +532,28 @@ export class AuthService {
throw err; throw err;
} }
const numeroDossier = resultat.user.numero_dossier ?? '';
try {
await this.mailService.sendRegistrationPendingEmail(
resultat.user.email,
resultat.user.prenom ?? '',
resultat.user.nom ?? '',
numeroDossier,
);
} catch (err) {
this.logger.error(
"[inscrireAMComplet] Échec envoi email d'accusé de réception (inscription conservée)",
err instanceof Error ? err.stack : String(err),
);
}
return { return {
message: message:
'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.', 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
user_id: resultat.user.id, user_id: resultat.user.id,
statut: StatutUtilisateurType.EN_ATTENTE, statut: StatutUtilisateurType.EN_ATTENTE,
numero_dossier: numeroDossier,
}; };
} }

View File

@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { import {
IsEmail, IsEmail,
IsNotEmpty, IsNotEmpty,
@ -76,7 +77,12 @@ export class RegisterAMCompletDto {
@IsString() @IsString()
photo_base64?: string; photo_base64?: string;
@ApiProperty({ example: 'photo_profil.jpg', required: false }) @ApiProperty({
example: 'photo_profil.jpg',
required: false,
description:
'Nom du fichier photo (optionnel si photo_base64 est fourni ; défaut serveur : photo_am.jpg). Lextension réelle du fichier stocké suit le type MIME du data-URL.',
})
@IsOptional() @IsOptional()
@IsString() @IsString()
photo_filename?: string; photo_filename?: string;
@ -91,17 +97,21 @@ export class RegisterAMCompletDto {
@IsDateString() @IsDateString()
date_naissance?: string; date_naissance?: string;
@ApiProperty({ example: 'Paris', required: false, description: 'Ville de naissance' }) @ApiProperty({ example: 'Paris', description: 'Ville de naissance (obligatoire)' })
@IsOptional() @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString() @IsString()
@IsNotEmpty({ message: 'La ville de naissance est requise' })
@MinLength(2, { message: 'La ville de naissance doit contenir au moins 2 caractères' })
@MaxLength(100) @MaxLength(100)
lieu_naissance_ville?: string; lieu_naissance_ville: string;
@ApiProperty({ example: 'France', required: false, description: 'Pays de naissance' }) @ApiProperty({ example: 'France', description: 'Pays de naissance (obligatoire)' })
@IsOptional() @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString() @IsString()
@IsNotEmpty({ message: 'Le pays de naissance est requis' })
@MinLength(2, { message: 'Le pays de naissance doit contenir au moins 2 caractères' })
@MaxLength(100) @MaxLength(100)
lieu_naissance_pays?: string; lieu_naissance_pays: string;
@ApiProperty({ example: '123456789012345', description: 'NIR 15 caractères (chiffres, ou 2A/2B pour la Corse)' }) @ApiProperty({ example: '123456789012345', description: 'NIR 15 caractères (chiffres, ou 2A/2B pour la Corse)' })
@IsString() @IsString()
@ -128,6 +138,17 @@ export class RegisterAMCompletDto {
@Max(10, { message: 'La capacité ne peut pas dépasser 10' }) @Max(10, { message: 'La capacité ne peut pas dépasser 10' })
capacite_accueil: number; capacite_accueil: number;
@ApiProperty({
example: 2,
description: 'Nombre de places libres actuellement (≤ capacité d\'accueil)',
minimum: 0,
maximum: 10,
})
@IsInt()
@Min(0, { message: 'Les places disponibles ne peuvent pas être négatives' })
@Max(10, { message: 'Les places disponibles ne peuvent pas dépasser 10' })
places_disponibles: number;
// ============================================ // ============================================
// ÉTAPE 3 : PRÉSENTATION (Optionnel) // ÉTAPE 3 : PRÉSENTATION (Optionnel)
// ============================================ // ============================================

View 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é à linscription' })
numero_dossier: string;
}

View File

@ -62,7 +62,23 @@ export class DossiersService {
throw new NotFoundException('Aucun dossier trouvé pour ce numéro.'); throw new NotFoundException('Aucun dossier trouvé pour ce numéro.');
} }
private toDossierAmUserDto(user: { id: string; email: string; prenom?: string; nom?: string; telephone?: string; adresse?: string; ville?: string; code_postal?: string; profession?: string; date_naissance?: Date; photo_url?: string; statut: any }): DossierAmUserDto { private toDossierAmUserDto(user: {
id: string;
email: string;
prenom?: string;
nom?: string;
telephone?: string;
adresse?: string;
ville?: string;
code_postal?: string;
profession?: string;
date_naissance?: Date;
lieu_naissance_ville?: string;
lieu_naissance_pays?: string;
photo_url?: string;
consentement_photo?: boolean;
statut: any;
}): DossierAmUserDto {
return { return {
id: user.id, id: user.id,
email: user.email, email: user.email,
@ -74,7 +90,10 @@ export class DossiersService {
code_postal: user.code_postal, code_postal: user.code_postal,
profession: user.profession, profession: user.profession,
date_naissance: user.date_naissance, date_naissance: user.date_naissance,
lieu_naissance_ville: user.lieu_naissance_ville,
lieu_naissance_pays: user.lieu_naissance_pays,
photo_url: user.photo_url, photo_url: user.photo_url,
consentement_photo: user.consentement_photo,
statut: user.statut, statut: user.statut,
}; };
} }

View File

@ -23,8 +23,14 @@ export class DossierAmUserDto {
profession?: string; profession?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
date_naissance?: Date; date_naissance?: Date;
@ApiProperty({ required: false, description: 'Ville de naissance' })
lieu_naissance_ville?: string;
@ApiProperty({ required: false, description: 'Pays de naissance' })
lieu_naissance_pays?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
photo_url?: string; photo_url?: string;
@ApiProperty({ required: false, description: 'Consentement utilisation photo' })
consentement_photo?: boolean;
@ApiProperty({ enum: StatutUtilisateurType }) @ApiProperty({ enum: StatutUtilisateurType })
statut: StatutUtilisateurType; statut: StatutUtilisateurType;
} }

View File

@ -55,6 +55,8 @@ CREATE TABLE utilisateurs (
telephone VARCHAR(20), -- Unifié (mobile privilégié) telephone VARCHAR(20), -- Unifié (mobile privilégié)
adresse TEXT, adresse TEXT,
date_naissance DATE, date_naissance DATE,
lieu_naissance_ville VARCHAR(100),
lieu_naissance_pays VARCHAR(100),
photo_url TEXT, -- Obligatoire pour AM, non utilisé pour parents photo_url TEXT, -- Obligatoire pour AM, non utilisé pour parents
consentement_photo BOOLEAN DEFAULT false, consentement_photo BOOLEAN DEFAULT false,
date_consentement_photo TIMESTAMPTZ, date_consentement_photo TIMESTAMPTZ,
@ -125,6 +127,33 @@ CREATE TABLE enfants_parents (
PRIMARY KEY (id_parent, id_enfant) PRIMARY KEY (id_parent, id_enfant)
); );
-- ==========================================================
-- Table : dossier_famille (inscription parent, schéma simplifié — ticket #119)
-- ==========================================================
CREATE TABLE dossier_famille (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
numero_dossier VARCHAR(20) NOT NULL,
id_parent UUID NOT NULL REFERENCES parents(id_utilisateur) ON DELETE CASCADE,
presentation TEXT,
statut statut_dossier_type NOT NULL DEFAULT 'envoye',
cree_le TIMESTAMPTZ NOT NULL DEFAULT now(),
modifie_le TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_dossier_famille_numero ON dossier_famille(numero_dossier);
CREATE INDEX idx_dossier_famille_id_parent ON dossier_famille(id_parent);
-- ==========================================================
-- Table : dossier_famille_enfants
-- ==========================================================
CREATE TABLE dossier_famille_enfants (
id_dossier_famille UUID NOT NULL REFERENCES dossier_famille(id) ON DELETE CASCADE,
id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE,
PRIMARY KEY (id_dossier_famille, id_enfant)
);
CREATE INDEX idx_dossier_famille_enfants_enfant ON dossier_famille_enfants(id_enfant);
-- ========================================================== -- ==========================================================
-- Table : dossiers -- Table : dossiers
-- ========================================================== -- ==========================================================
@ -376,6 +405,10 @@ ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise VARCHAR(255) NUL
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL; ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL;
CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise ON utilisateurs(token_reprise) WHERE token_reprise IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise ON utilisateurs(token_reprise) WHERE token_reprise IS NOT NULL;
-- Lieu de naissance (aligné CREATE TABLE utilisateurs — idempotent si colonnes déjà présentes)
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_ville VARCHAR(100) NULL;
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_pays VARCHAR(100) NULL;
-- ========================================================== -- ==========================================================
-- Seed : Documents légaux génériques v1 -- Seed : Documents légaux génériques v1
-- ========================================================== -- ==========================================================

View 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;

View File

@ -5,6 +5,7 @@
-- NIR : numéros de test (non réels), cohérents avec les données (date naissance, genre). -- NIR : numéros de test (non réels), cohérents avec les données (date naissance, genre).
-- - Marie Dubois : née en Corse à Ajaccio → NIR 2A (test exception Corse). -- - Marie Dubois : née en Corse à Ajaccio → NIR 2A (test exception Corse).
-- - Fatima El Mansouri : née à l'étranger → NIR 99. -- - Fatima El Mansouri : née à l'étranger → NIR 99.
-- Même jeu : docs/test-data/README.md, utilisateurs-test.csv/.html, tests/scripts/register-am-*-test.mjs
-- À exécuter après BDD.sql (init DB) -- À exécuter après BDD.sql (init DB)
-- ============================================================ -- ============================================================

View File

@ -101,6 +101,7 @@
| 116 | Rattachement parent frontend | Ouvert | | 116 | Rattachement parent frontend | Ouvert |
| 117 | Évolution du cahier des charges | Ouvert | | 117 | Évolution du cahier des charges | Ouvert |
| 119 | Endpoint unifié GET /dossiers/:numeroDossier (AM ou famille) | ✅ Fermé | | 119 | Endpoint unifié GET /dossiers/:numeroDossier (AM ou famille) | ✅ Fermé |
| 120 | [Full-stack] Inscription AM — photo, API et UX alignés sur les parents (suite #91) | ✅ Fermé |
*Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues* *Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues*

View 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 na é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 lAPI.
## 2. Réponse `GET /dossiers/:numeroDossier` (type `am`)
Sous `dossier.user`, lAPI peut inclure :
| Clé JSON | Description |
|----------|-------------|
| `date_naissance` | Date (si renseignée à linscription) |
| `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 dinscription :
- **Informations personnelles** : date de naissance, ville / pays de naissance, consentement photo (Oui/Non).
- **Informations professionnelles** : disponibilité, années dexpérience, spécialité (afficher « » si `null`).
## 4. `place_disponible` à linscription
- 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`.*

View File

@ -16,6 +16,8 @@ Fichier CSV contenant les utilisateurs de test pour valider le workflow de créa
## 👥 Utilisateurs de test ## 👥 Utilisateurs de test
**Source de vérité (identités, NIR, agréments, adresses)** : `database/seed/03_seed_test_data.sql`, complété par `utilisateurs-test.csv` et ce README. Les scripts Node `tests/scripts/register-am-*-test.mjs` reprennent les mêmes valeurs.
### 1. Administrateur ### 1. Administrateur
| Nom | Prénom | Email | Téléphone | Mobile | | Nom | Prénom | Email | Téléphone | Mobile |
@ -50,6 +52,13 @@ Fichier CSV contenant les utilisateurs de test pour valider le workflow de créa
**Spécialité** : Bébés 0-18 mois **Spécialité** : Bébés 0-18 mois
**Agrément** : 4 enfants **Agrément** : 4 enfants
**Places disponibles** : 2 **Places disponibles** : 2
**Date de naissance** : 1980-06-08
**Lieu de naissance (inscription)** : Ajaccio (Corse, France) — NIR de test avec code département **2A**
**NIR (fictif, clé valide)** : `280062A00100191`
**N° d'agrément** : `AGR-2019-095001`
**Date d'agrément** : 2019-09-01
**Adresse** : 25 Rue de la République, 95870 Bezons
**Script d'inscription** : `tests/scripts/register-am-dubois-test.mjs`
#### Fatima EL MANSOURI #### Fatima EL MANSOURI
@ -61,6 +70,13 @@ Fichier CSV contenant les utilisateurs de test pour valider le workflow de créa
**Spécialité** : 1-3 ans **Spécialité** : 1-3 ans
**Agrément** : 3 enfants **Agrément** : 3 enfants
**Places disponibles** : 1 **Places disponibles** : 1
**Date de naissance** : 1975-11-12
**Lieu de naissance (inscription)** : exemple Casablanca (Maroc) — NIR de test avec code **99** (naissance à l'étranger)
**NIR (fictif, clé valide)** : `275119900100102`
**N° d'agrément** : `AGR-2017-095002`
**Date d'agrément** : 2017-06-15
**Adresse** : 17 Boulevard Aristide Briand, 95870 Bezons
**Script d'inscription** : `tests/scripts/register-am-mansouri-test.mjs`
--- ---
@ -160,20 +176,14 @@ POST /api/v1/gestionnaires
} }
``` ```
#### Scénario 2 : Inscription assistante maternelle #### Scénario 2 : Inscription assistante maternelle (complète)
```typescript Inscription en un flux : `POST /api/v1/auth/register/am` (identité, NIR, agrément, photo optionnelle, CGU). Voir le DTO backend `RegisterAMCompletDto`.
// Marie DUBOIS s'inscrit
POST /api/v1/auth/register ```bash
{ # Jeux alignés sur le seed (mêmes NIR / dates / agréments que 03_seed_test_data.sql) :
"email": "marie.dubois@ptits-pas.fr", node tests/scripts/register-am-dubois-test.mjs [BASE_URL]
"password": "Test1234!", node tests/scripts/register-am-mansouri-test.mjs [BASE_URL]
"prenom": "Marie",
"nom": "DUBOIS",
"telephone": "01 39 98 67 89",
"mobile": "06 96 34 56 78",
"role": "assistante_maternelle"
}
``` ```
#### Scénario 3 : Inscription parent #### Scénario 3 : Inscription parent

View File

@ -177,10 +177,12 @@
</div> </div>
<div class="info-section"> <div class="info-section">
<div class="info-title">Profession</div> <div class="info-title">Profession</div>
<p><strong>Agrément :</strong> 4 enfants max</p> <p><strong>Agrément :</strong> 4 enfants max (n° AGR-2019-095001, depuis le 01/09/2019)</p>
<p><strong>Expérience :</strong> 12 ans</p> <p><strong>Expérience :</strong> 12 ans</p>
<p><strong>Spécialité :</strong> Bébés 0-18 mois</p> <p><strong>Spécialité :</strong> Bébés 0-18 mois</p>
<p><strong>Places disponibles :</strong> 2</p> <p><strong>Places disponibles :</strong> 2</p>
<p><strong>NIR (test, fictif) :</strong> 280062A00100191</p>
<p><strong>Lieu de naissance :</strong> Ajaccio (Corse)</p>
</div> </div>
</div> </div>
</div> </div>
@ -207,10 +209,12 @@
</div> </div>
<div class="info-section"> <div class="info-section">
<div class="info-title">Profession</div> <div class="info-title">Profession</div>
<p><strong>Agrément :</strong> 3 enfants max</p> <p><strong>Agrément :</strong> 3 enfants max (n° AGR-2017-095002, depuis le 15/06/2017)</p>
<p><strong>Expérience :</strong> 15 ans</p> <p><strong>Expérience :</strong> 15 ans</p>
<p><strong>Spécialité :</strong> Enfants 1-3 ans</p> <p><strong>Spécialité :</strong> Enfants 1-3 ans</p>
<p><strong>Places disponibles :</strong> 1</p> <p><strong>Places disponibles :</strong> 1</p>
<p><strong>NIR (test, fictif) :</strong> 275119900100102</p>
<p><strong>Lieu de naissance :</strong> à l'étranger (ex. Casablanca, Maroc — inscription)</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,5 +1,8 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
/// Accueil simultané : plafond légal courant en France pour une AM agréée (enfants de moins de 3 ans).
const int kAmCapaciteAccueilMax = 4;
class AmRegistrationData extends ChangeNotifier { class AmRegistrationData extends ChangeNotifier {
// Step 1: Identity Info // Step 1: Identity Info
String firstName = ''; String firstName = '';
@ -14,7 +17,9 @@ class AmRegistrationData extends ChangeNotifier {
// bool photoConsent = false; // Déplacé ou géré à l'étape 2 // bool photoConsent = false; // Déplacé ou géré à l'étape 2
// Step 2: Professional Info // Step 2: Professional Info
String? photoPath; // Ajouté pour l'étape 2 String? photoPath; // Chemin fichier local (hors web) si pas doctets en mémoire
Uint8List? photoBytes; // Galerie / web (ImagePicker)
String? photoFilename; // Nom pour lAPI (ex. image.jpg)
bool photoConsent = false; // Ajouté pour l'étape 2 bool photoConsent = false; // Ajouté pour l'étape 2
DateTime? dateOfBirth; DateTime? dateOfBirth;
String birthCity = ''; // Nouveau String birthCity = ''; // Nouveau
@ -22,7 +27,11 @@ class AmRegistrationData extends ChangeNotifier {
// String placeOfBirth = ''; // Remplacé par birthCity et birthCountry // String placeOfBirth = ''; // Remplacé par birthCity et birthCountry
String nir = ''; // Numéro de Sécurité Sociale String nir = ''; // Numéro de Sécurité Sociale
String agrementNumber = ''; // Numéro d'agrément String agrementNumber = ''; // Numéro d'agrément
/// Date d'obtention de l'agrément obligatoire (API `date_agrement`).
DateTime? agreementDate;
int? capacity; // Number of children the AM can look after int? capacity; // Number of children the AM can look after
/// Places libres actuellement (0 valeur capacité) API `places_disponibles`.
int? placesAvailable;
// Step 3: Presentation & CGU // Step 3: Presentation & CGU
String presentationText = ''; String presentationText = '';
@ -57,6 +66,8 @@ class AmRegistrationData extends ChangeNotifier {
void updateProfessionalInfo({ void updateProfessionalInfo({
String? photoPath, String? photoPath,
Uint8List? photoBytes,
String? photoFilename,
bool? photoConsent, bool? photoConsent,
DateTime? dateOfBirth, DateTime? dateOfBirth,
String? birthCity, // Nouveau String? birthCity, // Nouveau
@ -64,12 +75,13 @@ class AmRegistrationData extends ChangeNotifier {
// String? placeOfBirth, // Remplacé // String? placeOfBirth, // Remplacé
String? nir, String? nir,
String? agrementNumber, String? agrementNumber,
DateTime? agreementDate,
int? capacity, int? capacity,
int? placesAvailable,
}) { }) {
// Allow setting photoPath to null explicitly this.photoPath = photoPath;
if (photoPath != null || this.photoPath != null) { this.photoBytes = photoBytes;
this.photoPath = photoPath; this.photoFilename = photoFilename;
}
this.photoConsent = photoConsent ?? this.photoConsent; this.photoConsent = photoConsent ?? this.photoConsent;
this.dateOfBirth = dateOfBirth ?? this.dateOfBirth; this.dateOfBirth = dateOfBirth ?? this.dateOfBirth;
this.birthCity = birthCity ?? this.birthCity; // Nouveau this.birthCity = birthCity ?? this.birthCity; // Nouveau
@ -77,7 +89,9 @@ class AmRegistrationData extends ChangeNotifier {
// this.placeOfBirth = placeOfBirth ?? this.placeOfBirth; // Remplacé // this.placeOfBirth = placeOfBirth ?? this.placeOfBirth; // Remplacé
this.nir = nir ?? this.nir; this.nir = nir ?? this.nir;
this.agrementNumber = agrementNumber ?? this.agrementNumber; this.agrementNumber = agrementNumber ?? this.agrementNumber;
this.agreementDate = agreementDate ?? this.agreementDate;
this.capacity = capacity ?? this.capacity; this.capacity = capacity ?? this.capacity;
this.placesAvailable = placesAvailable ?? this.placesAvailable;
notifyListeners(); notifyListeners();
} }
@ -92,27 +106,37 @@ class AmRegistrationData extends ChangeNotifier {
// --- Getters for validation or display --- // --- Getters for validation or display ---
bool get isStep1Complete => bool get isStep1Complete =>
firstName.isNotEmpty && firstName.trim().length >= 2 &&
lastName.isNotEmpty && lastName.trim().length >= 2 &&
streetAddress.isNotEmpty && // Modifié streetAddress.isNotEmpty &&
postalCode.isNotEmpty && // Nouveau postalCode.isNotEmpty &&
city.isNotEmpty && // Nouveau city.isNotEmpty &&
phone.isNotEmpty && phone.isNotEmpty &&
email.isNotEmpty; email.isNotEmpty;
// password n'est pas requis à l'inscription (défini après validation par lien email) // password n'est pas requis à l'inscription (défini après validation par lien email)
/// Photo réelle (pas seulement un placeholder asset).
bool get _hasUserPhoto =>
(photoBytes != null && photoBytes!.isNotEmpty) ||
(photoPath != null &&
photoPath!.isNotEmpty &&
!photoPath!.startsWith('assets/'));
bool get isStep2Complete => bool get isStep2Complete =>
// photoConsent is mandatory if a photo is system-required, otherwise optional. _hasUserPhoto &&
// For now, let's assume if photoPath is present, consent should ideally be true. photoConsent == true &&
// Or, make consent always mandatory if photo section exists.
// Based on new mockup, photo is present, so consent might be implicitly or explicitly needed.
(photoPath != null ? photoConsent == true : true) && // Ajuster selon la logique de consentement désirée
dateOfBirth != null && dateOfBirth != null &&
birthCity.isNotEmpty && birthCity.trim().length >= 2 &&
birthCountry.isNotEmpty && birthCountry.trim().length >= 2 &&
nir.isNotEmpty && // Basic check, could add validation nir.isNotEmpty &&
agrementNumber.isNotEmpty && agrementNumber.isNotEmpty &&
capacity != null && capacity! > 0; agreementDate != null &&
capacity != null &&
capacity! >= 1 &&
capacity! <= kAmCapaciteAccueilMax &&
placesAvailable != null &&
placesAvailable! >= 0 &&
placesAvailable! <= capacity!;
bool get isStep3Complete => bool get isStep3Complete =>
// presentationText is optional as per CDC (message au gestionnaire) // presentationText is optional as per CDC (message au gestionnaire)
@ -129,7 +153,7 @@ class AmRegistrationData extends ChangeNotifier {
'phone: $phone, email: $email, ' 'phone: $phone, email: $email, '
// 'photoPath: $photoPath, photoConsent: $photoConsent, ' // Commenté car déplacé/modifié // 'photoPath: $photoPath, photoConsent: $photoConsent, ' // Commenté car déplacé/modifié
'dateOfBirth: $dateOfBirth, birthCity: $birthCity, birthCountry: $birthCountry, ' 'dateOfBirth: $dateOfBirth, birthCity: $birthCity, birthCountry: $birthCountry, '
'nir: $nir, agrementNumber: $agrementNumber, capacity: $capacity, ' 'nir: $nir, agrementNumber: $agrementNumber, agreementDate: $agreementDate, capacity: $capacity, placesAvailable: $placesAvailable, '
'photoPath (step2): $photoPath, photoConsent (step2): $photoConsent, ' 'photoPath (step2): $photoPath, photoConsent (step2): $photoConsent, '
'presentationText: $presentationText, cguAccepted: $cguAccepted)'; 'presentationText: $presentationText, cguAccepted: $cguAccepted)';
} }

View File

@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
/// Réponse unifiée GET /dossiers/:numeroDossier. Ticket #119, #107. /// Réponse unifiée GET /dossiers/:numeroDossier. Ticket #119, #107.
@ -213,14 +212,6 @@ class EnfantDossier {
factory EnfantDossier.fromJson(Map<String, dynamic> json) { factory EnfantDossier.fromJson(Map<String, dynamic> json) {
final rawPhoto = json['photo_url'] ?? json['photoUrl']; final rawPhoto = json['photo_url'] ?? json['photoUrl'];
final resolvedPhoto = _optionalPhotoUrl(rawPhoto); final resolvedPhoto = _optionalPhotoUrl(rawPhoto);
if (kDebugMode) {
debugPrint(
'[PetitsPas/dossier] EnfantDossier.fromJson id=${json['id']} '
'prénom=${json['first_name'] ?? json['prenom']} | '
'photo_url brute=$rawPhoto (${rawPhoto?.runtimeType}) → '
'résolu=${resolvedPhoto ?? ""}',
);
}
return EnfantDossier( return EnfantDossier(
id: json['id']?.toString() ?? '', id: json['id']?.toString() ?? '',
firstName: (json['first_name'] ?? json['prenom'])?.toString(), firstName: (json['first_name'] ?? json['prenom'])?.toString(),

View File

@ -16,6 +16,9 @@ class AppUser {
final String? relaisId; final String? relaisId;
final String? relaisNom; final String? relaisNom;
final String? numeroDossier; final String? numeroDossier;
final String? dateNaissance;
final String? lieuNaissanceVille;
final String? lieuNaissancePays;
AppUser({ AppUser({
required this.id, required this.id,
@ -35,6 +38,9 @@ class AppUser {
this.relaisId, this.relaisId,
this.relaisNom, this.relaisNom,
this.numeroDossier, this.numeroDossier,
this.dateNaissance,
this.lieuNaissanceVille,
this.lieuNaissancePays,
}); });
static String _str(dynamic v) { static String _str(dynamic v) {
@ -78,9 +84,24 @@ class AppUser {
?.toString(), ?.toString(),
relaisNom: relaisMap['nom']?.toString(), relaisNom: relaisMap['nom']?.toString(),
numeroDossier: json['numero_dossier'] is String ? json['numero_dossier'] as String : null, numeroDossier: json['numero_dossier'] is String ? json['numero_dossier'] as String : null,
dateNaissance: _optionalDateString(json['date_naissance']),
lieuNaissanceVille: json['lieu_naissance_ville'] is String
? json['lieu_naissance_ville'] as String
: null,
lieuNaissancePays: json['lieu_naissance_pays'] is String
? json['lieu_naissance_pays'] as String
: null,
); );
} }
static String? _optionalDateString(dynamic v) {
if (v == null) return null;
if (v is String) return v.isEmpty ? null : v;
if (v is DateTime) return v.toIso8601String().split('T').first;
final s = v.toString();
return s.isEmpty ? null : s;
}
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
'id': id, 'id': id,
@ -100,6 +121,9 @@ class AppUser {
'relais_id': relaisId, 'relais_id': relaisId,
'relais_nom': relaisNom, 'relais_nom': relaisNom,
'numero_dossier': numeroDossier, 'numero_dossier': numeroDossier,
'date_naissance': dateNaissance,
'lieu_naissance_ville': lieuNaissanceVille,
'lieu_naissance_pays': lieuNaissancePays,
}; };
} }

View File

@ -28,6 +28,7 @@ class AmRegisterStep1Screen extends StatelessWidget {
title: 'Vos informations personnelles', title: 'Vos informations personnelles',
cardColor: CardColorHorizontal.blue, cardColor: CardColorHorizontal.blue,
initialData: initialData, initialData: initialData,
minPersonNameLength: 2,
previousRoute: '/register-choice', previousRoute: '/register-choice',
onSubmit: (data, {hasSecondPerson, sameAddress}) { onSubmit: (data, {hasSecondPerson, sameAddress}) {
registrationData.updateIdentityInfo( registrationData.updateIdentityInfo(

View File

@ -6,34 +6,26 @@ import '../../models/am_registration_data.dart';
import '../../models/card_assets.dart'; import '../../models/card_assets.dart';
import '../../widgets/professional_info_form_screen.dart'; import '../../widgets/professional_info_form_screen.dart';
class AmRegisterStep2Screen extends StatefulWidget { class AmRegisterStep2Screen extends StatelessWidget {
const AmRegisterStep2Screen({super.key}); const AmRegisterStep2Screen({super.key});
@override
State<AmRegisterStep2Screen> createState() => _AmRegisterStep2ScreenState();
}
class _AmRegisterStep2ScreenState extends State<AmRegisterStep2Screen> {
String? _photoPathFramework;
Future<void> _pickPhoto() async {
// TODO: brancher ImagePicker ; ne pas préremplir de chemin factice
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final registrationData = Provider.of<AmRegistrationData>(context, listen: false); final registrationData = Provider.of<AmRegistrationData>(context, listen: false);
// Préparer les données initiales final initialData = ProfessionalInfoData(
ProfessionalInfoData initialData = ProfessionalInfoData(
photoPath: registrationData.photoPath, photoPath: registrationData.photoPath,
photoBytes: registrationData.photoBytes,
photoFilename: registrationData.photoFilename,
photoConsent: registrationData.photoConsent, photoConsent: registrationData.photoConsent,
dateOfBirth: registrationData.dateOfBirth, dateOfBirth: registrationData.dateOfBirth,
birthCity: registrationData.birthCity, birthCity: registrationData.birthCity,
birthCountry: registrationData.birthCountry, birthCountry: registrationData.birthCountry,
nir: registrationData.nir, nir: registrationData.nir,
agrementNumber: registrationData.agrementNumber, agrementNumber: registrationData.agrementNumber,
agreementDate: registrationData.agreementDate,
capacity: registrationData.capacity, capacity: registrationData.capacity,
placesAvailable: registrationData.placesAvailable,
); );
return ProfessionalInfoFormScreen( return ProfessionalInfoFormScreen(
@ -42,17 +34,20 @@ class _AmRegisterStep2ScreenState extends State<AmRegisterStep2Screen> {
cardColor: CardColorHorizontal.green, cardColor: CardColorHorizontal.green,
initialData: initialData, initialData: initialData,
previousRoute: '/am-register-step1', previousRoute: '/am-register-step1',
onPickPhoto: _pickPhoto,
onSubmit: (data) { onSubmit: (data) {
registrationData.updateProfessionalInfo( registrationData.updateProfessionalInfo(
photoPath: _photoPathFramework ?? data.photoPath, photoPath: data.photoPath,
photoBytes: data.photoBytes,
photoFilename: data.photoFilename,
photoConsent: data.photoConsent, photoConsent: data.photoConsent,
dateOfBirth: data.dateOfBirth, dateOfBirth: data.dateOfBirth,
birthCity: data.birthCity, birthCity: data.birthCity,
birthCountry: data.birthCountry, birthCountry: data.birthCountry,
nir: data.nir, nir: data.nir,
agrementNumber: data.agrementNumber, agrementNumber: data.agrementNumber,
agreementDate: data.agreementDate,
capacity: data.capacity, capacity: data.capacity,
placesAvailable: data.placesAvailable,
); );
context.go('/am-register-step3'); context.go('/am-register-step3');
}, },

View File

@ -27,6 +27,19 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
Future<void> _submitAMRegistration(AmRegistrationData registrationData) async { Future<void> _submitAMRegistration(AmRegistrationData registrationData) async {
if (_isSubmitting) return; if (_isSubmitting) return;
if (!registrationData.isRegistrationComplete) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Le dossier est incomplet. Vérifiez chaque étape (photo, consentement, lieux de naissance, etc.).',
style: GoogleFonts.merienda(fontSize: 14),
),
backgroundColor: Colors.red.shade700,
),
);
return;
}
setState(() => _isSubmitting = true); setState(() => _isSubmitting = true);
try { try {
await AuthService.registerAM(registrationData); await AuthService.registerAM(registrationData);
@ -196,14 +209,17 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
title: 'Informations professionnelles', title: 'Informations professionnelles',
cardColor: CardColorHorizontal.green, cardColor: CardColorHorizontal.green,
initialData: ProfessionalInfoData( initialData: ProfessionalInfoData(
// TODO: Gérer photoPath vs photoFile correctement photoPath: data.photoPath,
photoPath: null, // Pas d'accès facile au fichier ici, on verra photoBytes: data.photoBytes,
photoFilename: data.photoFilename,
dateOfBirth: data.dateOfBirth, dateOfBirth: data.dateOfBirth,
birthCity: data.birthCity, birthCity: data.birthCity,
birthCountry: data.birthCountry, birthCountry: data.birthCountry,
nir: data.nir, nir: data.nir,
agrementNumber: data.agrementNumber, agrementNumber: data.agrementNumber,
agreementDate: data.agreementDate,
capacity: data.capacity, capacity: data.capacity,
placesAvailable: data.placesAvailable,
photoConsent: data.photoConsent, photoConsent: data.photoConsent,
), ),
onSubmit: (d) {}, onSubmit: (d) {},

View File

@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:p_tits_pas/config/env.dart'; import 'package:p_tits_pas/config/env.dart';
class ApiConfig { class ApiConfig {
@ -21,26 +20,14 @@ class ApiConfig {
/// On préfixe avec [baseUrl] (/api/v1), pas seulement lhôte : Traefik nexpose souvent que `/api`. /// On préfixe avec [baseUrl] (/api/v1), pas seulement lhôte : Traefik nexpose souvent que `/api`.
static String absoluteMediaUrl(String? pathOrUrl) { static String absoluteMediaUrl(String? pathOrUrl) {
if (pathOrUrl == null || pathOrUrl.trim().isEmpty) { if (pathOrUrl == null || pathOrUrl.trim().isEmpty) {
if (kDebugMode) {
debugPrint('[PetitsPas/media] absoluteMediaUrl: entrée vide (null ou "")');
}
return ''; return '';
} }
final u = pathOrUrl.trim(); final u = pathOrUrl.trim();
if (u.startsWith('http://') || u.startsWith('https://')) { if (u.startsWith('http://') || u.startsWith('https://')) {
if (kDebugMode) {
debugPrint('[PetitsPas/media] absoluteMediaUrl: déjà absolu → $u');
}
return u; return u;
} }
final base = baseUrl.replaceAll(RegExp(r'/+$'), ''); final base = baseUrl.replaceAll(RegExp(r'/+$'), '');
final out = u.startsWith('/') ? '$base$u' : '$base/$u'; return u.startsWith('/') ? '$base$u' : '$base/$u';
if (kDebugMode) {
debugPrint(
'[PetitsPas/media] absoluteMediaUrl: base=$base | chemin brut="$u" → "$out"',
);
}
return out;
} }
// Auth endpoints // Auth endpoints

View File

@ -1,5 +1,8 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/user.dart'; import '../models/user.dart';
@ -13,6 +16,24 @@ import '../utils/nir_utils.dart';
class AuthService { class AuthService {
static const String _currentUserKey = 'current_user'; static const String _currentUserKey = 'current_user';
static String? _basenameFromPath(String path) {
final s = path.replaceAll(r'\', '/');
final i = s.lastIndexOf('/');
final name = i >= 0 ? s.substring(i + 1) : s;
return name.isEmpty ? null : name;
}
static String _imageMimeForBytes(Uint8List bytes) {
if (bytes.length >= 8 &&
bytes[0] == 0x89 &&
bytes[1] == 0x50 &&
bytes[2] == 0x4E &&
bytes[3] == 0x47) {
return 'image/png';
}
return 'image/jpeg';
}
/// Connexion de l'utilisateur /// Connexion de l'utilisateur
/// Retourne l'utilisateur connecté avec le flag changement_mdp_obligatoire /// Retourne l'utilisateur connecté avec le flag changement_mdp_obligatoire
static Future<AppUser> login(String email, String password) async { static Future<AppUser> login(String email, String password) async {
@ -141,18 +162,49 @@ class AuthService {
/// Inscription AM complète (POST /auth/register/am). /// Inscription AM complète (POST /auth/register/am).
/// En cas de succès (201), aucune donnée utilisateur retournée ; rediriger vers login. /// En cas de succès (201), aucune donnée utilisateur retournée ; rediriger vers login.
static Future<void> registerAM(AmRegistrationData data) async { static Future<void> registerAM(AmRegistrationData data) async {
if (data.agreementDate == null) {
throw Exception('La date d\'obtention de l\'agrément est requise.');
}
if (data.placesAvailable == null) {
throw Exception('Le nombre de places disponibles est requis.');
}
final lieuVille = data.birthCity.trim();
final lieuPays = data.birthCountry.trim();
if (lieuVille.length < 2 || lieuPays.length < 2) {
throw Exception(
'La ville et le pays de naissance sont requis (au moins 2 caractères chacun).',
);
}
String? photoBase64; String? photoBase64;
if (data.photoPath != null && data.photoPath!.isNotEmpty && !data.photoPath!.startsWith('assets/')) { String? photoFilename;
try {
final file = File(data.photoPath!); if (data.photoBytes != null && data.photoBytes!.isNotEmpty) {
if (await file.exists()) { final mime = _imageMimeForBytes(data.photoBytes!);
final bytes = await file.readAsBytes(); photoBase64 = 'data:$mime;base64,${base64Encode(data.photoBytes!)}';
photoBase64 = 'data:image/jpeg;base64,${base64Encode(bytes)}'; final fn = (data.photoFilename ?? '').trim();
} photoFilename = fn.isNotEmpty ? fn : 'photo_am.jpg';
} catch (_) {} } else if (data.photoPath != null &&
data.photoPath!.isNotEmpty &&
!data.photoPath!.startsWith('assets/')) {
if (!kIsWeb) {
try {
final file = File(data.photoPath!);
if (await file.exists()) {
final bytes = await file.readAsBytes();
final mime = _imageMimeForBytes(bytes);
photoBase64 = 'data:$mime;base64,${base64Encode(bytes)}';
photoFilename =
_basenameFromPath(data.photoPath!) ?? 'photo_am.jpg';
}
} catch (_) {}
}
} }
final body = { if (photoBase64 == null || photoBase64.isEmpty) {
throw Exception('Une photo de profil est requise pour finaliser linscription.');
}
final body = <String, dynamic>{
'email': data.email, 'email': data.email,
'prenom': data.firstName, 'prenom': data.firstName,
'nom': data.lastName, 'nom': data.lastName,
@ -160,26 +212,48 @@ class AuthService {
'adresse': data.streetAddress.isNotEmpty ? data.streetAddress : null, 'adresse': data.streetAddress.isNotEmpty ? data.streetAddress : null,
'code_postal': data.postalCode.isNotEmpty ? data.postalCode : null, 'code_postal': data.postalCode.isNotEmpty ? data.postalCode : null,
'ville': data.city.isNotEmpty ? data.city : null, 'ville': data.city.isNotEmpty ? data.city : null,
if (photoBase64 != null) 'photo_base64': photoBase64, 'photo_base64': photoBase64,
'photo_filename': photoFilename ?? 'photo_am.jpg',
'consentement_photo': data.photoConsent, 'consentement_photo': data.photoConsent,
'date_naissance': data.dateOfBirth != null 'date_naissance': data.dateOfBirth != null
? '${data.dateOfBirth!.year}-${data.dateOfBirth!.month.toString().padLeft(2, '0')}-${data.dateOfBirth!.day.toString().padLeft(2, '0')}' ? '${data.dateOfBirth!.year}-${data.dateOfBirth!.month.toString().padLeft(2, '0')}-${data.dateOfBirth!.day.toString().padLeft(2, '0')}'
: null, : null,
'lieu_naissance_ville': data.birthCity.isNotEmpty ? data.birthCity : null, 'lieu_naissance_ville': lieuVille,
'lieu_naissance_pays': data.birthCountry.isNotEmpty ? data.birthCountry : null, 'lieu_naissance_pays': lieuPays,
'nir': normalizeNir(data.nir), 'nir': normalizeNir(data.nir),
'numero_agrement': data.agrementNumber, 'numero_agrement': data.agrementNumber,
'date_agrement':
'${data.agreementDate!.year}-${data.agreementDate!.month.toString().padLeft(2, '0')}-${data.agreementDate!.day.toString().padLeft(2, '0')}',
'capacite_accueil': data.capacity ?? 1, 'capacite_accueil': data.capacity ?? 1,
'places_disponibles': data.placesAvailable!,
'biographie': data.presentationText.isNotEmpty ? data.presentationText : null, 'biographie': data.presentationText.isNotEmpty ? data.presentationText : null,
'acceptation_cgu': data.cguAccepted, 'acceptation_cgu': data.cguAccepted,
'acceptation_privacy': data.cguAccepted, 'acceptation_privacy': data.cguAccepted,
}; };
final response = await http.post( final String encodedBody;
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.registerAM}'), try {
headers: ApiConfig.headers, encodedBody = jsonEncode(body);
body: jsonEncode(body), } catch (_) {
); throw Exception(
'Impossible de préparer lenvoi (données trop volumineuses ou invalides). '
'Réessayez avec une photo plus légère.',
);
}
late final http.Response response;
try {
response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.registerAM}'),
headers: ApiConfig.headers,
body: encodedBody,
);
} on http.ClientException {
throw Exception(
'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau, '
'un pare-feu ou un bloqueur, puis réessayez.',
);
}
if (response.statusCode == 200 || response.statusCode == 201) { if (response.statusCode == 200 || response.statusCode == 201) {
return; return;

View File

@ -1,5 +1,4 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/models/parent_model.dart'; import 'package:p_tits_pas/models/parent_model.dart';
@ -94,9 +93,6 @@ class UserService {
static Future<DossierUnifie> getDossier(String numeroDossier) async { static Future<DossierUnifie> getDossier(String numeroDossier) async {
final encoded = Uri.encodeComponent(numeroDossier); final encoded = Uri.encodeComponent(numeroDossier);
final uri = Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'); final uri = Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded');
if (kDebugMode) {
debugPrint('[PetitsPas/dossier] GET $uri');
}
final response = await http.get( final response = await http.get(
uri, uri,
headers: await _headers(), headers: await _headers(),
@ -119,19 +115,6 @@ class UserService {
throw FormatException('Réponse invalide'); throw FormatException('Réponse invalide');
} }
final dossier = DossierUnifie.fromJson(Map<String, dynamic>.from(decoded)); final dossier = DossierUnifie.fromJson(Map<String, dynamic>.from(decoded));
if (kDebugMode) {
debugPrint(
'[PetitsPas/dossier] réponse OK type=${dossier.type} | '
'ApiConfig.baseUrl=${ApiConfig.baseUrl} | apiOrigin=${ApiConfig.apiOrigin}',
);
if (dossier.isFamily) {
final f = dossier.asFamily;
debugPrint(
'[PetitsPas/dossier] famille ${f.numeroDossier} | '
'${f.enfants.length} enfant(s)',
);
}
}
return dossier; return dossier;
} catch (e) { } catch (e) {
if (e is FormatException) rethrow; if (e is FormatException) rethrow;

View File

@ -0,0 +1,11 @@
import 'package:intl/intl.dart';
/// Affiche une date ISO / parseable en `dd/MM/yyyy`, avec repli sur la chaîne ou [ifEmpty].
String formatIsoDateFr(String? s, {String ifEmpty = ''}) {
if (s == null || s.trim().isEmpty) return ifEmpty;
try {
return DateFormat('dd/MM/yyyy').format(DateTime.parse(s.trim()));
} catch (_) {
return s.trim();
}
}

View File

@ -57,20 +57,22 @@ String formatNir(String raw) {
return '${r.substring(0, 1)} ${r.substring(1, 3)} ${r.substring(3, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)} - ${r.substring(13, 15)}'; return '${r.substring(0, 1)} ${r.substring(1, 3)} ${r.substring(3, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)} - ${r.substring(13, 15)}';
} }
/// Vérifie le format : 15 caractères, structure 1+2+2+2+3+3+2, département 2A/2B autorisé. /// Aligné sur le backend (NIR côté API / nir.util.ts) : sexe 13, département 2A ou 2B pour la Corse.
bool _isFormatValid(String raw) { bool _isFormatValid(String raw) {
if (raw.length != 15) return false; final r = raw.toUpperCase();
final dept = raw.substring(5, 7); if (r.length != 15) return false;
final restDigits = raw.substring(0, 5) + (dept == '2A' ? '19' : dept == '2B' ? '18' : dept) + raw.substring(7, 15); return RegExp(r'^[1-3]\d{4}(?:2A|2B|\d{2})\d{6}\d{2}$').hasMatch(r);
if (!RegExp(r'^[12]\d{12}\d{2}$').hasMatch(restDigits)) return false;
return RegExp(r'^[12]\d{4}(?:\d{2}|2A|2B)\d{8}$').hasMatch(raw);
} }
/// Calcule la clé de contrôle (97 - (NIR13 mod 97)). Pour 2A19, 2B18. /// CINSEE : 97 - (NIR13 mod 97). Corse : 2A19, 2B20 (aligné backend).
int _controlKey(String raw13) { int _controlKey(String raw13) {
String n = raw13; String n;
if (raw13.length >= 7 && (raw13.substring(5, 7) == '2A' || raw13.substring(5, 7) == '2B')) { if (raw13.length >= 7 && raw13.substring(5, 7) == '2A') {
n = raw13.substring(0, 5) + (raw13.substring(5, 7) == '2A' ? '19' : '18') + raw13.substring(7); n = '${raw13.substring(0, 5)}19${raw13.substring(7)}';
} else if (raw13.length >= 7 && raw13.substring(5, 7) == '2B') {
n = '${raw13.substring(0, 5)}20${raw13.substring(7)}';
} else {
n = '${raw13.substring(0, 5)}${raw13.substring(5, 7)}${raw13.substring(7)}';
} }
final big = int.tryParse(n); final big = int.tryParse(n);
if (big == null) return -1; if (big == null) return -1;

View File

@ -5,7 +5,8 @@ import 'admin_detail_modal.dart';
/// [rowLayout] : même disposition que la création de compte, ex. [2, 2, 1, 2] = ligne de 2, ligne de 2, plein largeur, ligne de 2. /// [rowLayout] : même disposition que la création de compte, ex. [2, 2, 1, 2] = ligne de 2, ligne de 2, plein largeur, ligne de 2.
/// [rowFlex] : flex par index de ligne (optionnel). Ex. {3: [2, 5]} = 4e ligne : code postal étroit (2), ville large (5). /// [rowFlex] : flex par index de ligne (optionnel). Ex. {3: [2, 5]} = 4e ligne : code postal étroit (2), ville large (5).
class ValidationDetailSection extends StatelessWidget { class ValidationDetailSection extends StatelessWidget {
final String title; /// Si null ou vide, pas de bandeau titre (gain de place vertical, ex. wizard AM).
final String? title;
final List<AdminDetailField> fields; final List<AdminDetailField> fields;
/// Nombre de champs par ligne (1 = plein largeur, 2 = deux côte à côte). Ex. [2, 2, 1, 2] pour identité. /// Nombre de champs par ligne (1 = plein largeur, 2 = deux côte à côte). Ex. [2, 2, 1, 2] pour identité.
@ -16,7 +17,7 @@ class ValidationDetailSection extends StatelessWidget {
const ValidationDetailSection({ const ValidationDetailSection({
super.key, super.key,
required this.title, this.title,
required this.fields, required this.fields,
this.rowLayout, this.rowLayout,
this.rowFlex, this.rowFlex,
@ -60,19 +61,22 @@ class ValidationDetailSection extends StatelessWidget {
)); ));
} }
} }
final showTitle = title != null && title!.trim().isNotEmpty;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( if (showTitle) ...[
title, Text(
style: const TextStyle( title!.trim(),
fontSize: 16, style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 16,
color: Colors.black87, fontWeight: FontWeight.w600,
color: Colors.black87,
),
), ),
), const SizedBox(height: 12),
const SizedBox(height: 12), ],
...rows, ...rows,
], ],
); );

View File

@ -155,70 +155,6 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
); );
} }
/// Ligne commune : icône | titre (+ sous-titre) | bouton Ouvrir.
/// [titleWidget] remplace [title] si les deux sont fournis : priorité à [titleWidget].
Widget _buildPendingRow({
required IconData icon,
String? title,
Widget? titleWidget,
String? subtitle,
TextStyle? subtitleStyle,
required VoidCallback onOpen,
}) {
assert(title != null || titleWidget != null);
final titleChild = titleWidget ??
Text(
title!,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14,
),
);
final subStyle = subtitleStyle ??
TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
);
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: Colors.grey.shade300),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: Colors.grey.shade600, size: 28),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
titleChild,
if (subtitle != null && subtitle.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
subtitle,
style: subStyle,
),
],
],
),
),
ElevatedButton.icon(
onPressed: onOpen,
icon: const Icon(Icons.open_in_new, size: 18),
label: const Text('Ouvrir'),
),
],
),
),
);
}
/// Sous-titre AM : `email - date tél. CP ville` (plan affichage lignes À valider). /// Sous-titre AM : `email - date tél. CP ville` (plan affichage lignes À valider).
String _amSubtitleLine(AppUser user) { String _amSubtitleLine(AppUser user) {
@ -244,9 +180,9 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
final numDossier = user.numeroDossier ?? ''; final numDossier = user.numeroDossier ?? '';
final nameBold = final nameBold =
user.fullName.isNotEmpty ? user.fullName : (user.email.isNotEmpty ? user.email : ''); user.fullName.isNotEmpty ? user.fullName : (user.email.isNotEmpty ? user.email : '');
return _buildPendingRow( return _PendingValidationRow(
icon: Icons.person_outline, icon: Icons.person_outline,
titleWidget: Text.rich( title: Text.rich(
TextSpan( TextSpan(
style: const TextStyle(fontSize: 14, color: Colors.black87), style: const TextStyle(fontSize: 14, color: Colors.black87),
children: [ children: [
@ -320,9 +256,9 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
Widget _buildFamilyCard(PendingFamily family) { Widget _buildFamilyCard(PendingFamily family) {
final numDossier = family.numeroDossier ?? ''; final numDossier = family.numeroDossier ?? '';
final nameBold = family.libelle.isNotEmpty ? family.libelle : 'Famille'; final nameBold = family.libelle.isNotEmpty ? family.libelle : 'Famille';
return _buildPendingRow( return _PendingValidationRow(
icon: Icons.family_restroom_outlined, icon: Icons.family_restroom_outlined,
titleWidget: Text.rich( title: Text.rich(
TextSpan( TextSpan(
style: const TextStyle(fontSize: 14, color: Colors.black87), style: const TextStyle(fontSize: 14, color: Colors.black87),
children: [ children: [
@ -352,3 +288,111 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
} }
} }
/// Ligne « À valider » : survol comme [AdminUserCard], icône « Ouvrir » visible au hover uniquement.
class _PendingValidationRow extends StatefulWidget {
final IconData icon;
final Widget title;
final String? subtitle;
final TextStyle? subtitleStyle;
final VoidCallback onOpen;
const _PendingValidationRow({
required this.icon,
required this.title,
this.subtitle,
this.subtitleStyle,
required this.onOpen,
});
@override
State<_PendingValidationRow> createState() => _PendingValidationRowState();
}
class _PendingValidationRowState extends State<_PendingValidationRow> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
final subStyle = widget.subtitleStyle ??
TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
);
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: () {},
borderRadius: BorderRadius.circular(10),
hoverColor: const Color(0x149CC5C0),
child: Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: Colors.grey.shade300),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(widget.icon, color: Colors.grey.shade600, size: 28),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
widget.title,
if (widget.subtitle != null &&
widget.subtitle!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
widget.subtitle!,
style: subStyle,
),
],
],
),
),
SizedBox(
width: 52,
child: Center(
child: AnimatedOpacity(
duration: const Duration(milliseconds: 120),
opacity: _isHovered ? 1 : 0,
child: IgnorePointer(
ignoring: !_isHovered,
child: IconButtonTheme(
data: IconButtonThemeData(
style: IconButton.styleFrom(
padding: const EdgeInsets.all(0),
minimumSize: const Size(48, 48),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
child: IconButton(
onPressed: widget.onOpen,
icon: const Icon(Icons.open_in_new),
iconSize: 34,
tooltip: 'Ouvrir',
),
),
),
),
),
),
],
),
),
),
),
),
);
}
}

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/utils/nir_utils.dart'; import 'package:p_tits_pas/utils/nir_utils.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
@ -74,29 +75,40 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
AdminDetailField(label: 'Ville', value: _v(u.ville)), AdminDetailField(label: 'Ville', value: _v(u.ville)),
]; ];
/// Informations professionnelles : N° Agrément|Date agrément, NIR, Capacité|Places, Ville. /// Panneau photo + grille droite : NIR|naissance, ville|pays, agrément|date, capa|places.
List<AdminDetailField> _proFields(DossierAM d) => [ List<AdminDetailField> _photoProFields(DossierAM d) {
AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)), final u = d.user;
AdminDetailField( return [
label: 'Date dagrément', AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)),
value: d.dateAgrement != null && d.dateAgrement!.trim().isNotEmpty AdminDetailField(
? d.dateAgrement!.trim() label: 'Date de naissance',
: '', value: formatIsoDateFr(u.dateNaissance),
), ),
AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)), AdminDetailField(
AdminDetailField( label: 'Ville de naissance',
label: 'Capacité max (enfants)', value: _v(u.lieuNaissanceVille),
value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '', ),
), AdminDetailField(
AdminDetailField( label: 'Pays de naissance',
label: 'Places disponibles', value: _v(u.lieuNaissancePays),
value: d.placesDisponibles != null ),
? d.placesDisponibles.toString() AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)),
: '', AdminDetailField(
), label: 'Date dagrément',
AdminDetailField( value: formatIsoDateFr(d.dateAgrement),
label: 'Ville de résidence', value: _v(d.villeResidence)), ),
]; AdminDetailField(
label: 'Capacité max (enfants)',
value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '',
),
AdminDetailField(
label: 'Places disponibles',
value: d.placesDisponibles != null
? d.placesDisponibles.toString()
: '',
),
];
}
static const List<int> _personalRowLayout = [2, 2, 1, 2]; static const List<int> _personalRowLayout = [2, 2, 1, 2];
static const Map<int, List<int>> _personalRowFlex = { static const Map<int, List<int>> _personalRowFlex = {
@ -119,15 +131,6 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Text(
'Photo de profil',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 12),
Expanded( Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.only(right: 8), padding: const EdgeInsets.only(right: 8),
@ -264,7 +267,7 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth), constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: ValidationDetailSection( child: ValidationDetailSection(
title: 'Informations personnelles', title: 'Identité et coordonnées',
fields: _personalFields(u), fields: _personalFields(u),
rowLayout: _personalRowLayout, rowLayout: _personalRowLayout,
rowFlex: _personalRowFlex, rowFlex: _personalRowFlex,
@ -280,8 +283,7 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
builder: (context, c) { builder: (context, c) {
final maxRowW = c.maxWidth; final maxRowW = c.maxWidth;
final maxRowH = c.maxHeight; final maxRowH = c.maxHeight;
// Titre « Photo de profil » + espacement (~52 px) : hauteur dispo pour le cadre photo. const photoHeaderH = 0.0;
const photoHeaderH = 52.0;
final bodyH = (maxRowH - photoHeaderH).clamp(0.0, double.infinity); final bodyH = (maxRowH - photoHeaderH).clamp(0.0, double.infinity);
final idealPhotoW = final idealPhotoW =
bodyH * _idPhotoAspectRatio + 16; // marge approx. cadre clair bodyH * _idPhotoAspectRatio + 16; // marge approx. cadre clair
@ -307,14 +309,9 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
constraints: BoxConstraints( constraints: BoxConstraints(
minWidth: constraints.maxWidth), minWidth: constraints.maxWidth),
child: ValidationDetailSection( child: ValidationDetailSection(
title: 'Informations professionnelles', title: 'Dossier professionnel',
fields: _proFields(d), fields: _photoProFields(d),
rowLayout: const [ rowLayout: const [2, 2, 2, 2],
2,
1,
2,
1
], // N° Agrément|Date agrément, NIR, Capacité|Places, Ville
), ),
), ),
); );
@ -333,12 +330,13 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( const Text(
'Présentation', 'Présentation',
style: const TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.black87), color: Colors.black87,
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Expanded( Expanded(

View File

@ -1,9 +1,8 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:intl/intl.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/services/api/api_config.dart';
@ -106,15 +105,8 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
(s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini'; (s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini';
/// Date de naissance en jour/mois/année (dd/MM/yyyy). /// Date de naissance en jour/mois/année (dd/MM/yyyy).
static String _formatBirthDate(String? s) { static String _formatBirthDate(String? s) =>
if (s == null || s.trim().isEmpty) return 'Non défini'; formatIsoDateFr(s, ifEmpty: 'Non défini');
try {
final d = DateTime.parse(s.trim());
return DateFormat('dd/MM/yyyy').format(d);
} catch (_) {
return s.trim();
}
}
/// Même ordre et disposition que le formulaire de création (Nom/Prénom, Tél/Email, Adresse, CP/Ville). /// Même ordre et disposition que le formulaire de création (Nom/Prénom, Tél/Email, Adresse, CP/Ville).
List<AdminDetailField> _parentFields(ParentDossier p) => [ List<AdminDetailField> _parentFields(ParentDossier p) => [
@ -344,12 +336,6 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
/// Carte enfant : prénom pleine largeur, puis ligne photo 1/3 + colonne 2/3 (champs + statut hors TF si besoin). /// Carte enfant : prénom pleine largeur, puis ligne photo 1/3 + colonne 2/3 (champs + statut hors TF si besoin).
Widget _buildEnfantCard(EnfantDossier e) { Widget _buildEnfantCard(EnfantDossier e) {
final photoUrl = _fullPhotoUrl(e.photoUrl); final photoUrl = _fullPhotoUrl(e.photoUrl);
if (kDebugMode) {
debugPrint(
'[PetitsPas/validation-famille] carte enfant id=${e.id} prénom=${e.firstName} | '
'photoUrl modèle=${e.photoUrl ?? ""} | url affichée=${photoUrl.isEmpty ? "" : photoUrl}',
);
}
final columnStatusLabel = _enfantColumnStatusLabel(e); final columnStatusLabel = _enfantColumnStatusLabel(e);
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.circular(_enfantCardRadius), borderRadius: BorderRadius.circular(_enfantCardRadius),
@ -569,7 +555,7 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Text( const Text(
'Présentation / Motivation', 'Présentation',
style: TextStyle( style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87), fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87),
), ),

View File

@ -7,7 +7,7 @@ import '../models/user_registration_data.dart';
import '../models/card_assets.dart'; import '../models/card_assets.dart';
import 'custom_app_text_field.dart'; import 'custom_app_text_field.dart';
import 'form_field_wrapper.dart'; import 'form_field_wrapper.dart';
import 'hover_relief_widget.dart'; import 'registration_photo_slot.dart';
import '../config/display_config.dart'; import '../config/display_config.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart'; import 'package:p_tits_pas/utils/name_format_utils.dart';
@ -16,13 +16,12 @@ const String _photoConsentTooltip =
'Suivi du dossier et organisation de laccueil (affichage interne, outils pédagogiques).\n' 'Suivi du dossier et organisation de laccueil (affichage interne, outils pédagogiques).\n'
'Dans le respect de la politique de confidentialité.'; 'Dans le respect de la politique de confidentialité.';
/// Cadre photo (centre transparent), même dossier que `photo.png`.
const String _photoSketchFrameAsset = 'assets/images/photo_frame.png';
bool _hasChildPhoto(ChildData c) { bool _hasChildPhoto(ChildData c) {
final b = c.imageBytes; return registrationPhotoSlotHasImage(
if (b != null && b.isNotEmpty) return true; imageBytes: c.imageBytes,
return c.imageFile != null; imageFile: c.imageFile,
imagePathOrAsset: null,
);
} }
Widget _buildChildPhotoImage(ChildData c, {required BoxFit fit}) { Widget _buildChildPhotoImage(ChildData c, {required BoxFit fit}) {
@ -194,11 +193,6 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
final Color baseCardColorForShadow = widget.childData.cardColor == CardColorVertical.lavender final Color baseCardColorForShadow = widget.childData.cardColor == CardColorVertical.lavender
? Colors.purple.shade200 ? Colors.purple.shade200
: (widget.childData.cardColor == CardColorVertical.pink ? Colors.pink.shade200 : Colors.grey.shade200); : (widget.childData.cardColor == CardColorVertical.pink ? Colors.pink.shade200 : Colors.grey.shade200);
final Color initialPhotoShadow =
Color.alphaBlend(Colors.black.withOpacity(0.22), baseCardColorForShadow);
final Color hoverPhotoShadow =
Color.alphaBlend(Colors.black.withOpacity(0.32), baseCardColorForShadow);
final double photoSide = 200.0 * (config.isMobile ? 0.8 : 1.0); final double photoSide = 200.0 * (config.isMobile ? 0.8 : 1.0);
final double editableCardWidth = config.isMobile final double editableCardWidth = config.isMobile
? double.infinity ? double.infinity
@ -222,14 +216,15 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
Column( Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_buildEditablePhotoArea( RegistrationPhotoSlot(
context: context, side: photoSide,
config: config,
scaleFactor: scaleFactor, scaleFactor: scaleFactor,
photoSide: photoSide, imageBytes: widget.childData.imageBytes,
childData: widget.childData, imageFile: widget.childData.imageFile,
initialPhotoShadow: initialPhotoShadow, onTapPick: !config.isReadonly ? widget.onPickImage : null,
hoverPhotoShadow: hoverPhotoShadow, onClear: !config.isReadonly ? widget.onClearImage : null,
baseShadowColor: baseCardColorForShadow,
isMobile: config.isMobile,
), ),
SizedBox(height: 8.0 * scaleFactor), SizedBox(height: 8.0 * scaleFactor),
_buildPhotoConsentRow( _buildPhotoConsentRow(
@ -321,96 +316,6 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
); );
} }
Widget _buildEditablePhotoArea({
required BuildContext context,
required DisplayConfig config,
required double scaleFactor,
required double photoSide,
required ChildData childData,
required Color initialPhotoShadow,
required Color hoverPhotoShadow,
}) {
final canInteract = !config.isReadonly;
final hasPhoto = _hasChildPhoto(childData);
final outerRadius = BorderRadius.circular(10 * scaleFactor);
// Arrondi photo (sous le cadre) : proportionnel au carré, plus fort quavant (~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 : larrondi de la photo est géré par ClipRRect (plus marqué).
clipBehavior: hasPhoto ? Clip.none : Clip.antiAlias,
child: SizedBox(
width: photoSide,
height: photoSide,
child: !hasPhoto
? ClipRRect(
borderRadius: outerRadius,
child: Center(
child: Image.asset(
'assets/images/photo.png',
fit: BoxFit.contain,
width: photoSide,
height: photoSide,
),
),
)
: Stack(
fit: StackFit.expand,
children: [
// Pas de fond opaque : les coins hors ClipRRect laissent voir la carte (aquarelle).
Positioned.fill(
child: ClipRRect(
borderRadius: photoClipRadius,
child: _buildChildPhotoImage(childData, fit: BoxFit.cover),
),
),
Positioned.fill(
child: Image.asset(
_photoSketchFrameAsset,
fit: BoxFit.fill,
errorBuilder: (context, error, stackTrace) =>
const SizedBox.shrink(),
),
),
],
),
),
),
if (hasPhoto && canInteract)
Positioned(
top: 8 * scaleFactor,
right: 8 * scaleFactor,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.onClearImage,
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(4),
child: Image.asset(
'assets/images/cross.png',
width: config.isMobile ? 26 : 30,
height: config.isMobile ? 26 : 30,
fit: BoxFit.contain,
),
),
),
),
),
],
);
}
/// Layout SPÉCIAL Readonly Desktop (Ancien Design Horizontal) /// Layout SPÉCIAL Readonly Desktop (Ancien Design Horizontal)
Widget _buildReadonlyDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) { Widget _buildReadonlyDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) {
// Convertir la couleur verticale (pour mobile) en couleur horizontale (pour desktop/récap) // Convertir la couleur verticale (pour mobile) en couleur horizontale (pour desktop/récap)

View File

@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/services/api/tokenService.dart'; import 'package:p_tits_pas/services/api/tokenService.dart';
@ -43,12 +42,6 @@ class _AuthNetworkImageState extends State<AuthNetworkImage> {
final isPublic = AuthNetworkImage.isPublicUploadUrl(widget.url); final isPublic = AuthNetworkImage.isPublicUploadUrl(widget.url);
_headersFuture = _headersFuture =
isPublic ? Future<Map<String, String>?>.value(null) : _loadHeaders(); isPublic ? Future<Map<String, String>?>.value(null) : _loadHeaders();
if (kDebugMode) {
debugPrint(
'[PetitsPas/image] préparation chargement url=${widget.url} | '
'uploadPublic=$isPublic | avecBearer=${!isPublic}',
);
}
} }
static Future<Map<String, String>?> _loadHeaders() async { static Future<Map<String, String>?> _loadHeaders() async {
@ -59,11 +52,6 @@ class _AuthNetworkImageState extends State<AuthNetworkImage> {
ImageErrorWidgetBuilder _wrapErrorBuilder() { ImageErrorWidgetBuilder _wrapErrorBuilder() {
return (BuildContext context, Object error, StackTrace? stackTrace) { return (BuildContext context, Object error, StackTrace? stackTrace) {
if (kDebugMode) {
debugPrint(
'[PetitsPas/image] ❌ échec chargement url=${widget.url} | erreur=$error',
);
}
final inner = widget.errorBuilder; final inner = widget.errorBuilder;
if (inner != null) { if (inner != null) {
return inner(context, error, stackTrace); return inner(context, error, stackTrace);
@ -112,9 +100,6 @@ class _AuthNetworkImageState extends State<AuthNetworkImage> {
); );
} }
final headers = snapshot.data; final headers = snapshot.data;
if (kDebugMode && headers != null) {
debugPrint('[PetitsPas/image] requête avec en-tête Authorization (Bearer présent)');
}
return Image.network( return Image.network(
widget.url, widget.url,
width: widget.width, width: widget.width,

View File

@ -27,10 +27,14 @@ class CustomAppTextField extends StatefulWidget {
final IconData? suffixIcon; final IconData? suffixIcon;
final double labelFontSize; final double labelFontSize;
final double inputFontSize; final double inputFontSize;
/// Espace vertical entre le libellé et la zone de saisie.
final double labelFieldSpacing;
final bool showLabel; final bool showLabel;
final Iterable<String>? autofillHints; final Iterable<String>? autofillHints;
final TextInputAction? textInputAction; final TextInputAction? textInputAction;
final ValueChanged<String>? onFieldSubmitted; final ValueChanged<String>? onFieldSubmitted;
/// Souvent appelé par la touche « suivant » du clavier (flèche), [onFieldSubmitted] ne lest pas.
final VoidCallback? onEditingComplete;
final List<TextInputFormatter>? inputFormatters; final List<TextInputFormatter>? inputFormatters;
final bool autocorrect; final bool autocorrect;
final bool enableSuggestions; final bool enableSuggestions;
@ -56,9 +60,11 @@ class CustomAppTextField extends StatefulWidget {
this.suffixIcon, this.suffixIcon,
this.labelFontSize = 18.0, this.labelFontSize = 18.0,
this.inputFontSize = 18.0, this.inputFontSize = 18.0,
this.labelFieldSpacing = 6.0,
this.autofillHints, this.autofillHints,
this.textInputAction, this.textInputAction,
this.onFieldSubmitted, this.onFieldSubmitted,
this.onEditingComplete,
this.inputFormatters, this.inputFormatters,
this.autocorrect = true, this.autocorrect = true,
this.enableSuggestions = true, this.enableSuggestions = true,
@ -105,7 +111,7 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
const SizedBox(height: 6), SizedBox(height: widget.labelFieldSpacing),
], ],
// Pas de hauteur fixe sur le TextFormField : le message derreur du // Pas de hauteur fixe sur le TextFormField : le message derreur du
// validateur saffiche en dessous ; un SizedBox fixe le masquait. // validateur saffiche en dessous ; un SizedBox fixe le masquait.
@ -141,6 +147,7 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
autofillHints: widget.autofillHints, autofillHints: widget.autofillHints,
textInputAction: widget.textInputAction, textInputAction: widget.textInputAction,
onFieldSubmitted: widget.onFieldSubmitted, onFieldSubmitted: widget.onFieldSubmitted,
onEditingComplete: widget.onEditingComplete,
enabled: widget.enabled, enabled: widget.enabled,
readOnly: widget.readOnly, readOnly: widget.readOnly,
onTap: widget.onTap, onTap: widget.onTap,

View File

@ -8,6 +8,9 @@ import 'custom_app_text_field.dart';
/// La valeur envoyée au [controller] est formatée ; utiliser [normalizeNir](controller.text) à la soumission. /// La valeur envoyée au [controller] est formatée ; utiliser [normalizeNir](controller.text) à la soumission.
class NirTextField extends StatelessWidget { class NirTextField extends StatelessWidget {
final TextEditingController controller; final TextEditingController controller;
final FocusNode? focusNode;
/// Si non null, impose lordre Tab parmi les descendants (ex. après « Pays de naissance »).
final double? focusTraversalOrder;
final String labelText; final String labelText;
final String hintText; final String hintText;
final String? Function(String?)? validator; final String? Function(String?)? validator;
@ -18,12 +21,15 @@ class NirTextField extends StatelessWidget {
final bool enabled; final bool enabled;
final bool readOnly; final bool readOnly;
final CustomAppTextFieldStyle style; final CustomAppTextFieldStyle style;
final double labelFieldSpacing;
const NirTextField({ const NirTextField({
super.key, super.key,
required this.controller, required this.controller,
this.focusNode,
this.focusTraversalOrder,
this.labelText = 'N° Sécurité Sociale (NIR)', this.labelText = 'N° Sécurité Sociale (NIR)',
this.hintText = '15 car. (ex. 1 12 34 56 789 012 - 34 ou 2A Corse)', this.hintText = '13 chiffres + clé',
this.validator, this.validator,
this.fieldWidth = double.infinity, this.fieldWidth = double.infinity,
this.fieldHeight = 53.0, this.fieldHeight = 53.0,
@ -32,12 +38,14 @@ class NirTextField extends StatelessWidget {
this.enabled = true, this.enabled = true,
this.readOnly = false, this.readOnly = false,
this.style = CustomAppTextFieldStyle.beige, this.style = CustomAppTextFieldStyle.beige,
this.labelFieldSpacing = 6.0,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return CustomAppTextField( final field = CustomAppTextField(
controller: controller, controller: controller,
focusNode: focusNode,
labelText: labelText, labelText: labelText,
hintText: hintText, hintText: hintText,
fieldWidth: fieldWidth, fieldWidth: fieldWidth,
@ -50,6 +58,15 @@ class NirTextField extends StatelessWidget {
enabled: enabled, enabled: enabled,
readOnly: readOnly, readOnly: readOnly,
style: style, style: style,
labelFieldSpacing: labelFieldSpacing,
); );
final o = focusTraversalOrder;
if (o != null) {
return FocusTraversalOrder(
order: NumericFocusOrder(o),
child: field,
);
}
return field;
} }
} }

View File

@ -55,6 +55,8 @@ class PersonalInfoFormScreen extends StatefulWidget {
final PersonalInfoData? referenceAddressData; // Pour pré-remplir si "même adresse" final PersonalInfoData? referenceAddressData; // Pour pré-remplir si "même adresse"
final bool embedContentOnly; // Si true, affiche seulement la carte (sans scaffold/fond/titre) final bool embedContentOnly; // Si true, affiche seulement la carte (sans scaffold/fond/titre)
final VoidCallback? onEdit; // Callback pour le bouton d'édition (si affiché) final VoidCallback? onEdit; // Callback pour le bouton d'édition (si affiché)
/// Si non null (ex. 2 pour inscription AM), nom/prénom : trim + longueur minimale (aligné API).
final int? minPersonNameLength;
const PersonalInfoFormScreen({ const PersonalInfoFormScreen({
super.key, super.key,
@ -72,6 +74,7 @@ class PersonalInfoFormScreen extends StatefulWidget {
this.referenceAddressData, this.referenceAddressData,
this.embedContentOnly = false, this.embedContentOnly = false,
this.onEdit, this.onEdit,
this.minPersonNameLength,
}); });
@override @override
@ -214,6 +217,16 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
return validateEmail(value, allowEmpty: true); return validateEmail(value, allowEmpty: true);
} }
/// Prénom / nom avec longueur minimale (ex. inscription AM, aligné `MinLength` API).
String? _validatePersonNameWithMinLength(String? value) {
final min = widget.minPersonNameLength;
if (min == null) return null;
final t = (value ?? '').trim();
if (t.isEmpty) return 'Ce champ est obligatoire';
if (t.length < min) return 'Au moins $min caractères';
return null;
}
/// Téléphone / e-mail du parent 2 : pas de validation si « Ajouter Parent 2 » est désactivé. /// Téléphone / e-mail du parent 2 : pas de validation si « Ajouter Parent 2 » est désactivé.
String? _validateFrenchPhoneIfSecondParentNeeded(String? value) { String? _validateFrenchPhoneIfSecondParentNeeded(String? value) {
if (widget.showSecondPersonToggle && !_fieldsEnabled) { if (widget.showSecondPersonToggle && !_fieldsEnabled) {
@ -837,6 +850,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusLastName, focusOrder: _focusLastName,
focusNode: _lastNameFocus, focusNode: _lastNameFocus,
fieldValidator: widget.minPersonNameLength != null
? _validatePersonNameWithMinLength
: null,
), ),
), ),
const SizedBox(width: 20), const SizedBox(width: 20),
@ -849,6 +865,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusFirstName, focusOrder: _focusFirstName,
focusNode: _firstNameFocus, focusNode: _firstNameFocus,
fieldValidator: widget.minPersonNameLength != null
? _validatePersonNameWithMinLength
: null,
), ),
), ),
], ],
@ -1106,6 +1125,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusLastName, focusOrder: _focusLastName,
focusNode: _lastNameFocus, focusNode: _lastNameFocus,
fieldValidator: widget.minPersonNameLength != null
? _validatePersonNameWithMinLength
: null,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -1118,6 +1140,9 @@ class _PersonalInfoFormScreenState extends State<PersonalInfoFormScreen> {
enabled: _fieldsEnabled, enabled: _fieldsEnabled,
focusOrder: _focusFirstName, focusOrder: _focusFirstName,
focusNode: _firstNameFocus, focusNode: _firstNameFocus,
fieldValidator: widget.minPersonNameLength != null
? _validatePersonNameWithMinLength
: null,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),

File diff suppressed because it is too large Load Diff

View 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,
),
),
),
),
),
],
);
}
}

View 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 */
}
}

View 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 */
}
}

View File

@ -2,10 +2,10 @@
* POST /api/v1/auth/register/parent jeu de test officiel couple DURAND / ROUSSEAU (docs/test-data + seed). * POST /api/v1/auth/register/parent jeu de test officiel couple DURAND / ROUSSEAU (docs/test-data + seed).
* Emails : amelie.durand@ptits-pas.fr, julien.rousseau@ptits-pas.fr * Emails : amelie.durand@ptits-pas.fr, julien.rousseau@ptits-pas.fr
* *
* Les PNG dans ressources/Photos dépassent la limite JSON (~15 Mo) du serveur : réduction locale * Les PNG dans tests/ressources/photos dépassent la limite JSON (~15 Mo) du serveur : réduction locale
* via npx sharp-cli (resize 600 + JPEG q80) avant encodage base64. * via npx sharp-cli (resize 600 + JPEG q80) avant encodage base64.
* *
* Usage : node scripts/register-parent-durand-rousseau-test.mjs [BASE_URL] * Usage : node tests/scripts/register-parent-durand-rousseau-test.mjs [BASE_URL]
*/ */
import fs from 'fs'; import fs from 'fs';
@ -17,8 +17,8 @@ import { execSync } from 'child_process';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.join(__dirname, '..'); const repoRoot = path.join(__dirname, '..', '..');
const photosDir = path.join(repoRoot, 'ressources', 'Photos'); const photosDir = path.join(__dirname, '..', 'ressources', 'photos');
function shrinkToJpeg(inputPath, label) { function shrinkToJpeg(inputPath, label) {
const out = path.join(os.tmpdir(), `ptitspas-${label}-${Date.now()}.jpg`); const out = path.join(os.tmpdir(), `ptitspas-${label}-${Date.now()}.jpg`);
@ -41,8 +41,8 @@ const presentationDossier =
"Nous recherchons une assistante maternelle à Bezons pour accueillir nos enfants dans un cadre bienveillant et stable. " + "Nous recherchons une assistante maternelle à Bezons pour accueillir nos enfants dans un cadre bienveillant et stable. " +
"Merci pour l'étude de notre dossier."; "Merci pour l'étude de notre dossier.";
const chloeSrc = path.join(photosDir, 'G_Chloé.png'); const chloeSrc = path.join(photosDir, 'rousseau-chloe.png');
const hugoSrc = path.join(photosDir, 'G_Hugo.png'); const hugoSrc = path.join(photosDir, 'rousseau-hugo.png');
let chloeJpg; let chloeJpg;
let hugoJpg; let hugoJpg;

View File

@ -2,7 +2,7 @@
* POST /api/v1/auth/register/parent jeu de test officiel David LECOMTE (père isolé). * POST /api/v1/auth/register/parent jeu de test officiel David LECOMTE (père isolé).
* Email : david.lecomte@ptits-pas.fr * Email : david.lecomte@ptits-pas.fr
* *
* Usage : node scripts/register-parent-lecomte-test.mjs [BASE_URL] * Usage : node tests/scripts/register-parent-lecomte-test.mjs [BASE_URL]
*/ */
import fs from 'fs'; import fs from 'fs';
@ -12,8 +12,7 @@ import http from 'http';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.join(__dirname, '..'); const photosDir = path.join(__dirname, '..', 'ressources', 'photos');
const photosDir = path.join(repoRoot, 'ressources', 'Photos');
function toDataUri(filePath) { function toDataUri(filePath) {
const buf = fs.readFileSync(filePath); const buf = fs.readFileSync(filePath);
@ -40,7 +39,7 @@ const body = {
nom: 'LECOMTE', nom: 'LECOMTE',
date_naissance: '2023-04-15', date_naissance: '2023-04-15',
genre: 'H', genre: 'H',
photo_base64: toDataUri(path.join(photosDir, 'C_Maxime.png')), photo_base64: toDataUri(path.join(photosDir, 'lecomte-maxime.png')),
photo_filename: 'maxime_lecomte.png', photo_filename: 'maxime_lecomte.png',
grossesse_multiple: false, grossesse_multiple: false,
}, },

View File

@ -2,8 +2,8 @@
* POST /api/v1/auth/register/parent jeu de test officiel famille MARTIN (docs/test-data + seed). * POST /api/v1/auth/register/parent jeu de test officiel famille MARTIN (docs/test-data + seed).
* Emails canoniques : claire.martin@ptits-pas.fr, thomas.martin@ptits-pas.fr * Emails canoniques : claire.martin@ptits-pas.fr, thomas.martin@ptits-pas.fr
* *
* Usage : node scripts/register-parent-martin-test.mjs [BASE_URL] * Usage : node tests/scripts/register-parent-martin-test.mjs [BASE_URL]
* Ex. : node scripts/register-parent-martin-test.mjs https://app.ptits-pas.fr * Ex. : node tests/scripts/register-parent-martin-test.mjs https://app.ptits-pas.fr
*/ */
import fs from 'fs'; import fs from 'fs';
@ -13,8 +13,7 @@ import http from 'http';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.join(__dirname, '..'); const photosDir = path.join(__dirname, '..', 'ressources', 'photos');
const photosDir = path.join(repoRoot, 'ressources', 'Photos');
function toDataUri(filePath) { function toDataUri(filePath) {
const buf = fs.readFileSync(filePath); const buf = fs.readFileSync(filePath);
@ -46,7 +45,7 @@ const body = {
nom: 'MARTIN', nom: 'MARTIN',
date_naissance: '2023-02-15', date_naissance: '2023-02-15',
genre: 'F', genre: 'F',
photo_base64: toDataUri(path.join(photosDir, 'C_Emma MARTIN.png')), photo_base64: toDataUri(path.join(photosDir, 'martin-emma.png')),
photo_filename: 'emma_martin.png', photo_filename: 'emma_martin.png',
grossesse_multiple: true, grossesse_multiple: true,
}, },
@ -55,7 +54,7 @@ const body = {
nom: 'MARTIN', nom: 'MARTIN',
date_naissance: '2023-02-15', date_naissance: '2023-02-15',
genre: 'H', genre: 'H',
photo_base64: toDataUri(path.join(photosDir, 'C_Noah MARTIN_2.png')), photo_base64: toDataUri(path.join(photosDir, 'martin-noah.png')),
photo_filename: 'noah_martin.png', photo_filename: 'noah_martin.png',
grossesse_multiple: true, grossesse_multiple: true,
}, },
@ -64,7 +63,7 @@ const body = {
nom: 'MARTIN', nom: 'MARTIN',
date_naissance: '2023-02-15', date_naissance: '2023-02-15',
genre: 'F', genre: 'F',
photo_base64: toDataUri(path.join(photosDir, 'C_Léa MMARTIN.png')), photo_base64: toDataUri(path.join(photosDir, 'martin-lea.png')),
photo_filename: 'lea_martin.png', photo_filename: 'lea_martin.png',
grossesse_multiple: true, grossesse_multiple: true,
}, },