Compare commits

..
Author SHA1 Message Date
jmartinandCursor d2172eafdb docs: snapshot empreinte code / disque (2026-07-22).
Mémoire après alerte disque plein — lignes de code et contexte VPS.

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 15:31:47 +02:00
30 changed files with 2444 additions and 280 deletions
@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsBoolean,
IsDateString,
@@ -6,11 +7,23 @@ import {
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
ValidateIf,
} from 'class-validator';
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 {
@ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE })
@IsEnum(StatutEnfantType)
@@ -52,6 +65,7 @@ export class CreateEnfantsDto {
photo_url?: string;
@ApiProperty({ default: false })
@Transform(toBoolean)
@IsBoolean()
consent_photo: boolean;
@@ -61,6 +75,20 @@ export class CreateEnfantsDto {
consent_photo_at?: string;
@ApiProperty({ default: false })
@Transform(toBoolean)
@IsBoolean()
is_multiple: boolean;
/**
* Parent pivot du foyer — obligatoire pour staff (gestionnaire/admin).
* Ignoré / interdit en externe pour un PARENT (ticket #132).
*/
@ApiPropertyOptional({
description:
'UUID du parent pivot (staff only). Obligatoire pour GESTIONNAIRE / ADMIN / SUPER_ADMIN.',
format: 'uuid',
})
@IsOptional()
@IsUUID('4')
parent_user_id?: string;
}
@@ -1,20 +1,33 @@
import {
Body,
CallHandler,
Controller,
Delete,
ExecutionContext,
Get,
HttpCode,
HttpStatus,
Injectable,
NestInterceptor,
Param,
ParseUUIDPipe,
Patch,
Post,
UploadedFile,
UseGuards,
UseInterceptors,
UploadedFile,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiTags, ApiConsumes } from '@nestjs/swagger';
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { diskStorage } from 'multer';
import { extname } from 'path';
import { Observable } from 'rxjs';
import { EnfantsService } from './enfants.service';
import { CreateEnfantsDto } from './dto/create_enfants.dto';
import { UpdateEnfantsDto } from './dto/update_enfants.dto';
@@ -24,18 +37,7 @@ import { AuthGuard } from 'src/common/guards/auth.guard';
import { Roles } from 'src/common/decorators/roles.decorator';
import { RolesGuard } from 'src/common/guards/roles.guard';
@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', {
const photoMulterOptions = {
storage: diskStorage({
destination: './uploads/photos',
filename: (req, file, cb) => {
@@ -53,8 +55,57 @@ export class EnfantsController {
limits: {
fileSize: 5 * 1024 * 1024,
},
}),
};
/**
* Multer uniquement si Content-Type multipart (parent ou staff + photo).
* JSON sans photo (#132) passe sans interceptor fichier.
*/
@Injectable()
class OptionalEnfantPhotoInterceptor implements NestInterceptor {
private readonly multipart = new (FileInterceptor(
'photo',
photoMulterOptions,
))();
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> | Promise<Observable<unknown>> {
const req = context.switchToHttp().getRequest();
const ct = String(req.headers['content-type'] ?? '');
if (!ct.includes('multipart/form-data')) {
return next.handle();
}
return this.multipart.intercept(context, next);
}
}
@ApiBearerAuth('access-token')
@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(
@Body() dto: CreateEnfantsDto,
@UploadedFile() photo: Express.Multer.File,
+107 -27
View File
@@ -13,6 +13,12 @@ import { ParentsChildren } from 'src/entities/parents_children.entity';
import { RoleType, Users } from 'src/entities/users.entity';
import { CreateEnfantsDto } from './dto/create_enfants.dto';
const STAFF_ROLES: RoleType[] = [
RoleType.GESTIONNAIRE,
RoleType.ADMINISTRATEUR,
RoleType.SUPER_ADMIN,
];
@Injectable()
export class EnfantsService {
constructor(
@@ -24,20 +30,35 @@ export class EnfantsService {
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
) { }
// Création d'un enfant
async create(dto: CreateEnfantsDto, currentUser: Users, photoFile?: Express.Multer.File): Promise<Children> {
private isStaff(user: Users): boolean {
return STAFF_ROLES.includes(user.role);
}
/**
* Création d'un enfant.
* - PARENT : rattache au parent connecté (multipart photo optionnel).
* - Staff : `parent_user_id` obligatoire ; JSON sans photo OK ;
* avec photo → multipart (même stockage `/uploads/photos/...`). Ticket #132.
*/
async create(
dto: CreateEnfantsDto,
currentUser: Users,
photoFile?: Express.Multer.File,
): Promise<Children> {
const pivotUserId = this.resolvePivotParentUserId(dto, currentUser);
const parent = await this.parentsRepository.findOne({
where: { user_id: currentUser.id },
where: { user_id: pivotUserId },
relations: ['co_parent'],
});
if (!parent) throw new NotFoundException('Parent introuvable');
// Vérif métier simple
// Vérif métier simple (aligné comportement historique parent)
if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) {
throw new BadRequestException('Un enfant né doit avoir une date de naissance');
}
// Vérif doublon éventuel (ex: même prénom + date de naissance pour ce parent)
// Vérif doublon éventuel (ex: même prénom + date de naissance)
const exist = await this.childrenRepository.findOne({
where: {
first_name: dto.first_name,
@@ -47,50 +68,108 @@ export class EnfantsService {
});
if (exist) throw new ConflictException('Cet enfant existe déjà');
// Gestion de la photo uploadée
// Gestion de la photo uploadée (multipart parent ou staff)
let photoUrl = dto.photo_url;
let consentAt: Date | undefined;
if (photoFile) {
dto.photo_url = `/uploads/photos/${photoFile.filename}`;
photoUrl = `/uploads/photos/${photoFile.filename}`;
if (dto.consent_photo) {
dto.consent_photo_at = new Date().toISOString();
consentAt = new Date();
}
} else if (dto.consent_photo) {
consentAt = dto.consent_photo_at
? new Date(dto.consent_photo_at)
: new Date();
}
// Création
const child = this.childrenRepository.create(dto);
const child = this.childrenRepository.create({
status: dto.status,
first_name: dto.first_name,
last_name: dto.last_name,
gender: dto.gender,
birth_date: dto.birth_date ? new Date(dto.birth_date) : undefined,
due_date: dto.due_date ? new Date(dto.due_date) : undefined,
photo_url: photoUrl,
consent_photo: !!dto.consent_photo,
consent_photo_at: consentAt,
is_multiple: !!dto.is_multiple,
});
await this.childrenRepository.save(child);
// Lien parent-enfant (Parent 1)
const parentLink = this.parentsChildrenRepository.create({
// Lien parent-enfant (pivot)
await this.parentsChildrenRepository.save(
this.parentsChildrenRepository.create({
parentId: parent.user_id,
enfantId: child.id,
});
await this.parentsChildrenRepository.save(parentLink);
}),
);
// Rattachement automatique au co-parent s'il existe
if (parent.co_parent) {
const coParentLink = this.parentsChildrenRepository.create({
await this.parentsChildrenRepository.save(
this.parentsChildrenRepository.create({
parentId: parent.co_parent.id,
enfantId: child.id,
});
await this.parentsChildrenRepository.save(coParentLink);
}),
);
}
return this.findOne(child.id, currentUser);
}
// Liste des enfants (admin/gestionnaire)
async findAll(): Promise<Children[]> {
return this.childrenRepository.find({
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
order: { last_name: 'ASC', first_name: 'ASC' },
private resolvePivotParentUserId(
dto: CreateEnfantsDto,
currentUser: Users,
): 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'],
order: { last_name: 'ASC', first_name: 'ASC' },
});
return children.map((c) => this.withSansResponsable(c));
}
// Récupérer un enfant par id
async findOne(id: string, currentUser: Users): Promise<Children> {
async findOne(
id: string,
currentUser: Users,
): Promise<Children & { sans_responsable: boolean }> {
const child = await this.childrenRepository.findOne({
where: { id },
relations: ['parentLinks'],
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
});
if (!child) throw new NotFoundException('Enfant introuvable');
@@ -104,14 +183,14 @@ export class EnfantsService {
case RoleType.ADMINISTRATEUR:
case RoleType.SUPER_ADMIN:
case RoleType.GESTIONNAIRE:
// accès complet
// accès complet (y compris orphelins)
break;
default:
throw new ForbiddenException('Accès interdit');
}
return child;
return this.withSansResponsable(child);
}
@@ -120,7 +199,8 @@ export class EnfantsService {
const child = await this.childrenRepository.findOne({ where: { id } });
if (!child) throw new NotFoundException('Enfant introuvable');
const patch: Partial<Children> = { ...dto } as Partial<Children>;
const { parent_user_id: _ignored, ...rest } = dto;
const patch: Partial<Children> = { ...rest } as Partial<Children>;
if (dto.consent_photo !== undefined) {
patch.consent_photo = dto.consent_photo;
patch.consent_photo_at = dto.consent_photo ? new Date() : null!;
+69 -17
View File
@@ -114,34 +114,86 @@ export class ParentsService {
}
/**
* Rattacher un enfant existant à un parent (enfants_parents). Ticket #115 / doc 28 §6.2.
* Membres du foyer (user ids) pour affiliation enfant.
* Pivot + co-parent (A→B et B→A) + même numero_dossier. Ticket #158.
*/
async attachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
await this.findOne(parentUserId);
private async resolveFoyerParentUserIds(parent: Parents): Promise<string[]> {
const ids = new Set<string>([parent.user_id]);
const existing = await this.parentsChildrenRepository.findOne({
where: { parentId: parentUserId, enfantId },
});
if (existing) {
throw new ConflictException('Cet enfant est déjà rattaché à ce parent');
if (parent.co_parent?.id) {
ids.add(parent.co_parent.id);
}
const child = await this.parentsRepository.manager.findOne(Children, { where: { id: enfantId } });
// Sens inverse : parents qui déclarent ce user comme co-parent
const reverseLinks = await this.parentsRepository.find({
where: { co_parent: { id: parent.user_id } },
relations: ['co_parent'],
});
for (const p of reverseLinks) {
ids.add(p.user_id);
if (p.co_parent?.id) ids.add(p.co_parent.id);
}
const dossier = parent.numero_dossier?.trim();
if (dossier) {
const sameDossier = await this.parentsRepository.find({
where: { numero_dossier: dossier },
relations: ['co_parent'],
});
for (const p of sameDossier) {
ids.add(p.user_id);
if (p.co_parent?.id) ids.add(p.co_parent.id);
}
}
return [...ids];
}
/**
* Rattacher un enfant au foyer du parent (tous les responsables). Ticket #158.
* Un seul POST suffit : liens créés pour pivot + co-parent / même dossier.
*/
async attachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
const parent = await this.findOne(parentUserId);
const child = await this.parentsRepository.manager.findOne(Children, {
where: { id: enfantId },
});
if (!child) {
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: parentUserId, enfantId }),
this.parentsChildrenRepository.create({
parentId: memberId,
enfantId,
}),
);
created += 1;
}
if (created === 0) {
throw new ConflictException('Cet enfant est déjà rattaché à ce foyer');
}
return this.findOne(parentUserId);
}
/**
* Détacher un enfant d'un parent sans supprimer l'enfant. Ticket #115 / doc 28 §6.2.
* Détacher un enfant du foyer du parent (tous les responsables). Ticket #158.
* Si plus aucun lien ensuite → enfant orphelin (#157).
*/
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
await this.findOne(parentUserId);
const parent = await this.findOne(parentUserId);
const link = await this.parentsChildrenRepository.findOne({
where: { parentId: parentUserId, enfantId },
@@ -150,12 +202,12 @@ export class ParentsService {
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');
}
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);
}
@@ -24,15 +24,19 @@ export class RelaisController {
}
@Get()
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Lister tous les relais' })
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({
summary: 'Lister tous les relais',
description:
'Lecture ouverte aux gestionnaires (combobox fiches). CRUD write reste admin-only. Ticket #151.',
})
@ApiResponse({ status: 200, description: 'Liste des relais.' })
findAll() {
return this.relaisService.findAll();
}
@Get(':id')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Récupérer un relais par ID' })
@ApiResponse({ status: 200, description: 'Le relais trouvé.' })
findOne(@Param('id') id: string) {
@@ -0,0 +1,104 @@
# Analyse dempreinte — P'titsPas
**Date :** 2026-07-22
**Contexte :** snapshot pour mémoire (après alerte disque plein + purge cache Gitea)
**Périmètre :** application `jmartin/petitspas` déployée sur le VPS (`/home/deploy/dev/ptitspas-app`)
---
## 1. Lignes de code
Comptage brut (`wc -l`), hors `node_modules` / builds / `.dart_tool`.
| Zone | Lignes | Fichiers |
|------|--------|----------|
| **Frontend** (Dart `frontend/lib/`) | ~29 800 | 141 |
| **Backend** (TypeScript `backend/src/`) | ~10 000 | 141 |
| **BDD** (SQL sous `database/`) | ~1 250 | ~15 |
| **Total** | **~41 000** | |
### Frontend (détail)
| Dossier | Lignes |
|---------|--------|
| `widgets/` | ~18 400 |
| `screens/` | ~5 000 |
| `services/` | ~2 300 |
| `models/` | ~2 200 |
| `utils/` | ~1 400 |
| reste | ~500 |
### Backend (détail)
| Dossier | Lignes |
|---------|--------|
| `routes/` | ~6 500 |
| `modules/` | ~1 700 |
| `entities/` | ~1 000 |
| `common/` + `config/` | ~400 |
### BDD (détail)
| Élément | Lignes |
|---------|--------|
| `BDD.sql` (schéma) | ~470 |
| seeds | ~300 |
| migrations / patches | ~280 |
| tests SQL | ~205 |
---
## 2. Empreinte disque — runtime (Docker)
| Composant | Taille | Notes |
|-----------|--------|--------|
| Image `ptitspas-app-backend` | ~300 Mo | NestJS |
| Image `ptitspas-app-frontend` | ~122 Mo | Flutter web + Nginx |
| Image `postgres:17` | ~454 Mo | |
| Image `dpage/pgadmin4` | ~534 Mo | optionnel |
| Volume `postgres_data` | ~49 Mo | données BDD live |
| Volume `backend_uploads` | ~20 Mo | photos |
| Volume `backend_documents_legaux` | ~0 | |
| **Stack complète (avec pgAdmin)** | **~1,5 Go** | |
| **Stack prod sans pgAdmin** | **~945 Mo** | |
---
## 3. Empreinte disque — code source
| Élément | Taille |
|---------|--------|
| Clone `/home/deploy/dev/ptitspas-app` | ~701 Mo |
| dont `backend/node_modules` | ~354 Mo |
| dont `frontend` (+ `.dart_tool`) | ~114 Mo |
| **Code utile** (hors deps / `.git` / builds) | **~51 Mo** |
| Repo Gitea `petitspas.git` | ~109 Mo |
| Dump `database/BDD.sql` | ~19 Ko |
---
## 4. Pour (re)déployer
Minimum requis :
1. Images custom backend + frontend (~422 Mo), ou code + build Docker
2. Image `postgres:17` (~454 Mo)
3. Volumes persistants (~70 Mo au snapshot)
4. Optionnel : pgAdmin (~534 Mo)
**Ordre de grandeur :** ~1 Go pour faire tourner lapp en prod (sans pgAdmin).
---
## 5. Notes infra du jour (lié)
- Disque VPS passé de **100 %** à ~**44 %** après :
- purge cache Docker / journals
- purge officielle Gitea `delete_repo_archives` (~24 Go de zip/bundle `petitspas`)
- Crons Gitea activés :
- `archive_cleanup` @midnight (`OLDER_THAN = 24h`)
- `delete_repo_archives` @weekly
---
*Fichier généré pour historique projet — ne pas considérer comme métrique CI automatisée.*
@@ -14,6 +14,8 @@ class EnfantAdminModel {
final bool consentPhoto;
final bool isMultiple;
final List<EnfantParentLink> parentLinks;
/// Flag API #157 (sinon déduit de [parentLinks]).
final bool? sansResponsable;
EnfantAdminModel({
required this.id,
@@ -27,6 +29,7 @@ class EnfantAdminModel {
this.consentPhoto = false,
this.isMultiple = false,
this.parentLinks = const [],
this.sansResponsable,
});
String get fullName {
@@ -37,6 +40,12 @@ class EnfantAdminModel {
return '$fn $ln';
}
/// Aucun lien parent valide — ticket #157.
bool get hasNoResponsable {
if (sansResponsable != null) return sansResponsable!;
return !parentLinks.any((l) => l.parentId.trim().isNotEmpty);
}
EnfantAdminModel copyWith({
String? id,
String? firstName,
@@ -49,6 +58,7 @@ class EnfantAdminModel {
bool? consentPhoto,
bool? isMultiple,
List<EnfantParentLink>? parentLinks,
bool? sansResponsable,
}) {
return EnfantAdminModel(
id: id ?? this.id,
@@ -62,6 +72,7 @@ class EnfantAdminModel {
consentPhoto: consentPhoto ?? this.consentPhoto,
isMultiple: isMultiple ?? this.isMultiple,
parentLinks: parentLinks ?? this.parentLinks,
sansResponsable: sansResponsable ?? this.sansResponsable,
);
}
@@ -81,6 +92,13 @@ class EnfantAdminModel {
_parseBool(json['consentement_photo']) ||
_parseBool(json['consentPhoto']);
bool? sansResponsable;
if (json.containsKey('sans_responsable') ||
json.containsKey('sansResponsable')) {
sansResponsable = _parseBool(json['sans_responsable']) ||
_parseBool(json['sansResponsable']);
}
return EnfantAdminModel(
id: (json['id'] ?? '').toString(),
firstName: json['first_name'] as String? ?? json['prenom'] as String?,
@@ -96,6 +114,7 @@ class EnfantAdminModel {
isMultiple: _parseBool(json['is_multiple']) ||
_parseBool(json['est_multiple']),
parentLinks: links,
sansResponsable: sansResponsable,
);
}
+52
View File
@@ -62,4 +62,56 @@ class ParentModel {
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;
}
}
@@ -146,6 +146,21 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
super.dispose();
}
/// Fallback si GET /relais échoue : conserve le relais déjà connu sur l'utilisateur.
List<RelaisModel> _fallbackRelaisFromUser() {
final id = _selectedRelaisId?.trim();
if (id == null || id.isEmpty) return const [];
final nom = (widget.initialUser?.relaisNom ?? '').trim();
return [
RelaisModel(
id: id,
nom: nom.isNotEmpty ? nom : 'Relais actuel',
adresse: '',
actif: true,
),
];
}
Future<void> _loadRelais() async {
try {
final list = await RelaisService.getRelais();
@@ -162,7 +177,8 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
if (selected != null) {
filtered.add(selected);
} else {
_selectedRelaisId = null;
// Garder l'id sélectionné et afficher un item de secours (nom carte).
filtered.addAll(_fallbackRelaisFromUser());
}
}
@@ -172,9 +188,9 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
});
} catch (_) {
if (!mounted) return;
// Ne pas nullifier _selectedRelaisId (#151) — la carte a déjà le bon libellé.
setState(() {
_selectedRelaisId = null;
_relais = [];
_relais = _fallbackRelaisFromUser();
_isLoadingRelais = false;
});
}
+109 -1
View File
@@ -1,5 +1,6 @@
import 'dart:convert';
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/enfant_admin_model.dart';
import 'package:p_tits_pas/models/parent_model.dart';
@@ -8,6 +9,7 @@ import 'package:p_tits_pas/models/pending_family.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/services/api/tokenService.dart';
import 'package:p_tits_pas/utils/am_vigilance.dart';
class DocumentActifInfo {
final String id;
@@ -56,6 +58,33 @@ class UserService {
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) {
if (err == null) return 'Erreur inconnue';
if (err is String) return err;
@@ -514,6 +543,68 @@ class UserService {
return enrichEnfantParentNames(enfant);
}
/// Création enfant côté staff (#132) — nécessite `POST /enfants` étendu (voir mini-spec).
/// [parentUserId] : parent pivot du foyer (`parent_user_id`).
/// Avec [photoBytes] : `multipart/form-data` (champ fichier `photo`), sinon JSON.
static Future<EnfantAdminModel> createEnfant({
required String parentUserId,
required Map<String, dynamic> body,
List<int>? photoBytes,
String? photoFilename,
}) async {
final hasPhoto = photoBytes != null && photoBytes.isNotEmpty;
final http.Response response;
if (hasPhoto) {
final token = await TokenService.getToken();
final req = http.MultipartRequest(
'POST',
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
);
req.headers['Accept'] = 'application/json';
if (token != null) {
req.headers['Authorization'] = 'Bearer $token';
}
body.forEach((key, value) {
if (value == null) return;
req.fields[key] = value is bool
? (value ? 'true' : 'false')
: value.toString();
});
req.fields['parent_user_id'] = parentUserId;
final name = (photoFilename ?? '').trim();
final filename = name.isNotEmpty ? name : 'photo.jpg';
req.files.add(
http.MultipartFile.fromBytes(
'photo',
photoBytes,
filename: filename,
contentType: _imageMediaType(filename, photoBytes),
),
);
final streamed = await req.send();
response = await http.Response.fromStream(streamed);
} else {
final payload = <String, dynamic>{
...body,
'parent_user_id': parentUserId,
};
response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
headers: await _headers(),
body: jsonEncode(payload),
);
}
if (response.statusCode != 200 && response.statusCode != 201) {
throw Exception(
_extractErrorMessage(response.body, 'Erreur création enfant'),
);
}
final enfant = EnfantAdminModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
return enrichEnfantParentNames(enfant);
}
static Future<void> deleteEnfant(String enfantId) async {
final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
@@ -688,7 +779,24 @@ class UserService {
if (response.statusCode != 200 && response.statusCode != 201) {
throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant'));
}
return _amModelFromBody(response.body);
final am = _amModelFromBody(response.body);
// Recalcule toujours places_available (capacité enfants) après attachement.
return syncAmPlacesAvailable(am);
}
/// Aligne `places_available` sur capacité enfants rattachés.
static Future<AssistanteMaternelleModel> syncAmPlacesAvailable(
AssistanteMaternelleModel am,
) async {
final expected = amExpectedPlacesAvailable(
maxChildren: am.maxChildren,
childrenCount: am.children.length,
);
if (expected == null || am.placesAvailable == expected) return am;
return updateAmFiche(
amUserId: am.user.id,
body: {'places_available': expected},
);
}
static Future<AssistanteMaternelleModel> detachEnfantFromAm({
+12
View File
@@ -9,6 +9,18 @@ int? amExpectedPlacesAvailable({
return (maxChildren - childrenCount).clamp(0, maxChildren);
}
/// True s'il reste au moins une place d'accueil (capacité enfants).
bool amHasFreePlace(AssistanteMaternelleModel am) {
final expected = amExpectedPlacesAvailable(
maxChildren: am.maxChildren,
childrenCount: am.children.length,
);
if (expected != null) return expected > 0;
final places = am.placesAvailable;
if (places != null) return places > 0;
return true;
}
/// True si la valeur déclarée par l'AM ne correspond pas au calcul métier.
bool amHasPlacesInconsistency({
required int? maxChildren,
+82 -1
View File
@@ -1,3 +1,4 @@
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
@@ -11,10 +12,50 @@ String formatIsoDateFr(String? s, {String ifEmpty = ''}) {
}
}
/// Convertit `dd/MM/yyyy` (ou ISO) en `yyyy-MM-dd` pour l'API.
/// Affiche une date ISO en saisie guidée `jj / mm / aaaa`.
String formatIsoDateFrInput(String? s, {String ifEmpty = ''}) {
if (s == null || s.trim().isEmpty) return ifEmpty;
try {
final dt = DateTime.parse(s.trim());
return formatFrenchDateDigits(
DateFormat('ddMMyyyy').format(dt),
);
} catch (_) {
return formatFrenchDateDigits(s.replaceAll(RegExp(r'\D'), ''));
}
}
/// Formate jusqu’à 8 chiffres en `jj / mm / aaaa`.
String formatFrenchDateDigits(String digits) {
final d = digits.replaceAll(RegExp(r'\D'), '');
final limited = d.length > 8 ? d.substring(0, 8) : d;
final buf = StringBuffer();
for (var i = 0; i < limited.length; i++) {
if (i == 2 || i == 4) buf.write(' / ');
buf.write(limited[i]);
}
return buf.toString();
}
/// Convertit `dd/MM/yyyy`, `dd / MM / yyyy`, 8 chiffres ou ISO en `yyyy-MM-dd`.
String? parseFrDateToIso(String text) {
final t = text.trim();
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 {
return DateFormat('dd/MM/yyyy')
.parseStrict(t)
@@ -96,3 +137,43 @@ String formatChildAgeLabel({
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;
}
}
+10
View File
@@ -0,0 +1,10 @@
import 'package:p_tits_pas/models/enfant_admin_model.dart';
/// Enfant sans lien parent (orphelins daffiliation) — ticket #157.
bool enfantHasNoResponsable(EnfantAdminModel enfant) => enfant.hasNoResponsable;
/// Message vigilance liste Enfants (même usage que [amPlacesVigilanceMessage]).
String? enfantSansResponsableVigilanceMessage(EnfantAdminModel enfant) {
if (!enfantHasNoResponsable(enfant)) return null;
return 'Aucun responsable rattaché — à rattacher à un foyer';
}
+41
View File
@@ -1,3 +1,5 @@
import 'package:flutter/services.dart';
/// Formatage affichage prénom / nom (capitalisation par mot et segments après `-` ou `'`).
String formatPersonNameCase(String raw) {
@@ -9,6 +11,22 @@ String formatPersonNameCase(String raw) {
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) {
if (word.isEmpty) {
return word;
@@ -30,3 +48,26 @@ String _capitalizeComposedWord(String word) {
}
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),
);
}
}
@@ -20,6 +20,8 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
final int capacity;
final void Function(ParentChildSummary child) onOpen;
final void Function(ParentChildSummary child) onDetach;
/// Clic sur une case libre → même flux que « Rattacher un enfant » (#149).
final VoidCallback? onAttachEmpty;
const AdminAmChildrenCapacityGrid({
super.key,
@@ -27,6 +29,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
required this.capacity,
required this.onOpen,
required this.onDetach,
this.onAttachEmpty,
});
@override
@@ -78,7 +81,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
);
}
if (index < maxSlots) {
return const _EmptySlot();
return _EmptySlot(onTap: onAttachEmpty);
}
return const _UnavailableSlot();
}
@@ -129,18 +132,52 @@ class _UnavailableSlot extends StatelessWidget {
}
}
class _EmptySlot extends StatelessWidget {
const _EmptySlot();
class _EmptySlot extends StatefulWidget {
final VoidCallback? onTap;
const _EmptySlot({this.onTap});
@override
State<_EmptySlot> createState() => _EmptySlotState();
}
class _EmptySlotState extends State<_EmptySlot> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return _SlotShell(
backgroundColor: Colors.grey.shade50,
borderColor: Colors.grey.shade300,
final clickable = widget.onTap != null;
return MouseRegion(
onEnter: clickable ? (_) => setState(() => _hovered = true) : null,
onExit: clickable ? (_) => setState(() => _hovered = false) : null,
cursor: clickable ? SystemMouseCursors.click : MouseCursor.defer,
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(8),
hoverColor: const Color(0x149CC5C0),
child: _SlotShell(
backgroundColor: _hovered
? const Color(0xFFF3F0FA)
: Colors.grey.shade50,
borderColor: _hovered
? const Color(0xFFB8A4D4)
: Colors.grey.shade300,
child: Center(
child: Text(
'Place libre',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
style: TextStyle(
fontSize: 12,
color: _hovered
? const Color(0xFF6B3FA0)
: Colors.grey.shade500,
fontWeight: _hovered ? FontWeight.w600 : FontWeight.w400,
),
),
),
),
),
),
);
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/utils/am_vigilance.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart';
@@ -11,6 +10,7 @@ import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_children_capacity_grid.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart';
@@ -55,6 +55,10 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
late List<ParentChildSummary> _children;
late Set<String> _baselineChildIds;
/// Enfants ajoutés localement qui étaient déjà chez une autre AM
/// (enfantId → amUserId d'origine). Au save : détacher puis rattacher.
final Map<String, String> _transferFromAmIds = {};
bool _saving = false;
bool _dirty = false;
@@ -234,6 +238,13 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
int? _capaciteMax() => _parseIntField(_capaciteCtrl);
/// True si plus aucune place d'accueil (enfants ≥ capacité max).
bool get _capacityFull {
final max = _capaciteMax();
if (max == null) return false;
return _children.length >= max;
}
int? _computedPlacesAvailable() => amExpectedPlacesAvailable(
maxChildren: _capaciteMax(),
childrenCount: _children.length,
@@ -311,6 +322,17 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
);
}
for (final id in currentIds.difference(_baselineChildIds)) {
// Transfert : détacher l'ancienne AM sans recalculer ses places
// (le ! de vigilance apparaît côté liste AM).
final previousAmId = _transferFromAmIds[id] ??
(await UserService.findAmForEnfant(id))?.user.id;
if (previousAmId != null &&
previousAmId != widget.assistante.user.id) {
await UserService.detachEnfantFromAm(
amUserId: previousAmId,
enfantId: id,
);
}
await UserService.attachEnfantToAm(
amUserId: widget.assistante.user.id,
enfantId: id,
@@ -346,6 +368,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
_dirty = false;
_saving = false;
_baselineChildIds = currentIds;
_transferFromAmIds.clear();
});
widget.onSaved?.call();
ScaffoldMessenger.of(context).showSnackBar(
@@ -377,6 +400,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
setState(() {
_children = kids;
_baselineChildIds = kids.map((c) => c.id).toSet();
_transferFromAmIds.clear();
});
} catch (_) {}
}
@@ -422,8 +446,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
builder: (ctx) => AlertDialog(
title: const Text('Détacher l\'enfant'),
content: Text(
'Retirer ${child.fullName} de la fiche de cette assistante ?\n'
'(L\'enfant ne sera pas supprimé.)',
'Retirer ${child.fullName} de la fiche de cette assistante ?',
),
actions: [
TextButton(
@@ -442,55 +465,75 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
setState(() {
_children = _children.where((c) => c.id != child.id).toList();
_transferFromAmIds.remove(child.id);
_syncPlacesAfterChildrenChange();
_dirty = true;
});
}
Future<void> _attachChild() async {
List<EnfantAdminModel> all;
try {
all = await UserService.getEnfants();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
return;
}
final linkedIds = _children.map((c) => c.id).toSet();
final candidates = all.where((e) => !linkedIds.contains(e.id)).toList();
if (candidates.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Aucun enfant disponible à rattacher')),
);
return;
}
if (!mounted) return;
final selected = await showDialog<EnfantAdminModel>(
context: context,
builder: (ctx) => SimpleDialog(
title: const Text('Rattacher un enfant'),
children: candidates
.map(
(e) => SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, e),
child: Text(e.fullName),
),
)
.toList(),
),
if (_capacityFull || !mounted) return;
final selected = await AdminSelectEnfantModal.show(
context,
excludeIds: _children.map((c) => c.id).toSet(),
title: 'Rattacher un enfant',
showSansGardeFilter: true,
);
if (selected == null || !mounted) return;
AssistanteMaternelleModel? previousAm;
try {
previousAm = await UserService.findAmForEnfant(selected.id);
} catch (_) {
previousAm = null;
}
if (!mounted) return;
final previousAmId = previousAm?.user.id;
final isTransfer = previousAmId != null &&
previousAmId != widget.assistante.user.id;
if (isTransfer) {
final amName = previousAm!.user.fullName.trim().isNotEmpty
? previousAm.user.fullName.trim()
: 'une autre assistante maternelle';
final gardeLabel =
(selected.gender ?? '').trim().toUpperCase() == 'F'
? 'déjà gardée'
: 'déjà gardé';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Changer d\'affectation'),
content: Text(
'${selected.fullName} est $gardeLabel par $amName.\n\n'
'Confirmer le transfert vers cette assistante ? '
'L\'enfant sera détaché de $amName.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ValidationModalTheme.primaryElevatedStyle,
child: const Text('Confirmer'),
),
],
),
);
if (confirmed != true || !mounted) return;
}
setState(() {
_children = [
..._children,
ParentChildSummary.fromEnfant(selected),
];
if (isTransfer) {
_transferFromAmIds[selected.id] = previousAmId!;
}
_syncPlacesAfterChildrenChange();
_dirty = true;
});
@@ -701,6 +744,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
capacity: capacity,
onOpen: _openChild,
onDetach: _detachChild,
onAttachEmpty: _capacityFull ? null : _attachChild,
),
],
);
@@ -708,6 +752,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
Widget _buildFooter() {
final isChildrenTab = _tabCtrl.index == 2;
final canAttachChild = !_saving && !_capacityFull;
return Row(
children: [
TextButton(
@@ -716,11 +761,16 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
),
const Spacer(),
if (isChildrenTab)
TextButton.icon(
onPressed: _attachChild,
Tooltip(
message: _capacityFull
? 'Capacité maximale atteinte'
: 'Rattacher un enfant',
child: TextButton.icon(
onPressed: canAttachChild ? _attachChild : null,
icon: const Icon(Icons.link, size: 18),
label: const Text('Rattacher un enfant'),
),
),
if (isChildrenTab) const SizedBox(width: 12),
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
@@ -1,14 +1,27 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
/// Cadre photo identité AM (35×45 mm) — même logique que [ValidationAmWizard].
/// Cadre photo identité AM / enfant (35×45 mm) — même logique que [ValidationAmWizard].
class AdminAmPhotoFrame extends StatelessWidget {
final String? photoUrl;
final Uint8List? imageBytes;
final VoidCallback? onTap;
final VoidCallback? onClear;
final String emptyLabel;
static const double idPhotoAspectRatio = 35 / 45;
const AdminAmPhotoFrame({super.key, this.photoUrl});
const AdminAmPhotoFrame({
super.key,
this.photoUrl,
this.imageBytes,
this.onTap,
this.onClear,
this.emptyLabel = 'Aucune photo',
});
/// Largeur colonne photo pour remplir [height] (cadre inclus).
static double columnWidthForHeight(double height) {
@@ -36,9 +49,12 @@ class AdminAmPhotoFrame extends StatelessWidget {
ph = pw / ar;
}
final hasLocal = imageBytes != null && imageBytes!.isNotEmpty;
final showClear = onClear != null && hasLocal;
// Cadre gris = taille photo + padding uniforme ; centré dans la colonne
// (évite le vide blanc en bas quand le conteneur parent est plus haut).
return Align(
Widget frame = Align(
alignment: Alignment.topCenter,
child: Container(
decoration: BoxDecoration(
@@ -60,23 +76,82 @@ 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) {
if (imageBytes != null && imageBytes!.isNotEmpty) {
return Image.memory(
imageBytes!,
width: pw,
height: ph,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
);
}
if (fullUrl.isEmpty) {
return ColoredBox(
color: Colors.grey.shade200,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.person_off_outlined, size: 36, color: Colors.grey.shade400),
Icon(
onTap != null ? Icons.add_a_photo_outlined : Icons.person_off_outlined,
size: 36,
color: Colors.grey.shade400,
),
const SizedBox(height: 6),
Text(
'Aucune photo',
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
emptyLabel,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600, fontSize: 11),
),
),
],
),
);
@@ -108,7 +183,11 @@ class AdminAmPhotoFrame extends StatelessWidget {
},
errorBuilder: (_, __, ___) => ColoredBox(
color: Colors.grey.shade200,
child: Icon(Icons.broken_image_outlined, size: 36, color: Colors.grey.shade400),
child: Icon(
Icons.broken_image_outlined,
size: 36,
color: Colors.grey.shade400,
),
),
);
}
@@ -1,4 +1,6 @@
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/enfant_admin_model.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
@@ -6,25 +8,37 @@ import 'package:p_tits_pas/services/auth_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/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_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_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/validation_modal_theme.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
/// Fiche enfant consultation / édition (ticket #138) — format paysage comme fiche AM.
/// Fiche enfant consultation / édition (#138) ou création (#132).
class AdminChildDetailModal extends StatefulWidget {
final EnfantAdminModel enfant;
final EnfantAdminModel? enfant;
final VoidCallback? onSaved;
final VoidCallback? onDeleted;
final bool isCreating;
const AdminChildDetailModal({
super.key,
required this.enfant,
required EnfantAdminModel this.enfant,
this.onSaved,
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
State<AdminChildDetailModal> createState() => _AdminChildDetailModalState();
@@ -36,7 +50,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
late final TextEditingController _birthCtrl;
late final TextEditingController _dueCtrl;
late String _status;
late String _gender;
late String? _gender;
late bool _consentPhoto;
late bool _isMultiple;
bool _dirty = false;
@@ -48,6 +62,16 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
/// AM rattachée au chargement (pour sync différé au Sauvegarder).
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 _mainRowHeight = 380;
static const double _placementHeight = 96;
@@ -73,44 +97,102 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
_isUnborn ? 'Date prévisionnelle' : 'Date de naissance';
bool get _canDelete =>
!widget.isCreating &&
(_currentUserRole ?? '').toLowerCase() == 'super_admin';
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
void initState() {
super.initState();
final e = widget.enfant;
_prenomCtrl = TextEditingController(text: e.firstName ?? '');
_nomCtrl = TextEditingController(text: e.lastName ?? '');
_prenomCtrl = TextEditingController(text: e?.firstName ?? '');
_nomCtrl = TextEditingController(text: e?.lastName ?? '');
_birthCtrl = TextEditingController(
text: formatIsoDateFr(e.birthDate, ifEmpty: ''),
text: formatIsoDateFrInput(e?.birthDate, ifEmpty: ''),
);
_dueCtrl = TextEditingController(
text: formatIsoDateFr(e.dueDate, ifEmpty: ''),
text: formatIsoDateFrInput(e?.dueDate, ifEmpty: ''),
);
_status = normalizeEnfantStatus(e.status);
_status = normalizeEnfantStatus(e?.status);
if (!enfantStatusValues.contains(_status)) {
_status = 'sans_garde';
}
_gender = _normalizeGender(e.gender, allowUnknown: _isUnborn);
_consentPhoto = e.consentPhoto;
_isMultiple = e.isMultiple;
for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) {
c.addListener(_markDirty);
if (widget.isCreating) {
_gender = null;
} else {
_gender = _normalizeGender(e?.gender, allowUnknown: _isUnborn);
}
_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();
if (widget.isCreating) {
_loadingAm = false;
_dirty = true;
} else {
_prenomCtrl.addListener(_markDirty);
_nomCtrl.addListener(_markDirty);
_loadLinkedAm();
}
}
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 {
if (widget.isCreating) {
setState(() => _loadingAm = false);
return;
}
final enfantId = widget.enfant?.id;
if (enfantId == null || enfantId.isEmpty) {
setState(() => _loadingAm = false);
return;
}
setState(() => _loadingAm = true);
try {
final am = await UserService.findAmForEnfant(widget.enfant.id);
final am = await UserService.findAmForEnfant(enfantId);
if (!mounted) return;
setState(() {
_linkedAm = am;
@@ -155,8 +237,8 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
}
void _coerceGenderForStatus() {
if (!_availableGenders.contains(_gender)) {
_gender = 'H';
if (_gender != null && !_availableGenders.contains(_gender)) {
_gender = null;
}
}
@@ -167,19 +249,30 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
String? _dateToIso(String text) => parseFrDateToIso(text);
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 ln = _nomCtrl.text.trim();
if (fn.isEmpty && ln.isEmpty) {
final fallback = widget.enfant.fullName.trim();
final fallback = widget.enfant?.fullName.trim() ?? '';
return fallback.isNotEmpty ? fallback : 'Enfant';
}
return '$fn $ln'.trim();
}
List<EnfantParentLink> get _parentLinks => widget.enfant.parentLinks
List<EnfantParentLink> get _parentLinks =>
(_localParentLinks ??
widget.enfant?.parentLinks ??
const <EnfantParentLink>[])
.where((l) => l.parentId.trim().isNotEmpty)
.toList();
bool get _isOrphan => !widget.isCreating && _parentLinks.isEmpty;
Future<void> _openParent(EnfantParentLink link) async {
if (_busy) return;
final id = link.parentId.trim();
@@ -204,6 +297,17 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
}
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;
if (links.isEmpty) return null;
@@ -251,15 +355,24 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
Future<void> _save() async {
if (!_dirty) return;
if (widget.isCreating) {
await _saveCreate();
return;
}
final enfantId = widget.enfant?.id;
if (enfantId == null || enfantId.isEmpty) return;
setState(() => _saving = true);
try {
await _syncAmPlacement();
await UserService.updateEnfant(
enfantId: widget.enfant.id,
enfantId: enfantId,
body: {
'first_name': _prenomCtrl.text.trim(),
'last_name': _nomCtrl.text.trim(),
'first_name': formatPersonNameCase(_prenomCtrl.text),
'last_name': formatPersonNameCase(_nomCtrl.text),
'status': _status,
'gender': _gender,
if (_isUnborn)
@@ -290,21 +403,101 @@ 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).
Future<void> _syncAmPlacement() async {
final enfantId = widget.enfant?.id;
if (enfantId == null || enfantId.isEmpty) return;
final baselineId = _baselineAmUserId;
final currentId = _linkedAm?.user.id;
if (baselineId != null && baselineId != currentId) {
await UserService.detachEnfantFromAm(
amUserId: baselineId,
enfantId: widget.enfant.id,
enfantId: enfantId,
);
}
if (currentId != null && currentId != baselineId) {
await UserService.attachEnfantToAm(
amUserId: currentId,
enfantId: widget.enfant.id,
enfantId: enfantId,
);
}
}
@@ -341,7 +534,9 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
setState(() => _deleting = true);
try {
await UserService.deleteEnfant(widget.enfant.id);
final id = widget.enfant?.id;
if (id == null || id.isEmpty) return;
await UserService.deleteEnfant(id);
if (!mounted) return;
widget.onDeleted?.call();
widget.onSaved?.call();
@@ -375,10 +570,12 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
/// Recharge AM + statut après une modale externe (ex. fiche AM).
Future<void> _reloadPlacementFromServer() async {
final id = widget.enfant?.id;
if (id == null || id.isEmpty) return;
try {
final results = await Future.wait([
UserService.findAmForEnfant(widget.enfant.id),
UserService.getEnfant(widget.enfant.id),
UserService.findAmForEnfant(id),
UserService.getEnfant(id),
]);
if (!mounted) return;
final am = results[0] as AssistanteMaternelleModel?;
@@ -390,6 +587,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
if (!enfantStatusValues.contains(_status)) {
_status = 'sans_garde';
}
_localParentLinks = enfant.parentLinks;
_coerceGenderForStatus();
});
} catch (_) {
@@ -399,49 +597,25 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
}
Future<void> _attachAm() async {
List<AssistanteMaternelleModel> all;
try {
all = await UserService.getAssistantesMaternelles();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
return;
}
if (_linkedAm != null && !_isSansGarde) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Détachez l\'assistante actuelle avant d\'en choisir une autre'),
content: Text(
'Détachez l\'assistante actuelle avant d\'en choisir une autre',
),
),
);
return;
}
if (all.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Aucune assistante maternelle disponible')),
);
return;
}
if (!mounted) return;
final selected = await showDialog<AssistanteMaternelleModel>(
context: context,
builder: (ctx) => SimpleDialog(
title: const Text('Choisir une assistante maternelle'),
children: all
.map(
(am) => SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, am),
child: Text(am.user.fullName),
),
)
.toList(),
),
final selected = await AdminSelectAmModal.show(
context,
excludeIds: {
if (_linkedAm != null) _linkedAm!.user.id,
},
title: 'Choisir une assistante maternelle',
);
if (selected == null || !mounted) return;
@@ -499,48 +673,121 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
});
}
InputDecoration _inputDecoration({String? hint}) {
return ValidationFieldDecoration.input(hint: hint).copyWith(
/// Conserve la date saisie en basculant naissance ↔ prévisionnelle.
void _preserveDateOnStatusChange(String from, String to) {
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,
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({
Key? key,
required String value,
required String? value,
required List<MapEntry<String, String>> items,
required ValueChanged<String> onChanged,
String? hint,
}) {
final safeValue =
items.any((e) => e.key == value) ? value : items.first.key;
value != null && items.any((e) => e.key == value) ? value : null;
return SizedBox(
height: _fieldHeight,
child: DropdownButtonFormField<String>(
key: key,
value: safeValue,
hint: hint != null
? Text(hint, style: TextStyle(color: Colors.grey.shade600))
: null,
isExpanded: true,
decoration: _inputDecoration(),
items: items
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
.toList(),
onChanged: _busy ? null : (v) {
onChanged: _busy
? null
: (v) {
if (v != null) onChanged(v);
},
),
);
}
Widget _textField(TextEditingController controller, {String? hint, Key? key}) {
Widget _textField(
TextEditingController controller, {
String? hint,
Key? key,
TextInputType? keyboardType,
List<TextInputFormatter>? inputFormatters,
bool error = false,
Widget? suffixIcon,
}) {
return SizedBox(
height: _fieldHeight,
child: TextField(
key: key,
controller: controller,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
textAlignVertical: TextAlignVertical.center,
style: const TextStyle(color: Colors.black87, fontSize: 14),
decoration: _inputDecoration(hint: hint),
style: TextStyle(
color: error ? Colors.red.shade800 : Colors.black87,
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,
);
}
@@ -550,19 +797,21 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
fields: [
ValidationLabeledField(
label: 'Prénom',
field: _textField(_prenomCtrl),
field: _textField(
_prenomCtrl,
inputFormatters: const [PersonNameInputFormatter()],
),
),
ValidationLabeledField(
label: 'Nom',
field: _textField(_nomCtrl),
field: _textField(
_nomCtrl,
inputFormatters: const [PersonNameInputFormatter()],
),
),
ValidationLabeledField(
label: _dateFieldLabel,
field: _textField(
_activeDateCtrl,
hint: 'jj/mm/aaaa',
key: ValueKey(_status),
),
field: _dateField(),
),
ValidationLabeledField(
label: 'Statut',
@@ -578,6 +827,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
.toList(),
onChanged: (v) {
setState(() {
_preserveDateOnStatusChange(_status, v);
_status = v;
_coerceGenderForStatus();
_dirty = true;
@@ -593,6 +843,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
field: _dropdownField(
key: ValueKey('genre-$_status'),
value: _gender,
hint: 'Veuillez sélectionner le genre',
items: _availableGenders
.map((g) => MapEntry(g, _genderLabel(g)))
.toList(),
@@ -626,6 +877,43 @@ 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() {
return LayoutBuilder(
builder: (context, c) {
@@ -648,7 +936,20 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
children: [
Expanded(
child: AdminAmPhotoFrame(
photoUrl: widget.enfant.photoUrl,
photoUrl: widget.isCreating
? 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),
@@ -667,10 +968,12 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
padding: const EdgeInsets.only(top: 16),
child: Align(
alignment: Alignment.bottomCenter,
child: SingleChildScrollView(
child: _placementBlock(),
),
),
),
),
],
),
),
@@ -916,14 +1219,34 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
);
}
String get _placementTitle =>
_isScolarise ? 'Scolarisation' : 'Assistante maternelle';
String get _placementTitle {
if (widget.isCreating) return 'Sélection du dossier de la famille';
return _isScolarise ? 'Scolarisation' : 'Assistante maternelle';
}
Widget _placementBlock() {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
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(
_placementTitle,
style: TextStyle(
@@ -933,11 +1256,194 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
),
),
const SizedBox(height: 8),
_placementSection(),
if (widget.isCreating) _familyPlacementSection() else _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/#158) : un seul attach — le back propage 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,
);
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() {
if (!_showsAmPlacement) return _scolariseCard();
@@ -989,7 +1495,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
const SizedBox(width: 12),
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: !_dirty || _busy ? null : _save,
onPressed: !_canSubmit || _busy ? null : _save,
child: _saving
? const SizedBox(
width: 18,
@@ -999,7 +1505,11 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
color: Colors.white,
),
)
: Text(_dirty ? 'Sauvegarder' : 'Aucune modification'),
: Text(
widget.isCreating
? 'Créer'
: (_dirty ? 'Sauvegarder' : 'Aucune modification'),
),
),
],
);
@@ -3,6 +3,7 @@ import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/utils/date_display_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';
List<String> enfantAdminSubtitleLines({
@@ -34,6 +35,10 @@ class AdminEnfantUserCard extends StatelessWidget {
final List<String> subtitleLines;
final List<Widget> actions;
final VoidCallback? onCardTap;
final EdgeInsetsGeometry? margin;
final EdgeInsetsGeometry? contentPadding;
final Color? borderColor;
final String? vigilanceTooltip;
const AdminEnfantUserCard({
super.key,
@@ -42,6 +47,10 @@ class AdminEnfantUserCard extends StatelessWidget {
required this.subtitleLines,
this.actions = const [],
this.onCardTap,
this.margin,
this.contentPadding,
this.borderColor,
this.vigilanceTooltip,
});
factory AdminEnfantUserCard.fromEnfant(
@@ -49,10 +58,15 @@ class AdminEnfantUserCard extends StatelessWidget {
List<String> extraSubtitleLines = const [],
List<Widget> actions = const [],
VoidCallback? onCardTap,
EdgeInsetsGeometry? margin,
EdgeInsetsGeometry? contentPadding,
Color? borderColor,
String? vigilanceTooltip,
}) {
final parents = enfant.parentLinks
.map((l) => l.parentName ?? 'Parent')
.join(', ');
final orphan = enfantHasNoResponsable(enfant);
return AdminEnfantUserCard(
title: enfant.fullName,
photoUrl: enfant.photoUrl,
@@ -63,11 +77,18 @@ class AdminEnfantUserCard extends StatelessWidget {
gender: enfant.gender,
extra: [
if (parents.isNotEmpty) 'Responsables : $parents',
if (orphan) 'Aucun responsable rattaché',
...extraSubtitleLines,
],
),
actions: actions,
onCardTap: onCardTap,
margin: margin,
contentPadding: contentPadding,
borderColor: borderColor ??
(orphan ? Colors.red.shade300 : null),
vigilanceTooltip: vigilanceTooltip ??
enfantSansResponsableVigilanceMessage(enfant),
);
}
@@ -76,6 +97,8 @@ class AdminEnfantUserCard extends StatelessWidget {
List<String> extraSubtitleLines = const [],
List<Widget> actions = const [],
VoidCallback? onCardTap,
EdgeInsetsGeometry? margin,
EdgeInsetsGeometry? contentPadding,
}) {
return AdminEnfantUserCard(
title: child.fullName,
@@ -88,6 +111,8 @@ class AdminEnfantUserCard extends StatelessWidget {
),
actions: actions,
onCardTap: onCardTap,
margin: margin,
contentPadding: contentPadding,
);
}
@@ -100,6 +125,10 @@ class AdminEnfantUserCard extends StatelessWidget {
subtitleLines: subtitleLines,
actions: actions,
onCardTap: onCardTap,
margin: margin,
contentPadding: contentPadding,
borderColor: borderColor,
vigilanceTooltip: vigilanceTooltip,
);
}
}
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/models/parent_model.dart';
@@ -7,6 +6,7 @@ import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
@@ -283,8 +283,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
builder: (ctx) => AlertDialog(
title: const Text('Détacher l\'enfant'),
content: Text(
'Retirer ${child.fullName} de la fiche de ce parent ?\n'
'(L\'enfant ne sera pas supprimé.)',
'Retirer ${child.fullName} du foyer (tous les responsables) ?',
),
actions: [
TextButton(
@@ -309,8 +308,9 @@ 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é')),
const SnackBar(content: Text('Enfant détaché du foyer')),
);
} catch (e) {
if (!mounted) return;
@@ -321,41 +321,11 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
}
Future<void> _attachChild() async {
List<EnfantAdminModel> all;
try {
all = await UserService.getEnfants();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
return;
}
final linkedIds = _children.map((c) => c.id).toSet();
final candidates = all.where((e) => !linkedIds.contains(e.id)).toList();
if (candidates.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Aucun enfant disponible à rattacher')),
);
return;
}
if (!mounted) return;
final selected = await showDialog<EnfantAdminModel>(
context: context,
builder: (ctx) => SimpleDialog(
title: const Text('Rattacher un enfant'),
children: candidates
.map(
(e) => SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, e),
child: Text(e.fullName),
),
)
.toList(),
),
final selected = await AdminSelectEnfantModal.show(
context,
excludeIds: _children.map((c) => c.id).toSet(),
title: 'Rattacher un enfant',
);
if (selected == null || !mounted) return;
@@ -367,8 +337,9 @@ 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é')),
const SnackBar(content: Text('Enfant rattaché au foyer')),
);
} catch (e) {
if (!mounted) return;
@@ -0,0 +1,218 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/am_vigilance.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
List<String> _amSelectSubtitleLines(AssistanteMaternelleModel am) {
final lines = <String>[];
final zone = (am.residenceCity ?? '').trim();
if (zone.isNotEmpty) lines.add('Zone : $zone');
final agrement = (am.approvalNumber ?? '').trim();
if (agrement.isNotEmpty) lines.add('Agrément : $agrement');
final max = am.maxChildren;
final free = amExpectedPlacesAvailable(
maxChildren: max,
childrenCount: am.children.length,
) ??
am.placesAvailable;
if (free != null || max != null) {
lines.add('Places libres : ${free ?? ''} / capa. ${max ?? ''}');
}
lines.add('${am.children.length} enfant(s)');
return lines;
}
/// Sélection d'une AM à rattacher (fiche enfant) — ticket #147.
/// S'appuie sur [AdminSelectListModal] (shell partagé avec #146).
class AdminSelectAmModal {
AdminSelectAmModal._();
static Future<AssistanteMaternelleModel?> show(
BuildContext context, {
Set<String> excludeIds = const {},
String title = 'Choisir une assistante maternelle',
}) {
return AdminSelectListModal.show<AssistanteMaternelleModel>(
context,
title: title,
searchHint: 'Rechercher par nom, prénom ou zone…',
emptyMessage: 'Aucune assistante maternelle disponible',
noResultsMessage: 'Aucune AM avec place libre pour cette recherche',
toggleFilter: const AdminSelectToggleFilter<AssistanteMaternelleModel>(
label: 'Libre',
initialValue: true,
whenEnabled: amHasFreePlace,
),
loadItems: () async {
final list = await UserService.getAssistantesMaternelles();
return list
.where((am) => !excludeIds.contains(am.user.id))
.toList()
..sort(
(a, b) => a.user.fullName
.toLowerCase()
.compareTo(b.user.fullName.toLowerCase()),
);
},
matchesQuery: (am, q) {
final u = am.user;
final name = u.fullName.toLowerCase();
final fn = (u.prenom ?? '').toLowerCase();
final ln = (u.nom ?? '').toLowerCase();
final zone = (am.residenceCity ?? '').toLowerCase();
final agrement = (am.approvalNumber ?? '').toLowerCase();
return name.contains(q) ||
fn.contains(q) ||
ln.contains(q) ||
zone.contains(q) ||
agrement.contains(q);
},
resolveSelect: (ctx, am, reload) =>
_resolveAmSelection(ctx, am, reload),
itemBuilder: (context, am, onSelect) {
final full = !amHasFreePlace(am);
return AdminUserCard(
title: am.user.fullName,
avatarUrl: am.user.photoUrl,
fallbackIcon: Icons.face,
subtitleLines: _amSelectSubtitleLines(am),
vigilanceTooltip: amPlacesVigilanceMessage(am),
onCardTap: onSelect,
margin: const EdgeInsets.only(bottom: 4),
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
backgroundColor: full ? const Color(0xFFFFEBEE) : null,
borderColor: full ? Colors.red.shade200 : null,
actions: [
IconButton(
icon: const Icon(Icons.add_link),
tooltip: 'Rattacher',
onPressed: onSelect,
),
],
);
},
);
}
static Future<AssistanteMaternelleModel?> _resolveAmSelection(
BuildContext context,
AssistanteMaternelleModel am,
Future<void> Function() reloadList,
) async {
if (amHasFreePlace(am)) return am;
final selected = await showDialog<AssistanteMaternelleModel>(
context: context,
builder: (ctx) => _AmNoPlaceWarningDialog(initialAm: am),
);
await reloadList();
return selected;
}
}
/// Avertissement AM saturée + lien vers la fiche pour ajuster les places.
class _AmNoPlaceWarningDialog extends StatefulWidget {
final AssistanteMaternelleModel initialAm;
const _AmNoPlaceWarningDialog({required this.initialAm});
@override
State<_AmNoPlaceWarningDialog> createState() =>
_AmNoPlaceWarningDialogState();
}
class _AmNoPlaceWarningDialogState extends State<_AmNoPlaceWarningDialog> {
late AssistanteMaternelleModel _am;
TapGestureRecognizer? _linkRecognizer;
@override
void initState() {
super.initState();
_am = widget.initialAm;
_linkRecognizer = TapGestureRecognizer()..onTap = _openAmFiche;
}
@override
void dispose() {
_linkRecognizer?.dispose();
super.dispose();
}
Future<void> _openAmFiche() async {
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AdminAmEditModal(
assistante: _am,
onSaved: () async {
try {
final fresh =
await UserService.getAssistanteMaternelle(_am.user.id);
if (mounted) setState(() => _am = fresh);
} catch (_) {}
},
),
);
if (!mounted) return;
try {
final fresh = await UserService.getAssistanteMaternelle(_am.user.id);
if (!mounted) return;
setState(() => _am = fresh);
if (amHasFreePlace(fresh)) {
Navigator.of(context).pop(fresh);
}
} catch (_) {}
}
@override
Widget build(BuildContext context) {
final name = _am.user.fullName.trim().isNotEmpty
? _am.user.fullName.trim()
: 'Cette assistante maternelle';
const linkColor = ValidationModalTheme.primaryActionBackground;
return AlertDialog(
title: const Text('Plus de place disponible'),
content: Text.rich(
TextSpan(
style: const TextStyle(fontSize: 14, color: Colors.black87, height: 1.4),
children: [
TextSpan(
text: '$name n\'a plus de place libre pour accueillir '
'un enfant supplémentaire.\n\n',
),
const TextSpan(text: 'Vous pouvez '),
TextSpan(
text: 'ouvrir sa fiche',
style: TextStyle(
color: linkColor,
decoration: TextDecoration.underline,
fontWeight: FontWeight.w600,
),
recognizer: _linkRecognizer,
),
const TextSpan(
text: ' pour modifier la capacité ou les places, '
'puis la sélectionner si une place se libère.',
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
],
);
}
}
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
/// Sélection d'un enfant à rattacher (fiche AM / fiche parent) — ticket #146.
/// S'appuie sur [AdminSelectListModal] (shell partagé avec #147).
class AdminSelectEnfantModal {
AdminSelectEnfantModal._();
static Future<EnfantAdminModel?> show(
BuildContext context, {
Set<String> excludeIds = const {},
String title = 'Rattacher un enfant',
/// Affiché uniquement depuis la fiche AM : filtre les enfants déjà en garde.
bool showSansGardeFilter = false,
}) {
return AdminSelectListModal.show<EnfantAdminModel>(
context,
title: title,
searchHint: 'Rechercher par nom ou prénom…',
emptyMessage: 'Aucun enfant disponible à rattacher',
noResultsMessage: showSansGardeFilter
? 'Aucun enfant sans garde pour cette recherche'
: 'Aucun résultat pour cette recherche',
toggleFilter: showSansGardeFilter
? AdminSelectToggleFilter<EnfantAdminModel>(
label: 'Sans garde',
initialValue: true,
whenEnabled: (e) =>
normalizeEnfantStatus(e.status) == 'sans_garde',
)
: null,
loadItems: () async {
final list = await UserService.getEnfants();
return list
.where((e) => !excludeIds.contains(e.id))
.toList()
..sort(
(a, b) =>
a.fullName.toLowerCase().compareTo(b.fullName.toLowerCase()),
);
},
matchesQuery: (e, q) {
final name = e.fullName.toLowerCase();
final fn = (e.firstName ?? '').toLowerCase();
final ln = (e.lastName ?? '').toLowerCase();
return name.contains(q) || fn.contains(q) || ln.contains(q);
},
itemBuilder: (context, e, onSelect) {
return AdminEnfantUserCard.fromEnfant(
e,
onCardTap: onSelect,
margin: const EdgeInsets.only(bottom: 4),
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
actions: [
IconButton(
icon: const Icon(Icons.add_link),
tooltip: 'Rattacher',
onPressed: onSelect,
),
],
);
},
);
}
}
@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
/// Foyer / famille sélectionnable pour rattacher un nouvel enfant (#132 / #157).
class AdminFamilleFoyer {
/// Parent pivot pour `POST /enfants` (`parent_user_id`).
final String pivotParentUserId;
/// Co-parent éventuel (rattachement foyer #157).
final String? coParentUserId;
final String? numeroDossier;
final String displayTitle;
final List<String> parentNames;
const AdminFamilleFoyer({
required this.pivotParentUserId,
required this.displayTitle,
required this.parentNames,
this.coParentUserId,
this.numeroDossier,
});
/// Parents du foyer à lier à lenfant (pivot puis co-parent).
List<String> get parentUserIds {
final ids = <String>[pivotParentUserId];
final co = (coParentUserId ?? '').trim();
if (co.isNotEmpty && co != pivotParentUserId) ids.add(co);
return ids;
}
String get subtitle {
final parts = <String>[];
final dossier = (numeroDossier ?? '').trim();
if (dossier.isNotEmpty) parts.add('Dossier $dossier');
if (parentNames.isNotEmpty) {
parts.add('Responsables : ${parentNames.join(', ')}');
}
return parts.join(' ');
}
}
/// Construit la liste des foyers uniques à partir de `GET /parents`.
List<AdminFamilleFoyer> buildFamilleFoyers(List<ParentModel> parents) {
final seenDossiers = <String>{};
final seenUserIds = <String>{};
final foyers = <AdminFamilleFoyer>[];
for (final p in parents) {
final dossier = (p.user.numeroDossier ?? '').trim();
if (dossier.isNotEmpty) {
if (seenDossiers.contains(dossier)) continue;
seenDossiers.add(dossier);
} else if (seenUserIds.contains(p.user.id)) {
continue;
}
seenUserIds.add(p.user.id);
final co = p.coParent;
if (co != null) seenUserIds.add(co.id);
final names = <String>[
if (p.user.fullName.trim().isNotEmpty) p.user.fullName.trim(),
if (co != null && co.fullName.trim().isNotEmpty) co.fullName.trim(),
];
final title = dossier.isNotEmpty
? 'Dossier $dossier'
: (names.isNotEmpty ? names.first : 'Famille');
foyers.add(
AdminFamilleFoyer(
pivotParentUserId: p.user.id,
coParentUserId: co?.id,
numeroDossier: dossier.isNotEmpty ? dossier : null,
displayTitle: title,
parentNames: names,
),
);
}
foyers.sort(
(a, b) => a.displayTitle.toLowerCase().compareTo(b.displayTitle.toLowerCase()),
);
return foyers;
}
/// Sélection d'une famille / dossier pour créer un enfant — ticket #132.
class AdminSelectFamilleModal {
AdminSelectFamilleModal._();
static Future<AdminFamilleFoyer?> show(
BuildContext context, {
String title = 'Choisir une famille',
}) {
return AdminSelectListModal.show<AdminFamilleFoyer>(
context,
title: title,
searchHint: 'Rechercher par dossier, nom…',
emptyMessage: 'Aucune famille disponible',
noResultsMessage: 'Aucun résultat pour cette recherche',
loadItems: () async {
final parents = await UserService.getParents();
return buildFamilleFoyers(parents);
},
matchesQuery: (f, q) {
final dossier = (f.numeroDossier ?? '').toLowerCase();
final title = f.displayTitle.toLowerCase();
final names = f.parentNames.join(' ').toLowerCase();
return dossier.contains(q) || title.contains(q) || names.contains(q);
},
itemBuilder: (context, f, onSelect) {
return AdminUserCard(
title: f.displayTitle,
fallbackIcon: Icons.family_restroom,
subtitleLines: [
if (f.parentNames.isNotEmpty) f.parentNames.join(', '),
if ((f.numeroDossier ?? '').isNotEmpty)
'Dossier ${f.numeroDossier}',
],
onCardTap: onSelect,
margin: const EdgeInsets.only(bottom: 4),
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
actions: [
IconButton(
icon: const Icon(Icons.check_circle_outline),
tooltip: 'Choisir',
onPressed: onSelect,
),
],
);
},
);
}
}
@@ -0,0 +1,367 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
/// Filtre optionnel (switch) sur la même ligne que la barre de recherche.
class AdminSelectToggleFilter<T> {
final String label;
final bool initialValue;
/// Si le switch est activé, ne garde que les éléments pour lesquels
/// [whenEnabled] renvoie `true`.
final bool Function(T item) whenEnabled;
const AdminSelectToggleFilter({
required this.label,
required this.whenEnabled,
this.initialValue = true,
});
}
/// Shell générique « rechercher + liste + sélection » pour les modales admin.
/// Utilisé par la sélection d'enfant (#146) et la sélection d'AM (#147).
class AdminSelectListModal<T> extends StatefulWidget {
final String title;
final String searchHint;
final Future<List<T>> Function() loadItems;
final bool Function(T item, String query) matchesQuery;
final Widget Function(
BuildContext context,
T item,
VoidCallback onSelect,
) itemBuilder;
final String emptyMessage;
final String noResultsMessage;
final double modalWidth;
/// Hauteur d'une carte (pour dimensionner la liste à ≥ [minVisibleCards]).
final double cardExtent;
/// Nombre minimum de cartes visibles dans la zone scrollable.
final int minVisibleCards;
/// Switch optionnel à droite du champ de recherche (ex. « Sans garde », « Libre »).
final AdminSelectToggleFilter<T>? toggleFilter;
/// Si fourni, appelé avant de valider la sélection.
/// Retourne l'élément à pop (éventuellement rafraîchi), ou `null` pour annuler.
final Future<T?> Function(
BuildContext context,
T item,
Future<void> Function() reload,
)? resolveSelect;
const AdminSelectListModal({
super.key,
required this.title,
required this.loadItems,
required this.matchesQuery,
required this.itemBuilder,
this.searchHint = 'Rechercher…',
this.emptyMessage = 'Aucun élément disponible',
this.noResultsMessage = 'Aucun résultat pour cette recherche',
this.modalWidth = 930,
this.cardExtent = 52,
this.minVisibleCards = 8,
this.toggleFilter,
this.resolveSelect,
});
static Future<T?> show<T>(
BuildContext context, {
required String title,
required Future<List<T>> Function() loadItems,
required bool Function(T item, String query) matchesQuery,
required Widget Function(
BuildContext context,
T item,
VoidCallback onSelect,
) itemBuilder,
String searchHint = 'Rechercher…',
String emptyMessage = 'Aucun élément disponible',
String noResultsMessage = 'Aucun résultat pour cette recherche',
double modalWidth = 930,
double cardExtent = 52,
int minVisibleCards = 8,
AdminSelectToggleFilter<T>? toggleFilter,
Future<T?> Function(
BuildContext context,
T item,
Future<void> Function() reload,
)? resolveSelect,
}) {
return showDialog<T>(
context: context,
builder: (ctx) => AdminSelectListModal<T>(
title: title,
loadItems: loadItems,
matchesQuery: matchesQuery,
itemBuilder: itemBuilder,
searchHint: searchHint,
emptyMessage: emptyMessage,
noResultsMessage: noResultsMessage,
modalWidth: modalWidth,
cardExtent: cardExtent,
minVisibleCards: minVisibleCards,
toggleFilter: toggleFilter,
resolveSelect: resolveSelect,
),
);
}
@override
State<AdminSelectListModal<T>> createState() =>
_AdminSelectListModalState<T>();
}
class _AdminSelectListModalState<T> extends State<AdminSelectListModal<T>> {
final _searchCtrl = TextEditingController();
List<T> _all = [];
bool _loading = true;
String? _error;
late bool _toggleOn;
bool _resolving = false;
double get _listHeight =>
widget.cardExtent * widget.minVisibleCards;
@override
void initState() {
super.initState();
_toggleOn = widget.toggleFilter?.initialValue ?? false;
_searchCtrl.addListener(() => setState(() {}));
_load();
}
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
final list = await widget.loadItems();
if (!mounted) return;
setState(() {
_all = list;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_error = e.toString().replaceFirst('Exception: ', '');
});
}
}
Future<void> _handleSelect(T item) async {
if (_resolving) return;
final resolve = widget.resolveSelect;
if (resolve == null) {
Navigator.of(context).pop(item);
return;
}
setState(() => _resolving = true);
try {
final chosen = await resolve(context, item, _load);
if (!mounted || chosen == null) return;
Navigator.of(context).pop(chosen);
} finally {
if (mounted) setState(() => _resolving = false);
}
}
List<T> get _filtered {
var list = _all;
final toggle = widget.toggleFilter;
if (toggle != null && _toggleOn) {
list = list.where(toggle.whenEnabled).toList();
}
final q = _searchCtrl.text.trim().toLowerCase();
if (q.isEmpty) return list;
return list.where((item) => widget.matchesQuery(item, q)).toList();
}
@override
Widget build(BuildContext context) {
final toggle = widget.toggleFilter;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: widget.modalWidth),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 4, 0),
child: Row(
children: [
Expanded(
child: Text(
widget.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
),
IconButton(
icon: const Icon(Icons.close),
tooltip: 'Fermer',
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: TextField(
controller: _searchCtrl,
decoration: InputDecoration(
isDense: true,
hintText: widget.searchHint,
prefixIcon: const Icon(Icons.search, size: 20),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
),
),
),
if (toggle != null) ...[
const SizedBox(width: 12),
Text(
toggle.label,
style: const TextStyle(fontSize: 13),
),
const SizedBox(width: 4),
Switch(
value: _toggleOn,
activeColor:
ValidationModalTheme.primaryActionBackground,
onChanged: (v) => setState(() => _toggleOn = v),
),
],
],
),
const SizedBox(height: 12),
SizedBox(
height: _listHeight,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: _buildBody(),
),
),
),
const SizedBox(height: 12),
Row(
children: [
const Spacer(),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
],
),
],
),
),
],
),
),
);
}
Widget _buildBody() {
if (_loading) {
return const Center(
child: CircularProgressIndicator(
color: ValidationModalTheme.primaryActionBackground,
),
);
}
if (_error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 40, color: Colors.red.shade400),
const SizedBox(height: 12),
Text(
_error!,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.red.shade700),
),
const SizedBox(height: 12),
TextButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
),
],
),
),
);
}
final items = _filtered;
if (_all.isEmpty) {
return Center(
child: Text(
widget.emptyMessage,
style: const TextStyle(fontSize: 14, color: Colors.black54),
),
);
}
if (items.isEmpty) {
return Center(
child: Text(
widget.noResultsMessage,
style: const TextStyle(fontSize: 14, color: Colors.black54),
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
itemExtent: widget.cardExtent,
itemCount: items.length,
itemBuilder: (context, i) {
final item = items[i];
return widget.itemBuilder(
context,
item,
() => _handleSelect(item),
);
},
);
}
}
@@ -15,6 +15,7 @@ class AdminUserCard extends StatefulWidget {
final String? vigilanceTooltip;
final VoidCallback? onCardTap;
final EdgeInsetsGeometry? margin;
final EdgeInsetsGeometry? contentPadding;
const AdminUserCard({
super.key,
@@ -30,6 +31,7 @@ class AdminUserCard extends StatefulWidget {
this.vigilanceTooltip,
this.onCardTap,
this.margin,
this.contentPadding,
});
@override
@@ -69,7 +71,8 @@ class _AdminUserCardState extends State<AdminUserCard> {
side: BorderSide(color: widget.borderColor ?? Colors.grey.shade300),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
padding: widget.contentPadding ??
const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
child: Row(
children: [
_buildAvatar(avatarUrl),
@@ -88,7 +91,10 @@ class _AdminUserCardState extends State<AdminUserCard> {
Expanded(
child: Row(
children: [
// flex: 0 → largeur du nom ; le reste va aux infos
// (évite le partage 50/50 qui tronque « Responsables »).
Flexible(
flex: 0,
fit: FlexFit.loose,
child: Text(
widget.title.isNotEmpty ? widget.title : 'Sans nom',
@@ -72,7 +72,14 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
normalizeEnfantStatus(e.status) ==
normalizeEnfantStatus(widget.statusFilter);
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(
isLoading: _isLoading,
@@ -80,7 +80,7 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
onCardTap: () => _openParentDetails(parent),
subtitleLines: [
parent.user.email,
'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.children.isNotEmpty ? parent.children.length : parent.childrenCount}',
'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${ParentModel.foyerChildrenCount(parent, _parents)}',
],
actions: [
IconButton(
@@ -3,6 +3,7 @@ import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create
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/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/enfant_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
@@ -26,6 +27,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
int _subIndex = 0;
int _gestionnaireRefreshTick = 0;
int _adminRefreshTick = 0;
int _enfantRefreshTick = 0;
final TextEditingController _searchController = TextEditingController();
final TextEditingController _amCapacityController = TextEditingController();
String? _parentStatus;
@@ -264,6 +266,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
);
case 1:
return EnfantManagementWidget(
key: ValueKey('enfants-$_enfantRefreshTick'),
searchQuery: _searchController.text,
statusFilter: _enfantStatus,
);
@@ -311,6 +314,23 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
Future<void> _handleAddPressed() async {
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) {
final created = await showDialog<bool>(
context: context,
+1 -1
View File
@@ -206,7 +206,7 @@ packages:
source: hosted
version: "1.2.2"
http_parser:
dependency: transitive
dependency: "direct main"
description:
name: http_parser
sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
+1
View File
@@ -21,6 +21,7 @@ dependencies:
js: ^0.6.7
url_launcher: ^6.2.4
http: ^1.2.2
http_parser: ^4.0.2
# flutter_secure_storage: ^9.0.0
pdfx: ^2.5.0
universal_platform: ^1.1.0