Compare commits

...
Author SHA1 Message Date
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
52 changed files with 5730 additions and 1146 deletions
+3
View File
@@ -89,6 +89,9 @@
"transform": { "transform": {
"^.+\\.(t|j)s$": "ts-jest" "^.+\\.(t|j)s$": "ts-jest"
}, },
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/$1"
},
"collectCoverageFrom": [ "collectCoverageFrom": [
"**/*.(t|j)s" "**/*.(t|j)s"
], ],
@@ -0,0 +1,111 @@
/**
* Crée l'issue Gitea — gestionnaire ne doit pas pouvoir se supprimer.
* Usage: node backend/scripts/create-gitea-issue-gestionnaire-self-delete.js
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
const repoRoot = path.join(__dirname, '../..');
const MILESTONE_0_1_0 = 10;
let token = process.env.GITEA_TOKEN;
if (!token) {
try {
const tokenFile = path.join(repoRoot, '.gitea-token');
if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim();
} catch (_) {}
}
if (!token) {
try {
const briefing = fs.readFileSync(
path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'),
'utf8',
);
const m = briefing.match(/Token:\s*(gitebu_[a-f0-9]+)/);
if (m) token = m[1].trim();
} catch (_) {}
}
if (!token) {
console.error('Token non trouvé : .gitea-token ou GITEA_TOKEN');
process.exit(1);
}
const body = `## Contexte
Quand un **gestionnaire** connecté ouvre sa propre fiche dans l'onglet **Gestionnaires** (Gestion des utilisateurs), la modale **Modifier un "Gestionnaire"** affiche le bouton **Supprimer**.
Comportement actuel : le gestionnaire voit et peut tenter de supprimer son propre compte.
## Comportement attendu
Comme pour le **super administrateur** (bouton Supprimer masqué sur la fiche super admin) :
- **Pas de bouton Supprimer** quand l'utilisateur édite **sa propre fiche**
- **Modification** des informations (prénom, nom, email, téléphone, relais, mot de passe) **toujours autorisée**
## Périmètre
- Frontend : \`AdminUserFormDialog\` (\`gestionnaires_create.dart\`)
- Comparer \`initialUser.id\` avec l'utilisateur connecté (\`AuthService.getCurrentUser\`)
- Masquer Supprimer si édition de soi-même ; conserver garde existante super admin
## Critères d'acceptation
- [ ] Gestionnaire connecté → ouvre sa fiche → **pas** de bouton Supprimer
- [ ] Gestionnaire connecté → peut **Modifier** ses informations
- [ ] Super admin / admin → peut toujours supprimer **un autre** gestionnaire (si droits API)
- [ ] Pas de régression sur fiche super administrateur (Supprimer toujours masqué)
## Fichiers clés
- \`frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart\`
- \`frontend/lib/widgets/admin/gestionnaire_management_widget.dart\`
## Milestone
**0.1.0** — correction UX / sécurité gestion utilisateurs.`;
const payloadClean = JSON.stringify({
title: '[Bug] Gestionnaire peut voir Supprimer sur sa propre fiche',
body,
milestone: MILESTONE_0_1_0,
});
const opts = {
hostname: 'git.ptits-pas.fr',
path: '/api/v1/repos/jmartin/petitspas/issues',
method: 'POST',
headers: {
Authorization: 'token ' + token,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payloadClean),
},
};
const req = https.request(opts, (res) => {
let d = '';
res.on('data', (c) => (d += c));
res.on('end', () => {
try {
const o = JSON.parse(d);
if (o.number) {
console.log('NUMBER:', o.number);
console.log('URL:', o.html_url);
console.log('MILESTONE:', o.milestone?.title ?? '(aucun)');
} else {
console.error('Réponse:', d);
process.exit(1);
}
} catch (e) {
console.error(d);
process.exit(1);
}
});
});
req.on('error', (e) => {
console.error(e);
process.exit(1);
});
req.write(payloadClean);
req.end();
@@ -1,20 +1,69 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { AssistantesMaternellesController } from './assistantes_maternelles.controller'; import { AssistantesMaternellesController } from './assistantes_maternelles.controller';
import { AssistantesMaternellesService } from './assistantes_maternelles.service'; import { AssistantesMaternellesService } from './assistantes_maternelles.service';
import { AuthService } from '../auth/auth.service';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
describe('AssistantesMaternellesController', () => { describe('AssistantesMaternellesController', () => {
let controller: AssistantesMaternellesController; let controller: AssistantesMaternellesController;
const authServiceMock = {
createAmDossierStaff: jest.fn(),
};
const amServiceMock = {};
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [AssistantesMaternellesController], controllers: [AssistantesMaternellesController],
providers: [AssistantesMaternellesService], providers: [
}).compile(); { provide: AssistantesMaternellesService, useValue: amServiceMock },
{ provide: AuthService, useValue: authServiceMock },
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<AssistantesMaternellesController>(AssistantesMaternellesController); controller = module.get<AssistantesMaternellesController>(AssistantesMaternellesController);
jest.clearAllMocks();
}); });
it('should be defined', () => { it('should be defined', () => {
expect(controller).toBeDefined(); expect(controller).toBeDefined();
}); });
it('createDossier delegates to authService.createAmDossierStaff with CGU accepted', async () => {
authServiceMock.createAmDossierStaff.mockResolvedValue({
message: 'ok',
user_id: 'u1',
statut: 'actif',
numero_dossier: '2026-000001',
});
const body = {
email: 'am.staff@test.fr',
prenom: 'Marie',
nom: 'TEST',
telephone: '0689567890',
consentement_photo: false,
lieu_naissance_ville: 'Paris',
lieu_naissance_pays: 'France',
nir: '285017512345678',
numero_agrement: 'AGR-TEST-001',
capacite_accueil: 3,
places_disponibles: 2,
};
const res = await controller.createDossier(body as any);
expect(authServiceMock.createAmDossierStaff).toHaveBeenCalledWith(
expect.objectContaining({
email: body.email,
acceptation_cgu: true,
acceptation_privacy: true,
}),
);
expect(res.numero_dossier).toBe('2026-000001');
});
}); });
@@ -7,6 +7,8 @@ import {
Param, Param,
Delete, Delete,
UseGuards, UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common'; } from '@nestjs/common';
import { AssistantesMaternellesService } from './assistantes_maternelles.service'; import { AssistantesMaternellesService } from './assistantes_maternelles.service';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
@@ -16,17 +18,49 @@ import { RoleType, Users } from 'src/entities/users.entity';
import { CreateAssistanteDto } from '../user/dto/create_assistante.dto'; import { CreateAssistanteDto } from '../user/dto/create_assistante.dto';
import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto'; import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto';
import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto'; import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto';
import { StaffCreateAmDossierDto } from './dto/staff-create-am-dossier.dto';
import { StaffCreateAmDossierResponseDto } from './dto/staff-create-am-dossier-response.dto';
import { RolesGuard } from 'src/common/guards/roles.guard'; import { RolesGuard } from 'src/common/guards/roles.guard';
import { AuthGuard } from 'src/common/guards/auth.guard'; import { AuthGuard } from 'src/common/guards/auth.guard';
import { User } from 'src/common/decorators/user.decorator'; import { User } from 'src/common/decorators/user.decorator';
import { mapAmForApi, mapAmsForApi } from './assistantes_maternelles.mapper'; import { mapAmForApi, mapAmsForApi } from './assistantes_maternelles.mapper';
import { AuthService } from '../auth/auth.service';
import { RegisterAMCompletDto } from '../auth/dto/register-am-complet.dto';
@ApiTags("Assistantes Maternelles") @ApiTags("Assistantes Maternelles")
@ApiBearerAuth('access-token') @ApiBearerAuth('access-token')
@UseGuards(AuthGuard, RolesGuard) @UseGuards(AuthGuard, RolesGuard)
@Controller('assistantes-maternelles') @Controller('assistantes-maternelles')
export class AssistantesMaternellesController { export class AssistantesMaternellesController {
constructor(private readonly assistantesMaternellesService: AssistantesMaternellesService) { } constructor(
private readonly assistantesMaternellesService: AssistantesMaternellesService,
private readonly authService: AuthService,
) { }
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Post('dossier')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Créer un dossier AM complet (staff) — ticket #156',
description:
'Crée user + fiche AM avec statut actif, n° dossier, et envoie le-mail de création de mot de passe. ' +
'Ne pas utiliser POST /auth/register/am depuis le dashboard.',
})
@ApiBody({ type: StaffCreateAmDossierDto })
@ApiResponse({ status: 201, type: StaffCreateAmDossierResponseDto })
@ApiResponse({ status: 400, description: 'Validation métier / NIR' })
@ApiResponse({ status: 403, description: 'Rôle non autorisé' })
@ApiResponse({ status: 409, description: 'Email / NIR / agrément déjà pris' })
async createDossier(
@Body() dto: StaffCreateAmDossierDto,
): Promise<StaffCreateAmDossierResponseDto> {
const registerDto = {
...dto,
acceptation_cgu: true,
acceptation_privacy: true,
} as RegisterAMCompletDto;
return this.authService.createAmDossierStaff(registerDto);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Créer nounou' }) @ApiOperation({ summary: 'Créer nounou' })
@@ -0,0 +1,23 @@
import { ApiProperty } from '@nestjs/swagger';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Réponse 201 POST /assistantes-maternelles/dossier (#156). */
export class StaffCreateAmDossierResponseDto {
@ApiProperty()
message: string;
@ApiProperty({ format: 'uuid' })
user_id: string;
@ApiProperty({
enum: StatutUtilisateurType,
example: StatutUtilisateurType.ACTIF,
})
statut: StatutUtilisateurType;
@ApiProperty({
example: '2026-000042',
description: 'Numéro de dossier attribué',
})
numero_dossier: string;
}
@@ -0,0 +1,29 @@
import { ApiPropertyOptional, OmitType } from '@nestjs/swagger';
import { IsBoolean, IsOptional } from 'class-validator';
import { RegisterAMCompletDto } from 'src/routes/auth/dto/register-am-complet.dto';
/**
* Création dossier AM par staff (#156).
* Mêmes champs que l'inscription publique, sans CGU/privacy obligatoires
* (acceptées côté serveur pour le compte du gestionnaire).
*/
export class StaffCreateAmDossierDto extends OmitType(RegisterAMCompletDto, [
'acceptation_cgu',
'acceptation_privacy',
] as const) {
@ApiPropertyOptional({
description: 'Ignoré côté staff (CGU acceptées serveur). Conservé pour compat éventuelle.',
default: true,
})
@IsOptional()
@IsBoolean()
acceptation_cgu?: boolean;
@ApiPropertyOptional({
description: 'Ignoré côté staff (privacy acceptée serveur).',
default: true,
})
@IsOptional()
@IsBoolean()
acceptation_privacy?: boolean;
}
+148 -62
View File
@@ -552,7 +552,8 @@ export class AuthService {
: undefined; : undefined;
enfant.photo_url = urlPhoto || undefined; enfant.photo_url = urlPhoto || undefined;
enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE; enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE;
enfant.consent_photo = false; enfant.consent_photo = !!enfantDto.consent_photo;
enfant.consent_photo_at = enfant.consent_photo ? new Date() : null!;
enfant.is_multiple = enfantDto.grossesse_multiple || false; enfant.is_multiple = enfantDto.grossesse_multiple || false;
const enfantEnregistre = await manager.save(Children, enfant); const enfantEnregistre = await manager.save(Children, enfant);
@@ -644,11 +645,28 @@ export class AuthService {
} }
/** /**
* Inscription Assistante Maternelle COMPLÈTE - Un seul endpoint (identité + pro + photo + CGU) * Cœur partagé création dossier AM (#156).
* Crée User (role AM) + entrée assistantes_maternelles, token création MDP * - public : statut en_attente + mail pending
* - staff : statut actif + mail création MDP
*/ */
async inscrireAMComplet(dto: RegisterAMCompletDto) { async createAmDossier(
if (!dto.acceptation_cgu || !dto.acceptation_privacy) { dto: RegisterAMCompletDto,
options: {
statut: StatutUtilisateurType;
sendPendingEmail: boolean;
sendPasswordSetupEmail: boolean;
requireCgu: boolean;
logContext?: string;
},
): Promise<{
message: string;
user_id: string;
statut: StatutUtilisateurType;
numero_dossier: string;
}> {
const logCtx = options.logContext ?? 'createAmDossier';
if (options.requireCgu && (!dto.acceptation_cgu || !dto.acceptation_privacy)) {
throw new BadRequestException( throw new BadRequestException(
"L'acceptation des CGU et de la politique de confidentialité est obligatoire", "L'acceptation des CGU et de la politique de confidentialité est obligatoire",
); );
@@ -668,8 +686,7 @@ export class AuthService {
throw new BadRequestException(nirValidation.error || 'NIR invalide'); throw new BadRequestException(nirValidation.error || 'NIR invalide');
} }
if (nirValidation.warning) { if (nirValidation.warning) {
// Warning uniquement : on ne bloque pas (AM souvent étrangères, DOM-TOM, Corse) console.warn(`[${logCtx}] NIR warning:`, nirValidation.warning, 'email=', dto.email);
console.warn('[inscrireAMComplet] NIR warning:', nirValidation.warning, 'email=', dto.email);
} }
const existe = await this.usersService.findByEmailOrNull(dto.email); const existe = await this.usersService.findByEmailOrNull(dto.email);
@@ -720,79 +737,142 @@ export class AuthService {
let resultat: { user: Users }; let resultat: { user: Users };
try { try {
resultat = await this.usersRepo.manager.transaction(async (manager) => { resultat = await this.usersRepo.manager.transaction(async (manager) => {
const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager); const { numero: numeroDossier } =
await this.numeroDossierService.getNextNumeroDossier(manager);
const user = manager.create(Users, { const user = manager.create(Users, {
email: dto.email, email: dto.email,
prenom: dto.prenom, prenom: dto.prenom,
nom: dto.nom, nom: dto.nom,
role: RoleType.ASSISTANTE_MATERNELLE, role: RoleType.ASSISTANTE_MATERNELLE,
statut: StatutUtilisateurType.EN_ATTENTE, statut: options.statut,
telephone: dto.telephone, telephone: dto.telephone,
adresse: dto.adresse, adresse: dto.adresse,
code_postal: dto.code_postal, code_postal: dto.code_postal,
ville: dto.ville, ville: dto.ville,
token_creation_mdp: tokenCreationMdp, token_creation_mdp: tokenCreationMdp,
token_creation_mdp_expire_le: dateExpiration, token_creation_mdp_expire_le: dateExpiration,
photo_url: urlPhoto ?? undefined, photo_url: urlPhoto ?? undefined,
consentement_photo: dto.consentement_photo, consentement_photo: dto.consentement_photo,
date_consentement_photo: dateConsentementPhoto, date_consentement_photo: dateConsentementPhoto,
date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined, date_naissance: dto.date_naissance
lieu_naissance_ville: dto.lieu_naissance_ville, ? new Date(dto.date_naissance)
lieu_naissance_pays: dto.lieu_naissance_pays, : undefined,
numero_dossier: numeroDossier, lieu_naissance_ville: dto.lieu_naissance_ville,
lieu_naissance_pays: dto.lieu_naissance_pays,
numero_dossier: numeroDossier,
});
const userEnregistre = await manager.save(Users, user);
const amRepo = manager.getRepository(AssistanteMaternelle);
const am = amRepo.create({
user_id: userEnregistre.id,
approval_number: dto.numero_agrement,
nir: nirNormalized,
max_children: dto.capacite_accueil,
places_available: dto.places_disponibles,
biography: dto.biographie,
residence_city: dto.ville ?? undefined,
agreement_date: dto.date_agrement
? new Date(dto.date_agrement)
: undefined,
available: true,
numero_dossier: numeroDossier,
});
await amRepo.save(am);
return { user: userEnregistre };
}); });
const userEnregistre = await manager.save(Users, user);
const amRepo = manager.getRepository(AssistanteMaternelle);
const am = amRepo.create({
user_id: userEnregistre.id,
approval_number: dto.numero_agrement,
nir: nirNormalized,
max_children: dto.capacite_accueil,
places_available: dto.places_disponibles,
biography: dto.biographie,
residence_city: dto.ville ?? undefined,
agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined,
available: true,
numero_dossier: numeroDossier,
});
await amRepo.save(am);
return { user: userEnregistre };
});
} catch (err) { } catch (err) {
if (this.isPostgresUniqueViolation(err)) { if (this.isPostgresUniqueViolation(err)) {
throw new ConflictException('Un compte avec cet email existe déjà (contrainte unique en base).'); throw new ConflictException(
'Un compte avec cet email existe déjà (contrainte unique en base).',
);
} }
throw err; throw err;
} }
const numeroDossier = resultat.user.numero_dossier ?? ''; const numeroDossier = resultat.user.numero_dossier ?? '';
try { if (options.sendPendingEmail) {
await this.mailService.sendRegistrationPendingEmail( try {
resultat.user.email, await this.mailService.sendRegistrationPendingEmail(
resultat.user.prenom ?? '', resultat.user.email,
resultat.user.nom ?? '', resultat.user.prenom ?? '',
numeroDossier, resultat.user.nom ?? '',
); numeroDossier,
} catch (err) { );
this.logger.error( } catch (err) {
"[inscrireAMComplet] Échec envoi email d'accusé de réception (inscription conservée)", this.logger.error(
err instanceof Error ? err.stack : String(err), `[${logCtx}] Échec envoi email d'accusé de réception (inscription conservée)`,
); err instanceof Error ? err.stack : String(err),
);
}
} }
if (options.sendPasswordSetupEmail) {
try {
await this.mailService.sendValidatedAccountPasswordSetupEmail(
{
email: resultat.user.email,
prenom: resultat.user.prenom ?? '',
nom: resultat.user.nom ?? '',
token: tokenCreationMdp,
numeroDossier,
},
'am',
);
} catch (err) {
this.logger.error(
`[${logCtx}] Échec envoi email création MDP (dossier conservé)`,
err instanceof Error ? err.stack : String(err),
);
}
}
const message =
options.statut === StatutUtilisateurType.ACTIF
? 'Dossier AM créé et validé. Un e-mail de création de mot de passe a été envoyé.'
: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.';
return { return {
message: message,
'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
user_id: resultat.user.id, user_id: resultat.user.id,
statut: StatutUtilisateurType.EN_ATTENTE, statut: options.statut,
numero_dossier: numeroDossier, numero_dossier: numeroDossier,
}; };
} }
/**
* Inscription Assistante Maternelle COMPLÈTE - Un seul endpoint (identité + pro + photo + CGU)
* Crée User (role AM) + entrée assistantes_maternelles, token création MDP
*/
async inscrireAMComplet(dto: RegisterAMCompletDto) {
return this.createAmDossier(dto, {
statut: StatutUtilisateurType.EN_ATTENTE,
sendPendingEmail: true,
sendPasswordSetupEmail: false,
requireCgu: true,
logContext: 'inscrireAMComplet',
});
}
/**
* Création dossier AM par staff (#156) — statut actif + e-mail création MDP.
*/
async createAmDossierStaff(dto: RegisterAMCompletDto) {
return this.createAmDossier(dto, {
statut: StatutUtilisateurType.ACTIF,
sendPendingEmail: false,
sendPasswordSetupEmail: true,
requireCgu: false,
logContext: 'createAmDossierStaff',
});
}
/**
* Sauvegarde une photo depuis base64 vers le système de fichiers
*/
/** /**
* Sauvegarde une photo depuis base64 vers le système de fichiers * Sauvegarde une photo depuis base64 vers le système de fichiers
*/ */
@@ -1074,6 +1154,12 @@ export class AuthService {
if (enfantDto.grossesse_multiple !== undefined) { if (enfantDto.grossesse_multiple !== undefined) {
enfant.is_multiple = enfantDto.grossesse_multiple; enfant.is_multiple = enfantDto.grossesse_multiple;
} }
if (enfantDto.consent_photo !== undefined) {
enfant.consent_photo = !!enfantDto.consent_photo;
enfant.consent_photo_at = enfant.consent_photo
? new Date()
: null!;
}
if (enfantDto.photo_base64 && enfantDto.photo_filename) { if (enfantDto.photo_base64 && enfantDto.photo_filename) {
enfant.photo_url = await this.sauvegarderPhotoDepuisBase64( enfant.photo_url = await this.sauvegarderPhotoDepuisBase64(
@@ -59,5 +59,14 @@ export class EnfantInscriptionDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
grossesse_multiple?: boolean; grossesse_multiple?: boolean;
@ApiProperty({
example: true,
required: false,
description: 'Consentement affichage / stockage photo (colonne enfants.consentement_photo)',
})
@IsOptional()
@IsBoolean()
consent_photo?: boolean;
} }
@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { import {
IsBoolean, IsBoolean,
IsDateString, IsDateString,
@@ -6,11 +7,23 @@ import {
IsNotEmpty, IsNotEmpty,
IsOptional, IsOptional,
IsString, IsString,
IsUUID,
MaxLength, MaxLength,
ValidateIf, ValidateIf,
} from 'class-validator'; } from 'class-validator';
import { GenreType, StatutEnfantType } from 'src/entities/children.entity'; import { GenreType, StatutEnfantType } from 'src/entities/children.entity';
/** Multipart envoie des strings ("true"/"false") — JSON envoie déjà des booleans. */
function toBoolean({ value }: { value: unknown }): boolean | unknown {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
const v = value.trim().toLowerCase();
if (v === 'true' || v === '1') return true;
if (v === 'false' || v === '0' || v === '') return false;
}
return value;
}
export class CreateEnfantsDto { export class CreateEnfantsDto {
@ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE }) @ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE })
@IsEnum(StatutEnfantType) @IsEnum(StatutEnfantType)
@@ -52,6 +65,7 @@ export class CreateEnfantsDto {
photo_url?: string; photo_url?: string;
@ApiProperty({ default: false }) @ApiProperty({ default: false })
@Transform(toBoolean)
@IsBoolean() @IsBoolean()
consent_photo: boolean; consent_photo: boolean;
@@ -61,6 +75,20 @@ export class CreateEnfantsDto {
consent_photo_at?: string; consent_photo_at?: string;
@ApiProperty({ default: false }) @ApiProperty({ default: false })
@Transform(toBoolean)
@IsBoolean() @IsBoolean()
is_multiple: boolean; is_multiple: boolean;
/**
* Parent pivot du foyer — obligatoire pour staff (gestionnaire/admin).
* Ignoré / interdit en externe pour un PARENT (ticket #132).
*/
@ApiPropertyOptional({
description:
'UUID du parent pivot (staff only). Obligatoire pour GESTIONNAIRE / ADMIN / SUPER_ADMIN.',
format: 'uuid',
})
@IsOptional()
@IsUUID('4')
parent_user_id?: string;
} }
@@ -1,20 +1,33 @@
import { import {
Body, Body,
CallHandler,
Controller, Controller,
Delete, Delete,
ExecutionContext,
Get, Get,
HttpCode,
HttpStatus,
Injectable,
NestInterceptor,
Param, Param,
ParseUUIDPipe, ParseUUIDPipe,
Patch, Patch,
Post, Post,
UploadedFile,
UseGuards, UseGuards,
UseInterceptors, UseInterceptors,
UploadedFile,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiTags, ApiConsumes } from '@nestjs/swagger'; import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { diskStorage } from 'multer'; import { diskStorage } from 'multer';
import { extname } from 'path'; import { extname } from 'path';
import { Observable } from 'rxjs';
import { EnfantsService } from './enfants.service'; import { EnfantsService } from './enfants.service';
import { CreateEnfantsDto } from './dto/create_enfants.dto'; import { CreateEnfantsDto } from './dto/create_enfants.dto';
import { UpdateEnfantsDto } from './dto/update_enfants.dto'; import { UpdateEnfantsDto } from './dto/update_enfants.dto';
@@ -24,6 +37,47 @@ import { AuthGuard } from 'src/common/guards/auth.guard';
import { Roles } from 'src/common/decorators/roles.decorator'; import { Roles } from 'src/common/decorators/roles.decorator';
import { RolesGuard } from 'src/common/guards/roles.guard'; import { RolesGuard } from 'src/common/guards/roles.guard';
const photoMulterOptions = {
storage: diskStorage({
destination: './uploads/photos',
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const ext = extname(file.originalname);
cb(null, `enfant-${uniqueSuffix}${ext}`);
},
}),
fileFilter: (req, file, cb) => {
if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) {
return cb(new Error('Seules les images sont autorisées'), false);
}
cb(null, true);
},
limits: {
fileSize: 5 * 1024 * 1024,
},
};
/**
* Multer uniquement si Content-Type multipart (parent ou staff + photo).
* JSON sans photo (#132) passe sans interceptor fichier.
*/
@Injectable()
class OptionalEnfantPhotoInterceptor implements NestInterceptor {
private readonly multipart = new (FileInterceptor(
'photo',
photoMulterOptions,
))();
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> | Promise<Observable<unknown>> {
const req = context.switchToHttp().getRequest();
const ct = String(req.headers['content-type'] ?? '');
if (!ct.includes('multipart/form-data')) {
return next.handle();
}
return this.multipart.intercept(context, next);
}
}
@ApiBearerAuth('access-token') @ApiBearerAuth('access-token')
@ApiTags('Enfants') @ApiTags('Enfants')
@UseGuards(AuthGuard, RolesGuard) @UseGuards(AuthGuard, RolesGuard)
@@ -31,30 +85,27 @@ import { RolesGuard } from 'src/common/guards/roles.guard';
export class EnfantsController { export class EnfantsController {
constructor(private readonly enfantsService: EnfantsService) { } constructor(private readonly enfantsService: EnfantsService) { }
@Roles(RoleType.PARENT) @Roles(
@Post() RoleType.PARENT,
@ApiConsumes('multipart/form-data') RoleType.GESTIONNAIRE,
@UseInterceptors( RoleType.ADMINISTRATEUR,
FileInterceptor('photo', { RoleType.SUPER_ADMIN,
storage: diskStorage({
destination: './uploads/photos',
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const ext = extname(file.originalname);
cb(null, `enfant-${uniqueSuffix}${ext}`);
},
}),
fileFilter: (req, file, cb) => {
if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) {
return cb(new Error('Seules les images sont autorisées'), false);
}
cb(null, true);
},
limits: {
fileSize: 5 * 1024 * 1024,
},
}),
) )
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Créer un enfant',
description:
'PARENT : multipart éventuel, rattache au compte connecté. ' +
'Staff : parent_user_id obligatoire ; JSON sans photo OK ; avec photo → multipart (champ fichier `photo`, max 5 Mo). Ticket #132.',
})
@ApiConsumes('application/json', 'multipart/form-data')
@ApiBody({
description:
'Champs métier (+ parent_user_id côté staff). Fichier optionnel `photo` en multipart.',
type: CreateEnfantsDto,
})
@UseInterceptors(OptionalEnfantPhotoInterceptor)
create( create(
@Body() dto: CreateEnfantsDto, @Body() dto: CreateEnfantsDto,
@UploadedFile() photo: Express.Multer.File, @UploadedFile() photo: Express.Multer.File,
+117 -31
View File
@@ -13,6 +13,12 @@ import { ParentsChildren } from 'src/entities/parents_children.entity';
import { RoleType, Users } from 'src/entities/users.entity'; import { RoleType, Users } from 'src/entities/users.entity';
import { CreateEnfantsDto } from './dto/create_enfants.dto'; import { CreateEnfantsDto } from './dto/create_enfants.dto';
const STAFF_ROLES: RoleType[] = [
RoleType.GESTIONNAIRE,
RoleType.ADMINISTRATEUR,
RoleType.SUPER_ADMIN,
];
@Injectable() @Injectable()
export class EnfantsService { export class EnfantsService {
constructor( constructor(
@@ -24,20 +30,35 @@ export class EnfantsService {
private readonly parentsChildrenRepository: Repository<ParentsChildren>, private readonly parentsChildrenRepository: Repository<ParentsChildren>,
) { } ) { }
// Création d'un enfant private isStaff(user: Users): boolean {
async create(dto: CreateEnfantsDto, currentUser: Users, photoFile?: Express.Multer.File): Promise<Children> { return STAFF_ROLES.includes(user.role);
}
/**
* Création d'un enfant.
* - PARENT : rattache au parent connecté (multipart photo optionnel).
* - Staff : `parent_user_id` obligatoire ; JSON sans photo OK ;
* avec photo → multipart (même stockage `/uploads/photos/...`). Ticket #132.
*/
async create(
dto: CreateEnfantsDto,
currentUser: Users,
photoFile?: Express.Multer.File,
): Promise<Children> {
const pivotUserId = this.resolvePivotParentUserId(dto, currentUser);
const parent = await this.parentsRepository.findOne({ const parent = await this.parentsRepository.findOne({
where: { user_id: currentUser.id }, where: { user_id: pivotUserId },
relations: ['co_parent'], relations: ['co_parent'],
}); });
if (!parent) throw new NotFoundException('Parent introuvable'); if (!parent) throw new NotFoundException('Parent introuvable');
// Vérif métier simple // Vérif métier simple (aligné comportement historique parent)
if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) { if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) {
throw new BadRequestException('Un enfant né doit avoir une date de naissance'); throw new BadRequestException('Un enfant né doit avoir une date de naissance');
} }
// Vérif doublon éventuel (ex: même prénom + date de naissance pour ce parent) // Vérif doublon éventuel (ex: même prénom + date de naissance)
const exist = await this.childrenRepository.findOne({ const exist = await this.childrenRepository.findOne({
where: { where: {
first_name: dto.first_name, first_name: dto.first_name,
@@ -47,50 +68,108 @@ export class EnfantsService {
}); });
if (exist) throw new ConflictException('Cet enfant existe déjà'); if (exist) throw new ConflictException('Cet enfant existe déjà');
// Gestion de la photo uploadée // Gestion de la photo uploadée (multipart parent ou staff)
let photoUrl = dto.photo_url;
let consentAt: Date | undefined;
if (photoFile) { if (photoFile) {
dto.photo_url = `/uploads/photos/${photoFile.filename}`; photoUrl = `/uploads/photos/${photoFile.filename}`;
if (dto.consent_photo) { if (dto.consent_photo) {
dto.consent_photo_at = new Date().toISOString(); consentAt = new Date();
} }
} else if (dto.consent_photo) {
consentAt = dto.consent_photo_at
? new Date(dto.consent_photo_at)
: new Date();
} }
// Création const child = this.childrenRepository.create({
const child = this.childrenRepository.create(dto); status: dto.status,
first_name: dto.first_name,
last_name: dto.last_name,
gender: dto.gender,
birth_date: dto.birth_date ? new Date(dto.birth_date) : undefined,
due_date: dto.due_date ? new Date(dto.due_date) : undefined,
photo_url: photoUrl,
consent_photo: !!dto.consent_photo,
consent_photo_at: consentAt,
is_multiple: !!dto.is_multiple,
});
await this.childrenRepository.save(child); await this.childrenRepository.save(child);
// Lien parent-enfant (Parent 1) // Lien parent-enfant (pivot)
const parentLink = this.parentsChildrenRepository.create({ await this.parentsChildrenRepository.save(
parentId: parent.user_id, this.parentsChildrenRepository.create({
enfantId: child.id, parentId: parent.user_id,
}); enfantId: child.id,
await this.parentsChildrenRepository.save(parentLink); }),
);
// Rattachement automatique au co-parent s'il existe // Rattachement automatique au co-parent s'il existe
if (parent.co_parent) { if (parent.co_parent) {
const coParentLink = this.parentsChildrenRepository.create({ await this.parentsChildrenRepository.save(
parentId: parent.co_parent.id, this.parentsChildrenRepository.create({
enfantId: child.id, parentId: parent.co_parent.id,
}); enfantId: child.id,
await this.parentsChildrenRepository.save(coParentLink); }),
);
} }
return this.findOne(child.id, currentUser); return this.findOne(child.id, currentUser);
} }
// Liste des enfants (admin/gestionnaire) private resolvePivotParentUserId(
async findAll(): Promise<Children[]> { dto: CreateEnfantsDto,
return this.childrenRepository.find({ currentUser: Users,
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'], ): string {
order: { last_name: 'ASC', first_name: 'ASC' }, if (this.isStaff(currentUser)) {
const id = dto.parent_user_id?.trim();
if (!id) {
throw new BadRequestException(
'parent_user_id est obligatoire pour créer un enfant (staff)',
);
}
return id;
}
if (currentUser.role === RoleType.PARENT) {
if (
dto.parent_user_id &&
dto.parent_user_id.trim() !== currentUser.id
) {
throw new ForbiddenException(
'Un parent ne peut pas créer un enfant pour un autre compte',
);
}
return currentUser.id;
}
throw new ForbiddenException('Accès interdit');
}
/** Flag API #157 — true si aucun lien enfants_parents. */
private withSansResponsable(child: Children): Children & { sans_responsable: boolean } {
return Object.assign(child, {
sans_responsable: !child.parentLinks || child.parentLinks.length === 0,
}); });
} }
// Liste des enfants (admin/gestionnaire) — inclut les orphelins (parentLinks: [])
async findAll(): Promise<Array<Children & { sans_responsable: boolean }>> {
const children = await this.childrenRepository.find({
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
order: { last_name: 'ASC', first_name: 'ASC' },
});
return children.map((c) => this.withSansResponsable(c));
}
// Récupérer un enfant par id // Récupérer un enfant par id
async findOne(id: string, currentUser: Users): Promise<Children> { async findOne(
id: string,
currentUser: Users,
): Promise<Children & { sans_responsable: boolean }> {
const child = await this.childrenRepository.findOne({ const child = await this.childrenRepository.findOne({
where: { id }, where: { id },
relations: ['parentLinks'], relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
}); });
if (!child) throw new NotFoundException('Enfant introuvable'); if (!child) throw new NotFoundException('Enfant introuvable');
@@ -104,14 +183,14 @@ export class EnfantsService {
case RoleType.ADMINISTRATEUR: case RoleType.ADMINISTRATEUR:
case RoleType.SUPER_ADMIN: case RoleType.SUPER_ADMIN:
case RoleType.GESTIONNAIRE: case RoleType.GESTIONNAIRE:
// accès complet // accès complet (y compris orphelins)
break; break;
default: default:
throw new ForbiddenException('Accès interdit'); throw new ForbiddenException('Accès interdit');
} }
return child; return this.withSansResponsable(child);
} }
@@ -120,7 +199,14 @@ export class EnfantsService {
const child = await this.childrenRepository.findOne({ where: { id } }); const child = await this.childrenRepository.findOne({ where: { id } });
if (!child) throw new NotFoundException('Enfant introuvable'); if (!child) throw new NotFoundException('Enfant introuvable');
await this.childrenRepository.update(id, dto); const { parent_user_id: _ignored, ...rest } = dto;
const patch: Partial<Children> = { ...rest } as Partial<Children>;
if (dto.consent_photo !== undefined) {
patch.consent_photo = dto.consent_photo;
patch.consent_photo_at = dto.consent_photo ? new Date() : null!;
}
await this.childrenRepository.update(id, patch);
return this.findOne(id, currentUser); return this.findOne(id, currentUser);
} }
+71 -19
View File
@@ -114,34 +114,86 @@ export class ParentsService {
} }
/** /**
* Rattacher un enfant existant à un parent (enfants_parents). Ticket #115 / doc 28 §6.2. * Membres du foyer (user ids) pour affiliation enfant.
* Pivot + co-parent (A→B et B→A) + même numero_dossier. Ticket #158.
*/ */
async attachEnfant(parentUserId: string, enfantId: string): Promise<Parents> { private async resolveFoyerParentUserIds(parent: Parents): Promise<string[]> {
await this.findOne(parentUserId); const ids = new Set<string>([parent.user_id]);
const existing = await this.parentsChildrenRepository.findOne({ if (parent.co_parent?.id) {
where: { parentId: parentUserId, enfantId }, ids.add(parent.co_parent.id);
});
if (existing) {
throw new ConflictException('Cet enfant est déjà rattaché à ce parent');
} }
const child = await this.parentsRepository.manager.findOne(Children, { where: { id: enfantId } }); // Sens inverse : parents qui déclarent ce user comme co-parent
const reverseLinks = await this.parentsRepository.find({
where: { co_parent: { id: parent.user_id } },
relations: ['co_parent'],
});
for (const p of reverseLinks) {
ids.add(p.user_id);
if (p.co_parent?.id) ids.add(p.co_parent.id);
}
const dossier = parent.numero_dossier?.trim();
if (dossier) {
const sameDossier = await this.parentsRepository.find({
where: { numero_dossier: dossier },
relations: ['co_parent'],
});
for (const p of sameDossier) {
ids.add(p.user_id);
if (p.co_parent?.id) ids.add(p.co_parent.id);
}
}
return [...ids];
}
/**
* Rattacher un enfant au foyer du parent (tous les responsables). Ticket #158.
* Un seul POST suffit : liens créés pour pivot + co-parent / même dossier.
*/
async attachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
const parent = await this.findOne(parentUserId);
const child = await this.parentsRepository.manager.findOne(Children, {
where: { id: enfantId },
});
if (!child) { if (!child) {
throw new NotFoundException('Enfant introuvable'); throw new NotFoundException('Enfant introuvable');
} }
await this.parentsChildrenRepository.save( const foyerIds = await this.resolveFoyerParentUserIds(parent);
this.parentsChildrenRepository.create({ parentId: parentUserId, enfantId }), let created = 0;
);
for (const memberId of foyerIds) {
const existing = await this.parentsChildrenRepository.findOne({
where: { parentId: memberId, enfantId },
});
if (existing) continue;
await this.parentsChildrenRepository.save(
this.parentsChildrenRepository.create({
parentId: memberId,
enfantId,
}),
);
created += 1;
}
if (created === 0) {
throw new ConflictException('Cet enfant est déjà rattaché à ce foyer');
}
return this.findOne(parentUserId); return this.findOne(parentUserId);
} }
/** /**
* Détacher un enfant d'un parent sans supprimer l'enfant. Ticket #115 / doc 28 §6.2. * Détacher un enfant du foyer du parent (tous les responsables). Ticket #158.
* Si plus aucun lien ensuite → enfant orphelin (#157).
*/ */
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> { async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
await this.findOne(parentUserId); const parent = await this.findOne(parentUserId);
const link = await this.parentsChildrenRepository.findOne({ const link = await this.parentsChildrenRepository.findOne({
where: { parentId: parentUserId, enfantId }, where: { parentId: parentUserId, enfantId },
@@ -150,12 +202,12 @@ export class ParentsService {
throw new NotFoundException('Lien parent-enfant introuvable'); throw new NotFoundException('Lien parent-enfant introuvable');
} }
const totalLinks = await this.parentsChildrenRepository.count({ where: { enfantId } }); const foyerIds = await this.resolveFoyerParentUserIds(parent);
if (totalLinks <= 1) { await this.parentsChildrenRepository.delete({
throw new BadRequestException('Un enfant doit rester rattaché à au moins un responsable'); parentId: In(foyerIds),
} enfantId,
});
await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId });
return this.findOne(parentUserId); return this.findOne(parentUserId);
} }
@@ -24,15 +24,19 @@ export class RelaisController {
} }
@Get() @Get()
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Lister tous les relais' }) @ApiOperation({
summary: 'Lister tous les relais',
description:
'Lecture ouverte aux gestionnaires (combobox fiches). CRUD write reste admin-only. Ticket #151.',
})
@ApiResponse({ status: 200, description: 'Liste des relais.' }) @ApiResponse({ status: 200, description: 'Liste des relais.' })
findAll() { findAll() {
return this.relaisService.findAll(); return this.relaisService.findAll();
} }
@Get(':id') @Get(':id')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Récupérer un relais par ID' }) @ApiOperation({ summary: 'Récupérer un relais par ID' })
@ApiResponse({ status: 200, description: 'Le relais trouvé.' }) @ApiResponse({ status: 200, description: 'Le relais trouvé.' })
findOne(@Param('id') id: string) { findOne(@Param('id') id: string) {
@@ -0,0 +1,104 @@
# Analyse dempreinte — P'titsPas
**Date :** 2026-07-22
**Contexte :** snapshot pour mémoire (après alerte disque plein + purge cache Gitea)
**Périmètre :** application `jmartin/petitspas` déployée sur le VPS (`/home/deploy/dev/ptitspas-app`)
---
## 1. Lignes de code
Comptage brut (`wc -l`), hors `node_modules` / builds / `.dart_tool`.
| Zone | Lignes | Fichiers |
|------|--------|----------|
| **Frontend** (Dart `frontend/lib/`) | ~29 800 | 141 |
| **Backend** (TypeScript `backend/src/`) | ~10 000 | 141 |
| **BDD** (SQL sous `database/`) | ~1 250 | ~15 |
| **Total** | **~41 000** | |
### Frontend (détail)
| Dossier | Lignes |
|---------|--------|
| `widgets/` | ~18 400 |
| `screens/` | ~5 000 |
| `services/` | ~2 300 |
| `models/` | ~2 200 |
| `utils/` | ~1 400 |
| reste | ~500 |
### Backend (détail)
| Dossier | Lignes |
|---------|--------|
| `routes/` | ~6 500 |
| `modules/` | ~1 700 |
| `entities/` | ~1 000 |
| `common/` + `config/` | ~400 |
### BDD (détail)
| Élément | Lignes |
|---------|--------|
| `BDD.sql` (schéma) | ~470 |
| seeds | ~300 |
| migrations / patches | ~280 |
| tests SQL | ~205 |
---
## 2. Empreinte disque — runtime (Docker)
| Composant | Taille | Notes |
|-----------|--------|--------|
| Image `ptitspas-app-backend` | ~300 Mo | NestJS |
| Image `ptitspas-app-frontend` | ~122 Mo | Flutter web + Nginx |
| Image `postgres:17` | ~454 Mo | |
| Image `dpage/pgadmin4` | ~534 Mo | optionnel |
| Volume `postgres_data` | ~49 Mo | données BDD live |
| Volume `backend_uploads` | ~20 Mo | photos |
| Volume `backend_documents_legaux` | ~0 | |
| **Stack complète (avec pgAdmin)** | **~1,5 Go** | |
| **Stack prod sans pgAdmin** | **~945 Mo** | |
---
## 3. Empreinte disque — code source
| Élément | Taille |
|---------|--------|
| Clone `/home/deploy/dev/ptitspas-app` | ~701 Mo |
| dont `backend/node_modules` | ~354 Mo |
| dont `frontend` (+ `.dart_tool`) | ~114 Mo |
| **Code utile** (hors deps / `.git` / builds) | **~51 Mo** |
| Repo Gitea `petitspas.git` | ~109 Mo |
| Dump `database/BDD.sql` | ~19 Ko |
---
## 4. Pour (re)déployer
Minimum requis :
1. Images custom backend + frontend (~422 Mo), ou code + build Docker
2. Image `postgres:17` (~454 Mo)
3. Volumes persistants (~70 Mo au snapshot)
4. Optionnel : pgAdmin (~534 Mo)
**Ordre de grandeur :** ~1 Go pour faire tourner lapp en prod (sans pgAdmin).
---
## 5. Notes infra du jour (lié)
- Disque VPS passé de **100 %** à ~**44 %** après :
- purge cache Docker / journals
- purge officielle Gitea `delete_repo_archives` (~24 Go de zip/bundle `petitspas`)
- Crons Gitea activés :
- `archive_cleanup` @midnight (`OLDER_THAN = 24h`)
- `delete_repo_archives` @weekly
---
*Fichier généré pour historique projet — ne pas considérer comme métrique CI automatisée.*
+75
View File
@@ -0,0 +1,75 @@
# Mini-spec API — POST /assistantes-maternelles/dossier (#156)
Contrat pour le **plan front** (wizard création AM staff).
## Endpoint
| | |
|--|--|
| **Méthode** | `POST` |
| **URL** | `{base}/assistantes-maternelles/dossier` |
| **Auth** | Bearer JWT |
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
| **Content-Type** | `application/json` |
Ne **pas** appeler `POST /auth/register/am` depuis le dashboard.
## Body (JSON)
Aligné inscription AM publique, **sans** CGU/privacy obligatoires (acceptées serveur).
| Champ | Type | Obligatoire | Notes |
|-------|------|-------------|--------|
| `email` | string | oui | unique |
| `prenom` | string | oui | |
| `nom` | string | oui | |
| `telephone` | string | oui | `0X…` ou `+33…` |
| `adresse` | string | non | |
| `code_postal` | string | non | |
| `ville` | string | non | |
| `photo_base64` | string | non | data-URL `data:image/…;base64,…` |
| `photo_filename` | string | non | hint nom fichier |
| `consentement_photo` | bool | oui | |
| `date_naissance` | date ISO | non | `YYYY-MM-DD` |
| `lieu_naissance_ville` | string | oui | |
| `lieu_naissance_pays` | string | oui | |
| `nir` | string | oui | 15 car. (Corse 2A/2B OK) |
| `numero_agrement` | string | oui | unique |
| `date_agrement` | date ISO | non | |
| `capacite_accueil` | int | oui | 110 |
| `places_disponibles` | int | oui | 010, ≤ capacité |
| `biographie` | string | non | max 2000 |
## Réponses
### 201 Created
```json
{
"message": "Dossier AM créé et validé. Un e-mail de création de mot de passe a été envoyé.",
"user_id": "uuid",
"statut": "actif",
"numero_dossier": "2026-000042"
}
```
Effets serveur : user AM **actif**, fiche `assistantes_maternelles`, n° dossier, **e-mail création MDP** (pas daccusé « en attente »).
### Erreurs
| Code | Cas |
|------|-----|
| 400 | Validation / NIR / places > capacité |
| 403 | Rôle non staff |
| 409 | Email, NIR ou agrément déjà pris |
| 401 | Token manquant / invalide |
## Front
- `UserService.createAmDossier(body)` → cet endpoint
- Après 201 : refresh liste AM ; snackbar OK
- Wizard create : ne pas envoyer `acceptation_cgu` / `acceptation_privacy` (optionnels)
## Branche
`feature/156-creation-dossier-am`
+86 -9
View File
@@ -14,6 +14,8 @@ class EnfantAdminModel {
final bool consentPhoto; final bool consentPhoto;
final bool isMultiple; final bool isMultiple;
final List<EnfantParentLink> parentLinks; final List<EnfantParentLink> parentLinks;
/// Flag API #157 (sinon déduit de [parentLinks]).
final bool? sansResponsable;
EnfantAdminModel({ EnfantAdminModel({
required this.id, required this.id,
@@ -27,6 +29,7 @@ class EnfantAdminModel {
this.consentPhoto = false, this.consentPhoto = false,
this.isMultiple = false, this.isMultiple = false,
this.parentLinks = const [], this.parentLinks = const [],
this.sansResponsable,
}); });
String get fullName { String get fullName {
@@ -37,6 +40,42 @@ class EnfantAdminModel {
return '$fn $ln'; return '$fn $ln';
} }
/// Aucun lien parent valide — ticket #157.
bool get hasNoResponsable {
if (sansResponsable != null) return sansResponsable!;
return !parentLinks.any((l) => l.parentId.trim().isNotEmpty);
}
EnfantAdminModel copyWith({
String? id,
String? firstName,
String? lastName,
String? gender,
String? birthDate,
String? dueDate,
String? status,
String? photoUrl,
bool? consentPhoto,
bool? isMultiple,
List<EnfantParentLink>? parentLinks,
bool? sansResponsable,
}) {
return EnfantAdminModel(
id: id ?? this.id,
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
gender: gender ?? this.gender,
birthDate: birthDate ?? this.birthDate,
dueDate: dueDate ?? this.dueDate,
status: status ?? this.status,
photoUrl: photoUrl ?? this.photoUrl,
consentPhoto: consentPhoto ?? this.consentPhoto,
isMultiple: isMultiple ?? this.isMultiple,
parentLinks: parentLinks ?? this.parentLinks,
sansResponsable: sansResponsable ?? this.sansResponsable,
);
}
factory EnfantAdminModel.fromJson(Map<String, dynamic> json) { factory EnfantAdminModel.fromJson(Map<String, dynamic> json) {
final linksRaw = json['parentLinks'] as List?; final linksRaw = json['parentLinks'] as List?;
final links = <EnfantParentLink>[]; final links = <EnfantParentLink>[];
@@ -48,18 +87,34 @@ class EnfantAdminModel {
} }
} }
final photoUrl = json['photo_url'] as String? ?? json['photoUrl'] as String?;
final consentPhoto = _parseBool(json['consent_photo']) ||
_parseBool(json['consentement_photo']) ||
_parseBool(json['consentPhoto']);
bool? sansResponsable;
if (json.containsKey('sans_responsable') ||
json.containsKey('sansResponsable')) {
sansResponsable = _parseBool(json['sans_responsable']) ||
_parseBool(json['sansResponsable']);
}
return EnfantAdminModel( return EnfantAdminModel(
id: (json['id'] ?? '').toString(), id: (json['id'] ?? '').toString(),
firstName: json['first_name'] as String?, firstName: json['first_name'] as String? ?? json['prenom'] as String?,
lastName: json['last_name'] as String?, lastName: json['last_name'] as String? ?? json['nom'] as String?,
gender: json['gender'] as String?, gender: json['gender'] as String? ?? json['genre'] as String?,
birthDate: _dateString(json['birth_date']), birthDate: _dateString(json['birth_date'] ?? json['date_naissance']),
dueDate: _dateString(json['due_date']), dueDate: _dateString(json['due_date'] ?? json['date_prevue_naissance']),
status: normalizeEnfantStatus(json['status']?.toString()), status: normalizeEnfantStatus(
photoUrl: json['photo_url'] as String?, (json['status'] ?? json['statut'])?.toString(),
consentPhoto: json['consent_photo'] == true, ),
isMultiple: json['is_multiple'] == true, photoUrl: photoUrl,
consentPhoto: consentPhoto,
isMultiple: _parseBool(json['is_multiple']) ||
_parseBool(json['est_multiple']),
parentLinks: links, parentLinks: links,
sansResponsable: sansResponsable,
); );
} }
@@ -76,6 +131,15 @@ class EnfantAdminModel {
}; };
} }
static bool _parseBool(dynamic value) {
if (value == true || value == 1) return true;
if (value is String) {
final s = value.trim().toLowerCase();
return s == 'true' || s == '1' || s == 't' || s == 'yes';
}
return false;
}
static String? _dateString(dynamic v) { static String? _dateString(dynamic v) {
if (v == null) return null; if (v == null) return null;
if (v is String) return v.split('T').first; if (v is String) return v.split('T').first;
@@ -89,6 +153,13 @@ class EnfantParentLink {
EnfantParentLink({required this.parentId, this.parentName}); EnfantParentLink({required this.parentId, this.parentName});
EnfantParentLink copyWith({String? parentId, String? parentName}) {
return EnfantParentLink(
parentId: parentId ?? this.parentId,
parentName: parentName ?? this.parentName,
);
}
factory EnfantParentLink.fromJson(Map<String, dynamic> json) { factory EnfantParentLink.fromJson(Map<String, dynamic> json) {
final parentId = final parentId =
(json['parentId'] ?? json['id_parent'] ?? '').toString(); (json['parentId'] ?? json['id_parent'] ?? '').toString();
@@ -99,6 +170,12 @@ class EnfantParentLink {
if (user is Map<String, dynamic>) { if (user is Map<String, dynamic>) {
final u = AppUser.fromJson(user); final u = AppUser.fromJson(user);
name = u.fullName.isNotEmpty ? u.fullName : u.email; name = u.fullName.isNotEmpty ? u.fullName : u.email;
} else {
// Parfois le parent est aplati (prenom/nom) sans nested user.
final prenom = (parent['prenom'] ?? parent['first_name'] ?? '').toString().trim();
final nom = (parent['nom'] ?? parent['last_name'] ?? '').toString().trim();
final flat = '$prenom $nom'.trim();
if (flat.isNotEmpty) name = flat;
} }
} }
return EnfantParentLink( return EnfantParentLink(
+52
View File
@@ -62,4 +62,56 @@ class ParentModel {
return children; return children;
} }
/// Nombre denfants distincts du foyer (ce parent + co-parent / même dossier).
/// Évite Claire=7 / Thomas=6 quand un lien nest que sur un des deux (#157).
static int foyerChildrenCount(
ParentModel parent,
List<ParentModel> allParents,
) {
final memberIds = <String>{parent.user.id};
final coId = parent.coParent?.id.trim();
if (coId != null && coId.isNotEmpty) memberIds.add(coId);
final dossier = (parent.user.numeroDossier ?? '').trim();
for (final other in allParents) {
if (memberIds.contains(other.user.id)) continue;
final otherCo = other.coParent?.id.trim();
if (otherCo != null && memberIds.contains(otherCo)) {
memberIds.add(other.user.id);
continue;
}
if (dossier.isNotEmpty &&
(other.user.numeroDossier ?? '').trim() == dossier) {
memberIds.add(other.user.id);
final oc = other.coParent?.id.trim();
if (oc != null && oc.isNotEmpty) memberIds.add(oc);
}
}
final childIds = <String>{};
for (final p in allParents) {
if (!memberIds.contains(p.user.id)) continue;
for (final c in p.children) {
final id = c.id.trim();
if (id.isNotEmpty) childIds.add(id);
}
// Repli si la liste enfants nest pas hydratée.
if (p.children.isEmpty && p.childrenCount > 0) {
// Impossible de dédupliquer sans IDs : on prend au moins ce compte.
// (évite dafficher 0 si lAPI nenvoie que childrenCount)
}
}
if (childIds.isNotEmpty) return childIds.length;
var maxCount = 0;
for (final p in allParents) {
if (!memberIds.contains(p.user.id)) continue;
final n = p.children.isNotEmpty ? p.children.length : p.childrenCount;
if (n > maxCount) maxCount = n;
}
return maxCount;
}
} }
@@ -5,6 +5,7 @@ import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/widgets/email_text_field.dart'; import 'package:p_tits_pas/widgets/email_text_field.dart';
import 'package:p_tits_pas/widgets/french_phone_field.dart'; import 'package:p_tits_pas/widgets/french_phone_field.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/relais_service.dart'; import 'package:p_tits_pas/services/relais_service.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
@@ -41,9 +42,15 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
bool _isLoadingRelais = true; bool _isLoadingRelais = true;
List<RelaisModel> _relais = []; List<RelaisModel> _relais = [];
String? _selectedRelaisId; String? _selectedRelaisId;
String? _currentUserId;
bool get _isEditMode => widget.initialUser != null; bool get _isEditMode => widget.initialUser != null;
bool get _isSuperAdminTarget => bool get _isSuperAdminTarget =>
(widget.initialUser?.role ?? '').toLowerCase() == 'super_admin'; (widget.initialUser?.role ?? '').toLowerCase() == 'super_admin';
bool get _isSelfTarget =>
_isEditMode &&
_currentUserId != null &&
widget.initialUser!.id == _currentUserId;
bool get _canDeleteTarget => !_isSuperAdminTarget && !_isSelfTarget;
bool get _isLockedAdminIdentity => bool get _isLockedAdminIdentity =>
_isEditMode && widget.adminMode && _isSuperAdminTarget; _isEditMode && widget.adminMode && _isSuperAdminTarget;
String get _targetRoleKey { String get _targetRoleKey {
@@ -109,6 +116,23 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
} else { } else {
_isLoadingRelais = false; _isLoadingRelais = false;
} }
_loadCurrentUserId();
}
Future<void> _loadCurrentUserId() async {
final cached = await AuthService.getCurrentUser();
if (!mounted) return;
if (cached != null) {
setState(() {
_currentUserId = cached.id;
});
return;
}
final refreshed = await AuthService.refreshCurrentUser();
if (!mounted || refreshed == null) return;
setState(() {
_currentUserId = refreshed.id;
});
} }
@override @override
@@ -122,6 +146,21 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
super.dispose(); super.dispose();
} }
/// Fallback si GET /relais échoue : conserve le relais déjà connu sur l'utilisateur.
List<RelaisModel> _fallbackRelaisFromUser() {
final id = _selectedRelaisId?.trim();
if (id == null || id.isEmpty) return const [];
final nom = (widget.initialUser?.relaisNom ?? '').trim();
return [
RelaisModel(
id: id,
nom: nom.isNotEmpty ? nom : 'Relais actuel',
adresse: '',
actif: true,
),
];
}
Future<void> _loadRelais() async { Future<void> _loadRelais() async {
try { try {
final list = await RelaisService.getRelais(); final list = await RelaisService.getRelais();
@@ -138,7 +177,8 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
if (selected != null) { if (selected != null) {
filtered.add(selected); filtered.add(selected);
} else { } else {
_selectedRelaisId = null; // Garder l'id sélectionné et afficher un item de secours (nom carte).
filtered.addAll(_fallbackRelaisFromUser());
} }
} }
@@ -148,9 +188,9 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
}); });
} catch (_) { } catch (_) {
if (!mounted) return; if (!mounted) return;
// Ne pas nullifier _selectedRelaisId (#151) — la carte a déjà le bon libellé.
setState(() { setState(() {
_selectedRelaisId = null; _relais = _fallbackRelaisFromUser();
_relais = [];
_isLoadingRelais = false; _isLoadingRelais = false;
}); });
} }
@@ -335,7 +375,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
Future<void> _delete() async { Future<void> _delete() async {
if (widget.readOnly) return; if (widget.readOnly) return;
if (_isSuperAdminTarget) return; if (!_canDeleteTarget) return;
if (!_isEditMode || _isSubmitting) return; if (!_isEditMode || _isSubmitting) return;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
@@ -462,7 +502,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
child: const Text('Fermer'), child: const Text('Fermer'),
), ),
] else if (_isEditMode) ...[ ] else if (_isEditMode) ...[
if (!_isSuperAdminTarget) if (_canDeleteTarget)
OutlinedButton( OutlinedButton(
onPressed: _isSubmitting ? null : _delete, onPressed: _isSubmitting ? null : _delete,
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700), style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
@@ -61,6 +61,9 @@ class ApiConfig {
static const String gestionnaires = '/gestionnaires'; static const String gestionnaires = '/gestionnaires';
static const String parents = '/parents'; static const String parents = '/parents';
static const String assistantesMaternelles = '/assistantes-maternelles'; static const String assistantesMaternelles = '/assistantes-maternelles';
/// Création dossier AM actif par le staff (#156) — body type register AM.
static const String assistantesMaternellesDossier =
'/assistantes-maternelles/dossier';
static const String enfants = '/enfants'; static const String enfants = '/enfants';
static const String relais = '/relais'; static const String relais = '/relais';
static const String dossiers = '/dossiers'; static const String dossiers = '/dossiers';
+216 -3
View File
@@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:http_parser/http_parser.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/models/parent_model.dart'; import 'package:p_tits_pas/models/parent_model.dart';
@@ -8,6 +9,7 @@ import 'package:p_tits_pas/models/pending_family.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/services/api/tokenService.dart'; import 'package:p_tits_pas/services/api/tokenService.dart';
import 'package:p_tits_pas/utils/am_vigilance.dart';
class DocumentActifInfo { class DocumentActifInfo {
final String id; final String id;
@@ -56,6 +58,33 @@ class UserService {
return v.toString(); return v.toString();
} }
/// MIME pour upload photo enfant (filtre Nest : jpg/jpeg/png/gif).
static MediaType _imageMediaType(String filename, List<int> bytes) {
if (bytes.length >= 3 &&
bytes[0] == 0xFF &&
bytes[1] == 0xD8 &&
bytes[2] == 0xFF) {
return MediaType('image', 'jpeg');
}
if (bytes.length >= 8 &&
bytes[0] == 0x89 &&
bytes[1] == 0x50 &&
bytes[2] == 0x4E &&
bytes[3] == 0x47) {
return MediaType('image', 'png');
}
if (bytes.length >= 6 &&
bytes[0] == 0x47 &&
bytes[1] == 0x49 &&
bytes[2] == 0x46) {
return MediaType('image', 'gif');
}
final lower = filename.toLowerCase();
if (lower.endsWith('.png')) return MediaType('image', 'png');
if (lower.endsWith('.gif')) return MediaType('image', 'gif');
return MediaType('image', 'jpeg');
}
static String _errMessage(dynamic err) { static String _errMessage(dynamic err) {
if (err == null) return 'Erreur inconnue'; if (err == null) return 'Erreur inconnue';
if (err is String) return err; if (err is String) return err;
@@ -460,7 +489,40 @@ class UserService {
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception(_extractErrorMessage(response.body, 'Erreur chargement enfant')); throw Exception(_extractErrorMessage(response.body, 'Erreur chargement enfant'));
} }
return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>); final enfant = EnfantAdminModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
// GET /enfants/:id ne joint pas toujours parent.user → noms manquants.
return enrichEnfantParentNames(enfant);
}
/// Complète [parentName] via GET parent quand l'API n'a pas fourni le nested user.
static Future<EnfantAdminModel> enrichEnfantParentNames(
EnfantAdminModel enfant,
) async {
final links = enfant.parentLinks;
if (links.isEmpty) return enfant;
if (links.every((l) => (l.parentName ?? '').trim().isNotEmpty)) {
return enfant;
}
final enriched = await Future.wait(
links.map((link) async {
if ((link.parentName ?? '').trim().isNotEmpty) return link;
final id = link.parentId.trim();
if (id.isEmpty) return link;
try {
final parent = await getParent(id);
final name = parent.user.fullName.trim();
if (name.isEmpty) return link;
return link.copyWith(parentName: name);
} catch (_) {
return link;
}
}),
);
return enfant.copyWith(parentLinks: enriched);
} }
static Future<EnfantAdminModel> updateEnfant({ static Future<EnfantAdminModel> updateEnfant({
@@ -475,7 +537,91 @@ class UserService {
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant')); throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant'));
} }
return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>); final enfant = EnfantAdminModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
return enrichEnfantParentNames(enfant);
}
/// Création enfant côté staff (#132) — nécessite `POST /enfants` étendu (voir mini-spec).
/// [parentUserId] : parent pivot du foyer (`parent_user_id`).
/// Avec [photoBytes] : `multipart/form-data` (champ fichier `photo`), sinon JSON.
static Future<EnfantAdminModel> createEnfant({
required String parentUserId,
required Map<String, dynamic> body,
List<int>? photoBytes,
String? photoFilename,
}) async {
final hasPhoto = photoBytes != null && photoBytes.isNotEmpty;
final http.Response response;
if (hasPhoto) {
final token = await TokenService.getToken();
final req = http.MultipartRequest(
'POST',
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
);
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();
});
req.fields['parent_user_id'] = parentUserId;
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 {
final payload = <String, dynamic>{
...body,
'parent_user_id': parentUserId,
};
response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
headers: await _headers(),
body: jsonEncode(payload),
);
}
if (response.statusCode != 200 && response.statusCode != 201) {
throw Exception(
_extractErrorMessage(response.body, 'Erreur création enfant'),
);
}
final enfant = EnfantAdminModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
return enrichEnfantParentNames(enfant);
}
static Future<void> deleteEnfant(String enfantId) async {
final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
headers: await _headers(),
);
if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception(_extractErrorMessage(response.body, 'Erreur suppression enfant'));
}
}
/// AM dont la liste d'enfants actifs contient [enfantId] (API actuelle).
static Future<AssistanteMaternelleModel?> findAmForEnfant(String enfantId) async {
final ams = await getAssistantesMaternelles();
for (final am in ams) {
if (am.children.any((c) => c.id == enfantId)) return am;
}
return null;
} }
static ParentModel _parentModelFromBody(String body) { static ParentModel _parentModelFromBody(String body) {
@@ -499,6 +645,56 @@ class UserService {
return fallback; return fallback;
} }
/// Création dossier AM actif côté staff (#156).
/// `POST /assistantes-maternelles/dossier` — body aligné sur register AM.
/// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur).
/// Ne pas utiliser `POST /auth/register/am` (public / en_attente).
static Future<Map<String, dynamic>> createAmDossier(
Map<String, dynamic> body,
) async {
final http.Response response;
try {
response = await http.post(
Uri.parse(
'${ApiConfig.baseUrl}${ApiConfig.assistantesMaternellesDossier}',
),
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 création dossier AM',
);
if (response.statusCode == 409) {
throw Exception(message.isNotEmpty
? message
: 'Conflit : e-mail, NIR ou agrément déjà utilisé.');
}
if (response.statusCode == 400) {
throw Exception(
message.isNotEmpty ? message : 'Données invalides (400).',
);
}
throw Exception(message);
}
// Récupérer la liste des assistantes maternelles // Récupérer la liste des assistantes maternelles
static Future<List<AssistanteMaternelleModel>> static Future<List<AssistanteMaternelleModel>>
getAssistantesMaternelles() async { getAssistantesMaternelles() async {
@@ -633,7 +829,24 @@ class UserService {
if (response.statusCode != 200 && response.statusCode != 201) { if (response.statusCode != 200 && response.statusCode != 201) {
throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant')); throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant'));
} }
return _amModelFromBody(response.body); final am = _amModelFromBody(response.body);
// Recalcule toujours places_available (capacité enfants) après attachement.
return syncAmPlacesAvailable(am);
}
/// Aligne `places_available` sur capacité enfants rattachés.
static Future<AssistanteMaternelleModel> syncAmPlacesAvailable(
AssistanteMaternelleModel am,
) async {
final expected = amExpectedPlacesAvailable(
maxChildren: am.maxChildren,
childrenCount: am.children.length,
);
if (expected == null || am.placesAvailable == expected) return am;
return updateAmFiche(
amUserId: am.user.id,
body: {'places_available': expected},
);
} }
static Future<AssistanteMaternelleModel> detachEnfantFromAm({ static Future<AssistanteMaternelleModel> detachEnfantFromAm({
+12
View File
@@ -9,6 +9,18 @@ int? amExpectedPlacesAvailable({
return (maxChildren - childrenCount).clamp(0, maxChildren); return (maxChildren - childrenCount).clamp(0, maxChildren);
} }
/// True s'il reste au moins une place d'accueil (capacité enfants).
bool amHasFreePlace(AssistanteMaternelleModel am) {
final expected = amExpectedPlacesAvailable(
maxChildren: am.maxChildren,
childrenCount: am.children.length,
);
if (expected != null) return expected > 0;
final places = am.placesAvailable;
if (places != null) return places > 0;
return true;
}
/// True si la valeur déclarée par l'AM ne correspond pas au calcul métier. /// True si la valeur déclarée par l'AM ne correspond pas au calcul métier.
bool amHasPlacesInconsistency({ bool amHasPlacesInconsistency({
required int? maxChildren, required int? maxChildren,
+82 -1
View File
@@ -1,3 +1,4 @@
import 'package:flutter/services.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart';
@@ -11,10 +12,50 @@ String formatIsoDateFr(String? s, {String ifEmpty = ''}) {
} }
} }
/// Convertit `dd/MM/yyyy` (ou ISO) en `yyyy-MM-dd` pour l'API. /// Affiche une date ISO en saisie guidée `jj / mm / aaaa`.
String formatIsoDateFrInput(String? s, {String ifEmpty = ''}) {
if (s == null || s.trim().isEmpty) return ifEmpty;
try {
final dt = DateTime.parse(s.trim());
return formatFrenchDateDigits(
DateFormat('ddMMyyyy').format(dt),
);
} catch (_) {
return formatFrenchDateDigits(s.replaceAll(RegExp(r'\D'), ''));
}
}
/// Formate jusqu’à 8 chiffres en `jj / mm / aaaa`.
String formatFrenchDateDigits(String digits) {
final d = digits.replaceAll(RegExp(r'\D'), '');
final limited = d.length > 8 ? d.substring(0, 8) : d;
final buf = StringBuffer();
for (var i = 0; i < limited.length; i++) {
if (i == 2 || i == 4) buf.write(' / ');
buf.write(limited[i]);
}
return buf.toString();
}
/// Convertit `dd/MM/yyyy`, `dd / MM / yyyy`, 8 chiffres ou ISO en `yyyy-MM-dd`.
String? parseFrDateToIso(String text) { String? parseFrDateToIso(String text) {
final t = text.trim(); final t = text.trim();
if (t.isEmpty) return null; if (t.isEmpty) return null;
final digits = t.replaceAll(RegExp(r'\D'), '');
if (digits.length == 8) {
final dd = digits.substring(0, 2);
final mm = digits.substring(2, 4);
final yyyy = digits.substring(4, 8);
try {
return DateFormat('dd/MM/yyyy')
.parseStrict('$dd/$mm/$yyyy')
.toIso8601String()
.split('T')
.first;
} catch (_) {}
}
try { try {
return DateFormat('dd/MM/yyyy') return DateFormat('dd/MM/yyyy')
.parseStrict(t) .parseStrict(t)
@@ -96,3 +137,43 @@ String formatChildAgeLabel({
return 'Né le ${formatIsoDateFr(birthDate)}'; return 'Né le ${formatIsoDateFr(birthDate)}';
} }
} }
/// Saisie date FR : 8 chiffres → `jj / mm / aaaa`.
class FrenchDateInputFormatter extends TextInputFormatter {
const FrenchDateInputFormatter();
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
final limited = digits.length > 8 ? digits.substring(0, 8) : digits;
final formatted = formatFrenchDateDigits(limited);
final digitsBeforeCursor = newValue.text
.substring(0, newValue.selection.start.clamp(0, newValue.text.length))
.replaceAll(RegExp(r'\D'), '')
.length
.clamp(0, limited.length);
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(
offset: _cursorOffset(formatted, digitsBeforeCursor),
),
);
}
static int _cursorOffset(String formatted, int digitsBeforeCursor) {
if (digitsBeforeCursor <= 0) return 0;
var seen = 0;
for (var i = 0; i < formatted.length; i++) {
if (RegExp(r'\d').hasMatch(formatted[i])) {
seen++;
if (seen >= digitsBeforeCursor) return i + 1;
}
}
return formatted.length;
}
}
+5 -5
View File
@@ -14,10 +14,10 @@ String normalizeEnfantStatus(String? raw) {
return s; return s;
} }
String _scolariseAccordeAuGenre(String? gender) { String _accordeAuGenre(String masculin, String feminin, String? gender) {
final g = (gender ?? '').trim().toUpperCase(); final g = (gender ?? '').trim().toUpperCase();
if (g == 'F') return 'Scolarisée'; if (g == 'F') return feminin;
return 'Scolarisé'; return masculin;
} }
/// Libellé affiché pour un statut enfant. /// Libellé affiché pour un statut enfant.
@@ -28,9 +28,9 @@ String enfantStatusLabel(String? status, {String? gender}) {
case 'sans_garde': case 'sans_garde':
return 'Sans garde'; return 'Sans garde';
case 'garde': case 'garde':
return 'En garde'; return _accordeAuGenre('Gardé', 'Gardée', gender);
case 'scolarise': case 'scolarise':
return _scolariseAccordeAuGenre(gender); return _accordeAuGenre('Scolarisé', 'Scolarisée', gender);
default: default:
return status?.trim().isNotEmpty == true ? status!.trim() : ''; return status?.trim().isNotEmpty == true ? status!.trim() : '';
} }
+10
View File
@@ -0,0 +1,10 @@
import 'package:p_tits_pas/models/enfant_admin_model.dart';
/// Enfant sans lien parent (orphelins daffiliation) — ticket #157.
bool enfantHasNoResponsable(EnfantAdminModel enfant) => enfant.hasNoResponsable;
/// Message vigilance liste Enfants (même usage que [amPlacesVigilanceMessage]).
String? enfantSansResponsableVigilanceMessage(EnfantAdminModel enfant) {
if (!enfantHasNoResponsable(enfant)) return null;
return 'Aucun responsable rattaché — à rattacher à un foyer';
}
+41
View File
@@ -1,3 +1,5 @@
import 'package:flutter/services.dart';
/// Formatage affichage prénom / nom (capitalisation par mot et segments après `-` ou `'`). /// Formatage affichage prénom / nom (capitalisation par mot et segments après `-` ou `'`).
String formatPersonNameCase(String raw) { String formatPersonNameCase(String raw) {
@@ -9,6 +11,22 @@ String formatPersonNameCase(String raw) {
return words.map(_capitalizeComposedWord).join(' '); return words.map(_capitalizeComposedWord).join(' ');
} }
/// Variante saisie live : pas de majuscule tant que le mot na quune lettre ;
/// dès la 2ᵉ lettre, capitalisation comme [formatPersonNameCase].
String formatPersonNameCaseTyping(String raw) {
if (raw.isEmpty) return raw;
final trailingMatch = RegExp(r'(\s*)$').firstMatch(raw);
final trailing = trailingMatch?.group(1) ?? '';
final core = raw.substring(0, raw.length - trailing.length);
if (core.isEmpty) return raw;
final words = core.split(RegExp(r'\s+'));
final formatted = words.map((w) {
if (w.length < 2) return w;
return _capitalizeComposedWord(w);
}).join(' ');
return formatted + trailing;
}
String _capitalizeComposedWord(String word) { String _capitalizeComposedWord(String word) {
if (word.isEmpty) { if (word.isEmpty) {
return word; return word;
@@ -30,3 +48,26 @@ String _capitalizeComposedWord(String word) {
} }
return buffer.toString(); return buffer.toString();
} }
/// Formateur de saisie prénom / nom (capitalisation progressive).
class PersonNameInputFormatter extends TextInputFormatter {
const PersonNameInputFormatter();
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final formatted = formatPersonNameCaseTyping(newValue.text);
if (formatted == newValue.text) return newValue;
final sel = newValue.selection;
final offset = sel.isValid
? sel.baseOffset.clamp(0, formatted.length)
: formatted.length;
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: offset),
);
}
}
+37 -9
View File
@@ -49,12 +49,21 @@ String nirToRaw(String normalized) {
return s; return s;
} }
/// Formate pour affichage : 1 12 34 56 789 012 - 34 ou 1 12 34 2A 789 012 - 34 (Corse). /// Formate pour affichage (complet ou en cours de saisie) :
/// `1 12 34 56 789 012 - 34` ou `1 12 34 2A 789 012 - 34` (Corse).
String formatNir(String raw) { String formatNir(String raw) {
final r = nirToRaw(raw); final r = nirToRaw(raw).toUpperCase();
if (r.length < 15) return r; if (r.isEmpty) return '';
// Même structure pour tous : sexe + année + mois + département + commune + ordre-clé. final buf = StringBuffer();
return '${r.substring(0, 1)} ${r.substring(1, 3)} ${r.substring(3, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)} - ${r.substring(13, 15)}'; for (var i = 0; i < r.length && i < 15; i++) {
if (i == 1 || i == 3 || i == 5 || i == 7 || i == 10) {
buf.write(' ');
} else if (i == 13) {
buf.write(' - ');
}
buf.write(r[i]);
}
return buf.toString();
} }
/// Aligné sur le backend (NIR côté API / nir.util.ts) : sexe 13, département 2A ou 2B pour la Corse. /// Aligné sur le backend (NIR côté API / nir.util.ts) : sexe 13, département 2A ou 2B pour la Corse.
@@ -92,20 +101,39 @@ String? validateNir(String? value) {
return null; return null;
} }
/// Formateur de saisie : affiche le NIR formaté (1 12 34 56 789 012 - 34) et limite à 15 caractères utiles. /// Validation pendant la saisie : pas derreur si incomplet encore plausible ;
/// dès 15 caractères, même contrôles que [validateNir].
String? validateNirTyping(String? value) {
if (value == null || value.trim().isEmpty) return null;
final raw = nirToRaw(value).toUpperCase();
if (raw.isEmpty) return null;
if (raw[0] != '1' && raw[0] != '2' && raw[0] != '3') {
return 'Format NIR invalide (ex. 1 12 34 56 789 012 - 34 ou 2A pour la Corse)';
}
if (raw.length < 15) return null;
return validateNir(value);
}
/// Formateur de saisie : affiche le NIR formaté au fil de la frappe et limite à 15 caractères utiles.
class NirInputFormatter extends TextInputFormatter { class NirInputFormatter extends TextInputFormatter {
const NirInputFormatter();
@override @override
TextEditingValue formatEditUpdate( TextEditingValue formatEditUpdate(
TextEditingValue oldValue, TextEditingValue oldValue,
TextEditingValue newValue, TextEditingValue newValue,
) { ) {
final raw = normalizeNir(newValue.text); final raw = normalizeNir(newValue.text);
if (raw.isEmpty) return newValue; if (raw.isEmpty) {
return const TextEditingValue(
text: '',
selection: TextSelection.collapsed(offset: 0),
);
}
final formatted = formatNir(raw); final formatted = formatNir(raw);
final offset = formatted.length;
return TextEditingValue( return TextEditingValue(
text: formatted, text: formatted,
selection: TextSelection.collapsed(offset: offset), selection: TextSelection.collapsed(offset: formatted.length),
); );
} }
} }
@@ -167,6 +167,7 @@ class ParentRegistrationPayload {
final map = <String, dynamic>{ final map = <String, dynamic>{
'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre', 'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre',
'grossesse_multiple': c.multipleBirth, 'grossesse_multiple': c.multipleBirth,
'consent_photo': c.photoConsent,
}; };
final prenom = c.firstName.trim(); final prenom = c.firstName.trim();
+2 -5
View File
@@ -38,15 +38,12 @@ class RepriseMapper {
? isoToDdMmYyyy(e.dueDate) ? isoToDdMmYyyy(e.dueDate)
: isoToDdMmYyyy(e.birthDate); : isoToDdMmYyyy(e.birthDate);
final photo = e.photoUrl?.trim(); final photo = e.photoUrl?.trim();
final hasPhoto = photo != null && photo.isNotEmpty;
return ChildData( return ChildData(
firstName: e.firstName ?? '', firstName: e.firstName ?? '',
lastName: e.lastName ?? '', lastName: e.lastName ?? '',
dob: dob, dob: dob,
genre: e.gender ?? '', genre: e.gender ?? '',
// Inscription initiale exigeait la coche pour envoyer la photo ; le back photoConsent: e.consentPhoto,
// ne persistait pas toujours consent_photo — on pré-coche si photo en base.
photoConsent: e.consentPhoto || hasPhoto,
multipleBirth: e.estMultiple, multipleBirth: e.estMultiple,
isUnbornChild: isUnborn, isUnbornChild: isUnborn,
cardColor: _childCardColors[index % _childCardColors.length], cardColor: _childCardColors[index % _childCardColors.length],
@@ -200,7 +197,7 @@ class RepriseMapper {
postalCode: dossier.codePostal ?? '', postalCode: dossier.codePostal ?? '',
city: dossier.ville ?? '', city: dossier.ville ?? '',
existingPhotoUrl: displayPhoto, existingPhotoUrl: displayPhoto,
consentementPhoto: dossier.consentementPhoto || hasPhoto, consentementPhoto: dossier.consentementPhoto,
dateOfBirth: parseIsoDate(dossier.dateNaissance), dateOfBirth: parseIsoDate(dossier.dateNaissance),
birthCity: dossier.lieuNaissanceVille ?? '', birthCity: dossier.lieuNaissanceVille ?? '',
birthCountry: dossier.lieuNaissancePays ?? '', birthCountry: dossier.lieuNaissancePays ?? '',
@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart';
/// Modale de création dossier AM (#156) — même shell que [ValidationDossierModal].
class AmDossierCreateModal extends StatefulWidget {
final VoidCallback onClose;
final VoidCallback? onSuccess;
const AmDossierCreateModal({
super.key,
required this.onClose,
this.onSuccess,
});
@override
State<AmDossierCreateModal> createState() => _AmDossierCreateModalState();
}
class _AmDossierCreateModalState extends State<AmDossierCreateModal> {
int? _stepIndex;
int? _stepTotal;
void _onStepChanged(int step, int total) {
if (!mounted) return;
setState(() {
_stepIndex = step;
_stepTotal = total;
});
}
void _onSuccess() {
widget.onSuccess?.call();
}
static const double _modalWidth = 930;
/// Hauteur calculée depuis 4 lignes de TF (voir [AmDossierWizard.shellBodyHeight]).
static double get _bodyHeight => AmDossierWizard.shellBodyHeight;
@override
Widget build(BuildContext context) {
final maxH = MediaQuery.of(context).size.height * 0.85;
final showStep =
_stepIndex != null && _stepTotal != null && (_stepTotal ?? 0) > 0;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: _modalWidth, maxHeight: maxH),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Padding(
padding: EdgeInsets.fromLTRB(18, 18, 0, 12),
child: Text(
'Nouvelle assistante maternelle',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
),
),
const Spacer(),
if (showStep) ...[
Text(
'Étape ${(_stepIndex ?? 0) + 1}/${_stepTotal ?? 1}',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Colors.black54,
fontStyle: FontStyle.italic,
),
),
const SizedBox(width: 8),
],
IconButton(
icon: const Icon(Icons.close),
onPressed: widget.onClose,
tooltip: 'Fermer',
),
],
),
const Divider(height: 1),
SizedBox(
height: _bodyHeight,
child: AmDossierWizard.create(
onClose: widget.onClose,
onSuccess: _onSuccess,
onStepChanged: _onStepChanged,
),
),
],
),
),
);
}
}
@@ -0,0 +1,965 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:p_tits_pas/models/am_registration_data.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart';
import 'package:p_tits_pas/utils/nir_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/utils/postal_utils.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
import 'package:p_tits_pas/widgets/admin/validation_refus_form.dart';
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 }
/// Wizard dossier AM — modes [review] (validation pending) et [create] (staff #156).
class AmDossierWizard extends StatefulWidget {
final AmDossierWizardMode mode;
final DossierAM? dossier;
final VoidCallback onClose;
final VoidCallback onSuccess;
final void Function(int step, int total)? onStepChanged;
const AmDossierWizard._({
super.key,
required this.mode,
this.dossier,
required this.onClose,
required this.onSuccess,
this.onStepChanged,
});
factory AmDossierWizard.review({
Key? key,
required DossierAM dossier,
required VoidCallback onClose,
required VoidCallback onSuccess,
void Function(int step, int total)? onStepChanged,
}) {
return AmDossierWizard._(
key: key,
mode: AmDossierWizardMode.review,
dossier: dossier,
onClose: onClose,
onSuccess: onSuccess,
onStepChanged: onStepChanged,
);
}
factory AmDossierWizard.create({
Key? key,
required VoidCallback onClose,
required VoidCallback onSuccess,
void Function(int step, int total)? onStepChanged,
}) {
return AmDossierWizard._(
key: key,
mode: AmDossierWizardMode.create,
onClose: onClose,
onSuccess: onSuccess,
onStepChanged: onStepChanged,
);
}
bool get isCreate => mode == AmDossierWizardMode.create;
/// Hauteur corps modale AM — dérivée de [ValidationFormMetrics] (4 lignes).
static double get shellBodyHeight =>
ValidationFormMetrics.shellBodyHeightForRows(4);
@override
State<AmDossierWizard> createState() => _AmDossierWizardState();
}
class _AmDossierWizardState extends State<AmDossierWizard> {
int _step = 0;
bool _showRefusForm = false;
bool _submitting = false;
static const int _stepCount = 3;
static const List<int> _photoProRowLayout = [2, 2, 2, 2];
static const double _idPhotoAspectRatio = 35 / 45;
static const double _photoProGap = 24;
static const double _proColumnMinWidth = 260;
static const double _photoColumnMinWidth = 160;
// --- Create mode controllers / state ---
late final TextEditingController _nomCtrl;
late final TextEditingController _prenomCtrl;
late final TextEditingController _telCtrl;
late final TextEditingController _emailCtrl;
late final TextEditingController _adresseCtrl;
late final TextEditingController _cpCtrl;
late final TextEditingController _villeCtrl;
late final TextEditingController _nirCtrl;
late final TextEditingController _dateNaissanceCtrl;
late final TextEditingController _lieuNaissanceVilleCtrl;
late final TextEditingController _lieuNaissancePaysCtrl;
late final TextEditingController _agrementCtrl;
late final TextEditingController _dateAgrementCtrl;
late final TextEditingController _capaciteCtrl;
late final TextEditingController _placesCtrl;
late final TextEditingController _presentationCtrl;
Uint8List? _photoBytes;
String? _photoFilename;
bool get _isCreate => widget.isCreate;
DossierAM get _dossier => widget.dossier!;
bool get _isEnAttente =>
!_isCreate && _dossier.user.statut == 'en_attente';
static String _v(String? s) =>
(s != null && s.trim().isNotEmpty) ? s.trim() : '';
static String _formatNirForDisplay(String? nir) {
final v = _v(nir);
if (v == '') return v;
final raw = nirToRaw(v).toUpperCase();
return raw.length == 15 ? formatNir(raw) : v;
}
@override
void initState() {
super.initState();
_nomCtrl = TextEditingController();
_prenomCtrl = TextEditingController();
_telCtrl = TextEditingController();
_emailCtrl = TextEditingController();
_adresseCtrl = TextEditingController();
_cpCtrl = TextEditingController();
_villeCtrl = TextEditingController();
_nirCtrl = TextEditingController();
_dateNaissanceCtrl = TextEditingController();
_lieuNaissanceVilleCtrl = TextEditingController();
_lieuNaissancePaysCtrl = TextEditingController(text: 'France');
_agrementCtrl = TextEditingController();
_dateAgrementCtrl = TextEditingController();
_capaciteCtrl = TextEditingController(text: '1');
_placesCtrl = TextEditingController(text: '1');
_presentationCtrl = TextEditingController();
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
}
@override
void dispose() {
_nomCtrl.dispose();
_prenomCtrl.dispose();
_telCtrl.dispose();
_emailCtrl.dispose();
_adresseCtrl.dispose();
_cpCtrl.dispose();
_villeCtrl.dispose();
_nirCtrl.dispose();
_dateNaissanceCtrl.dispose();
_lieuNaissanceVilleCtrl.dispose();
_lieuNaissancePaysCtrl.dispose();
_agrementCtrl.dispose();
_dateAgrementCtrl.dispose();
_capaciteCtrl.dispose();
_placesCtrl.dispose();
_presentationCtrl.dispose();
super.dispose();
}
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
List<AdminDetailField> _photoProFields(DossierAM d) {
final u = d.user;
return [
AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)),
AdminDetailField(
label: 'Date de naissance',
value: formatIsoDateFr(u.dateNaissance),
),
AdminDetailField(
label: 'Ville de naissance',
value: _v(u.lieuNaissanceVille),
),
AdminDetailField(
label: 'Pays de naissance',
value: _v(u.lieuNaissancePays),
),
AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)),
AdminDetailField(
label: 'Date dagrément',
value: formatIsoDateFr(d.dateAgrement),
),
AdminDetailField(
label: 'Capacité max (enfants)',
value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '',
),
AdminDetailField(
label: 'Places disponibles',
value: d.placesDisponibles != null
? d.placesDisponibles.toString()
: '',
),
];
}
static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url);
Widget _buildPhotoSectionReview(AppUser u) {
final photoUrl = _fullPhotoUrl(u.photoUrl);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: LayoutBuilder(
builder: (context, c) {
const uniformFrame = 8.0;
final maxPhotoW =
(c.maxWidth - 2 * uniformFrame).clamp(0.0, double.infinity);
final maxPhotoH =
(c.maxHeight - 2 * uniformFrame).clamp(0.0, double.infinity);
const ar = _idPhotoAspectRatio;
double ph = maxPhotoH;
double pw = ph * ar;
if (pw > maxPhotoW) {
pw = maxPhotoW;
ph = pw / ar;
}
return Align(
alignment: Alignment.center,
child: Container(
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(uniformFrame),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: pw,
height: ph,
child: photoUrl.isEmpty
? ColoredBox(
color: Colors.grey.shade200,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.person_off_outlined,
size: 40,
color: Colors.grey.shade400),
const SizedBox(height: 8),
Text(
'Aucune photo fournie',
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 12),
),
],
),
)
: AuthNetworkImage(
url: photoUrl,
fit: BoxFit.cover,
width: pw,
height: ph,
loadingBuilder: (_, child, progress) {
if (progress == null) return child;
return ColoredBox(
color: Colors.grey.shade200,
child: Center(
child: CircularProgressIndicator(
value: progress.expectedTotalBytes !=
null
? progress.cumulativeBytesLoaded /
(progress.expectedTotalBytes!)
: null,
),
),
);
},
errorBuilder: (_, __, ___) => ColoredBox(
color: Colors.grey.shade200,
child: Column(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(Icons.broken_image_outlined,
size: 40,
color: Colors.grey.shade400),
const SizedBox(height: 8),
Text(
'Impossible de charger la photo',
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 12),
),
],
),
),
),
),
),
),
),
);
},
),
),
),
],
);
}
Future<void> _pickPhoto() async {
try {
final picked = await ImagePicker().pickImage(
source: ImageSource.gallery,
maxWidth: 1200,
imageQuality: 85,
);
if (picked == null) return;
final bytes = await picked.readAsBytes();
if (!mounted) return;
setState(() {
_photoBytes = bytes;
_photoFilename = picked.name;
});
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Impossible de charger la photo'),
backgroundColor: Colors.red.shade700,
),
);
}
}
void _clearPhoto() => setState(() {
_photoBytes = null;
_photoFilename = null;
});
String? _validateStep0() {
final nom = _nomCtrl.text.trim();
final prenom = _prenomCtrl.text.trim();
if (nom.length < 2) return 'Le nom doit contenir au moins 2 caractères.';
if (prenom.length < 2) {
return 'Le prénom doit contenir au moins 2 caractères.';
}
final phoneErr = validateFrenchNationalPhone(_telCtrl.text);
if (phoneErr != null) return phoneErr;
final emailErr = validateEmail(_emailCtrl.text);
if (emailErr != null) return emailErr;
if (_adresseCtrl.text.trim().isEmpty) {
return 'Ladresse est requise.';
}
final cpErr = validateFrenchPostalCode(_cpCtrl.text);
if (cpErr != null) return cpErr;
if (_villeCtrl.text.trim().isEmpty) return 'La ville est requise.';
return null;
}
String? _validateStep1() {
if (_photoBytes == null || _photoBytes!.isEmpty) {
return 'Une photo de profil est requise.';
}
final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text);
if (birthIso == null) {
return 'La date de naissance est invalide (jj/mm/aaaa).';
}
final ville = _lieuNaissanceVilleCtrl.text.trim();
final pays = _lieuNaissancePaysCtrl.text.trim();
if (ville.length < 2 || pays.length < 2) {
return 'La ville et le pays de naissance sont requis (au moins 2 caractères).';
}
final nirErr = validateNir(_nirCtrl.text);
if (nirErr != null) return nirErr;
if (_agrementCtrl.text.trim().isEmpty) {
return 'Le numéro dagrément est requis.';
}
final agrementIso = parseFrDateToIso(_dateAgrementCtrl.text);
if (agrementIso == null) {
return 'La date dagrément est invalide (jj/mm/aaaa).';
}
final capa = int.tryParse(_capaciteCtrl.text.trim());
if (capa == null || capa < 1 || capa > kAmCapaciteAccueilMax) {
return 'La capacité doit être entre 1 et $kAmCapaciteAccueilMax.';
}
final places = int.tryParse(_placesCtrl.text.trim());
if (places == null || places < 0 || places > capa) {
return 'Les places disponibles doivent être entre 0 et la capacité.';
}
return null;
}
String? _validateCurrentStep() {
if (!_isCreate) return null;
switch (_step) {
case 0:
return _validateStep0();
case 1:
return _validateStep1();
default:
return null;
}
}
void _goNext() {
final err = _validateCurrentStep();
if (err != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(err), backgroundColor: Colors.red.shade700),
);
return;
}
setState(() => _step++);
_emitStep();
}
static String _imageMimeForBytes(Uint8List bytes) {
if (bytes.length >= 8 &&
bytes[0] == 0x89 &&
bytes[1] == 0x50 &&
bytes[2] == 0x4E &&
bytes[3] == 0x47) {
return 'image/png';
}
if (bytes.length >= 3 &&
bytes[0] == 0xFF &&
bytes[1] == 0xD8 &&
bytes[2] == 0xFF) {
return 'image/jpeg';
}
if (bytes.length >= 6 &&
bytes[0] == 0x47 &&
bytes[1] == 0x49 &&
bytes[2] == 0x46) {
return 'image/gif';
}
if (bytes.length >= 12 &&
bytes[0] == 0x52 &&
bytes[1] == 0x49 &&
bytes[2] == 0x46 &&
bytes[3] == 0x46) {
return 'image/webp';
}
return 'image/jpeg';
}
Map<String, dynamic> _buildCreateBody() {
final bytes = _photoBytes!;
final mime = _imageMimeForBytes(bytes);
final photoBase64 = 'data:$mime;base64,${base64Encode(bytes)}';
final fn = (_photoFilename ?? '').trim();
final birthIso = parseFrDateToIso(_dateNaissanceCtrl.text)!;
final agrementIso = parseFrDateToIso(_dateAgrementCtrl.text)!;
final capa = int.parse(_capaciteCtrl.text.trim());
final places = int.parse(_placesCtrl.text.trim());
final presentation = _presentationCtrl.text.trim();
return <String, dynamic>{
'email': normalizeEmailText(_emailCtrl.text),
'prenom': formatPersonNameCase(_prenomCtrl.text),
'nom': formatPersonNameCase(_nomCtrl.text),
'telephone': normalizePhone(_telCtrl.text),
'adresse':
_adresseCtrl.text.trim().isNotEmpty ? _adresseCtrl.text.trim() : null,
'code_postal':
_cpCtrl.text.trim().isNotEmpty ? _cpCtrl.text.trim() : null,
'ville': _villeCtrl.text.trim().isNotEmpty
? formatPersonNameCase(_villeCtrl.text)
: null,
'photo_base64': photoBase64,
'photo_filename': fn.isNotEmpty ? fn : 'photo_am.jpg',
'consentement_photo': true,
'date_naissance': birthIso,
'lieu_naissance_ville':
formatPersonNameCase(_lieuNaissanceVilleCtrl.text),
'lieu_naissance_pays':
formatPersonNameCase(_lieuNaissancePaysCtrl.text),
'nir': normalizeNir(_nirCtrl.text),
'numero_agrement': _agrementCtrl.text.trim(),
'date_agrement': agrementIso,
'capacite_accueil': capa,
'places_disponibles': places,
'biographie': presentation.isNotEmpty ? presentation : null,
'acceptation_cgu': true,
'acceptation_privacy': true,
};
}
Future<void> _createAndValidate() async {
if (_submitting) return;
final err0 = _validateStep0();
if (err0 != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(err0), backgroundColor: Colors.red.shade700),
);
return;
}
final err1 = _validateStep1();
if (err1 != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(err1), backgroundColor: Colors.red.shade700),
);
return;
}
final ok = await showValidationValiderConfirmDialog(
context,
body:
'Créer et activer le dossier de cette assistante maternelle ? Un e-mail de création de mot de passe sera envoyé.',
);
if (!mounted || !ok) return;
setState(() => _submitting = true);
try {
await UserService.createAmDossier(_buildCreateBody());
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Dossier créé et validé. Lassistante maternelle est active.',
),
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) {
return _buildRefusPage();
}
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 4),
Expanded(child: _buildStepContent()),
const SizedBox(height: 24),
_buildNavigation(),
],
),
);
}
Widget _buildStepContent() {
switch (_step) {
case 0:
return _buildStep0();
case 1:
return _buildStep1();
case 2:
return _buildStep2();
default:
return const SizedBox();
}
}
Widget _buildStep0() {
if (_isCreate) {
return IdentityBlock.editable(
title: 'Identité et coordonnées',
nomController: _nomCtrl,
prenomController: _prenomCtrl,
telephoneController: _telCtrl,
emailController: _emailCtrl,
adresseController: _adresseCtrl,
codePostalController: _cpCtrl,
villeController: _villeCtrl,
);
}
final u = _dossier.user;
return IdentityBlock.readOnlyFromUser(
u,
title: 'Identité et coordonnées',
emptyLabel: '',
);
}
Widget _buildStep1() {
// Modale calée sur les TF ; photo étirée sur toute la hauteur utile
// (largeur = [AdminAmPhotoFrame.columnWidthForHeight], cadre inclus).
return LayoutBuilder(
builder: (context, c) {
final maxRowW = c.maxWidth;
final maxRowH = c.maxHeight.clamp(0.0, double.infinity);
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
.clamp(0.0, double.infinity);
var photoW = AdminAmPhotoFrame.columnWidthForHeight(maxRowH)
.clamp(_photoColumnMinWidth, 360.0);
if (photoW > maxPhotoW) photoW = maxPhotoW;
final form = _isCreate
? _buildCreateProFields()
: ValidationDetailSection(
title: 'Dossier professionnel',
fields: _photoProFields(_dossier),
rowLayout: _photoProRowLayout,
);
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
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(
alignment: Alignment.topCenter,
child: form,
),
),
],
);
},
);
}
Widget _buildCreateProFields() {
return ValidationFormGrid(
title: 'Dossier professionnel',
rowLayout: _photoProRowLayout,
fields: [
ValidationLabeledField(
label: 'NIR',
field: ValidationNirField(controller: _nirCtrl),
),
ValidationLabeledField(
label: 'Date de naissance',
field: ValidationEditableField(
controller: _dateNaissanceCtrl,
hintText: 'jj / mm / aaaa',
keyboardType: TextInputType.number,
inputFormatters: const [FrenchDateInputFormatter()],
),
),
ValidationLabeledField(
label: 'Ville de naissance',
field: ValidationEditableField(
controller: _lieuNaissanceVilleCtrl,
inputFormatters: const [PersonNameInputFormatter()],
),
),
ValidationLabeledField(
label: 'Pays de naissance',
field: ValidationEditableField(
controller: _lieuNaissancePaysCtrl,
inputFormatters: const [PersonNameInputFormatter()],
),
),
ValidationLabeledField(
label: 'N° Agrément',
field: ValidationEditableField(controller: _agrementCtrl),
),
ValidationLabeledField(
label: 'Date dagrément',
field: ValidationEditableField(
controller: _dateAgrementCtrl,
hintText: 'jj / mm / aaaa',
keyboardType: TextInputType.number,
inputFormatters: const [FrenchDateInputFormatter()],
),
),
ValidationLabeledField(
label: 'Capacité max (enfants)',
field: ValidationEditableField(
controller: _capaciteCtrl,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
),
ValidationLabeledField(
label: 'Places disponibles',
field: ValidationEditableField(
controller: _placesCtrl,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
),
],
);
}
Widget _buildStep2() {
if (_isCreate) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Présentation',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 12),
Expanded(
child: TextField(
controller: _presentationCtrl,
maxLines: null,
expands: true,
maxLength: 2000,
textAlignVertical: TextAlignVertical.top,
decoration: InputDecoration(
hintText: 'Présentation (optionnel, max 2000 caractères)',
filled: true,
fillColor: Colors.grey.shade50,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: Colors.grey.shade300),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
),
),
],
);
}
final presentation =
(_dossier.presentation != null && _dossier.presentation!.trim().isNotEmpty)
? _dossier.presentation!
: '';
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Présentation',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 12),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.grey.shade300),
),
child: SelectableText(
presentation,
style: const TextStyle(color: Colors.black87, fontSize: 14),
),
),
),
);
},
),
),
],
);
}
Widget _buildNavigation() {
if (_step == 2) {
return Row(
children: [
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
const Spacer(),
Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: () {
setState(() => _step = 1);
_emitStep();
},
child: const Text('Précédent'),
),
const SizedBox(width: 8),
if (_isCreate) ...[
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: _submitting ? null : _createAndValidate,
child: Text(_submitting ? 'Envoi...' : 'Créer et valider'),
),
] else if (_isEnAttente) ...[
OutlinedButton(
onPressed: _submitting ? null : _refuser,
child: const Text('Refuser')),
const SizedBox(width: 12),
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: _submitting ? null : _onValiderPressed,
child: Text(_submitting ? 'Envoi...' : 'Valider'),
),
] else
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: widget.onClose,
child: const Text('Fermer'),
),
],
),
],
);
}
return Row(
children: [
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
const Spacer(),
if (_step > 0) ...[
TextButton(
onPressed: () {
setState(() => _step--);
_emitStep();
},
child: const Text('Précédent'),
),
const SizedBox(width: 8),
],
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: _goNext,
child: const Text('Suivant'),
),
],
);
}
Future<void> _onValiderPressed() async {
if (_submitting) return;
final ok = await showValidationValiderConfirmDialog(
context,
body:
'Voulez-vous valider le dossier de cette assistante maternelle ? Cette action confirme le compte.',
);
if (!mounted || !ok) return;
await _valider();
}
Future<void> _valider() async {
if (_submitting) return;
setState(() => _submitting = true);
try {
await UserService.validateUser(_dossier.user.id);
if (!mounted) return;
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);
}
}
void _refuser() => setState(() => _showRefusForm = true);
Widget _buildRefusPage() {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: ValidationRefusForm(
isSubmitting: _submitting,
onCancel: widget.onClose,
onPrevious: () => setState(() => _showRefusForm = false),
onSubmit: (comment) {
if (comment == null || comment.trim().isEmpty) return;
_refuserEnvoyer(comment.trim());
},
),
),
],
),
);
}
Future<void> _refuserEnvoyer(String comment) async {
if (_submitting) return;
setState(() => _submitting = true);
try {
await UserService.refuseUser(_dossier.user.id, comment: comment);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.',
),
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);
}
}
}
@@ -20,6 +20,8 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
final int capacity; final int capacity;
final void Function(ParentChildSummary child) onOpen; final void Function(ParentChildSummary child) onOpen;
final void Function(ParentChildSummary child) onDetach; final void Function(ParentChildSummary child) onDetach;
/// Clic sur une case libre → même flux que « Rattacher un enfant » (#149).
final VoidCallback? onAttachEmpty;
const AdminAmChildrenCapacityGrid({ const AdminAmChildrenCapacityGrid({
super.key, super.key,
@@ -27,6 +29,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
required this.capacity, required this.capacity,
required this.onOpen, required this.onOpen,
required this.onDetach, required this.onDetach,
this.onAttachEmpty,
}); });
@override @override
@@ -78,7 +81,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
); );
} }
if (index < maxSlots) { if (index < maxSlots) {
return const _EmptySlot(); return _EmptySlot(onTap: onAttachEmpty);
} }
return const _UnavailableSlot(); return const _UnavailableSlot();
} }
@@ -129,18 +132,52 @@ class _UnavailableSlot extends StatelessWidget {
} }
} }
class _EmptySlot extends StatelessWidget { class _EmptySlot extends StatefulWidget {
const _EmptySlot(); final VoidCallback? onTap;
const _EmptySlot({this.onTap});
@override
State<_EmptySlot> createState() => _EmptySlotState();
}
class _EmptySlotState extends State<_EmptySlot> {
bool _hovered = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return _SlotShell( final clickable = widget.onTap != null;
backgroundColor: Colors.grey.shade50, return MouseRegion(
borderColor: Colors.grey.shade300, onEnter: clickable ? (_) => setState(() => _hovered = true) : null,
child: Center( onExit: clickable ? (_) => setState(() => _hovered = false) : null,
child: Text( cursor: clickable ? SystemMouseCursors.click : MouseCursor.defer,
'Place libre', child: Material(
style: TextStyle(fontSize: 12, color: Colors.grey.shade500), color: Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(8),
hoverColor: const Color(0x149CC5C0),
child: _SlotShell(
backgroundColor: _hovered
? const Color(0xFFF3F0FA)
: Colors.grey.shade50,
borderColor: _hovered
? const Color(0xFFB8A4D4)
: Colors.grey.shade300,
child: Center(
child: Text(
'Place libre',
style: TextStyle(
fontSize: 12,
color: _hovered
? const Color(0xFF6B3FA0)
: Colors.grey.shade500,
fontWeight: _hovered ? FontWeight.w600 : FontWeight.w400,
),
),
),
),
), ),
), ),
); );
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/utils/am_vigilance.dart'; import 'package:p_tits_pas/utils/am_vigilance.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart';
@@ -11,6 +10,7 @@ import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_children_capacity_grid.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_children_capacity_grid.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart';
@@ -55,6 +55,10 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
late List<ParentChildSummary> _children; late List<ParentChildSummary> _children;
late Set<String> _baselineChildIds; late Set<String> _baselineChildIds;
/// Enfants ajoutés localement qui étaient déjà chez une autre AM
/// (enfantId → amUserId d'origine). Au save : détacher puis rattacher.
final Map<String, String> _transferFromAmIds = {};
bool _saving = false; bool _saving = false;
bool _dirty = false; bool _dirty = false;
@@ -234,6 +238,13 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
int? _capaciteMax() => _parseIntField(_capaciteCtrl); int? _capaciteMax() => _parseIntField(_capaciteCtrl);
/// True si plus aucune place d'accueil (enfants ≥ capacité max).
bool get _capacityFull {
final max = _capaciteMax();
if (max == null) return false;
return _children.length >= max;
}
int? _computedPlacesAvailable() => amExpectedPlacesAvailable( int? _computedPlacesAvailable() => amExpectedPlacesAvailable(
maxChildren: _capaciteMax(), maxChildren: _capaciteMax(),
childrenCount: _children.length, childrenCount: _children.length,
@@ -311,6 +322,17 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
); );
} }
for (final id in currentIds.difference(_baselineChildIds)) { for (final id in currentIds.difference(_baselineChildIds)) {
// Transfert : détacher l'ancienne AM sans recalculer ses places
// (le ! de vigilance apparaît côté liste AM).
final previousAmId = _transferFromAmIds[id] ??
(await UserService.findAmForEnfant(id))?.user.id;
if (previousAmId != null &&
previousAmId != widget.assistante.user.id) {
await UserService.detachEnfantFromAm(
amUserId: previousAmId,
enfantId: id,
);
}
await UserService.attachEnfantToAm( await UserService.attachEnfantToAm(
amUserId: widget.assistante.user.id, amUserId: widget.assistante.user.id,
enfantId: id, enfantId: id,
@@ -346,6 +368,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
_dirty = false; _dirty = false;
_saving = false; _saving = false;
_baselineChildIds = currentIds; _baselineChildIds = currentIds;
_transferFromAmIds.clear();
}); });
widget.onSaved?.call(); widget.onSaved?.call();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -377,6 +400,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
setState(() { setState(() {
_children = kids; _children = kids;
_baselineChildIds = kids.map((c) => c.id).toSet(); _baselineChildIds = kids.map((c) => c.id).toSet();
_transferFromAmIds.clear();
}); });
} catch (_) {} } catch (_) {}
} }
@@ -422,8 +446,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Détacher l\'enfant'), title: const Text('Détacher l\'enfant'),
content: Text( content: Text(
'Retirer ${child.fullName} de la fiche de cette assistante ?\n' 'Retirer ${child.fullName} de la fiche de cette assistante ?',
'(L\'enfant ne sera pas supprimé.)',
), ),
actions: [ actions: [
TextButton( TextButton(
@@ -442,55 +465,75 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
setState(() { setState(() {
_children = _children.where((c) => c.id != child.id).toList(); _children = _children.where((c) => c.id != child.id).toList();
_transferFromAmIds.remove(child.id);
_syncPlacesAfterChildrenChange(); _syncPlacesAfterChildrenChange();
_dirty = true; _dirty = true;
}); });
} }
Future<void> _attachChild() async { Future<void> _attachChild() async {
List<EnfantAdminModel> all; if (_capacityFull || !mounted) return;
try { final selected = await AdminSelectEnfantModal.show(
all = await UserService.getEnfants(); context,
} catch (e) { excludeIds: _children.map((c) => c.id).toSet(),
if (!mounted) return; title: 'Rattacher un enfant',
ScaffoldMessenger.of(context).showSnackBar( showSansGardeFilter: true,
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
return;
}
final linkedIds = _children.map((c) => c.id).toSet();
final candidates = all.where((e) => !linkedIds.contains(e.id)).toList();
if (candidates.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Aucun enfant disponible à rattacher')),
);
return;
}
if (!mounted) return;
final selected = await showDialog<EnfantAdminModel>(
context: context,
builder: (ctx) => SimpleDialog(
title: const Text('Rattacher un enfant'),
children: candidates
.map(
(e) => SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, e),
child: Text(e.fullName),
),
)
.toList(),
),
); );
if (selected == null || !mounted) return; if (selected == null || !mounted) return;
AssistanteMaternelleModel? previousAm;
try {
previousAm = await UserService.findAmForEnfant(selected.id);
} catch (_) {
previousAm = null;
}
if (!mounted) return;
final previousAmId = previousAm?.user.id;
final isTransfer = previousAmId != null &&
previousAmId != widget.assistante.user.id;
if (isTransfer) {
final amName = previousAm!.user.fullName.trim().isNotEmpty
? previousAm.user.fullName.trim()
: 'une autre assistante maternelle';
final gardeLabel =
(selected.gender ?? '').trim().toUpperCase() == 'F'
? 'déjà gardée'
: 'déjà gardé';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Changer d\'affectation'),
content: Text(
'${selected.fullName} est $gardeLabel par $amName.\n\n'
'Confirmer le transfert vers cette assistante ? '
'L\'enfant sera détaché de $amName.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ValidationModalTheme.primaryElevatedStyle,
child: const Text('Confirmer'),
),
],
),
);
if (confirmed != true || !mounted) return;
}
setState(() { setState(() {
_children = [ _children = [
..._children, ..._children,
ParentChildSummary.fromEnfant(selected), ParentChildSummary.fromEnfant(selected),
]; ];
if (isTransfer) {
_transferFromAmIds[selected.id] = previousAmId!;
}
_syncPlacesAfterChildrenChange(); _syncPlacesAfterChildrenChange();
_dirty = true; _dirty = true;
}); });
@@ -701,6 +744,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
capacity: capacity, capacity: capacity,
onOpen: _openChild, onOpen: _openChild,
onDetach: _detachChild, onDetach: _detachChild,
onAttachEmpty: _capacityFull ? null : _attachChild,
), ),
], ],
); );
@@ -708,6 +752,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
Widget _buildFooter() { Widget _buildFooter() {
final isChildrenTab = _tabCtrl.index == 2; final isChildrenTab = _tabCtrl.index == 2;
final canAttachChild = !_saving && !_capacityFull;
return Row( return Row(
children: [ children: [
TextButton( TextButton(
@@ -716,10 +761,15 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
), ),
const Spacer(), const Spacer(),
if (isChildrenTab) if (isChildrenTab)
TextButton.icon( Tooltip(
onPressed: _attachChild, message: _capacityFull
icon: const Icon(Icons.link, size: 18), ? 'Capacité maximale atteinte'
label: const Text('Rattacher un enfant'), : 'Rattacher un enfant',
child: TextButton.icon(
onPressed: canAttachChild ? _attachChild : null,
icon: const Icon(Icons.link, size: 18),
label: const Text('Rattacher un enfant'),
),
), ),
if (isChildrenTab) const SizedBox(width: 12), if (isChildrenTab) const SizedBox(width: 12),
ElevatedButton( ElevatedButton(
@@ -1,14 +1,27 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
/// Cadre photo identité AM (35×45 mm) — même logique que [ValidationAmWizard]. /// Cadre photo identité AM / enfant (35×45 mm) — même logique que [ValidationAmWizard].
class AdminAmPhotoFrame extends StatelessWidget { class AdminAmPhotoFrame extends StatelessWidget {
final String? photoUrl; final String? photoUrl;
final Uint8List? imageBytes;
final VoidCallback? onTap;
final VoidCallback? onClear;
final String emptyLabel;
static const double idPhotoAspectRatio = 35 / 45; static const double idPhotoAspectRatio = 35 / 45;
const AdminAmPhotoFrame({super.key, this.photoUrl}); const AdminAmPhotoFrame({
super.key,
this.photoUrl,
this.imageBytes,
this.onTap,
this.onClear,
this.emptyLabel = 'Aucune photo',
});
/// Largeur colonne photo pour remplir [height] (cadre inclus). /// Largeur colonne photo pour remplir [height] (cadre inclus).
static double columnWidthForHeight(double height) { static double columnWidthForHeight(double height) {
@@ -36,9 +49,12 @@ class AdminAmPhotoFrame extends StatelessWidget {
ph = pw / ar; ph = pw / ar;
} }
final hasLocal = imageBytes != null && imageBytes!.isNotEmpty;
final showClear = onClear != null && hasLocal;
// Cadre gris = taille photo + padding uniforme ; centré dans la colonne // Cadre gris = taille photo + padding uniforme ; centré dans la colonne
// (évite le vide blanc en bas quand le conteneur parent est plus haut). // (évite le vide blanc en bas quand le conteneur parent est plus haut).
return Align( Widget frame = Align(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -60,22 +76,81 @@ class AdminAmPhotoFrame extends StatelessWidget {
), ),
), ),
); );
if (onTap != null) {
frame = MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: frame,
),
);
}
if (!showClear) return frame;
return Stack(
clipBehavior: Clip.none,
children: [
frame,
Positioned(
top: 0,
right: 0,
child: Material(
color: Colors.transparent,
child: IconButton(
tooltip: 'Retirer la photo',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
icon: Icon(
Icons.cancel,
size: 22,
color: Colors.grey.shade700,
),
onPressed: onClear,
),
),
),
],
);
}, },
); );
} }
Widget _photoContent(String fullUrl, double pw, double ph) { Widget _photoContent(String fullUrl, double pw, double ph) {
if (imageBytes != null && imageBytes!.isNotEmpty) {
return Image.memory(
imageBytes!,
width: pw,
height: ph,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
);
}
if (fullUrl.isEmpty) { if (fullUrl.isEmpty) {
return ColoredBox( return ColoredBox(
color: Colors.grey.shade200, color: Colors.grey.shade200,
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.person_off_outlined, size: 36, color: Colors.grey.shade400), Icon(
onTap != null ? Icons.add_a_photo_outlined : Icons.person_off_outlined,
size: 36,
color: Colors.grey.shade400,
),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Padding(
'Aucune photo', padding: const EdgeInsets.symmetric(horizontal: 6),
style: TextStyle(color: Colors.grey.shade600, fontSize: 11), child: Text(
emptyLabel,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600, fontSize: 11),
),
), ),
], ],
), ),
@@ -108,7 +183,11 @@ class AdminAmPhotoFrame extends StatelessWidget {
}, },
errorBuilder: (_, __, ___) => ColoredBox( errorBuilder: (_, __, ___) => ColoredBox(
color: Colors.grey.shade200, color: Colors.grey.shade200,
child: Icon(Icons.broken_image_outlined, size: 36, color: Colors.grey.shade400), child: Icon(
Icons.broken_image_outlined,
size: 36,
color: Colors.grey.shade400,
),
), ),
); );
} }
File diff suppressed because it is too large Load Diff
@@ -64,6 +64,7 @@ class AdminChildrenAffiliationPanel extends StatelessWidget {
final c = children[i]; final c = children[i];
return AdminEnfantUserCard.fromSummary( return AdminEnfantUserCard.fromSummary(
c, c,
onCardTap: () => onOpen(c),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.visibility_outlined), icon: const Icon(Icons.visibility_outlined),
@@ -3,6 +3,7 @@ import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart';
import 'package:p_tits_pas/utils/enfant_vigilance.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
List<String> enfantAdminSubtitleLines({ List<String> enfantAdminSubtitleLines({
@@ -34,6 +35,10 @@ class AdminEnfantUserCard extends StatelessWidget {
final List<String> subtitleLines; final List<String> subtitleLines;
final List<Widget> actions; final List<Widget> actions;
final VoidCallback? onCardTap; final VoidCallback? onCardTap;
final EdgeInsetsGeometry? margin;
final EdgeInsetsGeometry? contentPadding;
final Color? borderColor;
final String? vigilanceTooltip;
const AdminEnfantUserCard({ const AdminEnfantUserCard({
super.key, super.key,
@@ -42,6 +47,10 @@ class AdminEnfantUserCard extends StatelessWidget {
required this.subtitleLines, required this.subtitleLines,
this.actions = const [], this.actions = const [],
this.onCardTap, this.onCardTap,
this.margin,
this.contentPadding,
this.borderColor,
this.vigilanceTooltip,
}); });
factory AdminEnfantUserCard.fromEnfant( factory AdminEnfantUserCard.fromEnfant(
@@ -49,10 +58,15 @@ class AdminEnfantUserCard extends StatelessWidget {
List<String> extraSubtitleLines = const [], List<String> extraSubtitleLines = const [],
List<Widget> actions = const [], List<Widget> actions = const [],
VoidCallback? onCardTap, VoidCallback? onCardTap,
EdgeInsetsGeometry? margin,
EdgeInsetsGeometry? contentPadding,
Color? borderColor,
String? vigilanceTooltip,
}) { }) {
final parents = enfant.parentLinks final parents = enfant.parentLinks
.map((l) => l.parentName ?? 'Parent') .map((l) => l.parentName ?? 'Parent')
.join(', '); .join(', ');
final orphan = enfantHasNoResponsable(enfant);
return AdminEnfantUserCard( return AdminEnfantUserCard(
title: enfant.fullName, title: enfant.fullName,
photoUrl: enfant.photoUrl, photoUrl: enfant.photoUrl,
@@ -63,11 +77,18 @@ class AdminEnfantUserCard extends StatelessWidget {
gender: enfant.gender, gender: enfant.gender,
extra: [ extra: [
if (parents.isNotEmpty) 'Responsables : $parents', if (parents.isNotEmpty) 'Responsables : $parents',
if (orphan) 'Aucun responsable rattaché',
...extraSubtitleLines, ...extraSubtitleLines,
], ],
), ),
actions: actions, actions: actions,
onCardTap: onCardTap, onCardTap: onCardTap,
margin: margin,
contentPadding: contentPadding,
borderColor: borderColor ??
(orphan ? Colors.red.shade300 : null),
vigilanceTooltip: vigilanceTooltip ??
enfantSansResponsableVigilanceMessage(enfant),
); );
} }
@@ -76,6 +97,8 @@ class AdminEnfantUserCard extends StatelessWidget {
List<String> extraSubtitleLines = const [], List<String> extraSubtitleLines = const [],
List<Widget> actions = const [], List<Widget> actions = const [],
VoidCallback? onCardTap, VoidCallback? onCardTap,
EdgeInsetsGeometry? margin,
EdgeInsetsGeometry? contentPadding,
}) { }) {
return AdminEnfantUserCard( return AdminEnfantUserCard(
title: child.fullName, title: child.fullName,
@@ -88,6 +111,8 @@ class AdminEnfantUserCard extends StatelessWidget {
), ),
actions: actions, actions: actions,
onCardTap: onCardTap, onCardTap: onCardTap,
margin: margin,
contentPadding: contentPadding,
); );
} }
@@ -100,6 +125,10 @@ class AdminEnfantUserCard extends StatelessWidget {
subtitleLines: subtitleLines, subtitleLines: subtitleLines,
actions: actions, actions: actions,
onCardTap: onCardTap, onCardTap: onCardTap,
margin: margin,
contentPadding: contentPadding,
borderColor: borderColor,
vigilanceTooltip: vigilanceTooltip,
); );
} }
} }
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/models/parent_model.dart'; import 'package:p_tits_pas/models/parent_model.dart';
@@ -7,6 +6,7 @@ import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
@@ -113,10 +113,73 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
return '$fn $ln'.trim(); return '$fn $ln'.trim();
} }
String? _coParentSubtitle() { String? _coParentName() {
final name = _coParent?.fullName.trim() ?? ''; final name = _coParent?.fullName.trim() ?? '';
if (name.isEmpty) return null; return name.isEmpty ? null : name;
return 'Co-parent : $name'; }
Future<void> _openCoParent() async {
if (_saving) return;
final co = _coParent;
final id = (co?.id ?? '').trim();
if (co == null || id.isEmpty) return;
try {
final parent = await UserService.getParent(id);
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AdminParentEditModal(
parent: parent,
onSaved: () async {
try {
final refreshed = await UserService.getParent(widget.parent.user.id);
if (!mounted) return;
setState(() => _coParent = refreshed.coParent);
} catch (_) {}
widget.onSaved?.call();
},
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
Widget? _coParentSubtitle() {
final name = _coParentName();
if (name == null) return null;
return Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
const Text(
'Co-parent : ',
style: TextStyle(fontSize: 13, color: Colors.black54),
),
InkWell(
onTap: _saving ? null : _openCoParent,
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 2),
child: Text(
name,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: ValidationModalTheme.primaryActionBackground,
decoration: TextDecoration.underline,
decorationColor: ValidationModalTheme.primaryActionBackground
.withValues(alpha: 0.5),
),
),
),
),
],
);
} }
Future<void> _save() async { Future<void> _save() async {
@@ -220,8 +283,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Détacher l\'enfant'), title: const Text('Détacher l\'enfant'),
content: Text( content: Text(
'Retirer ${child.fullName} de la fiche de ce parent ?\n' 'Retirer ${child.fullName} du foyer (tous les responsables) ?',
'(L\'enfant ne sera pas supprimé.)',
), ),
actions: [ actions: [
TextButton( TextButton(
@@ -246,8 +308,9 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
if (!mounted) return; if (!mounted) return;
await _reloadChildren(); await _reloadChildren();
if (!mounted) return; if (!mounted) return;
widget.onSaved?.call();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enfant détaché')), const SnackBar(content: Text('Enfant détaché du foyer')),
); );
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
@@ -258,41 +321,11 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
} }
Future<void> _attachChild() async { Future<void> _attachChild() async {
List<EnfantAdminModel> all;
try {
all = await UserService.getEnfants();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
return;
}
final linkedIds = _children.map((c) => c.id).toSet();
final candidates = all.where((e) => !linkedIds.contains(e.id)).toList();
if (candidates.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Aucun enfant disponible à rattacher')),
);
return;
}
if (!mounted) return; if (!mounted) return;
final selected = await showDialog<EnfantAdminModel>( final selected = await AdminSelectEnfantModal.show(
context: context, context,
builder: (ctx) => SimpleDialog( excludeIds: _children.map((c) => c.id).toSet(),
title: const Text('Rattacher un enfant'), title: 'Rattacher un enfant',
children: candidates
.map(
(e) => SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, e),
child: Text(e.fullName),
),
)
.toList(),
),
); );
if (selected == null || !mounted) return; if (selected == null || !mounted) return;
@@ -304,8 +337,9 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
if (!mounted) return; if (!mounted) return;
await _reloadChildren(); await _reloadChildren();
if (!mounted) return; if (!mounted) return;
widget.onSaved?.call();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enfant rattaché')), const SnackBar(content: Text('Enfant rattaché au foyer')),
); );
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
@@ -395,13 +429,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
), ),
if (_coParentSubtitle() != null) ...[ if (_coParentSubtitle() != null) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( _coParentSubtitle()!,
_coParentSubtitle()!,
style: const TextStyle(
fontSize: 13,
color: Colors.black54,
),
),
], ],
], ],
), ),
@@ -0,0 +1,218 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/am_vigilance.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
List<String> _amSelectSubtitleLines(AssistanteMaternelleModel am) {
final lines = <String>[];
final zone = (am.residenceCity ?? '').trim();
if (zone.isNotEmpty) lines.add('Zone : $zone');
final agrement = (am.approvalNumber ?? '').trim();
if (agrement.isNotEmpty) lines.add('Agrément : $agrement');
final max = am.maxChildren;
final free = amExpectedPlacesAvailable(
maxChildren: max,
childrenCount: am.children.length,
) ??
am.placesAvailable;
if (free != null || max != null) {
lines.add('Places libres : ${free ?? ''} / capa. ${max ?? ''}');
}
lines.add('${am.children.length} enfant(s)');
return lines;
}
/// Sélection d'une AM à rattacher (fiche enfant) — ticket #147.
/// S'appuie sur [AdminSelectListModal] (shell partagé avec #146).
class AdminSelectAmModal {
AdminSelectAmModal._();
static Future<AssistanteMaternelleModel?> show(
BuildContext context, {
Set<String> excludeIds = const {},
String title = 'Choisir une assistante maternelle',
}) {
return AdminSelectListModal.show<AssistanteMaternelleModel>(
context,
title: title,
searchHint: 'Rechercher par nom, prénom ou zone…',
emptyMessage: 'Aucune assistante maternelle disponible',
noResultsMessage: 'Aucune AM avec place libre pour cette recherche',
toggleFilter: const AdminSelectToggleFilter<AssistanteMaternelleModel>(
label: 'Libre',
initialValue: true,
whenEnabled: amHasFreePlace,
),
loadItems: () async {
final list = await UserService.getAssistantesMaternelles();
return list
.where((am) => !excludeIds.contains(am.user.id))
.toList()
..sort(
(a, b) => a.user.fullName
.toLowerCase()
.compareTo(b.user.fullName.toLowerCase()),
);
},
matchesQuery: (am, q) {
final u = am.user;
final name = u.fullName.toLowerCase();
final fn = (u.prenom ?? '').toLowerCase();
final ln = (u.nom ?? '').toLowerCase();
final zone = (am.residenceCity ?? '').toLowerCase();
final agrement = (am.approvalNumber ?? '').toLowerCase();
return name.contains(q) ||
fn.contains(q) ||
ln.contains(q) ||
zone.contains(q) ||
agrement.contains(q);
},
resolveSelect: (ctx, am, reload) =>
_resolveAmSelection(ctx, am, reload),
itemBuilder: (context, am, onSelect) {
final full = !amHasFreePlace(am);
return AdminUserCard(
title: am.user.fullName,
avatarUrl: am.user.photoUrl,
fallbackIcon: Icons.face,
subtitleLines: _amSelectSubtitleLines(am),
vigilanceTooltip: amPlacesVigilanceMessage(am),
onCardTap: onSelect,
margin: const EdgeInsets.only(bottom: 4),
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
backgroundColor: full ? const Color(0xFFFFEBEE) : null,
borderColor: full ? Colors.red.shade200 : null,
actions: [
IconButton(
icon: const Icon(Icons.add_link),
tooltip: 'Rattacher',
onPressed: onSelect,
),
],
);
},
);
}
static Future<AssistanteMaternelleModel?> _resolveAmSelection(
BuildContext context,
AssistanteMaternelleModel am,
Future<void> Function() reloadList,
) async {
if (amHasFreePlace(am)) return am;
final selected = await showDialog<AssistanteMaternelleModel>(
context: context,
builder: (ctx) => _AmNoPlaceWarningDialog(initialAm: am),
);
await reloadList();
return selected;
}
}
/// Avertissement AM saturée + lien vers la fiche pour ajuster les places.
class _AmNoPlaceWarningDialog extends StatefulWidget {
final AssistanteMaternelleModel initialAm;
const _AmNoPlaceWarningDialog({required this.initialAm});
@override
State<_AmNoPlaceWarningDialog> createState() =>
_AmNoPlaceWarningDialogState();
}
class _AmNoPlaceWarningDialogState extends State<_AmNoPlaceWarningDialog> {
late AssistanteMaternelleModel _am;
TapGestureRecognizer? _linkRecognizer;
@override
void initState() {
super.initState();
_am = widget.initialAm;
_linkRecognizer = TapGestureRecognizer()..onTap = _openAmFiche;
}
@override
void dispose() {
_linkRecognizer?.dispose();
super.dispose();
}
Future<void> _openAmFiche() async {
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AdminAmEditModal(
assistante: _am,
onSaved: () async {
try {
final fresh =
await UserService.getAssistanteMaternelle(_am.user.id);
if (mounted) setState(() => _am = fresh);
} catch (_) {}
},
),
);
if (!mounted) return;
try {
final fresh = await UserService.getAssistanteMaternelle(_am.user.id);
if (!mounted) return;
setState(() => _am = fresh);
if (amHasFreePlace(fresh)) {
Navigator.of(context).pop(fresh);
}
} catch (_) {}
}
@override
Widget build(BuildContext context) {
final name = _am.user.fullName.trim().isNotEmpty
? _am.user.fullName.trim()
: 'Cette assistante maternelle';
const linkColor = ValidationModalTheme.primaryActionBackground;
return AlertDialog(
title: const Text('Plus de place disponible'),
content: Text.rich(
TextSpan(
style: const TextStyle(fontSize: 14, color: Colors.black87, height: 1.4),
children: [
TextSpan(
text: '$name n\'a plus de place libre pour accueillir '
'un enfant supplémentaire.\n\n',
),
const TextSpan(text: 'Vous pouvez '),
TextSpan(
text: 'ouvrir sa fiche',
style: TextStyle(
color: linkColor,
decoration: TextDecoration.underline,
fontWeight: FontWeight.w600,
),
recognizer: _linkRecognizer,
),
const TextSpan(
text: ' pour modifier la capacité ou les places, '
'puis la sélectionner si une place se libère.',
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
],
);
}
}
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
/// Sélection d'un enfant à rattacher (fiche AM / fiche parent) — ticket #146.
/// S'appuie sur [AdminSelectListModal] (shell partagé avec #147).
class AdminSelectEnfantModal {
AdminSelectEnfantModal._();
static Future<EnfantAdminModel?> show(
BuildContext context, {
Set<String> excludeIds = const {},
String title = 'Rattacher un enfant',
/// Affiché uniquement depuis la fiche AM : filtre les enfants déjà en garde.
bool showSansGardeFilter = false,
}) {
return AdminSelectListModal.show<EnfantAdminModel>(
context,
title: title,
searchHint: 'Rechercher par nom ou prénom…',
emptyMessage: 'Aucun enfant disponible à rattacher',
noResultsMessage: showSansGardeFilter
? 'Aucun enfant sans garde pour cette recherche'
: 'Aucun résultat pour cette recherche',
toggleFilter: showSansGardeFilter
? AdminSelectToggleFilter<EnfantAdminModel>(
label: 'Sans garde',
initialValue: true,
whenEnabled: (e) =>
normalizeEnfantStatus(e.status) == 'sans_garde',
)
: null,
loadItems: () async {
final list = await UserService.getEnfants();
return list
.where((e) => !excludeIds.contains(e.id))
.toList()
..sort(
(a, b) =>
a.fullName.toLowerCase().compareTo(b.fullName.toLowerCase()),
);
},
matchesQuery: (e, q) {
final name = e.fullName.toLowerCase();
final fn = (e.firstName ?? '').toLowerCase();
final ln = (e.lastName ?? '').toLowerCase();
return name.contains(q) || fn.contains(q) || ln.contains(q);
},
itemBuilder: (context, e, onSelect) {
return AdminEnfantUserCard.fromEnfant(
e,
onCardTap: onSelect,
margin: const EdgeInsets.only(bottom: 4),
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
actions: [
IconButton(
icon: const Icon(Icons.add_link),
tooltip: 'Rattacher',
onPressed: onSelect,
),
],
);
},
);
}
}
@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
/// Foyer / famille sélectionnable pour rattacher un nouvel enfant (#132 / #157).
class AdminFamilleFoyer {
/// Parent pivot pour `POST /enfants` (`parent_user_id`).
final String pivotParentUserId;
/// Co-parent éventuel (rattachement foyer #157).
final String? coParentUserId;
final String? numeroDossier;
final String displayTitle;
final List<String> parentNames;
const AdminFamilleFoyer({
required this.pivotParentUserId,
required this.displayTitle,
required this.parentNames,
this.coParentUserId,
this.numeroDossier,
});
/// Parents du foyer à lier à lenfant (pivot puis co-parent).
List<String> get parentUserIds {
final ids = <String>[pivotParentUserId];
final co = (coParentUserId ?? '').trim();
if (co.isNotEmpty && co != pivotParentUserId) ids.add(co);
return ids;
}
String get subtitle {
final parts = <String>[];
final dossier = (numeroDossier ?? '').trim();
if (dossier.isNotEmpty) parts.add('Dossier $dossier');
if (parentNames.isNotEmpty) {
parts.add('Responsables : ${parentNames.join(', ')}');
}
return parts.join(' ');
}
}
/// Construit la liste des foyers uniques à partir de `GET /parents`.
List<AdminFamilleFoyer> buildFamilleFoyers(List<ParentModel> parents) {
final seenDossiers = <String>{};
final seenUserIds = <String>{};
final foyers = <AdminFamilleFoyer>[];
for (final p in parents) {
final dossier = (p.user.numeroDossier ?? '').trim();
if (dossier.isNotEmpty) {
if (seenDossiers.contains(dossier)) continue;
seenDossiers.add(dossier);
} else if (seenUserIds.contains(p.user.id)) {
continue;
}
seenUserIds.add(p.user.id);
final co = p.coParent;
if (co != null) seenUserIds.add(co.id);
final names = <String>[
if (p.user.fullName.trim().isNotEmpty) p.user.fullName.trim(),
if (co != null && co.fullName.trim().isNotEmpty) co.fullName.trim(),
];
final title = dossier.isNotEmpty
? 'Dossier $dossier'
: (names.isNotEmpty ? names.first : 'Famille');
foyers.add(
AdminFamilleFoyer(
pivotParentUserId: p.user.id,
coParentUserId: co?.id,
numeroDossier: dossier.isNotEmpty ? dossier : null,
displayTitle: title,
parentNames: names,
),
);
}
foyers.sort(
(a, b) => a.displayTitle.toLowerCase().compareTo(b.displayTitle.toLowerCase()),
);
return foyers;
}
/// Sélection d'une famille / dossier pour créer un enfant — ticket #132.
class AdminSelectFamilleModal {
AdminSelectFamilleModal._();
static Future<AdminFamilleFoyer?> show(
BuildContext context, {
String title = 'Choisir une famille',
}) {
return AdminSelectListModal.show<AdminFamilleFoyer>(
context,
title: title,
searchHint: 'Rechercher par dossier, nom…',
emptyMessage: 'Aucune famille disponible',
noResultsMessage: 'Aucun résultat pour cette recherche',
loadItems: () async {
final parents = await UserService.getParents();
return buildFamilleFoyers(parents);
},
matchesQuery: (f, q) {
final dossier = (f.numeroDossier ?? '').toLowerCase();
final title = f.displayTitle.toLowerCase();
final names = f.parentNames.join(' ').toLowerCase();
return dossier.contains(q) || title.contains(q) || names.contains(q);
},
itemBuilder: (context, f, onSelect) {
return AdminUserCard(
title: f.displayTitle,
fallbackIcon: Icons.family_restroom,
subtitleLines: [
if (f.parentNames.isNotEmpty) f.parentNames.join(', '),
if ((f.numeroDossier ?? '').isNotEmpty)
'Dossier ${f.numeroDossier}',
],
onCardTap: onSelect,
margin: const EdgeInsets.only(bottom: 4),
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
actions: [
IconButton(
icon: const Icon(Icons.check_circle_outline),
tooltip: 'Choisir',
onPressed: onSelect,
),
],
);
},
);
}
}
@@ -0,0 +1,367 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
/// Filtre optionnel (switch) sur la même ligne que la barre de recherche.
class AdminSelectToggleFilter<T> {
final String label;
final bool initialValue;
/// Si le switch est activé, ne garde que les éléments pour lesquels
/// [whenEnabled] renvoie `true`.
final bool Function(T item) whenEnabled;
const AdminSelectToggleFilter({
required this.label,
required this.whenEnabled,
this.initialValue = true,
});
}
/// Shell générique « rechercher + liste + sélection » pour les modales admin.
/// Utilisé par la sélection d'enfant (#146) et la sélection d'AM (#147).
class AdminSelectListModal<T> extends StatefulWidget {
final String title;
final String searchHint;
final Future<List<T>> Function() loadItems;
final bool Function(T item, String query) matchesQuery;
final Widget Function(
BuildContext context,
T item,
VoidCallback onSelect,
) itemBuilder;
final String emptyMessage;
final String noResultsMessage;
final double modalWidth;
/// Hauteur d'une carte (pour dimensionner la liste à ≥ [minVisibleCards]).
final double cardExtent;
/// Nombre minimum de cartes visibles dans la zone scrollable.
final int minVisibleCards;
/// Switch optionnel à droite du champ de recherche (ex. « Sans garde », « Libre »).
final AdminSelectToggleFilter<T>? toggleFilter;
/// Si fourni, appelé avant de valider la sélection.
/// Retourne l'élément à pop (éventuellement rafraîchi), ou `null` pour annuler.
final Future<T?> Function(
BuildContext context,
T item,
Future<void> Function() reload,
)? resolveSelect;
const AdminSelectListModal({
super.key,
required this.title,
required this.loadItems,
required this.matchesQuery,
required this.itemBuilder,
this.searchHint = 'Rechercher…',
this.emptyMessage = 'Aucun élément disponible',
this.noResultsMessage = 'Aucun résultat pour cette recherche',
this.modalWidth = 930,
this.cardExtent = 52,
this.minVisibleCards = 8,
this.toggleFilter,
this.resolveSelect,
});
static Future<T?> show<T>(
BuildContext context, {
required String title,
required Future<List<T>> Function() loadItems,
required bool Function(T item, String query) matchesQuery,
required Widget Function(
BuildContext context,
T item,
VoidCallback onSelect,
) itemBuilder,
String searchHint = 'Rechercher…',
String emptyMessage = 'Aucun élément disponible',
String noResultsMessage = 'Aucun résultat pour cette recherche',
double modalWidth = 930,
double cardExtent = 52,
int minVisibleCards = 8,
AdminSelectToggleFilter<T>? toggleFilter,
Future<T?> Function(
BuildContext context,
T item,
Future<void> Function() reload,
)? resolveSelect,
}) {
return showDialog<T>(
context: context,
builder: (ctx) => AdminSelectListModal<T>(
title: title,
loadItems: loadItems,
matchesQuery: matchesQuery,
itemBuilder: itemBuilder,
searchHint: searchHint,
emptyMessage: emptyMessage,
noResultsMessage: noResultsMessage,
modalWidth: modalWidth,
cardExtent: cardExtent,
minVisibleCards: minVisibleCards,
toggleFilter: toggleFilter,
resolveSelect: resolveSelect,
),
);
}
@override
State<AdminSelectListModal<T>> createState() =>
_AdminSelectListModalState<T>();
}
class _AdminSelectListModalState<T> extends State<AdminSelectListModal<T>> {
final _searchCtrl = TextEditingController();
List<T> _all = [];
bool _loading = true;
String? _error;
late bool _toggleOn;
bool _resolving = false;
double get _listHeight =>
widget.cardExtent * widget.minVisibleCards;
@override
void initState() {
super.initState();
_toggleOn = widget.toggleFilter?.initialValue ?? false;
_searchCtrl.addListener(() => setState(() {}));
_load();
}
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
final list = await widget.loadItems();
if (!mounted) return;
setState(() {
_all = list;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_error = e.toString().replaceFirst('Exception: ', '');
});
}
}
Future<void> _handleSelect(T item) async {
if (_resolving) return;
final resolve = widget.resolveSelect;
if (resolve == null) {
Navigator.of(context).pop(item);
return;
}
setState(() => _resolving = true);
try {
final chosen = await resolve(context, item, _load);
if (!mounted || chosen == null) return;
Navigator.of(context).pop(chosen);
} finally {
if (mounted) setState(() => _resolving = false);
}
}
List<T> get _filtered {
var list = _all;
final toggle = widget.toggleFilter;
if (toggle != null && _toggleOn) {
list = list.where(toggle.whenEnabled).toList();
}
final q = _searchCtrl.text.trim().toLowerCase();
if (q.isEmpty) return list;
return list.where((item) => widget.matchesQuery(item, q)).toList();
}
@override
Widget build(BuildContext context) {
final toggle = widget.toggleFilter;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: widget.modalWidth),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 4, 0),
child: Row(
children: [
Expanded(
child: Text(
widget.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
),
IconButton(
icon: const Icon(Icons.close),
tooltip: 'Fermer',
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: TextField(
controller: _searchCtrl,
decoration: InputDecoration(
isDense: true,
hintText: widget.searchHint,
prefixIcon: const Icon(Icons.search, size: 20),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
),
),
),
if (toggle != null) ...[
const SizedBox(width: 12),
Text(
toggle.label,
style: const TextStyle(fontSize: 13),
),
const SizedBox(width: 4),
Switch(
value: _toggleOn,
activeColor:
ValidationModalTheme.primaryActionBackground,
onChanged: (v) => setState(() => _toggleOn = v),
),
],
],
),
const SizedBox(height: 12),
SizedBox(
height: _listHeight,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: _buildBody(),
),
),
),
const SizedBox(height: 12),
Row(
children: [
const Spacer(),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
],
),
],
),
),
],
),
),
);
}
Widget _buildBody() {
if (_loading) {
return const Center(
child: CircularProgressIndicator(
color: ValidationModalTheme.primaryActionBackground,
),
);
}
if (_error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 40, color: Colors.red.shade400),
const SizedBox(height: 12),
Text(
_error!,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.red.shade700),
),
const SizedBox(height: 12),
TextButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
),
],
),
),
);
}
final items = _filtered;
if (_all.isEmpty) {
return Center(
child: Text(
widget.emptyMessage,
style: const TextStyle(fontSize: 14, color: Colors.black54),
),
);
}
if (items.isEmpty) {
return Center(
child: Text(
widget.noResultsMessage,
style: const TextStyle(fontSize: 14, color: Colors.black54),
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
itemExtent: widget.cardExtent,
itemCount: items.length,
itemBuilder: (context, i) {
final item = items[i];
return widget.itemBuilder(
context,
item,
() => _handleSelect(item),
);
},
);
}
}
@@ -14,6 +14,8 @@ class AdminUserCard extends StatefulWidget {
final Color? infoColor; final Color? infoColor;
final String? vigilanceTooltip; final String? vigilanceTooltip;
final VoidCallback? onCardTap; final VoidCallback? onCardTap;
final EdgeInsetsGeometry? margin;
final EdgeInsetsGeometry? contentPadding;
const AdminUserCard({ const AdminUserCard({
super.key, super.key,
@@ -28,6 +30,8 @@ class AdminUserCard extends StatefulWidget {
this.infoColor, this.infoColor,
this.vigilanceTooltip, this.vigilanceTooltip,
this.onCardTap, this.onCardTap,
this.margin,
this.contentPadding,
}); });
@override @override
@@ -59,7 +63,7 @@ class _AdminUserCardState extends State<AdminUserCard> {
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
hoverColor: const Color(0x149CC5C0), hoverColor: const Color(0x149CC5C0),
child: Card( child: Card(
margin: const EdgeInsets.only(bottom: 12), margin: widget.margin ?? const EdgeInsets.only(bottom: 12),
elevation: 0, elevation: 0,
color: widget.backgroundColor, color: widget.backgroundColor,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -67,7 +71,8 @@ class _AdminUserCardState extends State<AdminUserCard> {
side: BorderSide(color: widget.borderColor ?? Colors.grey.shade300), side: BorderSide(color: widget.borderColor ?? Colors.grey.shade300),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), padding: widget.contentPadding ??
const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
child: Row( child: Row(
children: [ children: [
_buildAvatar(avatarUrl), _buildAvatar(avatarUrl),
@@ -86,7 +91,10 @@ class _AdminUserCardState extends State<AdminUserCard> {
Expanded( Expanded(
child: Row( child: Row(
children: [ children: [
// flex: 0 → largeur du nom ; le reste va aux infos
// (évite le partage 50/50 qui tronque « Responsables »).
Flexible( Flexible(
flex: 0,
fit: FlexFit.loose, fit: FlexFit.loose,
child: Text( child: Text(
widget.title.isNotEmpty ? widget.title : 'Sans nom', widget.title.isNotEmpty ? widget.title : 'Sans nom',
@@ -1,7 +1,55 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/utils/nir_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/utils/postal_utils.dart';
import 'admin_detail_modal.dart'; import 'admin_detail_modal.dart';
/// Réglages des formulaires validation / wizard AM — **jouer sur ces 3 leviers**.
class ValidationFormMetrics {
ValidationFormMetrics._();
// --- 1. Titres de section ---
static const double sectionTitleFontSize = 16;
static const double sectionTitleGapBelow = 12;
// --- 2. TF : texte intérieur + padding vertical (= hauteur) ---
static const double fieldTextFontSize = 14;
static const double fieldContentPaddingV = 12;
static const double fieldContentPaddingH = 12;
/// Hauteur estimée du TF (texte + padding haut/bas + bordure).
static const double fieldHeight =
fieldTextFontSize + fieldContentPaddingV * 2 + 4;
// --- 3. Espace entre les lignes de TF ---
static const double rowGapBelow = 12;
// Libellé au-dessus du TF (titre du champ)
static const double fieldLabelFontSize = 13;
static const double fieldLabelGapBelow = 4;
static const TextStyle fieldTextStyle = TextStyle(
color: Colors.black87,
fontSize: fieldTextFontSize,
);
static double get sectionTitleBlockHeight =>
sectionTitleFontSize + sectionTitleGapBelow;
static double get labeledRowHeight =>
fieldLabelFontSize + fieldLabelGapBelow + fieldHeight + rowGapBelow;
/// Corps modale AM : padding wizard + titre + [rows] lignes + nav.
static double shellBodyHeightForRows(int rows) =>
20 * 2 + // padding wizard
4 + // espace haut
sectionTitleBlockHeight +
rows * labeledRowHeight +
24 + // avant nav
44; // boutons
}
/// Bloc type formulaire (titre de section + champs read-only) pour les modales de validation. /// Bloc type formulaire (titre de section + champs read-only) pour les modales de validation.
/// [rowLayout] : même disposition que la création de compte, ex. [2, 2, 1, 2] = ligne de 2, ligne de 2, plein largeur, ligne de 2. /// [rowLayout] : même disposition que la création de compte, ex. [2, 2, 1, 2] = ligne de 2, ligne de 2, plein largeur, ligne de 2.
/// [rowFlex] : flex par index de ligne (optionnel). Ex. {3: [2, 5]} = 4e ligne : code postal étroit (2), ville large (5). /// [rowFlex] : flex par index de ligne (optionnel). Ex. {3: [2, 5]} = 4e ligne : code postal étroit (2), ville large (5).
@@ -16,12 +64,16 @@ class ValidationDetailSection extends StatelessWidget {
/// Flex par ligne (index de ligne -> [flex1, flex2, ...]). Ex. {3: [2, 5]} pour Code postal | Ville. /// Flex par ligne (index de ligne -> [flex1, flex2, ...]). Ex. {3: [2, 5]} pour Code postal | Ville.
final Map<int, List<int>>? rowFlex; final Map<int, List<int>>? rowFlex;
/// Remplit la hauteur disponible (wizard AM étapes 12).
final bool expandVertically;
const ValidationDetailSection({ const ValidationDetailSection({
super.key, super.key,
this.title, this.title,
required this.fields, required this.fields,
this.rowLayout, this.rowLayout,
this.rowFlex, this.rowFlex,
this.expandVertically = false,
}); });
@override @override
@@ -30,6 +82,7 @@ class ValidationDetailSection extends StatelessWidget {
title: title, title: title,
rowLayout: rowLayout, rowLayout: rowLayout,
rowFlex: rowFlex, rowFlex: rowFlex,
expandVertically: expandVertically,
fields: fields fields: fields
.map( .map(
(f) => ValidationLabeledField( (f) => ValidationLabeledField(
@@ -49,6 +102,8 @@ class ValidationFormGrid extends StatelessWidget {
final List<int>? rowLayout; final List<int>? rowLayout;
final Map<int, List<int>>? rowFlex; final Map<int, List<int>>? rowFlex;
final bool compact; final bool compact;
/// Répartit la hauteur dispo entre les lignes (remplit le blanc sans scroll).
final bool expandVertically;
const ValidationFormGrid({ const ValidationFormGrid({
super.key, super.key,
@@ -57,6 +112,7 @@ class ValidationFormGrid extends StatelessWidget {
this.rowLayout, this.rowLayout,
this.rowFlex, this.rowFlex,
this.compact = false, this.compact = false,
this.expandVertically = false,
}); });
@override @override
@@ -64,7 +120,7 @@ class ValidationFormGrid extends StatelessWidget {
final layout = rowLayout ?? List.filled(fields.length, 1); final layout = rowLayout ?? List.filled(fields.length, 1);
int index = 0; int index = 0;
int rowIndex = 0; int rowIndex = 0;
final rows = <Widget>[]; final rowWidgets = <Widget>[];
for (final count in layout) { for (final count in layout) {
if (index >= fields.length) break; if (index >= fields.length) break;
final rowFields = fields.skip(index).take(count).toList(); final rowFields = fields.skip(index).take(count).toList();
@@ -72,48 +128,73 @@ class ValidationFormGrid extends StatelessWidget {
if (rowFields.isEmpty) continue; if (rowFields.isEmpty) continue;
final flexForRow = rowFlex?[rowIndex]; final flexForRow = rowFlex?[rowIndex];
rowIndex++; rowIndex++;
final labeled = rowFields
.map(
(f) => ValidationLabeledField(
label: f.label,
field: f.field,
expand: expandVertically,
),
)
.toList();
Widget row;
if (count == 1) { if (count == 1) {
rows.add(Padding( row = labeled.first;
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
child: rowFields.first,
));
} else { } else {
rows.add(Padding( row = Row(
padding: EdgeInsets.only(bottom: compact ? 8 : 12), crossAxisAlignment: expandVertically
child: Row( ? CrossAxisAlignment.stretch
crossAxisAlignment: CrossAxisAlignment.start, : CrossAxisAlignment.start,
children: [ children: [
for (int i = 0; i < rowFields.length; i++) ...[ for (int i = 0; i < labeled.length; i++) ...[
if (i > 0) SizedBox(width: compact ? 12 : 16), if (i > 0) SizedBox(width: compact ? 12 : 16),
Expanded( Expanded(
flex: (flexForRow != null && i < flexForRow.length) flex: (flexForRow != null && i < flexForRow.length)
? flexForRow[i] ? flexForRow[i]
: 1, : 1,
child: rowFields[i], child: labeled[i],
), ),
],
], ],
],
);
}
if (expandVertically) {
rowWidgets.add(Expanded(child: row));
} else {
rowWidgets.add(Padding(
padding: EdgeInsets.only(
bottom: compact ? 8 : ValidationFormMetrics.rowGapBelow,
), ),
child: row,
)); ));
} }
} }
final showTitle = title != null && title!.trim().isNotEmpty; final showTitle = title != null && title!.trim().isNotEmpty;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: expandVertically ? MainAxisSize.max : MainAxisSize.min,
children: [ children: [
if (showTitle) ...[ if (showTitle) ...[
Text( Text(
title!.trim(), title!.trim(),
style: TextStyle( style: TextStyle(
fontSize: compact ? 15 : 16, fontSize: compact
? ValidationFormMetrics.sectionTitleFontSize - 1
: ValidationFormMetrics.sectionTitleFontSize,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.black87, color: Colors.black87,
), ),
), ),
SizedBox(height: compact ? 8 : 12), SizedBox(
height: compact
? 8
: ValidationFormMetrics.sectionTitleGapBelow,
),
], ],
...rows, ...rowWidgets,
], ],
); );
} }
@@ -130,8 +211,8 @@ class ValidationFieldDecoration {
fillColor: Colors.grey.shade50, fillColor: Colors.grey.shade50,
hintText: hint, hintText: hint,
contentPadding: EdgeInsets.symmetric( contentPadding: EdgeInsets.symmetric(
horizontal: compact ? 10 : 12, horizontal: compact ? 10 : ValidationFormMetrics.fieldContentPaddingH,
vertical: compact ? 7 : 10, vertical: compact ? 7 : ValidationFormMetrics.fieldContentPaddingV,
), ),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
@@ -180,29 +261,31 @@ class ValidationFieldDecoration {
class ValidationLabeledField extends StatelessWidget { class ValidationLabeledField extends StatelessWidget {
final String label; final String label;
final Widget field; final Widget field;
final bool expand;
const ValidationLabeledField({ const ValidationLabeledField({
super.key, super.key,
required this.label, required this.label,
required this.field, required this.field,
this.expand = false,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min,
children: [ children: [
Text( Text(
label, label,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: ValidationFormMetrics.fieldLabelFontSize,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: Colors.grey.shade700, color: Colors.grey.shade700,
), ),
), ),
const SizedBox(height: 4), SizedBox(height: ValidationFormMetrics.fieldLabelGapBelow),
field, if (expand) Expanded(child: field) else field,
], ],
); );
} }
@@ -264,13 +347,16 @@ class ValidationEditableField extends StatelessWidget {
); );
} }
if (!compact) { if (!compact) {
return TextField( return _validationFieldFillHeight(
controller: controller, TextField(
keyboardType: keyboardType, controller: controller,
inputFormatters: inputFormatters, keyboardType: keyboardType,
maxLines: 1, inputFormatters: inputFormatters,
style: const TextStyle(color: Colors.black87, fontSize: 14), maxLines: 1,
decoration: ValidationFieldDecoration.input(hint: hintText), textAlignVertical: TextAlignVertical.center,
style: ValidationFormMetrics.fieldTextStyle,
decoration: ValidationFieldDecoration.input(hint: hintText),
),
); );
} }
return SizedBox( return SizedBox(
@@ -295,6 +381,347 @@ class ValidationEditableField extends StatelessWidget {
} }
} }
/// Étire le champ si le parent impose une hauteur (grille [expandVertically]).
Widget _validationFieldFillHeight(Widget field) {
return LayoutBuilder(
builder: (context, c) {
if (!c.hasBoundedHeight || !c.maxHeight.isFinite) return field;
return SizedBox(
height: c.maxHeight,
width: double.infinity,
child: field,
);
},
);
}
/// E-mail style validation — même supervision que login / création de compte :
/// [EmailMaxLengthFormatter] + à la perte de focus : trim/minuscules + validation.
class ValidationEmailField extends StatefulWidget {
final TextEditingController controller;
final String? hintText;
final bool allowEmpty;
const ValidationEmailField({
super.key,
required this.controller,
this.hintText,
this.allowEmpty = false,
});
@override
State<ValidationEmailField> createState() => _ValidationEmailFieldState();
}
class _ValidationEmailFieldState extends State<ValidationEmailField> {
final GlobalKey<FormFieldState<String>> _fieldKey =
GlobalKey<FormFieldState<String>>();
late final FocusNode _focusNode;
@override
void initState() {
super.initState();
_focusNode = FocusNode();
_focusNode.addListener(_onFocusChange);
}
void _onFocusChange() {
if (_focusNode.hasFocus) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _focusNode.hasFocus) return;
final c = widget.controller;
final normalized = normalizeEmailText(c.text);
if (normalized != c.text) {
c.value = TextEditingValue(
text: normalized,
selection: TextSelection.collapsed(offset: normalized.length),
);
}
_fieldKey.currentState?.validate();
});
}
@override
void dispose() {
_focusNode.removeListener(_onFocusChange);
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return _validationFieldFillHeight(
TextFormField(
key: _fieldKey,
controller: widget.controller,
focusNode: _focusNode,
keyboardType: TextInputType.emailAddress,
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.email],
textInputAction: TextInputAction.next,
textAlignVertical: TextAlignVertical.center,
inputFormatters: const [EmailMaxLengthFormatter()],
style: ValidationFormMetrics.fieldTextStyle,
decoration:
ValidationFieldDecoration.input(hint: widget.hintText).copyWith(
errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11),
errorMaxLines: 2,
),
validator: (value) =>
validateEmail(value, allowEmpty: widget.allowEmpty),
),
);
}
}
/// Code postal FR — même supervision que création de compte :
/// chiffres uniquement (max 5) + validation à la perte de focus.
class ValidationPostalCodeField extends StatefulWidget {
final TextEditingController controller;
final String? hintText;
final bool allowEmpty;
const ValidationPostalCodeField({
super.key,
required this.controller,
this.hintText,
this.allowEmpty = false,
});
@override
State<ValidationPostalCodeField> createState() =>
_ValidationPostalCodeFieldState();
}
class _ValidationPostalCodeFieldState extends State<ValidationPostalCodeField> {
final GlobalKey<FormFieldState<String>> _fieldKey =
GlobalKey<FormFieldState<String>>();
late final FocusNode _focusNode;
@override
void initState() {
super.initState();
_focusNode = FocusNode();
_focusNode.addListener(_onFocusChange);
}
void _onFocusChange() {
if (_focusNode.hasFocus) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _focusNode.hasFocus) return;
final c = widget.controller;
final trimmed = c.text.trim();
if (trimmed != c.text) {
c.value = TextEditingValue(
text: trimmed,
selection: TextSelection.collapsed(offset: trimmed.length),
);
}
_fieldKey.currentState?.validate();
});
}
@override
void dispose() {
_focusNode.removeListener(_onFocusChange);
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return _validationFieldFillHeight(
TextFormField(
key: _fieldKey,
controller: widget.controller,
focusNode: _focusNode,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
textAlignVertical: TextAlignVertical.center,
inputFormatters: kFrenchPostalCodeInputFormatters,
style: ValidationFormMetrics.fieldTextStyle,
decoration: ValidationFieldDecoration.input(
hint: widget.hintText ?? '5 chiffres',
).copyWith(
errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11),
errorMaxLines: 2,
),
validator: (value) =>
validateFrenchPostalCode(value, allowEmpty: widget.allowEmpty),
),
);
}
}
/// Téléphone FR — formatters live + [validateFrenchNationalPhone] à la perte de focus.
class ValidationPhoneField extends StatefulWidget {
final TextEditingController controller;
final String? hintText;
final bool allowEmpty;
const ValidationPhoneField({
super.key,
required this.controller,
this.hintText,
this.allowEmpty = false,
});
@override
State<ValidationPhoneField> createState() => _ValidationPhoneFieldState();
}
class _ValidationPhoneFieldState extends State<ValidationPhoneField> {
final GlobalKey<FormFieldState<String>> _fieldKey =
GlobalKey<FormFieldState<String>>();
late final FocusNode _focusNode;
@override
void initState() {
super.initState();
_focusNode = FocusNode();
_focusNode.addListener(_onFocusChange);
}
void _onFocusChange() {
if (_focusNode.hasFocus) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _focusNode.hasFocus) return;
final c = widget.controller;
final digits = normalizePhone(c.text);
final formatted = digits.isEmpty ? '' : formatPhoneForDisplay(digits);
if (formatted != c.text) {
c.value = TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}
_fieldKey.currentState?.validate();
});
}
@override
void dispose() {
_focusNode.removeListener(_onFocusChange);
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return _validationFieldFillHeight(
TextFormField(
key: _fieldKey,
controller: widget.controller,
focusNode: _focusNode,
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.next,
textAlignVertical: TextAlignVertical.center,
inputFormatters: frenchPhoneInputFormatters,
style: ValidationFormMetrics.fieldTextStyle,
decoration:
ValidationFieldDecoration.input(hint: widget.hintText).copyWith(
errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11),
errorMaxLines: 2,
),
validator: (value) =>
validateFrenchNationalPhone(value, allowEmpty: widget.allowEmpty),
),
);
}
}
/// NIR — formatage live ([NirInputFormatter]) + validation au fil de la saisie / blur.
class ValidationNirField extends StatefulWidget {
final TextEditingController controller;
final String? hintText;
final bool allowEmpty;
const ValidationNirField({
super.key,
required this.controller,
this.hintText,
this.allowEmpty = false,
});
@override
State<ValidationNirField> createState() => _ValidationNirFieldState();
}
class _ValidationNirFieldState extends State<ValidationNirField> {
final GlobalKey<FormFieldState<String>> _fieldKey =
GlobalKey<FormFieldState<String>>();
late final FocusNode _focusNode;
bool _blurred = false;
@override
void initState() {
super.initState();
_focusNode = FocusNode();
_focusNode.addListener(_onFocusChange);
}
void _onFocusChange() {
if (_focusNode.hasFocus) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _focusNode.hasFocus) return;
setState(() => _blurred = true);
final c = widget.controller;
final raw = nirToRaw(c.text).toUpperCase();
final formatted = raw.isEmpty ? '' : formatNir(raw);
if (formatted != c.text) {
c.value = TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}
_fieldKey.currentState?.validate();
});
}
@override
void dispose() {
_focusNode.removeListener(_onFocusChange);
_focusNode.dispose();
super.dispose();
}
String? _validator(String? value) {
if (_blurred) {
if (widget.allowEmpty && (value == null || value.trim().isEmpty)) {
return null;
}
return validateNir(value);
}
return validateNirTyping(value);
}
@override
Widget build(BuildContext context) {
return _validationFieldFillHeight(
TextFormField(
key: _fieldKey,
controller: widget.controller,
focusNode: _focusNode,
keyboardType: TextInputType.text,
textInputAction: TextInputAction.next,
textAlignVertical: TextAlignVertical.center,
autovalidateMode: AutovalidateMode.onUserInteraction,
inputFormatters: const [NirInputFormatter()],
style: ValidationFormMetrics.fieldTextStyle,
decoration: ValidationFieldDecoration.input(
hint: widget.hintText ?? '1 12 34 56 789 012 - 34',
).copyWith(
errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11),
errorMaxLines: 2,
),
onChanged: (_) => _fieldKey.currentState?.validate(),
validator: _validator,
),
);
}
}
/// Grille label/champ éditable (délègue à [ValidationFormGrid]). /// Grille label/champ éditable (délègue à [ValidationFormGrid]).
class ValidationEditableSection extends StatelessWidget { class ValidationEditableSection extends StatelessWidget {
final List<ValidationLabeledField> fields; final List<ValidationLabeledField> fields;
@@ -368,16 +795,19 @@ class _ValidationReadOnlyFieldState extends State<ValidationReadOnlyField> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!widget.compact && widget.maxLines == 1) { if (!widget.compact && widget.maxLines == 1) {
return TextField( return _validationFieldFillHeight(
controller: _controller, TextField(
readOnly: true, controller: _controller,
enableInteractiveSelection: false, readOnly: true,
style: TextStyle( enableInteractiveSelection: false,
color: widget.error ? Colors.red.shade800 : Colors.black87, textAlignVertical: TextAlignVertical.center,
fontSize: 14, style: TextStyle(
fontWeight: widget.error ? FontWeight.w600 : null, color: widget.error ? Colors.red.shade800 : Colors.black87,
fontSize: ValidationFormMetrics.fieldTextFontSize,
fontWeight: widget.error ? FontWeight.w600 : null,
),
decoration: ValidationFieldDecoration.readOnly(error: widget.error),
), ),
decoration: ValidationFieldDecoration.readOnly(error: widget.error),
); );
} }
@@ -72,7 +72,14 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
normalizeEnfantStatus(e.status) == normalizeEnfantStatus(e.status) ==
normalizeEnfantStatus(widget.statusFilter); normalizeEnfantStatus(widget.statusFilter);
return matchesName && matchesStatus; return matchesName && matchesStatus;
}).toList(); }).toList()
..sort((a, b) {
// Orphelins (#157) en tête, puis ordre alphabétique.
final ao = a.hasNoResponsable ? 0 : 1;
final bo = b.hasNoResponsable ? 0 : 1;
if (ao != bo) return ao.compareTo(bo);
return a.fullName.toLowerCase().compareTo(b.fullName.toLowerCase());
});
return UserList( return UserList(
isLoading: _isLoading, isLoading: _isLoading,
@@ -80,7 +80,7 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
onCardTap: () => _openParentDetails(parent), onCardTap: () => _openParentDetails(parent),
subtitleLines: [ subtitleLines: [
parent.user.email, parent.user.email,
'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.children.isNotEmpty ? parent.children.length : parent.childrenCount}', 'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${ParentModel.foyerChildrenCount(parent, _parents)}',
], ],
actions: [ actions: [
IconButton( IconButton(
@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.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/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/admin_management_widget.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/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/dashboard_admin.dart';
import 'package:p_tits_pas/widgets/admin/enfant_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/gestionnaire_management_widget.dart';
@@ -26,6 +28,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
int _subIndex = 0; int _subIndex = 0;
int _gestionnaireRefreshTick = 0; int _gestionnaireRefreshTick = 0;
int _adminRefreshTick = 0; int _adminRefreshTick = 0;
int _enfantRefreshTick = 0;
int _amRefreshTick = 0;
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final TextEditingController _amCapacityController = TextEditingController(); final TextEditingController _amCapacityController = TextEditingController();
String? _parentStatus; String? _parentStatus;
@@ -215,7 +219,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
value: 'garde', value: 'garde',
child: Padding( child: Padding(
padding: EdgeInsets.only(left: 10), padding: EdgeInsets.only(left: 10),
child: Text('En garde', style: TextStyle(fontSize: 12)), child: Text('Gardé', style: TextStyle(fontSize: 12)),
), ),
), ),
DropdownMenuItem<String?>( DropdownMenuItem<String?>(
@@ -264,11 +268,13 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
); );
case 1: case 1:
return EnfantManagementWidget( return EnfantManagementWidget(
key: ValueKey('enfants-$_enfantRefreshTick'),
searchQuery: _searchController.text, searchQuery: _searchController.text,
statusFilter: _enfantStatus, statusFilter: _enfantStatus,
); );
case 2: case 2:
return AssistanteMaternelleManagementWidget( return AssistanteMaternelleManagementWidget(
key: ValueKey('ams-$_amRefreshTick'),
searchQuery: _searchController.text, searchQuery: _searchController.text,
capacityMin: int.tryParse(_amCapacityController.text), capacityMin: int.tryParse(_amCapacityController.text),
); );
@@ -311,6 +317,41 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
Future<void> _handleAddPressed() async { Future<void> _handleAddPressed() async {
final contentIndex = _subIndex - _contentIndexOffset; final contentIndex = _subIndex - _contentIndexOffset;
if (contentIndex == 1) {
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return AdminChildDetailModal.create(
onSaved: () {
if (!mounted) return;
setState(() => _enfantRefreshTick++);
},
);
},
);
return;
}
if (contentIndex == 2) {
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return AmDossierCreateModal(
onClose: () => Navigator.of(dialogContext).pop(),
onSuccess: () {
Navigator.of(dialogContext).pop();
if (!mounted) return;
setState(() => _amRefreshTick++);
},
);
},
);
return;
}
if (contentIndex == 3) { if (contentIndex == 3) {
final created = await showDialog<bool>( final created = await showDialog<bool>(
context: context, context: context,
@@ -354,7 +395,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text( content: Text(
'La création parent / enfant / AM sera disponible avec le ticket #129.', 'La création parent sera disponible avec le ticket #129.',
), ),
), ),
); );
@@ -1,20 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart';
import 'package:p_tits_pas/utils/nir_utils.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
import 'validation_modal_theme.dart';
import 'validation_refus_form.dart';
import 'validation_valider_confirm_dialog.dart';
/// Wizard de validation dossier AM : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107. /// Wrapper historique (#107) — délègue à [AmDossierWizard.review].
class ValidationAmWizard extends StatefulWidget { class ValidationAmWizard extends StatelessWidget {
final DossierAM dossier; final DossierAM dossier;
final VoidCallback onClose; final VoidCallback onClose;
final VoidCallback onSuccess; final VoidCallback onSuccess;
@@ -28,480 +17,13 @@ class ValidationAmWizard extends StatefulWidget {
this.onStepChanged, this.onStepChanged,
}); });
@override
State<ValidationAmWizard> createState() => _ValidationAmWizardState();
}
class _ValidationAmWizardState extends State<ValidationAmWizard> {
int _step = 0;
bool _showRefusForm = false;
bool _submitting = false;
static const int _stepCount = 3;
bool get _isEnAttente => widget.dossier.user.statut == 'en_attente';
static String _v(String? s) =>
(s != null && s.trim().isNotEmpty) ? s.trim() : '';
/// Présentation lisible : `1 12 34 56 789 012 - 34` (15 caractères utiles requis).
static String _formatNirForDisplay(String? nir) {
final v = _v(nir);
if (v == '') return v;
final raw = nirToRaw(v).toUpperCase();
return raw.length == 15 ? formatNir(raw) : v;
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
}
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
/// Panneau photo + grille droite : NIR|naissance, ville|pays, agrément|date, capa|places.
List<AdminDetailField> _photoProFields(DossierAM d) {
final u = d.user;
return [
AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)),
AdminDetailField(
label: 'Date de naissance',
value: formatIsoDateFr(u.dateNaissance),
),
AdminDetailField(
label: 'Ville de naissance',
value: _v(u.lieuNaissanceVille),
),
AdminDetailField(
label: 'Pays de naissance',
value: _v(u.lieuNaissancePays),
),
AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)),
AdminDetailField(
label: 'Date dagrément',
value: formatIsoDateFr(d.dateAgrement),
),
AdminDetailField(
label: 'Capacité max (enfants)',
value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '',
),
AdminDetailField(
label: 'Places disponibles',
value: d.placesDisponibles != null
? d.placesDisponibles.toString()
: '',
),
];
}
static const List<int> _photoProRowLayout = [2, 2, 2, 2];
/// Proportion photo didentité (35×45 mm).
static const double _idPhotoAspectRatio = 35 / 45;
static const double _photoProGap = 24;
/// Largeur mini réservée aux champs (évite une colonne photo trop gourmande).
static const double _proColumnMinWidth = 260;
static const double _photoColumnMinWidth = 160;
/// URL complète pour la photo : si relatif, préfixe [ApiConfig.baseUrl] (ex. `/api/v1/uploads/...`).
static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url);
Widget _buildPhotoSection(AppUser u) {
final photoUrl = _fullPhotoUrl(u.photoUrl);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: LayoutBuilder(
builder: (context, c) {
// Cadre clair : une seule épaisseur partout (photo + padding identique haut/bas/gauche/droite).
const uniformFrame = 8.0;
final maxPhotoW =
(c.maxWidth - 2 * uniformFrame).clamp(0.0, double.infinity);
final maxPhotoH =
(c.maxHeight - 2 * uniformFrame).clamp(0.0, double.infinity);
const ar = _idPhotoAspectRatio;
double ph = maxPhotoH;
double pw = ph * ar;
if (pw > maxPhotoW) {
pw = maxPhotoW;
ph = pw / ar;
}
return Align(
alignment: Alignment.center,
child: Container(
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(uniformFrame),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: pw,
height: ph,
child: photoUrl.isEmpty
? ColoredBox(
color: Colors.grey.shade200,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.person_off_outlined,
size: 40,
color: Colors.grey.shade400),
const SizedBox(height: 8),
Text(
'Aucune photo fournie',
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 12),
),
],
),
)
: AuthNetworkImage(
url: photoUrl,
fit: BoxFit.cover,
width: pw,
height: ph,
loadingBuilder: (_, child, progress) {
if (progress == null) return child;
return ColoredBox(
color: Colors.grey.shade200,
child: Center(
child: CircularProgressIndicator(
value: progress.expectedTotalBytes !=
null
? progress.cumulativeBytesLoaded /
(progress.expectedTotalBytes!)
: null,
),
),
);
},
errorBuilder: (_, __, ___) => ColoredBox(
color: Colors.grey.shade200,
child: Column(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(Icons.broken_image_outlined,
size: 40,
color: Colors.grey.shade400),
const SizedBox(height: 8),
Text(
'Impossible de charger la photo',
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 12),
),
],
),
),
),
),
),
),
),
);
},
),
),
),
],
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_showRefusForm) { return AmDossierWizard.review(
return _buildRefusPage(); dossier: dossier,
} onClose: onClose,
return Padding( onSuccess: onSuccess,
padding: const EdgeInsets.all(20), onStepChanged: onStepChanged,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 4),
Expanded(child: _buildStepContent()),
const SizedBox(height: 24),
_buildNavigation(),
],
),
); );
} }
Widget _buildStepContent() {
final d = widget.dossier;
final u = d.user;
switch (_step) {
case 0:
return LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: IdentityBlock.readOnlyFromUser(
u,
title: 'Identité et coordonnées',
emptyLabel: '',
),
),
);
},
);
case 1:
// Pas de SingleChildScrollView sur la Row (hauteur non bornée). Défilement à droite.
// Largeur photo ≈ ratio × hauteur utile, plafonnée pour laisser au moins [_proColumnMinWidth] aux champs.
return LayoutBuilder(
builder: (context, c) {
final maxRowW = c.maxWidth;
final maxRowH = c.maxHeight;
const photoHeaderH = 0.0;
final bodyH = (maxRowH - photoHeaderH).clamp(0.0, double.infinity);
final idealPhotoW =
bodyH * _idPhotoAspectRatio + 16; // marge approx. cadre clair
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
.clamp(0.0, double.infinity);
var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 360.0);
if (photoW > maxPhotoW) photoW = maxPhotoW;
photoW = photoW.clamp(0.0, maxRowW - _photoProGap);
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: photoW,
child: _buildPhotoSection(u),
),
const SizedBox(width: _photoProGap),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(
minWidth: constraints.maxWidth),
child: ValidationDetailSection(
title: 'Dossier professionnel',
fields: _photoProFields(d),
rowLayout: _photoProRowLayout,
),
),
);
},
),
),
],
);
},
);
case 2:
final presentation =
(d.presentation != null && d.presentation!.trim().isNotEmpty)
? d.presentation!
: '';
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Présentation',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 12),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints:
BoxConstraints(minHeight: constraints.maxHeight),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.grey.shade300),
),
child: SelectableText(
presentation,
style: const TextStyle(
color: Colors.black87, fontSize: 14),
),
),
),
);
},
),
),
],
);
default:
return const SizedBox();
}
}
Widget _buildNavigation() {
if (_step == 2) {
return Row(
children: [
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
const Spacer(),
Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: () {
setState(() => _step = 1);
_emitStep();
},
child: const Text('Précédent'),
),
const SizedBox(width: 8),
if (_isEnAttente) ...[
OutlinedButton(
onPressed: _submitting ? null : _refuser,
child: const Text('Refuser')),
const SizedBox(width: 12),
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: _submitting ? null : _onValiderPressed,
child: Text(_submitting ? 'Envoi...' : 'Valider'),
),
] else
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: widget.onClose,
child: const Text('Fermer'),
),
],
),
],
);
}
return Row(
children: [
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
const Spacer(),
if (_step > 0) ...[
TextButton(
onPressed: () {
setState(() => _step--);
_emitStep();
},
child: const Text('Précédent'),
),
const SizedBox(width: 8),
],
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: () {
setState(() => _step++);
_emitStep();
},
child: const Text('Suivant'),
),
],
);
}
Future<void> _onValiderPressed() async {
if (_submitting) return;
final ok = await showValidationValiderConfirmDialog(
context,
body:
'Voulez-vous valider le dossier de cette assistante maternelle ? Cette action confirme le compte.',
);
if (!mounted || !ok) return;
await _valider();
}
Future<void> _valider() async {
if (_submitting) return;
setState(() => _submitting = true);
try {
await UserService.validateUser(widget.dossier.user.id);
if (!mounted) return;
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);
}
}
void _refuser() => setState(() => _showRefusForm = true);
Widget _buildRefusPage() {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: ValidationRefusForm(
isSubmitting: _submitting,
onCancel: widget.onClose,
onPrevious: () => setState(() => _showRefusForm = false),
onSubmit: (comment) {
if (comment == null || comment.trim().isEmpty) return;
_refuserEnvoyer(comment.trim());
},
),
),
],
),
);
}
Future<void> _refuserEnvoyer(String comment) async {
if (_submitting) return;
setState(() => _submitting = true);
try {
await UserService.refuseUser(widget.dossier.user.id, comment: comment);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text(
'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.',
),
duration: const 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);
}
}
} }
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/services/user_service.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/validation_am_wizard.dart'; import 'package:p_tits_pas/widgets/admin/validation_am_wizard.dart';
import 'package:p_tits_pas/widgets/admin/validation_family_wizard.dart'; import 'package:p_tits_pas/widgets/admin/validation_family_wizard.dart';
@@ -76,8 +77,13 @@ class _ValidationDossierModalState extends State<ValidationDossierModal> {
/// Largeur modale = 1,5 × 620. /// Largeur modale = 1,5 × 620.
static const double _modalWidth = 930; // 620 * 1.5 static const double _modalWidth = 930; // 620 * 1.5
// Hauteur uniforme (ajustée +5px pour éviter l'overflow des étapes parents sans scroll). static const double _familyBodyHeight = 435;
static const double _bodyHeight = 435;
double get _bodyHeight {
final d = _dossier;
if (d != null && d.isAm) return AmDossierWizard.shellBodyHeight;
return _familyBodyHeight;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
+27 -17
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
@@ -67,7 +68,9 @@ class IdentityValues {
/// Bloc identité : Nom/Prénom, Tél/Email, Adresse, CP/Ville. /// Bloc identité : Nom/Prénom, Tél/Email, Adresse, CP/Ville.
/// Grille partagée (création de compte, validation AM/famille, fiches admin, etc.). /// Grille partagée (création de compte, validation AM/famille, fiches admin, etc.).
class IdentityBlock extends StatelessWidget { final String? title; class IdentityBlock extends StatelessWidget {
final String? title;
final bool expandVertically;
final String? _nom; final String? _nom;
final String? _prenom; final String? _prenom;
@@ -91,6 +94,7 @@ class IdentityBlock extends StatelessWidget { final String? title;
const IdentityBlock.readOnly({ const IdentityBlock.readOnly({
super.key, super.key,
this.title, this.title,
this.expandVertically = false,
required String nom, required String nom,
required String prenom, required String prenom,
required String telephone, required String telephone,
@@ -116,6 +120,7 @@ class IdentityBlock extends StatelessWidget { final String? title;
const IdentityBlock.editable({ const IdentityBlock.editable({
super.key, super.key,
this.title, this.title,
this.expandVertically = false,
required TextEditingController nomController, required TextEditingController nomController,
required TextEditingController prenomController, required TextEditingController prenomController,
required TextEditingController telephoneController, required TextEditingController telephoneController,
@@ -142,11 +147,13 @@ class IdentityBlock extends StatelessWidget { final String? title;
factory IdentityBlock.readOnlyValues({ factory IdentityBlock.readOnlyValues({
Key? key, Key? key,
String? title, String? title,
bool expandVertically = false,
required IdentityValues values, required IdentityValues values,
}) { }) {
return IdentityBlock.readOnly( return IdentityBlock.readOnly(
key: key, key: key,
title: title, title: title,
expandVertically: expandVertically,
nom: values.nom, nom: values.nom,
prenom: values.prenom, prenom: values.prenom,
telephone: values.telephone, telephone: values.telephone,
@@ -163,10 +170,12 @@ class IdentityBlock extends StatelessWidget { final String? title;
Key? key, Key? key,
String? title, String? title,
String emptyLabel = 'Non défini', String emptyLabel = 'Non défini',
bool expandVertically = false,
}) { }) {
return IdentityBlock.readOnlyValues( return IdentityBlock.readOnlyValues(
key: key, key: key,
title: title, title: title,
expandVertically: expandVertically,
values: IdentityValues.fromUser(user, emptyLabel: emptyLabel), values: IdentityValues.fromUser(user, emptyLabel: emptyLabel),
); );
} }
@@ -196,29 +205,29 @@ class IdentityBlock extends StatelessWidget { final String? title;
title: title, title: title,
rowLayout: rowLayout, rowLayout: rowLayout,
rowFlex: rowFlex, rowFlex: rowFlex,
expandVertically: expandVertically,
fields: [ fields: [
ValidationLabeledField( ValidationLabeledField(
label: 'Nom', label: 'Nom',
field: ValidationEditableField(controller: _nomCtrl!), field: ValidationEditableField(
controller: _nomCtrl!,
inputFormatters: const [PersonNameInputFormatter()],
),
), ),
ValidationLabeledField( ValidationLabeledField(
label: 'Prénom', label: 'Prénom',
field: ValidationEditableField(controller: _prenomCtrl!), field: ValidationEditableField(
controller: _prenomCtrl!,
inputFormatters: const [PersonNameInputFormatter()],
),
), ),
ValidationLabeledField( ValidationLabeledField(
label: 'Téléphone', label: 'Téléphone',
field: ValidationEditableField( field: ValidationPhoneField(controller: _telCtrl!),
controller: _telCtrl!,
keyboardType: TextInputType.phone,
inputFormatters: frenchPhoneInputFormatters,
),
), ),
ValidationLabeledField( ValidationLabeledField(
label: 'Email', label: 'Email',
field: ValidationEditableField( field: ValidationEmailField(controller: _emailCtrl!),
controller: _emailCtrl!,
keyboardType: TextInputType.emailAddress,
),
), ),
ValidationLabeledField( ValidationLabeledField(
label: 'Adresse (N° et Rue)', label: 'Adresse (N° et Rue)',
@@ -226,14 +235,14 @@ class IdentityBlock extends StatelessWidget { final String? title;
), ),
ValidationLabeledField( ValidationLabeledField(
label: 'Code postal', label: 'Code postal',
field: ValidationEditableField( field: ValidationPostalCodeField(controller: _cpCtrl!),
controller: _cpCtrl!,
keyboardType: TextInputType.number,
),
), ),
ValidationLabeledField( ValidationLabeledField(
label: 'Ville', label: 'Ville',
field: ValidationEditableField(controller: _villeCtrl!), field: ValidationEditableField(
controller: _villeCtrl!,
inputFormatters: const [PersonNameInputFormatter()],
),
), ),
], ],
); );
@@ -243,6 +252,7 @@ class IdentityBlock extends StatelessWidget { final String? title;
title: title, title: title,
rowLayout: rowLayout, rowLayout: rowLayout,
rowFlex: rowFlex, rowFlex: rowFlex,
expandVertically: expandVertically,
fields: [ fields: [
ValidationLabeledField( ValidationLabeledField(
label: 'Nom', label: 'Nom',
+1 -1
View File
@@ -54,7 +54,7 @@ class NirTextField extends StatelessWidget {
inputFontSize: inputFontSize, inputFontSize: inputFontSize,
keyboardType: TextInputType.text, keyboardType: TextInputType.text,
validator: validator ?? validateNir, validator: validator ?? validateNir,
inputFormatters: [NirInputFormatter()], inputFormatters: const [NirInputFormatter()],
enabled: enabled, enabled: enabled,
readOnly: readOnly, readOnly: readOnly,
style: style, style: style,
+1 -1
View File
@@ -206,7 +206,7 @@ packages:
source: hosted source: hosted
version: "1.2.2" version: "1.2.2"
http_parser: http_parser:
dependency: transitive dependency: "direct main"
description: description:
name: http_parser name: http_parser
sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
+1
View File
@@ -21,6 +21,7 @@ dependencies:
js: ^0.6.7 js: ^0.6.7
url_launcher: ^6.2.4 url_launcher: ^6.2.4
http: ^1.2.2 http: ^1.2.2
http_parser: ^4.0.2
# flutter_secure_storage: ^9.0.0 # flutter_secure_storage: ^9.0.0
pdfx: ^2.5.0 pdfx: ^2.5.0
universal_platform: ^1.1.0 universal_platform: ^1.1.0