Compare commits

...

6 Commits

Author SHA1 Message Date
99a6c17c23 feat(#157): enfants sans responsable — alerte liste et détach dernier parent.
Squash depuis develop : détachement dernier parent autorisé, flag
sans_responsable, vigilance liste Enfants, rattachement foyer depuis
la fiche, polish détach et compteur foyer interim (#158 à suivre).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 19:43:58 +02:00
04f49cb62f feat(#132): création enfant staff (onglet Enfants) — front + back.
Squash depuis develop : POST /enfants staff + parent_user_id, photo
multipart, modale création + sélection famille, fixes UX/birth_date.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 19:00:12 +02:00
3c7f4f6e16 fix(#151): autoriser GET /relais pour gestionnaire + robustesse combo
Permet au gestionnaire de peupler la liste Relais (création/édition).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:54:17 +02:00
dcd407a3da feat(#146–#149): sélection enfant/AM, capacité max et case libre.
Modales de rattachement partagées, filtre Libre/Sans garde, recalcul des places à l'attach, désactivation si capacité pleine, clic sur case libre.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:25:42 +02:00
fde63f8e72 feat(#145): lien co-parent cliquable dans la fiche parent.
Squash merge develop → master.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 14:56:45 +02:00
1f8f1b9507 feat(#138,#143,#144): fiche enfant admin, consentement photo et fix gestionnaire.
Squash merge develop → master.
- #138 : modale enfant paysage, zone AM/scolarisation, liens responsables
- #144 : persistance consent_photo à l'inscription enfant
- #143 : masquer Supprimer sur la propre fiche gestionnaire

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 13:21:43 +02:00
36 changed files with 3512 additions and 506 deletions

View File

@ -0,0 +1,111 @@
/**
* Crée l'issue Gitea gestionnaire ne doit pas pouvoir se supprimer.
* Usage: node backend/scripts/create-gitea-issue-gestionnaire-self-delete.js
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
const repoRoot = path.join(__dirname, '../..');
const MILESTONE_0_1_0 = 10;
let token = process.env.GITEA_TOKEN;
if (!token) {
try {
const tokenFile = path.join(repoRoot, '.gitea-token');
if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim();
} catch (_) {}
}
if (!token) {
try {
const briefing = fs.readFileSync(
path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'),
'utf8',
);
const m = briefing.match(/Token:\s*(gitebu_[a-f0-9]+)/);
if (m) token = m[1].trim();
} catch (_) {}
}
if (!token) {
console.error('Token non trouvé : .gitea-token ou GITEA_TOKEN');
process.exit(1);
}
const body = `## Contexte
Quand un **gestionnaire** connecté ouvre sa propre fiche dans l'onglet **Gestionnaires** (Gestion des utilisateurs), la modale **Modifier un "Gestionnaire"** affiche le bouton **Supprimer**.
Comportement actuel : le gestionnaire voit et peut tenter de supprimer son propre compte.
## Comportement attendu
Comme pour le **super administrateur** (bouton Supprimer masqué sur la fiche super admin) :
- **Pas de bouton Supprimer** quand l'utilisateur édite **sa propre fiche**
- **Modification** des informations (prénom, nom, email, téléphone, relais, mot de passe) **toujours autorisée**
## Périmètre
- Frontend : \`AdminUserFormDialog\` (\`gestionnaires_create.dart\`)
- Comparer \`initialUser.id\` avec l'utilisateur connecté (\`AuthService.getCurrentUser\`)
- Masquer Supprimer si édition de soi-même ; conserver garde existante super admin
## Critères d'acceptation
- [ ] Gestionnaire connecté ouvre sa fiche **pas** de bouton Supprimer
- [ ] Gestionnaire connecté peut **Modifier** ses informations
- [ ] Super admin / admin peut toujours supprimer **un autre** gestionnaire (si droits API)
- [ ] Pas de régression sur fiche super administrateur (Supprimer toujours masqué)
## Fichiers clés
- \`frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart\`
- \`frontend/lib/widgets/admin/gestionnaire_management_widget.dart\`
## Milestone
**0.1.0** correction UX / sécurité gestion utilisateurs.`;
const payloadClean = JSON.stringify({
title: '[Bug] Gestionnaire peut voir Supprimer sur sa propre fiche',
body,
milestone: MILESTONE_0_1_0,
});
const opts = {
hostname: 'git.ptits-pas.fr',
path: '/api/v1/repos/jmartin/petitspas/issues',
method: 'POST',
headers: {
Authorization: 'token ' + token,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payloadClean),
},
};
const req = https.request(opts, (res) => {
let d = '';
res.on('data', (c) => (d += c));
res.on('end', () => {
try {
const o = JSON.parse(d);
if (o.number) {
console.log('NUMBER:', o.number);
console.log('URL:', o.html_url);
console.log('MILESTONE:', o.milestone?.title ?? '(aucun)');
} else {
console.error('Réponse:', d);
process.exit(1);
}
} catch (e) {
console.error(d);
process.exit(1);
}
});
});
req.on('error', (e) => {
console.error(e);
process.exit(1);
});
req.write(payloadClean);
req.end();

View File

@ -552,7 +552,8 @@ export class AuthService {
: undefined; : undefined;
enfant.photo_url = urlPhoto || undefined; enfant.photo_url = urlPhoto || undefined;
enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE; enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE;
enfant.consent_photo = false; enfant.consent_photo = !!enfantDto.consent_photo;
enfant.consent_photo_at = enfant.consent_photo ? new Date() : null!;
enfant.is_multiple = enfantDto.grossesse_multiple || false; enfant.is_multiple = enfantDto.grossesse_multiple || false;
const enfantEnregistre = await manager.save(Children, enfant); const enfantEnregistre = await manager.save(Children, enfant);
@ -1074,6 +1075,12 @@ export class AuthService {
if (enfantDto.grossesse_multiple !== undefined) { if (enfantDto.grossesse_multiple !== undefined) {
enfant.is_multiple = enfantDto.grossesse_multiple; enfant.is_multiple = enfantDto.grossesse_multiple;
} }
if (enfantDto.consent_photo !== undefined) {
enfant.consent_photo = !!enfantDto.consent_photo;
enfant.consent_photo_at = enfant.consent_photo
? new Date()
: null!;
}
if (enfantDto.photo_base64 && enfantDto.photo_filename) { if (enfantDto.photo_base64 && enfantDto.photo_filename) {
enfant.photo_url = await this.sauvegarderPhotoDepuisBase64( enfant.photo_url = await this.sauvegarderPhotoDepuisBase64(

View File

@ -59,5 +59,14 @@ export class EnfantInscriptionDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
grossesse_multiple?: boolean; grossesse_multiple?: boolean;
@ApiProperty({
example: true,
required: false,
description: 'Consentement affichage / stockage photo (colonne enfants.consentement_photo)',
})
@IsOptional()
@IsBoolean()
consent_photo?: boolean;
} }

View File

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

View File

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

View File

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

View File

@ -138,7 +138,9 @@ export class ParentsService {
} }
/** /**
* Détacher un enfant d'un parent sans supprimer l'enfant. Ticket #115 / doc 28 §6.2. * Détacher un enfant d'un parent sans supprimer l'enfant.
* Autorise le détachement du dernier responsable (#157) l'enfant reste listé
* via GET /enfants avec parentLinks vides (alerte front).
*/ */
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> { async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
await this.findOne(parentUserId); await this.findOne(parentUserId);
@ -150,11 +152,6 @@ export class ParentsService {
throw new NotFoundException('Lien parent-enfant introuvable'); throw new NotFoundException('Lien parent-enfant introuvable');
} }
const totalLinks = await this.parentsChildrenRepository.count({ where: { enfantId } });
if (totalLinks <= 1) {
throw new BadRequestException('Un enfant doit rester rattaché à au moins un responsable');
}
await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId }); await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId });
return this.findOne(parentUserId); return this.findOne(parentUserId);
} }

View File

@ -24,15 +24,19 @@ export class RelaisController {
} }
@Get() @Get()
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Lister tous les relais' }) @ApiOperation({
summary: 'Lister tous les relais',
description:
'Lecture ouverte aux gestionnaires (combobox fiches). CRUD write reste admin-only. Ticket #151.',
})
@ApiResponse({ status: 200, description: 'Liste des relais.' }) @ApiResponse({ status: 200, description: 'Liste des relais.' })
findAll() { findAll() {
return this.relaisService.findAll(); return this.relaisService.findAll();
} }
@Get(':id') @Get(':id')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Récupérer un relais par ID' }) @ApiOperation({ summary: 'Récupérer un relais par ID' })
@ApiResponse({ status: 200, description: 'Le relais trouvé.' }) @ApiResponse({ status: 200, description: 'Le relais trouvé.' })
findOne(@Param('id') id: string) { findOne(@Param('id') id: string) {

View File

@ -14,6 +14,8 @@ class EnfantAdminModel {
final bool consentPhoto; final bool consentPhoto;
final bool isMultiple; final bool isMultiple;
final List<EnfantParentLink> parentLinks; final List<EnfantParentLink> parentLinks;
/// Flag API #157 (sinon déduit de [parentLinks]).
final bool? sansResponsable;
EnfantAdminModel({ EnfantAdminModel({
required this.id, required this.id,
@ -27,6 +29,7 @@ class EnfantAdminModel {
this.consentPhoto = false, this.consentPhoto = false,
this.isMultiple = false, this.isMultiple = false,
this.parentLinks = const [], this.parentLinks = const [],
this.sansResponsable,
}); });
String get fullName { String get fullName {
@ -37,6 +40,42 @@ class EnfantAdminModel {
return '$fn $ln'; return '$fn $ln';
} }
/// Aucun lien parent valide ticket #157.
bool get hasNoResponsable {
if (sansResponsable != null) return sansResponsable!;
return !parentLinks.any((l) => l.parentId.trim().isNotEmpty);
}
EnfantAdminModel copyWith({
String? id,
String? firstName,
String? lastName,
String? gender,
String? birthDate,
String? dueDate,
String? status,
String? photoUrl,
bool? consentPhoto,
bool? isMultiple,
List<EnfantParentLink>? parentLinks,
bool? sansResponsable,
}) {
return EnfantAdminModel(
id: id ?? this.id,
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
gender: gender ?? this.gender,
birthDate: birthDate ?? this.birthDate,
dueDate: dueDate ?? this.dueDate,
status: status ?? this.status,
photoUrl: photoUrl ?? this.photoUrl,
consentPhoto: consentPhoto ?? this.consentPhoto,
isMultiple: isMultiple ?? this.isMultiple,
parentLinks: parentLinks ?? this.parentLinks,
sansResponsable: sansResponsable ?? this.sansResponsable,
);
}
factory EnfantAdminModel.fromJson(Map<String, dynamic> json) { factory EnfantAdminModel.fromJson(Map<String, dynamic> json) {
final linksRaw = json['parentLinks'] as List?; final linksRaw = json['parentLinks'] as List?;
final links = <EnfantParentLink>[]; final links = <EnfantParentLink>[];
@ -48,18 +87,34 @@ class EnfantAdminModel {
} }
} }
final photoUrl = json['photo_url'] as String? ?? json['photoUrl'] as String?;
final consentPhoto = _parseBool(json['consent_photo']) ||
_parseBool(json['consentement_photo']) ||
_parseBool(json['consentPhoto']);
bool? sansResponsable;
if (json.containsKey('sans_responsable') ||
json.containsKey('sansResponsable')) {
sansResponsable = _parseBool(json['sans_responsable']) ||
_parseBool(json['sansResponsable']);
}
return EnfantAdminModel( return EnfantAdminModel(
id: (json['id'] ?? '').toString(), id: (json['id'] ?? '').toString(),
firstName: json['first_name'] as String?, firstName: json['first_name'] as String? ?? json['prenom'] as String?,
lastName: json['last_name'] as String?, lastName: json['last_name'] as String? ?? json['nom'] as String?,
gender: json['gender'] as String?, gender: json['gender'] as String? ?? json['genre'] as String?,
birthDate: _dateString(json['birth_date']), birthDate: _dateString(json['birth_date'] ?? json['date_naissance']),
dueDate: _dateString(json['due_date']), dueDate: _dateString(json['due_date'] ?? json['date_prevue_naissance']),
status: normalizeEnfantStatus(json['status']?.toString()), status: normalizeEnfantStatus(
photoUrl: json['photo_url'] as String?, (json['status'] ?? json['statut'])?.toString(),
consentPhoto: json['consent_photo'] == true, ),
isMultiple: json['is_multiple'] == true, photoUrl: photoUrl,
consentPhoto: consentPhoto,
isMultiple: _parseBool(json['is_multiple']) ||
_parseBool(json['est_multiple']),
parentLinks: links, parentLinks: links,
sansResponsable: sansResponsable,
); );
} }
@ -76,6 +131,15 @@ class EnfantAdminModel {
}; };
} }
static bool _parseBool(dynamic value) {
if (value == true || value == 1) return true;
if (value is String) {
final s = value.trim().toLowerCase();
return s == 'true' || s == '1' || s == 't' || s == 'yes';
}
return false;
}
static String? _dateString(dynamic v) { static String? _dateString(dynamic v) {
if (v == null) return null; if (v == null) return null;
if (v is String) return v.split('T').first; if (v is String) return v.split('T').first;
@ -89,6 +153,13 @@ class EnfantParentLink {
EnfantParentLink({required this.parentId, this.parentName}); EnfantParentLink({required this.parentId, this.parentName});
EnfantParentLink copyWith({String? parentId, String? parentName}) {
return EnfantParentLink(
parentId: parentId ?? this.parentId,
parentName: parentName ?? this.parentName,
);
}
factory EnfantParentLink.fromJson(Map<String, dynamic> json) { factory EnfantParentLink.fromJson(Map<String, dynamic> json) {
final parentId = final parentId =
(json['parentId'] ?? json['id_parent'] ?? '').toString(); (json['parentId'] ?? json['id_parent'] ?? '').toString();
@ -99,6 +170,12 @@ class EnfantParentLink {
if (user is Map<String, dynamic>) { if (user is Map<String, dynamic>) {
final u = AppUser.fromJson(user); final u = AppUser.fromJson(user);
name = u.fullName.isNotEmpty ? u.fullName : u.email; name = u.fullName.isNotEmpty ? u.fullName : u.email;
} else {
// Parfois le parent est aplati (prenom/nom) sans nested user.
final prenom = (parent['prenom'] ?? parent['first_name'] ?? '').toString().trim();
final nom = (parent['nom'] ?? parent['last_name'] ?? '').toString().trim();
final flat = '$prenom $nom'.trim();
if (flat.isNotEmpty) name = flat;
} }
} }
return EnfantParentLink( return EnfantParentLink(

View File

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

View File

@ -5,6 +5,7 @@ import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/widgets/email_text_field.dart'; import 'package:p_tits_pas/widgets/email_text_field.dart';
import 'package:p_tits_pas/widgets/french_phone_field.dart'; import 'package:p_tits_pas/widgets/french_phone_field.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/relais_service.dart'; import 'package:p_tits_pas/services/relais_service.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
@ -41,9 +42,15 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
bool _isLoadingRelais = true; bool _isLoadingRelais = true;
List<RelaisModel> _relais = []; List<RelaisModel> _relais = [];
String? _selectedRelaisId; String? _selectedRelaisId;
String? _currentUserId;
bool get _isEditMode => widget.initialUser != null; bool get _isEditMode => widget.initialUser != null;
bool get _isSuperAdminTarget => bool get _isSuperAdminTarget =>
(widget.initialUser?.role ?? '').toLowerCase() == 'super_admin'; (widget.initialUser?.role ?? '').toLowerCase() == 'super_admin';
bool get _isSelfTarget =>
_isEditMode &&
_currentUserId != null &&
widget.initialUser!.id == _currentUserId;
bool get _canDeleteTarget => !_isSuperAdminTarget && !_isSelfTarget;
bool get _isLockedAdminIdentity => bool get _isLockedAdminIdentity =>
_isEditMode && widget.adminMode && _isSuperAdminTarget; _isEditMode && widget.adminMode && _isSuperAdminTarget;
String get _targetRoleKey { String get _targetRoleKey {
@ -109,6 +116,23 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
} else { } else {
_isLoadingRelais = false; _isLoadingRelais = false;
} }
_loadCurrentUserId();
}
Future<void> _loadCurrentUserId() async {
final cached = await AuthService.getCurrentUser();
if (!mounted) return;
if (cached != null) {
setState(() {
_currentUserId = cached.id;
});
return;
}
final refreshed = await AuthService.refreshCurrentUser();
if (!mounted || refreshed == null) return;
setState(() {
_currentUserId = refreshed.id;
});
} }
@override @override
@ -122,6 +146,21 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
super.dispose(); super.dispose();
} }
/// Fallback si GET /relais échoue : conserve le relais déjà connu sur l'utilisateur.
List<RelaisModel> _fallbackRelaisFromUser() {
final id = _selectedRelaisId?.trim();
if (id == null || id.isEmpty) return const [];
final nom = (widget.initialUser?.relaisNom ?? '').trim();
return [
RelaisModel(
id: id,
nom: nom.isNotEmpty ? nom : 'Relais actuel',
adresse: '',
actif: true,
),
];
}
Future<void> _loadRelais() async { Future<void> _loadRelais() async {
try { try {
final list = await RelaisService.getRelais(); final list = await RelaisService.getRelais();
@ -138,7 +177,8 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
if (selected != null) { if (selected != null) {
filtered.add(selected); filtered.add(selected);
} else { } else {
_selectedRelaisId = null; // Garder l'id sélectionné et afficher un item de secours (nom carte).
filtered.addAll(_fallbackRelaisFromUser());
} }
} }
@ -148,9 +188,9 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
}); });
} catch (_) { } catch (_) {
if (!mounted) return; if (!mounted) return;
// Ne pas nullifier _selectedRelaisId (#151) la carte a déjà le bon libellé.
setState(() { setState(() {
_selectedRelaisId = null; _relais = _fallbackRelaisFromUser();
_relais = [];
_isLoadingRelais = false; _isLoadingRelais = false;
}); });
} }
@ -335,7 +375,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
Future<void> _delete() async { Future<void> _delete() async {
if (widget.readOnly) return; if (widget.readOnly) return;
if (_isSuperAdminTarget) return; if (!_canDeleteTarget) return;
if (!_isEditMode || _isSubmitting) return; if (!_isEditMode || _isSubmitting) return;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
@ -462,7 +502,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
child: const Text('Fermer'), child: const Text('Fermer'),
), ),
] else if (_isEditMode) ...[ ] else if (_isEditMode) ...[
if (!_isSuperAdminTarget) if (_canDeleteTarget)
OutlinedButton( OutlinedButton(
onPressed: _isSubmitting ? null : _delete, onPressed: _isSubmitting ? null : _delete,
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700), style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),

View File

@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:http_parser/http_parser.dart';
import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/models/parent_model.dart'; import 'package:p_tits_pas/models/parent_model.dart';
@ -8,6 +9,7 @@ import 'package:p_tits_pas/models/pending_family.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/services/api/tokenService.dart'; import 'package:p_tits_pas/services/api/tokenService.dart';
import 'package:p_tits_pas/utils/am_vigilance.dart';
class DocumentActifInfo { class DocumentActifInfo {
final String id; final String id;
@ -56,6 +58,33 @@ class UserService {
return v.toString(); return v.toString();
} }
/// MIME pour upload photo enfant (filtre Nest : jpg/jpeg/png/gif).
static MediaType _imageMediaType(String filename, List<int> bytes) {
if (bytes.length >= 3 &&
bytes[0] == 0xFF &&
bytes[1] == 0xD8 &&
bytes[2] == 0xFF) {
return MediaType('image', 'jpeg');
}
if (bytes.length >= 8 &&
bytes[0] == 0x89 &&
bytes[1] == 0x50 &&
bytes[2] == 0x4E &&
bytes[3] == 0x47) {
return MediaType('image', 'png');
}
if (bytes.length >= 6 &&
bytes[0] == 0x47 &&
bytes[1] == 0x49 &&
bytes[2] == 0x46) {
return MediaType('image', 'gif');
}
final lower = filename.toLowerCase();
if (lower.endsWith('.png')) return MediaType('image', 'png');
if (lower.endsWith('.gif')) return MediaType('image', 'gif');
return MediaType('image', 'jpeg');
}
static String _errMessage(dynamic err) { static String _errMessage(dynamic err) {
if (err == null) return 'Erreur inconnue'; if (err == null) return 'Erreur inconnue';
if (err is String) return err; if (err is String) return err;
@ -460,7 +489,40 @@ class UserService {
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception(_extractErrorMessage(response.body, 'Erreur chargement enfant')); throw Exception(_extractErrorMessage(response.body, 'Erreur chargement enfant'));
} }
return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>); final enfant = EnfantAdminModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
// GET /enfants/:id ne joint pas toujours parent.user noms manquants.
return enrichEnfantParentNames(enfant);
}
/// Complète [parentName] via GET parent quand l'API n'a pas fourni le nested user.
static Future<EnfantAdminModel> enrichEnfantParentNames(
EnfantAdminModel enfant,
) async {
final links = enfant.parentLinks;
if (links.isEmpty) return enfant;
if (links.every((l) => (l.parentName ?? '').trim().isNotEmpty)) {
return enfant;
}
final enriched = await Future.wait(
links.map((link) async {
if ((link.parentName ?? '').trim().isNotEmpty) return link;
final id = link.parentId.trim();
if (id.isEmpty) return link;
try {
final parent = await getParent(id);
final name = parent.user.fullName.trim();
if (name.isEmpty) return link;
return link.copyWith(parentName: name);
} catch (_) {
return link;
}
}),
);
return enfant.copyWith(parentLinks: enriched);
} }
static Future<EnfantAdminModel> updateEnfant({ static Future<EnfantAdminModel> updateEnfant({
@ -475,7 +537,91 @@ class UserService {
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant')); throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant'));
} }
return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>); final enfant = EnfantAdminModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
return enrichEnfantParentNames(enfant);
}
/// Création enfant côté staff (#132) nécessite `POST /enfants` étendu (voir mini-spec).
/// [parentUserId] : parent pivot du foyer (`parent_user_id`).
/// Avec [photoBytes] : `multipart/form-data` (champ fichier `photo`), sinon JSON.
static Future<EnfantAdminModel> createEnfant({
required String parentUserId,
required Map<String, dynamic> body,
List<int>? photoBytes,
String? photoFilename,
}) async {
final hasPhoto = photoBytes != null && photoBytes.isNotEmpty;
final http.Response response;
if (hasPhoto) {
final token = await TokenService.getToken();
final req = http.MultipartRequest(
'POST',
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
);
req.headers['Accept'] = 'application/json';
if (token != null) {
req.headers['Authorization'] = 'Bearer $token';
}
body.forEach((key, value) {
if (value == null) return;
req.fields[key] = value is bool
? (value ? 'true' : 'false')
: value.toString();
});
req.fields['parent_user_id'] = parentUserId;
final name = (photoFilename ?? '').trim();
final filename = name.isNotEmpty ? name : 'photo.jpg';
req.files.add(
http.MultipartFile.fromBytes(
'photo',
photoBytes,
filename: filename,
contentType: _imageMediaType(filename, photoBytes),
),
);
final streamed = await req.send();
response = await http.Response.fromStream(streamed);
} else {
final payload = <String, dynamic>{
...body,
'parent_user_id': parentUserId,
};
response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
headers: await _headers(),
body: jsonEncode(payload),
);
}
if (response.statusCode != 200 && response.statusCode != 201) {
throw Exception(
_extractErrorMessage(response.body, 'Erreur création enfant'),
);
}
final enfant = EnfantAdminModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
return enrichEnfantParentNames(enfant);
}
static Future<void> deleteEnfant(String enfantId) async {
final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
headers: await _headers(),
);
if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception(_extractErrorMessage(response.body, 'Erreur suppression enfant'));
}
}
/// AM dont la liste d'enfants actifs contient [enfantId] (API actuelle).
static Future<AssistanteMaternelleModel?> findAmForEnfant(String enfantId) async {
final ams = await getAssistantesMaternelles();
for (final am in ams) {
if (am.children.any((c) => c.id == enfantId)) return am;
}
return null;
} }
static ParentModel _parentModelFromBody(String body) { static ParentModel _parentModelFromBody(String body) {
@ -633,7 +779,24 @@ class UserService {
if (response.statusCode != 200 && response.statusCode != 201) { if (response.statusCode != 200 && response.statusCode != 201) {
throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant')); throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant'));
} }
return _amModelFromBody(response.body); final am = _amModelFromBody(response.body);
// Recalcule toujours places_available (capacité enfants) après attachement.
return syncAmPlacesAvailable(am);
}
/// Aligne `places_available` sur capacité enfants rattachés.
static Future<AssistanteMaternelleModel> syncAmPlacesAvailable(
AssistanteMaternelleModel am,
) async {
final expected = amExpectedPlacesAvailable(
maxChildren: am.maxChildren,
childrenCount: am.children.length,
);
if (expected == null || am.placesAvailable == expected) return am;
return updateAmFiche(
amUserId: am.user.id,
body: {'places_available': expected},
);
} }
static Future<AssistanteMaternelleModel> detachEnfantFromAm({ static Future<AssistanteMaternelleModel> detachEnfantFromAm({

View File

@ -9,6 +9,18 @@ int? amExpectedPlacesAvailable({
return (maxChildren - childrenCount).clamp(0, maxChildren); return (maxChildren - childrenCount).clamp(0, maxChildren);
} }
/// True s'il reste au moins une place d'accueil (capacité enfants).
bool amHasFreePlace(AssistanteMaternelleModel am) {
final expected = amExpectedPlacesAvailable(
maxChildren: am.maxChildren,
childrenCount: am.children.length,
);
if (expected != null) return expected > 0;
final places = am.placesAvailable;
if (places != null) return places > 0;
return true;
}
/// True si la valeur déclarée par l'AM ne correspond pas au calcul métier. /// True si la valeur déclarée par l'AM ne correspond pas au calcul métier.
bool amHasPlacesInconsistency({ bool amHasPlacesInconsistency({
required int? maxChildren, required int? maxChildren,

View File

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

View File

@ -14,10 +14,10 @@ String normalizeEnfantStatus(String? raw) {
return s; return s;
} }
String _scolariseAccordeAuGenre(String? gender) { String _accordeAuGenre(String masculin, String feminin, String? gender) {
final g = (gender ?? '').trim().toUpperCase(); final g = (gender ?? '').trim().toUpperCase();
if (g == 'F') return 'Scolarisée'; if (g == 'F') return feminin;
return 'Scolarisé'; return masculin;
} }
/// Libellé affiché pour un statut enfant. /// Libellé affiché pour un statut enfant.
@ -28,9 +28,9 @@ String enfantStatusLabel(String? status, {String? gender}) {
case 'sans_garde': case 'sans_garde':
return 'Sans garde'; return 'Sans garde';
case 'garde': case 'garde':
return 'En garde'; return _accordeAuGenre('Gardé', 'Gardée', gender);
case 'scolarise': case 'scolarise':
return _scolariseAccordeAuGenre(gender); return _accordeAuGenre('Scolarisé', 'Scolarisée', gender);
default: default:
return status?.trim().isNotEmpty == true ? status!.trim() : ''; return status?.trim().isNotEmpty == true ? status!.trim() : '';
} }

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';
}

View File

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

View File

@ -167,6 +167,7 @@ class ParentRegistrationPayload {
final map = <String, dynamic>{ final map = <String, dynamic>{
'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre', 'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre',
'grossesse_multiple': c.multipleBirth, 'grossesse_multiple': c.multipleBirth,
'consent_photo': c.photoConsent,
}; };
final prenom = c.firstName.trim(); final prenom = c.firstName.trim();

View File

@ -38,15 +38,12 @@ class RepriseMapper {
? isoToDdMmYyyy(e.dueDate) ? isoToDdMmYyyy(e.dueDate)
: isoToDdMmYyyy(e.birthDate); : isoToDdMmYyyy(e.birthDate);
final photo = e.photoUrl?.trim(); final photo = e.photoUrl?.trim();
final hasPhoto = photo != null && photo.isNotEmpty;
return ChildData( return ChildData(
firstName: e.firstName ?? '', firstName: e.firstName ?? '',
lastName: e.lastName ?? '', lastName: e.lastName ?? '',
dob: dob, dob: dob,
genre: e.gender ?? '', genre: e.gender ?? '',
// Inscription initiale exigeait la coche pour envoyer la photo ; le back photoConsent: e.consentPhoto,
// ne persistait pas toujours consent_photo on pré-coche si photo en base.
photoConsent: e.consentPhoto || hasPhoto,
multipleBirth: e.estMultiple, multipleBirth: e.estMultiple,
isUnbornChild: isUnborn, isUnbornChild: isUnborn,
cardColor: _childCardColors[index % _childCardColors.length], cardColor: _childCardColors[index % _childCardColors.length],
@ -200,7 +197,7 @@ class RepriseMapper {
postalCode: dossier.codePostal ?? '', postalCode: dossier.codePostal ?? '',
city: dossier.ville ?? '', city: dossier.ville ?? '',
existingPhotoUrl: displayPhoto, existingPhotoUrl: displayPhoto,
consentementPhoto: dossier.consentementPhoto || hasPhoto, consentementPhoto: dossier.consentementPhoto,
dateOfBirth: parseIsoDate(dossier.dateNaissance), dateOfBirth: parseIsoDate(dossier.dateNaissance),
birthCity: dossier.lieuNaissanceVille ?? '', birthCity: dossier.lieuNaissanceVille ?? '',
birthCountry: dossier.lieuNaissancePays ?? '', birthCountry: dossier.lieuNaissancePays ?? '',

View File

@ -20,6 +20,8 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
final int capacity; final int capacity;
final void Function(ParentChildSummary child) onOpen; final void Function(ParentChildSummary child) onOpen;
final void Function(ParentChildSummary child) onDetach; final void Function(ParentChildSummary child) onDetach;
/// Clic sur une case libre même flux que « Rattacher un enfant » (#149).
final VoidCallback? onAttachEmpty;
const AdminAmChildrenCapacityGrid({ const AdminAmChildrenCapacityGrid({
super.key, super.key,
@ -27,6 +29,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
required this.capacity, required this.capacity,
required this.onOpen, required this.onOpen,
required this.onDetach, required this.onDetach,
this.onAttachEmpty,
}); });
@override @override
@ -78,7 +81,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
); );
} }
if (index < maxSlots) { if (index < maxSlots) {
return const _EmptySlot(); return _EmptySlot(onTap: onAttachEmpty);
} }
return const _UnavailableSlot(); return const _UnavailableSlot();
} }
@ -129,18 +132,52 @@ class _UnavailableSlot extends StatelessWidget {
} }
} }
class _EmptySlot extends StatelessWidget { class _EmptySlot extends StatefulWidget {
const _EmptySlot(); final VoidCallback? onTap;
const _EmptySlot({this.onTap});
@override
State<_EmptySlot> createState() => _EmptySlotState();
}
class _EmptySlotState extends State<_EmptySlot> {
bool _hovered = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return _SlotShell( final clickable = widget.onTap != null;
backgroundColor: Colors.grey.shade50, return MouseRegion(
borderColor: Colors.grey.shade300, onEnter: clickable ? (_) => setState(() => _hovered = true) : null,
child: Center( onExit: clickable ? (_) => setState(() => _hovered = false) : null,
child: Text( cursor: clickable ? SystemMouseCursors.click : MouseCursor.defer,
'Place libre', child: Material(
style: TextStyle(fontSize: 12, color: Colors.grey.shade500), color: Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(8),
hoverColor: const Color(0x149CC5C0),
child: _SlotShell(
backgroundColor: _hovered
? const Color(0xFFF3F0FA)
: Colors.grey.shade50,
borderColor: _hovered
? const Color(0xFFB8A4D4)
: Colors.grey.shade300,
child: Center(
child: Text(
'Place libre',
style: TextStyle(
fontSize: 12,
color: _hovered
? const Color(0xFF6B3FA0)
: Colors.grey.shade500,
fontWeight: _hovered ? FontWeight.w600 : FontWeight.w400,
),
),
),
),
), ),
), ),
); );

View File

@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/utils/am_vigilance.dart'; import 'package:p_tits_pas/utils/am_vigilance.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart';
@ -11,6 +10,7 @@ import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_children_capacity_grid.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_children_capacity_grid.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart';
@ -55,6 +55,10 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
late List<ParentChildSummary> _children; late List<ParentChildSummary> _children;
late Set<String> _baselineChildIds; late Set<String> _baselineChildIds;
/// Enfants ajoutés localement qui étaient déjà chez une autre AM
/// (enfantId amUserId d'origine). Au save : détacher puis rattacher.
final Map<String, String> _transferFromAmIds = {};
bool _saving = false; bool _saving = false;
bool _dirty = false; bool _dirty = false;
@ -234,6 +238,13 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
int? _capaciteMax() => _parseIntField(_capaciteCtrl); int? _capaciteMax() => _parseIntField(_capaciteCtrl);
/// True si plus aucune place d'accueil (enfants ≥ capacité max).
bool get _capacityFull {
final max = _capaciteMax();
if (max == null) return false;
return _children.length >= max;
}
int? _computedPlacesAvailable() => amExpectedPlacesAvailable( int? _computedPlacesAvailable() => amExpectedPlacesAvailable(
maxChildren: _capaciteMax(), maxChildren: _capaciteMax(),
childrenCount: _children.length, childrenCount: _children.length,
@ -311,6 +322,17 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
); );
} }
for (final id in currentIds.difference(_baselineChildIds)) { for (final id in currentIds.difference(_baselineChildIds)) {
// Transfert : détacher l'ancienne AM sans recalculer ses places
// (le ! de vigilance apparaît côté liste AM).
final previousAmId = _transferFromAmIds[id] ??
(await UserService.findAmForEnfant(id))?.user.id;
if (previousAmId != null &&
previousAmId != widget.assistante.user.id) {
await UserService.detachEnfantFromAm(
amUserId: previousAmId,
enfantId: id,
);
}
await UserService.attachEnfantToAm( await UserService.attachEnfantToAm(
amUserId: widget.assistante.user.id, amUserId: widget.assistante.user.id,
enfantId: id, enfantId: id,
@ -346,6 +368,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
_dirty = false; _dirty = false;
_saving = false; _saving = false;
_baselineChildIds = currentIds; _baselineChildIds = currentIds;
_transferFromAmIds.clear();
}); });
widget.onSaved?.call(); widget.onSaved?.call();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -377,6 +400,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
setState(() { setState(() {
_children = kids; _children = kids;
_baselineChildIds = kids.map((c) => c.id).toSet(); _baselineChildIds = kids.map((c) => c.id).toSet();
_transferFromAmIds.clear();
}); });
} catch (_) {} } catch (_) {}
} }
@ -422,8 +446,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Détacher l\'enfant'), title: const Text('Détacher l\'enfant'),
content: Text( content: Text(
'Retirer ${child.fullName} de la fiche de cette assistante ?\n' 'Retirer ${child.fullName} de la fiche de cette assistante ?',
'(L\'enfant ne sera pas supprimé.)',
), ),
actions: [ actions: [
TextButton( TextButton(
@ -442,55 +465,75 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
setState(() { setState(() {
_children = _children.where((c) => c.id != child.id).toList(); _children = _children.where((c) => c.id != child.id).toList();
_transferFromAmIds.remove(child.id);
_syncPlacesAfterChildrenChange(); _syncPlacesAfterChildrenChange();
_dirty = true; _dirty = true;
}); });
} }
Future<void> _attachChild() async { Future<void> _attachChild() async {
List<EnfantAdminModel> all; if (_capacityFull || !mounted) return;
try { final selected = await AdminSelectEnfantModal.show(
all = await UserService.getEnfants(); context,
} catch (e) { excludeIds: _children.map((c) => c.id).toSet(),
if (!mounted) return; title: 'Rattacher un enfant',
ScaffoldMessenger.of(context).showSnackBar( showSansGardeFilter: true,
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
return;
}
final linkedIds = _children.map((c) => c.id).toSet();
final candidates = all.where((e) => !linkedIds.contains(e.id)).toList();
if (candidates.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Aucun enfant disponible à rattacher')),
);
return;
}
if (!mounted) return;
final selected = await showDialog<EnfantAdminModel>(
context: context,
builder: (ctx) => SimpleDialog(
title: const Text('Rattacher un enfant'),
children: candidates
.map(
(e) => SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, e),
child: Text(e.fullName),
),
)
.toList(),
),
); );
if (selected == null || !mounted) return; if (selected == null || !mounted) return;
AssistanteMaternelleModel? previousAm;
try {
previousAm = await UserService.findAmForEnfant(selected.id);
} catch (_) {
previousAm = null;
}
if (!mounted) return;
final previousAmId = previousAm?.user.id;
final isTransfer = previousAmId != null &&
previousAmId != widget.assistante.user.id;
if (isTransfer) {
final amName = previousAm!.user.fullName.trim().isNotEmpty
? previousAm.user.fullName.trim()
: 'une autre assistante maternelle';
final gardeLabel =
(selected.gender ?? '').trim().toUpperCase() == 'F'
? 'déjà gardée'
: 'déjà gardé';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Changer d\'affectation'),
content: Text(
'${selected.fullName} est $gardeLabel par $amName.\n\n'
'Confirmer le transfert vers cette assistante ? '
'L\'enfant sera détaché de $amName.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ValidationModalTheme.primaryElevatedStyle,
child: const Text('Confirmer'),
),
],
),
);
if (confirmed != true || !mounted) return;
}
setState(() { setState(() {
_children = [ _children = [
..._children, ..._children,
ParentChildSummary.fromEnfant(selected), ParentChildSummary.fromEnfant(selected),
]; ];
if (isTransfer) {
_transferFromAmIds[selected.id] = previousAmId!;
}
_syncPlacesAfterChildrenChange(); _syncPlacesAfterChildrenChange();
_dirty = true; _dirty = true;
}); });
@ -701,6 +744,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
capacity: capacity, capacity: capacity,
onOpen: _openChild, onOpen: _openChild,
onDetach: _detachChild, onDetach: _detachChild,
onAttachEmpty: _capacityFull ? null : _attachChild,
), ),
], ],
); );
@ -708,6 +752,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
Widget _buildFooter() { Widget _buildFooter() {
final isChildrenTab = _tabCtrl.index == 2; final isChildrenTab = _tabCtrl.index == 2;
final canAttachChild = !_saving && !_capacityFull;
return Row( return Row(
children: [ children: [
TextButton( TextButton(
@ -716,10 +761,15 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
), ),
const Spacer(), const Spacer(),
if (isChildrenTab) if (isChildrenTab)
TextButton.icon( Tooltip(
onPressed: _attachChild, message: _capacityFull
icon: const Icon(Icons.link, size: 18), ? 'Capacité maximale atteinte'
label: const Text('Rattacher un enfant'), : 'Rattacher un enfant',
child: TextButton.icon(
onPressed: canAttachChild ? _attachChild : null,
icon: const Icon(Icons.link, size: 18),
label: const Text('Rattacher un enfant'),
),
), ),
if (isChildrenTab) const SizedBox(width: 12), if (isChildrenTab) const SizedBox(width: 12),
ElevatedButton( ElevatedButton(

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -64,6 +64,7 @@ class AdminChildrenAffiliationPanel extends StatelessWidget {
final c = children[i]; final c = children[i];
return AdminEnfantUserCard.fromSummary( return AdminEnfantUserCard.fromSummary(
c, c,
onCardTap: () => onOpen(c),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.visibility_outlined), icon: const Icon(Icons.visibility_outlined),

View File

@ -3,6 +3,7 @@ import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart';
import 'package:p_tits_pas/utils/enfant_vigilance.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
List<String> enfantAdminSubtitleLines({ List<String> enfantAdminSubtitleLines({
@ -34,6 +35,10 @@ class AdminEnfantUserCard extends StatelessWidget {
final List<String> subtitleLines; final List<String> subtitleLines;
final List<Widget> actions; final List<Widget> actions;
final VoidCallback? onCardTap; final VoidCallback? onCardTap;
final EdgeInsetsGeometry? margin;
final EdgeInsetsGeometry? contentPadding;
final Color? borderColor;
final String? vigilanceTooltip;
const AdminEnfantUserCard({ const AdminEnfantUserCard({
super.key, super.key,
@ -42,6 +47,10 @@ class AdminEnfantUserCard extends StatelessWidget {
required this.subtitleLines, required this.subtitleLines,
this.actions = const [], this.actions = const [],
this.onCardTap, this.onCardTap,
this.margin,
this.contentPadding,
this.borderColor,
this.vigilanceTooltip,
}); });
factory AdminEnfantUserCard.fromEnfant( factory AdminEnfantUserCard.fromEnfant(
@ -49,10 +58,15 @@ class AdminEnfantUserCard extends StatelessWidget {
List<String> extraSubtitleLines = const [], List<String> extraSubtitleLines = const [],
List<Widget> actions = const [], List<Widget> actions = const [],
VoidCallback? onCardTap, VoidCallback? onCardTap,
EdgeInsetsGeometry? margin,
EdgeInsetsGeometry? contentPadding,
Color? borderColor,
String? vigilanceTooltip,
}) { }) {
final parents = enfant.parentLinks final parents = enfant.parentLinks
.map((l) => l.parentName ?? 'Parent') .map((l) => l.parentName ?? 'Parent')
.join(', '); .join(', ');
final orphan = enfantHasNoResponsable(enfant);
return AdminEnfantUserCard( return AdminEnfantUserCard(
title: enfant.fullName, title: enfant.fullName,
photoUrl: enfant.photoUrl, photoUrl: enfant.photoUrl,
@ -63,11 +77,18 @@ class AdminEnfantUserCard extends StatelessWidget {
gender: enfant.gender, gender: enfant.gender,
extra: [ extra: [
if (parents.isNotEmpty) 'Responsables : $parents', if (parents.isNotEmpty) 'Responsables : $parents',
if (orphan) 'Aucun responsable rattaché',
...extraSubtitleLines, ...extraSubtitleLines,
], ],
), ),
actions: actions, actions: actions,
onCardTap: onCardTap, onCardTap: onCardTap,
margin: margin,
contentPadding: contentPadding,
borderColor: borderColor ??
(orphan ? Colors.red.shade300 : null),
vigilanceTooltip: vigilanceTooltip ??
enfantSansResponsableVigilanceMessage(enfant),
); );
} }
@ -76,6 +97,8 @@ class AdminEnfantUserCard extends StatelessWidget {
List<String> extraSubtitleLines = const [], List<String> extraSubtitleLines = const [],
List<Widget> actions = const [], List<Widget> actions = const [],
VoidCallback? onCardTap, VoidCallback? onCardTap,
EdgeInsetsGeometry? margin,
EdgeInsetsGeometry? contentPadding,
}) { }) {
return AdminEnfantUserCard( return AdminEnfantUserCard(
title: child.fullName, title: child.fullName,
@ -88,6 +111,8 @@ class AdminEnfantUserCard extends StatelessWidget {
), ),
actions: actions, actions: actions,
onCardTap: onCardTap, onCardTap: onCardTap,
margin: margin,
contentPadding: contentPadding,
); );
} }
@ -100,6 +125,10 @@ class AdminEnfantUserCard extends StatelessWidget {
subtitleLines: subtitleLines, subtitleLines: subtitleLines,
actions: actions, actions: actions,
onCardTap: onCardTap, onCardTap: onCardTap,
margin: margin,
contentPadding: contentPadding,
borderColor: borderColor,
vigilanceTooltip: vigilanceTooltip,
); );
} }
} }

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/models/parent_model.dart'; import 'package:p_tits_pas/models/parent_model.dart';
@ -7,6 +6,7 @@ import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
@ -113,10 +113,73 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
return '$fn $ln'.trim(); return '$fn $ln'.trim();
} }
String? _coParentSubtitle() { String? _coParentName() {
final name = _coParent?.fullName.trim() ?? ''; final name = _coParent?.fullName.trim() ?? '';
if (name.isEmpty) return null; return name.isEmpty ? null : name;
return 'Co-parent : $name'; }
Future<void> _openCoParent() async {
if (_saving) return;
final co = _coParent;
final id = (co?.id ?? '').trim();
if (co == null || id.isEmpty) return;
try {
final parent = await UserService.getParent(id);
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AdminParentEditModal(
parent: parent,
onSaved: () async {
try {
final refreshed = await UserService.getParent(widget.parent.user.id);
if (!mounted) return;
setState(() => _coParent = refreshed.coParent);
} catch (_) {}
widget.onSaved?.call();
},
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
Widget? _coParentSubtitle() {
final name = _coParentName();
if (name == null) return null;
return Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
const Text(
'Co-parent : ',
style: TextStyle(fontSize: 13, color: Colors.black54),
),
InkWell(
onTap: _saving ? null : _openCoParent,
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 2),
child: Text(
name,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: ValidationModalTheme.primaryActionBackground,
decoration: TextDecoration.underline,
decorationColor: ValidationModalTheme.primaryActionBackground
.withValues(alpha: 0.5),
),
),
),
),
],
);
} }
Future<void> _save() async { Future<void> _save() async {
@ -220,8 +283,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Détacher l\'enfant'), title: const Text('Détacher l\'enfant'),
content: Text( content: Text(
'Retirer ${child.fullName} de la fiche de ce parent ?\n' 'Retirer ${child.fullName} de la fiche de ce parent ?',
'(L\'enfant ne sera pas supprimé.)',
), ),
actions: [ actions: [
TextButton( TextButton(
@ -258,41 +320,11 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
} }
Future<void> _attachChild() async { Future<void> _attachChild() async {
List<EnfantAdminModel> all;
try {
all = await UserService.getEnfants();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
return;
}
final linkedIds = _children.map((c) => c.id).toSet();
final candidates = all.where((e) => !linkedIds.contains(e.id)).toList();
if (candidates.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Aucun enfant disponible à rattacher')),
);
return;
}
if (!mounted) return; if (!mounted) return;
final selected = await showDialog<EnfantAdminModel>( final selected = await AdminSelectEnfantModal.show(
context: context, context,
builder: (ctx) => SimpleDialog( excludeIds: _children.map((c) => c.id).toSet(),
title: const Text('Rattacher un enfant'), title: 'Rattacher un enfant',
children: candidates
.map(
(e) => SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, e),
child: Text(e.fullName),
),
)
.toList(),
),
); );
if (selected == null || !mounted) return; if (selected == null || !mounted) return;
@ -395,13 +427,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
), ),
if (_coParentSubtitle() != null) ...[ if (_coParentSubtitle() != null) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( _coParentSubtitle()!,
_coParentSubtitle()!,
style: const TextStyle(
fontSize: 13,
color: Colors.black54,
),
),
], ],
], ],
), ),

View File

@ -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'),
),
],
);
}
}

View File

@ -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,
),
],
);
},
);
}
}

View File

@ -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,
),
],
);
},
);
}
}

View File

@ -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),
);
},
);
}
}

View File

@ -14,6 +14,8 @@ class AdminUserCard extends StatefulWidget {
final Color? infoColor; final Color? infoColor;
final String? vigilanceTooltip; final String? vigilanceTooltip;
final VoidCallback? onCardTap; final VoidCallback? onCardTap;
final EdgeInsetsGeometry? margin;
final EdgeInsetsGeometry? contentPadding;
const AdminUserCard({ const AdminUserCard({
super.key, super.key,
@ -28,6 +30,8 @@ class AdminUserCard extends StatefulWidget {
this.infoColor, this.infoColor,
this.vigilanceTooltip, this.vigilanceTooltip,
this.onCardTap, this.onCardTap,
this.margin,
this.contentPadding,
}); });
@override @override
@ -59,7 +63,7 @@ class _AdminUserCardState extends State<AdminUserCard> {
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
hoverColor: const Color(0x149CC5C0), hoverColor: const Color(0x149CC5C0),
child: Card( child: Card(
margin: const EdgeInsets.only(bottom: 12), margin: widget.margin ?? const EdgeInsets.only(bottom: 12),
elevation: 0, elevation: 0,
color: widget.backgroundColor, color: widget.backgroundColor,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@ -67,7 +71,8 @@ class _AdminUserCardState extends State<AdminUserCard> {
side: BorderSide(color: widget.borderColor ?? Colors.grey.shade300), side: BorderSide(color: widget.borderColor ?? Colors.grey.shade300),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), padding: widget.contentPadding ??
const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
child: Row( child: Row(
children: [ children: [
_buildAvatar(avatarUrl), _buildAvatar(avatarUrl),
@ -86,7 +91,10 @@ class _AdminUserCardState extends State<AdminUserCard> {
Expanded( Expanded(
child: Row( child: Row(
children: [ children: [
// flex: 0 largeur du nom ; le reste va aux infos
// (évite le partage 50/50 qui tronque « Responsables »).
Flexible( Flexible(
flex: 0,
fit: FlexFit.loose, fit: FlexFit.loose,
child: Text( child: Text(
widget.title.isNotEmpty ? widget.title : 'Sans nom', widget.title.isNotEmpty ? widget.title : 'Sans nom',

View File

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

View File

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

View File

@ -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/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
@ -26,6 +27,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
int _subIndex = 0; int _subIndex = 0;
int _gestionnaireRefreshTick = 0; int _gestionnaireRefreshTick = 0;
int _adminRefreshTick = 0; int _adminRefreshTick = 0;
int _enfantRefreshTick = 0;
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final TextEditingController _amCapacityController = TextEditingController(); final TextEditingController _amCapacityController = TextEditingController();
String? _parentStatus; String? _parentStatus;
@ -215,7 +217,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
value: 'garde', value: 'garde',
child: Padding( child: Padding(
padding: EdgeInsets.only(left: 10), padding: EdgeInsets.only(left: 10),
child: Text('En garde', style: TextStyle(fontSize: 12)), child: Text('Gardé', style: TextStyle(fontSize: 12)),
), ),
), ),
DropdownMenuItem<String?>( DropdownMenuItem<String?>(
@ -264,6 +266,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
); );
case 1: case 1:
return EnfantManagementWidget( return EnfantManagementWidget(
key: ValueKey('enfants-$_enfantRefreshTick'),
searchQuery: _searchController.text, searchQuery: _searchController.text,
statusFilter: _enfantStatus, statusFilter: _enfantStatus,
); );
@ -311,6 +314,23 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
Future<void> _handleAddPressed() async { Future<void> _handleAddPressed() async {
final contentIndex = _subIndex - _contentIndexOffset; final contentIndex = _subIndex - _contentIndexOffset;
if (contentIndex == 1) {
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return AdminChildDetailModal.create(
onSaved: () {
if (!mounted) return;
setState(() => _enfantRefreshTick++);
},
);
},
);
return;
}
if (contentIndex == 3) { if (contentIndex == 3) {
final created = await showDialog<bool>( final created = await showDialog<bool>(
context: context, context: context,

View File

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

View File

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