Compare commits

..
Author SHA1 Message Date
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
41 changed files with 458 additions and 3674 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,85 +0,0 @@
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';
describe('DossiersController', () => {
let controller: DossiersController;
const dossiersServiceMock = {
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 },
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<DossiersController>(DossiersController);
jest.clearAllMocks();
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('list delegates to dossiersService.listDossiers with q', async () => {
dossiersServiceMock.listDossiers.mockResolvedValue([
{
type: 'famille',
numero_dossier: '2026-000043',
libelle: 'Claire MARTIN',
emails: ['claire@test.fr'],
user_ids: ['u1'],
statut: StatutUtilisateurType.ACTIF,
a_valider: false,
date_reference: null,
},
]);
const res = await controller.list('martin');
expect(dossiersServiceMock.listDossiers).toHaveBeenCalledWith('martin');
expect(res).toHaveLength(1);
expect(res[0].numero_dossier).toBe('2026-000043');
});
it('getDossier delegates to getDossierByNumero', async () => {
dossiersServiceMock.getDossierByNumero.mockResolvedValue({
type: 'family',
dossier: { numero_dossier: '2026-000001' },
});
const res = await controller.getDossier('2026-000001');
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,58 +1,17 @@
import {
Controller,
Delete,
Get,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiQuery,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Roles } from 'src/common/decorators/roles.decorator';
import { RoleType, 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';
@ApiTags('Dossiers')
@ApiBearerAuth('access-token')
@Controller('dossiers')
@UseGuards(AuthGuard, RolesGuard)
export class DossiersController {
constructor(
private readonly dossiersService: DossiersService,
private readonly suppressionService: SuppressionService,
) {}
@Get()
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({
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.',
})
@ApiQuery({
name: 'q',
required: false,
description: 'Recherche libre : n° dossier, libellé, email…',
})
@ApiResponse({ status: 200, type: [DossierListItemDto] })
@ApiResponse({ status: 403, description: 'Accès refusé' })
list(@Query('q') q?: string): Promise<DossierListItemDto[]> {
return this.dossiersService.listDossiers(q);
}
constructor(private readonly dossiersService: DossiersService) {}
@Get(':numeroDossier')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@@ -64,21 +23,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) => ({
@@ -1,148 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
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', () => {
let service: DossiersService;
const parentsQb = {
innerJoinAndSelect: jest.fn().mockReturnThis(),
leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn(),
};
const amQb = {
innerJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn(),
};
const parentsRepo = {
createQueryBuilder: jest.fn(() => parentsQb),
findOne: jest.fn(),
};
const amRepo = {
createQueryBuilder: jest.fn(() => amQb),
findOne: jest.fn(),
};
const parentsService = {
getDossierFamilleByNumero: jest.fn(),
};
const suppressionService = {
countEnfantsForNumero: jest.fn().mockResolvedValue(1),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
DossiersService,
{ provide: getRepositoryToken(Parents), useValue: parentsRepo },
{ provide: getRepositoryToken(AssistanteMaternelle), useValue: amRepo },
{ provide: ParentsService, useValue: parentsService },
{ provide: SuppressionService, useValue: suppressionService },
],
}).compile();
service = module.get(DossiersService);
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 () => {
parentsQb.getMany.mockResolvedValue([
{
user_id: 'p1',
numero_dossier: '2026-000010',
user: {
id: 'p1',
email: 'claire@test.fr',
prenom: 'Claire',
nom: 'Martin',
statut: StatutUtilisateurType.ACTIF,
cree_le: new Date('2026-01-01'),
},
co_parent: {
id: 'p2',
email: 'thomas@test.fr',
prenom: 'Thomas',
nom: 'Martin',
statut: StatutUtilisateurType.ACTIF,
cree_le: new Date('2026-01-02'),
},
},
{
user_id: 'p3',
numero_dossier: '2026-000020',
user: {
id: 'p3',
email: 'pending@test.fr',
prenom: 'Paul',
nom: 'Pending',
statut: StatutUtilisateurType.EN_ATTENTE,
cree_le: new Date('2026-02-01'),
},
co_parent: undefined,
},
]);
amQb.getMany.mockResolvedValue([
{
user_id: 'am1',
numero_dossier: '2026-000015',
user: {
id: 'am1',
email: 'am@test.fr',
prenom: 'Marie',
nom: 'Dupont',
statut: StatutUtilisateurType.ACTIF,
cree_le: new Date('2026-01-15'),
},
},
]);
const list = await service.listDossiers();
expect(list).toHaveLength(3);
expect(list[0].a_valider).toBe(true);
expect(list[0].type).toBe('famille');
expect(list[0].numero_dossier).toBe('2026-000020');
const famille = list.find((i) => i.numero_dossier === '2026-000010')!;
expect(famille.type).toBe('famille');
expect(famille.user_ids).toEqual(expect.arrayContaining(['p1', 'p2']));
expect(famille.emails).toHaveLength(2);
expect(famille.libelle).toContain('MARTIN');
const am = list.find((i) => i.type === 'assistante_maternelle')!;
expect(am.numero_dossier).toBe('2026-000015');
expect(am.libelle).toContain('Marie');
});
it('filters with q', async () => {
parentsQb.getMany.mockResolvedValue([]);
amQb.getMany.mockResolvedValue([
{
user_id: 'am1',
numero_dossier: '2026-000015',
user: {
id: 'am1',
email: 'am@test.fr',
prenom: 'Marie',
nom: 'Dupont',
statut: StatutUtilisateurType.ACTIF,
cree_le: new Date('2026-01-15'),
},
},
]);
const hit = await service.listDossiers('dupont');
expect(hit).toHaveLength(1);
const miss = await service.listDossiers('zzz');
expect(miss).toHaveLength(0);
});
});
+1 -169
View File
@@ -3,15 +3,12 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { 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).
* Endpoint unifié GET /dossiers/:numeroDossier AM ou famille. Ticket #119.
*/
@Injectable()
export class DossiersService {
@@ -21,173 +18,8 @@ export class DossiersService {
@InjectRepository(AssistanteMaternelle)
private readonly amRepository: Repository<AssistanteMaternelle>,
private readonly parentsService: ParentsService,
private readonly suppressionService: SuppressionService,
) {}
/**
* Liste unifiée tous dossiers (familles + AM) ayant un numero_dossier.
* Ticket #153 — optionnel `q` filtre n° / nom / prénom / email (côté serveur).
*/
async listDossiers(q?: string): Promise<DossierListItemDto[]> {
const items: DossierListItemDto[] = [
...(await this.listFamilleItems()),
...(await this.listAmItems()),
];
const needle = (q ?? '').trim().toLowerCase();
const filtered = needle
? items.filter((item) => this.matchesQuery(item, needle))
: items;
filtered.sort((a, b) => {
// À valider d'abord, puis n° dossier décroissant
if (a.a_valider !== b.a_valider) return a.a_valider ? -1 : 1;
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;
}
private async listFamilleItems(): Promise<DossierListItemDto[]> {
const parents = await this.parentsRepository
.createQueryBuilder('p')
.innerJoinAndSelect('p.user', 'u')
.leftJoinAndSelect('p.co_parent', 'cp')
.where('p.numero_dossier IS NOT NULL')
.andWhere("TRIM(p.numero_dossier) <> ''")
.getMany();
const byNum = new Map<string, Parents[]>();
for (const p of parents) {
const num = (p.numero_dossier ?? '').trim();
if (!num) continue;
const group = byNum.get(num) ?? [];
group.push(p);
byNum.set(num, group);
}
const items: DossierListItemDto[] = [];
for (const [numero_dossier, group] of byNum) {
const usersMap = new Map<string, Users>();
for (const p of group) {
if (p.user) usersMap.set(p.user.id, p.user);
if (p.co_parent) usersMap.set(p.co_parent.id, p.co_parent);
}
const users = [...usersMap.values()].sort((a, b) => {
const an = `${a.nom ?? ''} ${a.prenom ?? ''}`.toLowerCase();
const bn = `${b.nom ?? ''} ${b.prenom ?? ''}`.toLowerCase();
return an.localeCompare(bn, 'fr') || a.id.localeCompare(b.id);
});
if (users.length === 0) continue;
const names = users.map((u) => this.formatPersonName(u)).filter(Boolean);
const libelle =
names.length === 0
? `Dossier ${numero_dossier}`
: names.length === 1
? names[0]
: names.join(' & ');
const emails = users.map((u) => u.email).filter(Boolean);
const user_ids = users.map((u) => u.id);
const a_valider = users.some((u) => u.statut === StatutUtilisateurType.EN_ATTENTE);
const statut = a_valider
? StatutUtilisateurType.EN_ATTENTE
: (users[0].statut ?? StatutUtilisateurType.ACTIF);
const date_reference = this.minCreeLeIso(users);
items.push({
type: 'famille',
numero_dossier,
libelle,
emails,
user_ids,
statut,
a_valider,
date_reference,
});
}
return items;
}
private async listAmItems(): Promise<DossierListItemDto[]> {
const ams = await this.amRepository
.createQueryBuilder('am')
.innerJoinAndSelect('am.user', 'u')
.where('am.numero_dossier IS NOT NULL')
.andWhere("TRIM(am.numero_dossier) <> ''")
.getMany();
const byNum = new Map<string, AssistanteMaternelle>();
for (const am of ams) {
const num = (am.numero_dossier ?? '').trim();
if (!num || !am.user) continue;
// Un n° = une AM ; garder le premier
if (!byNum.has(num)) byNum.set(num, am);
}
const items: DossierListItemDto[] = [];
for (const [numero_dossier, am] of byNum) {
const u = am.user!;
const libelle = this.formatPersonName(u) || `AM ${numero_dossier}`;
const a_valider = u.statut === StatutUtilisateurType.EN_ATTENTE;
items.push({
type: 'assistante_maternelle',
numero_dossier,
libelle,
emails: u.email ? [u.email] : [],
user_ids: [u.id],
statut: u.statut ?? StatutUtilisateurType.ACTIF,
a_valider,
date_reference: this.minCreeLeIso([u]),
});
}
return items;
}
private matchesQuery(item: DossierListItemDto, needle: string): boolean {
const hay = [
item.numero_dossier,
item.libelle,
...item.emails,
item.statut,
item.type,
]
.join(' ')
.toLowerCase();
return hay.includes(needle);
}
private formatPersonName(u: Users): string {
const prenom = (u.prenom ?? '').trim();
const nom = (u.nom ?? '').trim();
const nomFmt = nom ? nom.toUpperCase() : '';
return [prenom, nomFmt].filter(Boolean).join(' ');
}
private minCreeLeIso(users: Users[]): string | null {
let min: Date | null = null;
for (const u of users) {
const d = u.cree_le;
if (!d) continue;
const date = d instanceof Date ? d : new Date(d);
if (Number.isNaN(date.getTime())) continue;
if (!min || date < min) min = date;
}
return min ? min.toISOString() : null;
}
async getDossierByNumero(numeroDossier: string): Promise<DossierUnifieDto> {
const num = numeroDossier?.trim();
if (!num) {
@@ -1,58 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Ligne de liste GET /dossiers (#153). */
export class DossierListItemDto {
@ApiProperty({
enum: ['famille', 'assistante_maternelle'],
description: 'Type de dossier',
})
type: 'famille' | 'assistante_maternelle';
@ApiProperty({ example: '2026-000043' })
numero_dossier: string;
@ApiProperty({
example: 'Claire MARTIN & Thomas MARTIN',
description: 'Libellé affiché (noms)',
})
libelle: string;
@ApiProperty({
type: [String],
example: ['claire@example.com', 'thomas@example.com'],
})
emails: string[];
@ApiProperty({
type: [String],
format: 'uuid',
description: 'IDs utilisateur liés au dossier (parents du foyer ou AM)',
})
user_ids: string[];
@ApiProperty({
enum: StatutUtilisateurType,
description:
'Statut agrégé : en_attente si au moins un user en_attente, sinon statut du premier',
})
statut: StatutUtilisateurType;
@ApiProperty({
description: 'True si le dossier est en attente de validation (section haute UI)',
})
a_valider: boolean;
@ApiPropertyOptional({
nullable: true,
example: '2026-01-12T10:00:00.000Z',
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);
@@ -7,12 +7,6 @@ export class ParentPendingSummaryDto {
@ApiProperty()
email: string;
@ApiPropertyOptional({ nullable: true })
nom?: string | null;
@ApiPropertyOptional({ nullable: true })
prenom?: string | null;
@ApiPropertyOptional({ nullable: true })
telephone?: string | null;
@@ -24,10 +18,7 @@ export class ParentPendingSummaryDto {
}
export class PendingFamilyDto {
@ApiProperty({
example: 'MARTIN Claire - MARTIN Thomas',
description: 'Libellé affiché : NOM Prénom (séparés par « - » si co-parent)',
})
@ApiProperty({ example: 'Famille Dupont', description: 'Libellé affiché pour la famille' })
libelle: string;
@ApiProperty({
@@ -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' })
+2 -22
View File
@@ -251,15 +251,7 @@ export class ParentsService {
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
)
SELECT
string_agg(
UPPER(TRIM(u.nom))
|| CASE
WHEN u.prenom IS NOT NULL AND TRIM(u.prenom) <> ''
THEN ' ' || INITCAP(TRIM(u.prenom))
ELSE ''
END,
' - ' ORDER BY u.nom, u.prenom, u.id
) AS libelle,
'Famille ' || string_agg(u.nom, ' - ' ORDER BY u.nom, u.prenom) AS libelle,
array_agg(p.id_utilisateur ORDER BY u.nom, u.prenom, u.id) AS "parentIds",
(array_agg(p.numero_dossier))[1] AS numero_dossier,
MIN(u.cree_le) AS date_soumission,
@@ -275,8 +267,6 @@ export class ParentsService {
json_build_object(
'id', u.id::text,
'email', u.email,
'nom', u.nom,
'prenom', u.prenom,
'telephone', u.telephone,
'code_postal', u.code_postal,
'ville', u.ville
@@ -327,21 +317,11 @@ export class ParentsService {
return [];
}
private normalizeParents(parents: unknown): {
id: string;
email: string;
nom: string | null;
prenom: string | null;
telephone: string | null;
code_postal: string | null;
ville: string | null;
}[] {
private normalizeParents(parents: unknown): { id: string; email: string; telephone: string | null; code_postal: string | null; ville: string | null }[] {
if (Array.isArray(parents)) {
return parents.map((p: any) => ({
id: String(p?.id ?? ''),
email: String(p?.email ?? ''),
nom: p?.nom != null ? String(p.nom) : null,
prenom: p?.prenom != null ? String(p.prenom) : null,
telephone: p?.telephone != null ? String(p.telephone) : null,
code_postal: p?.code_postal != null ? String(p.code_postal) : null,
ville: p?.ville != null ? String(p.ville) : null,
@@ -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`
@@ -1,77 +0,0 @@
# Mini-spec API — GET /dossiers (#153)
Contrat pour le **plan front** (onglet permanent Dossiers).
## Endpoint
| | |
|--|--|
| **Méthode** | `GET` |
| **URL** | `{base}/api/v1/dossiers` |
| **Auth** | Bearer JWT |
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
| **Query** | `q` (optionnel) — recherche n° / libellé / email |
Complète `GET /dossiers/:numeroDossier` (#119) déjà existant.
---
## Réponse 200
Tableau de lignes (1 entrée = 1 `numero_dossier`) :
```json
[
{
"type": "famille",
"numero_dossier": "2026-000043",
"libelle": "Claire MARTIN & Thomas MARTIN",
"emails": ["claire@test.fr", "thomas@test.fr"],
"user_ids": ["uuid-pivot", "uuid-co"],
"statut": "actif",
"a_valider": false,
"date_reference": "2026-01-12T10:00:00.000Z"
},
{
"type": "assistante_maternelle",
"numero_dossier": "2026-000042",
"libelle": "Marie DUPONT",
"emails": ["marie@test.fr"],
"user_ids": ["uuid-am"],
"statut": "en_attente",
"a_valider": true,
"date_reference": "2026-02-01T08:00:00.000Z"
}
]
```
### Champs
| Champ | Notes |
|-------|--------|
| `type` | `famille` \| `assistante_maternelle` |
| `numero_dossier` | Clé dunité |
| `libelle` | Noms formatés (foyer : `A & B`) |
| `emails` / `user_ids` | Membres du foyer ou AM |
| `statut` | Agrégé : `en_attente` si au moins un user pending |
| `a_valider` | `true` si pending → section haute UI |
| `date_reference` | `MIN(cree_le)` des users |
**Tri** : `a_valider` dabord, puis `numero_dossier` décroissant.
**Famille** : dédupliquée par `numero_dossier` (pivot + co-parent = 1 ligne).
---
## Front
- `UserService.getDossiers({ q? })` → cet endpoint
- Section haute : filtrer `a_valider == true` **ou** continuer pending APIs existantes
- Section basse : liste complète (ou hors pending selon règle UX)
- Clic → `GET /dossiers/:numero` (détail) / validation review
Composition client `getParents`+`getAM` **plus nécessaire** si cet endpoint est déployé.
## Branche
`feature/153-onglet-dossiers`
-167
View File
@@ -1,167 +0,0 @@
# Mini-spec front — Onglet permanent « Dossiers » (#153)
Branche Git (front + back) : `feature/153-onglet-dossiers`
Ticket Gitea : **#153** (ticket normal, plus epic)
> Suite prévue : **#135** = au clic, mode **édition** wizard + ajout 2ᵉ parent.
> **#153** = onglet + listes + navigation / validation pending. **Pas** de création, **pas** d’édition complète.
---
## Contexte / objectif
Remplacer longlet conditionnel **« À valider »** (apparaît/disparaît selon pending) par un onglet **permanent « Dossiers »** dans le dashboard admin/gestionnaire.
Quand on ouvre **Dossiers** :
1. **En haut** — section **Dossiers à valider** (AM + familles pending)
2. **En dessous** — liste de **tous les dossiers** (familles **et** AM), 1 ligne = 1 `numero_dossier`
3. Différenciation visuelle famille vs AM : **couleur + icône**
4. **Barre de recherche** (n° dossier, nom, email…)
**Pas** de bouton « Créer un dossier » ici (création via **+ Parents** #129 / **+ Asmat** #156).
---
## UX cible
### Onglets dashboard (`UserManagementPanel`)
| Avant (#107) | Après (#153) |
|--------------|--------------|
| « À valider » **conditionnel** si pending | **« Dossiers » toujours visible** (admin + gestionnaire) |
| Contenu = seulement pending | Pending **en haut** + liste complète **en bas** |
Ordre suggéré des onglets :
`Dossiers` | `Parents` | `Enfants` | `Assistantes maternelles` | `Gestionnaires` | (`Administrateurs`)
### Section haute — À valider
- Réutiliser / adapter `PendingValidationWidget` (ou extraire la liste dans un sous-widget).
- Sources déjà branchées :
- `UserService.getPendingUsers(role: 'assistante_maternelle')`
- `UserService.getPendingFamilies()`
- Clic ligne pending → **`ValidationDossierModal`** / wizards `.review` (inchangé).
- Si section vide : ne pas afficher de gros vide ; masquer la section ou message court « Aucun dossier en attente ».
### Section basse — Tous les dossiers
1 ligne = **1 dossier** (`numero_dossier`), type :
| Type | Libellé UI | Couleur (suggestion) |
|------|------------|----------------------|
| `famille` | Famille / Parents | teinte existante parents (ex. violet / rose dashboard) |
| `assistante_maternelle` | AM | teinte existante AM (ex. teal / bleu) |
Colonnes / infos utiles (cartes style `AdminUserCard` ou lignes type pending) :
- n° dossier
- type (pastille couleur + icône)
- libellé (noms parents ou AM)
- email(s) principal(aux)
- statut user / dossier si dispo (`actif`, `en_attente`, …)
- date utile si dispo
**Déduplication** : un foyer (pivot + co-parent) = **une** ligne famille (même `numero_dossier`). Idem AM.
### Recherche
- La search bar du panel (aujourdhui désactivée / hint « pas de recherche » sur À valider) doit **filtrer la liste unifiée** (et idéalement aussi le pending affiché).
- Critères **minimum** : `numero_dossier`, nom, prénom, email.
- Harmoniser le hint : `Rechercher un dossier (n°, nom, email)…`
### État vide liste complète
Aide optionnelle : *« Pour créer un dossier → onglet Parents (+ Parents) ou Assistantes maternelles (+ Asmat) »*.
### Clic sur un dossier de la liste complète (#153)
| Cas | Comportement #153 |
|-----|-------------------|
| Pending | Ouvrir validation (review) — déjà en place |
| Dossier **actif** / non pending | Ouvrir consultation via `GET /dossiers/:numeroDossier` (`UserService.getDossierByNumero`) en **lecture / review** si possible **sans** save édition |
**Ne pas** implémenter le mode `edit` ni le switch 2ᵉ parent → **#135**.
Si louverture « review » dun dossier actif est trop lourde pour ce ticket : clic peut temporairement no-op / snackbar *« Édition dossier : prochainement (#135) »***à éviter** si `getDossierByNumero` + wizard review marche déjà pour les deux types.
---
## Données / APIs (front)
### Déjà disponibles (préférer composer côté front pour #153)
| Besoin | API / service |
|--------|----------------|
| Pending AM | `getPendingUsers(role: assistante_maternelle)` |
| Pending familles | `getPendingFamilies()` |
| Parents (avec `numero_dossier`) | `getParents()` |
| AM (avec `numero_dossier`) | `getAssistantesMaternelles()` |
| Détail unifié | `getDossierByNumero(numero)``GET /dossiers/:numeroDossier` |
**Pas dendpoint `GET /dossiers` liste** aujourdhui. Pour #153 :
- Construire la liste unifiée **côté client** à partir de `getParents()` + `getAssistantesMaternelles()` (group by `numero_dossier`).
- Exclure ou marquer les pending déjà dans la section haute (éviter doublons visuels, ou les laisser dans les deux avec badge « à valider » — **préférence** : pending **uniquement** en haut ; liste basse = tous **hors** pending **ou** tous avec badge ; choisir une règle claire et documenter dans le PR).
**Règle recommandée** :
- Haut = pending only
- Bas = **tous** les dossiers ayant un `numero_dossier` (y compris pending) **OU** bas = non-pending only
**Recommandation produit** : bas = **tous** (vision complète), pending aussi en haut pour action rapide. Si doublon gênant : bas = non-pending only.
### Si le back ajoute plus tard `GET /dossiers`
Brancher `UserService.getDossiers()` — hors scope bloquant #153 front si composition client OK.
---
## Fichiers front probables
| Fichier | Rôle |
|---------|------|
| `frontend/lib/widgets/admin/user_management_panel.dart` | Onglet permanent **Dossiers** ; retirer logique conditionnelle À valider ; search sur cet onglet |
| `frontend/lib/widgets/admin/pending_validation_widget.dart` | Réemploi section haute (ou refactor léger) |
| **Nouveau** `…/dossiers_management_widget.dart` (nom libre) | Shell onglet : pending + liste unifiée + refresh |
| **Nouveau** modèle léger `DossierListItem` (type, numero, libelle, emails, statut…) | Mapping parents/AM → ligne |
| `user_service.dart` / `api_config.dart` | Seulement si helper `getDossiersUnified()` côté client (pas forcément nouvel endpoint) |
| `validation_dossier_modal.dart` | Réemploi ouverture pending / détail |
Réutiliser look & feel cartes / hover « Ouvrir » de `_PendingValidationRow` / `AdminUserCard`.
---
## Hors scope (#153)
- Bouton créer dossier
- Mode `edit` wizard + ajout 2ᵉ parent → **#135**
- Suppressions → **#154**
- Famille N responsables → **#139**
- Changer les onglets Parents / AM / Enfants (restent)
---
## Critères dacceptation front
- [ ] Onglet **Dossiers** toujours visible (même 0 pending)
- [ ] Plus donglet conditionnel **« À valider »**
- [ ] Section haute pending si non vide ; validation au clic OK
- [ ] Liste unifiée familles + AM en dessous ; 1 ligne / `numero_dossier`
- [ ] Couleur + icône différencient famille / AM
- [ ] Recherche filtre (n° + nom + email minimum)
- [ ] **Aucun** bouton créer dans cet onglet
- [ ] Pas de régression validation pending (valider / refuser)
---
## Back (info — Cursor back séparé si besoin)
- Liste unifiée : **pas bloquante** si composition front
- Optionnel : `GET /api/v1/dossiers` (liste) pour perf / pagination plus tard
- `GET /dossiers/:numero` déjà là (#119)
---
## Branche
`feature/153-onglet-dossiers` (depuis `develop`)
-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)
-196
View File
@@ -1,196 +0,0 @@
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart';
/// Ligne de liste unifiée dossiers (famille ou AM) — ticket #153.
enum DossierListType { famille, assistanteMaternelle }
class DossierListItem {
final DossierListType type;
final String numeroDossier;
final String libelle;
final List<String> emails;
final String? statut;
/// Photo profil (AM) — affichée à la place de licône si présente.
final String? photoUrl;
const DossierListItem({
required this.type,
required this.numeroDossier,
required this.libelle,
this.emails = const [],
this.statut,
this.photoUrl,
});
bool get isFamille => type == DossierListType.famille;
bool get isAm => type == DossierListType.assistanteMaternelle;
String get typeLabel => isFamille ? 'Famille' : 'AM';
/// Sous-titre carte : `NOM Prénom` ou `NOM Prénom - NOM Prénom`.
String get namesLine => libelle;
String get emailsLine => emails.where((e) => e.trim().isNotEmpty).join(' · ');
/// Titre carte : numéro de dossier seul.
String get titleLine => numeroDossier;
bool matchesQuery(String query) {
final q = query.trim().toLowerCase();
if (q.isEmpty) return true;
if (numeroDossier.toLowerCase().contains(q)) return true;
if (libelle.toLowerCase().contains(q)) return true;
for (final e in emails) {
if (e.toLowerCase().contains(q)) return true;
}
return false;
}
/// Une ligne par `numero_dossier` (foyer dédupliqué).
static List<DossierListItem> fromParents(List<ParentModel> parents) {
final byDossier = <String, List<ParentModel>>{};
for (final p in parents) {
final num = (p.user.numeroDossier ?? '').trim();
if (num.isEmpty) continue;
byDossier.putIfAbsent(num, () => []).add(p);
}
final items = <DossierListItem>[];
for (final entry in byDossier.entries) {
final seenIds = <String>{};
final names = <String>[];
final emails = <String>[];
final statuts = <String>[];
void consider(
String? id,
String? nom,
String? prenom,
String? email,
String? statut,
) {
final uid = (id ?? '').trim();
if (uid.isEmpty || !seenIds.add(uid)) return;
final label = formatDossierPersonLabel(
nom: nom,
prenom: prenom,
email: email,
);
if (label.isNotEmpty) names.add(label);
final e = (email ?? '').trim();
if (e.isNotEmpty) emails.add(e);
final s = (statut ?? '').trim();
if (s.isNotEmpty) statuts.add(s);
}
for (final p in entry.value) {
consider(
p.user.id,
p.user.nom,
p.user.prenom,
p.user.email,
p.user.statut,
);
final co = p.coParent;
if (co != null) {
consider(co.id, co.nom, co.prenom, co.email, co.statut);
}
}
items.add(
DossierListItem(
type: DossierListType.famille,
numeroDossier: entry.key,
libelle: names.isNotEmpty ? names.join(' - ') : 'Famille',
emails: emails,
statut: _preferStatut(statuts),
),
);
}
return items;
}
static List<DossierListItem> fromAssistantes(
List<AssistanteMaternelleModel> ams,
) {
final byDossier = <String, AssistanteMaternelleModel>{};
for (final am in ams) {
final num = (am.user.numeroDossier ?? '').trim();
if (num.isEmpty) continue;
byDossier.putIfAbsent(num, () => am);
}
return byDossier.entries.map((e) {
final u = e.value.user;
final name = formatDossierPersonLabel(
nom: u.nom,
prenom: u.prenom,
email: u.email,
);
final photo = (u.photoUrl ?? '').trim();
return DossierListItem(
type: DossierListType.assistanteMaternelle,
numeroDossier: e.key,
libelle: name.isNotEmpty ? name : 'AM',
emails: u.email.trim().isEmpty ? const [] : [u.email.trim()],
statut: u.statut?.trim(),
photoUrl: photo.isEmpty ? null : photo,
);
}).toList();
}
/// Priorité affichage : en_attente > suspendu > refuse > actif > autre.
static String? _preferStatut(List<String> raw) {
if (raw.isEmpty) return null;
const order = ['en_attente', 'suspendu', 'refuse', 'actif'];
for (final wanted in order) {
for (final s in raw) {
if (s.toLowerCase() == wanted) return s;
}
}
return raw.first;
}
}
/// Affichage carte dossier : `NOM Prénom` (repli email).
String formatDossierPersonLabel({
String? nom,
String? prenom,
String? email,
}) {
final n = (nom ?? '').trim().toUpperCase();
final p = formatPersonNameCase(prenom ?? '');
if (n.isNotEmpty && p.isNotEmpty) return '$n $p';
if (n.isNotEmpty) return n;
if (p.isNotEmpty) return p;
return (email ?? '').trim();
}
/// Reformate un libellé famille API (`A & B` / `Famille …`) en `NOM Prénom - …`.
String formatDossierFamilyNamesLine(String libelle) {
var raw = libelle.trim();
if (raw.isEmpty) return '';
raw = raw.replaceFirst(RegExp(r'^famille\s+', caseSensitive: false), '');
raw = raw
.replaceAll(RegExp(r'\s+&\s+'), ' - ')
.replaceAll(RegExp(r'\s+et\s+', caseSensitive: false), ' - ');
final parts = raw
.split(RegExp(r'\s+-\s+'))
.map((part) => _formatLoosePersonSegment(part.trim()))
.where((s) => s.isNotEmpty)
.toList();
return parts.join(' - ');
}
/// Segment libre type « martin sophie » ou « DURAND Amélie » → `NOM Prénom`.
String _formatLoosePersonSegment(String segment) {
final words =
segment.split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList();
if (words.isEmpty) return '';
if (words.length == 1) return words.first.toUpperCase();
// Convention affichage : premier mot = NOM, reste = prénom(s).
final nom = words.first.toUpperCase();
final prenom = formatPersonNameCase(words.sublist(1).join(' '));
return '$nom $prenom';
}
+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,
@@ -12,10 +12,6 @@ class AdminUserCard extends StatefulWidget {
final Color? backgroundColor;
final Color? titleColor;
final Color? infoColor;
/// Fond du cercle avatar / icône (défaut lavande admin).
final Color? avatarBackgroundColor;
/// Couleur de licône fallback (défaut violet admin).
final Color? avatarIconColor;
final String? vigilanceTooltip;
final VoidCallback? onCardTap;
final EdgeInsetsGeometry? margin;
@@ -32,8 +28,6 @@ class AdminUserCard extends StatefulWidget {
this.backgroundColor,
this.titleColor,
this.infoColor,
this.avatarBackgroundColor,
this.avatarIconColor,
this.vigilanceTooltip,
this.onCardTap,
this.margin,
@@ -165,8 +159,8 @@ class _AdminUserCardState extends State<AdminUserCard> {
Widget _buildAvatar(String url) {
const size = 28.0;
final bg = widget.avatarBackgroundColor ?? const Color(0xFFEDE5FA);
final iconColor = widget.avatarIconColor ?? const Color(0xFF6B3FA0);
const bg = Color(0xFFEDE5FA);
const iconColor = Color(0xFF6B3FA0);
if (url.isEmpty) {
return CircleAvatar(
+16 -41
View File
@@ -8,8 +8,6 @@ class DashboardUserManagementSubBar extends StatelessWidget {
final ValueChanged<int> onSubTabChange;
final TextEditingController searchController;
final String searchHint;
/// Infobulle au survol de la barre de recherche (ex. critères de recherche).
final String? searchTooltip;
final Widget? filterControl;
final VoidCallback? onAddPressed;
final String addLabel;
@@ -24,16 +22,12 @@ class DashboardUserManagementSubBar extends StatelessWidget {
'Administrateurs',
];
/// Aligné sur la taille des libellés donglets.
static const double _searchFontSize = 13;
const DashboardUserManagementSubBar({
Key? key,
required this.selectedSubIndex,
required this.onSubTabChange,
required this.searchController,
required this.searchHint,
this.searchTooltip,
this.filterControl,
this.onAddPressed,
this.addLabel = '+ Ajouter',
@@ -60,7 +54,22 @@ class DashboardUserManagementSubBar extends StatelessWidget {
_buildSubNavItem(context, labels[i], i),
],
const SizedBox(width: 36),
_buildSearchField(),
_pillField(
width: 320,
child: TextField(
controller: searchController,
decoration: InputDecoration(
hintText: searchHint,
prefixIcon: const Icon(Icons.search, size: 18),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
),
),
),
if (filterControl != null) ...[
const SizedBox(width: 12),
_pillField(width: 150, child: filterControl!),
@@ -72,40 +81,6 @@ class DashboardUserManagementSubBar extends StatelessWidget {
);
}
Widget _buildSearchField() {
final field = _pillField(
width: 320,
child: TextField(
controller: searchController,
style: const TextStyle(fontSize: _searchFontSize),
decoration: InputDecoration(
hintText: searchHint,
hintStyle: TextStyle(
fontSize: _searchFontSize,
fontStyle: FontStyle.normal,
fontWeight: FontWeight.normal,
color: Colors.black45,
),
prefixIcon: const Icon(Icons.search, size: 18),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
),
),
);
final tip = (searchTooltip ?? '').trim();
if (tip.isEmpty) return field;
return Tooltip(
message: tip,
preferBelow: false,
waitDuration: const Duration(milliseconds: 400),
child: field,
);
}
Widget _pillField({required double width, required Widget child}) {
return Container(
width: width,
@@ -1,54 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
/// Carte dossier unifiée (#153) — fond neutre, accent couleur sur licône.
class DossierListCard extends StatelessWidget {
final String numeroDossier;
final String namesLine;
final bool isFamille;
final VoidCallback onOpen;
/// Photo AM (si absente → icône fallback).
final String? photoUrl;
/// Lavande — Famille / Parents.
static const Color familleAccent = Color(0xFFB289C9);
/// Menthe logo — AM.
static const Color amAccent = Color(0xFF5A9D94);
const DossierListCard({
super.key,
required this.numeroDossier,
required this.namesLine,
required this.isFamille,
required this.onOpen,
this.photoUrl,
});
@override
Widget build(BuildContext context) {
final accent = isFamille ? familleAccent : amAccent;
final num = numeroDossier.trim().isEmpty ? '' : numeroDossier.trim();
final names = namesLine.trim();
final avatar = (photoUrl ?? '').trim();
return AdminUserCard(
title: num,
subtitleLines: names.isEmpty ? const [] : [names],
avatarUrl: !isFamille && avatar.isNotEmpty ? avatar : null,
fallbackIcon:
isFamille ? Icons.family_restroom_outlined : Icons.face,
// N° = titre neutre (comme Parents / AM) ; accent = icône seule.
avatarIconColor: accent,
infoColor: Colors.black87,
onCardTap: onOpen,
actions: [
IconButton(
tooltip: 'Ouvrir',
icon: Icon(Icons.open_in_new, size: 20, color: accent),
onPressed: onOpen,
),
],
);
}
}
@@ -1,203 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_list_item.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/dossier_list_card.dart';
import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart';
import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart';
/// Onglet permanent « Dossiers » (#153) : pending en haut + liste unifiée en bas.
class DossiersManagementWidget extends StatefulWidget {
final String searchQuery;
final VoidCallback? onRefresh;
const DossiersManagementWidget({
super.key,
this.searchQuery = '',
this.onRefresh,
});
@override
State<DossiersManagementWidget> createState() =>
_DossiersManagementWidgetState();
}
class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
bool _loading = true;
String? _error;
List<DossierListItem> _all = [];
Set<String> _pendingNumeros = {};
int _pendingRefreshTick = 0;
@override
void initState() {
super.initState();
_loadAll();
}
Future<void> _loadAll() async {
setState(() {
_loading = true;
_error = null;
});
try {
final parents = await UserService.getParents();
final ams = await UserService.getAssistantesMaternelles();
if (!mounted) return;
final items = <DossierListItem>[
...DossierListItem.fromParents(parents),
...DossierListItem.fromAssistantes(ams),
];
items.sort((a, b) {
final byNum = a.numeroDossier.compareTo(b.numeroDossier);
if (byNum != 0) return byNum;
return a.typeLabel.compareTo(b.typeLabel);
});
setState(() {
_all = items;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur inconnue';
_loading = false;
});
}
}
Future<void> _refreshEverything() async {
setState(() => _pendingRefreshTick++);
await _loadAll();
widget.onRefresh?.call();
}
void _openDossier(String numeroDossier) {
final num = numeroDossier.trim();
if (num.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Numéro de dossier manquant.')),
);
return;
}
showDialog<void>(
context: context,
builder: (context) => ValidationDossierModal(
numeroDossier: num,
openAsEdit: true,
onClose: () => Navigator.of(context).pop(),
onSuccess: () {
Navigator.of(context).pop();
_refreshEverything();
},
),
);
}
@override
Widget build(BuildContext context) {
final query = widget.searchQuery;
// Pending uniquement en haut — exclus de « Tous les dossiers ».
final filtered = _all
.where((d) => !_pendingNumeros.contains(d.numeroDossier))
.where((d) => (d.statut ?? '').toLowerCase() != 'en_attente')
.where((d) => d.matchesQuery(query))
.toList(growable: false);
return RefreshIndicator(
onRefresh: _refreshEverything,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: PendingValidationWidget(
key: ValueKey('pending-$_pendingRefreshTick'),
searchQuery: query,
compactWhenEmpty: true,
onPendingNumerosChanged: (nums) {
if (!mounted) return;
setState(() => _pendingNumeros = nums);
},
onRefresh: () {
_loadAll();
widget.onRefresh?.call();
},
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
'Tous les dossiers',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
),
),
if (_loading)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (_error != null && _error!.isNotEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!, style: const TextStyle(color: Colors.red)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadAll,
child: const Text('Réessayer'),
),
],
),
),
),
)
else if (filtered.isEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 32),
child: Text(
query.trim().isEmpty
? 'Aucun dossier pour le moment.\n'
'Pour créer un dossier → onglet Parents (+ Parents) '
'ou Assistantes maternelles (+ Asmat).'
: 'Aucun dossier ne correspond à la recherche.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600, height: 1.4),
),
),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final item = filtered[index];
return DossierListCard(
numeroDossier: item.numeroDossier,
namesLine: item.namesLine,
isFamille: item.isFamille,
photoUrl: item.photoUrl,
onOpen: () => _openDossier(item.numeroDossier),
);
},
childCount: filtered.length,
),
),
),
],
),
);
}
}
@@ -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,
@@ -1,32 +1,19 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_list_item.dart';
import 'package:intl/intl.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/models/pending_family.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/dossier_list_card.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart';
/// Section « dossiers à valider » (liste unifiée AM + familles). Ticket #107 / #153.
/// Onglet « À valider » : deux listes (AM en attente, familles en attente). Ticket #107.
class PendingValidationWidget extends StatefulWidget {
final VoidCallback? onRefresh;
/// Filtre client (n°, nom, email) — onglet Dossiers (#153).
final String searchQuery;
/// Si true et liste vide : message court (pas de grand vide centré).
final bool compactWhenEmpty;
/// Numéros des dossiers pending (pour exclure de « Tous les dossiers »).
final ValueChanged<Set<String>>? onPendingNumerosChanged;
const PendingValidationWidget({
super.key,
this.onRefresh,
this.searchQuery = '',
this.compactWhenEmpty = false,
this.onPendingNumerosChanged,
});
const PendingValidationWidget({super.key, this.onRefresh});
@override
State<PendingValidationWidget> createState() =>
_PendingValidationWidgetState();
State<PendingValidationWidget> createState() => _PendingValidationWidgetState();
}
class _PendingValidationWidgetState extends State<PendingValidationWidget> {
@@ -34,8 +21,6 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
String? _error;
List<AppUser> _pendingAM = [];
List<PendingFamily> _pendingFamilies = [];
/// Noms enrichis via GET /dossiers/:numero (libelle API = noms seuls).
final Map<String, String> _familyNamesByNumero = {};
@override
void initState() {
@@ -49,77 +34,24 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
_error = null;
});
try {
final am =
await UserService.getPendingUsers(role: 'assistante_maternelle');
final am = await UserService.getPendingUsers(role: 'assistante_maternelle');
final families = await UserService.getPendingFamilies();
final namesByNumero = await _enrichFamilyNames(families);
if (!mounted) return;
setState(() {
_pendingAM = am;
_pendingFamilies = families;
_familyNamesByNumero
..clear()
..addAll(namesByNumero);
_isLoading = false;
});
_emitPendingNumeros();
} catch (e) {
if (!mounted) return;
setState(() {
_error = e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur inconnue';
_error = e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur inconnue';
_isLoading = false;
});
widget.onPendingNumerosChanged?.call(const {});
}
}
/// Complète `NOM Prénom` via le détail dossier (sans changer le back).
Future<Map<String, String>> _enrichFamilyNames(
List<PendingFamily> families,
) async {
final out = <String, String>{};
await Future.wait(families.map((f) async {
final num = (f.numeroDossier ?? '').trim();
if (num.isEmpty) return;
try {
final dossier = await UserService.getDossier(num);
if (!dossier.isFamily) return;
final labels = <String>[];
final seen = <String>{};
for (final p in dossier.asFamily.parents) {
final id = p.id.trim();
if (id.isNotEmpty && !seen.add(id)) continue;
final label = formatDossierPersonLabel(
nom: p.nom,
prenom: p.prenom,
email: p.email,
);
if (label.isNotEmpty) labels.add(label);
}
if (labels.isNotEmpty) out[num] = labels.join(' - ');
} catch (_) {
// Repli libellé API ci-dessous.
}
}));
return out;
}
void _emitPendingNumeros() {
final nums = <String>{};
for (final u in _pendingAM) {
final n = (u.numeroDossier ?? '').trim();
if (n.isNotEmpty) nums.add(n);
}
for (final f in _pendingFamilies) {
final n = (f.numeroDossier ?? '').trim();
if (n.isNotEmpty) nums.add(n);
}
widget.onPendingNumerosChanged?.call(nums);
}
void _onOpenValidation({String? numeroDossier}) {
void _onOpenValidation({String? type, String? id, String? numeroDossier}) {
final num = numeroDossier?.trim();
if (num == null || num.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -141,48 +73,9 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
);
}
bool _matchesQuery(String haystack) {
final q = widget.searchQuery.trim().toLowerCase();
if (q.isEmpty) return true;
return haystack.toLowerCase().contains(q);
}
List<AppUser> get _filteredAM {
return _pendingAM.where((u) {
final bits = [
u.numeroDossier ?? '',
u.fullName,
u.email,
u.nom ?? '',
u.prenom ?? '',
].join(' ');
return _matchesQuery(bits);
}).toList();
}
List<PendingFamily> get _filteredFamilies {
return _pendingFamilies.where((f) {
final num = (f.numeroDossier ?? '').trim();
final enriched = _familyNamesByNumero[num] ?? '';
final bits = [
f.numeroDossier ?? '',
f.libelle,
enriched,
f.emails.join(' '),
].join(' ');
return _matchesQuery(bits);
}).toList();
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
if (widget.compactWhenEmpty) {
return const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
);
}
return const Center(child: CircularProgressIndicator());
}
if (_error != null && _error!.isNotEmpty) {
@@ -204,32 +97,14 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
);
}
final pendingAM = _filteredAM;
final pendingFamilies = _filteredFamilies;
final cards = <Widget>[
...pendingAM.map(_buildAMCard),
...pendingFamilies.map(_buildFamilyCard),
];
if (cards.isEmpty) {
if (widget.compactWhenEmpty) {
final searching = widget.searchQuery.trim().isNotEmpty;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
searching
? 'Aucun dossier en attente ne correspond à la recherche.'
: 'Aucun dossier en attente.',
style: TextStyle(color: Colors.grey.shade600, fontSize: 13),
),
);
}
final hasAM = _pendingAM.isNotEmpty;
final hasFamilies = _pendingFamilies.isNotEmpty;
if (!hasAM && !hasFamilies) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle_outline,
size: 64, color: Colors.grey.shade400),
Icon(Icons.check_circle_outline, size: 64, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(
'Aucun dossier en attente de validation',
@@ -242,28 +117,6 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
);
}
final list = Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Dossiers à valider',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 8),
...cards,
],
);
if (widget.compactWhenEmpty) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: list,
);
}
return RefreshIndicator(
onRefresh: () async {
await _load();
@@ -272,40 +125,275 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: list,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (hasAM) ...[
_sectionTitle('Assistantes maternelles en attente'),
const SizedBox(height: 8),
..._pendingAM.map((u) => _buildAMCard(u)),
const SizedBox(height: 24),
],
if (hasFamilies) ...[
_sectionTitle('Familles en attente'),
const SizedBox(height: 8),
..._pendingFamilies.map((f) => _buildFamilyCard(f)),
],
],
),
),
);
}
String _amNamesLine(AppUser user) {
return formatDossierPersonLabel(
nom: user.nom,
prenom: user.prenom,
email: user.email,
Widget _sectionTitle(String title) {
return Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Colors.black87,
),
);
}
/// Sous-titre AM : `email - date • tél. • CP ville` (plan affichage lignes À valider).
String _amSubtitleLine(AppUser user) {
final email = user.email.trim();
final bits = <String>[];
bits.add(DateFormat('dd/MM/yyyy').format(user.createdAt.toLocal()));
final tel = user.telephone?.trim();
if (tel != null && tel.isNotEmpty) {
bits.add(formatPhoneForDisplay(tel));
}
final cp = user.codePostal?.trim();
final ville = user.ville?.trim();
final loc = [if (cp != null && cp.isNotEmpty) cp, if (ville != null && ville.isNotEmpty) ville]
.join(' ')
.trim();
if (loc.isNotEmpty) bits.add(loc);
final infos = bits.join('');
if (email.isEmpty) return infos;
return '$email - $infos';
}
Widget _buildAMCard(AppUser user) {
return DossierListCard(
numeroDossier: user.numeroDossier ?? '',
namesLine: _amNamesLine(user),
isFamille: false,
photoUrl: user.photoUrl,
onOpen: () => _onOpenValidation(numeroDossier: user.numeroDossier),
final numDossier = user.numeroDossier ?? '';
final nameBold =
user.fullName.isNotEmpty ? user.fullName : (user.email.isNotEmpty ? user.email : '');
return _PendingValidationRow(
icon: Icons.person_outline,
title: Text.rich(
TextSpan(
style: const TextStyle(fontSize: 14, color: Colors.black87),
children: [
TextSpan(
text: nameBold,
style: const TextStyle(fontWeight: FontWeight.w700),
),
TextSpan(
text: ' - $numDossier',
style: const TextStyle(fontWeight: FontWeight.w400),
),
],
),
),
subtitle: _amSubtitleLine(user),
subtitleStyle: TextStyle(
fontSize: 12,
fontStyle: FontStyle.italic,
color: Colors.grey.shade600,
),
onOpen: () => _onOpenValidation(
type: 'AM',
id: user.id,
numeroDossier: user.numeroDossier,
),
);
}
/// `email, tél., localisation` par parent, puis `date soumission`, puis `nb enfants`.
String _familyParentSegment(PendingParentLine p) {
final parts = <String>[];
final e = p.email?.trim();
if (e != null && e.isNotEmpty) parts.add(e);
final t = p.telephone?.trim();
if (t != null && t.isNotEmpty) parts.add(formatPhoneForDisplay(t));
final cp = p.codePostal?.trim();
final v = p.ville?.trim();
final loc = [if (cp != null && cp.isNotEmpty) cp, if (v != null && v.isNotEmpty) v]
.join(' ')
.trim();
if (loc.isNotEmpty) parts.add(loc);
return parts.join(', ');
}
String _familySubtitleLine(PendingFamily family) {
final blocks = family.parentLines
.map(_familyParentSegment)
.where((s) => s.isNotEmpty)
.join(' - ');
final tail = <String>[];
final date = family.dateSoumission;
if (date != null) {
tail.add(DateFormat('dd/MM/yyyy').format(date.toLocal()));
}
if (family.nombreEnfants > 0) {
tail.add(
family.nombreEnfants > 1
? '${family.nombreEnfants} enfants'
: '1 enfant',
);
}
final right = tail.join(' - ');
if (blocks.isEmpty && right.isEmpty) return '';
if (blocks.isEmpty) return right;
if (right.isEmpty) return blocks;
return '$blocks - $right';
}
Widget _buildFamilyCard(PendingFamily family) {
final num = (family.numeroDossier ?? '').trim();
final enriched = num.isNotEmpty ? _familyNamesByNumero[num] : null;
final names = (enriched != null && enriched.isNotEmpty)
? enriched
: formatDossierFamilyNamesLine(family.libelle);
return DossierListCard(
numeroDossier: family.numeroDossier ?? '',
namesLine: names,
isFamille: true,
onOpen: () => _onOpenValidation(numeroDossier: family.numeroDossier),
final numDossier = family.numeroDossier ?? '';
final nameBold = family.libelle.isNotEmpty ? family.libelle : 'Famille';
return _PendingValidationRow(
icon: Icons.family_restroom_outlined,
title: Text.rich(
TextSpan(
style: const TextStyle(fontSize: 14, color: Colors.black87),
children: [
TextSpan(
text: nameBold,
style: const TextStyle(fontWeight: FontWeight.w700),
),
TextSpan(
text: ' - $numDossier',
style: const TextStyle(fontWeight: FontWeight.w400),
),
],
),
),
subtitle: _familySubtitleLine(family),
subtitleStyle: TextStyle(
fontSize: 12,
fontStyle: FontStyle.italic,
color: Colors.grey.shade600,
),
onOpen: () => _onOpenValidation(
type: 'famille',
id: family.parentIds.isNotEmpty ? family.parentIds.first : null,
numeroDossier: family.numeroDossier,
),
);
}
}
/// Ligne « À valider » : survol comme [AdminUserCard], icône « Ouvrir » visible au hover uniquement.
class _PendingValidationRow extends StatefulWidget {
final IconData icon;
final Widget title;
final String? subtitle;
final TextStyle? subtitleStyle;
final VoidCallback onOpen;
const _PendingValidationRow({
required this.icon,
required this.title,
this.subtitle,
this.subtitleStyle,
required this.onOpen,
});
@override
State<_PendingValidationRow> createState() => _PendingValidationRowState();
}
class _PendingValidationRowState extends State<_PendingValidationRow> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
final subStyle = widget.subtitleStyle ??
TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
);
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
cursor: SystemMouseCursors.click,
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: widget.onOpen,
borderRadius: BorderRadius.circular(10),
hoverColor: const Color(0x149CC5C0),
child: Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: Colors.grey.shade300),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(widget.icon, color: Colors.grey.shade600, size: 28),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
widget.title,
if (widget.subtitle != null &&
widget.subtitle!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
widget.subtitle!,
style: subStyle,
),
],
],
),
),
SizedBox(
width: 52,
child: Center(
child: AnimatedOpacity(
duration: const Duration(milliseconds: 120),
opacity: _isHovered ? 1 : 0,
child: IgnorePointer(
ignoring: !_isHovered,
child: IconButtonTheme(
data: IconButtonThemeData(
style: IconButton.styleFrom(
padding: const EdgeInsets.all(0),
minimumSize: const Size(48, 48),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
child: IconButton(
onPressed: widget.onOpen,
icon: const Icon(Icons.open_in_new),
iconSize: 34,
tooltip: 'Ouvrir',
),
),
),
),
),
),
],
),
),
),
),
),
);
}
}
@@ -1,18 +1,19 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/am_dossier_create_modal.dart';
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
import 'package:p_tits_pas/widgets/admin/dossiers_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/parent_dossier_create_modal.dart';
import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart';
import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart';
class UserManagementPanel extends StatefulWidget {
/// Afficher l'onglet Administrateurs (sinon sans Administrateurs).
/// Afficher l'onglet Administrateurs (sinon 3 onglets : Gestionnaires, Parents, AM).
final bool showAdministrateursTab;
const UserManagementPanel({
@@ -31,17 +32,45 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
int _adminRefreshTick = 0;
int _enfantRefreshTick = 0;
int _amRefreshTick = 0;
int _dossiersRefreshTick = 0;
final TextEditingController _searchController = TextEditingController();
final TextEditingController _amCapacityController = TextEditingController();
String? _parentStatus;
String? _enfantStatus;
bool _hasPending = false;
bool _pendingLoading = true;
@override
void initState() {
super.initState();
_searchController.addListener(_onFilterChanged);
_amCapacityController.addListener(_onFilterChanged);
_loadPending();
}
Future<void> _loadPending() async {
try {
final am = await UserService.getPendingUsers(role: 'assistante_maternelle');
final families = await UserService.getPendingFamilies();
if (!mounted) return;
final hasPending = am.isNotEmpty || families.isNotEmpty;
setState(() {
final hadPending = _hasPending;
_hasPending = hasPending;
_pendingLoading = false;
// Si on passe à "plus de dossiers", recaler l'index (onglet À valider disparaît).
if (hadPending && !hasPending) {
_subIndex = (_subIndex > 0 ? _subIndex - 1 : 0).clamp(0, _tabLabels.length - 1);
} else if (!hadPending && hasPending) {
_subIndex = 0; // Afficher l'onglet À valider
}
});
} catch (_) {
if (!mounted) return;
setState(() {
_hasPending = false;
_pendingLoading = false;
});
}
}
@override
@@ -58,19 +87,13 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
setState(() {});
}
/// Ordre #153 : Dossiers | Parents | Enfants | AM | Gestionnaires | (Admin).
List<String> get _tabLabels {
const base = [
'Dossiers',
'Parents',
'Enfants',
'Assistantes maternelles',
'Gestionnaires',
];
if (widget.showAdministrateursTab) {
return [...base, 'Administrateurs'];
}
return base;
const base = ['Parents', 'Enfants', 'Assistantes maternelles', 'Gestionnaires'];
final withAdmin = [...base, 'Administrateurs'];
final list = widget.showAdministrateursTab ? withAdmin : base;
// Onglet « À valider » visible seulement s'il y a des dossiers en attente (ticket #107).
if (!_pendingLoading && _hasPending) return ['À valider', ...list];
return list;
}
void _onSubTabChange(int index) {
@@ -84,35 +107,31 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
});
}
bool get _isDossiersTab => _subIndex == 0;
/// Index du contenu : -1 = À valider, 0 = Parents, 1 = Enfants, 2 = AM, 3 = Gestionnaires, 4 = Admin.
int get _contentIndexOffset => (_hasPending && !_pendingLoading) ? 1 : 0;
String _searchHintForTab() {
switch (_subIndex) {
final contentIndex = _subIndex - _contentIndexOffset;
switch (contentIndex) {
case -1:
return 'À valider (pas de recherche)';
case 0:
return 'Rechercher un dossier';
case 1:
return 'Rechercher un parent...';
case 2:
case 1:
return 'Rechercher un enfant...';
case 3:
case 2:
return 'Rechercher une assistante...';
case 4:
case 3:
return 'Rechercher un gestionnaire...';
case 5:
case 4:
return 'Rechercher un administrateur...';
default:
return 'Rechercher...';
}
}
String? _searchTooltipForTab() {
if (_subIndex != 0) return null;
return 'Recherche possible : n° de dossier, nom, prénom ou e-mail.';
}
Widget? _subBarFilterControl() {
// Parents
if (_subIndex == 1) {
if (_subIndex == _contentIndexOffset + 0) {
return DropdownButtonHideUnderline(
child: DropdownButton<String?>(
value: _parentStatus,
@@ -167,8 +186,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
);
}
// Enfants
if (_subIndex == 2) {
if (_subIndex == _contentIndexOffset + 1) {
return DropdownButtonHideUnderline(
child: DropdownButton<String?>(
value: _enfantStatus,
@@ -223,8 +241,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
);
}
// AM
if (_subIndex == 3) {
if (_subIndex == _contentIndexOffset + 2) {
return TextField(
controller: _amCapacityController,
decoration: const InputDecoration(
@@ -241,36 +258,35 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
}
Widget _buildBody() {
switch (_subIndex) {
final contentIndex = _subIndex - _contentIndexOffset;
if (_hasPending && !_pendingLoading && contentIndex == -1) {
return PendingValidationWidget(onRefresh: _loadPending);
}
switch (contentIndex) {
case 0:
return DossiersManagementWidget(
key: ValueKey('dossiers-$_dossiersRefreshTick'),
searchQuery: _searchController.text,
);
case 1:
return ParentManagementWidget(
key: ValueKey('parents-$_parentRefreshTick'),
searchQuery: _searchController.text,
statusFilter: _parentStatus,
);
case 2:
case 1:
return EnfantManagementWidget(
key: ValueKey('enfants-$_enfantRefreshTick'),
searchQuery: _searchController.text,
statusFilter: _enfantStatus,
);
case 3:
case 2:
return AssistanteMaternelleManagementWidget(
key: ValueKey('ams-$_amRefreshTick'),
searchQuery: _searchController.text,
capacityMin: int.tryParse(_amCapacityController.text),
);
case 4:
case 3:
return GestionnaireManagementWidget(
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
searchQuery: _searchController.text,
);
case 5:
case 4:
return AdminManagementWidget(
key: ValueKey('admins-$_adminRefreshTick'),
searchQuery: _searchController.text,
@@ -283,6 +299,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
@override
Widget build(BuildContext context) {
final labels = _tabLabels;
final isAValiderTab = _hasPending && !_pendingLoading && _subIndex == 0;
return Column(
children: [
DashboardUserManagementSubBar(
@@ -290,10 +307,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
onSubTabChange: _onSubTabChange,
searchController: _searchController,
searchHint: _searchHintForTab(),
searchTooltip: _searchTooltipForTab(),
filterControl: _subBarFilterControl(),
// Pas de « Créer » sur longlet Dossiers (#153).
onAddPressed: _isDossiersTab ? null : _handleAddPressed,
onAddPressed: isAValiderTab ? null : _handleAddPressed,
addLabel: 'Ajouter',
subTabCount: labels.length,
tabLabels: labels,
@@ -304,8 +319,9 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
}
Future<void> _handleAddPressed() async {
// 1 Parents, 2 Enfants, 3 AM, 4 Gestionnaires, 5 Admin
if (_subIndex == 1) {
final contentIndex = _subIndex - _contentIndexOffset;
if (contentIndex == 0) {
await showDialog<void>(
context: context,
barrierDismissible: false,
@@ -315,10 +331,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
onSuccess: () {
Navigator.of(dialogContext).pop();
if (!mounted) return;
setState(() {
_parentRefreshTick++;
_dossiersRefreshTick++;
});
setState(() => _parentRefreshTick++);
},
);
},
@@ -326,7 +339,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
return;
}
if (_subIndex == 2) {
if (contentIndex == 1) {
await showDialog<void>(
context: context,
barrierDismissible: false,
@@ -342,7 +355,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
return;
}
if (_subIndex == 3) {
if (contentIndex == 2) {
await showDialog<void>(
context: context,
barrierDismissible: false,
@@ -352,10 +365,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
onSuccess: () {
Navigator.of(dialogContext).pop();
if (!mounted) return;
setState(() {
_amRefreshTick++;
_dossiersRefreshTick++;
});
setState(() => _amRefreshTick++);
},
);
},
@@ -363,7 +373,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
return;
}
if (_subIndex == 4) {
if (contentIndex == 3) {
final created = await showDialog<bool>(
context: context,
barrierDismissible: false,
@@ -381,7 +391,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
return;
}
if (_subIndex == 5) {
if (contentIndex == 4) {
final created = await showDialog<bool>(
context: context,
barrierDismissible: false,
@@ -399,6 +409,16 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
_adminRefreshTick++;
});
}
return;
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'La création parent sera disponible avec le ticket #129.',
),
),
);
}
}
@@ -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,