Compare commits

..
Author SHA1 Message Date
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
64 changed files with 1781 additions and 8006 deletions
-3
View File
@@ -89,9 +89,6 @@
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/$1"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
-2
View File
@@ -18,7 +18,6 @@ import { AppConfigModule } from './modules/config/config.module';
import { DocumentsLegauxModule } from './modules/documents-legaux';
import { RelaisModule } from './routes/relais/relais.module';
import { DossiersModule } from './routes/dossiers/dossiers.module';
import { SuppressionsModule } from './routes/suppressions/suppressions.module';
@Module({
imports: [
@@ -58,7 +57,6 @@ import { SuppressionsModule } from './routes/suppressions/suppressions.module';
DocumentsLegauxModule,
RelaisModule,
DossiersModule,
SuppressionsModule,
],
controllers: [AppController],
providers: [
@@ -1,69 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AssistantesMaternellesController } from './assistantes_maternelles.controller';
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', () => {
let controller: AssistantesMaternellesController;
const authServiceMock = {
createAmDossierStaff: jest.fn(),
};
const amServiceMock = {};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AssistantesMaternellesController],
providers: [
{ provide: AssistantesMaternellesService, useValue: amServiceMock },
{ provide: AuthService, useValue: authServiceMock },
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();
providers: [AssistantesMaternellesService],
}).compile();
controller = module.get<AssistantesMaternellesController>(AssistantesMaternellesController);
jest.clearAllMocks();
});
it('should be defined', () => {
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,8 +7,6 @@ import {
Param,
Delete,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
@@ -18,49 +16,17 @@ import { RoleType, Users } from 'src/entities/users.entity';
import { CreateAssistanteDto } from '../user/dto/create_assistante.dto';
import { UpdateAssistanteDto } from '../user/dto/update_assistante.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 { AuthGuard } from 'src/common/guards/auth.guard';
import { User } from 'src/common/decorators/user.decorator';
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")
@ApiBearerAuth('access-token')
@UseGuards(AuthGuard, RolesGuard)
@Controller('assistantes-maternelles')
export class AssistantesMaternellesController {
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);
}
constructor(private readonly assistantesMaternellesService: AssistantesMaternellesService) { }
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Créer nounou' })
@@ -1,23 +0,0 @@
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;
}
@@ -1,29 +0,0 @@
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;
}
+1 -1
View File
@@ -29,7 +29,7 @@ import { ParentsChildren } from 'src/entities/parents_children.entity';
ParentsChildren,
]),
forwardRef(() => UserModule),
forwardRef(() => ParentsModule),
ParentsModule,
DossiersModule,
AppConfigModule,
MailModule,
+84 -399
View File
@@ -36,7 +36,6 @@ import { MailService } from 'src/modules/mail/mail.service';
import { ParentsService } from '../parents/parents.service';
import { DossiersService } from '../dossiers/dossiers.service';
import { DossierAmCompletDto } from '../dossiers/dto/dossier-am-complet.dto';
import { StaffAddCoParentDto } from '../parents/dto/staff-add-co-parent.dto';
@Injectable()
export class AuthService {
@@ -417,20 +416,11 @@ export class AuthService {
}
/**
* Cœur partagé création dossier parent (#129).
* - public : statut en_attente + mails pending
* - staff : statut actif + mails création MDP (pas de mail « dossier en attente »)
* Inscription Parent COMPLÈTE - Workflow CDC 6 étapes en 1 transaction
* Gère : Parent 1 + Parent 2 (opt) + Enfants + Présentation + CGU
*/
async createParentDossier(
dto: RegisterParentCompletDto,
options: {
statut: StatutUtilisateurType;
sendPendingEmail: boolean;
sendPasswordSetupEmail: boolean;
requireCgu: boolean;
},
) {
if (options.requireCgu && (!dto.acceptation_cgu || !dto.acceptation_privacy)) {
async inscrireParentComplet(dto: RegisterParentCompletDto) {
if (!dto.acceptation_cgu || !dto.acceptation_privacy) {
throw new BadRequestException('L\'acceptation des CGU et de la politique de confidentialité est obligatoire');
}
@@ -481,7 +471,7 @@ export class AuthService {
prenom: dto.prenom,
nom: dto.nom,
role: RoleType.PARENT,
statut: options.statut,
statut: StatutUtilisateurType.EN_ATTENTE,
telephone: dto.telephone,
adresse: dto.adresse,
code_postal: dto.code_postal,
@@ -506,7 +496,7 @@ export class AuthService {
prenom: dto.co_parent_prenom,
nom: dto.co_parent_nom,
role: RoleType.PARENT,
statut: options.statut,
statut: StatutUtilisateurType.EN_ATTENTE,
telephone: dto.co_parent_telephone,
adresse: dto.co_parent_meme_adresse ? dto.adresse : dto.co_parent_adresse,
code_postal: dto.co_parent_meme_adresse ? dto.code_postal : dto.co_parent_code_postal,
@@ -622,287 +612,44 @@ export class AuthService {
const numeroDossier = resultat.parent1.numero_dossier ?? '';
if (options.sendPendingEmail) {
try {
try {
await this.mailService.sendRegistrationPendingEmail(
resultat.parent1.email,
resultat.parent1.prenom ?? '',
resultat.parent1.nom ?? '',
numeroDossier,
);
if (resultat.parent2) {
await this.mailService.sendRegistrationPendingEmail(
resultat.parent1.email,
resultat.parent1.prenom ?? '',
resultat.parent1.nom ?? '',
resultat.parent2.email,
resultat.parent2.prenom ?? '',
resultat.parent2.nom ?? '',
numeroDossier,
);
if (resultat.parent2) {
await this.mailService.sendRegistrationPendingEmail(
resultat.parent2.email,
resultat.parent2.prenom ?? '',
resultat.parent2.nom ?? '',
numeroDossier,
);
}
} catch (err) {
this.logger.error(
"[createParentDossier] É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.parent1.email,
prenom: resultat.parent1.prenom ?? '',
nom: resultat.parent1.nom ?? '',
token: resultat.tokenCreationMdp,
numeroDossier,
},
'parent',
);
if (resultat.parent2 && resultat.tokenCoParent) {
await this.mailService.sendValidatedAccountPasswordSetupEmail(
{
email: resultat.parent2.email,
prenom: resultat.parent2.prenom ?? '',
nom: resultat.parent2.nom ?? '',
token: resultat.tokenCoParent,
numeroDossier,
},
'parent',
);
}
} catch (err) {
this.logger.error(
'[createParentDossier] Échec envoi email création MDP (dossier conservé)',
err instanceof Error ? err.stack : String(err),
);
}
}
const message = options.sendPasswordSetupEmail
? 'Dossier famille 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 {
message,
parent_id: resultat.parent1.id,
parent_user_id: resultat.parent1.id,
co_parent_id: resultat.parent2?.id ?? null,
co_parent_user_id: resultat.parent2?.id ?? null,
enfants_ids: resultat.enfants.map(e => e.id),
enfant_ids: resultat.enfants.map(e => e.id),
statut: options.statut,
numero_dossier: numeroDossier,
};
}
/**
* Inscription Parent publique — CDC (statut en_attente + mail pending).
*/
async inscrireParentComplet(dto: RegisterParentCompletDto) {
return this.createParentDossier(dto, {
statut: StatutUtilisateurType.EN_ATTENTE,
sendPendingEmail: true,
sendPasswordSetupEmail: false,
requireCgu: true,
});
}
/**
* Création dossier parent par le staff (#129) — actif + mail création MDP.
*/
async createParentDossierStaff(dto: RegisterParentCompletDto) {
return this.createParentDossier(dto, {
statut: StatutUtilisateurType.ACTIF,
sendPendingEmail: false,
sendPasswordSetupEmail: true,
requireCgu: false,
});
}
/**
* Ajoute un co-parent à un foyer existant (mono-parent) — ticket #135.
* Compte actif + mail création MDP + liens Parents bidirectionnels + enfants du foyer.
*/
async addCoParentStaff(pivotUserId: string, dto: StaffAddCoParentDto) {
const pivotParent = await this.parentsRepo.findOne({
where: { user_id: pivotUserId },
relations: ['user', 'co_parent', 'parentChildren'],
});
if (!pivotParent?.user) {
throw new NotFoundException('Parent introuvable');
}
if (pivotParent.co_parent) {
throw new BadRequestException('Ce foyer a déjà un co-parent.');
}
const numeroDossier = pivotParent.numero_dossier?.trim() || pivotParent.user.numero_dossier?.trim();
if (!numeroDossier) {
throw new BadRequestException("Ce parent n'a pas de numéro de dossier.");
}
const sameDossierCount = await this.parentsRepo.count({
where: { numero_dossier: numeroDossier },
});
if (sameDossierCount >= 2) {
throw new BadRequestException('Ce dossier a déjà deux responsables.');
}
const email = dto.email.trim().toLowerCase();
if (pivotParent.user.email.trim().toLowerCase() === email) {
throw new BadRequestException(
"L'email du co-parent doit être différent de celui du parent principal.",
);
}
const emailExiste = await this.usersService.findByEmailOrNull(dto.email);
if (emailExiste) {
throw new ConflictException("L'email du co-parent est déjà utilisé");
}
const memeAdresse = dto.meme_adresse ?? true;
if (!memeAdresse) {
if (!dto.adresse?.trim() || !dto.ville?.trim() || !dto.code_postal?.trim()) {
throw new BadRequestException(
"Adresse, code postal et ville du co-parent sont requis si meme_adresse est faux.",
);
}
}
const joursExpirationToken = await this.appConfigService.get<number>(
'password_reset_token_expiry_days',
7,
);
const tokenCreationMdp = crypto.randomUUID();
const dateExpiration = new Date();
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken);
let coParent: Users;
try {
coParent = await this.usersRepo.manager.transaction(async (manager) => {
const pivotUser = await manager.findOne(Users, {
where: { id: pivotUserId },
});
if (!pivotUser) {
throw new NotFoundException('Parent introuvable');
}
const pivotEntite = await manager.findOne(Parents, {
where: { user_id: pivotUserId },
relations: ['parentChildren'],
});
if (!pivotEntite) {
throw new NotFoundException('Parent introuvable');
}
const coUser = manager.create(Users, {
email: dto.email.trim(),
prenom: dto.prenom,
nom: dto.nom,
role: RoleType.PARENT,
statut: StatutUtilisateurType.ACTIF,
telephone: dto.telephone,
adresse: memeAdresse ? pivotUser.adresse : dto.adresse,
code_postal: memeAdresse ? pivotUser.code_postal : dto.code_postal,
ville: memeAdresse ? pivotUser.ville : dto.ville,
token_creation_mdp: tokenCreationMdp,
token_creation_mdp_expire_le: dateExpiration,
numero_dossier: numeroDossier,
});
const coUserSaved = await manager.save(Users, coUser);
pivotEntite.co_parent = coUserSaved;
pivotEntite.numero_dossier = numeroDossier;
await manager.save(Parents, pivotEntite);
const coEntite = manager.create(Parents, {
user_id: coUserSaved.id,
numero_dossier: numeroDossier,
});
coEntite.user = coUserSaved;
coEntite.co_parent = pivotUser;
await manager.save(Parents, coEntite);
const enfantIds = (pivotEntite.parentChildren ?? [])
.map((pc) => pc.enfantId)
.filter(Boolean);
for (const enfantId of enfantIds) {
const existing = await manager.findOne(ParentsChildren, {
where: { parentId: coUserSaved.id, enfantId },
});
if (existing) continue;
await manager.save(
ParentsChildren,
manager.create(ParentsChildren, {
parentId: coUserSaved.id,
enfantId,
}),
);
}
return coUserSaved;
});
} catch (err) {
if (this.isPostgresUniqueViolation(err)) {
throw new ConflictException(
'Un compte avec cet email existe déjà (contrainte unique en base).',
);
}
throw err;
}
try {
await this.mailService.sendValidatedAccountPasswordSetupEmail(
{
email: coParent.email,
prenom: coParent.prenom ?? '',
nom: coParent.nom ?? '',
token: tokenCreationMdp,
numeroDossier,
},
'parent',
);
} catch (err) {
this.logger.error(
'[addCoParentStaff] Échec envoi email création MDP (co-parent conservé)',
"[inscrireParentComplet] Échec envoi email d'accusé de réception (inscription conservée)",
err instanceof Error ? err.stack : String(err),
);
}
return {
message:
'Co-parent ajouté au foyer. Un e-mail de création de mot de passe a été envoyé.',
message: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
parent_id: resultat.parent1.id,
co_parent_id: resultat.parent2?.id,
enfants_ids: resultat.enfants.map(e => e.id),
statut: StatutUtilisateurType.EN_ATTENTE,
numero_dossier: numeroDossier,
parent_user_id: pivotUserId,
co_parent_user_id: coParent.id,
statut: StatutUtilisateurType.ACTIF,
};
}
/**
* Cœur partagé création dossier AM (#156).
* - public : statut en_attente + mail pending
* - staff : statut actif + mail création MDP
* 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 createAmDossier(
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)) {
async inscrireAMComplet(dto: RegisterAMCompletDto) {
if (!dto.acceptation_cgu || !dto.acceptation_privacy) {
throw new BadRequestException(
"L'acceptation des CGU et de la politique de confidentialité est obligatoire",
);
@@ -922,7 +669,8 @@ export class AuthService {
throw new BadRequestException(nirValidation.error || 'NIR invalide');
}
if (nirValidation.warning) {
console.warn(`[${logCtx}] NIR warning:`, nirValidation.warning, 'email=', dto.email);
// Warning uniquement : on ne bloque pas (AM souvent étrangères, DOM-TOM, Corse)
console.warn('[inscrireAMComplet] NIR warning:', nirValidation.warning, 'email=', dto.email);
}
const existe = await this.usersService.findByEmailOrNull(dto.email);
@@ -973,142 +721,79 @@ export class AuthService {
let resultat: { user: Users };
try {
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, {
email: dto.email,
prenom: dto.prenom,
nom: dto.nom,
role: RoleType.ASSISTANTE_MATERNELLE,
statut: options.statut,
telephone: dto.telephone,
adresse: dto.adresse,
code_postal: dto.code_postal,
ville: dto.ville,
token_creation_mdp: tokenCreationMdp,
token_creation_mdp_expire_le: dateExpiration,
photo_url: urlPhoto ?? undefined,
consentement_photo: dto.consentement_photo,
date_consentement_photo: dateConsentementPhoto,
date_naissance: dto.date_naissance
? new Date(dto.date_naissance)
: undefined,
lieu_naissance_ville: dto.lieu_naissance_ville,
lieu_naissance_pays: dto.lieu_naissance_pays,
numero_dossier: numeroDossier,
});
const userEnregistre = await manager.save(Users, user);
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 user = manager.create(Users, {
email: dto.email,
prenom: dto.prenom,
nom: dto.nom,
role: RoleType.ASSISTANTE_MATERNELLE,
statut: StatutUtilisateurType.EN_ATTENTE,
telephone: dto.telephone,
adresse: dto.adresse,
code_postal: dto.code_postal,
ville: dto.ville,
token_creation_mdp: tokenCreationMdp,
token_creation_mdp_expire_le: dateExpiration,
photo_url: urlPhoto ?? undefined,
consentement_photo: dto.consentement_photo,
date_consentement_photo: dateConsentementPhoto,
date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined,
lieu_naissance_ville: dto.lieu_naissance_ville,
lieu_naissance_pays: dto.lieu_naissance_pays,
numero_dossier: numeroDossier,
});
const userEnregistre = await manager.save(Users, user);
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) {
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;
}
const numeroDossier = resultat.user.numero_dossier ?? '';
if (options.sendPendingEmail) {
try {
await this.mailService.sendRegistrationPendingEmail(
resultat.user.email,
resultat.user.prenom ?? '',
resultat.user.nom ?? '',
numeroDossier,
);
} catch (err) {
this.logger.error(
`[${logCtx}] Échec envoi email d'accusé de réception (inscription conservée)`,
err instanceof Error ? err.stack : String(err),
);
}
try {
await this.mailService.sendRegistrationPendingEmail(
resultat.user.email,
resultat.user.prenom ?? '',
resultat.user.nom ?? '',
numeroDossier,
);
} catch (err) {
this.logger.error(
"[inscrireAMComplet] Échec envoi email d'accusé de réception (inscription conservée)",
err instanceof Error ? err.stack : String(err),
);
}
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 {
message,
message:
'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
user_id: resultat.user.id,
statut: options.statut,
statut: StatutUtilisateurType.EN_ATTENTE,
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
*/
@@ -1,85 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DossiersController } from './dossiers.controller';
import { DossiersService } from './dossiers.service';
import { SuppressionService } from '../suppressions/suppression.service';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { StatutUtilisateurType } from 'src/entities/users.entity';
describe('DossiersController', () => {
let controller: DossiersController;
const dossiersServiceMock = {
listDossiers: jest.fn(),
getDossierByNumero: jest.fn(),
};
const suppressionServiceMock = {
deleteDossier: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [DossiersController],
providers: [
{ provide: DossiersService, useValue: dossiersServiceMock },
{ provide: SuppressionService, useValue: suppressionServiceMock },
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<DossiersController>(DossiersController);
jest.clearAllMocks();
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('list delegates to dossiersService.listDossiers with q', async () => {
dossiersServiceMock.listDossiers.mockResolvedValue([
{
type: 'famille',
numero_dossier: '2026-000043',
libelle: 'Claire MARTIN',
emails: ['claire@test.fr'],
user_ids: ['u1'],
statut: StatutUtilisateurType.ACTIF,
a_valider: false,
date_reference: null,
},
]);
const res = await controller.list('martin');
expect(dossiersServiceMock.listDossiers).toHaveBeenCalledWith('martin');
expect(res).toHaveLength(1);
expect(res[0].numero_dossier).toBe('2026-000043');
});
it('getDossier delegates to getDossierByNumero', async () => {
dossiersServiceMock.getDossierByNumero.mockResolvedValue({
type: 'family',
dossier: { numero_dossier: '2026-000001' },
});
const res = await controller.getDossier('2026-000001');
expect(dossiersServiceMock.getDossierByNumero).toHaveBeenCalledWith('2026-000001');
expect(res.type).toBe('family');
});
it('remove delegates to suppressionService.deleteDossier', async () => {
const user = { id: 'u1', role: 'gestionnaire' } as never;
suppressionServiceMock.deleteDossier.mockResolvedValue({
type: 'famille',
deleted_user_ids: [],
deleted_enfant_ids: [],
message: 'ok',
});
await controller.remove('2026-000001', user);
expect(suppressionServiceMock.deleteDossier).toHaveBeenCalledWith(
'2026-000001',
user,
);
});
});
@@ -1,58 +1,17 @@
import {
Controller,
Delete,
Get,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiQuery,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Roles } from 'src/common/decorators/roles.decorator';
import { RoleType, Users } from 'src/entities/users.entity';
import { RoleType } from 'src/entities/users.entity';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { User } from 'src/common/decorators/user.decorator';
import { DossiersService } from './dossiers.service';
import { SuppressionService } from '../suppressions/suppression.service';
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
import { DossierListItemDto } from './dto/dossier-list-item.dto';
@ApiTags('Dossiers')
@ApiBearerAuth('access-token')
@Controller('dossiers')
@UseGuards(AuthGuard, RolesGuard)
export class DossiersController {
constructor(
private readonly dossiersService: DossiersService,
private readonly suppressionService: SuppressionService,
) {}
@Get()
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({
summary: 'Liste unifiée des dossiers (familles + AM) — ticket #153',
description:
'1 entrée = 1 numero_dossier. Types `famille` | `assistante_maternelle`. ' +
'Filtre optionnel `q` (n°, nom, email). Tri : à valider dabord, puis n° décroissant. ' +
'`sans_enfant` (#159) pour dossiers famille sans enfant.',
})
@ApiQuery({
name: 'q',
required: false,
description: 'Recherche libre : n° dossier, libellé, email…',
})
@ApiResponse({ status: 200, type: [DossierListItemDto] })
@ApiResponse({ status: 403, description: 'Accès refusé' })
list(@Query('q') q?: string): Promise<DossierListItemDto[]> {
return this.dossiersService.listDossiers(q);
}
constructor(private readonly dossiersService: DossiersService) {}
@Get(':numeroDossier')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@@ -64,21 +23,4 @@ export class DossiersController {
getDossier(@Param('numeroDossier') numeroDossier: string): Promise<DossierUnifieDto> {
return this.dossiersService.getDossierByNumero(numeroDossier);
}
@Delete(':numeroDossier')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({
summary: 'Supprimer un dossier (famille ou AM) — #159',
description:
'Famille : parents + enfants. AM : compte AM + dossier AM (enfants conservés, placements clos).',
})
@ApiParam({ name: 'numeroDossier', description: 'Numéro de dossier' })
@ApiResponse({ status: 200, description: 'Résultat de suppression' })
@ApiResponse({ status: 404, description: 'Dossier introuvable' })
remove(
@Param('numeroDossier') numeroDossier: string,
@User() currentUser: Users,
) {
return this.suppressionService.deleteDossier(numeroDossier, currentUser);
}
}
@@ -5,7 +5,6 @@ import { JwtModule } from '@nestjs/jwt';
import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { ParentsModule } from '../parents/parents.module';
import { SuppressionsModule } from '../suppressions/suppressions.module';
import { DossiersController } from './dossiers.controller';
import { DossiersService } from './dossiers.service';
@@ -13,7 +12,6 @@ import { DossiersService } from './dossiers.service';
imports: [
TypeOrmModule.forFeature([Parents, AssistanteMaternelle]),
ParentsModule,
SuppressionsModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
@@ -1,148 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DossiersService } from './dossiers.service';
import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { ParentsService } from '../parents/parents.service';
import { SuppressionService } from '../suppressions/suppression.service';
import { StatutUtilisateurType } from 'src/entities/users.entity';
describe('DossiersService.listDossiers', () => {
let service: DossiersService;
const parentsQb = {
innerJoinAndSelect: jest.fn().mockReturnThis(),
leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn(),
};
const amQb = {
innerJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn(),
};
const parentsRepo = {
createQueryBuilder: jest.fn(() => parentsQb),
findOne: jest.fn(),
};
const amRepo = {
createQueryBuilder: jest.fn(() => amQb),
findOne: jest.fn(),
};
const parentsService = {
getDossierFamilleByNumero: jest.fn(),
};
const suppressionService = {
countEnfantsForNumero: jest.fn().mockResolvedValue(1),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
DossiersService,
{ provide: getRepositoryToken(Parents), useValue: parentsRepo },
{ provide: getRepositoryToken(AssistanteMaternelle), useValue: amRepo },
{ provide: ParentsService, useValue: parentsService },
{ provide: SuppressionService, useValue: suppressionService },
],
}).compile();
service = module.get(DossiersService);
jest.clearAllMocks();
parentsRepo.createQueryBuilder.mockReturnValue(parentsQb);
amRepo.createQueryBuilder.mockReturnValue(amQb);
suppressionService.countEnfantsForNumero.mockResolvedValue(1);
});
it('aggregates famille (pivot+co-parent) and AM, sorts a_valider first', async () => {
parentsQb.getMany.mockResolvedValue([
{
user_id: 'p1',
numero_dossier: '2026-000010',
user: {
id: 'p1',
email: 'claire@test.fr',
prenom: 'Claire',
nom: 'Martin',
statut: StatutUtilisateurType.ACTIF,
cree_le: new Date('2026-01-01'),
},
co_parent: {
id: 'p2',
email: 'thomas@test.fr',
prenom: 'Thomas',
nom: 'Martin',
statut: StatutUtilisateurType.ACTIF,
cree_le: new Date('2026-01-02'),
},
},
{
user_id: 'p3',
numero_dossier: '2026-000020',
user: {
id: 'p3',
email: 'pending@test.fr',
prenom: 'Paul',
nom: 'Pending',
statut: StatutUtilisateurType.EN_ATTENTE,
cree_le: new Date('2026-02-01'),
},
co_parent: undefined,
},
]);
amQb.getMany.mockResolvedValue([
{
user_id: 'am1',
numero_dossier: '2026-000015',
user: {
id: 'am1',
email: 'am@test.fr',
prenom: 'Marie',
nom: 'Dupont',
statut: StatutUtilisateurType.ACTIF,
cree_le: new Date('2026-01-15'),
},
},
]);
const list = await service.listDossiers();
expect(list).toHaveLength(3);
expect(list[0].a_valider).toBe(true);
expect(list[0].type).toBe('famille');
expect(list[0].numero_dossier).toBe('2026-000020');
const famille = list.find((i) => i.numero_dossier === '2026-000010')!;
expect(famille.type).toBe('famille');
expect(famille.user_ids).toEqual(expect.arrayContaining(['p1', 'p2']));
expect(famille.emails).toHaveLength(2);
expect(famille.libelle).toContain('MARTIN');
const am = list.find((i) => i.type === 'assistante_maternelle')!;
expect(am.numero_dossier).toBe('2026-000015');
expect(am.libelle).toContain('Marie');
});
it('filters with q', async () => {
parentsQb.getMany.mockResolvedValue([]);
amQb.getMany.mockResolvedValue([
{
user_id: 'am1',
numero_dossier: '2026-000015',
user: {
id: 'am1',
email: 'am@test.fr',
prenom: 'Marie',
nom: 'Dupont',
statut: StatutUtilisateurType.ACTIF,
cree_le: new Date('2026-01-15'),
},
},
]);
const hit = await service.listDossiers('dupont');
expect(hit).toHaveLength(1);
const miss = await service.listDossiers('zzz');
expect(miss).toHaveLength(0);
});
});
+1 -169
View File
@@ -3,15 +3,12 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { StatutUtilisateurType, Users } from 'src/entities/users.entity';
import { ParentsService } from '../parents/parents.service';
import { SuppressionService } from '../suppressions/suppression.service';
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto';
import { DossierListItemDto } from './dto/dossier-list-item.dto';
/**
* Dossiers unifiés — détail (#119) + liste (#153) + sans_enfant (#159).
* Endpoint unifié GET /dossiers/:numeroDossier AM ou famille. Ticket #119.
*/
@Injectable()
export class DossiersService {
@@ -21,173 +18,8 @@ export class DossiersService {
@InjectRepository(AssistanteMaternelle)
private readonly amRepository: Repository<AssistanteMaternelle>,
private readonly parentsService: ParentsService,
private readonly suppressionService: SuppressionService,
) {}
/**
* Liste unifiée tous dossiers (familles + AM) ayant un numero_dossier.
* Ticket #153 — optionnel `q` filtre n° / nom / prénom / email (côté serveur).
*/
async listDossiers(q?: string): Promise<DossierListItemDto[]> {
const items: DossierListItemDto[] = [
...(await this.listFamilleItems()),
...(await this.listAmItems()),
];
const needle = (q ?? '').trim().toLowerCase();
const filtered = needle
? items.filter((item) => this.matchesQuery(item, needle))
: items;
filtered.sort((a, b) => {
// À valider d'abord, puis n° dossier décroissant
if (a.a_valider !== b.a_valider) return a.a_valider ? -1 : 1;
return b.numero_dossier.localeCompare(a.numero_dossier, 'fr');
});
for (const item of filtered) {
if (item.type === 'famille') {
const n = await this.suppressionService.countEnfantsForNumero(
item.numero_dossier,
);
item.sans_enfant = n === 0;
} else {
item.sans_enfant = false;
}
}
return filtered;
}
private async listFamilleItems(): Promise<DossierListItemDto[]> {
const parents = await this.parentsRepository
.createQueryBuilder('p')
.innerJoinAndSelect('p.user', 'u')
.leftJoinAndSelect('p.co_parent', 'cp')
.where('p.numero_dossier IS NOT NULL')
.andWhere("TRIM(p.numero_dossier) <> ''")
.getMany();
const byNum = new Map<string, Parents[]>();
for (const p of parents) {
const num = (p.numero_dossier ?? '').trim();
if (!num) continue;
const group = byNum.get(num) ?? [];
group.push(p);
byNum.set(num, group);
}
const items: DossierListItemDto[] = [];
for (const [numero_dossier, group] of byNum) {
const usersMap = new Map<string, Users>();
for (const p of group) {
if (p.user) usersMap.set(p.user.id, p.user);
if (p.co_parent) usersMap.set(p.co_parent.id, p.co_parent);
}
const users = [...usersMap.values()].sort((a, b) => {
const an = `${a.nom ?? ''} ${a.prenom ?? ''}`.toLowerCase();
const bn = `${b.nom ?? ''} ${b.prenom ?? ''}`.toLowerCase();
return an.localeCompare(bn, 'fr') || a.id.localeCompare(b.id);
});
if (users.length === 0) continue;
const names = users.map((u) => this.formatPersonName(u)).filter(Boolean);
const libelle =
names.length === 0
? `Dossier ${numero_dossier}`
: names.length === 1
? names[0]
: names.join(' & ');
const emails = users.map((u) => u.email).filter(Boolean);
const user_ids = users.map((u) => u.id);
const a_valider = users.some((u) => u.statut === StatutUtilisateurType.EN_ATTENTE);
const statut = a_valider
? StatutUtilisateurType.EN_ATTENTE
: (users[0].statut ?? StatutUtilisateurType.ACTIF);
const date_reference = this.minCreeLeIso(users);
items.push({
type: 'famille',
numero_dossier,
libelle,
emails,
user_ids,
statut,
a_valider,
date_reference,
});
}
return items;
}
private async listAmItems(): Promise<DossierListItemDto[]> {
const ams = await this.amRepository
.createQueryBuilder('am')
.innerJoinAndSelect('am.user', 'u')
.where('am.numero_dossier IS NOT NULL')
.andWhere("TRIM(am.numero_dossier) <> ''")
.getMany();
const byNum = new Map<string, AssistanteMaternelle>();
for (const am of ams) {
const num = (am.numero_dossier ?? '').trim();
if (!num || !am.user) continue;
// Un n° = une AM ; garder le premier
if (!byNum.has(num)) byNum.set(num, am);
}
const items: DossierListItemDto[] = [];
for (const [numero_dossier, am] of byNum) {
const u = am.user!;
const libelle = this.formatPersonName(u) || `AM ${numero_dossier}`;
const a_valider = u.statut === StatutUtilisateurType.EN_ATTENTE;
items.push({
type: 'assistante_maternelle',
numero_dossier,
libelle,
emails: u.email ? [u.email] : [],
user_ids: [u.id],
statut: u.statut ?? StatutUtilisateurType.ACTIF,
a_valider,
date_reference: this.minCreeLeIso([u]),
});
}
return items;
}
private matchesQuery(item: DossierListItemDto, needle: string): boolean {
const hay = [
item.numero_dossier,
item.libelle,
...item.emails,
item.statut,
item.type,
]
.join(' ')
.toLowerCase();
return hay.includes(needle);
}
private formatPersonName(u: Users): string {
const prenom = (u.prenom ?? '').trim();
const nom = (u.nom ?? '').trim();
const nomFmt = nom ? nom.toUpperCase() : '';
return [prenom, nomFmt].filter(Boolean).join(' ');
}
private minCreeLeIso(users: Users[]): string | null {
let min: Date | null = null;
for (const u of users) {
const d = u.cree_le;
if (!d) continue;
const date = d instanceof Date ? d : new Date(d);
if (Number.isNaN(date.getTime())) continue;
if (!min || date < min) min = date;
}
return min ? min.toISOString() : null;
}
async getDossierByNumero(numeroDossier: string): Promise<DossierUnifieDto> {
const num = numeroDossier?.trim();
if (!num) {
@@ -1,58 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Ligne de liste GET /dossiers (#153). */
export class DossierListItemDto {
@ApiProperty({
enum: ['famille', 'assistante_maternelle'],
description: 'Type de dossier',
})
type: 'famille' | 'assistante_maternelle';
@ApiProperty({ example: '2026-000043' })
numero_dossier: string;
@ApiProperty({
example: 'Claire MARTIN & Thomas MARTIN',
description: 'Libellé affiché (noms)',
})
libelle: string;
@ApiProperty({
type: [String],
example: ['claire@example.com', 'thomas@example.com'],
})
emails: string[];
@ApiProperty({
type: [String],
format: 'uuid',
description: 'IDs utilisateur liés au dossier (parents du foyer ou AM)',
})
user_ids: string[];
@ApiProperty({
enum: StatutUtilisateurType,
description:
'Statut agrégé : en_attente si au moins un user en_attente, sinon statut du premier',
})
statut: StatutUtilisateurType;
@ApiProperty({
description: 'True si le dossier est en attente de validation (section haute UI)',
})
a_valider: boolean;
@ApiPropertyOptional({
nullable: true,
example: '2026-01-12T10:00:00.000Z',
description: 'Date de référence (MIN cree_le des users du dossier)',
})
date_reference: string | null;
@ApiPropertyOptional({
description:
'True si dossier famille sans enfant lié (#159). Omis ou false pour AM.',
})
sans_enfant?: boolean;
}
@@ -13,7 +13,6 @@ import {
ParseUUIDPipe,
Patch,
Post,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
@@ -24,7 +23,6 @@ import {
ApiBody,
ApiConsumes,
ApiOperation,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import { diskStorage } from 'multer';
@@ -38,7 +36,6 @@ import { User } from 'src/common/decorators/user.decorator';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { Roles } from 'src/common/decorators/roles.decorator';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { SuppressionService } from '../suppressions/suppression.service';
const photoMulterOptions = {
storage: diskStorage({
@@ -86,10 +83,7 @@ class OptionalEnfantPhotoInterceptor implements NestInterceptor {
@UseGuards(AuthGuard, RolesGuard)
@Controller('enfants')
export class EnfantsController {
constructor(
private readonly enfantsService: EnfantsService,
private readonly suppressionService: SuppressionService,
) { }
constructor(private readonly enfantsService: EnfantsService) { }
@Roles(
RoleType.PARENT,
@@ -147,47 +141,17 @@ export class EnfantsController {
RoleType.GESTIONNAIRE,
)
@Patch(':id')
@ApiOperation({
summary: 'Mettre à jour un enfant',
description:
'JSON sans photo OK ; avec nouvelle photo → multipart (champ fichier `photo`, max 5 Mo).',
})
@ApiConsumes('application/json', 'multipart/form-data')
@UseInterceptors(OptionalEnfantPhotoInterceptor)
update(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() dto: UpdateEnfantsDto,
@UploadedFile() photo: Express.Multer.File,
@User() currentUser: Users,
) {
return this.enfantsService.update(id, dto, currentUser, photo);
return this.enfantsService.update(id, dto, currentUser);
}
@Roles(
RoleType.SUPER_ADMIN,
RoleType.ADMINISTRATEUR,
RoleType.GESTIONNAIRE,
)
@Roles(RoleType.SUPER_ADMIN)
@Delete(':id')
@ApiOperation({
summary: 'Supprimer un enfant (#159)',
description:
'Query `deleteDossier=true` si dernier enfant et suppression du dossier famille souhaitée.',
})
@ApiQuery({
name: 'deleteDossier',
required: false,
description: 'Si true et dernier enfant : cascade dossier famille',
})
remove(
@Param('id', new ParseUUIDPipe()) id: string,
@Query('deleteDossier') deleteDossier: string | undefined,
@User() currentUser: Users,
) {
const flag =
deleteDossier === 'true' ||
deleteDossier === '1' ||
deleteDossier === 'yes';
return this.suppressionService.deleteEnfant(id, flag, currentUser);
remove(@Param('id', new ParseUUIDPipe()) id: string) {
return this.enfantsService.remove(id);
}
}
+4 -7
View File
@@ -6,16 +6,13 @@ import { Children } from 'src/entities/children.entity';
import { Parents } from 'src/entities/parents.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { AuthModule } from '../auth/auth.module';
import { SuppressionsModule } from '../suppressions/suppressions.module';
@Module({
imports: [
TypeOrmModule.forFeature([Children, Parents, ParentsChildren]),
AuthModule,
SuppressionsModule,
imports: [TypeOrmModule.forFeature([Children, Parents, ParentsChildren]),
AuthModule
],
controllers: [EnfantsController],
providers: [EnfantsService],
exports: [EnfantsService],
providers: [EnfantsService]
})
export class EnfantsModule { }
+1 -13
View File
@@ -195,12 +195,7 @@ export class EnfantsService {
// Mise à jour
async update(
id: string,
dto: Partial<CreateEnfantsDto>,
currentUser: Users,
photoFile?: Express.Multer.File,
): Promise<Children> {
async update(id: string, dto: Partial<CreateEnfantsDto>, currentUser: Users): Promise<Children> {
const child = await this.childrenRepository.findOne({ where: { id } });
if (!child) throw new NotFoundException('Enfant introuvable');
@@ -210,13 +205,6 @@ export class EnfantsService {
patch.consent_photo = dto.consent_photo;
patch.consent_photo_at = dto.consent_photo ? new Date() : null!;
}
if (photoFile) {
patch.photo_url = `/uploads/photos/${photoFile.filename}`;
if (dto.consent_photo !== false) {
patch.consent_photo = true;
patch.consent_photo_at = new Date();
}
}
await this.childrenRepository.update(id, patch);
return this.findOne(id, currentUser);
@@ -7,12 +7,6 @@ export class ParentPendingSummaryDto {
@ApiProperty()
email: string;
@ApiPropertyOptional({ nullable: true })
nom?: string | null;
@ApiPropertyOptional({ nullable: true })
prenom?: string | null;
@ApiPropertyOptional({ nullable: true })
telephone?: string | null;
@@ -24,10 +18,7 @@ export class ParentPendingSummaryDto {
}
export class PendingFamilyDto {
@ApiProperty({
example: 'MARTIN Claire - MARTIN Thomas',
description: 'Libellé affiché : NOM Prénom (séparés par « - » si co-parent)',
})
@ApiProperty({ example: 'Famille Dupont', description: 'Libellé affiché pour la famille' })
libelle: string;
@ApiProperty({
@@ -1,23 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Réponse 201 POST /parents/:id/co-parent (#135). */
export class StaffAddCoParentResponseDto {
@ApiProperty()
message: string;
@ApiProperty({ example: '2026-000043' })
numero_dossier: string;
@ApiProperty({ format: 'uuid', description: 'UUID du parent pivot' })
parent_user_id: string;
@ApiProperty({ format: 'uuid', description: 'UUID du co-parent créé' })
co_parent_user_id: string;
@ApiProperty({
enum: StatutUtilisateurType,
example: StatutUtilisateurType.ACTIF,
})
statut: StatutUtilisateurType;
}
@@ -1,69 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
/**
* Ajout dun co-parent sur un foyer existant (staff) ticket #135.
* Corps sans préfixe `co_parent_*` (lURL cible déjà le pivot).
*/
export class StaffAddCoParentDto {
@ApiProperty({ example: 'thomas.martin@ptits-pas.fr' })
@IsEmail({}, { message: 'Email invalide' })
@IsNotEmpty({ message: "L'email est requis" })
email: string;
@ApiProperty({ example: 'Thomas' })
@IsString()
@IsNotEmpty({ message: 'Le prénom est requis' })
@MinLength(2)
@MaxLength(100)
prenom: string;
@ApiProperty({ example: 'MARTIN' })
@IsString()
@IsNotEmpty({ message: 'Le nom est requis' })
@MinLength(2)
@MaxLength(100)
nom: string;
@ApiProperty({ example: '0678456789' })
@IsString()
@IsNotEmpty({ message: 'Le téléphone est requis' })
@Matches(/^(\+33|0)[1-9](\d{2}){4}$/, {
message: 'Le numéro de téléphone doit être valide (ex: 0689567890 ou +33689567890)',
})
telephone: string;
@ApiPropertyOptional({
example: true,
description: 'Si true, copie ladresse du parent pivot',
})
@IsOptional()
@IsBoolean()
meme_adresse?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
adresse?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(10)
code_postal?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(150)
ville?: string;
}
@@ -1,37 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Réponse 201 POST /parents/dossier (#129). */
export class StaffCreateParentDossierResponseDto {
@ApiProperty()
message: string;
@ApiProperty({
example: '2026-000043',
description: 'Numéro de dossier famille attribué',
})
numero_dossier: string;
@ApiProperty({ format: 'uuid', description: 'UUID user du parent pivot' })
parent_user_id: string;
@ApiPropertyOptional({
format: 'uuid',
nullable: true,
description: 'UUID user du co-parent, ou null',
})
co_parent_user_id: string | null;
@ApiProperty({
enum: StatutUtilisateurType,
example: StatutUtilisateurType.ACTIF,
})
statut: StatutUtilisateurType;
@ApiProperty({
type: [String],
format: 'uuid',
description: 'IDs des enfants créés',
})
enfant_ids: string[];
}
@@ -1,29 +0,0 @@
import { ApiPropertyOptional, OmitType } from '@nestjs/swagger';
import { IsBoolean, IsOptional } from 'class-validator';
import { RegisterParentCompletDto } from 'src/routes/auth/dto/register-parent-complet.dto';
/**
* Création dossier parent/famille par staff (#129).
* Mêmes champs que l'inscription publique, sans CGU/privacy obligatoires
* (acceptées côté serveur pour le compte du gestionnaire).
*/
export class StaffCreateParentDossierDto extends OmitType(RegisterParentCompletDto, [
'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;
}
@@ -1,104 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ParentsController } from './parents.controller';
import { ParentsService } from './parents.service';
import { UserService } from '../user/user.service';
import { AuthService } from '../auth/auth.service';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { StatutUtilisateurType } from 'src/entities/users.entity';
describe('ParentsController', () => {
let controller: ParentsController;
const authServiceMock = {
createParentDossierStaff: jest.fn(),
addCoParentStaff: jest.fn(),
};
const parentsServiceMock = {};
const userServiceMock = {};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ParentsController],
providers: [
{ provide: ParentsService, useValue: parentsServiceMock },
{ provide: UserService, useValue: userServiceMock },
{ provide: AuthService, useValue: authServiceMock },
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();
}).compile();
controller = module.get<ParentsController>(ParentsController);
jest.clearAllMocks();
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('createDossier delegates to authService.createParentDossierStaff with CGU accepted', async () => {
authServiceMock.createParentDossierStaff.mockResolvedValue({
message: 'Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.',
parent_user_id: 'p1',
co_parent_user_id: 'p2',
enfant_ids: ['e1'],
statut: StatutUtilisateurType.ACTIF,
numero_dossier: '2026-000043',
});
const body = {
email: 'parent.staff@test.fr',
prenom: 'Claire',
nom: 'MARTIN',
telephone: '0689567890',
enfants: [
{
prenom: 'Emma',
nom: 'MARTIN',
date_naissance: '2023-02-15',
genre: 'F',
},
],
};
const res = await controller.createDossier(body as any);
expect(authServiceMock.createParentDossierStaff).toHaveBeenCalledWith(
expect.objectContaining({
email: body.email,
acceptation_cgu: true,
acceptation_privacy: true,
}),
);
expect(res.numero_dossier).toBe('2026-000043');
expect(res.parent_user_id).toBe('p1');
expect(res.co_parent_user_id).toBe('p2');
expect(res.enfant_ids).toEqual(['e1']);
expect(res.statut).toBe(StatutUtilisateurType.ACTIF);
});
it('addCoParent delegates to authService.addCoParentStaff', async () => {
authServiceMock.addCoParentStaff.mockResolvedValue({
message: 'ok',
numero_dossier: '2026-000043',
parent_user_id: 'p1',
co_parent_user_id: 'p2',
statut: StatutUtilisateurType.ACTIF,
});
const body = {
email: 'coparent@test.fr',
prenom: 'Thomas',
nom: 'MARTIN',
telephone: '0678456789',
meme_adresse: true,
};
const res = await controller.addCoParent('p1', body as any);
expect(authServiceMock.addCoParentStaff).toHaveBeenCalledWith('p1', body);
expect(res.co_parent_user_id).toBe('p2');
expect(res.statut).toBe(StatutUtilisateurType.ACTIF);
});
});
@@ -3,8 +3,6 @@ import {
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
@@ -12,27 +10,14 @@ import {
} from '@nestjs/common';
import { ParentsService } from './parents.service';
import { UserService } from '../user/user.service';
import { AuthService } from '../auth/auth.service';
import { Parents } from 'src/entities/parents.entity';
import { Users } from 'src/entities/users.entity';
import { Roles } from 'src/common/decorators/roles.decorator';
import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CreateParentDto } from '../user/dto/create_parent.dto';
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
import { StaffCreateParentDossierDto } from './dto/staff-create-parent-dossier.dto';
import { StaffCreateParentDossierResponseDto } from './dto/staff-create-parent-dossier-response.dto';
import { StaffAddCoParentDto } from './dto/staff-add-co-parent.dto';
import { StaffAddCoParentResponseDto } from './dto/staff-add-co-parent-response.dto';
import { RegisterParentCompletDto } from '../auth/dto/register-parent-complet.dto';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { User } from 'src/common/decorators/user.decorator';
@@ -41,50 +26,14 @@ import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto';
import { mapParentForApi, mapParentsForApi } from './parents.mapper';
@ApiTags('Parents')
@ApiBearerAuth('access-token')
@Controller('parents')
@UseGuards(AuthGuard, RolesGuard)
export class ParentsController {
constructor(
private readonly parentsService: ParentsService,
private readonly userService: UserService,
private readonly authService: AuthService,
) {}
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@Post('dossier')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Créer un dossier famille/parent complet (staff) — ticket #129',
description:
'Crée parent (+ co-parent optionnel) + enfants + n° dossier avec statut actif, ' +
'et envoie le-mail de création de mot de passe. ' +
'Ne pas utiliser POST /auth/register/parent depuis le dashboard.',
})
@ApiBody({ type: StaffCreateParentDossierDto })
@ApiResponse({ status: 201, type: StaffCreateParentDossierResponseDto })
@ApiResponse({ status: 400, description: 'Validation DTO / métier' })
@ApiResponse({ status: 403, description: 'Rôle non autorisé' })
@ApiResponse({ status: 409, description: 'Email pivot et/ou co-parent déjà pris' })
async createDossier(
@Body() dto: StaffCreateParentDossierDto,
): Promise<StaffCreateParentDossierResponseDto> {
const registerDto = {
...dto,
acceptation_cgu: true,
acceptation_privacy: true,
} as RegisterParentCompletDto;
const result = await this.authService.createParentDossierStaff(registerDto);
return {
message: result.message,
numero_dossier: result.numero_dossier,
parent_user_id: result.parent_user_id,
co_parent_user_id: result.co_parent_user_id ?? null,
statut: result.statut,
enfant_ids: result.enfant_ids,
};
}
@Get('pending-families')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' })
@@ -178,28 +127,6 @@ export class ParentsController {
return mapParentForApi(parent);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@Post(':id/co-parent')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Ajouter un co-parent à un foyer existant (staff) — ticket #135',
description:
'Foyer mono-parent uniquement. Crée le co-parent actif, liens foyer + enfants, ' +
'e-mail de création de mot de passe. Ne pas utiliser POST /auth/register/parent.',
})
@ApiParam({ name: 'id', description: 'UUID utilisateur du parent pivot' })
@ApiBody({ type: StaffAddCoParentDto })
@ApiResponse({ status: 201, type: StaffAddCoParentResponseDto })
@ApiResponse({ status: 400, description: 'Foyer déjà à 2 parents / validation' })
@ApiResponse({ status: 404, description: 'Parent introuvable' })
@ApiResponse({ status: 409, description: 'Email déjà pris' })
async addCoParent(
@Param('id') id: string,
@Body() dto: StaffAddCoParentDto,
): Promise<StaffAddCoParentResponseDto> {
return this.authService.addCoParentStaff(id, dto);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Post(':id/enfants/:enfantId')
@ApiOperation({ summary: 'Rattacher un enfant à un parent — ticket #115' })
@@ -9,13 +9,11 @@ import { ParentsController } from './parents.controller';
import { ParentsService } from './parents.service';
import { Users } from 'src/entities/users.entity';
import { UserModule } from '../user/user.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant, ParentsChildren]),
forwardRef(() => UserModule),
forwardRef(() => AuthModule),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
+19 -94
View File
@@ -114,86 +114,36 @@ export class ParentsService {
}
/**
* Membres du foyer (user ids) pour affiliation enfant.
* Pivot + co-parent (AB et BA) + même numero_dossier. Ticket #158.
*/
private async resolveFoyerParentUserIds(parent: Parents): Promise<string[]> {
const ids = new Set<string>([parent.user_id]);
if (parent.co_parent?.id) {
ids.add(parent.co_parent.id);
}
// 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.
* Rattacher un enfant existant à un parent (enfants_parents). Ticket #115 / doc 28 §6.2.
*/
async attachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
const parent = await this.findOne(parentUserId);
await this.findOne(parentUserId);
const child = await this.parentsRepository.manager.findOne(Children, {
where: { id: enfantId },
const existing = await this.parentsChildrenRepository.findOne({
where: { parentId: parentUserId, enfantId },
});
if (existing) {
throw new ConflictException('Cet enfant est déjà rattaché à ce parent');
}
const child = await this.parentsRepository.manager.findOne(Children, { where: { id: enfantId } });
if (!child) {
throw new NotFoundException('Enfant introuvable');
}
const foyerIds = await this.resolveFoyerParentUserIds(parent);
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');
}
await this.parentsChildrenRepository.save(
this.parentsChildrenRepository.create({ parentId: parentUserId, enfantId }),
);
return this.findOne(parentUserId);
}
/**
* Détacher un enfant du foyer du parent (tous les responsables). Ticket #158.
* Si plus aucun lien ensuite enfant orphelin (#157).
* Détacher un enfant d'un parent sans supprimer l'enfant.
* Autorise le détachement du dernier responsable (#157) l'enfant reste listé
* via GET /enfants avec parentLinks vides (alerte front).
*/
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
const parent = await this.findOne(parentUserId);
await this.findOne(parentUserId);
const link = await this.parentsChildrenRepository.findOne({
where: { parentId: parentUserId, enfantId },
@@ -202,12 +152,7 @@ export class ParentsService {
throw new NotFoundException('Lien parent-enfant introuvable');
}
const foyerIds = await this.resolveFoyerParentUserIds(parent);
await this.parentsChildrenRepository.delete({
parentId: In(foyerIds),
enfantId,
});
await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId });
return this.findOne(parentUserId);
}
@@ -251,15 +196,7 @@ export class ParentsService {
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
)
SELECT
string_agg(
UPPER(TRIM(u.nom))
|| CASE
WHEN u.prenom IS NOT NULL AND TRIM(u.prenom) <> ''
THEN ' ' || INITCAP(TRIM(u.prenom))
ELSE ''
END,
' - ' ORDER BY u.nom, u.prenom, u.id
) AS libelle,
'Famille ' || string_agg(u.nom, ' - ' ORDER BY u.nom, u.prenom) AS libelle,
array_agg(p.id_utilisateur ORDER BY u.nom, u.prenom, u.id) AS "parentIds",
(array_agg(p.numero_dossier))[1] AS numero_dossier,
MIN(u.cree_le) AS date_soumission,
@@ -275,8 +212,6 @@ export class ParentsService {
json_build_object(
'id', u.id::text,
'email', u.email,
'nom', u.nom,
'prenom', u.prenom,
'telephone', u.telephone,
'code_postal', u.code_postal,
'ville', u.ville
@@ -327,21 +262,11 @@ export class ParentsService {
return [];
}
private normalizeParents(parents: unknown): {
id: string;
email: string;
nom: string | null;
prenom: string | null;
telephone: string | null;
code_postal: string | null;
ville: string | null;
}[] {
private normalizeParents(parents: unknown): { id: string; email: string; telephone: string | null; code_postal: string | null; ville: string | null }[] {
if (Array.isArray(parents)) {
return parents.map((p: any) => ({
id: String(p?.id ?? ''),
email: String(p?.email ?? ''),
nom: p?.nom != null ? String(p.nom) : null,
prenom: p?.prenom != null ? String(p.prenom) : null,
telephone: p?.telephone != null ? String(p.telephone) : null,
code_postal: p?.code_postal != null ? String(p.code_postal) : null,
ville: p?.ville != null ? String(p.ville) : null,
@@ -1,175 +0,0 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { SuppressionService } from './suppression.service';
import { RoleType } from 'src/entities/users.entity';
describe('SuppressionService (#159)', () => {
const dataSource = {
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) =>
cb({
delete: jest.fn(),
query: jest.fn(),
}),
),
};
const usersRepository = {
findOne: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
};
const parentsRepository = {
findOne: jest.fn(),
find: jest.fn(),
query: jest.fn(),
};
const amRepository = {
findOne: jest.fn(),
};
const childrenRepository = {
findOne: jest.fn(),
delete: jest.fn(),
save: jest.fn(),
};
const parentsChildrenRepository = {
find: jest.fn(),
};
const amChildrenRepository = {
find: jest.fn(),
save: jest.fn(),
count: jest.fn(),
};
let service: SuppressionService;
const staff = {
id: 'staff-1',
role: RoleType.GESTIONNAIRE,
} as never;
const admin = {
id: 'admin-1',
role: RoleType.ADMINISTRATEUR,
} as never;
const superAdmin = {
id: 'sa-1',
role: RoleType.SUPER_ADMIN,
} as never;
beforeEach(() => {
jest.clearAllMocks();
service = new SuppressionService(
dataSource as never,
usersRepository as never,
parentsRepository as never,
amRepository as never,
childrenRepository as never,
parentsChildrenRepository as never,
amChildrenRepository as never,
);
});
it('refuse self-delete', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'admin-1',
role: RoleType.ADMINISTRATEUR,
});
await expect(service.deleteUser('admin-1', admin)).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('refuse gestionnaire deleting another gestionnaire', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'g2',
role: RoleType.GESTIONNAIRE,
});
await expect(service.deleteUser('g2', staff)).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('dernier admin : refus si pas super_admin', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'admin-2',
role: RoleType.ADMINISTRATEUR,
});
usersRepository.count.mockResolvedValue(1);
await expect(service.deleteUser('admin-2', admin)).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('dernier admin : OK pour super_admin', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'admin-2',
role: RoleType.ADMINISTRATEUR,
});
usersRepository.count.mockResolvedValue(1);
usersRepository.delete.mockResolvedValue({ affected: 1 });
const res = await service.deleteUser('admin-2', superAdmin);
expect(res.deleted_user_ids).toEqual(['admin-2']);
});
it('delete AM : clos placements, pas denfants deleted', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'am-1',
role: RoleType.ASSISTANTE_MATERNELLE,
});
amRepository.findOne.mockResolvedValue({
user_id: 'am-1',
numero_dossier: '2026-000015',
});
amChildrenRepository.find.mockResolvedValue([
{
amId: 'am-1',
enfantId: 'e1',
child: { id: 'e1', status: 'garde' },
},
]);
amChildrenRepository.count.mockResolvedValue(0);
amChildrenRepository.save.mockImplementation(async (x) => x);
childrenRepository.save.mockResolvedValue({});
usersRepository.delete.mockResolvedValue({ affected: 1 });
const res = await service.deleteUser('am-1', staff);
expect(res.deleted_enfant_ids).toEqual([]);
expect(res.deleted_user_ids).toEqual(['am-1']);
expect(res.type).toBe('assistante_maternelle');
});
it('delete dossier famille introuvable', async () => {
parentsRepository.findOne.mockResolvedValue(null);
amRepository.findOne.mockResolvedValue(null);
await expect(
service.deleteDossier('2026-999999', staff),
).rejects.toBeInstanceOf(NotFoundException);
});
it('co-parent : delete user seul', async () => {
usersRepository.findOne.mockResolvedValue({
id: 'p2',
role: RoleType.PARENT,
});
parentsRepository.findOne.mockResolvedValue({
user_id: 'p2',
numero_dossier: '2026-000010',
co_parent: { id: 'p1' },
});
parentsRepository.query.mockResolvedValue([
{ id: 'p1' },
{ id: 'p2' },
]);
dataSource.transaction.mockImplementation(async (cb) =>
cb({
delete: jest.fn(),
query: jest.fn(),
}),
);
const res = await service.deleteUser('p2', staff);
expect(res.deleted_enfant_ids).toEqual([]);
expect(res.deleted_user_ids).toEqual(['p2']);
expect(res.message).toMatch(/co-parent/i);
});
});
@@ -1,420 +0,0 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, IsNull, Repository } from 'typeorm';
import { RoleType, Users } from 'src/entities/users.entity';
import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { Children, StatutEnfantType } from 'src/entities/children.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { AmChildren } from 'src/entities/am_children.entity';
export type SuppressionResult = {
type?: 'famille' | 'assistante_maternelle';
numero_dossier?: string;
deleted_user_ids: string[];
deleted_enfant_ids: string[];
dossier_supprime?: boolean;
message: string;
};
const STAFF_METIER: RoleType[] = [
RoleType.GESTIONNAIRE,
RoleType.ADMINISTRATEUR,
RoleType.SUPER_ADMIN,
];
/**
* Cascades de suppression métier tickets #154 / #159.
*/
@Injectable()
export class SuppressionService {
constructor(
private readonly dataSource: DataSource,
@InjectRepository(Users)
private readonly usersRepository: Repository<Users>,
@InjectRepository(Parents)
private readonly parentsRepository: Repository<Parents>,
@InjectRepository(AssistanteMaternelle)
private readonly amRepository: Repository<AssistanteMaternelle>,
@InjectRepository(Children)
private readonly childrenRepository: Repository<Children>,
@InjectRepository(ParentsChildren)
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
@InjectRepository(AmChildren)
private readonly amChildrenRepository: Repository<AmChildren>,
) {}
assertStaffMetier(currentUser: Users): void {
if (!STAFF_METIER.includes(currentUser.role)) {
throw new ForbiddenException('Accès refusé');
}
}
async deleteDossier(
numeroDossier: string,
currentUser: Users,
): Promise<SuppressionResult> {
this.assertStaffMetier(currentUser);
const num = numeroDossier?.trim();
if (!num) {
throw new BadRequestException('Numéro de dossier requis.');
}
const parentHit = await this.parentsRepository.findOne({
where: { numero_dossier: num },
});
if (parentHit) {
return this.deleteFamilleByNumero(num);
}
const amHit = await this.amRepository.findOne({
where: { numero_dossier: num },
relations: ['user'],
});
if (amHit?.user) {
return this.deleteAmUser(amHit.user.id);
}
throw new NotFoundException('Aucun dossier trouvé pour ce numéro.');
}
async deleteUser(
id: string,
currentUser: Users,
): Promise<SuppressionResult> {
const target = await this.usersRepository.findOne({ where: { id } });
if (!target) {
throw new NotFoundException('Utilisateur introuvable');
}
if (target.id === currentUser.id) {
throw new ForbiddenException('Vous ne pouvez pas supprimer votre propre compte.');
}
if (target.role === RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Le super administrateur ne peut pas être supprimé.');
}
if (target.role === RoleType.PARENT) {
this.assertStaffMetier(currentUser);
return this.deleteParentUser(target.id);
}
if (target.role === RoleType.ASSISTANTE_MATERNELLE) {
this.assertStaffMetier(currentUser);
return this.deleteAmUser(target.id);
}
if (target.role === RoleType.GESTIONNAIRE) {
if (
currentUser.role !== RoleType.ADMINISTRATEUR &&
currentUser.role !== RoleType.SUPER_ADMIN
) {
throw new ForbiddenException(
'Seul un administrateur peut supprimer un gestionnaire.',
);
}
await this.usersRepository.delete(target.id);
return {
deleted_user_ids: [target.id],
deleted_enfant_ids: [],
message: 'Gestionnaire supprimé.',
};
}
if (target.role === RoleType.ADMINISTRATEUR) {
await this.assertCanDeleteAdministrateur(target, currentUser);
await this.usersRepository.delete(target.id);
return {
deleted_user_ids: [target.id],
deleted_enfant_ids: [],
message: 'Administrateur supprimé.',
};
}
throw new BadRequestException('Type dutilisateur non supprimable via cet endpoint.');
}
async deleteEnfant(
enfantId: string,
deleteDossier: boolean,
currentUser: Users,
): Promise<SuppressionResult> {
this.assertStaffMetier(currentUser);
const child = await this.childrenRepository.findOne({
where: { id: enfantId },
relations: ['parentLinks', 'parentLinks.parent'],
});
if (!child) {
throw new NotFoundException('Enfant introuvable');
}
const parentIds = (child.parentLinks ?? [])
.map((l) => l.parentId ?? l.parent?.user_id)
.filter(Boolean) as string[];
let numero: string | undefined;
if (parentIds.length > 0) {
const parents = await this.parentsRepository.find({
where: { user_id: In(parentIds) },
});
numero = parents.map((p) => p.numero_dossier?.trim()).find((n) => !!n);
}
if (!numero) {
await this.closePlacementsForEnfants([enfantId]);
await this.childrenRepository.delete(enfantId);
return {
deleted_user_ids: [],
deleted_enfant_ids: [enfantId],
dossier_supprime: false,
message: 'Enfant supprimé.',
};
}
const siblingIds = await this.listEnfantIdsForNumero(numero);
const isLast = siblingIds.length <= 1;
if (isLast && deleteDossier) {
const result = await this.deleteFamilleByNumero(numero);
return {
...result,
dossier_supprime: true,
message: 'Dernier enfant et dossier famille supprimés.',
};
}
await this.closePlacementsForEnfants([enfantId]);
await this.childrenRepository.delete(enfantId);
return {
deleted_user_ids: [],
deleted_enfant_ids: [enfantId],
dossier_supprime: false,
numero_dossier: numero,
type: 'famille',
message: isLast
? 'Dernier enfant supprimé. Le dossier famille reste sans enfant.'
: 'Enfant supprimé du dossier famille.',
};
}
/** Compte enfants liés à un numero_dossier famille (pour flag sans_enfant). */
async countEnfantsForNumero(numeroDossier: string): Promise<number> {
const ids = await this.listEnfantIdsForNumero(numeroDossier);
return ids.length;
}
private async assertCanDeleteAdministrateur(
target: Users,
currentUser: Users,
): Promise<void> {
if (
currentUser.role !== RoleType.ADMINISTRATEUR &&
currentUser.role !== RoleType.SUPER_ADMIN
) {
throw new ForbiddenException(
'Seul un administrateur peut supprimer un administrateur.',
);
}
const adminCount = await this.usersRepository.count({
where: { role: RoleType.ADMINISTRATEUR },
});
if (adminCount <= 1) {
if (currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException(
'Seul le super administrateur peut supprimer le dernier administrateur.',
);
}
}
}
private async deleteParentUser(userId: string): Promise<SuppressionResult> {
const parent = await this.parentsRepository.findOne({
where: { user_id: userId },
relations: ['co_parent'],
});
if (!parent) {
// Compte parent sans fiche — hard delete user
await this.usersRepository.delete(userId);
return {
deleted_user_ids: [userId],
deleted_enfant_ids: [],
message: 'Parent supprimé.',
};
}
const numero = parent.numero_dossier?.trim();
const foyerIds = numero
? await this.listParentUserIdsForNumero(numero)
: [userId];
const isLast = foyerIds.filter((id) => id !== userId).length === 0;
if (!isLast) {
// Co-parent : retirer liens enfants de ce parent, clear co_parent refs, delete user
await this.dataSource.transaction(async (manager) => {
await manager.delete(ParentsChildren, { parentId: userId });
await manager.query(
`UPDATE parents SET id_co_parent = NULL WHERE id_co_parent = $1 OR id_utilisateur = $1`,
[userId],
);
await manager.delete(Users, { id: userId });
});
return {
deleted_user_ids: [userId],
deleted_enfant_ids: [],
numero_dossier: numero,
type: 'famille',
message: 'Parent retiré du dossier (co-parent).',
};
}
// Dernier parent : + enfants
const enfantIds = numero
? await this.listEnfantIdsForNumero(numero)
: await this.listEnfantIdsForParent(userId);
await this.closePlacementsForEnfants(enfantIds);
await this.dataSource.transaction(async (manager) => {
if (enfantIds.length) {
await manager.delete(Children, { id: In(enfantIds) });
}
await manager.query(
`UPDATE parents SET id_co_parent = NULL WHERE id_utilisateur = $1 OR id_co_parent = $1`,
[userId],
);
await manager.delete(Users, { id: userId });
});
return {
deleted_user_ids: [userId],
deleted_enfant_ids: enfantIds,
numero_dossier: numero,
type: 'famille',
message: 'Dernier parent et enfants rattachés supprimés.',
};
}
private async deleteFamilleByNumero(numero: string): Promise<SuppressionResult> {
const parentIds = await this.listParentUserIdsForNumero(numero);
if (parentIds.length === 0) {
throw new NotFoundException('Aucun parent pour ce dossier.');
}
const enfantIds = await this.listEnfantIdsForNumero(numero);
await this.closePlacementsForEnfants(enfantIds);
await this.dataSource.transaction(async (manager) => {
if (enfantIds.length) {
await manager.delete(Children, { id: In(enfantIds) });
}
await manager.query(
`UPDATE parents SET id_co_parent = NULL WHERE id_utilisateur = ANY($1::uuid[]) OR id_co_parent = ANY($1::uuid[])`,
[parentIds],
);
await manager.delete(Users, { id: In(parentIds) });
});
return {
type: 'famille',
numero_dossier: numero,
deleted_user_ids: parentIds,
deleted_enfant_ids: enfantIds,
message: 'Dossier famille supprimé.',
};
}
private async deleteAmUser(userId: string): Promise<SuppressionResult> {
const am = await this.amRepository.findOne({ where: { user_id: userId } });
const numero = am?.numero_dossier?.trim();
const active = await this.amChildrenRepository.find({
where: { amId: userId, date_fin: IsNull() },
relations: ['child'],
});
const now = new Date();
for (const link of active) {
link.date_fin = now;
await this.amChildrenRepository.save(link);
if (link.child) {
await this.applySansGarde(link.child);
}
}
await this.usersRepository.delete(userId);
return {
type: 'assistante_maternelle',
numero_dossier: numero,
deleted_user_ids: [userId],
deleted_enfant_ids: [],
message: 'Dossier assistante maternelle supprimé.',
};
}
private async applySansGarde(child: Children): Promise<void> {
if (
child.status === StatutEnfantType.A_NAITRE ||
child.status === StatutEnfantType.SCOLARISE
) {
return;
}
const remaining = await this.amChildrenRepository.count({
where: { enfantId: child.id, date_fin: IsNull() },
});
if (remaining === 0) {
child.status = StatutEnfantType.SANS_GARDE;
await this.childrenRepository.save(child);
}
}
private async closePlacementsForEnfants(enfantIds: string[]): Promise<void> {
if (!enfantIds.length) return;
const links = await this.amChildrenRepository.find({
where: { enfantId: In(enfantIds), date_fin: IsNull() },
relations: ['child'],
});
const now = new Date();
for (const link of links) {
link.date_fin = now;
await this.amChildrenRepository.save(link);
if (link.child) {
await this.applySansGarde(link.child);
}
}
}
private async listParentUserIdsForNumero(numero: string): Promise<string[]> {
const rows: Array<{ id: string }> = await this.parentsRepository.query(
`
SELECT DISTINCT x.id::text AS id FROM (
SELECT id_utilisateur AS id FROM parents WHERE TRIM(numero_dossier) = $1
UNION
SELECT id_co_parent AS id FROM parents
WHERE TRIM(numero_dossier) = $1 AND id_co_parent IS NOT NULL
UNION
SELECT p2.id_utilisateur AS id FROM parents p1
JOIN parents p2 ON p2.id_utilisateur = p1.id_co_parent
WHERE TRIM(p1.numero_dossier) = $1
) x WHERE x.id IS NOT NULL
`,
[numero],
);
return rows.map((r) => r.id);
}
private async listEnfantIdsForNumero(numero: string): Promise<string[]> {
const parentIds = await this.listParentUserIdsForNumero(numero);
if (!parentIds.length) return [];
return this.listEnfantIdsForParents(parentIds);
}
private async listEnfantIdsForParent(parentId: string): Promise<string[]> {
return this.listEnfantIdsForParents([parentId]);
}
private async listEnfantIdsForParents(parentIds: string[]): Promise<string[]> {
const links = await this.parentsChildrenRepository.find({
where: { parentId: In(parentIds) },
});
return [...new Set(links.map((l) => l.enfantId))];
}
}
@@ -1,25 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Users } from 'src/entities/users.entity';
import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { Children } from 'src/entities/children.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { AmChildren } from 'src/entities/am_children.entity';
import { SuppressionService } from './suppression.service';
@Module({
imports: [
TypeOrmModule.forFeature([
Users,
Parents,
AssistanteMaternelle,
Children,
ParentsChildren,
AmChildren,
]),
],
providers: [SuppressionService],
exports: [SuppressionService],
})
export class SuppressionsModule {}
+5 -17
View File
@@ -10,17 +10,13 @@ import { CreateUserDto } from './dto/create_user.dto';
import { CreateAdminDto } from './dto/create_admin.dto';
import { UpdateUserDto } from './dto/update_user.dto';
import { AffecterNumeroDossierDto } from './dto/affecter-numero-dossier.dto';
import { SuppressionService } from '../suppressions/suppression.service';
@ApiTags('Utilisateurs')
@ApiBearerAuth('access-token')
@UseGuards(AuthGuard, RolesGuard)
@Controller('users')
export class UserController {
constructor(
private readonly userService: UserService,
private readonly suppressionService: SuppressionService,
) { }
constructor(private readonly userService: UserService) { }
// Création d'un administrateur (réservée aux super admins)
@Post('admin')
@@ -150,20 +146,12 @@ export class UserController {
return this.userService.suspendUser(id, currentUser, comment);
}
// Supprimer un utilisateur — cascades métier #159
// Supprimer un utilisateur (super_admin uniquement)
@Delete(':id')
@Roles(
RoleType.SUPER_ADMIN,
RoleType.ADMINISTRATEUR,
RoleType.GESTIONNAIRE,
)
@ApiOperation({
summary: 'Supprimer un utilisateur (cascades métier #159)',
description:
'Parent / AM / staff selon matrice. Gestionnaire ne peut pas supprimer un autre gestionnaire. Self interdit.',
})
@Roles(RoleType.SUPER_ADMIN)
@ApiOperation({ summary: 'Supprimer un utilisateur' })
@ApiParam({ name: 'id', description: "UUID de l'utilisateur" })
remove(@Param('id') id: string, @User() currentUser: Users) {
return this.suppressionService.deleteUser(id, currentUser);
return this.userService.remove(id, currentUser);
}
}
-2
View File
@@ -12,7 +12,6 @@ import { Parents } from 'src/entities/parents.entity';
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
import { MailModule } from 'src/modules/mail/mail.module';
import { AppConfigModule } from 'src/modules/config/config.module';
import { SuppressionsModule } from '../suppressions/suppressions.module';
@Module({
imports: [TypeOrmModule.forFeature(
@@ -27,7 +26,6 @@ import { SuppressionsModule } from '../suppressions/suppressions.module';
GestionnairesModule,
MailModule,
AppConfigModule,
SuppressionsModule,
],
controllers: [UserController],
providers: [UserService],
-1
View File
@@ -520,7 +520,6 @@ export class UserService {
}
async remove(id: string, currentUser: Users): Promise<void> {
// Délégué historiquement ; préférer SuppressionService via controller (#159).
if (currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Accès réservé aux super admins');
}
@@ -1,104 +0,0 @@
# 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.*
-89
View File
@@ -1,89 +0,0 @@
# Mini-spec API — POST /parents/dossier (#129)
Contrat pour le **plan front** (wizard création dossier famille staff).
Miroir de **#156** (`POST /assistantes-maternelles/dossier`).
## Endpoint
| | |
|--|--|
| **Méthode** | `POST` |
| **URL** | `{base}/parents/dossier` |
| **Auth** | Bearer JWT |
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
| **Content-Type** | `application/json` |
Ne **pas** appeler `POST /auth/register/parent` depuis le dashboard.
## Body (JSON)
Aligné `RegisterParentCompletDto`, **sans** CGU/privacy obligatoires (acceptées serveur).
### Parent 1 (obligatoire)
| 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 | |
### Co-parent (optionnel)
`co_parent_email`, `co_parent_prenom`, `co_parent_nom`, `co_parent_telephone`,
`co_parent_meme_adresse`, `co_parent_adresse`, `co_parent_code_postal`, `co_parent_ville`.
Si co-parent fourni : e-mail distinct ; mêmes règles téléphone / adresse que register.
### Enfants (≥ 1)
| Champ | Type | Notes |
|-------|------|--------|
| `enfants` | `EnfantInscriptionDto[]` | `prenom`, `nom`, `date_naissance` / `date_previsionnelle_naissance`, `genre`, `photo_base64`, `photo_filename`, etc. |
### Présentation
| Champ | Type | Obligatoire |
|-------|------|-------------|
| `presentation_dossier` | string | non (max 2000) |
## Réponses
### 201 Created
```json
{
"message": "Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.",
"numero_dossier": "2026-000043",
"parent_user_id": "uuid-pivot",
"co_parent_user_id": "uuid-ou-null",
"statut": "actif",
"enfant_ids": ["uuid", "..."]
}
```
Effets serveur : user(s) parent **actif**, fiches `parents`, enfants + foyer, n° dossier,
**e-mail création MDP** pour chaque compte sans MDP (pas daccusé « en attente »).
### Erreurs
| Code | Cas |
|------|-----|
| 400 | Validation DTO / métier (enfants vides, dates, etc.) |
| 401 | Token manquant / invalide |
| 403 | Rôle non staff |
| 409 | Conflit e-mail (pivot et/ou co-parent) |
## Front
- `UserService.createParentDossier(body)` → cet endpoint
- Wizard create basé sur `ValidationFamilyWizard`
- Ne pas envoyer `acceptation_cgu` / `acceptation_privacy` (optionnels)
## Branche
`feature/129-creation-dossier-parent`
@@ -1,73 +0,0 @@
# Mini-spec API — POST /parents/:id/co-parent (#135)
Contrat back pour lajout dun **2ᵉ parent** sur un foyer mono-parent (staff).
## Endpoint
| | |
|--|--|
| **Méthode** | `POST` |
| **URL** | `{base}/api/v1/parents/{parentUserId}/co-parent` |
| **Auth** | Bearer JWT |
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
| **Succès** | **201** |
`parentUserId` = UUID du **parent pivot** (déjà dans le dossier).
Ne **pas** appeler `POST /auth/register/parent` ni `POST /parents/dossier`.
---
## Body (JSON)
| Champ | Type | Obligatoire | Notes |
|-------|------|-------------|--------|
| `email` | string | oui | unique |
| `prenom` | string | oui | |
| `nom` | string | oui | |
| `telephone` | string | oui | `0X…` ou `+33…` |
| `meme_adresse` | bool | non | défaut **true** → copie adresse du pivot |
| `adresse` | string | si `meme_adresse=false` | |
| `code_postal` | string | si `meme_adresse=false` | |
| `ville` | string | si `meme_adresse=false` | |
---
## Comportement 201
- User co-parent **actif** + token création MDP
- Fiche `parents` + liens pivot ↔ co-parent + même `numero_dossier`
- Enfants du foyer rattachés au co-parent
- E-mail **création MDP** (pas mail « en attente »)
```json
{
"message": "Co-parent ajouté au foyer. Un e-mail de création de mot de passe a été envoyé.",
"numero_dossier": "2026-000043",
"parent_user_id": "uuid-pivot",
"co_parent_user_id": "uuid-co",
"statut": "actif"
}
```
## Erreurs
| Code | Cas |
|------|-----|
| 400 | Déjà un co-parent / 2 responsables / validation adresse |
| 401 | Token invalide |
| 403 | Rôle non staff |
| 404 | Pivot introuvable |
| 409 | Email déjà pris |
## Réemploi édition identité
| Endpoint | Usage |
|----------|--------|
| `GET /dossiers/:numero` | Préremplir wizard edit |
| `PATCH /parents/:id/fiche` | Sauver identité pivot / co-parent existant |
| `PATCH /assistantes-maternelles/:id/fiche` | Édition AM |
## Branche
`feature/135-edition-dossier`
@@ -1,83 +0,0 @@
# Mini-spec front — Mode édition dossier + ajout 2ᵉ parent (#135)
Branche : `feature/135-edition-dossier`
Ticket : **#135** (full-stack)
Prérequis : **#153** (liste Dossiers) livré.
---
## Objectif
1. Clic sur un dossier (liste #153) → ouvrir le wizard en mode **`edit`**
2. Foyer **mono-parent** : page co-parent → **switch** ajouter un 2ᵉ parent
3. Sauvegarder les champs via APIs existantes + nouvel endpoint co-parent
---
## Modes wizard
| Mode | Famille | AM |
|------|---------|-----|
| `review` | déjà | déjà |
| `create` | déjà (#129) | déjà (#156) |
| **`edit`** | **à faire** | **à faire** |
Factories : `ParentDossierWizard.edit(...)` / `AmDossierWizard.edit(...)`
Préremplir via `UserService.getDossierByNumero(numero)`.
---
## APIs
| Action | Endpoint |
|--------|----------|
| Charger | `GET /dossiers/:numero` |
| Sauver parent | `PATCH /parents/:id/fiche` |
| Sauver AM | `PATCH /assistantes-maternelles/:id/fiche` |
| **Ajouter co-parent** | **`POST /parents/:pivotUserId/co-parent`** — voir `docs/tmp/135-contrat-api-ajout-co-parent.md` |
Body co-parent :
```json
{
"email": "thomas@…",
"prenom": "Thomas",
"nom": "MARTIN",
"telephone": "0678456789",
"meme_adresse": true
}
```
`UserService.addCoParent(pivotUserId, body)` → cet endpoint.
---
## UX
- Depuis `DossiersManagementWidget` / carte liste : clic → edit (plus seulement review pending)
- Pending : garder validation (review) ; dossiers actifs → edit
- Mono-parent : switch « Ajouter un co-parent » (comme create) → au save, `POST …/co-parent` si nouveau
- Déjà 2 parents : éditer les deux fiches ; pas de 3ᵉ
- Pas de bouton créer dans longlet Dossiers
---
## Hors scope
- Famille N responsables (#139)
- Suppressions (#154)
- Création dossier initial (#129 / #156)
---
## Critères dacceptation
- [ ] Clic dossier actif → wizard edit prérempli
- [ ] PATCH fiche enregistre les modifs
- [ ] Mono-parent + switch → co-parent créé (actif + mail MDP)
- [ ] review / create inchangés
## Branche
`feature/135-edition-dossier`
@@ -1,77 +0,0 @@
# Mini-spec API — GET /dossiers (#153)
Contrat pour le **plan front** (onglet permanent Dossiers).
## Endpoint
| | |
|--|--|
| **Méthode** | `GET` |
| **URL** | `{base}/api/v1/dossiers` |
| **Auth** | Bearer JWT |
| **Rôles** | `gestionnaire`, `administrateur`, `super_admin` |
| **Query** | `q` (optionnel) — recherche n° / libellé / email |
Complète `GET /dossiers/:numeroDossier` (#119) déjà existant.
---
## Réponse 200
Tableau de lignes (1 entrée = 1 `numero_dossier`) :
```json
[
{
"type": "famille",
"numero_dossier": "2026-000043",
"libelle": "Claire MARTIN & Thomas MARTIN",
"emails": ["claire@test.fr", "thomas@test.fr"],
"user_ids": ["uuid-pivot", "uuid-co"],
"statut": "actif",
"a_valider": false,
"date_reference": "2026-01-12T10:00:00.000Z"
},
{
"type": "assistante_maternelle",
"numero_dossier": "2026-000042",
"libelle": "Marie DUPONT",
"emails": ["marie@test.fr"],
"user_ids": ["uuid-am"],
"statut": "en_attente",
"a_valider": true,
"date_reference": "2026-02-01T08:00:00.000Z"
}
]
```
### Champs
| Champ | Notes |
|-------|--------|
| `type` | `famille` \| `assistante_maternelle` |
| `numero_dossier` | Clé dunité |
| `libelle` | Noms formatés (foyer : `A & B`) |
| `emails` / `user_ids` | Membres du foyer ou AM |
| `statut` | Agrégé : `en_attente` si au moins un user pending |
| `a_valider` | `true` si pending → section haute UI |
| `date_reference` | `MIN(cree_le)` des users |
**Tri** : `a_valider` dabord, puis `numero_dossier` décroissant.
**Famille** : dédupliquée par `numero_dossier` (pivot + co-parent = 1 ligne).
---
## Front
- `UserService.getDossiers({ q? })` → cet endpoint
- Section haute : filtrer `a_valider == true` **ou** continuer pending APIs existantes
- Section basse : liste complète (ou hors pending selon règle UX)
- Clic → `GET /dossiers/:numero` (détail) / validation review
Composition client `getParents`+`getAM` **plus nécessaire** si cet endpoint est déployé.
## Branche
`feature/153-onglet-dossiers`
-167
View File
@@ -1,167 +0,0 @@
# Mini-spec front — Onglet permanent « Dossiers » (#153)
Branche Git (front + back) : `feature/153-onglet-dossiers`
Ticket Gitea : **#153** (ticket normal, plus epic)
> Suite prévue : **#135** = au clic, mode **édition** wizard + ajout 2ᵉ parent.
> **#153** = onglet + listes + navigation / validation pending. **Pas** de création, **pas** d’édition complète.
---
## Contexte / objectif
Remplacer longlet conditionnel **« À valider »** (apparaît/disparaît selon pending) par un onglet **permanent « Dossiers »** dans le dashboard admin/gestionnaire.
Quand on ouvre **Dossiers** :
1. **En haut** — section **Dossiers à valider** (AM + familles pending)
2. **En dessous** — liste de **tous les dossiers** (familles **et** AM), 1 ligne = 1 `numero_dossier`
3. Différenciation visuelle famille vs AM : **couleur + icône**
4. **Barre de recherche** (n° dossier, nom, email…)
**Pas** de bouton « Créer un dossier » ici (création via **+ Parents** #129 / **+ Asmat** #156).
---
## UX cible
### Onglets dashboard (`UserManagementPanel`)
| Avant (#107) | Après (#153) |
|--------------|--------------|
| « À valider » **conditionnel** si pending | **« Dossiers » toujours visible** (admin + gestionnaire) |
| Contenu = seulement pending | Pending **en haut** + liste complète **en bas** |
Ordre suggéré des onglets :
`Dossiers` | `Parents` | `Enfants` | `Assistantes maternelles` | `Gestionnaires` | (`Administrateurs`)
### Section haute — À valider
- Réutiliser / adapter `PendingValidationWidget` (ou extraire la liste dans un sous-widget).
- Sources déjà branchées :
- `UserService.getPendingUsers(role: 'assistante_maternelle')`
- `UserService.getPendingFamilies()`
- Clic ligne pending → **`ValidationDossierModal`** / wizards `.review` (inchangé).
- Si section vide : ne pas afficher de gros vide ; masquer la section ou message court « Aucun dossier en attente ».
### Section basse — Tous les dossiers
1 ligne = **1 dossier** (`numero_dossier`), type :
| Type | Libellé UI | Couleur (suggestion) |
|------|------------|----------------------|
| `famille` | Famille / Parents | teinte existante parents (ex. violet / rose dashboard) |
| `assistante_maternelle` | AM | teinte existante AM (ex. teal / bleu) |
Colonnes / infos utiles (cartes style `AdminUserCard` ou lignes type pending) :
- n° dossier
- type (pastille couleur + icône)
- libellé (noms parents ou AM)
- email(s) principal(aux)
- statut user / dossier si dispo (`actif`, `en_attente`, …)
- date utile si dispo
**Déduplication** : un foyer (pivot + co-parent) = **une** ligne famille (même `numero_dossier`). Idem AM.
### Recherche
- La search bar du panel (aujourdhui désactivée / hint « pas de recherche » sur À valider) doit **filtrer la liste unifiée** (et idéalement aussi le pending affiché).
- Critères **minimum** : `numero_dossier`, nom, prénom, email.
- Harmoniser le hint : `Rechercher un dossier (n°, nom, email)…`
### État vide liste complète
Aide optionnelle : *« Pour créer un dossier → onglet Parents (+ Parents) ou Assistantes maternelles (+ Asmat) »*.
### Clic sur un dossier de la liste complète (#153)
| Cas | Comportement #153 |
|-----|-------------------|
| Pending | Ouvrir validation (review) — déjà en place |
| Dossier **actif** / non pending | Ouvrir consultation via `GET /dossiers/:numeroDossier` (`UserService.getDossierByNumero`) en **lecture / review** si possible **sans** save édition |
**Ne pas** implémenter le mode `edit` ni le switch 2ᵉ parent → **#135**.
Si louverture « review » dun dossier actif est trop lourde pour ce ticket : clic peut temporairement no-op / snackbar *« Édition dossier : prochainement (#135) »***à éviter** si `getDossierByNumero` + wizard review marche déjà pour les deux types.
---
## Données / APIs (front)
### Déjà disponibles (préférer composer côté front pour #153)
| Besoin | API / service |
|--------|----------------|
| Pending AM | `getPendingUsers(role: assistante_maternelle)` |
| Pending familles | `getPendingFamilies()` |
| Parents (avec `numero_dossier`) | `getParents()` |
| AM (avec `numero_dossier`) | `getAssistantesMaternelles()` |
| Détail unifié | `getDossierByNumero(numero)``GET /dossiers/:numeroDossier` |
**Pas dendpoint `GET /dossiers` liste** aujourdhui. Pour #153 :
- Construire la liste unifiée **côté client** à partir de `getParents()` + `getAssistantesMaternelles()` (group by `numero_dossier`).
- Exclure ou marquer les pending déjà dans la section haute (éviter doublons visuels, ou les laisser dans les deux avec badge « à valider » — **préférence** : pending **uniquement** en haut ; liste basse = tous **hors** pending **ou** tous avec badge ; choisir une règle claire et documenter dans le PR).
**Règle recommandée** :
- Haut = pending only
- Bas = **tous** les dossiers ayant un `numero_dossier` (y compris pending) **OU** bas = non-pending only
**Recommandation produit** : bas = **tous** (vision complète), pending aussi en haut pour action rapide. Si doublon gênant : bas = non-pending only.
### Si le back ajoute plus tard `GET /dossiers`
Brancher `UserService.getDossiers()` — hors scope bloquant #153 front si composition client OK.
---
## Fichiers front probables
| Fichier | Rôle |
|---------|------|
| `frontend/lib/widgets/admin/user_management_panel.dart` | Onglet permanent **Dossiers** ; retirer logique conditionnelle À valider ; search sur cet onglet |
| `frontend/lib/widgets/admin/pending_validation_widget.dart` | Réemploi section haute (ou refactor léger) |
| **Nouveau** `…/dossiers_management_widget.dart` (nom libre) | Shell onglet : pending + liste unifiée + refresh |
| **Nouveau** modèle léger `DossierListItem` (type, numero, libelle, emails, statut…) | Mapping parents/AM → ligne |
| `user_service.dart` / `api_config.dart` | Seulement si helper `getDossiersUnified()` côté client (pas forcément nouvel endpoint) |
| `validation_dossier_modal.dart` | Réemploi ouverture pending / détail |
Réutiliser look & feel cartes / hover « Ouvrir » de `_PendingValidationRow` / `AdminUserCard`.
---
## Hors scope (#153)
- Bouton créer dossier
- Mode `edit` wizard + ajout 2ᵉ parent → **#135**
- Suppressions → **#154**
- Famille N responsables → **#139**
- Changer les onglets Parents / AM / Enfants (restent)
---
## Critères dacceptation front
- [ ] Onglet **Dossiers** toujours visible (même 0 pending)
- [ ] Plus donglet conditionnel **« À valider »**
- [ ] Section haute pending si non vide ; validation au clic OK
- [ ] Liste unifiée familles + AM en dessous ; 1 ligne / `numero_dossier`
- [ ] Couleur + icône différencient famille / AM
- [ ] Recherche filtre (n° + nom + email minimum)
- [ ] **Aucun** bouton créer dans cet onglet
- [ ] Pas de régression validation pending (valider / refuser)
---
## Back (info — Cursor back séparé si besoin)
- Liste unifiée : **pas bloquante** si composition front
- Optionnel : `GET /api/v1/dossiers` (liste) pour perf / pagination plus tard
- `GET /dossiers/:numero` déjà là (#119)
---
## Branche
`feature/153-onglet-dossiers` (depuis `develop`)
-35
View File
@@ -1,35 +0,0 @@
# Matrice suppression — #154 / back **#159** / front **#160**
**Statut** : cadrage PO validé (sept. 2026)
**Milestone** : 0.1.0
**Email** : pas demail de suppression (cas rare)
## Droits
| Cible | Qui peut supprimer |
|-------|-------------------|
| Dossier / parent / enfant / AM | `GESTIONNAIRE`, `ADMINISTRATEUR`, `SUPER_ADMIN` |
| Gestionnaire (user) | `ADMINISTRATEUR`, `SUPER_ADMIN` |
| Administrateur (user) | Autre admin OK ; **self interdit** ; **dernier admin** = `SUPER_ADMIN` only ; `SUPER_ADMIN` non supprimable |
## Matrice métier
| Point dentrée | Action | Effet |
|----------------|--------|--------|
| Dossiers | Delete dossier **famille** | Tous **parents** + tous **enfants** ; clore placements AM des enfants |
| Dossiers / AM | Delete dossier **AM** ou compte AM | **Compte AM + dossier AM** ; enfants **conservés** ; placements **clos** |
| Parents | Co-parent (autre parent reste) | Compte parent seul ; dossier + enfants restent |
| Parents | Dernier parent | Parent + **enfants** rattachés |
| Enfants | Pas dernier | Enfant seul (retiré du dossier) |
| Enfants | Dernier + `deleteDossier=true` | Cascade dossier famille (parents + enfants) |
| Enfants | Dernier + `deleteDossier=false` | Enfant seul ; dossier peut apparaître **`sans_enfant`** |
| Pending / validé | — | **Mêmes règles** (pas de différenciation) |
## Warning
- `sans_enfant` sur liste `GET /dossiers` (dossier famille sans enfant lié).
- Miroir de `sans_responsable` (#157) côté enfants.
## Hors scope
Soft-delete RGPD, audit (#128), famille N (#139), restriction admin-only métier (plus tard).
-169
View File
@@ -1,169 +0,0 @@
# Mini-spec front — Suppressions dashboard
**Ticket front** : **#160** — https://git.ptits-pas.fr/jmartin/petitspas/issues/160
**Ticket back** : **#159** — https://git.ptits-pas.fr/jmartin/petitspas/issues/159
**Epic** : #154 (complète #133)
**Branche back** : `feature/159-suppressions-backend`
**Doc matrice** : [154-matrice-suppression.md](./154-matrice-suppression.md)
Travail **en parallèle** : ce contrat est la source de vérité UI ↔ API.
---
## UX commune
Sur chaque ligne / carte des listes :
- Icône **poubelle** en bout de ligne
- Clic → **dialog de confirmation** (texte dimpact) → DELETE → **refresh** liste
- Pending = **mêmes** règles que validés
- **Pas** demail
| Liste | Poubelle visible si |
|-------|---------------------|
| Dossiers, Parents, Enfants, AM | gestionnaire **ou** admin |
| Gestionnaires | **admin** only |
| Administrateurs | admin+ ; **pas** sur sa propre ligne ; dernier admin : UI warning + réservé super_admin |
---
## Contrat API
Base : auth Bearer. Erreurs : `400` / `403` / `404` / `409` avec `message` FR.
### 1. `DELETE /dossiers/:numeroDossier`
- **Famille** → supprime tous parents + enfants du n° ; clos placements AM des enfants.
- **AM** → compte AM + dossier AM ; enfants gardés ; placements clos.
**Réponse 200** (exemple) :
```json
{
"type": "famille",
"numero_dossier": "2026-000043",
"deleted_user_ids": ["…"],
"deleted_enfant_ids": ["…"],
"message": "Dossier famille supprimé."
}
```
ou
```json
{
"type": "assistante_maternelle",
"numero_dossier": "2026-000015",
"deleted_user_ids": ["…"],
"deleted_enfant_ids": [],
"message": "Dossier assistante maternelle supprimé."
}
```
**Dialog UI** : lister libellé + n° + « X parent(s), Y enfant(s) » (ou « compte AM, enfants conservés »).
---
### 2. `DELETE /users/:id`
Comportement selon le **rôle** de la cible :
| Cible | Effet |
|-------|--------|
| Parent **co-parent** | Delete ce user seul |
| Parent **dernier** du dossier | Delete user + enfants du foyer |
| AM | Delete user AM + dossier AM ; enfants conservés ; placements clos |
| Gestionnaire | Admin only ; self → 403 |
| Administrateur | Self → 403 ; dernier admin → super_admin only sinon 403 ; super_admin → 403 |
**Réponse 200** :
```json
{
"deleted_user_ids": ["…"],
"deleted_enfant_ids": ["…"],
"message": "…"
}
```
**Dialogs** :
- Co-parent : « Ce parent sera retiré / supprimé du dossier {n°}. Les enfants restent avec le co-parent. »
- Dernier parent : « Dernier parent du dossier {n°}. Les enfants rattachés seront aussi supprimés. »
- AM : « Le compte et le dossier AM seront supprimés. Les enfants accueillis ne seront pas supprimés. »
Optionnel (si exposé) : `GET /users/:id/suppression-impact` — sinon calculer depuis données déjà en liste / détail dossier.
---
### 3. `DELETE /enfants/:id?deleteDossier=true|false`
- Pas dernier enfant → delete enfant (`deleteDossier` ignoré ou false).
- Dernier enfant + `deleteDossier=false` → delete enfant ; dossier famille peut passer `sans_enfant`.
- Dernier enfant + `deleteDossier=true` → cascade dossier famille (parents + enfants).
**Réponse 200** :
```json
{
"deleted_enfant_ids": ["…"],
"deleted_user_ids": ["…"],
"dossier_supprime": false,
"message": "…"
}
```
**Dialog** :
- Standard : « Lenfant sera supprimé du dossier de {famille} ({n°}). »
- Dernier : proposer **deux actions** :
1. Supprimer lenfant seulement (`deleteDossier=false`)
2. Supprimer aussi le dossier / parents (`deleteDossier=true`)
Pour savoir si dernier : compter enfants du `numero_dossier` (détail dossier ou champ impact API).
---
### 4. `GET /dossiers` — flag `sans_enfant`
Chaque item famille peut exposer :
```json
"sans_enfant": true
```
- `true` si dossier **famille** sans enfant lié.
- AM : `false` ou omis.
**UI** : badge / warning vigilance (comme `sans_responsable` / alertes AM).
---
## UserService (Flutter) — signatures cibles
```dart
Future<void> deleteDossier(String numeroDossier);
Future<Map<String, dynamic>> deleteUser(String userId);
Future<Map<String, dynamic>> deleteEnfant(String enfantId, {bool deleteDossier = false});
```
(Adapter le parsing au JSON réel une fois le back mergé ; en parallèle, stubber sur ce contrat.)
---
## Fichiers front probables
- Cartes listes : `admin_user_card.dart`, `admin_enfant_user_card.dart`, cartes dossiers
- Listes : `dossiers_management_widget.dart`, `parent_managmant_widget.dart`, `enfant_management_widget.dart`, `assistante_maternelle_management_widget.dart`, `gestionnaire_management_widget.dart`, `admin_management_widget.dart`
- `user_service.dart`
---
## Critères front (#160)
- [ ] Poubelle selon droits
- [ ] Confirmations avec impact
- [ ] Refresh après succès
- [ ] Dernier enfant : choix dossier oui/non
- [ ] Warning `sans_enfant`
- [ ] Self-admin / dernier admin gérés côté UI (masquer ou message 403)
-75
View File
@@ -1,75 +0,0 @@
# 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`
-196
View File
@@ -1,196 +0,0 @@
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart';
/// Ligne de liste unifiée dossiers (famille ou AM) — ticket #153.
enum DossierListType { famille, assistanteMaternelle }
class DossierListItem {
final DossierListType type;
final String numeroDossier;
final String libelle;
final List<String> emails;
final String? statut;
/// Photo profil (AM) — affichée à la place de licône si présente.
final String? photoUrl;
const DossierListItem({
required this.type,
required this.numeroDossier,
required this.libelle,
this.emails = const [],
this.statut,
this.photoUrl,
});
bool get isFamille => type == DossierListType.famille;
bool get isAm => type == DossierListType.assistanteMaternelle;
String get typeLabel => isFamille ? 'Famille' : 'AM';
/// Sous-titre carte : `NOM Prénom` ou `NOM Prénom - NOM Prénom`.
String get namesLine => libelle;
String get emailsLine => emails.where((e) => e.trim().isNotEmpty).join(' · ');
/// Titre carte : numéro de dossier seul.
String get titleLine => numeroDossier;
bool matchesQuery(String query) {
final q = query.trim().toLowerCase();
if (q.isEmpty) return true;
if (numeroDossier.toLowerCase().contains(q)) return true;
if (libelle.toLowerCase().contains(q)) return true;
for (final e in emails) {
if (e.toLowerCase().contains(q)) return true;
}
return false;
}
/// Une ligne par `numero_dossier` (foyer dédupliqué).
static List<DossierListItem> fromParents(List<ParentModel> parents) {
final byDossier = <String, List<ParentModel>>{};
for (final p in parents) {
final num = (p.user.numeroDossier ?? '').trim();
if (num.isEmpty) continue;
byDossier.putIfAbsent(num, () => []).add(p);
}
final items = <DossierListItem>[];
for (final entry in byDossier.entries) {
final seenIds = <String>{};
final names = <String>[];
final emails = <String>[];
final statuts = <String>[];
void consider(
String? id,
String? nom,
String? prenom,
String? email,
String? statut,
) {
final uid = (id ?? '').trim();
if (uid.isEmpty || !seenIds.add(uid)) return;
final label = formatDossierPersonLabel(
nom: nom,
prenom: prenom,
email: email,
);
if (label.isNotEmpty) names.add(label);
final e = (email ?? '').trim();
if (e.isNotEmpty) emails.add(e);
final s = (statut ?? '').trim();
if (s.isNotEmpty) statuts.add(s);
}
for (final p in entry.value) {
consider(
p.user.id,
p.user.nom,
p.user.prenom,
p.user.email,
p.user.statut,
);
final co = p.coParent;
if (co != null) {
consider(co.id, co.nom, co.prenom, co.email, co.statut);
}
}
items.add(
DossierListItem(
type: DossierListType.famille,
numeroDossier: entry.key,
libelle: names.isNotEmpty ? names.join(' - ') : 'Famille',
emails: emails,
statut: _preferStatut(statuts),
),
);
}
return items;
}
static List<DossierListItem> fromAssistantes(
List<AssistanteMaternelleModel> ams,
) {
final byDossier = <String, AssistanteMaternelleModel>{};
for (final am in ams) {
final num = (am.user.numeroDossier ?? '').trim();
if (num.isEmpty) continue;
byDossier.putIfAbsent(num, () => am);
}
return byDossier.entries.map((e) {
final u = e.value.user;
final name = formatDossierPersonLabel(
nom: u.nom,
prenom: u.prenom,
email: u.email,
);
final photo = (u.photoUrl ?? '').trim();
return DossierListItem(
type: DossierListType.assistanteMaternelle,
numeroDossier: e.key,
libelle: name.isNotEmpty ? name : 'AM',
emails: u.email.trim().isEmpty ? const [] : [u.email.trim()],
statut: u.statut?.trim(),
photoUrl: photo.isEmpty ? null : photo,
);
}).toList();
}
/// Priorité affichage : en_attente > suspendu > refuse > actif > autre.
static String? _preferStatut(List<String> raw) {
if (raw.isEmpty) return null;
const order = ['en_attente', 'suspendu', 'refuse', 'actif'];
for (final wanted in order) {
for (final s in raw) {
if (s.toLowerCase() == wanted) return s;
}
}
return raw.first;
}
}
/// Affichage carte dossier : `NOM Prénom` (repli email).
String formatDossierPersonLabel({
String? nom,
String? prenom,
String? email,
}) {
final n = (nom ?? '').trim().toUpperCase();
final p = formatPersonNameCase(prenom ?? '');
if (n.isNotEmpty && p.isNotEmpty) return '$n $p';
if (n.isNotEmpty) return n;
if (p.isNotEmpty) return p;
return (email ?? '').trim();
}
/// Reformate un libellé famille API (`A & B` / `Famille …`) en `NOM Prénom - …`.
String formatDossierFamilyNamesLine(String libelle) {
var raw = libelle.trim();
if (raw.isEmpty) return '';
raw = raw.replaceFirst(RegExp(r'^famille\s+', caseSensitive: false), '');
raw = raw
.replaceAll(RegExp(r'\s+&\s+'), ' - ')
.replaceAll(RegExp(r'\s+et\s+', caseSensitive: false), ' - ');
final parts = raw
.split(RegExp(r'\s+-\s+'))
.map((part) => _formatLoosePersonSegment(part.trim()))
.where((s) => s.isNotEmpty)
.toList();
return parts.join(' - ');
}
/// Segment libre type « martin sophie » ou « DURAND Amélie » → `NOM Prénom`.
String _formatLoosePersonSegment(String segment) {
final words =
segment.split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList();
if (words.isEmpty) return '';
if (words.length == 1) return words.first.toUpperCase();
// Convention affichage : premier mot = NOM, reste = prénom(s).
final nom = words.first.toUpperCase();
final prenom = formatPersonNameCase(words.sublist(1).join(' '));
return '$nom $prenom';
}
+1 -6
View File
@@ -215,13 +215,8 @@ class EnfantDossier {
factory EnfantDossier.fromJson(Map<String, dynamic> json) {
final rawPhoto = json['photo_url'] ?? json['photoUrl'];
final resolvedPhoto = _optionalPhotoUrl(rawPhoto);
final rawId = json['id'] ??
json['enfant_id'] ??
json['enfantId'] ??
json['child_id'] ??
json['childId'];
return EnfantDossier(
id: rawId?.toString().trim() ?? '',
id: json['id']?.toString() ?? '',
firstName: (json['first_name'] ?? json['prenom'])?.toString(),
lastName: (json['last_name'] ?? json['nom'])?.toString(),
birthDate: json['birth_date']?.toString(),
@@ -60,12 +60,7 @@ class ApiConfig {
static const String userChildren = '/users/children';
static const String gestionnaires = '/gestionnaires';
static const String parents = '/parents';
/// Création dossier famille actif par le staff (#129) — body type register parent.
static const String parentsDossier = '/parents/dossier';
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 relais = '/relais';
static const String dossiers = '/dossiers';
+6 -196
View File
@@ -525,58 +525,17 @@ class UserService {
return enfant.copyWith(parentLinks: enriched);
}
/// Mise à jour enfant. Avec [photoBytes] : multipart (champ `photo`), sinon JSON.
static Future<EnfantAdminModel> updateEnfant({
required String enfantId,
required Map<String, dynamic> body,
List<int>? photoBytes,
String? photoFilename,
}) async {
final id = enfantId.trim();
if (id.isEmpty) {
throw Exception('Identifiant enfant manquant.');
}
final hasPhoto = photoBytes != null && photoBytes.isNotEmpty;
final http.Response response;
if (hasPhoto) {
final token = await TokenService.getToken();
final req = http.MultipartRequest(
'PATCH',
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$id'),
);
req.headers['Accept'] = 'application/json';
if (token != null) {
req.headers['Authorization'] = 'Bearer $token';
}
body.forEach((key, value) {
if (value == null) return;
req.fields[key] = value is bool
? (value ? 'true' : 'false')
: value.toString();
});
final name = (photoFilename ?? '').trim();
final filename = name.isNotEmpty ? name : 'photo.jpg';
req.files.add(
http.MultipartFile.fromBytes(
'photo',
photoBytes,
filename: filename,
contentType: _imageMediaType(filename, photoBytes),
),
);
final streamed = await req.send();
response = await http.Response.fromStream(streamed);
} else {
response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$id'),
headers: await _headers(),
body: jsonEncode(body),
);
}
final response = await http.patch(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
headers: await _headers(),
body: jsonEncode(body),
);
if (response.statusCode != 200) {
throw Exception(
_extractErrorMessage(response.body, 'Erreur mise à jour enfant'),
);
throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant'));
}
final enfant = EnfantAdminModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
@@ -686,155 +645,6 @@ class UserService {
return fallback;
}
/// Ajout dun co-parent sur foyer mono-parent (staff, #135).
/// `POST /parents/:pivotUserId/co-parent` — succès 201.
static Future<Map<String, dynamic>> addCoParent(
String pivotUserId, {
required Map<String, dynamic> body,
}) async {
final id = pivotUserId.trim();
if (id.isEmpty) {
throw Exception('Identifiant du parent pivot manquant.');
}
final http.Response response;
try {
response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$id/co-parent'),
headers: await _headers(),
body: jsonEncode(body),
);
} on http.ClientException {
throw Exception(
'Connexion au serveur impossible. Vérifiez votre réseau puis réessayez.',
);
}
if (response.statusCode == 200 || response.statusCode == 201) {
if (response.body.trim().isEmpty) return <String, dynamic>{};
try {
final decoded = jsonDecode(response.body);
if (decoded is Map) {
return Map<String, dynamic>.from(decoded);
}
} catch (_) {}
return <String, dynamic>{};
}
final message = _extractErrorMessage(
response.body,
'Erreur ajout co-parent',
);
if (response.statusCode == 409) {
throw Exception(
message.isNotEmpty ? message : 'Conflit : e-mail déjà utilisé.',
);
}
if (response.statusCode == 400) {
throw Exception(
message.isNotEmpty ? message : 'Données invalides (400).',
);
}
throw Exception(message);
}
/// Création dossier famille actif côté staff (#129).
/// `POST /parents/dossier` — body aligné sur register parent complet.
/// Succès 201 : dossier actif + `numero_dossier` (mail MDP côté serveur, par parent créé).
/// Ne pas utiliser `POST /auth/register/parent` (public / en_attente).
static Future<Map<String, dynamic>> createParentDossier(
Map<String, dynamic> body,
) async {
final http.Response response;
try {
response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parentsDossier}'),
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 famille',
);
if (response.statusCode == 409) {
throw Exception(
message.isNotEmpty ? message : 'Conflit : e-mail déjà utilisé.',
);
}
if (response.statusCode == 400) {
throw Exception(
message.isNotEmpty ? message : 'Données invalides (400).',
);
}
throw Exception(message);
}
/// Création dossier 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
static Future<List<AssistanteMaternelleModel>>
getAssistantesMaternelles() async {
+9 -37
View File
@@ -49,21 +49,12 @@ String nirToRaw(String normalized) {
return s;
}
/// 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).
/// Formate pour affichage : 1 12 34 56 789 012 - 34 ou 1 12 34 2A 789 012 - 34 (Corse).
String formatNir(String raw) {
final r = nirToRaw(raw).toUpperCase();
if (r.isEmpty) return '';
final buf = StringBuffer();
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();
final r = nirToRaw(raw);
if (r.length < 15) return r;
// Même structure pour tous : sexe + année + mois + département + commune + ordre-clé.
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)}';
}
/// Aligné sur le backend (NIR côté API / nir.util.ts) : sexe 13, département 2A ou 2B pour la Corse.
@@ -101,39 +92,20 @@ String? validateNir(String? value) {
return null;
}
/// 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.
/// Formateur de saisie : affiche le NIR formaté (1 12 34 56 789 012 - 34) et limite à 15 caractères utiles.
class NirInputFormatter extends TextInputFormatter {
const NirInputFormatter();
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final raw = normalizeNir(newValue.text);
if (raw.isEmpty) {
return const TextEditingValue(
text: '',
selection: TextSelection.collapsed(offset: 0),
);
}
if (raw.isEmpty) return newValue;
final formatted = formatNir(raw);
final offset = formatted.length;
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
selection: TextSelection.collapsed(offset: offset),
);
}
}
@@ -1,92 +0,0 @@
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,
),
),
],
),
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -1277,16 +1277,18 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
return;
}
// Édition orphelin (#157/#158) : un seul attach — le back propage au foyer.
// Édition orphelin (#157) : rattache immédiatement au foyer.
final enfantId = widget.enfant?.id;
if (enfantId == null || enfantId.isEmpty) return;
setState(() => _saving = true);
try {
await UserService.attachEnfantToParent(
parentUserId: selected.pivotParentUserId,
enfantId: enfantId,
);
for (final parentId in selected.parentUserIds) {
await UserService.attachEnfantToParent(
parentUserId: parentId,
enfantId: enfantId,
);
}
final refreshed = await UserService.getEnfant(enfantId);
if (!mounted) return;
setState(() {
@@ -283,7 +283,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
builder: (ctx) => AlertDialog(
title: const Text('Détacher l\'enfant'),
content: Text(
'Retirer ${child.fullName} du foyer (tous les responsables) ?',
'Retirer ${child.fullName} de la fiche de ce parent ?',
),
actions: [
TextButton(
@@ -308,9 +308,8 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
if (!mounted) return;
await _reloadChildren();
if (!mounted) return;
widget.onSaved?.call();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enfant détaché du foyer')),
const SnackBar(content: Text('Enfant détaché')),
);
} catch (e) {
if (!mounted) return;
@@ -337,9 +336,8 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
if (!mounted) return;
await _reloadChildren();
if (!mounted) return;
widget.onSaved?.call();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enfant rattaché au foyer')),
const SnackBar(content: Text('Enfant rattaché')),
);
} catch (e) {
if (!mounted) return;
@@ -12,10 +12,6 @@ class AdminUserCard extends StatefulWidget {
final Color? backgroundColor;
final Color? titleColor;
final Color? infoColor;
/// Fond du cercle avatar / icône (défaut lavande admin).
final Color? avatarBackgroundColor;
/// Couleur de licône fallback (défaut violet admin).
final Color? avatarIconColor;
final String? vigilanceTooltip;
final VoidCallback? onCardTap;
final EdgeInsetsGeometry? margin;
@@ -32,8 +28,6 @@ class AdminUserCard extends StatefulWidget {
this.backgroundColor,
this.titleColor,
this.infoColor,
this.avatarBackgroundColor,
this.avatarIconColor,
this.vigilanceTooltip,
this.onCardTap,
this.margin,
@@ -165,8 +159,8 @@ class _AdminUserCardState extends State<AdminUserCard> {
Widget _buildAvatar(String url) {
const size = 28.0;
final bg = widget.avatarBackgroundColor ?? const Color(0xFFEDE5FA);
final iconColor = widget.avatarIconColor ?? const Color(0xFF6B3FA0);
const bg = Color(0xFFEDE5FA);
const iconColor = Color(0xFF6B3FA0);
if (url.isEmpty) {
return CircleAvatar(
@@ -1,59 +1,7 @@
import 'package:flutter/material.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';
/// 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 * 1.25 + sectionTitleGapBelow;
/// Libellé : marge au-dessus de [fieldLabelFontSize] (métriques police).
static double get labeledRowHeight =>
fieldLabelFontSize * 1.25 +
fieldLabelGapBelow +
fieldHeight +
rowGapBelow;
/// Corps modale AM / famille : padding wizard + titre + [rows] lignes + nav.
static double shellBodyHeightForRows(int rows) =>
20 * 2 + // padding wizard
4 + // espace haut
sectionTitleBlockHeight +
rows * labeledRowHeight +
24 + // avant nav
48; // boutons (+ marge anti-overflow)
}
/// 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.
/// [rowFlex] : flex par index de ligne (optionnel). Ex. {3: [2, 5]} = 4e ligne : code postal étroit (2), ville large (5).
@@ -68,16 +16,12 @@ class ValidationDetailSection extends StatelessWidget {
/// Flex par ligne (index de ligne -> [flex1, flex2, ...]). Ex. {3: [2, 5]} pour Code postal | Ville.
final Map<int, List<int>>? rowFlex;
/// Remplit la hauteur disponible (wizard AM étapes 12).
final bool expandVertically;
const ValidationDetailSection({
super.key,
this.title,
required this.fields,
this.rowLayout,
this.rowFlex,
this.expandVertically = false,
});
@override
@@ -86,7 +30,6 @@ class ValidationDetailSection extends StatelessWidget {
title: title,
rowLayout: rowLayout,
rowFlex: rowFlex,
expandVertically: expandVertically,
fields: fields
.map(
(f) => ValidationLabeledField(
@@ -106,8 +49,6 @@ class ValidationFormGrid extends StatelessWidget {
final List<int>? rowLayout;
final Map<int, List<int>>? rowFlex;
final bool compact;
/// Répartit la hauteur dispo entre les lignes (remplit le blanc sans scroll).
final bool expandVertically;
const ValidationFormGrid({
super.key,
@@ -116,7 +57,6 @@ class ValidationFormGrid extends StatelessWidget {
this.rowLayout,
this.rowFlex,
this.compact = false,
this.expandVertically = false,
});
@override
@@ -124,7 +64,7 @@ class ValidationFormGrid extends StatelessWidget {
final layout = rowLayout ?? List.filled(fields.length, 1);
int index = 0;
int rowIndex = 0;
final rowWidgets = <Widget>[];
final rows = <Widget>[];
for (final count in layout) {
if (index >= fields.length) break;
final rowFields = fields.skip(index).take(count).toList();
@@ -132,74 +72,48 @@ class ValidationFormGrid extends StatelessWidget {
if (rowFields.isEmpty) continue;
final flexForRow = rowFlex?[rowIndex];
rowIndex++;
final labeled = rowFields
.map(
(f) => ValidationLabeledField(
label: f.label,
field: f.field,
expand: expandVertically,
labelTrailing: f.labelTrailing,
),
)
.toList();
Widget row;
if (count == 1) {
row = labeled.first;
rows.add(Padding(
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
child: rowFields.first,
));
} else {
row = Row(
crossAxisAlignment: expandVertically
? CrossAxisAlignment.stretch
: CrossAxisAlignment.start,
children: [
for (int i = 0; i < labeled.length; i++) ...[
if (i > 0) SizedBox(width: compact ? 12 : 16),
Expanded(
flex: (flexForRow != null && i < flexForRow.length)
? flexForRow[i]
: 1,
child: labeled[i],
),
rows.add(Padding(
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (int i = 0; i < rowFields.length; i++) ...[
if (i > 0) SizedBox(width: compact ? 12 : 16),
Expanded(
flex: (flexForRow != null && i < flexForRow.length)
? flexForRow[i]
: 1,
child: rowFields[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;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: expandVertically ? MainAxisSize.max : MainAxisSize.min,
mainAxisSize: MainAxisSize.min,
children: [
if (showTitle) ...[
Text(
title!.trim(),
style: TextStyle(
fontSize: compact
? ValidationFormMetrics.sectionTitleFontSize - 1
: ValidationFormMetrics.sectionTitleFontSize,
fontSize: compact ? 15 : 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
SizedBox(
height: compact
? 8
: ValidationFormMetrics.sectionTitleGapBelow,
),
SizedBox(height: compact ? 8 : 12),
],
...rowWidgets,
...rows,
],
);
}
@@ -216,8 +130,8 @@ class ValidationFieldDecoration {
fillColor: Colors.grey.shade50,
hintText: hint,
contentPadding: EdgeInsets.symmetric(
horizontal: compact ? 10 : ValidationFormMetrics.fieldContentPaddingH,
vertical: compact ? 7 : ValidationFormMetrics.fieldContentPaddingV,
horizontal: compact ? 10 : 12,
vertical: compact ? 7 : 10,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
@@ -266,40 +180,29 @@ class ValidationFieldDecoration {
class ValidationLabeledField extends StatelessWidget {
final String label;
final Widget field;
final bool expand;
/// Widget aligné à droite sur la ligne du libellé (ex. switch « Même adresse »).
final Widget? labelTrailing;
const ValidationLabeledField({
super.key,
required this.label,
required this.field,
this.expand = false,
this.labelTrailing,
});
@override
Widget build(BuildContext context) {
final labelStyle = TextStyle(
fontSize: ValidationFormMetrics.fieldLabelFontSize,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min,
mainAxisSize: MainAxisSize.min,
children: [
if (labelTrailing == null)
Text(label, style: labelStyle)
else
Row(
children: [
Expanded(child: Text(label, style: labelStyle)),
labelTrailing!,
],
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
),
SizedBox(height: ValidationFormMetrics.fieldLabelGapBelow),
if (expand) Expanded(child: field) else field,
),
const SizedBox(height: 4),
field,
],
);
}
@@ -313,7 +216,6 @@ class ValidationEditableField extends StatelessWidget {
final String? hintText;
final int maxLines;
final bool compact;
final bool enabled;
const ValidationEditableField({
super.key,
@@ -323,7 +225,6 @@ class ValidationEditableField extends StatelessWidget {
this.hintText,
this.maxLines = 1,
this.compact = false,
this.enabled = true,
});
static const double _compactFieldHeight = 34;
@@ -355,7 +256,6 @@ class ValidationEditableField extends StatelessWidget {
if (maxLines > 1) {
return TextField(
controller: controller,
enabled: enabled,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
maxLines: maxLines,
@@ -364,17 +264,13 @@ class ValidationEditableField extends StatelessWidget {
);
}
if (!compact) {
return _validationFieldFillHeight(
TextField(
controller: controller,
enabled: enabled,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
maxLines: 1,
textAlignVertical: TextAlignVertical.center,
style: ValidationFormMetrics.fieldTextStyle,
decoration: ValidationFieldDecoration.input(hint: hintText),
),
return TextField(
controller: controller,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
maxLines: 1,
style: const TextStyle(color: Colors.black87, fontSize: 14),
decoration: ValidationFieldDecoration.input(hint: hintText),
);
}
return SizedBox(
@@ -383,7 +279,6 @@ class ValidationEditableField extends StatelessWidget {
decoration: _compactDecoration(),
child: TextField(
controller: controller,
enabled: enabled,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
maxLines: 1,
@@ -400,352 +295,6 @@ class ValidationEditableField extends StatelessWidget {
}
}
/// Hauteur TF fixe ; en grille [expandVertically], étire jusqu’à la hauteur dispo.
Widget _validationFieldFillHeight(Widget field) {
return LayoutBuilder(
builder: (context, c) {
final h = (c.hasBoundedHeight && c.maxHeight.isFinite)
? c.maxHeight
: ValidationFormMetrics.fieldHeight;
return SizedBox(
height: h,
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;
final bool enabled;
const ValidationPostalCodeField({
super.key,
required this.controller,
this.hintText,
this.allowEmpty = false,
this.enabled = true,
});
@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,
enabled: widget.enabled,
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]).
class ValidationEditableSection extends StatelessWidget {
final List<ValidationLabeledField> fields;
@@ -819,19 +368,16 @@ class _ValidationReadOnlyFieldState extends State<ValidationReadOnlyField> {
@override
Widget build(BuildContext context) {
if (!widget.compact && widget.maxLines == 1) {
return _validationFieldFillHeight(
TextField(
controller: _controller,
readOnly: true,
enableInteractiveSelection: false,
textAlignVertical: TextAlignVertical.center,
style: TextStyle(
color: widget.error ? Colors.red.shade800 : Colors.black87,
fontSize: ValidationFormMetrics.fieldTextFontSize,
fontWeight: widget.error ? FontWeight.w600 : null,
),
decoration: ValidationFieldDecoration.readOnly(error: widget.error),
return TextField(
controller: _controller,
readOnly: true,
enableInteractiveSelection: false,
style: TextStyle(
color: widget.error ? Colors.red.shade800 : Colors.black87,
fontSize: 14,
fontWeight: widget.error ? FontWeight.w600 : null,
),
decoration: ValidationFieldDecoration.readOnly(error: widget.error),
);
}
+16 -41
View File
@@ -8,8 +8,6 @@ class DashboardUserManagementSubBar extends StatelessWidget {
final ValueChanged<int> onSubTabChange;
final TextEditingController searchController;
final String searchHint;
/// Infobulle au survol de la barre de recherche (ex. critères de recherche).
final String? searchTooltip;
final Widget? filterControl;
final VoidCallback? onAddPressed;
final String addLabel;
@@ -24,16 +22,12 @@ class DashboardUserManagementSubBar extends StatelessWidget {
'Administrateurs',
];
/// Aligné sur la taille des libellés donglets.
static const double _searchFontSize = 13;
const DashboardUserManagementSubBar({
Key? key,
required this.selectedSubIndex,
required this.onSubTabChange,
required this.searchController,
required this.searchHint,
this.searchTooltip,
this.filterControl,
this.onAddPressed,
this.addLabel = '+ Ajouter',
@@ -60,7 +54,22 @@ class DashboardUserManagementSubBar extends StatelessWidget {
_buildSubNavItem(context, labels[i], i),
],
const SizedBox(width: 36),
_buildSearchField(),
_pillField(
width: 320,
child: TextField(
controller: searchController,
decoration: InputDecoration(
hintText: searchHint,
prefixIcon: const Icon(Icons.search, size: 18),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
),
),
),
if (filterControl != null) ...[
const SizedBox(width: 12),
_pillField(width: 150, child: filterControl!),
@@ -72,40 +81,6 @@ class DashboardUserManagementSubBar extends StatelessWidget {
);
}
Widget _buildSearchField() {
final field = _pillField(
width: 320,
child: TextField(
controller: searchController,
style: const TextStyle(fontSize: _searchFontSize),
decoration: InputDecoration(
hintText: searchHint,
hintStyle: TextStyle(
fontSize: _searchFontSize,
fontStyle: FontStyle.normal,
fontWeight: FontWeight.normal,
color: Colors.black45,
),
prefixIcon: const Icon(Icons.search, size: 18),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
),
),
);
final tip = (searchTooltip ?? '').trim();
if (tip.isEmpty) return field;
return Tooltip(
message: tip,
preferBelow: false,
waitDuration: const Duration(milliseconds: 400),
child: field,
);
}
Widget _pillField({required double width, required Widget child}) {
return Container(
width: width,
@@ -1,54 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
/// Carte dossier unifiée (#153) — fond neutre, accent couleur sur licône.
class DossierListCard extends StatelessWidget {
final String numeroDossier;
final String namesLine;
final bool isFamille;
final VoidCallback onOpen;
/// Photo AM (si absente → icône fallback).
final String? photoUrl;
/// Lavande — Famille / Parents.
static const Color familleAccent = Color(0xFFB289C9);
/// Menthe logo — AM.
static const Color amAccent = Color(0xFF5A9D94);
const DossierListCard({
super.key,
required this.numeroDossier,
required this.namesLine,
required this.isFamille,
required this.onOpen,
this.photoUrl,
});
@override
Widget build(BuildContext context) {
final accent = isFamille ? familleAccent : amAccent;
final num = numeroDossier.trim().isEmpty ? '' : numeroDossier.trim();
final names = namesLine.trim();
final avatar = (photoUrl ?? '').trim();
return AdminUserCard(
title: num,
subtitleLines: names.isEmpty ? const [] : [names],
avatarUrl: !isFamille && avatar.isNotEmpty ? avatar : null,
fallbackIcon:
isFamille ? Icons.family_restroom_outlined : Icons.face,
// N° = titre neutre (comme Parents / AM) ; accent = icône seule.
avatarIconColor: accent,
infoColor: Colors.black87,
onCardTap: onOpen,
actions: [
IconButton(
tooltip: 'Ouvrir',
icon: Icon(Icons.open_in_new, size: 20, color: accent),
onPressed: onOpen,
),
],
);
}
}
@@ -1,203 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_list_item.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/dossier_list_card.dart';
import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart';
import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart';
/// Onglet permanent « Dossiers » (#153) : pending en haut + liste unifiée en bas.
class DossiersManagementWidget extends StatefulWidget {
final String searchQuery;
final VoidCallback? onRefresh;
const DossiersManagementWidget({
super.key,
this.searchQuery = '',
this.onRefresh,
});
@override
State<DossiersManagementWidget> createState() =>
_DossiersManagementWidgetState();
}
class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
bool _loading = true;
String? _error;
List<DossierListItem> _all = [];
Set<String> _pendingNumeros = {};
int _pendingRefreshTick = 0;
@override
void initState() {
super.initState();
_loadAll();
}
Future<void> _loadAll() async {
setState(() {
_loading = true;
_error = null;
});
try {
final parents = await UserService.getParents();
final ams = await UserService.getAssistantesMaternelles();
if (!mounted) return;
final items = <DossierListItem>[
...DossierListItem.fromParents(parents),
...DossierListItem.fromAssistantes(ams),
];
items.sort((a, b) {
final byNum = a.numeroDossier.compareTo(b.numeroDossier);
if (byNum != 0) return byNum;
return a.typeLabel.compareTo(b.typeLabel);
});
setState(() {
_all = items;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur inconnue';
_loading = false;
});
}
}
Future<void> _refreshEverything() async {
setState(() => _pendingRefreshTick++);
await _loadAll();
widget.onRefresh?.call();
}
void _openDossier(String numeroDossier) {
final num = numeroDossier.trim();
if (num.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Numéro de dossier manquant.')),
);
return;
}
showDialog<void>(
context: context,
builder: (context) => ValidationDossierModal(
numeroDossier: num,
openAsEdit: true,
onClose: () => Navigator.of(context).pop(),
onSuccess: () {
Navigator.of(context).pop();
_refreshEverything();
},
),
);
}
@override
Widget build(BuildContext context) {
final query = widget.searchQuery;
// Pending uniquement en haut — exclus de « Tous les dossiers ».
final filtered = _all
.where((d) => !_pendingNumeros.contains(d.numeroDossier))
.where((d) => (d.statut ?? '').toLowerCase() != 'en_attente')
.where((d) => d.matchesQuery(query))
.toList(growable: false);
return RefreshIndicator(
onRefresh: _refreshEverything,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: PendingValidationWidget(
key: ValueKey('pending-$_pendingRefreshTick'),
searchQuery: query,
compactWhenEmpty: true,
onPendingNumerosChanged: (nums) {
if (!mounted) return;
setState(() => _pendingNumeros = nums);
},
onRefresh: () {
_loadAll();
widget.onRefresh?.call();
},
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
'Tous les dossiers',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
),
),
if (_loading)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (_error != null && _error!.isNotEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!, style: const TextStyle(color: Colors.red)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadAll,
child: const Text('Réessayer'),
),
],
),
),
),
)
else if (filtered.isEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 32),
child: Text(
query.trim().isEmpty
? 'Aucun dossier pour le moment.\n'
'Pour créer un dossier → onglet Parents (+ Parents) '
'ou Assistantes maternelles (+ Asmat).'
: 'Aucun dossier ne correspond à la recherche.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600, height: 1.4),
),
),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final item = filtered[index];
return DossierListCard(
numeroDossier: item.numeroDossier,
namesLine: item.namesLine,
isFamille: item.isFamille,
photoUrl: item.photoUrl,
onOpen: () => _openDossier(item.numeroDossier),
);
},
childCount: filtered.length,
),
),
),
],
),
);
}
}
@@ -1,93 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart';
/// Modale de création dossier famille (#129) — même shell que [AmDossierCreateModal].
class ParentDossierCreateModal extends StatefulWidget {
final VoidCallback onClose;
final VoidCallback? onSuccess;
const ParentDossierCreateModal({
super.key,
required this.onClose,
this.onSuccess,
});
@override
State<ParentDossierCreateModal> createState() =>
_ParentDossierCreateModalState();
}
class _ParentDossierCreateModalState extends State<ParentDossierCreateModal> {
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 [ParentDossierWizard.shellBodyHeight]).
static double get _bodyHeight => ParentDossierWizard.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(
'Nouveau dossier famille',
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: ParentDossierWizard.create(
onClose: widget.onClose,
onSuccess: _onSuccess,
onStepChanged: _onStepChanged,
),
),
],
),
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,32 +1,19 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_list_item.dart';
import 'package:intl/intl.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/models/pending_family.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/dossier_list_card.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart';
/// Section « dossiers à valider » (liste unifiée AM + familles). Ticket #107 / #153.
/// Onglet « À valider » : deux listes (AM en attente, familles en attente). Ticket #107.
class PendingValidationWidget extends StatefulWidget {
final VoidCallback? onRefresh;
/// Filtre client (n°, nom, email) — onglet Dossiers (#153).
final String searchQuery;
/// Si true et liste vide : message court (pas de grand vide centré).
final bool compactWhenEmpty;
/// Numéros des dossiers pending (pour exclure de « Tous les dossiers »).
final ValueChanged<Set<String>>? onPendingNumerosChanged;
const PendingValidationWidget({
super.key,
this.onRefresh,
this.searchQuery = '',
this.compactWhenEmpty = false,
this.onPendingNumerosChanged,
});
const PendingValidationWidget({super.key, this.onRefresh});
@override
State<PendingValidationWidget> createState() =>
_PendingValidationWidgetState();
State<PendingValidationWidget> createState() => _PendingValidationWidgetState();
}
class _PendingValidationWidgetState extends State<PendingValidationWidget> {
@@ -34,8 +21,6 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
String? _error;
List<AppUser> _pendingAM = [];
List<PendingFamily> _pendingFamilies = [];
/// Noms enrichis via GET /dossiers/:numero (libelle API = noms seuls).
final Map<String, String> _familyNamesByNumero = {};
@override
void initState() {
@@ -49,77 +34,24 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
_error = null;
});
try {
final am =
await UserService.getPendingUsers(role: 'assistante_maternelle');
final am = await UserService.getPendingUsers(role: 'assistante_maternelle');
final families = await UserService.getPendingFamilies();
final namesByNumero = await _enrichFamilyNames(families);
if (!mounted) return;
setState(() {
_pendingAM = am;
_pendingFamilies = families;
_familyNamesByNumero
..clear()
..addAll(namesByNumero);
_isLoading = false;
});
_emitPendingNumeros();
} catch (e) {
if (!mounted) return;
setState(() {
_error = e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur inconnue';
_error = e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur inconnue';
_isLoading = false;
});
widget.onPendingNumerosChanged?.call(const {});
}
}
/// Complète `NOM Prénom` via le détail dossier (sans changer le back).
Future<Map<String, String>> _enrichFamilyNames(
List<PendingFamily> families,
) async {
final out = <String, String>{};
await Future.wait(families.map((f) async {
final num = (f.numeroDossier ?? '').trim();
if (num.isEmpty) return;
try {
final dossier = await UserService.getDossier(num);
if (!dossier.isFamily) return;
final labels = <String>[];
final seen = <String>{};
for (final p in dossier.asFamily.parents) {
final id = p.id.trim();
if (id.isNotEmpty && !seen.add(id)) continue;
final label = formatDossierPersonLabel(
nom: p.nom,
prenom: p.prenom,
email: p.email,
);
if (label.isNotEmpty) labels.add(label);
}
if (labels.isNotEmpty) out[num] = labels.join(' - ');
} catch (_) {
// Repli libellé API ci-dessous.
}
}));
return out;
}
void _emitPendingNumeros() {
final nums = <String>{};
for (final u in _pendingAM) {
final n = (u.numeroDossier ?? '').trim();
if (n.isNotEmpty) nums.add(n);
}
for (final f in _pendingFamilies) {
final n = (f.numeroDossier ?? '').trim();
if (n.isNotEmpty) nums.add(n);
}
widget.onPendingNumerosChanged?.call(nums);
}
void _onOpenValidation({String? numeroDossier}) {
void _onOpenValidation({String? type, String? id, String? numeroDossier}) {
final num = numeroDossier?.trim();
if (num == null || num.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -141,48 +73,9 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
);
}
bool _matchesQuery(String haystack) {
final q = widget.searchQuery.trim().toLowerCase();
if (q.isEmpty) return true;
return haystack.toLowerCase().contains(q);
}
List<AppUser> get _filteredAM {
return _pendingAM.where((u) {
final bits = [
u.numeroDossier ?? '',
u.fullName,
u.email,
u.nom ?? '',
u.prenom ?? '',
].join(' ');
return _matchesQuery(bits);
}).toList();
}
List<PendingFamily> get _filteredFamilies {
return _pendingFamilies.where((f) {
final num = (f.numeroDossier ?? '').trim();
final enriched = _familyNamesByNumero[num] ?? '';
final bits = [
f.numeroDossier ?? '',
f.libelle,
enriched,
f.emails.join(' '),
].join(' ');
return _matchesQuery(bits);
}).toList();
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
if (widget.compactWhenEmpty) {
return const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
);
}
return const Center(child: CircularProgressIndicator());
}
if (_error != null && _error!.isNotEmpty) {
@@ -204,32 +97,14 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
);
}
final pendingAM = _filteredAM;
final pendingFamilies = _filteredFamilies;
final cards = <Widget>[
...pendingAM.map(_buildAMCard),
...pendingFamilies.map(_buildFamilyCard),
];
if (cards.isEmpty) {
if (widget.compactWhenEmpty) {
final searching = widget.searchQuery.trim().isNotEmpty;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
searching
? 'Aucun dossier en attente ne correspond à la recherche.'
: 'Aucun dossier en attente.',
style: TextStyle(color: Colors.grey.shade600, fontSize: 13),
),
);
}
final hasAM = _pendingAM.isNotEmpty;
final hasFamilies = _pendingFamilies.isNotEmpty;
if (!hasAM && !hasFamilies) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle_outline,
size: 64, color: Colors.grey.shade400),
Icon(Icons.check_circle_outline, size: 64, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(
'Aucun dossier en attente de validation',
@@ -242,28 +117,6 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
);
}
final list = Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Dossiers à valider',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 8),
...cards,
],
);
if (widget.compactWhenEmpty) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: list,
);
}
return RefreshIndicator(
onRefresh: () async {
await _load();
@@ -272,40 +125,275 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: list,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (hasAM) ...[
_sectionTitle('Assistantes maternelles en attente'),
const SizedBox(height: 8),
..._pendingAM.map((u) => _buildAMCard(u)),
const SizedBox(height: 24),
],
if (hasFamilies) ...[
_sectionTitle('Familles en attente'),
const SizedBox(height: 8),
..._pendingFamilies.map((f) => _buildFamilyCard(f)),
],
],
),
),
);
}
String _amNamesLine(AppUser user) {
return formatDossierPersonLabel(
nom: user.nom,
prenom: user.prenom,
email: user.email,
Widget _sectionTitle(String title) {
return Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Colors.black87,
),
);
}
/// Sous-titre AM : `email - date • tél. • CP ville` (plan affichage lignes À valider).
String _amSubtitleLine(AppUser user) {
final email = user.email.trim();
final bits = <String>[];
bits.add(DateFormat('dd/MM/yyyy').format(user.createdAt.toLocal()));
final tel = user.telephone?.trim();
if (tel != null && tel.isNotEmpty) {
bits.add(formatPhoneForDisplay(tel));
}
final cp = user.codePostal?.trim();
final ville = user.ville?.trim();
final loc = [if (cp != null && cp.isNotEmpty) cp, if (ville != null && ville.isNotEmpty) ville]
.join(' ')
.trim();
if (loc.isNotEmpty) bits.add(loc);
final infos = bits.join('');
if (email.isEmpty) return infos;
return '$email - $infos';
}
Widget _buildAMCard(AppUser user) {
return DossierListCard(
numeroDossier: user.numeroDossier ?? '',
namesLine: _amNamesLine(user),
isFamille: false,
photoUrl: user.photoUrl,
onOpen: () => _onOpenValidation(numeroDossier: user.numeroDossier),
final numDossier = user.numeroDossier ?? '';
final nameBold =
user.fullName.isNotEmpty ? user.fullName : (user.email.isNotEmpty ? user.email : '');
return _PendingValidationRow(
icon: Icons.person_outline,
title: Text.rich(
TextSpan(
style: const TextStyle(fontSize: 14, color: Colors.black87),
children: [
TextSpan(
text: nameBold,
style: const TextStyle(fontWeight: FontWeight.w700),
),
TextSpan(
text: ' - $numDossier',
style: const TextStyle(fontWeight: FontWeight.w400),
),
],
),
),
subtitle: _amSubtitleLine(user),
subtitleStyle: TextStyle(
fontSize: 12,
fontStyle: FontStyle.italic,
color: Colors.grey.shade600,
),
onOpen: () => _onOpenValidation(
type: 'AM',
id: user.id,
numeroDossier: user.numeroDossier,
),
);
}
/// `email, tél., localisation` par parent, puis `date soumission`, puis `nb enfants`.
String _familyParentSegment(PendingParentLine p) {
final parts = <String>[];
final e = p.email?.trim();
if (e != null && e.isNotEmpty) parts.add(e);
final t = p.telephone?.trim();
if (t != null && t.isNotEmpty) parts.add(formatPhoneForDisplay(t));
final cp = p.codePostal?.trim();
final v = p.ville?.trim();
final loc = [if (cp != null && cp.isNotEmpty) cp, if (v != null && v.isNotEmpty) v]
.join(' ')
.trim();
if (loc.isNotEmpty) parts.add(loc);
return parts.join(', ');
}
String _familySubtitleLine(PendingFamily family) {
final blocks = family.parentLines
.map(_familyParentSegment)
.where((s) => s.isNotEmpty)
.join(' - ');
final tail = <String>[];
final date = family.dateSoumission;
if (date != null) {
tail.add(DateFormat('dd/MM/yyyy').format(date.toLocal()));
}
if (family.nombreEnfants > 0) {
tail.add(
family.nombreEnfants > 1
? '${family.nombreEnfants} enfants'
: '1 enfant',
);
}
final right = tail.join(' - ');
if (blocks.isEmpty && right.isEmpty) return '';
if (blocks.isEmpty) return right;
if (right.isEmpty) return blocks;
return '$blocks - $right';
}
Widget _buildFamilyCard(PendingFamily family) {
final num = (family.numeroDossier ?? '').trim();
final enriched = num.isNotEmpty ? _familyNamesByNumero[num] : null;
final names = (enriched != null && enriched.isNotEmpty)
? enriched
: formatDossierFamilyNamesLine(family.libelle);
return DossierListCard(
numeroDossier: family.numeroDossier ?? '',
namesLine: names,
isFamille: true,
onOpen: () => _onOpenValidation(numeroDossier: family.numeroDossier),
final numDossier = family.numeroDossier ?? '';
final nameBold = family.libelle.isNotEmpty ? family.libelle : 'Famille';
return _PendingValidationRow(
icon: Icons.family_restroom_outlined,
title: Text.rich(
TextSpan(
style: const TextStyle(fontSize: 14, color: Colors.black87),
children: [
TextSpan(
text: nameBold,
style: const TextStyle(fontWeight: FontWeight.w700),
),
TextSpan(
text: ' - $numDossier',
style: const TextStyle(fontWeight: FontWeight.w400),
),
],
),
),
subtitle: _familySubtitleLine(family),
subtitleStyle: TextStyle(
fontSize: 12,
fontStyle: FontStyle.italic,
color: Colors.grey.shade600,
),
onOpen: () => _onOpenValidation(
type: 'famille',
id: family.parentIds.isNotEmpty ? family.parentIds.first : null,
numeroDossier: family.numeroDossier,
),
);
}
}
/// Ligne « À valider » : survol comme [AdminUserCard], icône « Ouvrir » visible au hover uniquement.
class _PendingValidationRow extends StatefulWidget {
final IconData icon;
final Widget title;
final String? subtitle;
final TextStyle? subtitleStyle;
final VoidCallback onOpen;
const _PendingValidationRow({
required this.icon,
required this.title,
this.subtitle,
this.subtitleStyle,
required this.onOpen,
});
@override
State<_PendingValidationRow> createState() => _PendingValidationRowState();
}
class _PendingValidationRowState extends State<_PendingValidationRow> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
final subStyle = widget.subtitleStyle ??
TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
);
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
cursor: SystemMouseCursors.click,
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: widget.onOpen,
borderRadius: BorderRadius.circular(10),
hoverColor: const Color(0x149CC5C0),
child: Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: Colors.grey.shade300),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(widget.icon, color: Colors.grey.shade600, size: 28),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
widget.title,
if (widget.subtitle != null &&
widget.subtitle!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
widget.subtitle!,
style: subStyle,
),
],
],
),
),
SizedBox(
width: 52,
child: Center(
child: AnimatedOpacity(
duration: const Duration(milliseconds: 120),
opacity: _isHovered ? 1 : 0,
child: IgnorePointer(
ignoring: !_isHovered,
child: IconButtonTheme(
data: IconButtonThemeData(
style: IconButton.styleFrom(
padding: const EdgeInsets.all(0),
minimumSize: const Size(48, 48),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
child: IconButton(
onPressed: widget.onOpen,
icon: const Icon(Icons.open_in_new),
iconSize: 34,
tooltip: 'Ouvrir',
),
),
),
),
),
),
],
),
),
),
),
),
);
}
}
@@ -1,18 +1,17 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/am_dossier_create_modal.dart';
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
import 'package:p_tits_pas/widgets/admin/dossiers_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/parent_dossier_create_modal.dart';
import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart';
import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart';
class UserManagementPanel extends StatefulWidget {
/// Afficher l'onglet Administrateurs (sinon sans Administrateurs).
/// Afficher l'onglet Administrateurs (sinon 3 onglets : Gestionnaires, Parents, AM).
final bool showAdministrateursTab;
const UserManagementPanel({
@@ -27,21 +26,47 @@ class UserManagementPanel extends StatefulWidget {
class _UserManagementPanelState extends State<UserManagementPanel> {
int _subIndex = 0;
int _gestionnaireRefreshTick = 0;
int _parentRefreshTick = 0;
int _adminRefreshTick = 0;
int _enfantRefreshTick = 0;
int _amRefreshTick = 0;
int _dossiersRefreshTick = 0;
final TextEditingController _searchController = TextEditingController();
final TextEditingController _amCapacityController = TextEditingController();
String? _parentStatus;
String? _enfantStatus;
bool _hasPending = false;
bool _pendingLoading = true;
@override
void initState() {
super.initState();
_searchController.addListener(_onFilterChanged);
_amCapacityController.addListener(_onFilterChanged);
_loadPending();
}
Future<void> _loadPending() async {
try {
final am = await UserService.getPendingUsers(role: 'assistante_maternelle');
final families = await UserService.getPendingFamilies();
if (!mounted) return;
final hasPending = am.isNotEmpty || families.isNotEmpty;
setState(() {
final hadPending = _hasPending;
_hasPending = hasPending;
_pendingLoading = false;
// Si on passe à "plus de dossiers", recaler l'index (onglet À valider disparaît).
if (hadPending && !hasPending) {
_subIndex = (_subIndex > 0 ? _subIndex - 1 : 0).clamp(0, _tabLabels.length - 1);
} else if (!hadPending && hasPending) {
_subIndex = 0; // Afficher l'onglet À valider
}
});
} catch (_) {
if (!mounted) return;
setState(() {
_hasPending = false;
_pendingLoading = false;
});
}
}
@override
@@ -58,19 +83,13 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
setState(() {});
}
/// Ordre #153 : Dossiers | Parents | Enfants | AM | Gestionnaires | (Admin).
List<String> get _tabLabels {
const base = [
'Dossiers',
'Parents',
'Enfants',
'Assistantes maternelles',
'Gestionnaires',
];
if (widget.showAdministrateursTab) {
return [...base, 'Administrateurs'];
}
return base;
const base = ['Parents', 'Enfants', 'Assistantes maternelles', 'Gestionnaires'];
final withAdmin = [...base, 'Administrateurs'];
final list = widget.showAdministrateursTab ? withAdmin : base;
// Onglet « À valider » visible seulement s'il y a des dossiers en attente (ticket #107).
if (!_pendingLoading && _hasPending) return ['À valider', ...list];
return list;
}
void _onSubTabChange(int index) {
@@ -84,35 +103,31 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
});
}
bool get _isDossiersTab => _subIndex == 0;
/// Index du contenu : -1 = À valider, 0 = Parents, 1 = Enfants, 2 = AM, 3 = Gestionnaires, 4 = Admin.
int get _contentIndexOffset => (_hasPending && !_pendingLoading) ? 1 : 0;
String _searchHintForTab() {
switch (_subIndex) {
final contentIndex = _subIndex - _contentIndexOffset;
switch (contentIndex) {
case -1:
return 'À valider (pas de recherche)';
case 0:
return 'Rechercher un dossier';
case 1:
return 'Rechercher un parent...';
case 2:
case 1:
return 'Rechercher un enfant...';
case 3:
case 2:
return 'Rechercher une assistante...';
case 4:
case 3:
return 'Rechercher un gestionnaire...';
case 5:
case 4:
return 'Rechercher un administrateur...';
default:
return 'Rechercher...';
}
}
String? _searchTooltipForTab() {
if (_subIndex != 0) return null;
return 'Recherche possible : n° de dossier, nom, prénom ou e-mail.';
}
Widget? _subBarFilterControl() {
// Parents
if (_subIndex == 1) {
if (_subIndex == _contentIndexOffset + 0) {
return DropdownButtonHideUnderline(
child: DropdownButton<String?>(
value: _parentStatus,
@@ -167,8 +182,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
);
}
// Enfants
if (_subIndex == 2) {
if (_subIndex == _contentIndexOffset + 1) {
return DropdownButtonHideUnderline(
child: DropdownButton<String?>(
value: _enfantStatus,
@@ -223,8 +237,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
);
}
// AM
if (_subIndex == 3) {
if (_subIndex == _contentIndexOffset + 2) {
return TextField(
controller: _amCapacityController,
decoration: const InputDecoration(
@@ -241,36 +254,33 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
}
Widget _buildBody() {
switch (_subIndex) {
final contentIndex = _subIndex - _contentIndexOffset;
if (_hasPending && !_pendingLoading && contentIndex == -1) {
return PendingValidationWidget(onRefresh: _loadPending);
}
switch (contentIndex) {
case 0:
return DossiersManagementWidget(
key: ValueKey('dossiers-$_dossiersRefreshTick'),
searchQuery: _searchController.text,
);
case 1:
return ParentManagementWidget(
key: ValueKey('parents-$_parentRefreshTick'),
searchQuery: _searchController.text,
statusFilter: _parentStatus,
);
case 2:
case 1:
return EnfantManagementWidget(
key: ValueKey('enfants-$_enfantRefreshTick'),
searchQuery: _searchController.text,
statusFilter: _enfantStatus,
);
case 3:
case 2:
return AssistanteMaternelleManagementWidget(
key: ValueKey('ams-$_amRefreshTick'),
searchQuery: _searchController.text,
capacityMin: int.tryParse(_amCapacityController.text),
);
case 4:
case 3:
return GestionnaireManagementWidget(
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
searchQuery: _searchController.text,
);
case 5:
case 4:
return AdminManagementWidget(
key: ValueKey('admins-$_adminRefreshTick'),
searchQuery: _searchController.text,
@@ -283,6 +293,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
@override
Widget build(BuildContext context) {
final labels = _tabLabels;
final isAValiderTab = _hasPending && !_pendingLoading && _subIndex == 0;
return Column(
children: [
DashboardUserManagementSubBar(
@@ -290,10 +301,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
onSubTabChange: _onSubTabChange,
searchController: _searchController,
searchHint: _searchHintForTab(),
searchTooltip: _searchTooltipForTab(),
filterControl: _subBarFilterControl(),
// Pas de « Créer » sur longlet Dossiers (#153).
onAddPressed: _isDossiersTab ? null : _handleAddPressed,
onAddPressed: isAValiderTab ? null : _handleAddPressed,
addLabel: 'Ajouter',
subTabCount: labels.length,
tabLabels: labels,
@@ -304,29 +313,9 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
}
Future<void> _handleAddPressed() async {
// 1 Parents, 2 Enfants, 3 AM, 4 Gestionnaires, 5 Admin
if (_subIndex == 1) {
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return ParentDossierCreateModal(
onClose: () => Navigator.of(dialogContext).pop(),
onSuccess: () {
Navigator.of(dialogContext).pop();
if (!mounted) return;
setState(() {
_parentRefreshTick++;
_dossiersRefreshTick++;
});
},
);
},
);
return;
}
final contentIndex = _subIndex - _contentIndexOffset;
if (_subIndex == 2) {
if (contentIndex == 1) {
await showDialog<void>(
context: context,
barrierDismissible: false,
@@ -342,28 +331,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
return;
}
if (_subIndex == 3) {
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++;
_dossiersRefreshTick++;
});
},
);
},
);
return;
}
if (_subIndex == 4) {
if (contentIndex == 3) {
final created = await showDialog<bool>(
context: context,
barrierDismissible: false,
@@ -381,7 +349,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
return;
}
if (_subIndex == 5) {
if (contentIndex == 4) {
final created = await showDialog<bool>(
context: context,
barrierDismissible: false,
@@ -399,6 +367,16 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
_adminRefreshTick++;
});
}
return;
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'La création parent / enfant / AM sera disponible avec le ticket #129.',
),
),
);
}
}
@@ -1,9 +1,20 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart';
import 'package:p_tits_pas/utils/date_display_utils.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';
/// Wrapper historique (#107) — délègue à [AmDossierWizard.review].
class ValidationAmWizard extends StatelessWidget {
/// Wizard de validation dossier AM : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107.
class ValidationAmWizard extends StatefulWidget {
final DossierAM dossier;
final VoidCallback onClose;
final VoidCallback onSuccess;
@@ -18,12 +29,479 @@ class ValidationAmWizard extends StatelessWidget {
});
@override
Widget build(BuildContext context) {
return AmDossierWizard.review(
dossier: dossier,
onClose: onClose,
onSuccess: onSuccess,
onStepChanged: onStepChanged,
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
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() {
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,26 +1,20 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart';
import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart';
import 'package:p_tits_pas/widgets/admin/validation_am_wizard.dart';
import 'package:p_tits_pas/widgets/admin/validation_family_wizard.dart';
/// Modale (dialog) : charge le dossier par numéro puis affiche le wizard AM ou Famille.
/// Ticket #107 / #119 (review), #135 (`openAsEdit`).
/// Modale (dialog) : charge le dossier par numéro puis affiche le wizard AM ou Famille. Ticket #107, #119.
class ValidationDossierModal extends StatefulWidget {
final String numeroDossier;
final VoidCallback onClose;
final VoidCallback? onSuccess;
/// Liste Dossiers actifs → mode edit (#135). Pending reste en review (défaut).
final bool openAsEdit;
const ValidationDossierModal({
super.key,
required this.numeroDossier,
required this.onClose,
this.onSuccess,
this.openAsEdit = false,
});
@override
@@ -82,13 +76,8 @@ class _ValidationDossierModalState extends State<ValidationDossierModal> {
/// Largeur modale = 1,5 × 620.
static const double _modalWidth = 930; // 620 * 1.5
double get _bodyHeight {
final d = _dossier;
if (d != null && d.isAm) return AmDossierWizard.shellBodyHeight;
// Aligné create (#129) / edit (#135) — évite overflow IdentityBlock (8px).
return ParentDossierWizard.shellBodyHeight;
}
// Hauteur uniforme (ajustée +5px pour éviter l'overflow des étapes parents sans scroll).
static const double _bodyHeight = 435;
@override
Widget build(BuildContext context) {
@@ -167,14 +156,6 @@ class _ValidationDossierModalState extends State<ValidationDossierModal> {
}
final d = _dossier!;
if (d.isAm) {
if (widget.openAsEdit) {
return AmDossierWizard.edit(
dossier: d.asAm,
onClose: widget.onClose,
onSuccess: _onSuccess,
onStepChanged: _onStepChanged,
);
}
return ValidationAmWizard(
dossier: d.asAm,
onClose: widget.onClose,
@@ -182,14 +163,6 @@ class _ValidationDossierModalState extends State<ValidationDossierModal> {
onStepChanged: _onStepChanged,
);
}
if (widget.openAsEdit) {
return ParentDossierWizard.edit(
dossier: d.asFamily,
onClose: widget.onClose,
onSuccess: _onSuccess,
onStepChanged: _onStepChanged,
);
}
return ValidationFamilyWizard(
dossier: d.asFamily,
onClose: widget.onClose,
@@ -1,9 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.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/services/user_service.dart';
import 'package:p_tits_pas/services/api/api_config.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';
/// Wrapper historique (#107) — délègue à [ParentDossierWizard.review].
class ValidationFamilyWizard extends StatelessWidget {
/// Wizard de validation dossier famille : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107.
class ValidationFamilyWizard extends StatefulWidget {
final DossierFamille dossier;
final VoidCallback onClose;
final VoidCallback onSuccess;
@@ -17,13 +28,697 @@ class ValidationFamilyWizard extends StatelessWidget {
this.onStepChanged,
});
@override
State<ValidationFamilyWizard> createState() => _ValidationFamilyWizardState();
}
class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
int _step = 0;
bool _showRefusForm = false;
bool _submitting = false;
final ScrollController _enfantsScrollController = ScrollController();
/// Même logique que [ParentRegisterStep3Screen] : masque alpha sur les bords (ShaderMask dstIn).
bool _enfantsIsScrollable = false;
bool _enfantsFadeLeft = false;
bool _enfantsFadeRight = false;
/// Fraction de la largeur du viewport pour le fondu (identique inscription étape 3).
static const double _enfantsFadeExtent = 0.05;
int get _stepCount => 4;
@override
void initState() {
super.initState();
_enfantsScrollController.addListener(_syncEnfantsScrollFades);
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
}
@override
void dispose() {
_enfantsScrollController.removeListener(_syncEnfantsScrollFades);
_enfantsScrollController.dispose();
super.dispose();
}
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
void _syncEnfantsScrollFades() {
if (!mounted) return;
if (!_enfantsScrollController.hasClients) {
if (_enfantsFadeLeft || _enfantsFadeRight || _enfantsIsScrollable) {
setState(() {
_enfantsIsScrollable = false;
_enfantsFadeLeft = false;
_enfantsFadeRight = false;
});
}
return;
}
final p = _enfantsScrollController.position;
final scrollable = p.maxScrollExtent > 0;
final left = scrollable &&
p.pixels > (p.viewportDimension * _enfantsFadeExtent / 2);
final right = scrollable &&
p.pixels <
(p.maxScrollExtent -
(p.viewportDimension * _enfantsFadeExtent / 2));
if (scrollable != _enfantsIsScrollable ||
left != _enfantsFadeLeft ||
right != _enfantsFadeRight) {
setState(() {
_enfantsIsScrollable = scrollable;
_enfantsFadeLeft = left;
_enfantsFadeRight = right;
});
}
}
bool get _isEnAttente => widget.dossier.isEnAttente;
String? get _firstParentId => widget.dossier.parents.isNotEmpty
? widget.dossier.parents.first.id
: null;
static String _v(String? s) =>
(s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini';
/// Date de naissance en jour/mois/année (dd/MM/yyyy).
static String _formatBirthDate(String? s) =>
formatIsoDateFr(s, ifEmpty: 'Non défini');
static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url);
@override
Widget build(BuildContext context) {
return ParentDossierWizard.review(
dossier: dossier,
onClose: onClose,
onSuccess: onSuccess,
onStepChanged: onStepChanged,
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() {
final d = widget.dossier;
switch (_step) {
case 0:
return IdentityBlock.readOnlyFromParentDossier(
d.parents.first,
title: 'Parent principal',
);
case 1:
return _buildParent2Step();
case 2:
return _buildEnfantsStep();
case 3:
return _buildPresentationStep();
default:
return const SizedBox();
}
}
Widget _buildParent2Step() {
if (widget.dossier.parents.length < 2) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text('Un seul parent pour ce dossier.',
style: TextStyle(color: Colors.black87)),
);
}
return IdentityBlock.readOnlyFromParentDossier(
widget.dossier.parents[1],
title: 'Deuxième parent',
);
}
static const double _idPhotoAspectRatio = 35 / 45;
Widget _buildEnfantsStep() {
final enfants = widget.dossier.enfants;
if (enfants.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text('Aucun enfant renseigné.',
style: TextStyle(color: Colors.black87)),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Enfants',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87),
),
const SizedBox(height: 16),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final cardHeight = constraints.maxHeight;
// Carte large : 1/3 photo + 2/3 champs (scroll horizontal si plusieurs enfants).
final cardWidth = (cardHeight * 1.72).clamp(500.0, 700.0);
return NotificationListener<ScrollMetricsNotification>(
onNotification: (_) {
_syncEnfantsScrollFades();
return false;
},
child: ShaderMask(
blendMode: BlendMode.dstIn,
shaderCallback: (Rect bounds) {
final stops = <double>[
0.0,
_enfantsFadeExtent,
1.0 - _enfantsFadeExtent,
1.0,
];
if (!_enfantsIsScrollable) {
return LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: const <Color>[
Colors.black,
Colors.black,
Colors.black,
Colors.black,
],
stops: stops,
).createShader(bounds);
}
final leftMask =
_enfantsFadeLeft ? Colors.transparent : Colors.black;
final rightMask =
_enfantsFadeRight ? Colors.transparent : Colors.black;
return LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: <Color>[
leftMask,
Colors.black,
Colors.black,
rightMask,
],
stops: stops,
).createShader(bounds);
},
child: Listener(
onPointerSignal: (event) {
if (event is PointerScrollEvent &&
_enfantsScrollController.hasClients) {
final offset = _enfantsScrollController.offset +
event.scrollDelta.dy;
_enfantsScrollController.jumpTo(offset.clamp(
_enfantsScrollController.position.minScrollExtent,
_enfantsScrollController.position.maxScrollExtent,
));
}
},
child: ListView.builder(
controller: _enfantsScrollController,
scrollDirection: Axis.horizontal,
itemCount: enfants.length,
itemBuilder: (_, i) => Padding(
padding: EdgeInsets.only(
right: i < enfants.length - 1 ? 16 : 0),
child: SizedBox(
width: cardWidth,
height: cardHeight,
child: _buildEnfantCard(enfants[i]),
),
),
),
),
),
);
},
),
),
],
);
}
/// Fond carte enfant : teintes très pastel ; bordure discrète ; accent léger (barre).
static const Color _enfantCardBoyBg = Color(0xFFF0F7FB);
static const Color _enfantCardBoyBorder = Color(0xFFE3EDF4);
static const Color _enfantCardGirlBg = Color(0xFFFCF5F8);
static const Color _enfantCardGirlBorder = Color(0xFFEAE3E7);
static const double _enfantCardRadius = 12;
static List<BoxShadow> _enfantCardShadows() => [
BoxShadow(
color: Colors.black.withOpacity(0.06),
blurRadius: 14,
offset: const Offset(0, 4),
),
];
static BoxDecoration _enfantCardDecoration(String? gender) {
final g = (gender ?? '').trim().toUpperCase();
if (g == 'H') {
return BoxDecoration(
color: _enfantCardBoyBg,
borderRadius: BorderRadius.circular(_enfantCardRadius),
border: Border.all(color: _enfantCardBoyBorder, width: 1),
boxShadow: _enfantCardShadows(),
);
}
if (g == 'F') {
return BoxDecoration(
color: _enfantCardGirlBg,
borderRadius: BorderRadius.circular(_enfantCardRadius),
border: Border.all(color: _enfantCardGirlBorder, width: 1),
boxShadow: _enfantCardShadows(),
);
}
return BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(_enfantCardRadius),
border: Border.all(color: Colors.grey.shade300),
boxShadow: _enfantCardShadows(),
);
}
/// Carte enfant : prénom pleine largeur, puis ligne photo 1/3 + colonne 2/3 (champs + statut hors TF si besoin).
Widget _buildEnfantCard(EnfantDossier e) {
final photoUrl = _fullPhotoUrl(e.photoUrl);
final columnStatusLabel = _enfantColumnStatusLabel(e);
return ClipRRect(
borderRadius: BorderRadius.circular(_enfantCardRadius),
child: Container(
decoration: _enfantCardDecoration(e.gender),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: _enfantLabeledField('Prénom', _v(e.firstName)),
),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
flex: 1,
child: LayoutBuilder(
builder: (context, c) {
// Même marge gauche que le bloc « Prénom » (12) ; droite / haut / bas 8.
const padL = 12.0;
const padR = 8.0;
const padV = 8.0;
final maxW =
(c.maxWidth - padL - padR).clamp(0.0, double.infinity);
final maxH =
(c.maxHeight - 2 * padV).clamp(0.0, double.infinity);
const ar = _idPhotoAspectRatio;
double ph = maxH;
double pw = ph * ar;
if (pw > maxW) {
pw = maxW;
ph = pw / ar;
}
return Padding(
padding: const EdgeInsets.fromLTRB(padL, padV, padR, padV),
child: Align(
alignment: Alignment.centerLeft,
child: _buildEnfantPhotoSlot(photoUrl, pw, ph),
),
);
},
),
),
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 14, 12),
child: columnStatusLabel == null
? SingleChildScrollView(
child: _buildEnfantInfoFields(e),
)
: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: _buildEnfantInfoFields(e),
),
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Text(
columnStatusLabel,
textAlign: TextAlign.center,
style: GoogleFonts.merienda(
fontSize: 14,
fontStyle: FontStyle.italic,
fontWeight: FontWeight.w600,
color: Colors.grey.shade800,
),
),
),
),
],
),
),
),
],
),
),
],
),
),
);
}
/// Statut dans la colonne 2/3 (scolarisé·e, à naître, sans garde, en garde).
String? _enfantColumnStatusLabel(EnfantDossier e) {
return enfantColumnStatusLabel(status: e.status, gender: e.gender);
}
/// Nom ; date de naissance et genre sur une ligne (prénom au-dessus, pleine largeur).
Widget _buildEnfantInfoFields(EnfantDossier e) {
final isANaitre = (e.status ?? '').trim().toLowerCase() == 'a_naitre';
final dueDateRenseignee = e.dueDate != null && e.dueDate!.trim().isNotEmpty;
final dateValue = isANaitre
? (dueDateRenseignee ? '${_formatBirthDate(e.dueDate)} (P)' : ' (P)')
: _formatBirthDate(e.birthDate);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _enfantLabeledField('Nom', _formatNom(e.lastName)),
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: _enfantLabeledField('Date de naissance', dateValue),
),
const SizedBox(width: 16),
Expanded(
flex: 2,
child: _enfantLabeledField(
'Genre',
_genreEnfantLabel(e.gender, e.status),
),
),
],
),
],
);
}
Widget _enfantLabeledField(String label, String value) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
),
),
const SizedBox(height: 4),
ValidationReadOnlyField(value: value),
],
);
}
Widget _buildEnfantPhotoSlot(String photoUrl, double width, double height) {
const photoRadius = 8.0;
return Container(
width: width,
height: height,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(photoRadius),
border: Border.all(color: Colors.black.withOpacity(0.08)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
clipBehavior: Clip.antiAlias,
child: photoUrl.isEmpty
? ColoredBox(
color: Colors.grey.shade100,
child: Center(
child: Icon(Icons.person_outline, size: 32, color: Colors.grey.shade400),
),
)
: AuthNetworkImage(
url: photoUrl,
fit: BoxFit.cover,
width: width,
height: height,
errorBuilder: (_, __, ___) => ColoredBox(
color: Colors.grey.shade100,
child: Center(
child: Icon(Icons.broken_image_outlined, size: 32, color: Colors.grey.shade400),
),
),
),
);
}
static String _formatNom(String? lastName) {
final n = (lastName ?? '').trim().toUpperCase();
return n.isEmpty ? 'Non défini' : n;
}
/// Genre enfant : Garçon, Fille, ou "Non connu" (uniquement si l'enfant est à naître).
static String _genreEnfantLabel(String? gender, String? status) {
final g = (gender ?? '').trim().toUpperCase();
final isANaitre = (status ?? '').trim().toLowerCase() == 'a_naitre';
if (g == 'H') return 'Garçon';
if (g == 'F') return 'Fille';
if (isANaitre) return 'Non connu';
if (g.isEmpty) return 'Non défini';
return (gender ?? '').trim();
}
Widget _buildPresentationStep() {
final p = widget.dossier.presentation ?? '';
final text = p.trim().isEmpty ? 'Non défini' : p;
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(
text,
style:
const TextStyle(color: Colors.black87, fontSize: 14),
),
),
),
);
},
),
),
],
);
}
Widget _buildNavigation() {
if (_step == 3) {
return Row(
children: [
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
const Spacer(),
Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: () {
setState(() => _step = 2);
_emitStep();
},
child: const Text('Précédent'),
),
const SizedBox(width: 8),
if (_isEnAttente && _firstParentId != null) ...[
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 if (!_isEnAttente)
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 || _firstParentId == null) return;
final ok = await showValidationValiderConfirmDialog(
context,
body:
'Voulez-vous valider ce dossier famille ? Les comptes parents concernés seront confirmés.',
);
if (!mounted || !ok) return;
await _valider();
}
Future<void> _valider() async {
if (_submitting || _firstParentId == null) return;
setState(() => _submitting = true);
try {
await UserService.validerDossierFamille(_firstParentId!);
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());
},
),
),
],
),
);
}
/// Un seul appel : le back refuse tout le dossier famille (co-parents + mails). Ticket #110.
Future<void> _refuserEnvoyer(String comment) async {
if (_submitting) return;
final parentId = _firstParentId?.trim();
if (parentId == null || parentId.isEmpty || !_isEnAttente) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Aucun compte en attente à refuser pour ce dossier.'),
backgroundColor: Colors.red.shade700,
),
);
return;
}
setState(() => _submitting = true);
try {
await UserService.refuseUser(parentId, comment: comment);
if (!mounted) return;
final nbEnAttente = widget.dossier.parents
.where((p) => p.statut == 'en_attente')
.length;
final msg = nbEnAttente > 1
? 'Refus enregistré. Un e-mail de reprise a été envoyé à chaque parent du dossier.'
: 'Refus enregistré. Un e-mail avec le lien de reprise a été envoyé.';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(msg),
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);
}
}
}
+17 -27
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_unifie.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/widgets/admin/common/validation_detail_section.dart';
@@ -68,9 +67,7 @@ class IdentityValues {
/// Bloc identité : Nom/Prénom, Tél/Email, Adresse, CP/Ville.
/// Grille partagée (création de compte, validation AM/famille, fiches admin, etc.).
class IdentityBlock extends StatelessWidget {
final String? title;
final bool expandVertically;
class IdentityBlock extends StatelessWidget { final String? title;
final String? _nom;
final String? _prenom;
@@ -94,7 +91,6 @@ class IdentityBlock extends StatelessWidget {
const IdentityBlock.readOnly({
super.key,
this.title,
this.expandVertically = false,
required String nom,
required String prenom,
required String telephone,
@@ -120,7 +116,6 @@ class IdentityBlock extends StatelessWidget {
const IdentityBlock.editable({
super.key,
this.title,
this.expandVertically = false,
required TextEditingController nomController,
required TextEditingController prenomController,
required TextEditingController telephoneController,
@@ -147,13 +142,11 @@ class IdentityBlock extends StatelessWidget {
factory IdentityBlock.readOnlyValues({
Key? key,
String? title,
bool expandVertically = false,
required IdentityValues values,
}) {
return IdentityBlock.readOnly(
key: key,
title: title,
expandVertically: expandVertically,
nom: values.nom,
prenom: values.prenom,
telephone: values.telephone,
@@ -170,12 +163,10 @@ class IdentityBlock extends StatelessWidget {
Key? key,
String? title,
String emptyLabel = 'Non défini',
bool expandVertically = false,
}) {
return IdentityBlock.readOnlyValues(
key: key,
title: title,
expandVertically: expandVertically,
values: IdentityValues.fromUser(user, emptyLabel: emptyLabel),
);
}
@@ -205,29 +196,29 @@ class IdentityBlock extends StatelessWidget {
title: title,
rowLayout: rowLayout,
rowFlex: rowFlex,
expandVertically: expandVertically,
fields: [
ValidationLabeledField(
label: 'Nom',
field: ValidationEditableField(
controller: _nomCtrl!,
inputFormatters: const [PersonNameInputFormatter()],
),
field: ValidationEditableField(controller: _nomCtrl!),
),
ValidationLabeledField(
label: 'Prénom',
field: ValidationEditableField(
controller: _prenomCtrl!,
inputFormatters: const [PersonNameInputFormatter()],
),
field: ValidationEditableField(controller: _prenomCtrl!),
),
ValidationLabeledField(
label: 'Téléphone',
field: ValidationPhoneField(controller: _telCtrl!),
field: ValidationEditableField(
controller: _telCtrl!,
keyboardType: TextInputType.phone,
inputFormatters: frenchPhoneInputFormatters,
),
),
ValidationLabeledField(
label: 'Email',
field: ValidationEmailField(controller: _emailCtrl!),
field: ValidationEditableField(
controller: _emailCtrl!,
keyboardType: TextInputType.emailAddress,
),
),
ValidationLabeledField(
label: 'Adresse (N° et Rue)',
@@ -235,14 +226,14 @@ class IdentityBlock extends StatelessWidget {
),
ValidationLabeledField(
label: 'Code postal',
field: ValidationPostalCodeField(controller: _cpCtrl!),
field: ValidationEditableField(
controller: _cpCtrl!,
keyboardType: TextInputType.number,
),
),
ValidationLabeledField(
label: 'Ville',
field: ValidationEditableField(
controller: _villeCtrl!,
inputFormatters: const [PersonNameInputFormatter()],
),
field: ValidationEditableField(controller: _villeCtrl!),
),
],
);
@@ -252,7 +243,6 @@ class IdentityBlock extends StatelessWidget {
title: title,
rowLayout: rowLayout,
rowFlex: rowFlex,
expandVertically: expandVertically,
fields: [
ValidationLabeledField(
label: 'Nom',
+1 -1
View File
@@ -54,7 +54,7 @@ class NirTextField extends StatelessWidget {
inputFontSize: inputFontSize,
keyboardType: TextInputType.text,
validator: validator ?? validateNir,
inputFormatters: const [NirInputFormatter()],
inputFormatters: [NirInputFormatter()],
enabled: enabled,
readOnly: readOnly,
style: style,