Compare commits

..
Author SHA1 Message Date
jmartinandCursor 0029c5ab86 fix(#153): peaufiner cartes dossiers et recherche.
Cartes neutres avec accent icône, photo AM si dispo, hint court
et infobulle de critères sur la barre de recherche.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 19:39:55 +02:00
jmartinandCursor 2ce9e9215f fix(#153): libellé pending-families en NOM Prénom.
Inclut le prénom dans string_agg et expose nom/prenom dans parents[].

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 18:41:25 +02:00
jmartinandCursor 3fdd913367 feat(#153): onglet permanent Dossiers (pending + liste unifiée).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 18:40:00 +02:00
jmartinandCursor 6708f73b06 feat(#153): GET /dossiers — liste unifiée familles + AM.
Endpoint staff pour l’onglet Dossiers : type, n°, libellé, emails,
statut, a_valider, filtre q. Complète GET /dossiers/:numero (#119).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 18:13:32 +02:00
jmartinandCursor f596f062a6 docs(#153): mini-spec front onglet Dossiers permanent.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 18:10:20 +02:00
jmartinandCursor 86701731e3 fix(#129): resserrer les champs enfant pour éviter le scroll.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 13:13:21 +02:00
jmartinandCursor b4abb7d6de fix(#129): peaufiner le wizard famille (co-parent, hauteurs, à naître).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 13:01:54 +02:00
jmartinandCursor cb5c1a5518 feat(#129): wizard admin création dossier famille (create/review).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 12:34:24 +02:00
jmartinandCursor 30ca99fb65 feat(#129): POST /parents/dossier — création dossier famille staff.
Factorise createParentDossier (actif + mail MDP) depuis l’inscription
publique qui reste en_attente + mail pending. Miroir #156 AM.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 16:59:09 +02:00
jmartinandCursor 8ee2ca8ea6 fix(#156): caler la modale AM sur les TF et unifier create/review.
ValidationFormMetrics (titres, TF, écarts) pilote la hauteur ; photo
étirée sur le corps ; mêmes widgets create et validation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 16:49:59 +02:00
jmartinandCursor e3552667bc fix(#156): valider téléphone au blur et NIR en live.
Formatage NIR progressif + contrôles au fil de la saisie ; téléphone
validé à la perte de focus comme e-mail / CP.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 16:28:35 +02:00
jmartinandCursor 4ae334b247 fix(#156): superviser nom, e-mail et CP comme à l’inscription.
IdentityBlock editable : capitalisation live, e-mail/CP normalisés
et validés à la perte de focus (aligné création de compte).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 19:27:20 +02:00
jmartinandCursor 471a62ddb7 feat(#156): wizard admin création dossier AM (create/review).
Généralise ValidationAmWizard en AmDossierWizard ; le + Asmat ouvre
le mode création vers POST /assistantes-maternelles/dossier.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 19:07:14 +02:00
jmartinandCursor 59afeb0a8d feat(#156): POST /assistantes-maternelles/dossier staff (actif + mail MDP).
Factorise createAmDossier depuis register/am ; staff crée un dossier
déjà actif et envoie l’e-mail de création de mot de passe.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 19:03:37 +02:00
jmartinandCursor d2172eafdb docs: snapshot empreinte code / disque (2026-07-22).
Mémoire après alerte disque plein — lignes de code et contexte VPS.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 18:27:49 +02:00
jmartinandCursor d247867fa0 feat(#158): front — attach/detach foyer (un seul appel API).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 19:53:31 +02:00
jmartinandCursor 93912d1374 feat(#158): attach/detach enfant au niveau foyer (pivot + co-parent).
POST/DELETE /parents/:id/enfants/:enfantId propagent les liens à tous
les responsables du foyer (co-parent bidirectionnel + même dossier).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 19:47:13 +02:00
jmartinandCursor 925b6d5cd4 fix(#157): texte détach + compteur enfants foyer (interim).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 19:42:36 +02:00
jmartinandCursor b0dddd6695 feat(#157): consommer le flag API sans_responsable.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 19:33:21 +02:00
jmartinandCursor ef7512dc1e feat(#157): autoriser détachement dernier parent + flag sans_responsable.
Supprime la garde totalLinks<=1 ; GET/findOne enfants exposent
sans_responsable pour les orphelins toujours listés.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 19:31:18 +02:00
jmartinandCursor 2ececa711b feat(#157): alerte enfants sans responsable + rattachement foyer.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 19:02:21 +02:00
jmartinandCursor 7a3d997ff9 fix(#132): UX création enfant — champs, genre et validation.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 18:51:00 +02:00
jmartinandCursor 6b89d7b405 fix(#132): envoyer birth_date à la création (enfant né).
Le if imbriqué n’incluait birth_date que pour le cas à naître.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 18:46:48 +02:00
jmartinandCursor f7ac628445 feat(#132): photo à la création enfant staff (multipart + bools).
Transform "true"/"false" pour class-validator ; multipart staff
avec FileInterceptor('photo') inchangé côté stockage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 18:08:52 +02:00
jmartinandCursor 26e9738ec7 feat(#132): upload photo à la création d'enfant (admin).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 18:07:47 +02:00
jmartinandCursor 0ec3410457 feat(#132): POST /enfants staff — parent_user_id + liens foyer.
Autorise GESTIONNAIRE/ADMIN/SUPER_ADMIN à créer un enfant rattaché
à un foyer existant (JSON + parent_user_id). Parent multipart inchangé.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 18:03:41 +02:00
jmartinandCursor c3acf1970d feat(#132): création enfant admin — modale + sélection famille.
Onglet Enfants « Ajouter » ouvre la fiche en mode création ; bloc Famille/dossier ; client POST /enfants avec parent_user_id (contrat back à livrer).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 18:00:41 +02:00
jmartinandCursor 6fe2b89a61 fix(#151): autoriser GET /relais pour gestionnaire + robustesse combo
- Back: RoleType.GESTIONNAIRE sur GET list/get (CRUD write admin-only)
- Front: ne plus effacer la sélection si le chargement échoue
- Fallback affichage depuis relaisNom de l'utilisateur

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:52:46 +02:00
jmartin d6a9b3fd66 Merge branch 'feature/149-clic-case-libre-rattacher-enfant' into develop
Clic case libre → rattacher enfant (#149).
2026-07-17 16:19:14 +02:00
jmartinandCursor 134b9781c8 feat(#149): clic sur case libre pour rattacher un enfant (fiche AM).
Les emplacements vides de la grille ouvrent le même flux que le lien footer, avec hover et respect de la capacité max.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:19:12 +02:00
jmartin b936d27445 Merge branch 'feature/148-rattacher-enfant-capacite-max' into develop
Fix capacité max — lien rattacher enfant (#148).
2026-07-17 16:12:54 +02:00
jmartinandCursor 18c1d1eba7 fix(#148): désactiver « Rattacher un enfant » si capacité AM pleine.
Le lien du footer est grisé avec tooltip quand enfants ≥ capacité max ; il se réactive après détachement.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:12:53 +02:00
jmartin f74b14c203 Merge branch 'feature/147-modale-selection-am-enfant' into develop
Modale de sélection d'AM (#147) depuis la fiche enfant.
2026-07-17 16:12:23 +02:00
jmartinandCursor f194b5f9e8 feat(#147): modale de sélection d'AM depuis la fiche enfant.
Réutilise AdminSelectListModal avec filtre Libre, cartes saturées en rouge, avertissement + lien fiche AM, et recalcul des places à chaque rattachement.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:11:55 +02:00
jmartin 5ab8ae3423 Merge branch 'feature/146-modale-selection-enfant-am' into develop
Modale de sélection d'enfant (#146) pour rattachement AM/parent.
2026-07-17 15:43:10 +02:00
jmartinandCursor 3277f77846 fix(#146): densifier la liste et corriger l'affichage des responsables.
Les cartes compactes montrent plus d'enfants d'un coup ; le nom ne monopolise plus la largeur au détriment des infos.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 15:39:59 +02:00
jmartinandCursor 2d80ad0d7e feat(#146): modale de sélection d'enfant pour rattachement AM/parent.
Recherche dynamique, filtre Sans garde côté AM, confirmation de transfert et détachement préalable pour laisser le point de vigilance places sur l'AM d'origine.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 15:31:47 +02:00
jmartinandCursor 09386f8aa6 feat(#145): lien co-parent cliquable dans la fiche parent.
Même UX que les responsables de la fiche enfant : ouverture de
AdminParentEditModal au clic sur le nom du co-parent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 14:56:32 +02:00
jmartinandCursor faa50f637c fix(#138): enrichir les noms des responsables sur GET enfant.
GET /enfants/:id ne joint pas parent.user ; on complète les noms via
GET parent pour l'affichage des liens (ex. ouverture depuis la fiche AM).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 13:19:09 +02:00
jmartinandCursor 267fe63aec fix(#138): différer le rattachement AM de la fiche enfant au Sauvegarder.
Attach/détach AM restent locaux comme sur la fiche AM ; la sync API
n'a lieu qu'à l'enregistrement, avec rechargement du statut au retour
de la fiche AM.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 13:12:46 +02:00
jmartinandCursor 90b185740c feat(#138): ouvrir la fiche parent depuis les responsables de la modale enfant.
Les noms du sous-titre deviennent des liens cliquables vers
AdminParentEditModal, comme l'ouverture de fiche AM.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 12:54:32 +02:00
jmartinandCursor b903dbf60b fix(#138): libellé garde accordé au genre (Gardé / Gardée).
Remplace « En garde » par Gardé/Gardée selon le genre, comme
Scolarisé/Scolarisée.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 12:51:07 +02:00
jmartinandCursor 291ea26b34 fix(#138): lire le consentement photo sans heuristique photo.
Admin et reprise utilisentent uniquement la valeur API ; plus de
pré-cochage implicite si une photo est présente.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 12:46:54 +02:00
jmartinandCursor 59919834b9 fix(#138): carte AM dédiée, titre placement et consent_photo au payload.
Améliore la zone AM/scolarisation (carte dédiée + titre), envoie
consent_photo à l'inscription parent, et parse le consentement enfant
de façon plus robuste (heuristique photo tant que le back ne persiste pas).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 12:37:45 +02:00
jmartinandCursor cf6acbae7c fix(#138): cadre vide en sans garde et passage auto en garde à l'attach AM.
En statut sans_garde, la zone placement affiche toujours le cadre vide ;
sélectionner une AM passe le statut à en garde, et passer à sans_garde
détache l'AM rattachée.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 12:37:45 +02:00
jmartinandCursor 7951c38d4d feat(#138): refonte modale enfant admin en format paysage.
Alignement sur les fiches AM/parent : photo identité, champs en grille,
zone AM rattachable (y compris à naître) ou carte scolarisation,
suppression réservée au super_admin et ouverture au clic sur la carte
depuis la fiche parent.

Refs: #138
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 12:37:45 +02:00
jmartinandCursor 77d952a6f7 fix(#144): persister le consentement photo des enfants
- DTO inscription enfant : champ consent_photo
- Inscription + reprise parent : sauvegarde bool + date
- Front payload : envoi consent_photo
- PATCH /enfants : horodatage du consentement

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 12:36:05 +02:00
jmartin b4b546044d merge fix(#143): masquer Supprimer gestionnaire sur sa propre fiche 2026-07-12 12:10:11 +02:00
jmartinandCursor 6788351070 fix(#143): masquer Supprimer quand un gestionnaire édite sa propre fiche
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 12:10:06 +02:00
jmartinandCursor 7b69f27ca5 feat(#142): ouvrir la modale au clic sur les cartes du dashboard admin.
Unifie le comportement des listes Parents, AM, Enfants, Gestionnaires, Admins et À valider avec le bouton d'action au survol.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 23:18:40 +02:00
jmartinandCursor 8ed68797aa fix(#131): PATCH fiche AM — NIR sans crash date ni effacement NOT NULL
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 23:41:45 +02:00
jmartinandCursor 9ad371a342 fix(#131): aligner PATCH fiche AM back sur le payload front
Étend UpdateAmFicheAdminDto (nir, date/lieu naissance, agreement_date)
et persiste ces champs dans updateFicheAdmin avec validation NIR.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 23:37:42 +02:00
jmartinandCursor 18718670e9 fix(#131): différer rattachement enfants AM jusqu'à Sauvegarder.
Rattachement et détachement restent locaux, recalculent places_available et sont persistés avec le PATCH fiche.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 23:36:06 +02:00
jmartin fbf22f2540 Revert "fix(#131): aligner PATCH fiche AM sur le DTO back actuel."
This reverts commit 43a2cd213b.
2026-07-07 23:13:58 +02:00
jmartinandCursor 43a2cd213b fix(#131): aligner PATCH fiche AM sur le DTO back actuel.
Retire nir, dates et lieux de naissance du body tant que UpdateAmFicheAdminDto ne les accepte pas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 23:12:38 +02:00
jmartin c865d11dc3 Revert "fix(#131): activer Sauvegarder sur statut, enfants et champs AM."
This reverts commit d03a8e6c8b.
2026-07-07 23:12:09 +02:00
jmartinandCursor d03a8e6c8b fix(#131): activer Sauvegarder sur statut, enfants et champs AM.
Compare l'état de la fiche à un snapshot initial (liste enfants, statut, places, formulaire) et corrige la gélule statut pour les valeurs hors liste.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 23:11:24 +02:00
jmartinandCursor 66c7f22280 feat(#131): grille enfants AM, statuts garde et polish champs admin.
Aligne le front sur les statuts enfant back (garde/sans_garde), refond l’onglet Enfants accueillis en grille 2×2 avec correction des places, et unifie les champs validation éditables/lecture seule.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 23:01:41 +02:00
jmartinandCursor 53721ffbb3 fix(#131): ajouter @Get() manquant sur liste parents
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 22:07:24 +02:00
jmartinandCursor 52e40d0001 feat(#131): back placement AM↔enfant + BDD garde/sans_garde
- API PATCH …/fiche, POST/DELETE …/enfants/:enfantId, GET avec amChildren
- Table enfants_assistantes_maternelles (option D, 1 garde active/enfant)
- Statuts enfant: garde/sans_garde remplacent actif (inscription → sans_garde)
- BDD.sql canonique réécrit; migration pour BDD existantes
- Seed test: hash bcrypt corrigé pour mot de passe « password »

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 14:05:39 +02:00
jmartinandCursor 4985726bc6 fix(#131): polish fiche AM — champs pro, places, vigilance et photo.
Fiche pro entièrement éditable, places déclarées en lecture seule avec alerte rouge, icône vigilance sur la liste AM, et cadrage photo aligné sur le wizard validation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 17:37:21 +02:00
jmartinandCursor 479a32b4bf feat(#131): fiche AM éditable en 3 onglets et widgets admin partagés.
Modale admin AM (identité, dossier pro, enfants), API front avec repli, note back affiliation, et extraction gélule statut / liste enfants pour la fiche parent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 17:33:41 +02:00
jmartinandCursor 2fd97ddecb fix(#131): aligner la gélule de statut sur l'en-tête parent.
Le titre, le sélecteur de statut et le bouton fermer partagent le même
padding : la gélule n'est plus collée au bord supérieur de la modale.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 16:34:12 +02:00
jmartinandCursor ce474797c4 fix(#131): co_parent en réponse parents + masquage secrets user
Contrat API fiche parent : co_parent peuplé (déjà chargé), sans password
ni tokens sur user/co_parent. Doc tmp front→back.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 23:39:30 +02:00
jmartinandCursor ebf794e1ac feat(#131): en-tête fiche parent avec nom et co-parent.
Affiche le prénom/nom du parent en titre et le co-parent en sous-titre ; note handoff back dans archive/temporaires.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 23:37:04 +02:00
jmartin d55f240f56 Merge branch 'feature/140-dashboard-admin-ch6-famille' into develop
Livraison epic #140 — dashboard admin ch.6 famille (doc 28 §6.1–§6.2).

Inclus :
- Backend : PATCH fiche parent, rattachement/détachement enfant (#115)
- Frontend : fiche parent éditable (#131), onglet Enfants (#137), fiche/liste enfant (#138), affiliation UI (#116), UserService (#130)
- IdentityBlock partagé, parsing parentChildren, avatars web /uploads

Reste ouvert sur tickets dédiés : fiche enfant UI (#138), modale rattacher (#116), fiche AM (#131), création enfant (#129).
2026-06-24 23:22:39 +02:00
jmartinandCursor 966f4a9c9c feat(#140): liste enfants unifiée — cartes, âge précis et cadre scrollable
Partage AdminEnfantUserCard entre fiche parent et onglet Enfants, enrichit ParentChildSummary (photo, dates) et compacte la modale parent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 23:06:38 +02:00
jmartinandCursor 8494341b56 feat(#140): fiches admin famille — IdentityBlock, parsing enfants et avatars web
Extrait IdentityBlock réutilisable, aligne les modales parent/enfant sur le style validation, corrige le parsing parentChildren et les URLs /uploads en Flutter web.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 17:42:00 +02:00
jmartinandCursor 1dddc67933 feat(#140): dashboard admin ch.6 — fiche parent, enfants, affiliation
Back: PATCH /parents/:id/fiche, attach/detach enfant, GET /enfants enrichi.
Front: modale parent éditable, onglet Enfants, fiche enfant, UserService.

Couvre doc 28 §6.1–6.2 ; tickets liés #115 #116 #130 #131 #137 #138.
Hors scope: fiche AM (#131), création admin (#129).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 00:35:00 +02:00
jmartinandCursor df776d8200 fix(#112): dates et places AM en reprise (resetForReprise)
Corrige l’ombre des paramètres dateOfBirth, agreementDate et placesAvailable
dans resetForReprise ; renforce le parsing reprise-dossier et ajoute un test.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 19:12:14 +02:00
jmartinandCursor c26ed00374 fix(#112): préremplissage AM complet en reprise + lien login repositionné
Parse dates/places/consentement de façon robuste, rafraîchit l'étape 2 AM
et place « J'ai un numéro de dossier » sous « Créer un compte ».

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 17:29:31 +02:00
jmartinandCursor 9d54d9b19b feat(#112): email accusé resoumission aux parents
Après PATCH reprise-resoumettre (parent), envoi à chaque parent du
dossier : confirmation resoumission + rappel numero_dossier.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 17:26:23 +02:00
jmartinandCursor c438009286 feat(#112): lien login « J'ai un numéro de dossier » + reprise-identify
Modale numéro + e-mail, obtention du token puis redirection vers /reprise.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 17:01:18 +02:00
jmartinandCursor 9b7231f1da fix(#112): afficher photos enfants en reprise + consentement cohérent
Charge existingPhotoUrl dans les cartes enfant (étapes 3 et 5) et pré-coche
le consentement photo lorsqu'une photo est déjà en base.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 17:00:15 +02:00
jmartinandCursor f300505225 feat(#112): aligner reprise front sur dossier complet GET/PATCH
Préremplit parents, enfants, motivation et fiche AM depuis reprise-dossier
et envoie le body PATCH complet (co-parent, enfants par id, champs pro AM).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 16:46:51 +02:00
jmartinandCursor 25c10c885a docs(#112): note alignement front reprise dossier complet
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 16:42:35 +02:00
jmartinandCursor d70577b1c3 feat(#112): reprise après refus — dossier complet GET/PATCH
- GET reprise-dossier : parents[], enfants[], texte_motivation (famille) ou fiche AM (#119)
- PATCH reprise-resoumettre : co-parent, enfants (update par id), motivation, fiche AM
- Resoumission famille : tous les parents en en_attente + invalidation token groupée
- DTOs étendus + est_multiple sur enfants dossier famille
- Tests unitaires getRepriseDossier parent/AM

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 16:39:53 +02:00
jmartinandCursor 671da71752 feat(#112): reprise après refus via lien e-mail (/reprise?token=)
Branche le flux front : chargement du dossier, session reprise, wizards
parent/AM préremplis et resoumission via reprise-resoumettre.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 16:28:41 +02:00
31 changed files with 88 additions and 2216 deletions
-2
View File
@@ -18,7 +18,6 @@ import { AppConfigModule } from './modules/config/config.module';
import { DocumentsLegauxModule } from './modules/documents-legaux';
import { RelaisModule } from './routes/relais/relais.module';
import { DossiersModule } from './routes/dossiers/dossiers.module';
import { SuppressionsModule } from './routes/suppressions/suppressions.module';
@Module({
imports: [
@@ -58,7 +57,6 @@ import { SuppressionsModule } from './routes/suppressions/suppressions.module';
DocumentsLegauxModule,
RelaisModule,
DossiersModule,
SuppressionsModule,
],
controllers: [AppController],
providers: [
-162
View File
@@ -36,7 +36,6 @@ import { MailService } from 'src/modules/mail/mail.service';
import { ParentsService } from '../parents/parents.service';
import { DossiersService } from '../dossiers/dossiers.service';
import { DossierAmCompletDto } from '../dossiers/dto/dossier-am-complet.dto';
import { StaffAddCoParentDto } from '../parents/dto/staff-add-co-parent.dto';
@Injectable()
export class AuthService {
@@ -719,167 +718,6 @@ export class AuthService {
});
}
/**
* Ajoute un co-parent à un foyer existant (mono-parent) — ticket #135.
* Compte actif + mail création MDP + liens Parents bidirectionnels + enfants du foyer.
*/
async addCoParentStaff(pivotUserId: string, dto: StaffAddCoParentDto) {
const pivotParent = await this.parentsRepo.findOne({
where: { user_id: pivotUserId },
relations: ['user', 'co_parent', 'parentChildren'],
});
if (!pivotParent?.user) {
throw new NotFoundException('Parent introuvable');
}
if (pivotParent.co_parent) {
throw new BadRequestException('Ce foyer a déjà un co-parent.');
}
const numeroDossier = pivotParent.numero_dossier?.trim() || pivotParent.user.numero_dossier?.trim();
if (!numeroDossier) {
throw new BadRequestException("Ce parent n'a pas de numéro de dossier.");
}
const sameDossierCount = await this.parentsRepo.count({
where: { numero_dossier: numeroDossier },
});
if (sameDossierCount >= 2) {
throw new BadRequestException('Ce dossier a déjà deux responsables.');
}
const email = dto.email.trim().toLowerCase();
if (pivotParent.user.email.trim().toLowerCase() === email) {
throw new BadRequestException(
"L'email du co-parent doit être différent de celui du parent principal.",
);
}
const emailExiste = await this.usersService.findByEmailOrNull(dto.email);
if (emailExiste) {
throw new ConflictException("L'email du co-parent est déjà utilisé");
}
const memeAdresse = dto.meme_adresse ?? true;
if (!memeAdresse) {
if (!dto.adresse?.trim() || !dto.ville?.trim() || !dto.code_postal?.trim()) {
throw new BadRequestException(
"Adresse, code postal et ville du co-parent sont requis si meme_adresse est faux.",
);
}
}
const joursExpirationToken = await this.appConfigService.get<number>(
'password_reset_token_expiry_days',
7,
);
const tokenCreationMdp = crypto.randomUUID();
const dateExpiration = new Date();
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken);
let coParent: Users;
try {
coParent = await this.usersRepo.manager.transaction(async (manager) => {
const pivotUser = await manager.findOne(Users, {
where: { id: pivotUserId },
});
if (!pivotUser) {
throw new NotFoundException('Parent introuvable');
}
const pivotEntite = await manager.findOne(Parents, {
where: { user_id: pivotUserId },
relations: ['parentChildren'],
});
if (!pivotEntite) {
throw new NotFoundException('Parent introuvable');
}
const coUser = manager.create(Users, {
email: dto.email.trim(),
prenom: dto.prenom,
nom: dto.nom,
role: RoleType.PARENT,
statut: StatutUtilisateurType.ACTIF,
telephone: dto.telephone,
adresse: memeAdresse ? pivotUser.adresse : dto.adresse,
code_postal: memeAdresse ? pivotUser.code_postal : dto.code_postal,
ville: memeAdresse ? pivotUser.ville : dto.ville,
token_creation_mdp: tokenCreationMdp,
token_creation_mdp_expire_le: dateExpiration,
numero_dossier: numeroDossier,
});
const coUserSaved = await manager.save(Users, coUser);
pivotEntite.co_parent = coUserSaved;
pivotEntite.numero_dossier = numeroDossier;
await manager.save(Parents, pivotEntite);
const coEntite = manager.create(Parents, {
user_id: coUserSaved.id,
numero_dossier: numeroDossier,
});
coEntite.user = coUserSaved;
coEntite.co_parent = pivotUser;
await manager.save(Parents, coEntite);
const enfantIds = (pivotEntite.parentChildren ?? [])
.map((pc) => pc.enfantId)
.filter(Boolean);
for (const enfantId of enfantIds) {
const existing = await manager.findOne(ParentsChildren, {
where: { parentId: coUserSaved.id, enfantId },
});
if (existing) continue;
await manager.save(
ParentsChildren,
manager.create(ParentsChildren, {
parentId: coUserSaved.id,
enfantId,
}),
);
}
return coUserSaved;
});
} catch (err) {
if (this.isPostgresUniqueViolation(err)) {
throw new ConflictException(
'Un compte avec cet email existe déjà (contrainte unique en base).',
);
}
throw err;
}
try {
await this.mailService.sendValidatedAccountPasswordSetupEmail(
{
email: coParent.email,
prenom: coParent.prenom ?? '',
nom: coParent.nom ?? '',
token: tokenCreationMdp,
numeroDossier,
},
'parent',
);
} catch (err) {
this.logger.error(
'[addCoParentStaff] Échec envoi email création MDP (co-parent conservé)',
err instanceof Error ? err.stack : String(err),
);
}
return {
message:
'Co-parent ajouté au foyer. Un e-mail de création de mot de passe a été envoyé.',
numero_dossier: numeroDossier,
parent_user_id: pivotUserId,
co_parent_user_id: coParent.id,
statut: StatutUtilisateurType.ACTIF,
};
}
/**
* Cœur partagé création dossier AM (#156).
* - public : statut en_attente + mail pending
@@ -1,7 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DossiersController } from './dossiers.controller';
import { DossiersService } from './dossiers.service';
import { SuppressionService } from '../suppressions/suppression.service';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { StatutUtilisateurType } from 'src/entities/users.entity';
@@ -12,17 +11,11 @@ describe('DossiersController', () => {
listDossiers: jest.fn(),
getDossierByNumero: jest.fn(),
};
const suppressionServiceMock = {
deleteDossier: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [DossiersController],
providers: [
{ provide: DossiersService, useValue: dossiersServiceMock },
{ provide: SuppressionService, useValue: suppressionServiceMock },
],
providers: [{ provide: DossiersService, useValue: dossiersServiceMock }],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
@@ -67,19 +60,4 @@ describe('DossiersController', () => {
expect(dossiersServiceMock.getDossierByNumero).toHaveBeenCalledWith('2026-000001');
expect(res.type).toBe('family');
});
it('remove delegates to suppressionService.deleteDossier', async () => {
const user = { id: 'u1', role: 'gestionnaire' } as never;
suppressionServiceMock.deleteDossier.mockResolvedValue({
type: 'famille',
deleted_user_ids: [],
deleted_enfant_ids: [],
message: 'ok',
});
await controller.remove('2026-000001', user);
expect(suppressionServiceMock.deleteDossier).toHaveBeenCalledWith(
'2026-000001',
user,
);
});
});
@@ -1,11 +1,4 @@
import {
Controller,
Delete,
Get,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
@@ -15,12 +8,10 @@ import {
ApiTags,
} from '@nestjs/swagger';
import { Roles } from 'src/common/decorators/roles.decorator';
import { RoleType, Users } from 'src/entities/users.entity';
import { RoleType } from 'src/entities/users.entity';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { User } from 'src/common/decorators/user.decorator';
import { DossiersService } from './dossiers.service';
import { SuppressionService } from '../suppressions/suppression.service';
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
import { DossierListItemDto } from './dto/dossier-list-item.dto';
@@ -29,10 +20,7 @@ import { DossierListItemDto } from './dto/dossier-list-item.dto';
@Controller('dossiers')
@UseGuards(AuthGuard, RolesGuard)
export class DossiersController {
constructor(
private readonly dossiersService: DossiersService,
private readonly suppressionService: SuppressionService,
) {}
constructor(private readonly dossiersService: DossiersService) {}
@Get()
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@@ -40,8 +28,7 @@ export class DossiersController {
summary: 'Liste unifiée des dossiers (familles + AM) — ticket #153',
description:
'1 entrée = 1 numero_dossier. Types `famille` | `assistante_maternelle`. ' +
'Filtre optionnel `q` (n°, nom, email). Tri : à valider dabord, puis n° décroissant. ' +
'`sans_enfant` (#159) pour dossiers famille sans enfant.',
'Filtre optionnel `q` (n°, nom, email). Tri : à valider dabord, puis n° décroissant.',
})
@ApiQuery({
name: 'q',
@@ -64,21 +51,4 @@ export class DossiersController {
getDossier(@Param('numeroDossier') numeroDossier: string): Promise<DossierUnifieDto> {
return this.dossiersService.getDossierByNumero(numeroDossier);
}
@Delete(':numeroDossier')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({
summary: 'Supprimer un dossier (famille ou AM) — #159',
description:
'Famille : parents + enfants. AM : compte AM + dossier AM (enfants conservés, placements clos).',
})
@ApiParam({ name: 'numeroDossier', description: 'Numéro de dossier' })
@ApiResponse({ status: 200, description: 'Résultat de suppression' })
@ApiResponse({ status: 404, description: 'Dossier introuvable' })
remove(
@Param('numeroDossier') numeroDossier: string,
@User() currentUser: Users,
) {
return this.suppressionService.deleteDossier(numeroDossier, currentUser);
}
}
@@ -5,7 +5,6 @@ import { JwtModule } from '@nestjs/jwt';
import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { ParentsModule } from '../parents/parents.module';
import { SuppressionsModule } from '../suppressions/suppressions.module';
import { DossiersController } from './dossiers.controller';
import { DossiersService } from './dossiers.service';
@@ -13,7 +12,6 @@ import { DossiersService } from './dossiers.service';
imports: [
TypeOrmModule.forFeature([Parents, AssistanteMaternelle]),
ParentsModule,
SuppressionsModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
@@ -4,7 +4,6 @@ import { DossiersService } from './dossiers.service';
import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { ParentsService } from '../parents/parents.service';
import { SuppressionService } from '../suppressions/suppression.service';
import { StatutUtilisateurType } from 'src/entities/users.entity';
describe('DossiersService.listDossiers', () => {
@@ -33,9 +32,6 @@ describe('DossiersService.listDossiers', () => {
const parentsService = {
getDossierFamilleByNumero: jest.fn(),
};
const suppressionService = {
countEnfantsForNumero: jest.fn().mockResolvedValue(1),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -44,7 +40,6 @@ describe('DossiersService.listDossiers', () => {
{ provide: getRepositoryToken(Parents), useValue: parentsRepo },
{ provide: getRepositoryToken(AssistanteMaternelle), useValue: amRepo },
{ provide: ParentsService, useValue: parentsService },
{ provide: SuppressionService, useValue: suppressionService },
],
}).compile();
@@ -52,7 +47,6 @@ describe('DossiersService.listDossiers', () => {
jest.clearAllMocks();
parentsRepo.createQueryBuilder.mockReturnValue(parentsQb);
amRepo.createQueryBuilder.mockReturnValue(amQb);
suppressionService.countEnfantsForNumero.mockResolvedValue(1);
});
it('aggregates famille (pivot+co-parent) and AM, sorts a_valider first', async () => {
@@ -5,13 +5,12 @@ import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { StatutUtilisateurType, Users } from 'src/entities/users.entity';
import { ParentsService } from '../parents/parents.service';
import { SuppressionService } from '../suppressions/suppression.service';
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto';
import { DossierListItemDto } from './dto/dossier-list-item.dto';
/**
* Dossiers unifiés — détail (#119) + liste (#153) + sans_enfant (#159).
* Dossiers unifiés — détail (#119) + liste (#153).
*/
@Injectable()
export class DossiersService {
@@ -21,7 +20,6 @@ export class DossiersService {
@InjectRepository(AssistanteMaternelle)
private readonly amRepository: Repository<AssistanteMaternelle>,
private readonly parentsService: ParentsService,
private readonly suppressionService: SuppressionService,
) {}
/**
@@ -45,17 +43,6 @@ export class DossiersService {
return b.numero_dossier.localeCompare(a.numero_dossier, 'fr');
});
for (const item of filtered) {
if (item.type === 'famille') {
const n = await this.suppressionService.countEnfantsForNumero(
item.numero_dossier,
);
item.sans_enfant = n === 0;
} else {
item.sans_enfant = false;
}
}
return filtered;
}
@@ -49,10 +49,4 @@ export class DossierListItemDto {
description: 'Date de référence (MIN cree_le des users du dossier)',
})
date_reference: string | null;
@ApiPropertyOptional({
description:
'True si dossier famille sans enfant lié (#159). Omis ou false pour AM.',
})
sans_enfant?: boolean;
}
@@ -13,7 +13,6 @@ import {
ParseUUIDPipe,
Patch,
Post,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
@@ -24,7 +23,6 @@ import {
ApiBody,
ApiConsumes,
ApiOperation,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import { diskStorage } from 'multer';
@@ -38,7 +36,6 @@ import { User } from 'src/common/decorators/user.decorator';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { Roles } from 'src/common/decorators/roles.decorator';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { SuppressionService } from '../suppressions/suppression.service';
const photoMulterOptions = {
storage: diskStorage({
@@ -86,10 +83,7 @@ class OptionalEnfantPhotoInterceptor implements NestInterceptor {
@UseGuards(AuthGuard, RolesGuard)
@Controller('enfants')
export class EnfantsController {
constructor(
private readonly enfantsService: EnfantsService,
private readonly suppressionService: SuppressionService,
) { }
constructor(private readonly enfantsService: EnfantsService) { }
@Roles(
RoleType.PARENT,
@@ -147,47 +141,17 @@ export class EnfantsController {
RoleType.GESTIONNAIRE,
)
@Patch(':id')
@ApiOperation({
summary: 'Mettre à jour un enfant',
description:
'JSON sans photo OK ; avec nouvelle photo → multipart (champ fichier `photo`, max 5 Mo).',
})
@ApiConsumes('application/json', 'multipart/form-data')
@UseInterceptors(OptionalEnfantPhotoInterceptor)
update(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() dto: UpdateEnfantsDto,
@UploadedFile() photo: Express.Multer.File,
@User() currentUser: Users,
) {
return this.enfantsService.update(id, dto, currentUser, photo);
return this.enfantsService.update(id, dto, currentUser);
}
@Roles(
RoleType.SUPER_ADMIN,
RoleType.ADMINISTRATEUR,
RoleType.GESTIONNAIRE,
)
@Roles(RoleType.SUPER_ADMIN)
@Delete(':id')
@ApiOperation({
summary: 'Supprimer un enfant (#159)',
description:
'Query `deleteDossier=true` si dernier enfant et suppression du dossier famille souhaitée.',
})
@ApiQuery({
name: 'deleteDossier',
required: false,
description: 'Si true et dernier enfant : cascade dossier famille',
})
remove(
@Param('id', new ParseUUIDPipe()) id: string,
@Query('deleteDossier') deleteDossier: string | undefined,
@User() currentUser: Users,
) {
const flag =
deleteDossier === 'true' ||
deleteDossier === '1' ||
deleteDossier === 'yes';
return this.suppressionService.deleteEnfant(id, flag, currentUser);
remove(@Param('id', new ParseUUIDPipe()) id: string) {
return this.enfantsService.remove(id);
}
}
+4 -7
View File
@@ -6,16 +6,13 @@ import { Children } from 'src/entities/children.entity';
import { Parents } from 'src/entities/parents.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { AuthModule } from '../auth/auth.module';
import { SuppressionsModule } from '../suppressions/suppressions.module';
@Module({
imports: [
TypeOrmModule.forFeature([Children, Parents, ParentsChildren]),
AuthModule,
SuppressionsModule,
imports: [TypeOrmModule.forFeature([Children, Parents, ParentsChildren]),
AuthModule
],
controllers: [EnfantsController],
providers: [EnfantsService],
exports: [EnfantsService],
providers: [EnfantsService]
})
export class EnfantsModule { }
+1 -13
View File
@@ -195,12 +195,7 @@ export class EnfantsService {
// Mise à jour
async update(
id: string,
dto: Partial<CreateEnfantsDto>,
currentUser: Users,
photoFile?: Express.Multer.File,
): Promise<Children> {
async update(id: string, dto: Partial<CreateEnfantsDto>, currentUser: Users): Promise<Children> {
const child = await this.childrenRepository.findOne({ where: { id } });
if (!child) throw new NotFoundException('Enfant introuvable');
@@ -210,13 +205,6 @@ export class EnfantsService {
patch.consent_photo = dto.consent_photo;
patch.consent_photo_at = dto.consent_photo ? new Date() : null!;
}
if (photoFile) {
patch.photo_url = `/uploads/photos/${photoFile.filename}`;
if (dto.consent_photo !== false) {
patch.consent_photo = true;
patch.consent_photo_at = new Date();
}
}
await this.childrenRepository.update(id, patch);
return this.findOne(id, currentUser);
@@ -1,23 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Réponse 201 POST /parents/:id/co-parent (#135). */
export class StaffAddCoParentResponseDto {
@ApiProperty()
message: string;
@ApiProperty({ example: '2026-000043' })
numero_dossier: string;
@ApiProperty({ format: 'uuid', description: 'UUID du parent pivot' })
parent_user_id: string;
@ApiProperty({ format: 'uuid', description: 'UUID du co-parent créé' })
co_parent_user_id: string;
@ApiProperty({
enum: StatutUtilisateurType,
example: StatutUtilisateurType.ACTIF,
})
statut: StatutUtilisateurType;
}
@@ -1,69 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
/**
* Ajout dun co-parent sur un foyer existant (staff) — ticket #135.
* Corps sans préfixe `co_parent_*` (lURL cible déjà le pivot).
*/
export class StaffAddCoParentDto {
@ApiProperty({ example: 'thomas.martin@ptits-pas.fr' })
@IsEmail({}, { message: 'Email invalide' })
@IsNotEmpty({ message: "L'email est requis" })
email: string;
@ApiProperty({ example: 'Thomas' })
@IsString()
@IsNotEmpty({ message: 'Le prénom est requis' })
@MinLength(2)
@MaxLength(100)
prenom: string;
@ApiProperty({ example: 'MARTIN' })
@IsString()
@IsNotEmpty({ message: 'Le nom est requis' })
@MinLength(2)
@MaxLength(100)
nom: string;
@ApiProperty({ example: '0678456789' })
@IsString()
@IsNotEmpty({ message: 'Le téléphone est requis' })
@Matches(/^(\+33|0)[1-9](\d{2}){4}$/, {
message: 'Le numéro de téléphone doit être valide (ex: 0689567890 ou +33689567890)',
})
telephone: string;
@ApiPropertyOptional({
example: true,
description: 'Si true, copie ladresse du parent pivot',
})
@IsOptional()
@IsBoolean()
meme_adresse?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
adresse?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(10)
code_postal?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(150)
ville?: string;
}
@@ -11,7 +11,6 @@ describe('ParentsController', () => {
let controller: ParentsController;
const authServiceMock = {
createParentDossierStaff: jest.fn(),
addCoParentStaff: jest.fn(),
};
const parentsServiceMock = {};
const userServiceMock = {};
@@ -78,27 +77,4 @@ describe('ParentsController', () => {
expect(res.enfant_ids).toEqual(['e1']);
expect(res.statut).toBe(StatutUtilisateurType.ACTIF);
});
it('addCoParent delegates to authService.addCoParentStaff', async () => {
authServiceMock.addCoParentStaff.mockResolvedValue({
message: 'ok',
numero_dossier: '2026-000043',
parent_user_id: 'p1',
co_parent_user_id: 'p2',
statut: StatutUtilisateurType.ACTIF,
});
const body = {
email: 'coparent@test.fr',
prenom: 'Thomas',
nom: 'MARTIN',
telephone: '0678456789',
meme_adresse: true,
};
const res = await controller.addCoParent('p1', body as any);
expect(authServiceMock.addCoParentStaff).toHaveBeenCalledWith('p1', body);
expect(res.co_parent_user_id).toBe('p2');
expect(res.statut).toBe(StatutUtilisateurType.ACTIF);
});
});
@@ -30,8 +30,6 @@ import { UpdateParentsDto } from '../user/dto/update_parent.dto';
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
import { StaffCreateParentDossierDto } from './dto/staff-create-parent-dossier.dto';
import { StaffCreateParentDossierResponseDto } from './dto/staff-create-parent-dossier-response.dto';
import { StaffAddCoParentDto } from './dto/staff-add-co-parent.dto';
import { StaffAddCoParentResponseDto } from './dto/staff-add-co-parent-response.dto';
import { RegisterParentCompletDto } from '../auth/dto/register-parent-complet.dto';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
@@ -178,28 +176,6 @@ export class ParentsController {
return mapParentForApi(parent);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@Post(':id/co-parent')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Ajouter un co-parent à un foyer existant (staff) — ticket #135',
description:
'Foyer mono-parent uniquement. Crée le co-parent actif, liens foyer + enfants, ' +
'e-mail de création de mot de passe. Ne pas utiliser POST /auth/register/parent.',
})
@ApiParam({ name: 'id', description: 'UUID utilisateur du parent pivot' })
@ApiBody({ type: StaffAddCoParentDto })
@ApiResponse({ status: 201, type: StaffAddCoParentResponseDto })
@ApiResponse({ status: 400, description: 'Foyer déjà à 2 parents / validation' })
@ApiResponse({ status: 404, description: 'Parent introuvable' })
@ApiResponse({ status: 409, description: 'Email déjà pris' })
async addCoParent(
@Param('id') id: string,
@Body() dto: StaffAddCoParentDto,
): Promise<StaffAddCoParentResponseDto> {
return this.authService.addCoParentStaff(id, dto);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Post(':id/enfants/:enfantId')
@ApiOperation({ summary: 'Rattacher un enfant à un parent — ticket #115' })
@@ -1,175 +0,0 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { SuppressionService } from './suppression.service';
import { RoleType } from 'src/entities/users.entity';
describe('SuppressionService (#159)', () => {
const dataSource = {
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) =>
cb({
delete: jest.fn(),
query: jest.fn(),
}),
),
};
const usersRepository = {
findOne: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
};
const parentsRepository = {
findOne: jest.fn(),
find: jest.fn(),
query: jest.fn(),
};
const amRepository = {
findOne: jest.fn(),
};
const childrenRepository = {
findOne: jest.fn(),
delete: jest.fn(),
save: jest.fn(),
};
const parentsChildrenRepository = {
find: jest.fn(),
};
const amChildrenRepository = {
find: jest.fn(),
save: jest.fn(),
count: jest.fn(),
};
let service: SuppressionService;
const staff = {
id: 'staff-1',
role: RoleType.GESTIONNAIRE,
} as never;
const admin = {
id: 'admin-1',
role: RoleType.ADMINISTRATEUR,
} as never;
const superAdmin = {
id: 'sa-1',
role: RoleType.SUPER_ADMIN,
} as never;
beforeEach(() => {
jest.clearAllMocks();
service = new SuppressionService(
dataSource as never,
usersRepository as never,
parentsRepository as never,
amRepository as never,
childrenRepository as never,
parentsChildrenRepository as never,
amChildrenRepository as never,
);
});
it('refuse self-delete', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'admin-1',
role: RoleType.ADMINISTRATEUR,
});
await expect(service.deleteUser('admin-1', admin)).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('refuse gestionnaire deleting another gestionnaire', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'g2',
role: RoleType.GESTIONNAIRE,
});
await expect(service.deleteUser('g2', staff)).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('dernier admin : refus si pas super_admin', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'admin-2',
role: RoleType.ADMINISTRATEUR,
});
usersRepository.count.mockResolvedValue(1);
await expect(service.deleteUser('admin-2', admin)).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('dernier admin : OK pour super_admin', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'admin-2',
role: RoleType.ADMINISTRATEUR,
});
usersRepository.count.mockResolvedValue(1);
usersRepository.delete.mockResolvedValue({ affected: 1 });
const res = await service.deleteUser('admin-2', superAdmin);
expect(res.deleted_user_ids).toEqual(['admin-2']);
});
it('delete AM : clos placements, pas denfants deleted', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'am-1',
role: RoleType.ASSISTANTE_MATERNELLE,
});
amRepository.findOne.mockResolvedValue({
user_id: 'am-1',
numero_dossier: '2026-000015',
});
amChildrenRepository.find.mockResolvedValue([
{
amId: 'am-1',
enfantId: 'e1',
child: { id: 'e1', status: 'garde' },
},
]);
amChildrenRepository.count.mockResolvedValue(0);
amChildrenRepository.save.mockImplementation(async (x) => x);
childrenRepository.save.mockResolvedValue({});
usersRepository.delete.mockResolvedValue({ affected: 1 });
const res = await service.deleteUser('am-1', staff);
expect(res.deleted_enfant_ids).toEqual([]);
expect(res.deleted_user_ids).toEqual(['am-1']);
expect(res.type).toBe('assistante_maternelle');
});
it('delete dossier famille introuvable', async () => {
parentsRepository.findOne.mockResolvedValue(null);
amRepository.findOne.mockResolvedValue(null);
await expect(
service.deleteDossier('2026-999999', staff),
).rejects.toBeInstanceOf(NotFoundException);
});
it('co-parent : delete user seul', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'p2',
role: RoleType.PARENT,
});
parentsRepository.findOne.mockResolvedValue({
user_id: 'p2',
numero_dossier: '2026-000010',
co_parent: { id: 'p1' },
});
parentsRepository.query.mockResolvedValue([
{ id: 'p1' },
{ id: 'p2' },
]);
dataSource.transaction.mockImplementation(async (cb) =>
cb({
delete: jest.fn(),
query: jest.fn(),
}),
);
const res = await service.deleteUser('p2', staff);
expect(res.deleted_enfant_ids).toEqual([]);
expect(res.deleted_user_ids).toEqual(['p2']);
expect(res.message).toMatch(/co-parent/i);
});
});
@@ -1,420 +0,0 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, IsNull, Repository } from 'typeorm';
import { RoleType, Users } from 'src/entities/users.entity';
import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { Children, StatutEnfantType } from 'src/entities/children.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { AmChildren } from 'src/entities/am_children.entity';
export type SuppressionResult = {
type?: 'famille' | 'assistante_maternelle';
numero_dossier?: string;
deleted_user_ids: string[];
deleted_enfant_ids: string[];
dossier_supprime?: boolean;
message: string;
};
const STAFF_METIER: RoleType[] = [
RoleType.GESTIONNAIRE,
RoleType.ADMINISTRATEUR,
RoleType.SUPER_ADMIN,
];
/**
* Cascades de suppression métier — tickets #154 / #159.
*/
@Injectable()
export class SuppressionService {
constructor(
private readonly dataSource: DataSource,
@InjectRepository(Users)
private readonly usersRepository: Repository<Users>,
@InjectRepository(Parents)
private readonly parentsRepository: Repository<Parents>,
@InjectRepository(AssistanteMaternelle)
private readonly amRepository: Repository<AssistanteMaternelle>,
@InjectRepository(Children)
private readonly childrenRepository: Repository<Children>,
@InjectRepository(ParentsChildren)
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
@InjectRepository(AmChildren)
private readonly amChildrenRepository: Repository<AmChildren>,
) {}
assertStaffMetier(currentUser: Users): void {
if (!STAFF_METIER.includes(currentUser.role)) {
throw new ForbiddenException('Accès refusé');
}
}
async deleteDossier(
numeroDossier: string,
currentUser: Users,
): Promise<SuppressionResult> {
this.assertStaffMetier(currentUser);
const num = numeroDossier?.trim();
if (!num) {
throw new BadRequestException('Numéro de dossier requis.');
}
const parentHit = await this.parentsRepository.findOne({
where: { numero_dossier: num },
});
if (parentHit) {
return this.deleteFamilleByNumero(num);
}
const amHit = await this.amRepository.findOne({
where: { numero_dossier: num },
relations: ['user'],
});
if (amHit?.user) {
return this.deleteAmUser(amHit.user.id);
}
throw new NotFoundException('Aucun dossier trouvé pour ce numéro.');
}
async deleteUser(
id: string,
currentUser: Users,
): Promise<SuppressionResult> {
const target = await this.usersRepository.findOne({ where: { id } });
if (!target) {
throw new NotFoundException('Utilisateur introuvable');
}
if (target.id === currentUser.id) {
throw new ForbiddenException('Vous ne pouvez pas supprimer votre propre compte.');
}
if (target.role === RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Le super administrateur ne peut pas être supprimé.');
}
if (target.role === RoleType.PARENT) {
this.assertStaffMetier(currentUser);
return this.deleteParentUser(target.id);
}
if (target.role === RoleType.ASSISTANTE_MATERNELLE) {
this.assertStaffMetier(currentUser);
return this.deleteAmUser(target.id);
}
if (target.role === RoleType.GESTIONNAIRE) {
if (
currentUser.role !== RoleType.ADMINISTRATEUR &&
currentUser.role !== RoleType.SUPER_ADMIN
) {
throw new ForbiddenException(
'Seul un administrateur peut supprimer un gestionnaire.',
);
}
await this.usersRepository.delete(target.id);
return {
deleted_user_ids: [target.id],
deleted_enfant_ids: [],
message: 'Gestionnaire supprimé.',
};
}
if (target.role === RoleType.ADMINISTRATEUR) {
await this.assertCanDeleteAdministrateur(target, currentUser);
await this.usersRepository.delete(target.id);
return {
deleted_user_ids: [target.id],
deleted_enfant_ids: [],
message: 'Administrateur supprimé.',
};
}
throw new BadRequestException('Type dutilisateur non supprimable via cet endpoint.');
}
async deleteEnfant(
enfantId: string,
deleteDossier: boolean,
currentUser: Users,
): Promise<SuppressionResult> {
this.assertStaffMetier(currentUser);
const child = await this.childrenRepository.findOne({
where: { id: enfantId },
relations: ['parentLinks', 'parentLinks.parent'],
});
if (!child) {
throw new NotFoundException('Enfant introuvable');
}
const parentIds = (child.parentLinks ?? [])
.map((l) => l.parentId ?? l.parent?.user_id)
.filter(Boolean) as string[];
let numero: string | undefined;
if (parentIds.length > 0) {
const parents = await this.parentsRepository.find({
where: { user_id: In(parentIds) },
});
numero = parents.map((p) => p.numero_dossier?.trim()).find((n) => !!n);
}
if (!numero) {
await this.closePlacementsForEnfants([enfantId]);
await this.childrenRepository.delete(enfantId);
return {
deleted_user_ids: [],
deleted_enfant_ids: [enfantId],
dossier_supprime: false,
message: 'Enfant supprimé.',
};
}
const siblingIds = await this.listEnfantIdsForNumero(numero);
const isLast = siblingIds.length <= 1;
if (isLast && deleteDossier) {
const result = await this.deleteFamilleByNumero(numero);
return {
...result,
dossier_supprime: true,
message: 'Dernier enfant et dossier famille supprimés.',
};
}
await this.closePlacementsForEnfants([enfantId]);
await this.childrenRepository.delete(enfantId);
return {
deleted_user_ids: [],
deleted_enfant_ids: [enfantId],
dossier_supprime: false,
numero_dossier: numero,
type: 'famille',
message: isLast
? 'Dernier enfant supprimé. Le dossier famille reste sans enfant.'
: 'Enfant supprimé du dossier famille.',
};
}
/** Compte enfants liés à un numero_dossier famille (pour flag sans_enfant). */
async countEnfantsForNumero(numeroDossier: string): Promise<number> {
const ids = await this.listEnfantIdsForNumero(numeroDossier);
return ids.length;
}
private async assertCanDeleteAdministrateur(
target: Users,
currentUser: Users,
): Promise<void> {
if (
currentUser.role !== RoleType.ADMINISTRATEUR &&
currentUser.role !== RoleType.SUPER_ADMIN
) {
throw new ForbiddenException(
'Seul un administrateur peut supprimer un administrateur.',
);
}
const adminCount = await this.usersRepository.count({
where: { role: RoleType.ADMINISTRATEUR },
});
if (adminCount <= 1) {
if (currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException(
'Seul le super administrateur peut supprimer le dernier administrateur.',
);
}
}
}
private async deleteParentUser(userId: string): Promise<SuppressionResult> {
const parent = await this.parentsRepository.findOne({
where: { user_id: userId },
relations: ['co_parent'],
});
if (!parent) {
// Compte parent sans fiche — hard delete user
await this.usersRepository.delete(userId);
return {
deleted_user_ids: [userId],
deleted_enfant_ids: [],
message: 'Parent supprimé.',
};
}
const numero = parent.numero_dossier?.trim();
const foyerIds = numero
? await this.listParentUserIdsForNumero(numero)
: [userId];
const isLast = foyerIds.filter((id) => id !== userId).length === 0;
if (!isLast) {
// Co-parent : retirer liens enfants de ce parent, clear co_parent refs, delete user
await this.dataSource.transaction(async (manager) => {
await manager.delete(ParentsChildren, { parentId: userId });
await manager.query(
`UPDATE parents SET id_co_parent = NULL WHERE id_co_parent = $1 OR id_utilisateur = $1`,
[userId],
);
await manager.delete(Users, { id: userId });
});
return {
deleted_user_ids: [userId],
deleted_enfant_ids: [],
numero_dossier: numero,
type: 'famille',
message: 'Parent retiré du dossier (co-parent).',
};
}
// Dernier parent : + enfants
const enfantIds = numero
? await this.listEnfantIdsForNumero(numero)
: await this.listEnfantIdsForParent(userId);
await this.closePlacementsForEnfants(enfantIds);
await this.dataSource.transaction(async (manager) => {
if (enfantIds.length) {
await manager.delete(Children, { id: In(enfantIds) });
}
await manager.query(
`UPDATE parents SET id_co_parent = NULL WHERE id_utilisateur = $1 OR id_co_parent = $1`,
[userId],
);
await manager.delete(Users, { id: userId });
});
return {
deleted_user_ids: [userId],
deleted_enfant_ids: enfantIds,
numero_dossier: numero,
type: 'famille',
message: 'Dernier parent et enfants rattachés supprimés.',
};
}
private async deleteFamilleByNumero(numero: string): Promise<SuppressionResult> {
const parentIds = await this.listParentUserIdsForNumero(numero);
if (parentIds.length === 0) {
throw new NotFoundException('Aucun parent pour ce dossier.');
}
const enfantIds = await this.listEnfantIdsForNumero(numero);
await this.closePlacementsForEnfants(enfantIds);
await this.dataSource.transaction(async (manager) => {
if (enfantIds.length) {
await manager.delete(Children, { id: In(enfantIds) });
}
await manager.query(
`UPDATE parents SET id_co_parent = NULL WHERE id_utilisateur = ANY($1::uuid[]) OR id_co_parent = ANY($1::uuid[])`,
[parentIds],
);
await manager.delete(Users, { id: In(parentIds) });
});
return {
type: 'famille',
numero_dossier: numero,
deleted_user_ids: parentIds,
deleted_enfant_ids: enfantIds,
message: 'Dossier famille supprimé.',
};
}
private async deleteAmUser(userId: string): Promise<SuppressionResult> {
const am = await this.amRepository.findOne({ where: { user_id: userId } });
const numero = am?.numero_dossier?.trim();
const active = await this.amChildrenRepository.find({
where: { amId: userId, date_fin: IsNull() },
relations: ['child'],
});
const now = new Date();
for (const link of active) {
link.date_fin = now;
await this.amChildrenRepository.save(link);
if (link.child) {
await this.applySansGarde(link.child);
}
}
await this.usersRepository.delete(userId);
return {
type: 'assistante_maternelle',
numero_dossier: numero,
deleted_user_ids: [userId],
deleted_enfant_ids: [],
message: 'Dossier assistante maternelle supprimé.',
};
}
private async applySansGarde(child: Children): Promise<void> {
if (
child.status === StatutEnfantType.A_NAITRE ||
child.status === StatutEnfantType.SCOLARISE
) {
return;
}
const remaining = await this.amChildrenRepository.count({
where: { enfantId: child.id, date_fin: IsNull() },
});
if (remaining === 0) {
child.status = StatutEnfantType.SANS_GARDE;
await this.childrenRepository.save(child);
}
}
private async closePlacementsForEnfants(enfantIds: string[]): Promise<void> {
if (!enfantIds.length) return;
const links = await this.amChildrenRepository.find({
where: { enfantId: In(enfantIds), date_fin: IsNull() },
relations: ['child'],
});
const now = new Date();
for (const link of links) {
link.date_fin = now;
await this.amChildrenRepository.save(link);
if (link.child) {
await this.applySansGarde(link.child);
}
}
}
private async listParentUserIdsForNumero(numero: string): Promise<string[]> {
const rows: Array<{ id: string }> = await this.parentsRepository.query(
`
SELECT DISTINCT x.id::text AS id FROM (
SELECT id_utilisateur AS id FROM parents WHERE TRIM(numero_dossier) = $1
UNION
SELECT id_co_parent AS id FROM parents
WHERE TRIM(numero_dossier) = $1 AND id_co_parent IS NOT NULL
UNION
SELECT p2.id_utilisateur AS id FROM parents p1
JOIN parents p2 ON p2.id_utilisateur = p1.id_co_parent
WHERE TRIM(p1.numero_dossier) = $1
) x WHERE x.id IS NOT NULL
`,
[numero],
);
return rows.map((r) => r.id);
}
private async listEnfantIdsForNumero(numero: string): Promise<string[]> {
const parentIds = await this.listParentUserIdsForNumero(numero);
if (!parentIds.length) return [];
return this.listEnfantIdsForParents(parentIds);
}
private async listEnfantIdsForParent(parentId: string): Promise<string[]> {
return this.listEnfantIdsForParents([parentId]);
}
private async listEnfantIdsForParents(parentIds: string[]): Promise<string[]> {
const links = await this.parentsChildrenRepository.find({
where: { parentId: In(parentIds) },
});
return [...new Set(links.map((l) => l.enfantId))];
}
}
@@ -1,25 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Users } from 'src/entities/users.entity';
import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { Children } from 'src/entities/children.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { AmChildren } from 'src/entities/am_children.entity';
import { SuppressionService } from './suppression.service';
@Module({
imports: [
TypeOrmModule.forFeature([
Users,
Parents,
AssistanteMaternelle,
Children,
ParentsChildren,
AmChildren,
]),
],
providers: [SuppressionService],
exports: [SuppressionService],
})
export class SuppressionsModule {}
+5 -17
View File
@@ -10,17 +10,13 @@ import { CreateUserDto } from './dto/create_user.dto';
import { CreateAdminDto } from './dto/create_admin.dto';
import { UpdateUserDto } from './dto/update_user.dto';
import { AffecterNumeroDossierDto } from './dto/affecter-numero-dossier.dto';
import { SuppressionService } from '../suppressions/suppression.service';
@ApiTags('Utilisateurs')
@ApiBearerAuth('access-token')
@UseGuards(AuthGuard, RolesGuard)
@Controller('users')
export class UserController {
constructor(
private readonly userService: UserService,
private readonly suppressionService: SuppressionService,
) { }
constructor(private readonly userService: UserService) { }
// Création d'un administrateur (réservée aux super admins)
@Post('admin')
@@ -150,20 +146,12 @@ export class UserController {
return this.userService.suspendUser(id, currentUser, comment);
}
// Supprimer un utilisateur — cascades métier #159
// Supprimer un utilisateur (super_admin uniquement)
@Delete(':id')
@Roles(
RoleType.SUPER_ADMIN,
RoleType.ADMINISTRATEUR,
RoleType.GESTIONNAIRE,
)
@ApiOperation({
summary: 'Supprimer un utilisateur (cascades métier #159)',
description:
'Parent / AM / staff selon matrice. Gestionnaire ne peut pas supprimer un autre gestionnaire. Self interdit.',
})
@Roles(RoleType.SUPER_ADMIN)
@ApiOperation({ summary: 'Supprimer un utilisateur' })
@ApiParam({ name: 'id', description: "UUID de l'utilisateur" })
remove(@Param('id') id: string, @User() currentUser: Users) {
return this.suppressionService.deleteUser(id, currentUser);
return this.userService.remove(id, currentUser);
}
}
-2
View File
@@ -12,7 +12,6 @@ import { Parents } from 'src/entities/parents.entity';
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
import { MailModule } from 'src/modules/mail/mail.module';
import { AppConfigModule } from 'src/modules/config/config.module';
import { SuppressionsModule } from '../suppressions/suppressions.module';
@Module({
imports: [TypeOrmModule.forFeature(
@@ -27,7 +26,6 @@ import { SuppressionsModule } from '../suppressions/suppressions.module';
GestionnairesModule,
MailModule,
AppConfigModule,
SuppressionsModule,
],
controllers: [UserController],
providers: [UserService],
-1
View File
@@ -520,7 +520,6 @@ export class UserService {
}
async remove(id: string, currentUser: Users): Promise<void> {
// Délégué historiquement ; préférer SuppressionService via controller (#159).
if (currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Accès réservé aux super admins');
}
@@ -1,73 +0,0 @@
# Mini-spec API — POST /parents/:id/co-parent (#135)
Contrat back pour lajout dun **2ᵉ parent** sur un foyer mono-parent (staff).
## Endpoint
| | |
|--|--|
| **Méthode** | `POST` |
| **URL** | `{base}/api/v1/parents/{parentUserId}/co-parent` |
| **Auth** | Bearer JWT |
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
| **Succès** | **201** |
`parentUserId` = UUID du **parent pivot** (déjà dans le dossier).
Ne **pas** appeler `POST /auth/register/parent` ni `POST /parents/dossier`.
---
## Body (JSON)
| Champ | Type | Obligatoire | Notes |
|-------|------|-------------|--------|
| `email` | string | oui | unique |
| `prenom` | string | oui | |
| `nom` | string | oui | |
| `telephone` | string | oui | `0X…` ou `+33…` |
| `meme_adresse` | bool | non | défaut **true** → copie adresse du pivot |
| `adresse` | string | si `meme_adresse=false` | |
| `code_postal` | string | si `meme_adresse=false` | |
| `ville` | string | si `meme_adresse=false` | |
---
## Comportement 201
- User co-parent **actif** + token création MDP
- Fiche `parents` + liens pivot ↔ co-parent + même `numero_dossier`
- Enfants du foyer rattachés au co-parent
- E-mail **création MDP** (pas mail « en attente »)
```json
{
"message": "Co-parent ajouté au foyer. Un e-mail de création de mot de passe a été envoyé.",
"numero_dossier": "2026-000043",
"parent_user_id": "uuid-pivot",
"co_parent_user_id": "uuid-co",
"statut": "actif"
}
```
## Erreurs
| Code | Cas |
|------|-----|
| 400 | Déjà un co-parent / 2 responsables / validation adresse |
| 401 | Token invalide |
| 403 | Rôle non staff |
| 404 | Pivot introuvable |
| 409 | Email déjà pris |
## Réemploi édition identité
| Endpoint | Usage |
|----------|--------|
| `GET /dossiers/:numero` | Préremplir wizard edit |
| `PATCH /parents/:id/fiche` | Sauver identité pivot / co-parent existant |
| `PATCH /assistantes-maternelles/:id/fiche` | Édition AM |
## Branche
`feature/135-edition-dossier`
@@ -1,83 +0,0 @@
# Mini-spec front — Mode édition dossier + ajout 2ᵉ parent (#135)
Branche : `feature/135-edition-dossier`
Ticket : **#135** (full-stack)
Prérequis : **#153** (liste Dossiers) livré.
---
## Objectif
1. Clic sur un dossier (liste #153) → ouvrir le wizard en mode **`edit`**
2. Foyer **mono-parent** : page co-parent → **switch** ajouter un 2ᵉ parent
3. Sauvegarder les champs via APIs existantes + nouvel endpoint co-parent
---
## Modes wizard
| Mode | Famille | AM |
|------|---------|-----|
| `review` | déjà | déjà |
| `create` | déjà (#129) | déjà (#156) |
| **`edit`** | **à faire** | **à faire** |
Factories : `ParentDossierWizard.edit(...)` / `AmDossierWizard.edit(...)`
Préremplir via `UserService.getDossierByNumero(numero)`.
---
## APIs
| Action | Endpoint |
|--------|----------|
| Charger | `GET /dossiers/:numero` |
| Sauver parent | `PATCH /parents/:id/fiche` |
| Sauver AM | `PATCH /assistantes-maternelles/:id/fiche` |
| **Ajouter co-parent** | **`POST /parents/:pivotUserId/co-parent`** — voir `docs/tmp/135-contrat-api-ajout-co-parent.md` |
Body co-parent :
```json
{
"email": "thomas@…",
"prenom": "Thomas",
"nom": "MARTIN",
"telephone": "0678456789",
"meme_adresse": true
}
```
`UserService.addCoParent(pivotUserId, body)` → cet endpoint.
---
## UX
- Depuis `DossiersManagementWidget` / carte liste : clic → edit (plus seulement review pending)
- Pending : garder validation (review) ; dossiers actifs → edit
- Mono-parent : switch « Ajouter un co-parent » (comme create) → au save, `POST …/co-parent` si nouveau
- Déjà 2 parents : éditer les deux fiches ; pas de 3ᵉ
- Pas de bouton créer dans longlet Dossiers
---
## Hors scope
- Famille N responsables (#139)
- Suppressions (#154)
- Création dossier initial (#129 / #156)
---
## Critères dacceptation
- [ ] Clic dossier actif → wizard edit prérempli
- [ ] PATCH fiche enregistre les modifs
- [ ] Mono-parent + switch → co-parent créé (actif + mail MDP)
- [ ] review / create inchangés
## Branche
`feature/135-edition-dossier`
-35
View File
@@ -1,35 +0,0 @@
# Matrice suppression — #154 / back **#159** / front **#160**
**Statut** : cadrage PO validé (sept. 2026)
**Milestone** : 0.1.0
**Email** : pas demail de suppression (cas rare)
## Droits
| Cible | Qui peut supprimer |
|-------|-------------------|
| Dossier / parent / enfant / AM | `GESTIONNAIRE`, `ADMINISTRATEUR`, `SUPER_ADMIN` |
| Gestionnaire (user) | `ADMINISTRATEUR`, `SUPER_ADMIN` |
| Administrateur (user) | Autre admin OK ; **self interdit** ; **dernier admin** = `SUPER_ADMIN` only ; `SUPER_ADMIN` non supprimable |
## Matrice métier
| Point dentrée | Action | Effet |
|----------------|--------|--------|
| Dossiers | Delete dossier **famille** | Tous **parents** + tous **enfants** ; clore placements AM des enfants |
| Dossiers / AM | Delete dossier **AM** ou compte AM | **Compte AM + dossier AM** ; enfants **conservés** ; placements **clos** |
| Parents | Co-parent (autre parent reste) | Compte parent seul ; dossier + enfants restent |
| Parents | Dernier parent | Parent + **enfants** rattachés |
| Enfants | Pas dernier | Enfant seul (retiré du dossier) |
| Enfants | Dernier + `deleteDossier=true` | Cascade dossier famille (parents + enfants) |
| Enfants | Dernier + `deleteDossier=false` | Enfant seul ; dossier peut apparaître **`sans_enfant`** |
| Pending / validé | — | **Mêmes règles** (pas de différenciation) |
## Warning
- `sans_enfant` sur liste `GET /dossiers` (dossier famille sans enfant lié).
- Miroir de `sans_responsable` (#157) côté enfants.
## Hors scope
Soft-delete RGPD, audit (#128), famille N (#139), restriction admin-only métier (plus tard).
-169
View File
@@ -1,169 +0,0 @@
# Mini-spec front — Suppressions dashboard
**Ticket front** : **#160** — https://git.ptits-pas.fr/jmartin/petitspas/issues/160
**Ticket back** : **#159** — https://git.ptits-pas.fr/jmartin/petitspas/issues/159
**Epic** : #154 (complète #133)
**Branche back** : `feature/159-suppressions-backend`
**Doc matrice** : [154-matrice-suppression.md](./154-matrice-suppression.md)
Travail **en parallèle** : ce contrat est la source de vérité UI ↔ API.
---
## UX commune
Sur chaque ligne / carte des listes :
- Icône **poubelle** en bout de ligne
- Clic → **dialog de confirmation** (texte dimpact) → DELETE → **refresh** liste
- Pending = **mêmes** règles que validés
- **Pas** demail
| Liste | Poubelle visible si |
|-------|---------------------|
| Dossiers, Parents, Enfants, AM | gestionnaire **ou** admin |
| Gestionnaires | **admin** only |
| Administrateurs | admin+ ; **pas** sur sa propre ligne ; dernier admin : UI warning + réservé super_admin |
---
## Contrat API
Base : auth Bearer. Erreurs : `400` / `403` / `404` / `409` avec `message` FR.
### 1. `DELETE /dossiers/:numeroDossier`
- **Famille** → supprime tous parents + enfants du n° ; clos placements AM des enfants.
- **AM** → compte AM + dossier AM ; enfants gardés ; placements clos.
**Réponse 200** (exemple) :
```json
{
"type": "famille",
"numero_dossier": "2026-000043",
"deleted_user_ids": ["…"],
"deleted_enfant_ids": ["…"],
"message": "Dossier famille supprimé."
}
```
ou
```json
{
"type": "assistante_maternelle",
"numero_dossier": "2026-000015",
"deleted_user_ids": ["…"],
"deleted_enfant_ids": [],
"message": "Dossier assistante maternelle supprimé."
}
```
**Dialog UI** : lister libellé + n° + « X parent(s), Y enfant(s) » (ou « compte AM, enfants conservés »).
---
### 2. `DELETE /users/:id`
Comportement selon le **rôle** de la cible :
| Cible | Effet |
|-------|--------|
| Parent **co-parent** | Delete ce user seul |
| Parent **dernier** du dossier | Delete user + enfants du foyer |
| AM | Delete user AM + dossier AM ; enfants conservés ; placements clos |
| Gestionnaire | Admin only ; self → 403 |
| Administrateur | Self → 403 ; dernier admin → super_admin only sinon 403 ; super_admin → 403 |
**Réponse 200** :
```json
{
"deleted_user_ids": ["…"],
"deleted_enfant_ids": ["…"],
"message": "…"
}
```
**Dialogs** :
- Co-parent : « Ce parent sera retiré / supprimé du dossier {n°}. Les enfants restent avec le co-parent. »
- Dernier parent : « Dernier parent du dossier {n°}. Les enfants rattachés seront aussi supprimés. »
- AM : « Le compte et le dossier AM seront supprimés. Les enfants accueillis ne seront pas supprimés. »
Optionnel (si exposé) : `GET /users/:id/suppression-impact` — sinon calculer depuis données déjà en liste / détail dossier.
---
### 3. `DELETE /enfants/:id?deleteDossier=true|false`
- Pas dernier enfant → delete enfant (`deleteDossier` ignoré ou false).
- Dernier enfant + `deleteDossier=false` → delete enfant ; dossier famille peut passer `sans_enfant`.
- Dernier enfant + `deleteDossier=true` → cascade dossier famille (parents + enfants).
**Réponse 200** :
```json
{
"deleted_enfant_ids": ["…"],
"deleted_user_ids": ["…"],
"dossier_supprime": false,
"message": "…"
}
```
**Dialog** :
- Standard : « Lenfant sera supprimé du dossier de {famille} ({n°}). »
- Dernier : proposer **deux actions** :
1. Supprimer lenfant seulement (`deleteDossier=false`)
2. Supprimer aussi le dossier / parents (`deleteDossier=true`)
Pour savoir si dernier : compter enfants du `numero_dossier` (détail dossier ou champ impact API).
---
### 4. `GET /dossiers` — flag `sans_enfant`
Chaque item famille peut exposer :
```json
"sans_enfant": true
```
- `true` si dossier **famille** sans enfant lié.
- AM : `false` ou omis.
**UI** : badge / warning vigilance (comme `sans_responsable` / alertes AM).
---
## UserService (Flutter) — signatures cibles
```dart
Future<void> deleteDossier(String numeroDossier);
Future<Map<String, dynamic>> deleteUser(String userId);
Future<Map<String, dynamic>> deleteEnfant(String enfantId, {bool deleteDossier = false});
```
(Adapter le parsing au JSON réel une fois le back mergé ; en parallèle, stubber sur ce contrat.)
---
## Fichiers front probables
- Cartes listes : `admin_user_card.dart`, `admin_enfant_user_card.dart`, cartes dossiers
- Listes : `dossiers_management_widget.dart`, `parent_managmant_widget.dart`, `enfant_management_widget.dart`, `assistante_maternelle_management_widget.dart`, `gestionnaire_management_widget.dart`, `admin_management_widget.dart`
- `user_service.dart`
---
## Critères front (#160)
- [ ] Poubelle selon droits
- [ ] Confirmations avec impact
- [ ] Refresh après succès
- [ ] Dernier enfant : choix dossier oui/non
- [ ] Warning `sans_enfant`
- [ ] Self-admin / dernier admin gérés côté UI (masquer ou message 403)
+1 -6
View File
@@ -215,13 +215,8 @@ class EnfantDossier {
factory EnfantDossier.fromJson(Map<String, dynamic> json) {
final rawPhoto = json['photo_url'] ?? json['photoUrl'];
final resolvedPhoto = _optionalPhotoUrl(rawPhoto);
final rawId = json['id'] ??
json['enfant_id'] ??
json['enfantId'] ??
json['child_id'] ??
json['childId'];
return EnfantDossier(
id: rawId?.toString().trim() ?? '',
id: json['id']?.toString() ?? '',
firstName: (json['first_name'] ?? json['prenom'])?.toString(),
lastName: (json['last_name'] ?? json['nom'])?.toString(),
birthDate: json['birth_date']?.toString(),
+6 -98
View File
@@ -525,58 +525,17 @@ class UserService {
return enfant.copyWith(parentLinks: enriched);
}
/// Mise à jour enfant. Avec [photoBytes] : multipart (champ `photo`), sinon JSON.
static Future<EnfantAdminModel> updateEnfant({
required String enfantId,
required Map<String, dynamic> body,
List<int>? photoBytes,
String? photoFilename,
}) async {
final id = enfantId.trim();
if (id.isEmpty) {
throw Exception('Identifiant enfant manquant.');
}
final hasPhoto = photoBytes != null && photoBytes.isNotEmpty;
final http.Response response;
if (hasPhoto) {
final token = await TokenService.getToken();
final req = http.MultipartRequest(
'PATCH',
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$id'),
);
req.headers['Accept'] = 'application/json';
if (token != null) {
req.headers['Authorization'] = 'Bearer $token';
}
body.forEach((key, value) {
if (value == null) return;
req.fields[key] = value is bool
? (value ? 'true' : 'false')
: value.toString();
});
final name = (photoFilename ?? '').trim();
final filename = name.isNotEmpty ? name : 'photo.jpg';
req.files.add(
http.MultipartFile.fromBytes(
'photo',
photoBytes,
filename: filename,
contentType: _imageMediaType(filename, photoBytes),
),
);
final streamed = await req.send();
response = await http.Response.fromStream(streamed);
} else {
response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$id'),
headers: await _headers(),
body: jsonEncode(body),
);
}
final response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
headers: await _headers(),
body: jsonEncode(body),
);
if (response.statusCode != 200) {
throw Exception(
_extractErrorMessage(response.body, 'Erreur mise à jour enfant'),
);
throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant'));
}
final enfant = EnfantAdminModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
@@ -686,57 +645,6 @@ class UserService {
return fallback;
}
/// Ajout dun co-parent sur foyer mono-parent (staff, #135).
/// `POST /parents/:pivotUserId/co-parent` — succès 201.
static Future<Map<String, dynamic>> addCoParent(
String pivotUserId, {
required Map<String, dynamic> body,
}) async {
final id = pivotUserId.trim();
if (id.isEmpty) {
throw Exception('Identifiant du parent pivot manquant.');
}
final http.Response response;
try {
response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$id/co-parent'),
headers: await _headers(),
body: jsonEncode(body),
);
} on http.ClientException {
throw Exception(
'Connexion au serveur impossible. Vérifiez votre réseau puis réessayez.',
);
}
if (response.statusCode == 200 || response.statusCode == 201) {
if (response.body.trim().isEmpty) return <String, dynamic>{};
try {
final decoded = jsonDecode(response.body);
if (decoded is Map) {
return Map<String, dynamic>.from(decoded);
}
} catch (_) {}
return <String, dynamic>{};
}
final message = _extractErrorMessage(
response.body,
'Erreur ajout co-parent',
);
if (response.statusCode == 409) {
throw Exception(
message.isNotEmpty ? message : 'Conflit : e-mail déjà utilisé.',
);
}
if (response.statusCode == 400) {
throw Exception(
message.isNotEmpty ? message : 'Données invalides (400).',
);
}
throw Exception(message);
}
/// Création dossier famille actif côté staff (#129).
/// `POST /parents/dossier` — body aligné sur register parent complet.
/// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur, par parent créé).
+22 -165
View File
@@ -23,9 +23,9 @@ import 'package:p_tits_pas/widgets/admin/validation_valider_confirm_dialog.dart'
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart';
enum AmDossierWizardMode { review, create, edit }
enum AmDossierWizardMode { review, create }
/// Wizard dossier AM — [review] (#107), [create] (#156), [edit] (#135).
/// Wizard dossier AM — modes [review] (validation pending) et [create] (staff #156).
class AmDossierWizard extends StatefulWidget {
final AmDossierWizardMode mode;
final DossierAM? dossier;
@@ -74,25 +74,7 @@ class AmDossierWizard extends StatefulWidget {
);
}
factory AmDossierWizard.edit({
Key? key,
required DossierAM dossier,
required VoidCallback onClose,
required VoidCallback onSuccess,
void Function(int step, int total)? onStepChanged,
}) {
return AmDossierWizard._(
key: key,
mode: AmDossierWizardMode.edit,
dossier: dossier,
onClose: onClose,
onSuccess: onSuccess,
onStepChanged: onStepChanged,
);
}
bool get isCreate => mode == AmDossierWizardMode.create;
bool get isEdit => mode == AmDossierWizardMode.edit;
/// Hauteur corps modale AM — dérivée de [ValidationFormMetrics] (4 lignes).
static double get shellBodyHeight =>
@@ -136,11 +118,9 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
String? _photoFilename;
bool get _isCreate => widget.isCreate;
bool get _isEdit => widget.isEdit;
bool get _isEditable => _isCreate || _isEdit;
DossierAM get _dossier => widget.dossier!;
bool get _isEnAttente =>
!_isCreate && !_isEdit && _dossier.user.statut == 'en_attente';
!_isCreate && _dossier.user.statut == 'en_attente';
static String _v(String? s) =>
(s != null && s.trim().isNotEmpty) ? s.trim() : '';
@@ -171,33 +151,9 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
_capaciteCtrl = TextEditingController(text: '1');
_placesCtrl = TextEditingController(text: '1');
_presentationCtrl = TextEditingController();
if (_isEdit) {
_prefillFromDossier();
}
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
}
void _prefillFromDossier() {
final u = _dossier.user;
_nomCtrl.text = (u.nom ?? '').trim();
_prenomCtrl.text = (u.prenom ?? '').trim();
_telCtrl.text = (u.telephone ?? '').trim();
_emailCtrl.text = u.email.trim();
_adresseCtrl.text = (u.adresse ?? '').trim();
_cpCtrl.text = (u.codePostal ?? '').trim();
_villeCtrl.text = (u.ville ?? '').trim();
_nirCtrl.text = (_dossier.nir ?? '').trim();
_dateNaissanceCtrl.text = formatIsoDateFrInput(u.dateNaissance);
_lieuNaissanceVilleCtrl.text = (u.lieuNaissanceVille ?? '').trim();
final pays = (u.lieuNaissancePays ?? '').trim();
_lieuNaissancePaysCtrl.text = pays.isEmpty ? 'France' : pays;
_agrementCtrl.text = (_dossier.numeroAgrement ?? '').trim();
_dateAgrementCtrl.text = formatIsoDateFrInput(_dossier.dateAgrement);
_capaciteCtrl.text = '${_dossier.nbMaxEnfants ?? 1}';
_placesCtrl.text = '${_dossier.placesDisponibles ?? 0}';
_presentationCtrl.text = (_dossier.presentation ?? '').trim();
}
@override
void dispose() {
_nomCtrl.dispose();
@@ -417,8 +373,8 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
return null;
}
String? _validateStep1({required bool requirePhoto}) {
if (requirePhoto && (_photoBytes == null || _photoBytes!.isEmpty)) {
String? _validateStep1() {
if (_photoBytes == null || _photoBytes!.isEmpty) {
return 'Une photo de profil est requise.';
}
final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text);
@@ -451,12 +407,12 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
}
String? _validateCurrentStep() {
if (!_isEditable) return null;
if (!_isCreate) return null;
switch (_step) {
case 0:
return _validateStep0();
case 1:
return _validateStep1(requirePhoto: _isCreate);
return _validateStep1();
default:
return null;
}
@@ -555,7 +511,7 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
);
return;
}
final err1 = _validateStep1(requirePhoto: true);
final err1 = _validateStep1();
if (err1 != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(err1), backgroundColor: Colors.red.shade700),
@@ -598,94 +554,6 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
}
}
Future<void> _saveEdit() async {
if (_submitting || !_isEdit) return;
final err0 = _validateStep0();
if (err0 != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(err0), backgroundColor: Colors.red.shade700),
);
return;
}
final err1 = _validateStep1(requirePhoto: false);
if (err1 != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(err1), backgroundColor: Colors.red.shade700),
);
return;
}
final amId = _dossier.user.id.trim();
if (amId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Identifiant AM manquant.'),
backgroundColor: Colors.red.shade700,
),
);
return;
}
final ok = await showValidationValiderConfirmDialog(
context,
body: 'Enregistrer les modifications de la fiche assistante maternelle ?',
);
if (!mounted || !ok) return;
final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text);
final agrementIso = parseFrDateToIso(_dateAgrementCtrl.text);
final capa = int.tryParse(_capaciteCtrl.text.trim());
final places = int.tryParse(_placesCtrl.text.trim());
final biography = _presentationCtrl.text.trim();
setState(() => _submitting = true);
try {
await UserService.updateAmFiche(
amUserId: amId,
body: {
'nom': formatPersonNameCase(_nomCtrl.text),
'prenom': formatPersonNameCase(_prenomCtrl.text),
'email': normalizeEmailText(_emailCtrl.text),
'telephone': normalizePhone(_telCtrl.text),
'adresse': _adresseCtrl.text.trim(),
'ville': formatPersonNameCase(_villeCtrl.text),
'code_postal': _cpCtrl.text.trim(),
'approval_number': _agrementCtrl.text.trim(),
'nir': nirToRaw(_nirCtrl.text),
if (birthIso != null) 'date_naissance': birthIso,
'lieu_naissance_ville':
formatPersonNameCase(_lieuNaissanceVilleCtrl.text),
'lieu_naissance_pays':
formatPersonNameCase(_lieuNaissancePaysCtrl.text),
if (agrementIso != null) 'agreement_date': agrementIso,
if (capa != null) 'max_children': capa,
if (places != null) 'places_available': places,
'biography': biography,
},
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Fiche AM enregistrée.'),
duration: Duration(seconds: 4),
),
);
widget.onSuccess();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur'),
backgroundColor: Colors.red.shade700,
),
);
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) {
if (_showRefusForm) {
@@ -719,7 +587,7 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
}
Widget _buildStep0() {
if (_isEditable) {
if (_isCreate) {
return IdentityBlock.editable(
title: 'Identité et coordonnées',
nomController: _nomCtrl,
@@ -752,7 +620,7 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
.clamp(_photoColumnMinWidth, 360.0);
if (photoW > maxPhotoW) photoW = maxPhotoW;
final form = _isEditable
final form = _isCreate
? _buildCreateProFields()
: ValidationDetailSection(
title: 'Dossier professionnel',
@@ -760,25 +628,20 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
rowLayout: _photoProRowLayout,
);
final Widget photo;
if (_isCreate) {
photo = AdminAmPhotoFrame(
imageBytes: _photoBytes,
onTap: _pickPhoto,
onClear: _photoBytes != null ? _clearPhoto : null,
emptyLabel: 'Ajouter une photo',
);
} else if (_isEdit) {
// Édition : photo existante en lecture ; remplacement hors scope PATCH fiche.
photo = _buildPhotoSectionReview(_dossier.user);
} else {
photo = _buildPhotoSectionReview(_dossier.user);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(width: photoW, child: photo),
SizedBox(
width: photoW,
child: _isCreate
? AdminAmPhotoFrame(
imageBytes: _photoBytes,
onTap: _pickPhoto,
onClear: _photoBytes != null ? _clearPhoto : null,
emptyLabel: 'Ajouter une photo',
)
: _buildPhotoSectionReview(_dossier.user),
),
const SizedBox(width: _photoProGap),
Expanded(
child: Align(
@@ -858,7 +721,7 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
}
Widget _buildStep2() {
if (_isEditable) {
if (_isCreate) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -969,12 +832,6 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
onPressed: _submitting ? null : _createAndValidate,
child: Text(_submitting ? 'Envoi...' : 'Créer et valider'),
),
] else if (_isEdit) ...[
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: _submitting ? null : _saveEdit,
child: Text(_submitting ? 'Envoi...' : 'Enregistrer'),
),
] else if (_isEnAttente) ...[
OutlinedButton(
onPressed: _submitting ? null : _refuser,
@@ -85,7 +85,6 @@ class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
context: context,
builder: (context) => ValidationDossierModal(
numeroDossier: num,
openAsEdit: true,
onClose: () => Navigator.of(context).pop(),
onSuccess: () {
Navigator.of(context).pop();
@@ -22,15 +22,10 @@ import 'package:p_tits_pas/widgets/admin/validation_valider_confirm_dialog.dart'
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart';
enum ParentDossierWizardMode { review, create, edit }
enum ParentDossierWizardMode { review, create }
/// Enfant en cours de saisie (création / édition). Contrôleurs propres, à disposer.
/// Enfant en cours de saisie (mode création). Contrôleurs propres, à disposer.
class _CreateChild {
/// Id existant en base (mode edit) — null = nouvel enfant → POST.
String? existingChildId;
String? existingPhotoUrl;
/// Statut dorigine (hors à naître) pour ne pas écraser garde/scolarise.
String? existingStatus;
final TextEditingController prenomCtrl = TextEditingController();
final TextEditingController nomCtrl = TextEditingController();
final TextEditingController dateCtrl = TextEditingController();
@@ -39,8 +34,6 @@ class _CreateChild {
Uint8List? photoBytes;
String? photoFilename;
bool get hasExistingId => (existingChildId ?? '').trim().isNotEmpty;
void dispose() {
prenomCtrl.dispose();
nomCtrl.dispose();
@@ -48,7 +41,8 @@ class _CreateChild {
}
}
/// Wizard dossier famille — [review] (#107), [create] (#129), [edit] (#135).
/// Wizard dossier famille — modes [review] (validation dossier en attente, ticket #107)
/// et [create] (création dossier famille actif par le staff, ticket #129).
class ParentDossierWizard extends StatefulWidget {
final ParentDossierWizardMode mode;
final DossierFamille? dossier;
@@ -97,25 +91,7 @@ class ParentDossierWizard extends StatefulWidget {
);
}
factory ParentDossierWizard.edit({
Key? key,
required DossierFamille dossier,
required VoidCallback onClose,
required VoidCallback onSuccess,
void Function(int step, int total)? onStepChanged,
}) {
return ParentDossierWizard._(
key: key,
mode: ParentDossierWizardMode.edit,
dossier: dossier,
onClose: onClose,
onSuccess: onSuccess,
onStepChanged: onStepChanged,
);
}
bool get isCreate => mode == ParentDossierWizardMode.create;
bool get isEdit => mode == ParentDossierWizardMode.edit;
/// Hauteur corps modale famille — 4 lignes TF + marge pour le bandeau switch.
static double get shellBodyHeight =>
@@ -165,27 +141,16 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
final List<_CreateChild> _children = [];
late final TextEditingController _presentationCtrl;
/// Nb de parents déjà en base au moment de louverture (mode edit).
int _initialParentCount = 0;
/// Enfants existants retirés en edit → DELETE au save.
final List<String> _removedEnfantIds = [];
bool get _isCreate => widget.isCreate;
bool get _isEdit => widget.isEdit;
bool get _isEditable => _isCreate || _isEdit;
DossierFamille get _dossier => widget.dossier!;
bool get _isEnAttente => !_isCreate && !_isEdit && _dossier.isEnAttente;
bool get _isEnAttente => !_isCreate && _dossier.isEnAttente;
String? get _firstParentId {
if (_isCreate) return null;
return _dossier.parents.isNotEmpty ? _dossier.parents.first.id : null;
}
/// Co-parent déjà présent au chargement (edit) — pas un ajout via POST.
bool get _hadExistingCoParent => _isEdit && _initialParentCount >= 2;
static String _v(String? s) =>
(s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini';
@@ -219,81 +184,11 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
_presentationCtrl = TextEditingController();
if (_isCreate) {
_children.add(_CreateChild());
} else if (_isEdit) {
_prefillFromDossier();
}
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
}
void _prefillFromDossier() {
final parents = _dossier.parents;
_initialParentCount = parents.length;
if (parents.isNotEmpty) {
final p1 = parents.first;
_p1NomCtrl.text = (p1.nom ?? '').trim();
_p1PrenomCtrl.text = (p1.prenom ?? '').trim();
_p1TelCtrl.text = (p1.telephone ?? '').trim();
_p1EmailCtrl.text = (p1.email).trim();
_p1AdresseCtrl.text = (p1.adresse ?? '').trim();
_p1CpCtrl.text = (p1.codePostal ?? '').trim();
_p1VilleCtrl.text = (p1.ville ?? '').trim();
if (parents.length >= 2) {
_hasCoParent = true;
final p2 = parents[1];
_p2NomCtrl.text = (p2.nom ?? '').trim();
_p2PrenomCtrl.text = (p2.prenom ?? '').trim();
_p2TelCtrl.text = (p2.telephone ?? '').trim();
_p2EmailCtrl.text = (p2.email).trim();
_p2AdresseCtrl.text = (p2.adresse ?? '').trim();
_p2CpCtrl.text = (p2.codePostal ?? '').trim();
_p2VilleCtrl.text = (p2.ville ?? '').trim();
} else {
_hasCoParent = false;
}
}
_prefillChildrenFromDossier();
}
void _prefillChildrenFromDossier() {
for (final c in _children) {
c.dispose();
}
_children.clear();
_removedEnfantIds.clear();
final enfants = _dossier.enfants;
if (enfants.isEmpty) {
_children.add(_CreateChild());
return;
}
for (final e in enfants) {
final status = (e.status ?? '').trim().toLowerCase();
final id = e.id.trim();
final child = _CreateChild()
..existingChildId = id.isEmpty ? null : id
..existingPhotoUrl = e.photoUrl
..existingStatus = status.isEmpty ? null : status
..isUnborn = status == 'a_naitre';
child.prenomCtrl.text = (e.firstName ?? '').trim();
child.nomCtrl.text = (e.lastName ?? '').trim();
final g = (e.gender ?? '').trim();
final gUp = g.toUpperCase();
if (gUp == 'H' || gUp == 'F') {
child.genre = gUp;
} else if (g == 'Autre' || child.isUnborn) {
child.genre = child.isUnborn ? 'Autre' : g;
}
final dateSrc = child.isUnborn ? e.dueDate : e.birthDate;
child.dateCtrl.text = formatIsoDateFrInput(dateSrc);
_children.add(child);
}
}
@override
void dispose() {
_enfantsScrollController.removeListener(_syncEnfantsScrollFades);
@@ -396,7 +291,7 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
}
Widget _buildStep0() {
if (_isEditable) {
if (_isCreate) {
return IdentityBlock.editable(
title: 'Parent principal',
nomController: _p1NomCtrl,
@@ -415,15 +310,13 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
}
Widget _buildStep1() {
if (_isEditable) {
return _buildCoParentStepEditable(
allowToggle: !_hadExistingCoParent,
);
if (_isCreate) {
return _buildCoParentStepCreate();
}
return _buildParent2Step();
}
Widget _buildCoParentStepEditable({required bool allowToggle}) {
Widget _buildCoParentStepCreate() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -437,21 +330,19 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
color: Colors.black87,
),
),
if (allowToggle) ...[
const Spacer(),
const Text(
'Ajouter un co-parent',
style: TextStyle(fontSize: 14, color: Colors.black87),
const Spacer(),
const Text(
'Ajouter un co-parent',
style: TextStyle(fontSize: 14, color: Colors.black87),
),
Transform.scale(
scale: 0.75,
child: Switch(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
value: _hasCoParent,
onChanged: (v) => setState(() => _hasCoParent = v),
),
Transform.scale(
scale: 0.75,
child: Switch(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
value: _hasCoParent,
onChanged: (v) => setState(() => _hasCoParent = v),
),
),
],
),
],
),
const SizedBox(height: 8),
@@ -648,7 +539,7 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
}
Widget _buildEnfantsStep() {
if (_isCreate || _isEdit) {
if (_isCreate) {
return _buildEnfantsStepCreate();
}
final enfants = _dossier.enfants;
@@ -727,10 +618,6 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
if (_children.length <= 1) return;
setState(() {
final removed = _children.removeAt(index);
final existingId = (removed.existingChildId ?? '').trim();
if (existingId.isNotEmpty) {
_removedEnfantIds.add(existingId);
}
removed.dispose();
});
}
@@ -819,8 +706,7 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
// Réserve langle haut-droit pour la croix de suppression.
padding: EdgeInsets.fromLTRB(12, 12, canRemove ? 36 : 14, 8),
padding: const EdgeInsets.fromLTRB(12, 12, 14, 8),
child: ValidationLabeledField(
label: 'Prénom',
field: ValidationEditableField(
@@ -861,9 +747,6 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
width: pw,
height: ph,
child: AdminAmPhotoFrame(
photoUrl: child.photoBytes == null
? child.existingPhotoUrl
: null,
imageBytes: child.photoBytes,
onTap: () => _pickChildPhoto(index),
onClear: child.photoBytes != null
@@ -950,28 +833,19 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
),
if (canRemove)
Positioned(
top: 6,
right: 6,
top: 4,
right: 4,
child: Material(
color: Colors.white,
elevation: 1,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
customBorder: const CircleBorder(),
onTap: () => _removeChild(index),
child: Tooltip(
message: 'Retirer cet enfant',
child: SizedBox(
width: 22,
height: 22,
child: Icon(
Icons.close,
size: 14,
color: Colors.grey.shade800,
),
),
),
color: Colors.transparent,
child: IconButton(
tooltip: 'Retirer cet enfant',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints:
const BoxConstraints(minWidth: 32, minHeight: 32),
icon: Icon(Icons.close,
size: 18, color: Colors.grey.shade700),
onPressed: () => _removeChild(index),
),
),
),
@@ -1390,18 +1264,6 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
}
String? _validateCurrentStep() {
if (_isEdit) {
switch (_step) {
case 0:
return _validateP1();
case 1:
return _validateP2();
case 2:
return _validateEnfants();
default:
return null;
}
}
if (!_isCreate) return null;
switch (_step) {
case 0:
@@ -1578,299 +1440,6 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
}
}
Map<String, dynamic> _parentFicheBody({
required TextEditingController nom,
required TextEditingController prenom,
required TextEditingController email,
required TextEditingController tel,
required TextEditingController adresse,
required TextEditingController cp,
required TextEditingController ville,
}) {
return {
'nom': formatPersonNameCase(nom.text),
'prenom': formatPersonNameCase(prenom.text),
'email': normalizeEmailText(email.text),
'telephone': normalizePhone(tel.text),
'adresse': adresse.text.trim(),
'ville': formatPersonNameCase(ville.text),
'code_postal': cp.text.trim(),
};
}
Future<void> _saveEdit() async {
if (_submitting || !_isEdit) return;
final err0 = _validateP1();
if (err0 != null) {
_showError(err0);
return;
}
final err1 = _validateP2();
if (err1 != null) {
_showError(err1);
return;
}
final err2 = _validateEnfants();
if (err2 != null) {
_showError(err2);
return;
}
final pivotId = (_firstParentId ?? '').trim();
if (pivotId.isEmpty) {
_showError('Identifiant du parent principal manquant.');
return;
}
final addingCoParent = _hasCoParent && !_hadExistingCoParent;
final ok = await showValidationValiderConfirmDialog(
context,
body: addingCoParent
? 'Enregistrer le dossier et ajouter le co-parent ? Un e-mail de création de mot de passe lui sera envoyé.'
: 'Enregistrer les modifications du dossier famille ?',
);
if (!mounted || !ok) return;
setState(() => _submitting = true);
try {
await UserService.updateParentFiche(
parentUserId: pivotId,
body: _parentFicheBody(
nom: _p1NomCtrl,
prenom: _p1PrenomCtrl,
email: _p1EmailCtrl,
tel: _p1TelCtrl,
adresse: _p1AdresseCtrl,
cp: _p1CpCtrl,
ville: _p1VilleCtrl,
),
);
if (_hadExistingCoParent) {
final coId = _dossier.parents[1].id.trim();
if (coId.isEmpty) {
throw Exception('Identifiant du co-parent manquant.');
}
await UserService.updateParentFiche(
parentUserId: coId,
body: _parentFicheBody(
nom: _p2NomCtrl,
prenom: _p2PrenomCtrl,
email: _p2EmailCtrl,
tel: _p2TelCtrl,
adresse: _p2AdresseCtrl,
cp: _p2CpCtrl,
ville: _p2VilleCtrl,
),
);
} else if (addingCoParent) {
if (_sameAddress) _copyP1AddressToP2();
final body = <String, dynamic>{
'email': normalizeEmailText(_p2EmailCtrl.text),
'prenom': formatPersonNameCase(_p2PrenomCtrl.text),
'nom': formatPersonNameCase(_p2NomCtrl.text),
'telephone': normalizePhone(_p2TelCtrl.text),
'meme_adresse': _sameAddress,
};
if (!_sameAddress) {
body['adresse'] = _p2AdresseCtrl.text.trim();
body['code_postal'] = _p2CpCtrl.text.trim();
body['ville'] = formatPersonNameCase(_p2VilleCtrl.text);
}
await UserService.addCoParent(pivotId, body: body);
}
await _saveEnfantsEdit(pivotId);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
addingCoParent
? 'Dossier enregistré. Co-parent ajouté.'
: 'Dossier enregistré.',
),
duration: const Duration(seconds: 4),
),
);
widget.onSuccess();
} catch (e) {
if (!mounted) return;
_showError(
e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur',
);
} finally {
if (mounted) setState(() => _submitting = false);
}
}
Map<String, dynamic> _enfantStaffBody(_CreateChild c) {
final prenom = formatPersonNameCase(c.prenomCtrl.text);
final nomRaw = c.nomCtrl.text.trim();
final nom = nomRaw.isNotEmpty
? formatPersonNameCase(nomRaw)
: formatPersonNameCase(_p1NomCtrl.text);
final dateIso = parseFrDateToIso(c.dateCtrl.text);
final existingStatus = (c.existingStatus ?? '').trim().toLowerCase();
final status = c.isUnborn
? 'a_naitre'
: (existingStatus.isNotEmpty && existingStatus != 'a_naitre'
? existingStatus
: 'sans_garde');
final gender = c.genre ?? 'H';
final map = <String, dynamic>{
'status': status,
'gender': gender,
'consent_photo': true,
'is_multiple': false,
};
if (prenom.length >= 2) map['first_name'] = prenom;
if (nom.length >= 2) map['last_name'] = nom;
if (c.isUnborn) {
if (dateIso != null) map['due_date'] = dateIso;
} else if (dateIso != null) {
map['birth_date'] = dateIso;
}
return map;
}
/// Empreinte identité pour rattacher un brouillon sans id à un enfant du GET.
String _enfantIdentityKey({
required String prenom,
required String nom,
required String? dateIso,
required bool isUnborn,
}) {
final pn = formatPersonNameCase(prenom).toLowerCase().trim();
final nm = formatPersonNameCase(nom).toLowerCase().trim();
final d = _normalizeDateKey(dateIso);
final kind = isUnborn ? 'due' : 'birth';
return '$pn|$nm|$kind|$d';
}
String _normalizeDateKey(String? raw) {
final s = (raw ?? '').trim();
if (s.isEmpty) return '';
final fromFr = parseFrDateToIso(s);
if (fromFr != null) return fromFr;
try {
final dt = DateTime.parse(s);
final y = dt.year.toString().padLeft(4, '0');
final m = dt.month.toString().padLeft(2, '0');
final d = dt.day.toString().padLeft(2, '0');
return '$y-$m-$d';
} catch (_) {
return s;
}
}
String _enfantKeyFromCreate(_CreateChild c) {
final prenom = formatPersonNameCase(c.prenomCtrl.text);
final nomRaw = c.nomCtrl.text.trim();
final nom = nomRaw.isNotEmpty
? formatPersonNameCase(nomRaw)
: formatPersonNameCase(_p1NomCtrl.text);
return _enfantIdentityKey(
prenom: prenom,
nom: nom,
dateIso: parseFrDateToIso(c.dateCtrl.text),
isUnborn: c.isUnborn,
);
}
String _enfantKeyFromDossier(EnfantDossier e) {
final isUnborn = (e.status ?? '').trim().toLowerCase() == 'a_naitre';
final nomRaw = (e.lastName ?? '').trim();
final nom = nomRaw.isNotEmpty ? nomRaw : (_p1NomCtrl.text.trim());
return _enfantIdentityKey(
prenom: e.firstName ?? '',
nom: nom,
dateIso: isUnborn ? e.dueDate : e.birthDate,
isUnborn: isUnborn,
);
}
bool _isBlankChildDraft(_CreateChild c) {
if (c.hasExistingId) return false;
final prenom = c.prenomCtrl.text.trim();
final nom = c.nomCtrl.text.trim();
final date = c.dateCtrl.text.trim();
return prenom.isEmpty && nom.isEmpty && date.isEmpty && c.genre == null;
}
/// Résout lid enfant : tracker edit, sinon match sur le GET dossier.
String? _resolveExistingChildId(
_CreateChild c,
Map<String, String> dossierIdByIdentity,
Set<String> alreadyUsedIds,
) {
final tracked = (c.existingChildId ?? '').trim();
if (tracked.isNotEmpty && !alreadyUsedIds.contains(tracked)) {
return tracked;
}
final key = _enfantKeyFromCreate(c);
final matched = (dossierIdByIdentity[key] ?? '').trim();
if (matched.isNotEmpty && !alreadyUsedIds.contains(matched)) {
return matched;
}
return null;
}
Future<void> _saveEnfantsEdit(String pivotUserId) async {
// Index id connus du GET (ne jamais POST pour ceux-là).
final dossierIdByIdentity = <String, String>{};
for (final e in _dossier.enfants) {
final id = e.id.trim();
if (id.isEmpty) continue;
dossierIdByIdentity[_enfantKeyFromDossier(e)] = id;
}
for (final id in _removedEnfantIds) {
await UserService.deleteEnfant(id);
}
final usedIds = <String>{..._removedEnfantIds};
for (final c in _children) {
if (_isBlankChildDraft(c)) continue;
final existingId = _resolveExistingChildId(
c,
dossierIdByIdentity,
usedIds,
);
final body = _enfantStaffBody(c);
if (existingId != null && existingId.isNotEmpty) {
// Ré-attache lid au modèle (si match identité a récupéré un id perdu).
c.existingChildId = existingId;
final bytes = c.photoBytes;
await UserService.updateEnfant(
enfantId: existingId,
body: body,
photoBytes: (bytes != null && bytes.isNotEmpty) ? bytes : null,
photoFilename: c.photoFilename,
);
usedIds.add(existingId);
continue;
}
// Uniquement les vrais nouveaux enfants (pas did dossier).
final bytes = c.photoBytes;
final created = await UserService.createEnfant(
parentUserId: pivotUserId,
body: body,
photoBytes: (bytes != null && bytes.isNotEmpty) ? bytes : null,
photoFilename: c.photoFilename,
);
final newId = created.id.trim();
if (newId.isNotEmpty) {
c.existingChildId = newId;
usedIds.add(newId);
}
}
}
Widget _buildNavigation() {
if (_step == 3) {
return Row(
@@ -1894,12 +1463,6 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
onPressed: _submitting ? null : _createAndValidate,
child: Text(_submitting ? 'Envoi...' : 'Créer et valider'),
),
] else if (_isEdit) ...[
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: _submitting ? null : _saveEdit,
child: Text(_submitting ? 'Envoi...' : 'Enregistrer'),
),
] else if (_isEnAttente && _firstParentId != null) ...[
OutlinedButton(
onPressed: _submitting ? null : _refuser,
@@ -2,25 +2,20 @@ import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart';
import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart';
import 'package:p_tits_pas/widgets/admin/validation_am_wizard.dart';
import 'package:p_tits_pas/widgets/admin/validation_family_wizard.dart';
/// Modale (dialog) : charge le dossier par numéro puis affiche le wizard AM ou Famille.
/// Ticket #107 / #119 (review), #135 (`openAsEdit`).
/// Modale (dialog) : charge le dossier par numéro puis affiche le wizard AM ou Famille. Ticket #107, #119.
class ValidationDossierModal extends StatefulWidget {
final String numeroDossier;
final VoidCallback onClose;
final VoidCallback? onSuccess;
/// Liste Dossiers actifs → mode edit (#135). Pending reste en review (défaut).
final bool openAsEdit;
const ValidationDossierModal({
super.key,
required this.numeroDossier,
required this.onClose,
this.onSuccess,
this.openAsEdit = false,
});
@override
@@ -82,12 +77,12 @@ class _ValidationDossierModalState extends State<ValidationDossierModal> {
/// Largeur modale = 1,5 × 620.
static const double _modalWidth = 930; // 620 * 1.5
static const double _familyBodyHeight = 435;
double get _bodyHeight {
final d = _dossier;
if (d != null && d.isAm) return AmDossierWizard.shellBodyHeight;
// Aligné create (#129) / edit (#135) — évite overflow IdentityBlock (8px).
return ParentDossierWizard.shellBodyHeight;
return _familyBodyHeight;
}
@override
@@ -167,14 +162,6 @@ class _ValidationDossierModalState extends State<ValidationDossierModal> {
}
final d = _dossier!;
if (d.isAm) {
if (widget.openAsEdit) {
return AmDossierWizard.edit(
dossier: d.asAm,
onClose: widget.onClose,
onSuccess: _onSuccess,
onStepChanged: _onStepChanged,
);
}
return ValidationAmWizard(
dossier: d.asAm,
onClose: widget.onClose,
@@ -182,14 +169,6 @@ class _ValidationDossierModalState extends State<ValidationDossierModal> {
onStepChanged: _onStepChanged,
);
}
if (widget.openAsEdit) {
return ParentDossierWizard.edit(
dossier: d.asFamily,
onClose: widget.onClose,
onSuccess: _onSuccess,
onStepChanged: _onStepChanged,
);
}
return ValidationFamilyWizard(
dossier: d.asFamily,
onClose: widget.onClose,