Compare commits

..

51 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
b4b546044d merge fix(#143): masquer Supprimer gestionnaire sur sa propre fiche 2026-07-12 12:10:11 +02:00
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
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
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
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
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
fbf22f2540 Revert "fix(#131): aligner PATCH fiche AM sur le DTO back actuel."
This reverts commit 43a2cd213b7b861ff239f5cfd756a47561b692f0.
2026-07-07 23:13:58 +02:00
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
c865d11dc3 Revert "fix(#131): activer Sauvegarder sur statut, enfants et champs AM."
This reverts commit d03a8e6c8b4363e9443e4a64711a7c68a21c5700.
2026-07-07 23:12:09 +02:00
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
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
53721ffbb3 fix(#131): ajouter @Get() manquant sur liste parents
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 22:07:24 +02:00
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
25c10c885a docs(#112): note alignement front reprise dossier complet
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 16:42:35 +02:00
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
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
21 changed files with 137 additions and 1380 deletions

View File

@ -1,5 +1,4 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { import {
IsBoolean, IsBoolean,
IsDateString, IsDateString,
@ -7,23 +6,11 @@ import {
IsNotEmpty, IsNotEmpty,
IsOptional, IsOptional,
IsString, IsString,
IsUUID,
MaxLength, MaxLength,
ValidateIf, ValidateIf,
} from 'class-validator'; } from 'class-validator';
import { GenreType, StatutEnfantType } from 'src/entities/children.entity'; import { GenreType, StatutEnfantType } from 'src/entities/children.entity';
/** Multipart envoie des strings ("true"/"false") — JSON envoie déjà des booleans. */
function toBoolean({ value }: { value: unknown }): boolean | unknown {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
const v = value.trim().toLowerCase();
if (v === 'true' || v === '1') return true;
if (v === 'false' || v === '0' || v === '') return false;
}
return value;
}
export class CreateEnfantsDto { export class CreateEnfantsDto {
@ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE }) @ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE })
@IsEnum(StatutEnfantType) @IsEnum(StatutEnfantType)
@ -65,7 +52,6 @@ export class CreateEnfantsDto {
photo_url?: string; photo_url?: string;
@ApiProperty({ default: false }) @ApiProperty({ default: false })
@Transform(toBoolean)
@IsBoolean() @IsBoolean()
consent_photo: boolean; consent_photo: boolean;
@ -75,20 +61,6 @@ export class CreateEnfantsDto {
consent_photo_at?: string; consent_photo_at?: string;
@ApiProperty({ default: false }) @ApiProperty({ default: false })
@Transform(toBoolean)
@IsBoolean() @IsBoolean()
is_multiple: boolean; is_multiple: boolean;
/**
* Parent pivot du foyer obligatoire pour staff (gestionnaire/admin).
* Ignoré / interdit en externe pour un PARENT (ticket #132).
*/
@ApiPropertyOptional({
description:
'UUID du parent pivot (staff only). Obligatoire pour GESTIONNAIRE / ADMIN / SUPER_ADMIN.',
format: 'uuid',
})
@IsOptional()
@IsUUID('4')
parent_user_id?: string;
} }

View File

@ -1,33 +1,20 @@
import { import {
Body, Body,
CallHandler,
Controller, Controller,
Delete, Delete,
ExecutionContext,
Get, Get,
HttpCode,
HttpStatus,
Injectable,
NestInterceptor,
Param, Param,
ParseUUIDPipe, ParseUUIDPipe,
Patch, Patch,
Post, Post,
UploadedFile,
UseGuards, UseGuards,
UseInterceptors, UseInterceptors,
UploadedFile,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { import { ApiBearerAuth, ApiTags, ApiConsumes } from '@nestjs/swagger';
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { diskStorage } from 'multer'; import { diskStorage } from 'multer';
import { extname } from 'path'; import { extname } from 'path';
import { Observable } from 'rxjs';
import { EnfantsService } from './enfants.service'; import { EnfantsService } from './enfants.service';
import { CreateEnfantsDto } from './dto/create_enfants.dto'; import { CreateEnfantsDto } from './dto/create_enfants.dto';
import { UpdateEnfantsDto } from './dto/update_enfants.dto'; import { UpdateEnfantsDto } from './dto/update_enfants.dto';
@ -37,7 +24,18 @@ import { AuthGuard } from 'src/common/guards/auth.guard';
import { Roles } from 'src/common/decorators/roles.decorator'; import { Roles } from 'src/common/decorators/roles.decorator';
import { RolesGuard } from 'src/common/guards/roles.guard'; import { RolesGuard } from 'src/common/guards/roles.guard';
const photoMulterOptions = { @ApiBearerAuth('access-token')
@ApiTags('Enfants')
@UseGuards(AuthGuard, RolesGuard)
@Controller('enfants')
export class EnfantsController {
constructor(private readonly enfantsService: EnfantsService) { }
@Roles(RoleType.PARENT)
@Post()
@ApiConsumes('multipart/form-data')
@UseInterceptors(
FileInterceptor('photo', {
storage: diskStorage({ storage: diskStorage({
destination: './uploads/photos', destination: './uploads/photos',
filename: (req, file, cb) => { filename: (req, file, cb) => {
@ -55,57 +53,8 @@ const photoMulterOptions = {
limits: { limits: {
fileSize: 5 * 1024 * 1024, fileSize: 5 * 1024 * 1024,
}, },
}; }),
/**
* Multer uniquement si Content-Type multipart (parent ou staff + photo).
* JSON sans photo (#132) passe sans interceptor fichier.
*/
@Injectable()
class OptionalEnfantPhotoInterceptor implements NestInterceptor {
private readonly multipart = new (FileInterceptor(
'photo',
photoMulterOptions,
))();
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> | Promise<Observable<unknown>> {
const req = context.switchToHttp().getRequest();
const ct = String(req.headers['content-type'] ?? '');
if (!ct.includes('multipart/form-data')) {
return next.handle();
}
return this.multipart.intercept(context, next);
}
}
@ApiBearerAuth('access-token')
@ApiTags('Enfants')
@UseGuards(AuthGuard, RolesGuard)
@Controller('enfants')
export class EnfantsController {
constructor(private readonly enfantsService: EnfantsService) { }
@Roles(
RoleType.PARENT,
RoleType.GESTIONNAIRE,
RoleType.ADMINISTRATEUR,
RoleType.SUPER_ADMIN,
) )
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'Créer un enfant',
description:
'PARENT : multipart éventuel, rattache au compte connecté. ' +
'Staff : parent_user_id obligatoire ; JSON sans photo OK ; avec photo → multipart (champ fichier `photo`, max 5 Mo). Ticket #132.',
})
@ApiConsumes('application/json', 'multipart/form-data')
@ApiBody({
description:
'Champs métier (+ parent_user_id côté staff). Fichier optionnel `photo` en multipart.',
type: CreateEnfantsDto,
})
@UseInterceptors(OptionalEnfantPhotoInterceptor)
create( create(
@Body() dto: CreateEnfantsDto, @Body() dto: CreateEnfantsDto,
@UploadedFile() photo: Express.Multer.File, @UploadedFile() photo: Express.Multer.File,

View File

@ -13,12 +13,6 @@ import { ParentsChildren } from 'src/entities/parents_children.entity';
import { RoleType, Users } from 'src/entities/users.entity'; import { RoleType, Users } from 'src/entities/users.entity';
import { CreateEnfantsDto } from './dto/create_enfants.dto'; import { CreateEnfantsDto } from './dto/create_enfants.dto';
const STAFF_ROLES: RoleType[] = [
RoleType.GESTIONNAIRE,
RoleType.ADMINISTRATEUR,
RoleType.SUPER_ADMIN,
];
@Injectable() @Injectable()
export class EnfantsService { export class EnfantsService {
constructor( constructor(
@ -30,35 +24,20 @@ export class EnfantsService {
private readonly parentsChildrenRepository: Repository<ParentsChildren>, private readonly parentsChildrenRepository: Repository<ParentsChildren>,
) { } ) { }
private isStaff(user: Users): boolean { // Création d'un enfant
return STAFF_ROLES.includes(user.role); async create(dto: CreateEnfantsDto, currentUser: Users, photoFile?: Express.Multer.File): Promise<Children> {
}
/**
* Création d'un enfant.
* - PARENT : rattache au parent connecté (multipart photo optionnel).
* - Staff : `parent_user_id` obligatoire ; JSON sans photo OK ;
* avec photo multipart (même stockage `/uploads/photos/...`). Ticket #132.
*/
async create(
dto: CreateEnfantsDto,
currentUser: Users,
photoFile?: Express.Multer.File,
): Promise<Children> {
const pivotUserId = this.resolvePivotParentUserId(dto, currentUser);
const parent = await this.parentsRepository.findOne({ const parent = await this.parentsRepository.findOne({
where: { user_id: pivotUserId }, where: { user_id: currentUser.id },
relations: ['co_parent'], relations: ['co_parent'],
}); });
if (!parent) throw new NotFoundException('Parent introuvable'); if (!parent) throw new NotFoundException('Parent introuvable');
// Vérif métier simple (aligné comportement historique parent) // Vérif métier simple
if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) { if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) {
throw new BadRequestException('Un enfant né doit avoir une date de naissance'); throw new BadRequestException('Un enfant né doit avoir une date de naissance');
} }
// Vérif doublon éventuel (ex: même prénom + date de naissance) // Vérif doublon éventuel (ex: même prénom + date de naissance pour ce parent)
const exist = await this.childrenRepository.findOne({ const exist = await this.childrenRepository.findOne({
where: { where: {
first_name: dto.first_name, first_name: dto.first_name,
@ -68,108 +47,50 @@ export class EnfantsService {
}); });
if (exist) throw new ConflictException('Cet enfant existe déjà'); if (exist) throw new ConflictException('Cet enfant existe déjà');
// Gestion de la photo uploadée (multipart parent ou staff) // Gestion de la photo uploadée
let photoUrl = dto.photo_url;
let consentAt: Date | undefined;
if (photoFile) { if (photoFile) {
photoUrl = `/uploads/photos/${photoFile.filename}`; dto.photo_url = `/uploads/photos/${photoFile.filename}`;
if (dto.consent_photo) { if (dto.consent_photo) {
consentAt = new Date(); dto.consent_photo_at = new Date().toISOString();
} }
} else if (dto.consent_photo) {
consentAt = dto.consent_photo_at
? new Date(dto.consent_photo_at)
: new Date();
} }
const child = this.childrenRepository.create({ // Création
status: dto.status, const child = this.childrenRepository.create(dto);
first_name: dto.first_name,
last_name: dto.last_name,
gender: dto.gender,
birth_date: dto.birth_date ? new Date(dto.birth_date) : undefined,
due_date: dto.due_date ? new Date(dto.due_date) : undefined,
photo_url: photoUrl,
consent_photo: !!dto.consent_photo,
consent_photo_at: consentAt,
is_multiple: !!dto.is_multiple,
});
await this.childrenRepository.save(child); await this.childrenRepository.save(child);
// Lien parent-enfant (pivot) // Lien parent-enfant (Parent 1)
await this.parentsChildrenRepository.save( const parentLink = this.parentsChildrenRepository.create({
this.parentsChildrenRepository.create({
parentId: parent.user_id, parentId: parent.user_id,
enfantId: child.id, enfantId: child.id,
}), });
); await this.parentsChildrenRepository.save(parentLink);
// Rattachement automatique au co-parent s'il existe // Rattachement automatique au co-parent s'il existe
if (parent.co_parent) { if (parent.co_parent) {
await this.parentsChildrenRepository.save( const coParentLink = this.parentsChildrenRepository.create({
this.parentsChildrenRepository.create({
parentId: parent.co_parent.id, parentId: parent.co_parent.id,
enfantId: child.id, enfantId: child.id,
}), });
); await this.parentsChildrenRepository.save(coParentLink);
} }
return this.findOne(child.id, currentUser); return this.findOne(child.id, currentUser);
} }
private resolvePivotParentUserId( // Liste des enfants (admin/gestionnaire)
dto: CreateEnfantsDto, async findAll(): Promise<Children[]> {
currentUser: Users, return this.childrenRepository.find({
): string {
if (this.isStaff(currentUser)) {
const id = dto.parent_user_id?.trim();
if (!id) {
throw new BadRequestException(
'parent_user_id est obligatoire pour créer un enfant (staff)',
);
}
return id;
}
if (currentUser.role === RoleType.PARENT) {
if (
dto.parent_user_id &&
dto.parent_user_id.trim() !== currentUser.id
) {
throw new ForbiddenException(
'Un parent ne peut pas créer un enfant pour un autre compte',
);
}
return currentUser.id;
}
throw new ForbiddenException('Accès interdit');
}
/** Flag API #157 — true si aucun lien enfants_parents. */
private withSansResponsable(child: Children): Children & { sans_responsable: boolean } {
return Object.assign(child, {
sans_responsable: !child.parentLinks || child.parentLinks.length === 0,
});
}
// Liste des enfants (admin/gestionnaire) — inclut les orphelins (parentLinks: [])
async findAll(): Promise<Array<Children & { sans_responsable: boolean }>> {
const children = await this.childrenRepository.find({
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'], relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
order: { last_name: 'ASC', first_name: 'ASC' }, order: { last_name: 'ASC', first_name: 'ASC' },
}); });
return children.map((c) => this.withSansResponsable(c));
} }
// Récupérer un enfant par id // Récupérer un enfant par id
async findOne( async findOne(id: string, currentUser: Users): Promise<Children> {
id: string,
currentUser: Users,
): Promise<Children & { sans_responsable: boolean }> {
const child = await this.childrenRepository.findOne({ const child = await this.childrenRepository.findOne({
where: { id }, where: { id },
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'], relations: ['parentLinks'],
}); });
if (!child) throw new NotFoundException('Enfant introuvable'); if (!child) throw new NotFoundException('Enfant introuvable');
@ -183,14 +104,14 @@ export class EnfantsService {
case RoleType.ADMINISTRATEUR: case RoleType.ADMINISTRATEUR:
case RoleType.SUPER_ADMIN: case RoleType.SUPER_ADMIN:
case RoleType.GESTIONNAIRE: case RoleType.GESTIONNAIRE:
// accès complet (y compris orphelins) // accès complet
break; break;
default: default:
throw new ForbiddenException('Accès interdit'); throw new ForbiddenException('Accès interdit');
} }
return this.withSansResponsable(child); return child;
} }
@ -199,8 +120,7 @@ export class EnfantsService {
const child = await this.childrenRepository.findOne({ where: { id } }); const child = await this.childrenRepository.findOne({ where: { id } });
if (!child) throw new NotFoundException('Enfant introuvable'); if (!child) throw new NotFoundException('Enfant introuvable');
const { parent_user_id: _ignored, ...rest } = dto; const patch: Partial<Children> = { ...dto } as Partial<Children>;
const patch: Partial<Children> = { ...rest } as Partial<Children>;
if (dto.consent_photo !== undefined) { if (dto.consent_photo !== undefined) {
patch.consent_photo = dto.consent_photo; patch.consent_photo = dto.consent_photo;
patch.consent_photo_at = dto.consent_photo ? new Date() : null!; patch.consent_photo_at = dto.consent_photo ? new Date() : null!;

View File

@ -138,9 +138,7 @@ export class ParentsService {
} }
/** /**
* Détacher un enfant d'un parent sans supprimer l'enfant. * Détacher un enfant d'un parent sans supprimer l'enfant. Ticket #115 / doc 28 §6.2.
* 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> { async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
await this.findOne(parentUserId); await this.findOne(parentUserId);
@ -152,6 +150,11 @@ export class ParentsService {
throw new NotFoundException('Lien parent-enfant introuvable'); throw new NotFoundException('Lien parent-enfant introuvable');
} }
const totalLinks = await this.parentsChildrenRepository.count({ where: { enfantId } });
if (totalLinks <= 1) {
throw new BadRequestException('Un enfant doit rester rattaché à au moins un responsable');
}
await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId }); await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId });
return this.findOne(parentUserId); return this.findOne(parentUserId);
} }

View File

@ -14,8 +14,6 @@ class EnfantAdminModel {
final bool consentPhoto; final bool consentPhoto;
final bool isMultiple; final bool isMultiple;
final List<EnfantParentLink> parentLinks; final List<EnfantParentLink> parentLinks;
/// Flag API #157 (sinon déduit de [parentLinks]).
final bool? sansResponsable;
EnfantAdminModel({ EnfantAdminModel({
required this.id, required this.id,
@ -29,7 +27,6 @@ class EnfantAdminModel {
this.consentPhoto = false, this.consentPhoto = false,
this.isMultiple = false, this.isMultiple = false,
this.parentLinks = const [], this.parentLinks = const [],
this.sansResponsable,
}); });
String get fullName { String get fullName {
@ -40,12 +37,6 @@ class EnfantAdminModel {
return '$fn $ln'; return '$fn $ln';
} }
/// Aucun lien parent valide ticket #157.
bool get hasNoResponsable {
if (sansResponsable != null) return sansResponsable!;
return !parentLinks.any((l) => l.parentId.trim().isNotEmpty);
}
EnfantAdminModel copyWith({ EnfantAdminModel copyWith({
String? id, String? id,
String? firstName, String? firstName,
@ -58,7 +49,6 @@ class EnfantAdminModel {
bool? consentPhoto, bool? consentPhoto,
bool? isMultiple, bool? isMultiple,
List<EnfantParentLink>? parentLinks, List<EnfantParentLink>? parentLinks,
bool? sansResponsable,
}) { }) {
return EnfantAdminModel( return EnfantAdminModel(
id: id ?? this.id, id: id ?? this.id,
@ -72,7 +62,6 @@ class EnfantAdminModel {
consentPhoto: consentPhoto ?? this.consentPhoto, consentPhoto: consentPhoto ?? this.consentPhoto,
isMultiple: isMultiple ?? this.isMultiple, isMultiple: isMultiple ?? this.isMultiple,
parentLinks: parentLinks ?? this.parentLinks, parentLinks: parentLinks ?? this.parentLinks,
sansResponsable: sansResponsable ?? this.sansResponsable,
); );
} }
@ -92,13 +81,6 @@ class EnfantAdminModel {
_parseBool(json['consentement_photo']) || _parseBool(json['consentement_photo']) ||
_parseBool(json['consentPhoto']); _parseBool(json['consentPhoto']);
bool? sansResponsable;
if (json.containsKey('sans_responsable') ||
json.containsKey('sansResponsable')) {
sansResponsable = _parseBool(json['sans_responsable']) ||
_parseBool(json['sansResponsable']);
}
return EnfantAdminModel( return EnfantAdminModel(
id: (json['id'] ?? '').toString(), id: (json['id'] ?? '').toString(),
firstName: json['first_name'] as String? ?? json['prenom'] as String?, firstName: json['first_name'] as String? ?? json['prenom'] as String?,
@ -114,7 +96,6 @@ class EnfantAdminModel {
isMultiple: _parseBool(json['is_multiple']) || isMultiple: _parseBool(json['is_multiple']) ||
_parseBool(json['est_multiple']), _parseBool(json['est_multiple']),
parentLinks: links, parentLinks: links,
sansResponsable: sansResponsable,
); );
} }

View File

@ -62,56 +62,4 @@ class ParentModel {
return children; return children;
} }
/// Nombre denfants distincts du foyer (ce parent + co-parent / même dossier).
/// Évite Claire=7 / Thomas=6 quand un lien nest que sur un des deux (#157).
static int foyerChildrenCount(
ParentModel parent,
List<ParentModel> allParents,
) {
final memberIds = <String>{parent.user.id};
final coId = parent.coParent?.id.trim();
if (coId != null && coId.isNotEmpty) memberIds.add(coId);
final dossier = (parent.user.numeroDossier ?? '').trim();
for (final other in allParents) {
if (memberIds.contains(other.user.id)) continue;
final otherCo = other.coParent?.id.trim();
if (otherCo != null && memberIds.contains(otherCo)) {
memberIds.add(other.user.id);
continue;
}
if (dossier.isNotEmpty &&
(other.user.numeroDossier ?? '').trim() == dossier) {
memberIds.add(other.user.id);
final oc = other.coParent?.id.trim();
if (oc != null && oc.isNotEmpty) memberIds.add(oc);
}
}
final childIds = <String>{};
for (final p in allParents) {
if (!memberIds.contains(p.user.id)) continue;
for (final c in p.children) {
final id = c.id.trim();
if (id.isNotEmpty) childIds.add(id);
}
// Repli si la liste enfants nest pas hydratée.
if (p.children.isEmpty && p.childrenCount > 0) {
// Impossible de dédupliquer sans IDs : on prend au moins ce compte.
// (évite dafficher 0 si lAPI nenvoie que childrenCount)
}
}
if (childIds.isNotEmpty) return childIds.length;
var maxCount = 0;
for (final p in allParents) {
if (!memberIds.contains(p.user.id)) continue;
final n = p.children.isNotEmpty ? p.children.length : p.childrenCount;
if (n > maxCount) maxCount = n;
}
return maxCount;
}
} }

View File

@ -1,6 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:http_parser/http_parser.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/models/parent_model.dart'; import 'package:p_tits_pas/models/parent_model.dart';
@ -58,33 +57,6 @@ class UserService {
return v.toString(); return v.toString();
} }
/// MIME pour upload photo enfant (filtre Nest : jpg/jpeg/png/gif).
static MediaType _imageMediaType(String filename, List<int> bytes) {
if (bytes.length >= 3 &&
bytes[0] == 0xFF &&
bytes[1] == 0xD8 &&
bytes[2] == 0xFF) {
return MediaType('image', 'jpeg');
}
if (bytes.length >= 8 &&
bytes[0] == 0x89 &&
bytes[1] == 0x50 &&
bytes[2] == 0x4E &&
bytes[3] == 0x47) {
return MediaType('image', 'png');
}
if (bytes.length >= 6 &&
bytes[0] == 0x47 &&
bytes[1] == 0x49 &&
bytes[2] == 0x46) {
return MediaType('image', 'gif');
}
final lower = filename.toLowerCase();
if (lower.endsWith('.png')) return MediaType('image', 'png');
if (lower.endsWith('.gif')) return MediaType('image', 'gif');
return MediaType('image', 'jpeg');
}
static String _errMessage(dynamic err) { static String _errMessage(dynamic err) {
if (err == null) return 'Erreur inconnue'; if (err == null) return 'Erreur inconnue';
if (err is String) return err; if (err is String) return err;
@ -543,68 +515,6 @@ class UserService {
return enrichEnfantParentNames(enfant); return enrichEnfantParentNames(enfant);
} }
/// Création enfant côté staff (#132) nécessite `POST /enfants` étendu (voir mini-spec).
/// [parentUserId] : parent pivot du foyer (`parent_user_id`).
/// Avec [photoBytes] : `multipart/form-data` (champ fichier `photo`), sinon JSON.
static Future<EnfantAdminModel> createEnfant({
required String parentUserId,
required Map<String, dynamic> body,
List<int>? photoBytes,
String? photoFilename,
}) async {
final hasPhoto = photoBytes != null && photoBytes.isNotEmpty;
final http.Response response;
if (hasPhoto) {
final token = await TokenService.getToken();
final req = http.MultipartRequest(
'POST',
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
);
req.headers['Accept'] = 'application/json';
if (token != null) {
req.headers['Authorization'] = 'Bearer $token';
}
body.forEach((key, value) {
if (value == null) return;
req.fields[key] = value is bool
? (value ? 'true' : 'false')
: value.toString();
});
req.fields['parent_user_id'] = parentUserId;
final name = (photoFilename ?? '').trim();
final filename = name.isNotEmpty ? name : 'photo.jpg';
req.files.add(
http.MultipartFile.fromBytes(
'photo',
photoBytes,
filename: filename,
contentType: _imageMediaType(filename, photoBytes),
),
);
final streamed = await req.send();
response = await http.Response.fromStream(streamed);
} else {
final payload = <String, dynamic>{
...body,
'parent_user_id': parentUserId,
};
response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
headers: await _headers(),
body: jsonEncode(payload),
);
}
if (response.statusCode != 200 && response.statusCode != 201) {
throw Exception(
_extractErrorMessage(response.body, 'Erreur création enfant'),
);
}
final enfant = EnfantAdminModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
return enrichEnfantParentNames(enfant);
}
static Future<void> deleteEnfant(String enfantId) async { static Future<void> deleteEnfant(String enfantId) async {
final response = await http.delete( final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'), Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),

View File

@ -1,4 +1,3 @@
import 'package:flutter/services.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart';
@ -12,50 +11,10 @@ String formatIsoDateFr(String? s, {String ifEmpty = ''}) {
} }
} }
/// Affiche une date ISO en saisie guidée `jj / mm / aaaa`. /// Convertit `dd/MM/yyyy` (ou ISO) en `yyyy-MM-dd` pour l'API.
String formatIsoDateFrInput(String? s, {String ifEmpty = ''}) {
if (s == null || s.trim().isEmpty) return ifEmpty;
try {
final dt = DateTime.parse(s.trim());
return formatFrenchDateDigits(
DateFormat('ddMMyyyy').format(dt),
);
} catch (_) {
return formatFrenchDateDigits(s.replaceAll(RegExp(r'\D'), ''));
}
}
/// Formate jusquà 8 chiffres en `jj / mm / aaaa`.
String formatFrenchDateDigits(String digits) {
final d = digits.replaceAll(RegExp(r'\D'), '');
final limited = d.length > 8 ? d.substring(0, 8) : d;
final buf = StringBuffer();
for (var i = 0; i < limited.length; i++) {
if (i == 2 || i == 4) buf.write(' / ');
buf.write(limited[i]);
}
return buf.toString();
}
/// Convertit `dd/MM/yyyy`, `dd / MM / yyyy`, 8 chiffres ou ISO en `yyyy-MM-dd`.
String? parseFrDateToIso(String text) { String? parseFrDateToIso(String text) {
final t = text.trim(); final t = text.trim();
if (t.isEmpty) return null; if (t.isEmpty) return null;
final digits = t.replaceAll(RegExp(r'\D'), '');
if (digits.length == 8) {
final dd = digits.substring(0, 2);
final mm = digits.substring(2, 4);
final yyyy = digits.substring(4, 8);
try {
return DateFormat('dd/MM/yyyy')
.parseStrict('$dd/$mm/$yyyy')
.toIso8601String()
.split('T')
.first;
} catch (_) {}
}
try { try {
return DateFormat('dd/MM/yyyy') return DateFormat('dd/MM/yyyy')
.parseStrict(t) .parseStrict(t)
@ -137,43 +96,3 @@ String formatChildAgeLabel({
return 'Né le ${formatIsoDateFr(birthDate)}'; return 'Né le ${formatIsoDateFr(birthDate)}';
} }
} }
/// Saisie date FR : 8 chiffres `jj / mm / aaaa`.
class FrenchDateInputFormatter extends TextInputFormatter {
const FrenchDateInputFormatter();
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
final limited = digits.length > 8 ? digits.substring(0, 8) : digits;
final formatted = formatFrenchDateDigits(limited);
final digitsBeforeCursor = newValue.text
.substring(0, newValue.selection.start.clamp(0, newValue.text.length))
.replaceAll(RegExp(r'\D'), '')
.length
.clamp(0, limited.length);
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(
offset: _cursorOffset(formatted, digitsBeforeCursor),
),
);
}
static int _cursorOffset(String formatted, int digitsBeforeCursor) {
if (digitsBeforeCursor <= 0) return 0;
var seen = 0;
for (var i = 0; i < formatted.length; i++) {
if (RegExp(r'\d').hasMatch(formatted[i])) {
seen++;
if (seen >= digitsBeforeCursor) return i + 1;
}
}
return formatted.length;
}
}

View File

@ -1,10 +0,0 @@
import 'package:p_tits_pas/models/enfant_admin_model.dart';
/// Enfant sans lien parent (orphelins daffiliation) ticket #157.
bool enfantHasNoResponsable(EnfantAdminModel enfant) => enfant.hasNoResponsable;
/// Message vigilance liste Enfants (même usage que [amPlacesVigilanceMessage]).
String? enfantSansResponsableVigilanceMessage(EnfantAdminModel enfant) {
if (!enfantHasNoResponsable(enfant)) return null;
return 'Aucun responsable rattaché — à rattacher à un foyer';
}

View File

@ -1,5 +1,3 @@
import 'package:flutter/services.dart';
/// Formatage affichage prénom / nom (capitalisation par mot et segments après `-` ou `'`). /// Formatage affichage prénom / nom (capitalisation par mot et segments après `-` ou `'`).
String formatPersonNameCase(String raw) { String formatPersonNameCase(String raw) {
@ -11,22 +9,6 @@ String formatPersonNameCase(String raw) {
return words.map(_capitalizeComposedWord).join(' '); return words.map(_capitalizeComposedWord).join(' ');
} }
/// Variante saisie live : pas de majuscule tant que le mot na quune lettre ;
/// dès la 2 lettre, capitalisation comme [formatPersonNameCase].
String formatPersonNameCaseTyping(String raw) {
if (raw.isEmpty) return raw;
final trailingMatch = RegExp(r'(\s*)$').firstMatch(raw);
final trailing = trailingMatch?.group(1) ?? '';
final core = raw.substring(0, raw.length - trailing.length);
if (core.isEmpty) return raw;
final words = core.split(RegExp(r'\s+'));
final formatted = words.map((w) {
if (w.length < 2) return w;
return _capitalizeComposedWord(w);
}).join(' ');
return formatted + trailing;
}
String _capitalizeComposedWord(String word) { String _capitalizeComposedWord(String word) {
if (word.isEmpty) { if (word.isEmpty) {
return word; return word;
@ -48,26 +30,3 @@ String _capitalizeComposedWord(String word) {
} }
return buffer.toString(); return buffer.toString();
} }
/// Formateur de saisie prénom / nom (capitalisation progressive).
class PersonNameInputFormatter extends TextInputFormatter {
const PersonNameInputFormatter();
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final formatted = formatPersonNameCaseTyping(newValue.text);
if (formatted == newValue.text) return newValue;
final sel = newValue.selection;
final offset = sel.isValid
? sel.baseOffset.clamp(0, formatted.length)
: formatted.length;
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: offset),
);
}
}

View File

@ -446,7 +446,8 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Détacher l\'enfant'), title: const Text('Détacher l\'enfant'),
content: Text( content: Text(
'Retirer ${child.fullName} de la fiche de cette assistante ?', 'Retirer ${child.fullName} de la fiche de cette assistante ?\n'
'(L\'enfant ne sera pas supprimé.)',
), ),
actions: [ actions: [
TextButton( TextButton(

View File

@ -1,27 +1,14 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
/// Cadre photo identité AM / enfant (35×45 mm) même logique que [ValidationAmWizard]. /// Cadre photo identité AM (35×45 mm) même logique que [ValidationAmWizard].
class AdminAmPhotoFrame extends StatelessWidget { class AdminAmPhotoFrame extends StatelessWidget {
final String? photoUrl; final String? photoUrl;
final Uint8List? imageBytes;
final VoidCallback? onTap;
final VoidCallback? onClear;
final String emptyLabel;
static const double idPhotoAspectRatio = 35 / 45; static const double idPhotoAspectRatio = 35 / 45;
const AdminAmPhotoFrame({ const AdminAmPhotoFrame({super.key, this.photoUrl});
super.key,
this.photoUrl,
this.imageBytes,
this.onTap,
this.onClear,
this.emptyLabel = 'Aucune photo',
});
/// Largeur colonne photo pour remplir [height] (cadre inclus). /// Largeur colonne photo pour remplir [height] (cadre inclus).
static double columnWidthForHeight(double height) { static double columnWidthForHeight(double height) {
@ -49,12 +36,9 @@ class AdminAmPhotoFrame extends StatelessWidget {
ph = pw / ar; ph = pw / ar;
} }
final hasLocal = imageBytes != null && imageBytes!.isNotEmpty;
final showClear = onClear != null && hasLocal;
// Cadre gris = taille photo + padding uniforme ; centré dans la colonne // Cadre gris = taille photo + padding uniforme ; centré dans la colonne
// (évite le vide blanc en bas quand le conteneur parent est plus haut). // (évite le vide blanc en bas quand le conteneur parent est plus haut).
Widget frame = Align( return Align(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@ -76,82 +60,23 @@ class AdminAmPhotoFrame extends StatelessWidget {
), ),
), ),
); );
if (onTap != null) {
frame = MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: frame,
),
);
}
if (!showClear) return frame;
return Stack(
clipBehavior: Clip.none,
children: [
frame,
Positioned(
top: 0,
right: 0,
child: Material(
color: Colors.transparent,
child: IconButton(
tooltip: 'Retirer la photo',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
icon: Icon(
Icons.cancel,
size: 22,
color: Colors.grey.shade700,
),
onPressed: onClear,
),
),
),
],
);
}, },
); );
} }
Widget _photoContent(String fullUrl, double pw, double ph) { Widget _photoContent(String fullUrl, double pw, double ph) {
if (imageBytes != null && imageBytes!.isNotEmpty) {
return Image.memory(
imageBytes!,
width: pw,
height: ph,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
);
}
if (fullUrl.isEmpty) { if (fullUrl.isEmpty) {
return ColoredBox( return ColoredBox(
color: Colors.grey.shade200, color: Colors.grey.shade200,
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(Icons.person_off_outlined, size: 36, color: Colors.grey.shade400),
onTap != null ? Icons.add_a_photo_outlined : Icons.person_off_outlined,
size: 36,
color: Colors.grey.shade400,
),
const SizedBox(height: 6), const SizedBox(height: 6),
Padding( Text(
padding: const EdgeInsets.symmetric(horizontal: 6), 'Aucune photo',
child: Text(
emptyLabel,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600, fontSize: 11), style: TextStyle(color: Colors.grey.shade600, fontSize: 11),
), ),
),
], ],
), ),
); );
@ -183,11 +108,7 @@ class AdminAmPhotoFrame extends StatelessWidget {
}, },
errorBuilder: (_, __, ___) => ColoredBox( errorBuilder: (_, __, ___) => ColoredBox(
color: Colors.grey.shade200, color: Colors.grey.shade200,
child: Icon( child: Icon(Icons.broken_image_outlined, size: 36, color: Colors.grey.shade400),
Icons.broken_image_outlined,
size: 36,
color: Colors.grey.shade400,
),
), ),
); );
} }

View File

@ -1,6 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/services/api/api_config.dart';
@ -8,37 +6,26 @@ import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart';
import 'package:p_tits_pas/utils/name_format_utils.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_am_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_select_am_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_famille_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
/// Fiche enfant consultation / édition (#138) ou création (#132). /// Fiche enfant consultation / édition (ticket #138) format paysage comme fiche AM.
class AdminChildDetailModal extends StatefulWidget { class AdminChildDetailModal extends StatefulWidget {
final EnfantAdminModel? enfant; final EnfantAdminModel enfant;
final VoidCallback? onSaved; final VoidCallback? onSaved;
final VoidCallback? onDeleted; final VoidCallback? onDeleted;
final bool isCreating;
const AdminChildDetailModal({ const AdminChildDetailModal({
super.key, super.key,
required EnfantAdminModel this.enfant, required this.enfant,
this.onSaved, this.onSaved,
this.onDeleted, this.onDeleted,
}) : isCreating = false; });
/// Création depuis l'onglet Enfants (#132).
const AdminChildDetailModal.create({
super.key,
this.onSaved,
}) : enfant = null,
onDeleted = null,
isCreating = true;
@override @override
State<AdminChildDetailModal> createState() => _AdminChildDetailModalState(); State<AdminChildDetailModal> createState() => _AdminChildDetailModalState();
@ -50,7 +37,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
late final TextEditingController _birthCtrl; late final TextEditingController _birthCtrl;
late final TextEditingController _dueCtrl; late final TextEditingController _dueCtrl;
late String _status; late String _status;
late String? _gender; late String _gender;
late bool _consentPhoto; late bool _consentPhoto;
late bool _isMultiple; late bool _isMultiple;
bool _dirty = false; bool _dirty = false;
@ -62,16 +49,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
/// AM rattachée au chargement (pour sync différé au Sauvegarder). /// AM rattachée au chargement (pour sync différé au Sauvegarder).
String? _baselineAmUserId; String? _baselineAmUserId;
/// Famille choisie en mode création (#132).
AdminFamilleFoyer? _selectedFamily;
/// Liens parents locaux (édition) mis à jour après rattachement foyer (#157).
List<EnfantParentLink>? _localParentLinks;
/// Photo locale (création) upload multipart `photo`.
Uint8List? _photoBytes;
String? _photoFilename;
static const double _modalWidth = 930; static const double _modalWidth = 930;
static const double _mainRowHeight = 380; static const double _mainRowHeight = 380;
static const double _placementHeight = 96; static const double _placementHeight = 96;
@ -97,102 +74,44 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
_isUnborn ? 'Date prévisionnelle' : 'Date de naissance'; _isUnborn ? 'Date prévisionnelle' : 'Date de naissance';
bool get _canDelete => bool get _canDelete =>
!widget.isCreating &&
(_currentUserRole ?? '').toLowerCase() == 'super_admin'; (_currentUserRole ?? '').toLowerCase() == 'super_admin';
bool get _busy => _saving || _deleting; bool get _busy => _saving || _deleting;
/// Création : tous les champs sauf photo / consentement ; édition : au moins une modif.
bool get _canSubmit {
if (_busy) return false;
if (widget.isCreating) return _isCreateFormComplete;
return _dirty;
}
bool get _isCreateFormComplete {
if (_selectedFamily == null) return false;
if (formatPersonNameCase(_prenomCtrl.text).isEmpty) return false;
if (formatPersonNameCase(_nomCtrl.text).isEmpty) return false;
if (_dateToIso(_activeDateCtrl.text) == null) return false;
if (_gender == null || !_availableGenders.contains(_gender)) return false;
return true;
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final e = widget.enfant; final e = widget.enfant;
_prenomCtrl = TextEditingController(text: e?.firstName ?? ''); _prenomCtrl = TextEditingController(text: e.firstName ?? '');
_nomCtrl = TextEditingController(text: e?.lastName ?? ''); _nomCtrl = TextEditingController(text: e.lastName ?? '');
_birthCtrl = TextEditingController( _birthCtrl = TextEditingController(
text: formatIsoDateFrInput(e?.birthDate, ifEmpty: ''), text: formatIsoDateFr(e.birthDate, ifEmpty: ''),
); );
_dueCtrl = TextEditingController( _dueCtrl = TextEditingController(
text: formatIsoDateFrInput(e?.dueDate, ifEmpty: ''), text: formatIsoDateFr(e.dueDate, ifEmpty: ''),
); );
_status = normalizeEnfantStatus(e?.status); _status = normalizeEnfantStatus(e.status);
if (!enfantStatusValues.contains(_status)) { if (!enfantStatusValues.contains(_status)) {
_status = 'sans_garde'; _status = 'sans_garde';
} }
if (widget.isCreating) { _gender = _normalizeGender(e.gender, allowUnknown: _isUnborn);
_gender = null; _consentPhoto = e.consentPhoto;
} else { _isMultiple = e.isMultiple;
_gender = _normalizeGender(e?.gender, allowUnknown: _isUnborn); for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) {
} c.addListener(_markDirty);
_consentPhoto = e?.consentPhoto ?? false;
_isMultiple = e?.isMultiple ?? false;
for (final c in [_prenomCtrl, _nomCtrl]) {
c.addListener(_onNameChanged);
}
for (final c in [_birthCtrl, _dueCtrl]) {
c.addListener(_onDateChanged);
} }
_prenomCtrl.addListener(_onNameChanged);
_nomCtrl.addListener(_onNameChanged);
_loadCurrentUserRole(); _loadCurrentUserRole();
if (widget.isCreating) {
_loadingAm = false;
_dirty = true;
} else {
_prenomCtrl.addListener(_markDirty);
_nomCtrl.addListener(_markDirty);
_loadLinkedAm(); _loadLinkedAm();
} }
}
void _onNameChanged() => setState(() {}); void _onNameChanged() => setState(() {});
void _onDateChanged() {
setState(() => _dirty = true);
}
/// Date prévisionnelle strictement avant aujourdhui (statut à naître).
bool get _dueDateInPast {
if (!_isUnborn) return false;
final iso = _dateToIso(_dueCtrl.text);
if (iso == null) return false;
try {
final due = DateTime.parse(iso);
final now = DateTime.now();
final dueDay = DateTime(due.year, due.month, due.day);
final today = DateTime(now.year, now.month, now.day);
return dueDay.isBefore(today);
} catch (_) {
return false;
}
}
Future<void> _loadLinkedAm() async { Future<void> _loadLinkedAm() async {
if (widget.isCreating) {
setState(() => _loadingAm = false);
return;
}
final enfantId = widget.enfant?.id;
if (enfantId == null || enfantId.isEmpty) {
setState(() => _loadingAm = false);
return;
}
setState(() => _loadingAm = true); setState(() => _loadingAm = true);
try { try {
final am = await UserService.findAmForEnfant(enfantId); final am = await UserService.findAmForEnfant(widget.enfant.id);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_linkedAm = am; _linkedAm = am;
@ -237,8 +156,8 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
} }
void _coerceGenderForStatus() { void _coerceGenderForStatus() {
if (_gender != null && !_availableGenders.contains(_gender)) { if (!_availableGenders.contains(_gender)) {
_gender = null; _gender = 'H';
} }
} }
@ -249,30 +168,19 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
String? _dateToIso(String text) => parseFrDateToIso(text); String? _dateToIso(String text) => parseFrDateToIso(text);
String _headerTitle() { String _headerTitle() {
if (widget.isCreating) {
final fn = _prenomCtrl.text.trim();
final ln = _nomCtrl.text.trim();
if (fn.isEmpty && ln.isEmpty) return 'Nouvel enfant';
return '$fn $ln'.trim();
}
final fn = _prenomCtrl.text.trim(); final fn = _prenomCtrl.text.trim();
final ln = _nomCtrl.text.trim(); final ln = _nomCtrl.text.trim();
if (fn.isEmpty && ln.isEmpty) { if (fn.isEmpty && ln.isEmpty) {
final fallback = widget.enfant?.fullName.trim() ?? ''; final fallback = widget.enfant.fullName.trim();
return fallback.isNotEmpty ? fallback : 'Enfant'; return fallback.isNotEmpty ? fallback : 'Enfant';
} }
return '$fn $ln'.trim(); return '$fn $ln'.trim();
} }
List<EnfantParentLink> get _parentLinks => List<EnfantParentLink> get _parentLinks => widget.enfant.parentLinks
(_localParentLinks ??
widget.enfant?.parentLinks ??
const <EnfantParentLink>[])
.where((l) => l.parentId.trim().isNotEmpty) .where((l) => l.parentId.trim().isNotEmpty)
.toList(); .toList();
bool get _isOrphan => !widget.isCreating && _parentLinks.isEmpty;
Future<void> _openParent(EnfantParentLink link) async { Future<void> _openParent(EnfantParentLink link) async {
if (_busy) return; if (_busy) return;
final id = link.parentId.trim(); final id = link.parentId.trim();
@ -297,17 +205,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
} }
Widget? _parentsSubtitle() { Widget? _parentsSubtitle() {
if (widget.isCreating) {
final family = _selectedFamily;
if (family == null) return null;
return Text(
family.parentNames.isNotEmpty
? 'Famille : ${family.parentNames.join(', ')}'
: family.displayTitle,
style: const TextStyle(fontSize: 13, color: Colors.black54),
);
}
final links = _parentLinks; final links = _parentLinks;
if (links.isEmpty) return null; if (links.isEmpty) return null;
@ -355,24 +252,15 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
Future<void> _save() async { Future<void> _save() async {
if (!_dirty) return; if (!_dirty) return;
if (widget.isCreating) {
await _saveCreate();
return;
}
final enfantId = widget.enfant?.id;
if (enfantId == null || enfantId.isEmpty) return;
setState(() => _saving = true); setState(() => _saving = true);
try { try {
await _syncAmPlacement(); await _syncAmPlacement();
await UserService.updateEnfant( await UserService.updateEnfant(
enfantId: enfantId, enfantId: widget.enfant.id,
body: { body: {
'first_name': formatPersonNameCase(_prenomCtrl.text), 'first_name': _prenomCtrl.text.trim(),
'last_name': formatPersonNameCase(_nomCtrl.text), 'last_name': _nomCtrl.text.trim(),
'status': _status, 'status': _status,
'gender': _gender, 'gender': _gender,
if (_isUnborn) if (_isUnborn)
@ -403,101 +291,21 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
} }
} }
Future<void> _saveCreate() async {
final family = _selectedFamily;
if (family == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Choisissez une famille / un dossier avant d\'enregistrer'),
),
);
return;
}
final prenom = formatPersonNameCase(_prenomCtrl.text);
final nom = formatPersonNameCase(_nomCtrl.text);
if (prenom.isEmpty || nom.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Prénom et nom sont obligatoires')),
);
return;
}
if (!_isUnborn && _dateToIso(_birthCtrl.text) == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Date de naissance invalide ou manquante')),
);
return;
}
final hasPhoto = _photoBytes != null && _photoBytes!.isNotEmpty;
if (hasPhoto && !_consentPhoto) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Activez le consentement photo pour enregistrer une photo',
),
),
);
return;
}
setState(() => _saving = true);
try {
await UserService.createEnfant(
parentUserId: family.pivotParentUserId,
photoBytes: hasPhoto ? _photoBytes : null,
photoFilename: _photoFilename,
body: {
'first_name': prenom,
'last_name': nom,
'status': _status,
'gender': _gender,
if (_isUnborn) ...{
if (_dateToIso(_dueCtrl.text) != null)
'due_date': _dateToIso(_dueCtrl.text),
} else ...{
if (_dateToIso(_birthCtrl.text) != null)
'birth_date': _dateToIso(_birthCtrl.text),
},
'consent_photo': _consentPhoto,
'is_multiple': _isMultiple,
},
);
if (!mounted) return;
setState(() {
_dirty = false;
_saving = false;
});
widget.onSaved?.call();
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enfant créé')),
);
} catch (e) {
if (!mounted) return;
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
/// Applique rattachement / détachement AM (différé jusqu'au Sauvegarder). /// Applique rattachement / détachement AM (différé jusqu'au Sauvegarder).
Future<void> _syncAmPlacement() async { Future<void> _syncAmPlacement() async {
final enfantId = widget.enfant?.id;
if (enfantId == null || enfantId.isEmpty) return;
final baselineId = _baselineAmUserId; final baselineId = _baselineAmUserId;
final currentId = _linkedAm?.user.id; final currentId = _linkedAm?.user.id;
if (baselineId != null && baselineId != currentId) { if (baselineId != null && baselineId != currentId) {
await UserService.detachEnfantFromAm( await UserService.detachEnfantFromAm(
amUserId: baselineId, amUserId: baselineId,
enfantId: enfantId, enfantId: widget.enfant.id,
); );
} }
if (currentId != null && currentId != baselineId) { if (currentId != null && currentId != baselineId) {
await UserService.attachEnfantToAm( await UserService.attachEnfantToAm(
amUserId: currentId, amUserId: currentId,
enfantId: enfantId, enfantId: widget.enfant.id,
); );
} }
} }
@ -534,9 +342,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
setState(() => _deleting = true); setState(() => _deleting = true);
try { try {
final id = widget.enfant?.id; await UserService.deleteEnfant(widget.enfant.id);
if (id == null || id.isEmpty) return;
await UserService.deleteEnfant(id);
if (!mounted) return; if (!mounted) return;
widget.onDeleted?.call(); widget.onDeleted?.call();
widget.onSaved?.call(); widget.onSaved?.call();
@ -570,12 +376,10 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
/// Recharge AM + statut après une modale externe (ex. fiche AM). /// Recharge AM + statut après une modale externe (ex. fiche AM).
Future<void> _reloadPlacementFromServer() async { Future<void> _reloadPlacementFromServer() async {
final id = widget.enfant?.id;
if (id == null || id.isEmpty) return;
try { try {
final results = await Future.wait([ final results = await Future.wait([
UserService.findAmForEnfant(id), UserService.findAmForEnfant(widget.enfant.id),
UserService.getEnfant(id), UserService.getEnfant(widget.enfant.id),
]); ]);
if (!mounted) return; if (!mounted) return;
final am = results[0] as AssistanteMaternelleModel?; final am = results[0] as AssistanteMaternelleModel?;
@ -587,7 +391,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
if (!enfantStatusValues.contains(_status)) { if (!enfantStatusValues.contains(_status)) {
_status = 'sans_garde'; _status = 'sans_garde';
} }
_localParentLinks = enfant.parentLinks;
_coerceGenderForStatus(); _coerceGenderForStatus();
}); });
} catch (_) { } catch (_) {
@ -673,121 +476,48 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
}); });
} }
/// Conserve la date saisie en basculant naissance prévisionnelle. InputDecoration _inputDecoration({String? hint}) {
void _preserveDateOnStatusChange(String from, String to) { return ValidationFieldDecoration.input(hint: hint).copyWith(
final becomingUnborn = to == 'a_naitre' && from != 'a_naitre';
final leavingUnborn = from == 'a_naitre' && to != 'a_naitre';
if (becomingUnborn) {
if (_dueCtrl.text.trim().isEmpty &&
_birthCtrl.text.trim().isNotEmpty) {
_dueCtrl.text = _birthCtrl.text;
}
} else if (leavingUnborn) {
if (_birthCtrl.text.trim().isEmpty &&
_dueCtrl.text.trim().isNotEmpty) {
_birthCtrl.text = _dueCtrl.text;
}
}
}
InputDecoration _inputDecoration({String? hint, bool error = false}) {
final base = ValidationFieldDecoration.input(hint: hint).copyWith(
isDense: false, isDense: false,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
); );
if (!error) return base;
final border = OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: Colors.red.shade400),
);
return base.copyWith(
fillColor: Colors.red.shade50,
enabledBorder: border,
focusedBorder: border,
border: border,
);
} }
Widget _dropdownField({ Widget _dropdownField({
Key? key, Key? key,
required String? value, required String value,
required List<MapEntry<String, String>> items, required List<MapEntry<String, String>> items,
required ValueChanged<String> onChanged, required ValueChanged<String> onChanged,
String? hint,
}) { }) {
final safeValue = final safeValue =
value != null && items.any((e) => e.key == value) ? value : null; items.any((e) => e.key == value) ? value : items.first.key;
return SizedBox( return SizedBox(
height: _fieldHeight, height: _fieldHeight,
child: DropdownButtonFormField<String>( child: DropdownButtonFormField<String>(
key: key, key: key,
value: safeValue, value: safeValue,
hint: hint != null
? Text(hint, style: TextStyle(color: Colors.grey.shade600))
: null,
isExpanded: true, isExpanded: true,
decoration: _inputDecoration(), decoration: _inputDecoration(),
items: items items: items
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
.toList(), .toList(),
onChanged: _busy onChanged: _busy ? null : (v) {
? null
: (v) {
if (v != null) onChanged(v); if (v != null) onChanged(v);
}, },
), ),
); );
} }
Widget _textField( Widget _textField(TextEditingController controller, {String? hint, Key? key}) {
TextEditingController controller, {
String? hint,
Key? key,
TextInputType? keyboardType,
List<TextInputFormatter>? inputFormatters,
bool error = false,
Widget? suffixIcon,
}) {
return SizedBox( return SizedBox(
height: _fieldHeight, height: _fieldHeight,
child: TextField( child: TextField(
key: key, key: key,
controller: controller, controller: controller,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
textAlignVertical: TextAlignVertical.center, textAlignVertical: TextAlignVertical.center,
style: TextStyle( style: const TextStyle(color: Colors.black87, fontSize: 14),
color: error ? Colors.red.shade800 : Colors.black87, decoration: _inputDecoration(hint: hint),
fontSize: 14,
), ),
decoration: _inputDecoration(
hint: hint,
error: error,
).copyWith(suffixIcon: suffixIcon),
),
);
}
Widget _dateField() {
final past = _dueDateInPast;
return _textField(
_activeDateCtrl,
hint: 'jj / mm / aaaa',
key: ValueKey(_status),
keyboardType: TextInputType.number,
inputFormatters: const [FrenchDateInputFormatter()],
error: past,
suffixIcon: past
? Tooltip(
message:
'Date prévisionnelle antérieure à aujourdhui',
child: Icon(
Icons.info_outline,
size: 20,
color: Colors.red.shade700,
),
)
: null,
); );
} }
@ -797,21 +527,19 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
fields: [ fields: [
ValidationLabeledField( ValidationLabeledField(
label: 'Prénom', label: 'Prénom',
field: _textField( field: _textField(_prenomCtrl),
_prenomCtrl,
inputFormatters: const [PersonNameInputFormatter()],
),
), ),
ValidationLabeledField( ValidationLabeledField(
label: 'Nom', label: 'Nom',
field: _textField( field: _textField(_nomCtrl),
_nomCtrl,
inputFormatters: const [PersonNameInputFormatter()],
),
), ),
ValidationLabeledField( ValidationLabeledField(
label: _dateFieldLabel, label: _dateFieldLabel,
field: _dateField(), field: _textField(
_activeDateCtrl,
hint: 'jj/mm/aaaa',
key: ValueKey(_status),
),
), ),
ValidationLabeledField( ValidationLabeledField(
label: 'Statut', label: 'Statut',
@ -827,7 +555,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
.toList(), .toList(),
onChanged: (v) { onChanged: (v) {
setState(() { setState(() {
_preserveDateOnStatusChange(_status, v);
_status = v; _status = v;
_coerceGenderForStatus(); _coerceGenderForStatus();
_dirty = true; _dirty = true;
@ -843,7 +570,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
field: _dropdownField( field: _dropdownField(
key: ValueKey('genre-$_status'), key: ValueKey('genre-$_status'),
value: _gender, value: _gender,
hint: 'Veuillez sélectionner le genre',
items: _availableGenders items: _availableGenders
.map((g) => MapEntry(g, _genderLabel(g))) .map((g) => MapEntry(g, _genderLabel(g)))
.toList(), .toList(),
@ -877,43 +603,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
); );
} }
Future<void> _pickPhoto() async {
if (_busy || !widget.isCreating) return;
try {
final picked = await ImagePicker().pickImage(
source: ImageSource.gallery,
imageQuality: 75,
maxWidth: 1200,
maxHeight: 1200,
);
if (picked == null) return;
final bytes = await picked.readAsBytes();
if (bytes.isEmpty) return;
final name = picked.name.trim();
if (!mounted) return;
setState(() {
_photoBytes = bytes;
_photoFilename = name.isNotEmpty ? name : 'photo.jpg';
_consentPhoto = true;
_dirty = true;
});
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Impossible de charger la photo')),
);
}
}
void _clearPhoto() {
if (_busy || !widget.isCreating) return;
setState(() {
_photoBytes = null;
_photoFilename = null;
_dirty = true;
});
}
Widget _mainRow() { Widget _mainRow() {
return LayoutBuilder( return LayoutBuilder(
builder: (context, c) { builder: (context, c) {
@ -936,20 +625,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
children: [ children: [
Expanded( Expanded(
child: AdminAmPhotoFrame( child: AdminAmPhotoFrame(
photoUrl: widget.isCreating photoUrl: widget.enfant.photoUrl,
? null
: widget.enfant?.photoUrl,
imageBytes: widget.isCreating ? _photoBytes : null,
onTap: widget.isCreating && !_busy ? _pickPhoto : null,
onClear: widget.isCreating &&
!_busy &&
_photoBytes != null &&
_photoBytes!.isNotEmpty
? _clearPhoto
: null,
emptyLabel: widget.isCreating
? 'Cliquer pour ajouter'
: 'Aucune photo',
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@ -968,12 +644,10 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
padding: const EdgeInsets.only(top: 16), padding: const EdgeInsets.only(top: 16),
child: Align( child: Align(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
child: SingleChildScrollView(
child: _placementBlock(), child: _placementBlock(),
), ),
), ),
), ),
),
], ],
), ),
), ),
@ -1219,34 +893,14 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
); );
} }
String get _placementTitle { String get _placementTitle =>
if (widget.isCreating) return 'Sélection du dossier de la famille'; _isScolarise ? 'Scolarisation' : 'Assistante maternelle';
return _isScolarise ? 'Scolarisation' : 'Assistante maternelle';
}
Widget _placementBlock() { Widget _placementBlock() {
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (_isOrphan) ...[
Text(
'Sélection du dossier de la famille',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.red.shade800,
),
),
const SizedBox(height: 4),
Text(
'Aucun responsable rattaché — choisissez un foyer',
style: TextStyle(fontSize: 12, color: Colors.red.shade700),
),
const SizedBox(height: 8),
_familyPlacementSection(),
const SizedBox(height: 16),
],
Text( Text(
_placementTitle, _placementTitle,
style: TextStyle( style: TextStyle(
@ -1256,196 +910,11 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
if (widget.isCreating) _familyPlacementSection() else _placementSection(), _placementSection(),
], ],
); );
} }
Future<void> _pickFamily() async {
if (_busy) return;
final selected = await AdminSelectFamilleModal.show(
context,
title: 'Choisir une famille',
);
if (selected == null || !mounted) return;
if (widget.isCreating) {
setState(() {
_selectedFamily = selected;
_dirty = true;
});
return;
}
// Édition orphelin (#157) : rattache immédiatement au foyer.
final enfantId = widget.enfant?.id;
if (enfantId == null || enfantId.isEmpty) return;
setState(() => _saving = true);
try {
for (final parentId in selected.parentUserIds) {
await UserService.attachEnfantToParent(
parentUserId: parentId,
enfantId: enfantId,
);
}
final refreshed = await UserService.getEnfant(enfantId);
if (!mounted) return;
setState(() {
_localParentLinks = refreshed.parentLinks;
_selectedFamily = selected;
_saving = false;
});
widget.onSaved?.call();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enfant rattaché au foyer')),
);
} catch (e) {
if (!mounted) return;
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
Widget _familyPlacementSection() {
final family = _selectedFamily;
if (family == null) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: _busy ? null : _pickFamily,
borderRadius: BorderRadius.circular(10),
child: Container(
height: _placementHeight,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.grey.shade300),
),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
),
child: Icon(
Icons.family_restroom,
size: 30,
color: Colors.grey.shade400,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Aucune famille sélectionnée',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: Colors.grey.shade800,
),
),
const SizedBox(height: 4),
Text(
'Cliquer pour choisir un dossier / foyer',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
),
],
),
),
Icon(Icons.add_circle_outline, color: Colors.grey.shade500),
],
),
),
),
);
}
return Material(
color: Colors.transparent,
child: InkWell(
onTap: _busy ? null : _pickFamily,
borderRadius: BorderRadius.circular(10),
child: Container(
height: _placementHeight,
width: double.infinity,
decoration: BoxDecoration(
color: const Color(0xFFF8F5FC),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFD8CCE8)),
),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: const Color(0xFFEDE5FA),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.family_restroom,
size: 30,
color: Color(0xFF6B3FA0),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
family.displayTitle,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (family.parentNames.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
family.parentNames.join(', '),
style: const TextStyle(
fontSize: 13,
color: Colors.black54,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
IconButton(
icon: Icon(Icons.swap_horiz, color: Colors.grey.shade700),
tooltip: 'Changer de famille',
onPressed: _busy ? null : _pickFamily,
),
],
),
),
),
);
}
Widget _placementSection() { Widget _placementSection() {
if (!_showsAmPlacement) return _scolariseCard(); if (!_showsAmPlacement) return _scolariseCard();
@ -1497,7 +966,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
const SizedBox(width: 12), const SizedBox(width: 12),
ElevatedButton( ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle, style: ValidationModalTheme.primaryElevatedStyle,
onPressed: !_canSubmit || _busy ? null : _save, onPressed: !_dirty || _busy ? null : _save,
child: _saving child: _saving
? const SizedBox( ? const SizedBox(
width: 18, width: 18,
@ -1507,11 +976,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
color: Colors.white, color: Colors.white,
), ),
) )
: Text( : Text(_dirty ? 'Sauvegarder' : 'Aucune modification'),
widget.isCreating
? 'Créer'
: (_dirty ? 'Sauvegarder' : 'Aucune modification'),
),
), ),
], ],
); );

View File

@ -3,7 +3,6 @@ import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart';
import 'package:p_tits_pas/utils/enfant_vigilance.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
List<String> enfantAdminSubtitleLines({ List<String> enfantAdminSubtitleLines({
@ -37,8 +36,6 @@ class AdminEnfantUserCard extends StatelessWidget {
final VoidCallback? onCardTap; final VoidCallback? onCardTap;
final EdgeInsetsGeometry? margin; final EdgeInsetsGeometry? margin;
final EdgeInsetsGeometry? contentPadding; final EdgeInsetsGeometry? contentPadding;
final Color? borderColor;
final String? vigilanceTooltip;
const AdminEnfantUserCard({ const AdminEnfantUserCard({
super.key, super.key,
@ -49,8 +46,6 @@ class AdminEnfantUserCard extends StatelessWidget {
this.onCardTap, this.onCardTap,
this.margin, this.margin,
this.contentPadding, this.contentPadding,
this.borderColor,
this.vigilanceTooltip,
}); });
factory AdminEnfantUserCard.fromEnfant( factory AdminEnfantUserCard.fromEnfant(
@ -60,13 +55,10 @@ class AdminEnfantUserCard extends StatelessWidget {
VoidCallback? onCardTap, VoidCallback? onCardTap,
EdgeInsetsGeometry? margin, EdgeInsetsGeometry? margin,
EdgeInsetsGeometry? contentPadding, EdgeInsetsGeometry? contentPadding,
Color? borderColor,
String? vigilanceTooltip,
}) { }) {
final parents = enfant.parentLinks final parents = enfant.parentLinks
.map((l) => l.parentName ?? 'Parent') .map((l) => l.parentName ?? 'Parent')
.join(', '); .join(', ');
final orphan = enfantHasNoResponsable(enfant);
return AdminEnfantUserCard( return AdminEnfantUserCard(
title: enfant.fullName, title: enfant.fullName,
photoUrl: enfant.photoUrl, photoUrl: enfant.photoUrl,
@ -77,7 +69,6 @@ class AdminEnfantUserCard extends StatelessWidget {
gender: enfant.gender, gender: enfant.gender,
extra: [ extra: [
if (parents.isNotEmpty) 'Responsables : $parents', if (parents.isNotEmpty) 'Responsables : $parents',
if (orphan) 'Aucun responsable rattaché',
...extraSubtitleLines, ...extraSubtitleLines,
], ],
), ),
@ -85,10 +76,6 @@ class AdminEnfantUserCard extends StatelessWidget {
onCardTap: onCardTap, onCardTap: onCardTap,
margin: margin, margin: margin,
contentPadding: contentPadding, contentPadding: contentPadding,
borderColor: borderColor ??
(orphan ? Colors.red.shade300 : null),
vigilanceTooltip: vigilanceTooltip ??
enfantSansResponsableVigilanceMessage(enfant),
); );
} }
@ -127,8 +114,6 @@ class AdminEnfantUserCard extends StatelessWidget {
onCardTap: onCardTap, onCardTap: onCardTap,
margin: margin, margin: margin,
contentPadding: contentPadding, contentPadding: contentPadding,
borderColor: borderColor,
vigilanceTooltip: vigilanceTooltip,
); );
} }
} }

View File

@ -283,7 +283,8 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Détacher l\'enfant'), title: const Text('Détacher l\'enfant'),
content: Text( content: Text(
'Retirer ${child.fullName} de la fiche de ce parent ?', 'Retirer ${child.fullName} de la fiche de ce parent ?\n'
'(L\'enfant ne sera pas supprimé.)',
), ),
actions: [ actions: [
TextButton( TextButton(

View File

@ -1,139 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
/// Foyer / famille sélectionnable pour rattacher un nouvel enfant (#132 / #157).
class AdminFamilleFoyer {
/// Parent pivot pour `POST /enfants` (`parent_user_id`).
final String pivotParentUserId;
/// Co-parent éventuel (rattachement foyer #157).
final String? coParentUserId;
final String? numeroDossier;
final String displayTitle;
final List<String> parentNames;
const AdminFamilleFoyer({
required this.pivotParentUserId,
required this.displayTitle,
required this.parentNames,
this.coParentUserId,
this.numeroDossier,
});
/// Parents du foyer à lier à lenfant (pivot puis co-parent).
List<String> get parentUserIds {
final ids = <String>[pivotParentUserId];
final co = (coParentUserId ?? '').trim();
if (co.isNotEmpty && co != pivotParentUserId) ids.add(co);
return ids;
}
String get subtitle {
final parts = <String>[];
final dossier = (numeroDossier ?? '').trim();
if (dossier.isNotEmpty) parts.add('Dossier $dossier');
if (parentNames.isNotEmpty) {
parts.add('Responsables : ${parentNames.join(', ')}');
}
return parts.join(' ');
}
}
/// Construit la liste des foyers uniques à partir de `GET /parents`.
List<AdminFamilleFoyer> buildFamilleFoyers(List<ParentModel> parents) {
final seenDossiers = <String>{};
final seenUserIds = <String>{};
final foyers = <AdminFamilleFoyer>[];
for (final p in parents) {
final dossier = (p.user.numeroDossier ?? '').trim();
if (dossier.isNotEmpty) {
if (seenDossiers.contains(dossier)) continue;
seenDossiers.add(dossier);
} else if (seenUserIds.contains(p.user.id)) {
continue;
}
seenUserIds.add(p.user.id);
final co = p.coParent;
if (co != null) seenUserIds.add(co.id);
final names = <String>[
if (p.user.fullName.trim().isNotEmpty) p.user.fullName.trim(),
if (co != null && co.fullName.trim().isNotEmpty) co.fullName.trim(),
];
final title = dossier.isNotEmpty
? 'Dossier $dossier'
: (names.isNotEmpty ? names.first : 'Famille');
foyers.add(
AdminFamilleFoyer(
pivotParentUserId: p.user.id,
coParentUserId: co?.id,
numeroDossier: dossier.isNotEmpty ? dossier : null,
displayTitle: title,
parentNames: names,
),
);
}
foyers.sort(
(a, b) => a.displayTitle.toLowerCase().compareTo(b.displayTitle.toLowerCase()),
);
return foyers;
}
/// Sélection d'une famille / dossier pour créer un enfant — ticket #132.
class AdminSelectFamilleModal {
AdminSelectFamilleModal._();
static Future<AdminFamilleFoyer?> show(
BuildContext context, {
String title = 'Choisir une famille',
}) {
return AdminSelectListModal.show<AdminFamilleFoyer>(
context,
title: title,
searchHint: 'Rechercher par dossier, nom…',
emptyMessage: 'Aucune famille disponible',
noResultsMessage: 'Aucun résultat pour cette recherche',
loadItems: () async {
final parents = await UserService.getParents();
return buildFamilleFoyers(parents);
},
matchesQuery: (f, q) {
final dossier = (f.numeroDossier ?? '').toLowerCase();
final title = f.displayTitle.toLowerCase();
final names = f.parentNames.join(' ').toLowerCase();
return dossier.contains(q) || title.contains(q) || names.contains(q);
},
itemBuilder: (context, f, onSelect) {
return AdminUserCard(
title: f.displayTitle,
fallbackIcon: Icons.family_restroom,
subtitleLines: [
if (f.parentNames.isNotEmpty) f.parentNames.join(', '),
if ((f.numeroDossier ?? '').isNotEmpty)
'Dossier ${f.numeroDossier}',
],
onCardTap: onSelect,
margin: const EdgeInsets.only(bottom: 4),
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
actions: [
IconButton(
icon: const Icon(Icons.check_circle_outline),
tooltip: 'Choisir',
onPressed: onSelect,
),
],
);
},
);
}
}

View File

@ -72,14 +72,7 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
normalizeEnfantStatus(e.status) == normalizeEnfantStatus(e.status) ==
normalizeEnfantStatus(widget.statusFilter); normalizeEnfantStatus(widget.statusFilter);
return matchesName && matchesStatus; return matchesName && matchesStatus;
}).toList() }).toList();
..sort((a, b) {
// Orphelins (#157) en tête, puis ordre alphabétique.
final ao = a.hasNoResponsable ? 0 : 1;
final bo = b.hasNoResponsable ? 0 : 1;
if (ao != bo) return ao.compareTo(bo);
return a.fullName.toLowerCase().compareTo(b.fullName.toLowerCase());
});
return UserList( return UserList(
isLoading: _isLoading, isLoading: _isLoading,

View File

@ -80,7 +80,7 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
onCardTap: () => _openParentDetails(parent), onCardTap: () => _openParentDetails(parent),
subtitleLines: [ subtitleLines: [
parent.user.email, parent.user.email,
'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${ParentModel.foyerChildrenCount(parent, _parents)}', 'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.children.isNotEmpty ? parent.children.length : parent.childrenCount}',
], ],
actions: [ actions: [
IconButton( IconButton(

View File

@ -3,7 +3,6 @@ import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
@ -27,7 +26,6 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
int _subIndex = 0; int _subIndex = 0;
int _gestionnaireRefreshTick = 0; int _gestionnaireRefreshTick = 0;
int _adminRefreshTick = 0; int _adminRefreshTick = 0;
int _enfantRefreshTick = 0;
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final TextEditingController _amCapacityController = TextEditingController(); final TextEditingController _amCapacityController = TextEditingController();
String? _parentStatus; String? _parentStatus;
@ -266,7 +264,6 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
); );
case 1: case 1:
return EnfantManagementWidget( return EnfantManagementWidget(
key: ValueKey('enfants-$_enfantRefreshTick'),
searchQuery: _searchController.text, searchQuery: _searchController.text,
statusFilter: _enfantStatus, statusFilter: _enfantStatus,
); );
@ -314,23 +311,6 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
Future<void> _handleAddPressed() async { Future<void> _handleAddPressed() async {
final contentIndex = _subIndex - _contentIndexOffset; final contentIndex = _subIndex - _contentIndexOffset;
if (contentIndex == 1) {
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return AdminChildDetailModal.create(
onSaved: () {
if (!mounted) return;
setState(() => _enfantRefreshTick++);
},
);
},
);
return;
}
if (contentIndex == 3) { if (contentIndex == 3) {
final created = await showDialog<bool>( final created = await showDialog<bool>(
context: context, context: context,

View File

@ -206,7 +206,7 @@ packages:
source: hosted source: hosted
version: "1.2.2" version: "1.2.2"
http_parser: http_parser:
dependency: "direct main" dependency: transitive
description: description:
name: http_parser name: http_parser
sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"

View File

@ -21,7 +21,6 @@ dependencies:
js: ^0.6.7 js: ^0.6.7
url_launcher: ^6.2.4 url_launcher: ^6.2.4
http: ^1.2.2 http: ^1.2.2
http_parser: ^4.0.2
# flutter_secure_storage: ^9.0.0 # flutter_secure_storage: ^9.0.0
pdfx: ^2.5.0 pdfx: ^2.5.0
universal_platform: ^1.1.0 universal_platform: ^1.1.0