Compare commits
51 Commits
master
...
fix/151-ge
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fe2b89a61 | |||
| d6a9b3fd66 | |||
| 134b9781c8 | |||
| b936d27445 | |||
| 18c1d1eba7 | |||
| f74b14c203 | |||
| f194b5f9e8 | |||
| 5ab8ae3423 | |||
| 3277f77846 | |||
| 2d80ad0d7e | |||
| 09386f8aa6 | |||
| faa50f637c | |||
| 267fe63aec | |||
| 90b185740c | |||
| b903dbf60b | |||
| 291ea26b34 | |||
| 59919834b9 | |||
| cf6acbae7c | |||
| 7951c38d4d | |||
| 77d952a6f7 | |||
| b4b546044d | |||
| 6788351070 | |||
| 7b69f27ca5 | |||
| 8ed68797aa | |||
| 9ad371a342 | |||
| 18718670e9 | |||
| fbf22f2540 | |||
| 43a2cd213b | |||
| c865d11dc3 | |||
| d03a8e6c8b | |||
| 66c7f22280 | |||
| 53721ffbb3 | |||
| 52e40d0001 | |||
| 4985726bc6 | |||
| 479a32b4bf | |||
| 2fd97ddecb | |||
| ce474797c4 | |||
| ebf794e1ac | |||
| d55f240f56 | |||
| 966f4a9c9c | |||
| 8494341b56 | |||
| 1dddc67933 | |||
| df776d8200 | |||
| c26ed00374 | |||
| 9d54d9b19b | |||
| c438009286 | |||
| 9b7231f1da | |||
| f300505225 | |||
| 25c10c885a | |||
| d70577b1c3 | |||
| 671da71752 |
@ -1,5 +1,4 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
@ -7,23 +6,11 @@ import {
|
|||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
|
||||||
MaxLength,
|
MaxLength,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { GenreType, StatutEnfantType } from 'src/entities/children.entity';
|
import { GenreType, StatutEnfantType } from 'src/entities/children.entity';
|
||||||
|
|
||||||
/** Multipart envoie des strings ("true"/"false") — JSON envoie déjà des booleans. */
|
|
||||||
function toBoolean({ value }: { value: unknown }): boolean | unknown {
|
|
||||||
if (typeof value === 'boolean') return value;
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
const v = value.trim().toLowerCase();
|
|
||||||
if (v === 'true' || v === '1') return true;
|
|
||||||
if (v === 'false' || v === '0' || v === '') return false;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CreateEnfantsDto {
|
export class CreateEnfantsDto {
|
||||||
@ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE })
|
@ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE })
|
||||||
@IsEnum(StatutEnfantType)
|
@IsEnum(StatutEnfantType)
|
||||||
@ -65,7 +52,6 @@ export class CreateEnfantsDto {
|
|||||||
photo_url?: string;
|
photo_url?: string;
|
||||||
|
|
||||||
@ApiProperty({ default: false })
|
@ApiProperty({ default: false })
|
||||||
@Transform(toBoolean)
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
consent_photo: boolean;
|
consent_photo: boolean;
|
||||||
|
|
||||||
@ -75,20 +61,6 @@ export class CreateEnfantsDto {
|
|||||||
consent_photo_at?: string;
|
consent_photo_at?: string;
|
||||||
|
|
||||||
@ApiProperty({ default: false })
|
@ApiProperty({ default: false })
|
||||||
@Transform(toBoolean)
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
is_multiple: boolean;
|
is_multiple: boolean;
|
||||||
|
|
||||||
/**
|
|
||||||
* Parent pivot du foyer — obligatoire pour staff (gestionnaire/admin).
|
|
||||||
* Ignoré / interdit en externe pour un PARENT (ticket #132).
|
|
||||||
*/
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description:
|
|
||||||
'UUID du parent pivot (staff only). Obligatoire pour GESTIONNAIRE / ADMIN / SUPER_ADMIN.',
|
|
||||||
format: 'uuid',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsUUID('4')
|
|
||||||
parent_user_id?: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,33 +1,20 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
CallHandler,
|
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
ExecutionContext,
|
|
||||||
Get,
|
Get,
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Injectable,
|
|
||||||
NestInterceptor,
|
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
UploadedFile,
|
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
|
UploadedFile,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import {
|
import { ApiBearerAuth, ApiTags, ApiConsumes } from '@nestjs/swagger';
|
||||||
ApiBearerAuth,
|
|
||||||
ApiBody,
|
|
||||||
ApiConsumes,
|
|
||||||
ApiOperation,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { diskStorage } from 'multer';
|
import { diskStorage } from 'multer';
|
||||||
import { extname } from 'path';
|
import { extname } from 'path';
|
||||||
import { Observable } from 'rxjs';
|
|
||||||
import { EnfantsService } from './enfants.service';
|
import { EnfantsService } from './enfants.service';
|
||||||
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
||||||
import { UpdateEnfantsDto } from './dto/update_enfants.dto';
|
import { UpdateEnfantsDto } from './dto/update_enfants.dto';
|
||||||
@ -37,7 +24,18 @@ import { AuthGuard } from 'src/common/guards/auth.guard';
|
|||||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
|
|
||||||
const photoMulterOptions = {
|
@ApiBearerAuth('access-token')
|
||||||
|
@ApiTags('Enfants')
|
||||||
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
|
@Controller('enfants')
|
||||||
|
export class EnfantsController {
|
||||||
|
constructor(private readonly enfantsService: EnfantsService) { }
|
||||||
|
|
||||||
|
@Roles(RoleType.PARENT)
|
||||||
|
@Post()
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('photo', {
|
||||||
storage: diskStorage({
|
storage: diskStorage({
|
||||||
destination: './uploads/photos',
|
destination: './uploads/photos',
|
||||||
filename: (req, file, cb) => {
|
filename: (req, file, cb) => {
|
||||||
@ -55,57 +53,8 @@ const photoMulterOptions = {
|
|||||||
limits: {
|
limits: {
|
||||||
fileSize: 5 * 1024 * 1024,
|
fileSize: 5 * 1024 * 1024,
|
||||||
},
|
},
|
||||||
};
|
}),
|
||||||
|
|
||||||
/**
|
|
||||||
* Multer uniquement si Content-Type multipart (parent ou staff + photo).
|
|
||||||
* JSON sans photo (#132) passe sans interceptor fichier.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
class OptionalEnfantPhotoInterceptor implements NestInterceptor {
|
|
||||||
private readonly multipart = new (FileInterceptor(
|
|
||||||
'photo',
|
|
||||||
photoMulterOptions,
|
|
||||||
))();
|
|
||||||
|
|
||||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> | Promise<Observable<unknown>> {
|
|
||||||
const req = context.switchToHttp().getRequest();
|
|
||||||
const ct = String(req.headers['content-type'] ?? '');
|
|
||||||
if (!ct.includes('multipart/form-data')) {
|
|
||||||
return next.handle();
|
|
||||||
}
|
|
||||||
return this.multipart.intercept(context, next);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ApiBearerAuth('access-token')
|
|
||||||
@ApiTags('Enfants')
|
|
||||||
@UseGuards(AuthGuard, RolesGuard)
|
|
||||||
@Controller('enfants')
|
|
||||||
export class EnfantsController {
|
|
||||||
constructor(private readonly enfantsService: EnfantsService) { }
|
|
||||||
|
|
||||||
@Roles(
|
|
||||||
RoleType.PARENT,
|
|
||||||
RoleType.GESTIONNAIRE,
|
|
||||||
RoleType.ADMINISTRATEUR,
|
|
||||||
RoleType.SUPER_ADMIN,
|
|
||||||
)
|
)
|
||||||
@Post()
|
|
||||||
@HttpCode(HttpStatus.CREATED)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Créer un enfant',
|
|
||||||
description:
|
|
||||||
'PARENT : multipart éventuel, rattache au compte connecté. ' +
|
|
||||||
'Staff : parent_user_id obligatoire ; JSON sans photo OK ; avec photo → multipart (champ fichier `photo`, max 5 Mo). Ticket #132.',
|
|
||||||
})
|
|
||||||
@ApiConsumes('application/json', 'multipart/form-data')
|
|
||||||
@ApiBody({
|
|
||||||
description:
|
|
||||||
'Champs métier (+ parent_user_id côté staff). Fichier optionnel `photo` en multipart.',
|
|
||||||
type: CreateEnfantsDto,
|
|
||||||
})
|
|
||||||
@UseInterceptors(OptionalEnfantPhotoInterceptor)
|
|
||||||
create(
|
create(
|
||||||
@Body() dto: CreateEnfantsDto,
|
@Body() dto: CreateEnfantsDto,
|
||||||
@UploadedFile() photo: Express.Multer.File,
|
@UploadedFile() photo: Express.Multer.File,
|
||||||
|
|||||||
@ -13,12 +13,6 @@ import { ParentsChildren } from 'src/entities/parents_children.entity';
|
|||||||
import { RoleType, Users } from 'src/entities/users.entity';
|
import { RoleType, Users } from 'src/entities/users.entity';
|
||||||
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
||||||
|
|
||||||
const STAFF_ROLES: RoleType[] = [
|
|
||||||
RoleType.GESTIONNAIRE,
|
|
||||||
RoleType.ADMINISTRATEUR,
|
|
||||||
RoleType.SUPER_ADMIN,
|
|
||||||
];
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EnfantsService {
|
export class EnfantsService {
|
||||||
constructor(
|
constructor(
|
||||||
@ -30,35 +24,20 @@ export class EnfantsService {
|
|||||||
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
private isStaff(user: Users): boolean {
|
// Création d'un enfant
|
||||||
return STAFF_ROLES.includes(user.role);
|
async create(dto: CreateEnfantsDto, currentUser: Users, photoFile?: Express.Multer.File): Promise<Children> {
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Création d'un enfant.
|
|
||||||
* - PARENT : rattache au parent connecté (multipart photo optionnel).
|
|
||||||
* - Staff : `parent_user_id` obligatoire ; JSON sans photo OK ;
|
|
||||||
* avec photo → multipart (même stockage `/uploads/photos/...`). Ticket #132.
|
|
||||||
*/
|
|
||||||
async create(
|
|
||||||
dto: CreateEnfantsDto,
|
|
||||||
currentUser: Users,
|
|
||||||
photoFile?: Express.Multer.File,
|
|
||||||
): Promise<Children> {
|
|
||||||
const pivotUserId = this.resolvePivotParentUserId(dto, currentUser);
|
|
||||||
|
|
||||||
const parent = await this.parentsRepository.findOne({
|
const parent = await this.parentsRepository.findOne({
|
||||||
where: { user_id: pivotUserId },
|
where: { user_id: currentUser.id },
|
||||||
relations: ['co_parent'],
|
relations: ['co_parent'],
|
||||||
});
|
});
|
||||||
if (!parent) throw new NotFoundException('Parent introuvable');
|
if (!parent) throw new NotFoundException('Parent introuvable');
|
||||||
|
|
||||||
// Vérif métier simple (aligné comportement historique parent)
|
// Vérif métier simple
|
||||||
if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) {
|
if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) {
|
||||||
throw new BadRequestException('Un enfant né doit avoir une date de naissance');
|
throw new BadRequestException('Un enfant né doit avoir une date de naissance');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérif doublon éventuel (ex: même prénom + date de naissance)
|
// Vérif doublon éventuel (ex: même prénom + date de naissance pour ce parent)
|
||||||
const exist = await this.childrenRepository.findOne({
|
const exist = await this.childrenRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
first_name: dto.first_name,
|
first_name: dto.first_name,
|
||||||
@ -68,108 +47,50 @@ export class EnfantsService {
|
|||||||
});
|
});
|
||||||
if (exist) throw new ConflictException('Cet enfant existe déjà');
|
if (exist) throw new ConflictException('Cet enfant existe déjà');
|
||||||
|
|
||||||
// Gestion de la photo uploadée (multipart parent ou staff)
|
// Gestion de la photo uploadée
|
||||||
let photoUrl = dto.photo_url;
|
|
||||||
let consentAt: Date | undefined;
|
|
||||||
if (photoFile) {
|
if (photoFile) {
|
||||||
photoUrl = `/uploads/photos/${photoFile.filename}`;
|
dto.photo_url = `/uploads/photos/${photoFile.filename}`;
|
||||||
if (dto.consent_photo) {
|
if (dto.consent_photo) {
|
||||||
consentAt = new Date();
|
dto.consent_photo_at = new Date().toISOString();
|
||||||
}
|
}
|
||||||
} else if (dto.consent_photo) {
|
|
||||||
consentAt = dto.consent_photo_at
|
|
||||||
? new Date(dto.consent_photo_at)
|
|
||||||
: new Date();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const child = this.childrenRepository.create({
|
// Création
|
||||||
status: dto.status,
|
const child = this.childrenRepository.create(dto);
|
||||||
first_name: dto.first_name,
|
|
||||||
last_name: dto.last_name,
|
|
||||||
gender: dto.gender,
|
|
||||||
birth_date: dto.birth_date ? new Date(dto.birth_date) : undefined,
|
|
||||||
due_date: dto.due_date ? new Date(dto.due_date) : undefined,
|
|
||||||
photo_url: photoUrl,
|
|
||||||
consent_photo: !!dto.consent_photo,
|
|
||||||
consent_photo_at: consentAt,
|
|
||||||
is_multiple: !!dto.is_multiple,
|
|
||||||
});
|
|
||||||
await this.childrenRepository.save(child);
|
await this.childrenRepository.save(child);
|
||||||
|
|
||||||
// Lien parent-enfant (pivot)
|
// Lien parent-enfant (Parent 1)
|
||||||
await this.parentsChildrenRepository.save(
|
const parentLink = this.parentsChildrenRepository.create({
|
||||||
this.parentsChildrenRepository.create({
|
|
||||||
parentId: parent.user_id,
|
parentId: parent.user_id,
|
||||||
enfantId: child.id,
|
enfantId: child.id,
|
||||||
}),
|
});
|
||||||
);
|
await this.parentsChildrenRepository.save(parentLink);
|
||||||
|
|
||||||
// Rattachement automatique au co-parent s'il existe
|
// Rattachement automatique au co-parent s'il existe
|
||||||
if (parent.co_parent) {
|
if (parent.co_parent) {
|
||||||
await this.parentsChildrenRepository.save(
|
const coParentLink = this.parentsChildrenRepository.create({
|
||||||
this.parentsChildrenRepository.create({
|
|
||||||
parentId: parent.co_parent.id,
|
parentId: parent.co_parent.id,
|
||||||
enfantId: child.id,
|
enfantId: child.id,
|
||||||
}),
|
});
|
||||||
);
|
await this.parentsChildrenRepository.save(coParentLink);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.findOne(child.id, currentUser);
|
return this.findOne(child.id, currentUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolvePivotParentUserId(
|
// Liste des enfants (admin/gestionnaire)
|
||||||
dto: CreateEnfantsDto,
|
async findAll(): Promise<Children[]> {
|
||||||
currentUser: Users,
|
return this.childrenRepository.find({
|
||||||
): string {
|
|
||||||
if (this.isStaff(currentUser)) {
|
|
||||||
const id = dto.parent_user_id?.trim();
|
|
||||||
if (!id) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'parent_user_id est obligatoire pour créer un enfant (staff)',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentUser.role === RoleType.PARENT) {
|
|
||||||
if (
|
|
||||||
dto.parent_user_id &&
|
|
||||||
dto.parent_user_id.trim() !== currentUser.id
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
'Un parent ne peut pas créer un enfant pour un autre compte',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return currentUser.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new ForbiddenException('Accès interdit');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Flag API #157 — true si aucun lien enfants_parents. */
|
|
||||||
private withSansResponsable(child: Children): Children & { sans_responsable: boolean } {
|
|
||||||
return Object.assign(child, {
|
|
||||||
sans_responsable: !child.parentLinks || child.parentLinks.length === 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Liste des enfants (admin/gestionnaire) — inclut les orphelins (parentLinks: [])
|
|
||||||
async findAll(): Promise<Array<Children & { sans_responsable: boolean }>> {
|
|
||||||
const children = await this.childrenRepository.find({
|
|
||||||
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
||||||
order: { last_name: 'ASC', first_name: 'ASC' },
|
order: { last_name: 'ASC', first_name: 'ASC' },
|
||||||
});
|
});
|
||||||
return children.map((c) => this.withSansResponsable(c));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer un enfant par id
|
// Récupérer un enfant par id
|
||||||
async findOne(
|
async findOne(id: string, currentUser: Users): Promise<Children> {
|
||||||
id: string,
|
|
||||||
currentUser: Users,
|
|
||||||
): Promise<Children & { sans_responsable: boolean }> {
|
|
||||||
const child = await this.childrenRepository.findOne({
|
const child = await this.childrenRepository.findOne({
|
||||||
where: { id },
|
where: { id },
|
||||||
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
relations: ['parentLinks'],
|
||||||
});
|
});
|
||||||
if (!child) throw new NotFoundException('Enfant introuvable');
|
if (!child) throw new NotFoundException('Enfant introuvable');
|
||||||
|
|
||||||
@ -183,14 +104,14 @@ export class EnfantsService {
|
|||||||
case RoleType.ADMINISTRATEUR:
|
case RoleType.ADMINISTRATEUR:
|
||||||
case RoleType.SUPER_ADMIN:
|
case RoleType.SUPER_ADMIN:
|
||||||
case RoleType.GESTIONNAIRE:
|
case RoleType.GESTIONNAIRE:
|
||||||
// accès complet (y compris orphelins)
|
// accès complet
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new ForbiddenException('Accès interdit');
|
throw new ForbiddenException('Accès interdit');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.withSansResponsable(child);
|
return child;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -199,8 +120,7 @@ export class EnfantsService {
|
|||||||
const child = await this.childrenRepository.findOne({ where: { id } });
|
const child = await this.childrenRepository.findOne({ where: { id } });
|
||||||
if (!child) throw new NotFoundException('Enfant introuvable');
|
if (!child) throw new NotFoundException('Enfant introuvable');
|
||||||
|
|
||||||
const { parent_user_id: _ignored, ...rest } = dto;
|
const patch: Partial<Children> = { ...dto } as Partial<Children>;
|
||||||
const patch: Partial<Children> = { ...rest } as Partial<Children>;
|
|
||||||
if (dto.consent_photo !== undefined) {
|
if (dto.consent_photo !== undefined) {
|
||||||
patch.consent_photo = dto.consent_photo;
|
patch.consent_photo = dto.consent_photo;
|
||||||
patch.consent_photo_at = dto.consent_photo ? new Date() : null!;
|
patch.consent_photo_at = dto.consent_photo ? new Date() : null!;
|
||||||
|
|||||||
@ -138,9 +138,7 @@ export class ParentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Détacher un enfant d'un parent sans supprimer l'enfant.
|
* Détacher un enfant d'un parent sans supprimer l'enfant. Ticket #115 / doc 28 §6.2.
|
||||||
* Autorise le détachement du dernier responsable (#157) — l'enfant reste listé
|
|
||||||
* via GET /enfants avec parentLinks vides (alerte front).
|
|
||||||
*/
|
*/
|
||||||
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
||||||
await this.findOne(parentUserId);
|
await this.findOne(parentUserId);
|
||||||
@ -152,6 +150,11 @@ export class ParentsService {
|
|||||||
throw new NotFoundException('Lien parent-enfant introuvable');
|
throw new NotFoundException('Lien parent-enfant introuvable');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totalLinks = await this.parentsChildrenRepository.count({ where: { enfantId } });
|
||||||
|
if (totalLinks <= 1) {
|
||||||
|
throw new BadRequestException('Un enfant doit rester rattaché à au moins un responsable');
|
||||||
|
}
|
||||||
|
|
||||||
await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId });
|
await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId });
|
||||||
return this.findOne(parentUserId);
|
return this.findOne(parentUserId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,8 +14,6 @@ class EnfantAdminModel {
|
|||||||
final bool consentPhoto;
|
final bool consentPhoto;
|
||||||
final bool isMultiple;
|
final bool isMultiple;
|
||||||
final List<EnfantParentLink> parentLinks;
|
final List<EnfantParentLink> parentLinks;
|
||||||
/// Flag API #157 (sinon déduit de [parentLinks]).
|
|
||||||
final bool? sansResponsable;
|
|
||||||
|
|
||||||
EnfantAdminModel({
|
EnfantAdminModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
@ -29,7 +27,6 @@ class EnfantAdminModel {
|
|||||||
this.consentPhoto = false,
|
this.consentPhoto = false,
|
||||||
this.isMultiple = false,
|
this.isMultiple = false,
|
||||||
this.parentLinks = const [],
|
this.parentLinks = const [],
|
||||||
this.sansResponsable,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
String get fullName {
|
String get fullName {
|
||||||
@ -40,12 +37,6 @@ class EnfantAdminModel {
|
|||||||
return '$fn $ln';
|
return '$fn $ln';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Aucun lien parent valide — ticket #157.
|
|
||||||
bool get hasNoResponsable {
|
|
||||||
if (sansResponsable != null) return sansResponsable!;
|
|
||||||
return !parentLinks.any((l) => l.parentId.trim().isNotEmpty);
|
|
||||||
}
|
|
||||||
|
|
||||||
EnfantAdminModel copyWith({
|
EnfantAdminModel copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
String? firstName,
|
String? firstName,
|
||||||
@ -58,7 +49,6 @@ class EnfantAdminModel {
|
|||||||
bool? consentPhoto,
|
bool? consentPhoto,
|
||||||
bool? isMultiple,
|
bool? isMultiple,
|
||||||
List<EnfantParentLink>? parentLinks,
|
List<EnfantParentLink>? parentLinks,
|
||||||
bool? sansResponsable,
|
|
||||||
}) {
|
}) {
|
||||||
return EnfantAdminModel(
|
return EnfantAdminModel(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@ -72,7 +62,6 @@ class EnfantAdminModel {
|
|||||||
consentPhoto: consentPhoto ?? this.consentPhoto,
|
consentPhoto: consentPhoto ?? this.consentPhoto,
|
||||||
isMultiple: isMultiple ?? this.isMultiple,
|
isMultiple: isMultiple ?? this.isMultiple,
|
||||||
parentLinks: parentLinks ?? this.parentLinks,
|
parentLinks: parentLinks ?? this.parentLinks,
|
||||||
sansResponsable: sansResponsable ?? this.sansResponsable,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,13 +81,6 @@ class EnfantAdminModel {
|
|||||||
_parseBool(json['consentement_photo']) ||
|
_parseBool(json['consentement_photo']) ||
|
||||||
_parseBool(json['consentPhoto']);
|
_parseBool(json['consentPhoto']);
|
||||||
|
|
||||||
bool? sansResponsable;
|
|
||||||
if (json.containsKey('sans_responsable') ||
|
|
||||||
json.containsKey('sansResponsable')) {
|
|
||||||
sansResponsable = _parseBool(json['sans_responsable']) ||
|
|
||||||
_parseBool(json['sansResponsable']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return EnfantAdminModel(
|
return EnfantAdminModel(
|
||||||
id: (json['id'] ?? '').toString(),
|
id: (json['id'] ?? '').toString(),
|
||||||
firstName: json['first_name'] as String? ?? json['prenom'] as String?,
|
firstName: json['first_name'] as String? ?? json['prenom'] as String?,
|
||||||
@ -114,7 +96,6 @@ class EnfantAdminModel {
|
|||||||
isMultiple: _parseBool(json['is_multiple']) ||
|
isMultiple: _parseBool(json['is_multiple']) ||
|
||||||
_parseBool(json['est_multiple']),
|
_parseBool(json['est_multiple']),
|
||||||
parentLinks: links,
|
parentLinks: links,
|
||||||
sansResponsable: sansResponsable,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -62,56 +62,4 @@ class ParentModel {
|
|||||||
|
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Nombre d’enfants distincts du foyer (ce parent + co-parent / même dossier).
|
|
||||||
/// Évite Claire=7 / Thomas=6 quand un lien n’est 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 n’est pas hydratée.
|
|
||||||
if (p.children.isEmpty && p.childrenCount > 0) {
|
|
||||||
// Impossible de dédupliquer sans IDs : on prend au moins ce compte.
|
|
||||||
// (évite d’afficher 0 si l’API n’envoie 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:http_parser/http_parser.dart';
|
|
||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||||
import 'package:p_tits_pas/models/parent_model.dart';
|
import 'package:p_tits_pas/models/parent_model.dart';
|
||||||
@ -58,33 +57,6 @@ class UserService {
|
|||||||
return v.toString();
|
return v.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MIME pour upload photo enfant (filtre Nest : jpg/jpeg/png/gif).
|
|
||||||
static MediaType _imageMediaType(String filename, List<int> bytes) {
|
|
||||||
if (bytes.length >= 3 &&
|
|
||||||
bytes[0] == 0xFF &&
|
|
||||||
bytes[1] == 0xD8 &&
|
|
||||||
bytes[2] == 0xFF) {
|
|
||||||
return MediaType('image', 'jpeg');
|
|
||||||
}
|
|
||||||
if (bytes.length >= 8 &&
|
|
||||||
bytes[0] == 0x89 &&
|
|
||||||
bytes[1] == 0x50 &&
|
|
||||||
bytes[2] == 0x4E &&
|
|
||||||
bytes[3] == 0x47) {
|
|
||||||
return MediaType('image', 'png');
|
|
||||||
}
|
|
||||||
if (bytes.length >= 6 &&
|
|
||||||
bytes[0] == 0x47 &&
|
|
||||||
bytes[1] == 0x49 &&
|
|
||||||
bytes[2] == 0x46) {
|
|
||||||
return MediaType('image', 'gif');
|
|
||||||
}
|
|
||||||
final lower = filename.toLowerCase();
|
|
||||||
if (lower.endsWith('.png')) return MediaType('image', 'png');
|
|
||||||
if (lower.endsWith('.gif')) return MediaType('image', 'gif');
|
|
||||||
return MediaType('image', 'jpeg');
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _errMessage(dynamic err) {
|
static String _errMessage(dynamic err) {
|
||||||
if (err == null) return 'Erreur inconnue';
|
if (err == null) return 'Erreur inconnue';
|
||||||
if (err is String) return err;
|
if (err is String) return err;
|
||||||
@ -543,68 +515,6 @@ class UserService {
|
|||||||
return enrichEnfantParentNames(enfant);
|
return enrichEnfantParentNames(enfant);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Création enfant côté staff (#132) — nécessite `POST /enfants` étendu (voir mini-spec).
|
|
||||||
/// [parentUserId] : parent pivot du foyer (`parent_user_id`).
|
|
||||||
/// Avec [photoBytes] : `multipart/form-data` (champ fichier `photo`), sinon JSON.
|
|
||||||
static Future<EnfantAdminModel> createEnfant({
|
|
||||||
required String parentUserId,
|
|
||||||
required Map<String, dynamic> body,
|
|
||||||
List<int>? photoBytes,
|
|
||||||
String? photoFilename,
|
|
||||||
}) async {
|
|
||||||
final hasPhoto = photoBytes != null && photoBytes.isNotEmpty;
|
|
||||||
final http.Response response;
|
|
||||||
if (hasPhoto) {
|
|
||||||
final token = await TokenService.getToken();
|
|
||||||
final req = http.MultipartRequest(
|
|
||||||
'POST',
|
|
||||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
|
|
||||||
);
|
|
||||||
req.headers['Accept'] = 'application/json';
|
|
||||||
if (token != null) {
|
|
||||||
req.headers['Authorization'] = 'Bearer $token';
|
|
||||||
}
|
|
||||||
body.forEach((key, value) {
|
|
||||||
if (value == null) return;
|
|
||||||
req.fields[key] = value is bool
|
|
||||||
? (value ? 'true' : 'false')
|
|
||||||
: value.toString();
|
|
||||||
});
|
|
||||||
req.fields['parent_user_id'] = parentUserId;
|
|
||||||
final name = (photoFilename ?? '').trim();
|
|
||||||
final filename = name.isNotEmpty ? name : 'photo.jpg';
|
|
||||||
req.files.add(
|
|
||||||
http.MultipartFile.fromBytes(
|
|
||||||
'photo',
|
|
||||||
photoBytes,
|
|
||||||
filename: filename,
|
|
||||||
contentType: _imageMediaType(filename, photoBytes),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final streamed = await req.send();
|
|
||||||
response = await http.Response.fromStream(streamed);
|
|
||||||
} else {
|
|
||||||
final payload = <String, dynamic>{
|
|
||||||
...body,
|
|
||||||
'parent_user_id': parentUserId,
|
|
||||||
};
|
|
||||||
response = await http.post(
|
|
||||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
|
|
||||||
headers: await _headers(),
|
|
||||||
body: jsonEncode(payload),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (response.statusCode != 200 && response.statusCode != 201) {
|
|
||||||
throw Exception(
|
|
||||||
_extractErrorMessage(response.body, 'Erreur création enfant'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final enfant = EnfantAdminModel.fromJson(
|
|
||||||
jsonDecode(response.body) as Map<String, dynamic>,
|
|
||||||
);
|
|
||||||
return enrichEnfantParentNames(enfant);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<void> deleteEnfant(String enfantId) async {
|
static Future<void> deleteEnfant(String enfantId) async {
|
||||||
final response = await http.delete(
|
final response = await http.delete(
|
||||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||||
|
|
||||||
@ -12,50 +11,10 @@ String formatIsoDateFr(String? s, {String ifEmpty = '–'}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Affiche une date ISO en saisie guidée `jj / mm / aaaa`.
|
/// Convertit `dd/MM/yyyy` (ou ISO) en `yyyy-MM-dd` pour l'API.
|
||||||
String formatIsoDateFrInput(String? s, {String ifEmpty = ''}) {
|
|
||||||
if (s == null || s.trim().isEmpty) return ifEmpty;
|
|
||||||
try {
|
|
||||||
final dt = DateTime.parse(s.trim());
|
|
||||||
return formatFrenchDateDigits(
|
|
||||||
DateFormat('ddMMyyyy').format(dt),
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
return formatFrenchDateDigits(s.replaceAll(RegExp(r'\D'), ''));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Formate jusqu’à 8 chiffres en `jj / mm / aaaa`.
|
|
||||||
String formatFrenchDateDigits(String digits) {
|
|
||||||
final d = digits.replaceAll(RegExp(r'\D'), '');
|
|
||||||
final limited = d.length > 8 ? d.substring(0, 8) : d;
|
|
||||||
final buf = StringBuffer();
|
|
||||||
for (var i = 0; i < limited.length; i++) {
|
|
||||||
if (i == 2 || i == 4) buf.write(' / ');
|
|
||||||
buf.write(limited[i]);
|
|
||||||
}
|
|
||||||
return buf.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convertit `dd/MM/yyyy`, `dd / MM / yyyy`, 8 chiffres ou ISO en `yyyy-MM-dd`.
|
|
||||||
String? parseFrDateToIso(String text) {
|
String? parseFrDateToIso(String text) {
|
||||||
final t = text.trim();
|
final t = text.trim();
|
||||||
if (t.isEmpty) return null;
|
if (t.isEmpty) return null;
|
||||||
|
|
||||||
final digits = t.replaceAll(RegExp(r'\D'), '');
|
|
||||||
if (digits.length == 8) {
|
|
||||||
final dd = digits.substring(0, 2);
|
|
||||||
final mm = digits.substring(2, 4);
|
|
||||||
final yyyy = digits.substring(4, 8);
|
|
||||||
try {
|
|
||||||
return DateFormat('dd/MM/yyyy')
|
|
||||||
.parseStrict('$dd/$mm/$yyyy')
|
|
||||||
.toIso8601String()
|
|
||||||
.split('T')
|
|
||||||
.first;
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return DateFormat('dd/MM/yyyy')
|
return DateFormat('dd/MM/yyyy')
|
||||||
.parseStrict(t)
|
.parseStrict(t)
|
||||||
@ -137,43 +96,3 @@ String formatChildAgeLabel({
|
|||||||
return 'Né le ${formatIsoDateFr(birthDate)}';
|
return 'Né le ${formatIsoDateFr(birthDate)}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saisie date FR : 8 chiffres → `jj / mm / aaaa`.
|
|
||||||
class FrenchDateInputFormatter extends TextInputFormatter {
|
|
||||||
const FrenchDateInputFormatter();
|
|
||||||
|
|
||||||
@override
|
|
||||||
TextEditingValue formatEditUpdate(
|
|
||||||
TextEditingValue oldValue,
|
|
||||||
TextEditingValue newValue,
|
|
||||||
) {
|
|
||||||
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
|
||||||
final limited = digits.length > 8 ? digits.substring(0, 8) : digits;
|
|
||||||
final formatted = formatFrenchDateDigits(limited);
|
|
||||||
|
|
||||||
final digitsBeforeCursor = newValue.text
|
|
||||||
.substring(0, newValue.selection.start.clamp(0, newValue.text.length))
|
|
||||||
.replaceAll(RegExp(r'\D'), '')
|
|
||||||
.length
|
|
||||||
.clamp(0, limited.length);
|
|
||||||
|
|
||||||
return TextEditingValue(
|
|
||||||
text: formatted,
|
|
||||||
selection: TextSelection.collapsed(
|
|
||||||
offset: _cursorOffset(formatted, digitsBeforeCursor),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static int _cursorOffset(String formatted, int digitsBeforeCursor) {
|
|
||||||
if (digitsBeforeCursor <= 0) return 0;
|
|
||||||
var seen = 0;
|
|
||||||
for (var i = 0; i < formatted.length; i++) {
|
|
||||||
if (RegExp(r'\d').hasMatch(formatted[i])) {
|
|
||||||
seen++;
|
|
||||||
if (seen >= digitsBeforeCursor) return i + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return formatted.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
|
||||||
|
|
||||||
/// Enfant sans lien parent (orphelins d’affiliation) — 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';
|
|
||||||
}
|
|
||||||
@ -1,5 +1,3 @@
|
|||||||
import 'package:flutter/services.dart';
|
|
||||||
|
|
||||||
/// Formatage affichage prénom / nom (capitalisation par mot et segments après `-` ou `'`).
|
/// Formatage affichage prénom / nom (capitalisation par mot et segments après `-` ou `'`).
|
||||||
|
|
||||||
String formatPersonNameCase(String raw) {
|
String formatPersonNameCase(String raw) {
|
||||||
@ -11,22 +9,6 @@ String formatPersonNameCase(String raw) {
|
|||||||
return words.map(_capitalizeComposedWord).join(' ');
|
return words.map(_capitalizeComposedWord).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Variante saisie live : pas de majuscule tant que le mot n’a qu’une lettre ;
|
|
||||||
/// dès la 2ᵉ lettre, capitalisation comme [formatPersonNameCase].
|
|
||||||
String formatPersonNameCaseTyping(String raw) {
|
|
||||||
if (raw.isEmpty) return raw;
|
|
||||||
final trailingMatch = RegExp(r'(\s*)$').firstMatch(raw);
|
|
||||||
final trailing = trailingMatch?.group(1) ?? '';
|
|
||||||
final core = raw.substring(0, raw.length - trailing.length);
|
|
||||||
if (core.isEmpty) return raw;
|
|
||||||
final words = core.split(RegExp(r'\s+'));
|
|
||||||
final formatted = words.map((w) {
|
|
||||||
if (w.length < 2) return w;
|
|
||||||
return _capitalizeComposedWord(w);
|
|
||||||
}).join(' ');
|
|
||||||
return formatted + trailing;
|
|
||||||
}
|
|
||||||
|
|
||||||
String _capitalizeComposedWord(String word) {
|
String _capitalizeComposedWord(String word) {
|
||||||
if (word.isEmpty) {
|
if (word.isEmpty) {
|
||||||
return word;
|
return word;
|
||||||
@ -48,26 +30,3 @@ String _capitalizeComposedWord(String word) {
|
|||||||
}
|
}
|
||||||
return buffer.toString();
|
return buffer.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Formateur de saisie prénom / nom (capitalisation progressive).
|
|
||||||
class PersonNameInputFormatter extends TextInputFormatter {
|
|
||||||
const PersonNameInputFormatter();
|
|
||||||
|
|
||||||
@override
|
|
||||||
TextEditingValue formatEditUpdate(
|
|
||||||
TextEditingValue oldValue,
|
|
||||||
TextEditingValue newValue,
|
|
||||||
) {
|
|
||||||
final formatted = formatPersonNameCaseTyping(newValue.text);
|
|
||||||
if (formatted == newValue.text) return newValue;
|
|
||||||
|
|
||||||
final sel = newValue.selection;
|
|
||||||
final offset = sel.isValid
|
|
||||||
? sel.baseOffset.clamp(0, formatted.length)
|
|
||||||
: formatted.length;
|
|
||||||
return TextEditingValue(
|
|
||||||
text: formatted,
|
|
||||||
selection: TextSelection.collapsed(offset: offset),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -446,7 +446,8 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('Détacher l\'enfant'),
|
title: const Text('Détacher l\'enfant'),
|
||||||
content: Text(
|
content: Text(
|
||||||
'Retirer ${child.fullName} de la fiche de cette assistante ?',
|
'Retirer ${child.fullName} de la fiche de cette assistante ?\n'
|
||||||
|
'(L\'enfant ne sera pas supprimé.)',
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
|
|||||||
@ -1,27 +1,14 @@
|
|||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||||
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
||||||
|
|
||||||
/// Cadre photo identité AM / enfant (35×45 mm) — même logique que [ValidationAmWizard].
|
/// Cadre photo identité AM (35×45 mm) — même logique que [ValidationAmWizard].
|
||||||
class AdminAmPhotoFrame extends StatelessWidget {
|
class AdminAmPhotoFrame extends StatelessWidget {
|
||||||
final String? photoUrl;
|
final String? photoUrl;
|
||||||
final Uint8List? imageBytes;
|
|
||||||
final VoidCallback? onTap;
|
|
||||||
final VoidCallback? onClear;
|
|
||||||
final String emptyLabel;
|
|
||||||
|
|
||||||
static const double idPhotoAspectRatio = 35 / 45;
|
static const double idPhotoAspectRatio = 35 / 45;
|
||||||
|
|
||||||
const AdminAmPhotoFrame({
|
const AdminAmPhotoFrame({super.key, this.photoUrl});
|
||||||
super.key,
|
|
||||||
this.photoUrl,
|
|
||||||
this.imageBytes,
|
|
||||||
this.onTap,
|
|
||||||
this.onClear,
|
|
||||||
this.emptyLabel = 'Aucune photo',
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Largeur colonne photo pour remplir [height] (cadre inclus).
|
/// Largeur colonne photo pour remplir [height] (cadre inclus).
|
||||||
static double columnWidthForHeight(double height) {
|
static double columnWidthForHeight(double height) {
|
||||||
@ -49,12 +36,9 @@ class AdminAmPhotoFrame extends StatelessWidget {
|
|||||||
ph = pw / ar;
|
ph = pw / ar;
|
||||||
}
|
}
|
||||||
|
|
||||||
final hasLocal = imageBytes != null && imageBytes!.isNotEmpty;
|
|
||||||
final showClear = onClear != null && hasLocal;
|
|
||||||
|
|
||||||
// Cadre gris = taille photo + padding uniforme ; centré dans la colonne
|
// Cadre gris = taille photo + padding uniforme ; centré dans la colonne
|
||||||
// (évite le vide blanc en bas quand le conteneur parent est plus haut).
|
// (évite le vide blanc en bas quand le conteneur parent est plus haut).
|
||||||
Widget frame = Align(
|
return Align(
|
||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@ -76,82 +60,23 @@ class AdminAmPhotoFrame extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (onTap != null) {
|
|
||||||
frame = MouseRegion(
|
|
||||||
cursor: SystemMouseCursors.click,
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: onTap,
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
child: frame,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!showClear) return frame;
|
|
||||||
|
|
||||||
return Stack(
|
|
||||||
clipBehavior: Clip.none,
|
|
||||||
children: [
|
|
||||||
frame,
|
|
||||||
Positioned(
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
child: Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: IconButton(
|
|
||||||
tooltip: 'Retirer la photo',
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
constraints: const BoxConstraints(
|
|
||||||
minWidth: 32,
|
|
||||||
minHeight: 32,
|
|
||||||
),
|
|
||||||
icon: Icon(
|
|
||||||
Icons.cancel,
|
|
||||||
size: 22,
|
|
||||||
color: Colors.grey.shade700,
|
|
||||||
),
|
|
||||||
onPressed: onClear,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _photoContent(String fullUrl, double pw, double ph) {
|
Widget _photoContent(String fullUrl, double pw, double ph) {
|
||||||
if (imageBytes != null && imageBytes!.isNotEmpty) {
|
|
||||||
return Image.memory(
|
|
||||||
imageBytes!,
|
|
||||||
width: pw,
|
|
||||||
height: ph,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
alignment: Alignment.topCenter,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (fullUrl.isEmpty) {
|
if (fullUrl.isEmpty) {
|
||||||
return ColoredBox(
|
return ColoredBox(
|
||||||
color: Colors.grey.shade200,
|
color: Colors.grey.shade200,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(Icons.person_off_outlined, size: 36, color: Colors.grey.shade400),
|
||||||
onTap != null ? Icons.add_a_photo_outlined : Icons.person_off_outlined,
|
|
||||||
size: 36,
|
|
||||||
color: Colors.grey.shade400,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Padding(
|
Text(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
'Aucune photo',
|
||||||
child: Text(
|
|
||||||
emptyLabel,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 11),
|
style: TextStyle(color: Colors.grey.shade600, fontSize: 11),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -183,11 +108,7 @@ class AdminAmPhotoFrame extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
errorBuilder: (_, __, ___) => ColoredBox(
|
errorBuilder: (_, __, ___) => ColoredBox(
|
||||||
color: Colors.grey.shade200,
|
color: Colors.grey.shade200,
|
||||||
child: Icon(
|
child: Icon(Icons.broken_image_outlined, size: 36, color: Colors.grey.shade400),
|
||||||
Icons.broken_image_outlined,
|
|
||||||
size: 36,
|
|
||||||
color: Colors.grey.shade400,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:image_picker/image_picker.dart';
|
|
||||||
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
||||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||||
@ -8,37 +6,26 @@ import 'package:p_tits_pas/services/auth_service.dart';
|
|||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
||||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||||
import 'package:p_tits_pas/utils/name_format_utils.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_select_am_modal.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_select_am_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_select_famille_modal.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
||||||
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
||||||
|
|
||||||
/// Fiche enfant consultation / édition (#138) ou création (#132).
|
/// Fiche enfant consultation / édition (ticket #138) — format paysage comme fiche AM.
|
||||||
class AdminChildDetailModal extends StatefulWidget {
|
class AdminChildDetailModal extends StatefulWidget {
|
||||||
final EnfantAdminModel? enfant;
|
final EnfantAdminModel enfant;
|
||||||
final VoidCallback? onSaved;
|
final VoidCallback? onSaved;
|
||||||
final VoidCallback? onDeleted;
|
final VoidCallback? onDeleted;
|
||||||
final bool isCreating;
|
|
||||||
|
|
||||||
const AdminChildDetailModal({
|
const AdminChildDetailModal({
|
||||||
super.key,
|
super.key,
|
||||||
required EnfantAdminModel this.enfant,
|
required this.enfant,
|
||||||
this.onSaved,
|
this.onSaved,
|
||||||
this.onDeleted,
|
this.onDeleted,
|
||||||
}) : isCreating = false;
|
});
|
||||||
|
|
||||||
/// Création depuis l'onglet Enfants (#132).
|
|
||||||
const AdminChildDetailModal.create({
|
|
||||||
super.key,
|
|
||||||
this.onSaved,
|
|
||||||
}) : enfant = null,
|
|
||||||
onDeleted = null,
|
|
||||||
isCreating = true;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AdminChildDetailModal> createState() => _AdminChildDetailModalState();
|
State<AdminChildDetailModal> createState() => _AdminChildDetailModalState();
|
||||||
@ -50,7 +37,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
late final TextEditingController _birthCtrl;
|
late final TextEditingController _birthCtrl;
|
||||||
late final TextEditingController _dueCtrl;
|
late final TextEditingController _dueCtrl;
|
||||||
late String _status;
|
late String _status;
|
||||||
late String? _gender;
|
late String _gender;
|
||||||
late bool _consentPhoto;
|
late bool _consentPhoto;
|
||||||
late bool _isMultiple;
|
late bool _isMultiple;
|
||||||
bool _dirty = false;
|
bool _dirty = false;
|
||||||
@ -62,16 +49,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
/// AM rattachée au chargement (pour sync différé au Sauvegarder).
|
/// AM rattachée au chargement (pour sync différé au Sauvegarder).
|
||||||
String? _baselineAmUserId;
|
String? _baselineAmUserId;
|
||||||
|
|
||||||
/// Famille choisie en mode création (#132).
|
|
||||||
AdminFamilleFoyer? _selectedFamily;
|
|
||||||
|
|
||||||
/// Liens parents locaux (édition) — mis à jour après rattachement foyer (#157).
|
|
||||||
List<EnfantParentLink>? _localParentLinks;
|
|
||||||
|
|
||||||
/// Photo locale (création) — upload multipart `photo`.
|
|
||||||
Uint8List? _photoBytes;
|
|
||||||
String? _photoFilename;
|
|
||||||
|
|
||||||
static const double _modalWidth = 930;
|
static const double _modalWidth = 930;
|
||||||
static const double _mainRowHeight = 380;
|
static const double _mainRowHeight = 380;
|
||||||
static const double _placementHeight = 96;
|
static const double _placementHeight = 96;
|
||||||
@ -97,102 +74,44 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
_isUnborn ? 'Date prévisionnelle' : 'Date de naissance';
|
_isUnborn ? 'Date prévisionnelle' : 'Date de naissance';
|
||||||
|
|
||||||
bool get _canDelete =>
|
bool get _canDelete =>
|
||||||
!widget.isCreating &&
|
|
||||||
(_currentUserRole ?? '').toLowerCase() == 'super_admin';
|
(_currentUserRole ?? '').toLowerCase() == 'super_admin';
|
||||||
|
|
||||||
bool get _busy => _saving || _deleting;
|
bool get _busy => _saving || _deleting;
|
||||||
|
|
||||||
/// Création : tous les champs sauf photo / consentement ; édition : au moins une modif.
|
|
||||||
bool get _canSubmit {
|
|
||||||
if (_busy) return false;
|
|
||||||
if (widget.isCreating) return _isCreateFormComplete;
|
|
||||||
return _dirty;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get _isCreateFormComplete {
|
|
||||||
if (_selectedFamily == null) return false;
|
|
||||||
if (formatPersonNameCase(_prenomCtrl.text).isEmpty) return false;
|
|
||||||
if (formatPersonNameCase(_nomCtrl.text).isEmpty) return false;
|
|
||||||
if (_dateToIso(_activeDateCtrl.text) == null) return false;
|
|
||||||
if (_gender == null || !_availableGenders.contains(_gender)) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final e = widget.enfant;
|
final e = widget.enfant;
|
||||||
_prenomCtrl = TextEditingController(text: e?.firstName ?? '');
|
_prenomCtrl = TextEditingController(text: e.firstName ?? '');
|
||||||
_nomCtrl = TextEditingController(text: e?.lastName ?? '');
|
_nomCtrl = TextEditingController(text: e.lastName ?? '');
|
||||||
_birthCtrl = TextEditingController(
|
_birthCtrl = TextEditingController(
|
||||||
text: formatIsoDateFrInput(e?.birthDate, ifEmpty: ''),
|
text: formatIsoDateFr(e.birthDate, ifEmpty: ''),
|
||||||
);
|
);
|
||||||
_dueCtrl = TextEditingController(
|
_dueCtrl = TextEditingController(
|
||||||
text: formatIsoDateFrInput(e?.dueDate, ifEmpty: ''),
|
text: formatIsoDateFr(e.dueDate, ifEmpty: ''),
|
||||||
);
|
);
|
||||||
_status = normalizeEnfantStatus(e?.status);
|
_status = normalizeEnfantStatus(e.status);
|
||||||
if (!enfantStatusValues.contains(_status)) {
|
if (!enfantStatusValues.contains(_status)) {
|
||||||
_status = 'sans_garde';
|
_status = 'sans_garde';
|
||||||
}
|
}
|
||||||
if (widget.isCreating) {
|
_gender = _normalizeGender(e.gender, allowUnknown: _isUnborn);
|
||||||
_gender = null;
|
_consentPhoto = e.consentPhoto;
|
||||||
} else {
|
_isMultiple = e.isMultiple;
|
||||||
_gender = _normalizeGender(e?.gender, allowUnknown: _isUnborn);
|
for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) {
|
||||||
}
|
c.addListener(_markDirty);
|
||||||
_consentPhoto = e?.consentPhoto ?? false;
|
|
||||||
_isMultiple = e?.isMultiple ?? false;
|
|
||||||
for (final c in [_prenomCtrl, _nomCtrl]) {
|
|
||||||
c.addListener(_onNameChanged);
|
|
||||||
}
|
|
||||||
for (final c in [_birthCtrl, _dueCtrl]) {
|
|
||||||
c.addListener(_onDateChanged);
|
|
||||||
}
|
}
|
||||||
|
_prenomCtrl.addListener(_onNameChanged);
|
||||||
|
_nomCtrl.addListener(_onNameChanged);
|
||||||
_loadCurrentUserRole();
|
_loadCurrentUserRole();
|
||||||
if (widget.isCreating) {
|
|
||||||
_loadingAm = false;
|
|
||||||
_dirty = true;
|
|
||||||
} else {
|
|
||||||
_prenomCtrl.addListener(_markDirty);
|
|
||||||
_nomCtrl.addListener(_markDirty);
|
|
||||||
_loadLinkedAm();
|
_loadLinkedAm();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
void _onNameChanged() => setState(() {});
|
void _onNameChanged() => setState(() {});
|
||||||
|
|
||||||
void _onDateChanged() {
|
|
||||||
setState(() => _dirty = true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Date prévisionnelle strictement avant aujourd’hui (statut à naître).
|
|
||||||
bool get _dueDateInPast {
|
|
||||||
if (!_isUnborn) return false;
|
|
||||||
final iso = _dateToIso(_dueCtrl.text);
|
|
||||||
if (iso == null) return false;
|
|
||||||
try {
|
|
||||||
final due = DateTime.parse(iso);
|
|
||||||
final now = DateTime.now();
|
|
||||||
final dueDay = DateTime(due.year, due.month, due.day);
|
|
||||||
final today = DateTime(now.year, now.month, now.day);
|
|
||||||
return dueDay.isBefore(today);
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadLinkedAm() async {
|
Future<void> _loadLinkedAm() async {
|
||||||
if (widget.isCreating) {
|
|
||||||
setState(() => _loadingAm = false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final enfantId = widget.enfant?.id;
|
|
||||||
if (enfantId == null || enfantId.isEmpty) {
|
|
||||||
setState(() => _loadingAm = false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() => _loadingAm = true);
|
setState(() => _loadingAm = true);
|
||||||
try {
|
try {
|
||||||
final am = await UserService.findAmForEnfant(enfantId);
|
final am = await UserService.findAmForEnfant(widget.enfant.id);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_linkedAm = am;
|
_linkedAm = am;
|
||||||
@ -237,8 +156,8 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _coerceGenderForStatus() {
|
void _coerceGenderForStatus() {
|
||||||
if (_gender != null && !_availableGenders.contains(_gender)) {
|
if (!_availableGenders.contains(_gender)) {
|
||||||
_gender = null;
|
_gender = 'H';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -249,30 +168,19 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
String? _dateToIso(String text) => parseFrDateToIso(text);
|
String? _dateToIso(String text) => parseFrDateToIso(text);
|
||||||
|
|
||||||
String _headerTitle() {
|
String _headerTitle() {
|
||||||
if (widget.isCreating) {
|
|
||||||
final fn = _prenomCtrl.text.trim();
|
|
||||||
final ln = _nomCtrl.text.trim();
|
|
||||||
if (fn.isEmpty && ln.isEmpty) return 'Nouvel enfant';
|
|
||||||
return '$fn $ln'.trim();
|
|
||||||
}
|
|
||||||
final fn = _prenomCtrl.text.trim();
|
final fn = _prenomCtrl.text.trim();
|
||||||
final ln = _nomCtrl.text.trim();
|
final ln = _nomCtrl.text.trim();
|
||||||
if (fn.isEmpty && ln.isEmpty) {
|
if (fn.isEmpty && ln.isEmpty) {
|
||||||
final fallback = widget.enfant?.fullName.trim() ?? '';
|
final fallback = widget.enfant.fullName.trim();
|
||||||
return fallback.isNotEmpty ? fallback : 'Enfant';
|
return fallback.isNotEmpty ? fallback : 'Enfant';
|
||||||
}
|
}
|
||||||
return '$fn $ln'.trim();
|
return '$fn $ln'.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<EnfantParentLink> get _parentLinks =>
|
List<EnfantParentLink> get _parentLinks => widget.enfant.parentLinks
|
||||||
(_localParentLinks ??
|
|
||||||
widget.enfant?.parentLinks ??
|
|
||||||
const <EnfantParentLink>[])
|
|
||||||
.where((l) => l.parentId.trim().isNotEmpty)
|
.where((l) => l.parentId.trim().isNotEmpty)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
bool get _isOrphan => !widget.isCreating && _parentLinks.isEmpty;
|
|
||||||
|
|
||||||
Future<void> _openParent(EnfantParentLink link) async {
|
Future<void> _openParent(EnfantParentLink link) async {
|
||||||
if (_busy) return;
|
if (_busy) return;
|
||||||
final id = link.parentId.trim();
|
final id = link.parentId.trim();
|
||||||
@ -297,17 +205,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget? _parentsSubtitle() {
|
Widget? _parentsSubtitle() {
|
||||||
if (widget.isCreating) {
|
|
||||||
final family = _selectedFamily;
|
|
||||||
if (family == null) return null;
|
|
||||||
return Text(
|
|
||||||
family.parentNames.isNotEmpty
|
|
||||||
? 'Famille : ${family.parentNames.join(', ')}'
|
|
||||||
: family.displayTitle,
|
|
||||||
style: const TextStyle(fontSize: 13, color: Colors.black54),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final links = _parentLinks;
|
final links = _parentLinks;
|
||||||
if (links.isEmpty) return null;
|
if (links.isEmpty) return null;
|
||||||
|
|
||||||
@ -355,24 +252,15 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
if (!_dirty) return;
|
if (!_dirty) return;
|
||||||
|
|
||||||
if (widget.isCreating) {
|
|
||||||
await _saveCreate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final enfantId = widget.enfant?.id;
|
|
||||||
if (enfantId == null || enfantId.isEmpty) return;
|
|
||||||
|
|
||||||
setState(() => _saving = true);
|
setState(() => _saving = true);
|
||||||
try {
|
try {
|
||||||
await _syncAmPlacement();
|
await _syncAmPlacement();
|
||||||
|
|
||||||
await UserService.updateEnfant(
|
await UserService.updateEnfant(
|
||||||
enfantId: enfantId,
|
enfantId: widget.enfant.id,
|
||||||
body: {
|
body: {
|
||||||
'first_name': formatPersonNameCase(_prenomCtrl.text),
|
'first_name': _prenomCtrl.text.trim(),
|
||||||
'last_name': formatPersonNameCase(_nomCtrl.text),
|
'last_name': _nomCtrl.text.trim(),
|
||||||
'status': _status,
|
'status': _status,
|
||||||
'gender': _gender,
|
'gender': _gender,
|
||||||
if (_isUnborn)
|
if (_isUnborn)
|
||||||
@ -403,101 +291,21 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveCreate() async {
|
|
||||||
final family = _selectedFamily;
|
|
||||||
if (family == null) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Choisissez une famille / un dossier avant d\'enregistrer'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final prenom = formatPersonNameCase(_prenomCtrl.text);
|
|
||||||
final nom = formatPersonNameCase(_nomCtrl.text);
|
|
||||||
if (prenom.isEmpty || nom.isEmpty) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Prénom et nom sont obligatoires')),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!_isUnborn && _dateToIso(_birthCtrl.text) == null) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Date de naissance invalide ou manquante')),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final hasPhoto = _photoBytes != null && _photoBytes!.isNotEmpty;
|
|
||||||
if (hasPhoto && !_consentPhoto) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'Activez le consentement photo pour enregistrer une photo',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() => _saving = true);
|
|
||||||
try {
|
|
||||||
await UserService.createEnfant(
|
|
||||||
parentUserId: family.pivotParentUserId,
|
|
||||||
photoBytes: hasPhoto ? _photoBytes : null,
|
|
||||||
photoFilename: _photoFilename,
|
|
||||||
body: {
|
|
||||||
'first_name': prenom,
|
|
||||||
'last_name': nom,
|
|
||||||
'status': _status,
|
|
||||||
'gender': _gender,
|
|
||||||
if (_isUnborn) ...{
|
|
||||||
if (_dateToIso(_dueCtrl.text) != null)
|
|
||||||
'due_date': _dateToIso(_dueCtrl.text),
|
|
||||||
} else ...{
|
|
||||||
if (_dateToIso(_birthCtrl.text) != null)
|
|
||||||
'birth_date': _dateToIso(_birthCtrl.text),
|
|
||||||
},
|
|
||||||
'consent_photo': _consentPhoto,
|
|
||||||
'is_multiple': _isMultiple,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_dirty = false;
|
|
||||||
_saving = false;
|
|
||||||
});
|
|
||||||
widget.onSaved?.call();
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Enfant créé')),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _saving = false);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Applique rattachement / détachement AM (différé jusqu'au Sauvegarder).
|
/// Applique rattachement / détachement AM (différé jusqu'au Sauvegarder).
|
||||||
Future<void> _syncAmPlacement() async {
|
Future<void> _syncAmPlacement() async {
|
||||||
final enfantId = widget.enfant?.id;
|
|
||||||
if (enfantId == null || enfantId.isEmpty) return;
|
|
||||||
|
|
||||||
final baselineId = _baselineAmUserId;
|
final baselineId = _baselineAmUserId;
|
||||||
final currentId = _linkedAm?.user.id;
|
final currentId = _linkedAm?.user.id;
|
||||||
|
|
||||||
if (baselineId != null && baselineId != currentId) {
|
if (baselineId != null && baselineId != currentId) {
|
||||||
await UserService.detachEnfantFromAm(
|
await UserService.detachEnfantFromAm(
|
||||||
amUserId: baselineId,
|
amUserId: baselineId,
|
||||||
enfantId: enfantId,
|
enfantId: widget.enfant.id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (currentId != null && currentId != baselineId) {
|
if (currentId != null && currentId != baselineId) {
|
||||||
await UserService.attachEnfantToAm(
|
await UserService.attachEnfantToAm(
|
||||||
amUserId: currentId,
|
amUserId: currentId,
|
||||||
enfantId: enfantId,
|
enfantId: widget.enfant.id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -534,9 +342,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
|
|
||||||
setState(() => _deleting = true);
|
setState(() => _deleting = true);
|
||||||
try {
|
try {
|
||||||
final id = widget.enfant?.id;
|
await UserService.deleteEnfant(widget.enfant.id);
|
||||||
if (id == null || id.isEmpty) return;
|
|
||||||
await UserService.deleteEnfant(id);
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
widget.onDeleted?.call();
|
widget.onDeleted?.call();
|
||||||
widget.onSaved?.call();
|
widget.onSaved?.call();
|
||||||
@ -570,12 +376,10 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
|
|
||||||
/// Recharge AM + statut après une modale externe (ex. fiche AM).
|
/// Recharge AM + statut après une modale externe (ex. fiche AM).
|
||||||
Future<void> _reloadPlacementFromServer() async {
|
Future<void> _reloadPlacementFromServer() async {
|
||||||
final id = widget.enfant?.id;
|
|
||||||
if (id == null || id.isEmpty) return;
|
|
||||||
try {
|
try {
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
UserService.findAmForEnfant(id),
|
UserService.findAmForEnfant(widget.enfant.id),
|
||||||
UserService.getEnfant(id),
|
UserService.getEnfant(widget.enfant.id),
|
||||||
]);
|
]);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final am = results[0] as AssistanteMaternelleModel?;
|
final am = results[0] as AssistanteMaternelleModel?;
|
||||||
@ -587,7 +391,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
if (!enfantStatusValues.contains(_status)) {
|
if (!enfantStatusValues.contains(_status)) {
|
||||||
_status = 'sans_garde';
|
_status = 'sans_garde';
|
||||||
}
|
}
|
||||||
_localParentLinks = enfant.parentLinks;
|
|
||||||
_coerceGenderForStatus();
|
_coerceGenderForStatus();
|
||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@ -673,121 +476,48 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Conserve la date saisie en basculant naissance ↔ prévisionnelle.
|
InputDecoration _inputDecoration({String? hint}) {
|
||||||
void _preserveDateOnStatusChange(String from, String to) {
|
return ValidationFieldDecoration.input(hint: hint).copyWith(
|
||||||
final becomingUnborn = to == 'a_naitre' && from != 'a_naitre';
|
|
||||||
final leavingUnborn = from == 'a_naitre' && to != 'a_naitre';
|
|
||||||
if (becomingUnborn) {
|
|
||||||
if (_dueCtrl.text.trim().isEmpty &&
|
|
||||||
_birthCtrl.text.trim().isNotEmpty) {
|
|
||||||
_dueCtrl.text = _birthCtrl.text;
|
|
||||||
}
|
|
||||||
} else if (leavingUnborn) {
|
|
||||||
if (_birthCtrl.text.trim().isEmpty &&
|
|
||||||
_dueCtrl.text.trim().isNotEmpty) {
|
|
||||||
_birthCtrl.text = _dueCtrl.text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
InputDecoration _inputDecoration({String? hint, bool error = false}) {
|
|
||||||
final base = ValidationFieldDecoration.input(hint: hint).copyWith(
|
|
||||||
isDense: false,
|
isDense: false,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||||
);
|
);
|
||||||
if (!error) return base;
|
|
||||||
final border = OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
borderSide: BorderSide(color: Colors.red.shade400),
|
|
||||||
);
|
|
||||||
return base.copyWith(
|
|
||||||
fillColor: Colors.red.shade50,
|
|
||||||
enabledBorder: border,
|
|
||||||
focusedBorder: border,
|
|
||||||
border: border,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _dropdownField({
|
Widget _dropdownField({
|
||||||
Key? key,
|
Key? key,
|
||||||
required String? value,
|
required String value,
|
||||||
required List<MapEntry<String, String>> items,
|
required List<MapEntry<String, String>> items,
|
||||||
required ValueChanged<String> onChanged,
|
required ValueChanged<String> onChanged,
|
||||||
String? hint,
|
|
||||||
}) {
|
}) {
|
||||||
final safeValue =
|
final safeValue =
|
||||||
value != null && items.any((e) => e.key == value) ? value : null;
|
items.any((e) => e.key == value) ? value : items.first.key;
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: _fieldHeight,
|
height: _fieldHeight,
|
||||||
child: DropdownButtonFormField<String>(
|
child: DropdownButtonFormField<String>(
|
||||||
key: key,
|
key: key,
|
||||||
value: safeValue,
|
value: safeValue,
|
||||||
hint: hint != null
|
|
||||||
? Text(hint, style: TextStyle(color: Colors.grey.shade600))
|
|
||||||
: null,
|
|
||||||
isExpanded: true,
|
isExpanded: true,
|
||||||
decoration: _inputDecoration(),
|
decoration: _inputDecoration(),
|
||||||
items: items
|
items: items
|
||||||
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
|
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
|
||||||
.toList(),
|
.toList(),
|
||||||
onChanged: _busy
|
onChanged: _busy ? null : (v) {
|
||||||
? null
|
|
||||||
: (v) {
|
|
||||||
if (v != null) onChanged(v);
|
if (v != null) onChanged(v);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _textField(
|
Widget _textField(TextEditingController controller, {String? hint, Key? key}) {
|
||||||
TextEditingController controller, {
|
|
||||||
String? hint,
|
|
||||||
Key? key,
|
|
||||||
TextInputType? keyboardType,
|
|
||||||
List<TextInputFormatter>? inputFormatters,
|
|
||||||
bool error = false,
|
|
||||||
Widget? suffixIcon,
|
|
||||||
}) {
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: _fieldHeight,
|
height: _fieldHeight,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
key: key,
|
key: key,
|
||||||
controller: controller,
|
controller: controller,
|
||||||
keyboardType: keyboardType,
|
|
||||||
inputFormatters: inputFormatters,
|
|
||||||
textAlignVertical: TextAlignVertical.center,
|
textAlignVertical: TextAlignVertical.center,
|
||||||
style: TextStyle(
|
style: const TextStyle(color: Colors.black87, fontSize: 14),
|
||||||
color: error ? Colors.red.shade800 : Colors.black87,
|
decoration: _inputDecoration(hint: hint),
|
||||||
fontSize: 14,
|
|
||||||
),
|
),
|
||||||
decoration: _inputDecoration(
|
|
||||||
hint: hint,
|
|
||||||
error: error,
|
|
||||||
).copyWith(suffixIcon: suffixIcon),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _dateField() {
|
|
||||||
final past = _dueDateInPast;
|
|
||||||
return _textField(
|
|
||||||
_activeDateCtrl,
|
|
||||||
hint: 'jj / mm / aaaa',
|
|
||||||
key: ValueKey(_status),
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
inputFormatters: const [FrenchDateInputFormatter()],
|
|
||||||
error: past,
|
|
||||||
suffixIcon: past
|
|
||||||
? Tooltip(
|
|
||||||
message:
|
|
||||||
'Date prévisionnelle antérieure à aujourd’hui',
|
|
||||||
child: Icon(
|
|
||||||
Icons.info_outline,
|
|
||||||
size: 20,
|
|
||||||
color: Colors.red.shade700,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -797,21 +527,19 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
fields: [
|
fields: [
|
||||||
ValidationLabeledField(
|
ValidationLabeledField(
|
||||||
label: 'Prénom',
|
label: 'Prénom',
|
||||||
field: _textField(
|
field: _textField(_prenomCtrl),
|
||||||
_prenomCtrl,
|
|
||||||
inputFormatters: const [PersonNameInputFormatter()],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
ValidationLabeledField(
|
ValidationLabeledField(
|
||||||
label: 'Nom',
|
label: 'Nom',
|
||||||
field: _textField(
|
field: _textField(_nomCtrl),
|
||||||
_nomCtrl,
|
|
||||||
inputFormatters: const [PersonNameInputFormatter()],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
ValidationLabeledField(
|
ValidationLabeledField(
|
||||||
label: _dateFieldLabel,
|
label: _dateFieldLabel,
|
||||||
field: _dateField(),
|
field: _textField(
|
||||||
|
_activeDateCtrl,
|
||||||
|
hint: 'jj/mm/aaaa',
|
||||||
|
key: ValueKey(_status),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
ValidationLabeledField(
|
ValidationLabeledField(
|
||||||
label: 'Statut',
|
label: 'Statut',
|
||||||
@ -827,7 +555,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
.toList(),
|
.toList(),
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_preserveDateOnStatusChange(_status, v);
|
|
||||||
_status = v;
|
_status = v;
|
||||||
_coerceGenderForStatus();
|
_coerceGenderForStatus();
|
||||||
_dirty = true;
|
_dirty = true;
|
||||||
@ -843,7 +570,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
field: _dropdownField(
|
field: _dropdownField(
|
||||||
key: ValueKey('genre-$_status'),
|
key: ValueKey('genre-$_status'),
|
||||||
value: _gender,
|
value: _gender,
|
||||||
hint: 'Veuillez sélectionner le genre',
|
|
||||||
items: _availableGenders
|
items: _availableGenders
|
||||||
.map((g) => MapEntry(g, _genderLabel(g)))
|
.map((g) => MapEntry(g, _genderLabel(g)))
|
||||||
.toList(),
|
.toList(),
|
||||||
@ -877,43 +603,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickPhoto() async {
|
|
||||||
if (_busy || !widget.isCreating) return;
|
|
||||||
try {
|
|
||||||
final picked = await ImagePicker().pickImage(
|
|
||||||
source: ImageSource.gallery,
|
|
||||||
imageQuality: 75,
|
|
||||||
maxWidth: 1200,
|
|
||||||
maxHeight: 1200,
|
|
||||||
);
|
|
||||||
if (picked == null) return;
|
|
||||||
final bytes = await picked.readAsBytes();
|
|
||||||
if (bytes.isEmpty) return;
|
|
||||||
final name = picked.name.trim();
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_photoBytes = bytes;
|
|
||||||
_photoFilename = name.isNotEmpty ? name : 'photo.jpg';
|
|
||||||
_consentPhoto = true;
|
|
||||||
_dirty = true;
|
|
||||||
});
|
|
||||||
} catch (_) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Impossible de charger la photo')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _clearPhoto() {
|
|
||||||
if (_busy || !widget.isCreating) return;
|
|
||||||
setState(() {
|
|
||||||
_photoBytes = null;
|
|
||||||
_photoFilename = null;
|
|
||||||
_dirty = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _mainRow() {
|
Widget _mainRow() {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, c) {
|
builder: (context, c) {
|
||||||
@ -936,20 +625,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AdminAmPhotoFrame(
|
child: AdminAmPhotoFrame(
|
||||||
photoUrl: widget.isCreating
|
photoUrl: widget.enfant.photoUrl,
|
||||||
? null
|
|
||||||
: widget.enfant?.photoUrl,
|
|
||||||
imageBytes: widget.isCreating ? _photoBytes : null,
|
|
||||||
onTap: widget.isCreating && !_busy ? _pickPhoto : null,
|
|
||||||
onClear: widget.isCreating &&
|
|
||||||
!_busy &&
|
|
||||||
_photoBytes != null &&
|
|
||||||
_photoBytes!.isNotEmpty
|
|
||||||
? _clearPhoto
|
|
||||||
: null,
|
|
||||||
emptyLabel: widget.isCreating
|
|
||||||
? 'Cliquer pour ajouter'
|
|
||||||
: 'Aucune photo',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@ -968,12 +644,10 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
padding: const EdgeInsets.only(top: 16),
|
padding: const EdgeInsets.only(top: 16),
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: _placementBlock(),
|
child: _placementBlock(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -1219,34 +893,14 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String get _placementTitle {
|
String get _placementTitle =>
|
||||||
if (widget.isCreating) return 'Sélection du dossier de la famille';
|
_isScolarise ? 'Scolarisation' : 'Assistante maternelle';
|
||||||
return _isScolarise ? 'Scolarisation' : 'Assistante maternelle';
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _placementBlock() {
|
Widget _placementBlock() {
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
if (_isOrphan) ...[
|
|
||||||
Text(
|
|
||||||
'Sélection du dossier de la famille',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Colors.red.shade800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'Aucun responsable rattaché — choisissez un foyer',
|
|
||||||
style: TextStyle(fontSize: 12, color: Colors.red.shade700),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_familyPlacementSection(),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
Text(
|
Text(
|
||||||
_placementTitle,
|
_placementTitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@ -1256,196 +910,11 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (widget.isCreating) _familyPlacementSection() else _placementSection(),
|
_placementSection(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickFamily() async {
|
|
||||||
if (_busy) return;
|
|
||||||
final selected = await AdminSelectFamilleModal.show(
|
|
||||||
context,
|
|
||||||
title: 'Choisir une famille',
|
|
||||||
);
|
|
||||||
if (selected == null || !mounted) return;
|
|
||||||
|
|
||||||
if (widget.isCreating) {
|
|
||||||
setState(() {
|
|
||||||
_selectedFamily = selected;
|
|
||||||
_dirty = true;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Édition orphelin (#157) : rattache immédiatement au foyer.
|
|
||||||
final enfantId = widget.enfant?.id;
|
|
||||||
if (enfantId == null || enfantId.isEmpty) return;
|
|
||||||
|
|
||||||
setState(() => _saving = true);
|
|
||||||
try {
|
|
||||||
for (final parentId in selected.parentUserIds) {
|
|
||||||
await UserService.attachEnfantToParent(
|
|
||||||
parentUserId: parentId,
|
|
||||||
enfantId: enfantId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final refreshed = await UserService.getEnfant(enfantId);
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_localParentLinks = refreshed.parentLinks;
|
|
||||||
_selectedFamily = selected;
|
|
||||||
_saving = false;
|
|
||||||
});
|
|
||||||
widget.onSaved?.call();
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Enfant rattaché au foyer')),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _saving = false);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _familyPlacementSection() {
|
|
||||||
final family = _selectedFamily;
|
|
||||||
if (family == null) {
|
|
||||||
return Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: _busy ? null : _pickFamily,
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
child: Container(
|
|
||||||
height: _placementHeight,
|
|
||||||
width: double.infinity,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade50,
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: Colors.grey.shade200),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
Icons.family_restroom,
|
|
||||||
size: 30,
|
|
||||||
color: Colors.grey.shade400,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Aucune famille sélectionnée',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: Colors.grey.shade800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'Cliquer pour choisir un dossier / foyer',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: Colors.grey.shade600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Icon(Icons.add_circle_outline, color: Colors.grey.shade500),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: _busy ? null : _pickFamily,
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
child: Container(
|
|
||||||
height: _placementHeight,
|
|
||||||
width: double.infinity,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFF8F5FC),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
border: Border.all(color: const Color(0xFFD8CCE8)),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFEDE5FA),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.family_restroom,
|
|
||||||
size: 30,
|
|
||||||
color: Color(0xFF6B3FA0),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
family.displayTitle,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
if (family.parentNames.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
family.parentNames.join(', '),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: Colors.black54,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.swap_horiz, color: Colors.grey.shade700),
|
|
||||||
tooltip: 'Changer de famille',
|
|
||||||
onPressed: _busy ? null : _pickFamily,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _placementSection() {
|
Widget _placementSection() {
|
||||||
if (!_showsAmPlacement) return _scolariseCard();
|
if (!_showsAmPlacement) return _scolariseCard();
|
||||||
|
|
||||||
@ -1497,7 +966,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
style: ValidationModalTheme.primaryElevatedStyle,
|
style: ValidationModalTheme.primaryElevatedStyle,
|
||||||
onPressed: !_canSubmit || _busy ? null : _save,
|
onPressed: !_dirty || _busy ? null : _save,
|
||||||
child: _saving
|
child: _saving
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 18,
|
width: 18,
|
||||||
@ -1507,11 +976,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
|||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Text(
|
: Text(_dirty ? 'Sauvegarder' : 'Aucune modification'),
|
||||||
widget.isCreating
|
|
||||||
? 'Créer'
|
|
||||||
: (_dirty ? 'Sauvegarder' : 'Aucune modification'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
|||||||
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
||||||
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
||||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||||
import 'package:p_tits_pas/utils/enfant_vigilance.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||||
|
|
||||||
List<String> enfantAdminSubtitleLines({
|
List<String> enfantAdminSubtitleLines({
|
||||||
@ -37,8 +36,6 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
final VoidCallback? onCardTap;
|
final VoidCallback? onCardTap;
|
||||||
final EdgeInsetsGeometry? margin;
|
final EdgeInsetsGeometry? margin;
|
||||||
final EdgeInsetsGeometry? contentPadding;
|
final EdgeInsetsGeometry? contentPadding;
|
||||||
final Color? borderColor;
|
|
||||||
final String? vigilanceTooltip;
|
|
||||||
|
|
||||||
const AdminEnfantUserCard({
|
const AdminEnfantUserCard({
|
||||||
super.key,
|
super.key,
|
||||||
@ -49,8 +46,6 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
this.onCardTap,
|
this.onCardTap,
|
||||||
this.margin,
|
this.margin,
|
||||||
this.contentPadding,
|
this.contentPadding,
|
||||||
this.borderColor,
|
|
||||||
this.vigilanceTooltip,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
factory AdminEnfantUserCard.fromEnfant(
|
factory AdminEnfantUserCard.fromEnfant(
|
||||||
@ -60,13 +55,10 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
VoidCallback? onCardTap,
|
VoidCallback? onCardTap,
|
||||||
EdgeInsetsGeometry? margin,
|
EdgeInsetsGeometry? margin,
|
||||||
EdgeInsetsGeometry? contentPadding,
|
EdgeInsetsGeometry? contentPadding,
|
||||||
Color? borderColor,
|
|
||||||
String? vigilanceTooltip,
|
|
||||||
}) {
|
}) {
|
||||||
final parents = enfant.parentLinks
|
final parents = enfant.parentLinks
|
||||||
.map((l) => l.parentName ?? 'Parent')
|
.map((l) => l.parentName ?? 'Parent')
|
||||||
.join(', ');
|
.join(', ');
|
||||||
final orphan = enfantHasNoResponsable(enfant);
|
|
||||||
return AdminEnfantUserCard(
|
return AdminEnfantUserCard(
|
||||||
title: enfant.fullName,
|
title: enfant.fullName,
|
||||||
photoUrl: enfant.photoUrl,
|
photoUrl: enfant.photoUrl,
|
||||||
@ -77,7 +69,6 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
gender: enfant.gender,
|
gender: enfant.gender,
|
||||||
extra: [
|
extra: [
|
||||||
if (parents.isNotEmpty) 'Responsables : $parents',
|
if (parents.isNotEmpty) 'Responsables : $parents',
|
||||||
if (orphan) 'Aucun responsable rattaché',
|
|
||||||
...extraSubtitleLines,
|
...extraSubtitleLines,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -85,10 +76,6 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
onCardTap: onCardTap,
|
onCardTap: onCardTap,
|
||||||
margin: margin,
|
margin: margin,
|
||||||
contentPadding: contentPadding,
|
contentPadding: contentPadding,
|
||||||
borderColor: borderColor ??
|
|
||||||
(orphan ? Colors.red.shade300 : null),
|
|
||||||
vigilanceTooltip: vigilanceTooltip ??
|
|
||||||
enfantSansResponsableVigilanceMessage(enfant),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,8 +114,6 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
onCardTap: onCardTap,
|
onCardTap: onCardTap,
|
||||||
margin: margin,
|
margin: margin,
|
||||||
contentPadding: contentPadding,
|
contentPadding: contentPadding,
|
||||||
borderColor: borderColor,
|
|
||||||
vigilanceTooltip: vigilanceTooltip,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -283,7 +283,8 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('Détacher l\'enfant'),
|
title: const Text('Détacher l\'enfant'),
|
||||||
content: Text(
|
content: Text(
|
||||||
'Retirer ${child.fullName} de la fiche de ce parent ?',
|
'Retirer ${child.fullName} de la fiche de ce parent ?\n'
|
||||||
|
'(L\'enfant ne sera pas supprimé.)',
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
|
|||||||
@ -1,139 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:p_tits_pas/models/parent_model.dart';
|
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
|
||||||
|
|
||||||
/// Foyer / famille sélectionnable pour rattacher un nouvel enfant (#132 / #157).
|
|
||||||
class AdminFamilleFoyer {
|
|
||||||
/// Parent pivot pour `POST /enfants` (`parent_user_id`).
|
|
||||||
final String pivotParentUserId;
|
|
||||||
/// Co-parent éventuel (rattachement foyer #157).
|
|
||||||
final String? coParentUserId;
|
|
||||||
final String? numeroDossier;
|
|
||||||
final String displayTitle;
|
|
||||||
final List<String> parentNames;
|
|
||||||
|
|
||||||
const AdminFamilleFoyer({
|
|
||||||
required this.pivotParentUserId,
|
|
||||||
required this.displayTitle,
|
|
||||||
required this.parentNames,
|
|
||||||
this.coParentUserId,
|
|
||||||
this.numeroDossier,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Parents du foyer à lier à l’enfant (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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -72,14 +72,7 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
|
|||||||
normalizeEnfantStatus(e.status) ==
|
normalizeEnfantStatus(e.status) ==
|
||||||
normalizeEnfantStatus(widget.statusFilter);
|
normalizeEnfantStatus(widget.statusFilter);
|
||||||
return matchesName && matchesStatus;
|
return matchesName && matchesStatus;
|
||||||
}).toList()
|
}).toList();
|
||||||
..sort((a, b) {
|
|
||||||
// Orphelins (#157) en tête, puis ordre alphabétique.
|
|
||||||
final ao = a.hasNoResponsable ? 0 : 1;
|
|
||||||
final bo = b.hasNoResponsable ? 0 : 1;
|
|
||||||
if (ao != bo) return ao.compareTo(bo);
|
|
||||||
return a.fullName.toLowerCase().compareTo(b.fullName.toLowerCase());
|
|
||||||
});
|
|
||||||
|
|
||||||
return UserList(
|
return UserList(
|
||||||
isLoading: _isLoading,
|
isLoading: _isLoading,
|
||||||
|
|||||||
@ -80,7 +80,7 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
|||||||
onCardTap: () => _openParentDetails(parent),
|
onCardTap: () => _openParentDetails(parent),
|
||||||
subtitleLines: [
|
subtitleLines: [
|
||||||
parent.user.email,
|
parent.user.email,
|
||||||
'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${ParentModel.foyerChildrenCount(parent, _parents)}',
|
'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.children.isNotEmpty ? parent.children.length : parent.childrenCount}',
|
||||||
],
|
],
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create
|
|||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
|
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
|
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
|
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart';
|
import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
|
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
|
||||||
@ -27,7 +26,6 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
int _subIndex = 0;
|
int _subIndex = 0;
|
||||||
int _gestionnaireRefreshTick = 0;
|
int _gestionnaireRefreshTick = 0;
|
||||||
int _adminRefreshTick = 0;
|
int _adminRefreshTick = 0;
|
||||||
int _enfantRefreshTick = 0;
|
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
final TextEditingController _amCapacityController = TextEditingController();
|
final TextEditingController _amCapacityController = TextEditingController();
|
||||||
String? _parentStatus;
|
String? _parentStatus;
|
||||||
@ -266,7 +264,6 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
);
|
);
|
||||||
case 1:
|
case 1:
|
||||||
return EnfantManagementWidget(
|
return EnfantManagementWidget(
|
||||||
key: ValueKey('enfants-$_enfantRefreshTick'),
|
|
||||||
searchQuery: _searchController.text,
|
searchQuery: _searchController.text,
|
||||||
statusFilter: _enfantStatus,
|
statusFilter: _enfantStatus,
|
||||||
);
|
);
|
||||||
@ -314,23 +311,6 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
|
|
||||||
Future<void> _handleAddPressed() async {
|
Future<void> _handleAddPressed() async {
|
||||||
final contentIndex = _subIndex - _contentIndexOffset;
|
final contentIndex = _subIndex - _contentIndexOffset;
|
||||||
|
|
||||||
if (contentIndex == 1) {
|
|
||||||
await showDialog<void>(
|
|
||||||
context: context,
|
|
||||||
barrierDismissible: false,
|
|
||||||
builder: (dialogContext) {
|
|
||||||
return AdminChildDetailModal.create(
|
|
||||||
onSaved: () {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _enfantRefreshTick++);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contentIndex == 3) {
|
if (contentIndex == 3) {
|
||||||
final created = await showDialog<bool>(
|
final created = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@ -206,7 +206,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.2"
|
version: "1.2.2"
|
||||||
http_parser:
|
http_parser:
|
||||||
dependency: "direct main"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: http_parser
|
name: http_parser
|
||||||
sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
|
sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
|
||||||
|
|||||||
@ -21,7 +21,6 @@ dependencies:
|
|||||||
js: ^0.6.7
|
js: ^0.6.7
|
||||||
url_launcher: ^6.2.4
|
url_launcher: ^6.2.4
|
||||||
http: ^1.2.2
|
http: ^1.2.2
|
||||||
http_parser: ^4.0.2
|
|
||||||
# flutter_secure_storage: ^9.0.0
|
# flutter_secure_storage: ^9.0.0
|
||||||
pdfx: ^2.5.0
|
pdfx: ^2.5.0
|
||||||
universal_platform: ^1.1.0
|
universal_platform: ^1.1.0
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user