Compare commits
61 Commits
master
...
feature/15
| Author | SHA1 | Date | |
|---|---|---|---|
| 925b6d5cd4 | |||
| b0dddd6695 | |||
| ef7512dc1e | |||
| 2ececa711b | |||
| 7a3d997ff9 | |||
| 6b89d7b405 | |||
| f7ac628445 | |||
| 26e9738ec7 | |||
| 0ec3410457 | |||
| c3acf1970d | |||
| 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 |
111
backend/scripts/create-gitea-issue-gestionnaire-self-delete.js
Normal file
111
backend/scripts/create-gitea-issue-gestionnaire-self-delete.js
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Crée l'issue Gitea — gestionnaire ne doit pas pouvoir se supprimer.
|
||||||
|
* Usage: node backend/scripts/create-gitea-issue-gestionnaire-self-delete.js
|
||||||
|
*/
|
||||||
|
const https = require('https');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const repoRoot = path.join(__dirname, '../..');
|
||||||
|
const MILESTONE_0_1_0 = 10;
|
||||||
|
|
||||||
|
let token = process.env.GITEA_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
try {
|
||||||
|
const tokenFile = path.join(repoRoot, '.gitea-token');
|
||||||
|
if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
try {
|
||||||
|
const briefing = fs.readFileSync(
|
||||||
|
path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const m = briefing.match(/Token:\s*(gitebu_[a-f0-9]+)/);
|
||||||
|
if (m) token = m[1].trim();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
console.error('Token non trouvé : .gitea-token ou GITEA_TOKEN');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = `## Contexte
|
||||||
|
|
||||||
|
Quand un **gestionnaire** connecté ouvre sa propre fiche dans l'onglet **Gestionnaires** (Gestion des utilisateurs), la modale **Modifier un "Gestionnaire"** affiche le bouton **Supprimer**.
|
||||||
|
|
||||||
|
Comportement actuel : le gestionnaire voit et peut tenter de supprimer son propre compte.
|
||||||
|
|
||||||
|
## Comportement attendu
|
||||||
|
|
||||||
|
Comme pour le **super administrateur** (bouton Supprimer masqué sur la fiche super admin) :
|
||||||
|
|
||||||
|
- **Pas de bouton Supprimer** quand l'utilisateur édite **sa propre fiche**
|
||||||
|
- **Modification** des informations (prénom, nom, email, téléphone, relais, mot de passe) **toujours autorisée**
|
||||||
|
|
||||||
|
## Périmètre
|
||||||
|
|
||||||
|
- Frontend : \`AdminUserFormDialog\` (\`gestionnaires_create.dart\`)
|
||||||
|
- Comparer \`initialUser.id\` avec l'utilisateur connecté (\`AuthService.getCurrentUser\`)
|
||||||
|
- Masquer Supprimer si édition de soi-même ; conserver garde existante super admin
|
||||||
|
|
||||||
|
## Critères d'acceptation
|
||||||
|
|
||||||
|
- [ ] Gestionnaire connecté → ouvre sa fiche → **pas** de bouton Supprimer
|
||||||
|
- [ ] Gestionnaire connecté → peut **Modifier** ses informations
|
||||||
|
- [ ] Super admin / admin → peut toujours supprimer **un autre** gestionnaire (si droits API)
|
||||||
|
- [ ] Pas de régression sur fiche super administrateur (Supprimer toujours masqué)
|
||||||
|
|
||||||
|
## Fichiers clés
|
||||||
|
|
||||||
|
- \`frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart\`
|
||||||
|
- \`frontend/lib/widgets/admin/gestionnaire_management_widget.dart\`
|
||||||
|
|
||||||
|
## Milestone
|
||||||
|
|
||||||
|
**0.1.0** — correction UX / sécurité gestion utilisateurs.`;
|
||||||
|
|
||||||
|
const payloadClean = JSON.stringify({
|
||||||
|
title: '[Bug] Gestionnaire peut voir Supprimer sur sa propre fiche',
|
||||||
|
body,
|
||||||
|
milestone: MILESTONE_0_1_0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
hostname: 'git.ptits-pas.fr',
|
||||||
|
path: '/api/v1/repos/jmartin/petitspas/issues',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'token ' + token,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(payloadClean),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(opts, (res) => {
|
||||||
|
let d = '';
|
||||||
|
res.on('data', (c) => (d += c));
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const o = JSON.parse(d);
|
||||||
|
if (o.number) {
|
||||||
|
console.log('NUMBER:', o.number);
|
||||||
|
console.log('URL:', o.html_url);
|
||||||
|
console.log('MILESTONE:', o.milestone?.title ?? '(aucun)');
|
||||||
|
} else {
|
||||||
|
console.error('Réponse:', d);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(d);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', (e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
req.write(payloadClean);
|
||||||
|
req.end();
|
||||||
@ -552,7 +552,8 @@ export class AuthService {
|
|||||||
: undefined;
|
: undefined;
|
||||||
enfant.photo_url = urlPhoto || undefined;
|
enfant.photo_url = urlPhoto || undefined;
|
||||||
enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE;
|
enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE;
|
||||||
enfant.consent_photo = false;
|
enfant.consent_photo = !!enfantDto.consent_photo;
|
||||||
|
enfant.consent_photo_at = enfant.consent_photo ? new Date() : null!;
|
||||||
enfant.is_multiple = enfantDto.grossesse_multiple || false;
|
enfant.is_multiple = enfantDto.grossesse_multiple || false;
|
||||||
|
|
||||||
const enfantEnregistre = await manager.save(Children, enfant);
|
const enfantEnregistre = await manager.save(Children, enfant);
|
||||||
@ -1074,6 +1075,12 @@ export class AuthService {
|
|||||||
if (enfantDto.grossesse_multiple !== undefined) {
|
if (enfantDto.grossesse_multiple !== undefined) {
|
||||||
enfant.is_multiple = enfantDto.grossesse_multiple;
|
enfant.is_multiple = enfantDto.grossesse_multiple;
|
||||||
}
|
}
|
||||||
|
if (enfantDto.consent_photo !== undefined) {
|
||||||
|
enfant.consent_photo = !!enfantDto.consent_photo;
|
||||||
|
enfant.consent_photo_at = enfant.consent_photo
|
||||||
|
? new Date()
|
||||||
|
: null!;
|
||||||
|
}
|
||||||
|
|
||||||
if (enfantDto.photo_base64 && enfantDto.photo_filename) {
|
if (enfantDto.photo_base64 && enfantDto.photo_filename) {
|
||||||
enfant.photo_url = await this.sauvegarderPhotoDepuisBase64(
|
enfant.photo_url = await this.sauvegarderPhotoDepuisBase64(
|
||||||
|
|||||||
@ -59,5 +59,14 @@ export class EnfantInscriptionDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
grossesse_multiple?: boolean;
|
grossesse_multiple?: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: true,
|
||||||
|
required: false,
|
||||||
|
description: 'Consentement affichage / stockage photo (colonne enfants.consentement_photo)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
consent_photo?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
@ -6,11 +7,23 @@ import {
|
|||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
IsUUID,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { GenreType, StatutEnfantType } from 'src/entities/children.entity';
|
import { GenreType, StatutEnfantType } from 'src/entities/children.entity';
|
||||||
|
|
||||||
|
/** Multipart envoie des strings ("true"/"false") — JSON envoie déjà des booleans. */
|
||||||
|
function toBoolean({ value }: { value: unknown }): boolean | unknown {
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const v = value.trim().toLowerCase();
|
||||||
|
if (v === 'true' || v === '1') return true;
|
||||||
|
if (v === 'false' || v === '0' || v === '') return false;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export class CreateEnfantsDto {
|
export class CreateEnfantsDto {
|
||||||
@ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE })
|
@ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE })
|
||||||
@IsEnum(StatutEnfantType)
|
@IsEnum(StatutEnfantType)
|
||||||
@ -52,6 +65,7 @@ export class CreateEnfantsDto {
|
|||||||
photo_url?: string;
|
photo_url?: string;
|
||||||
|
|
||||||
@ApiProperty({ default: false })
|
@ApiProperty({ default: false })
|
||||||
|
@Transform(toBoolean)
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
consent_photo: boolean;
|
consent_photo: boolean;
|
||||||
|
|
||||||
@ -61,6 +75,20 @@ export class CreateEnfantsDto {
|
|||||||
consent_photo_at?: string;
|
consent_photo_at?: string;
|
||||||
|
|
||||||
@ApiProperty({ default: false })
|
@ApiProperty({ default: false })
|
||||||
|
@Transform(toBoolean)
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
is_multiple: boolean;
|
is_multiple: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parent pivot du foyer — obligatoire pour staff (gestionnaire/admin).
|
||||||
|
* Ignoré / interdit en externe pour un PARENT (ticket #132).
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'UUID du parent pivot (staff only). Obligatoire pour GESTIONNAIRE / ADMIN / SUPER_ADMIN.',
|
||||||
|
format: 'uuid',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
parent_user_id?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +1,33 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
|
CallHandler,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
|
ExecutionContext,
|
||||||
Get,
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
UploadedFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
UploadedFile,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { ApiBearerAuth, ApiTags, ApiConsumes } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiConsumes,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import { diskStorage } from 'multer';
|
import { diskStorage } from 'multer';
|
||||||
import { extname } from 'path';
|
import { extname } from 'path';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
import { EnfantsService } from './enfants.service';
|
import { EnfantsService } from './enfants.service';
|
||||||
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
||||||
import { UpdateEnfantsDto } from './dto/update_enfants.dto';
|
import { UpdateEnfantsDto } from './dto/update_enfants.dto';
|
||||||
@ -24,6 +37,47 @@ import { AuthGuard } from 'src/common/guards/auth.guard';
|
|||||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||||
|
|
||||||
|
const photoMulterOptions = {
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: './uploads/photos',
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||||
|
const ext = extname(file.originalname);
|
||||||
|
cb(null, `enfant-${uniqueSuffix}${ext}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) {
|
||||||
|
return cb(new Error('Seules les images sont autorisées'), false);
|
||||||
|
}
|
||||||
|
cb(null, true);
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
fileSize: 5 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multer uniquement si Content-Type multipart (parent ou staff + photo).
|
||||||
|
* JSON sans photo (#132) passe sans interceptor fichier.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
class OptionalEnfantPhotoInterceptor implements NestInterceptor {
|
||||||
|
private readonly multipart = new (FileInterceptor(
|
||||||
|
'photo',
|
||||||
|
photoMulterOptions,
|
||||||
|
))();
|
||||||
|
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> | Promise<Observable<unknown>> {
|
||||||
|
const req = context.switchToHttp().getRequest();
|
||||||
|
const ct = String(req.headers['content-type'] ?? '');
|
||||||
|
if (!ct.includes('multipart/form-data')) {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
return this.multipart.intercept(context, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
@ApiTags('Enfants')
|
@ApiTags('Enfants')
|
||||||
@UseGuards(AuthGuard, RolesGuard)
|
@UseGuards(AuthGuard, RolesGuard)
|
||||||
@ -31,30 +85,27 @@ import { RolesGuard } from 'src/common/guards/roles.guard';
|
|||||||
export class EnfantsController {
|
export class EnfantsController {
|
||||||
constructor(private readonly enfantsService: EnfantsService) { }
|
constructor(private readonly enfantsService: EnfantsService) { }
|
||||||
|
|
||||||
@Roles(RoleType.PARENT)
|
@Roles(
|
||||||
@Post()
|
RoleType.PARENT,
|
||||||
@ApiConsumes('multipart/form-data')
|
RoleType.GESTIONNAIRE,
|
||||||
@UseInterceptors(
|
RoleType.ADMINISTRATEUR,
|
||||||
FileInterceptor('photo', {
|
RoleType.SUPER_ADMIN,
|
||||||
storage: diskStorage({
|
|
||||||
destination: './uploads/photos',
|
|
||||||
filename: (req, file, cb) => {
|
|
||||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
|
||||||
const ext = extname(file.originalname);
|
|
||||||
cb(null, `enfant-${uniqueSuffix}${ext}`);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
fileFilter: (req, file, cb) => {
|
|
||||||
if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) {
|
|
||||||
return cb(new Error('Seules les images sont autorisées'), false);
|
|
||||||
}
|
|
||||||
cb(null, true);
|
|
||||||
},
|
|
||||||
limits: {
|
|
||||||
fileSize: 5 * 1024 * 1024,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
@Post()
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Créer un enfant',
|
||||||
|
description:
|
||||||
|
'PARENT : multipart éventuel, rattache au compte connecté. ' +
|
||||||
|
'Staff : parent_user_id obligatoire ; JSON sans photo OK ; avec photo → multipart (champ fichier `photo`, max 5 Mo). Ticket #132.',
|
||||||
|
})
|
||||||
|
@ApiConsumes('application/json', 'multipart/form-data')
|
||||||
|
@ApiBody({
|
||||||
|
description:
|
||||||
|
'Champs métier (+ parent_user_id côté staff). Fichier optionnel `photo` en multipart.',
|
||||||
|
type: CreateEnfantsDto,
|
||||||
|
})
|
||||||
|
@UseInterceptors(OptionalEnfantPhotoInterceptor)
|
||||||
create(
|
create(
|
||||||
@Body() dto: CreateEnfantsDto,
|
@Body() dto: CreateEnfantsDto,
|
||||||
@UploadedFile() photo: Express.Multer.File,
|
@UploadedFile() photo: Express.Multer.File,
|
||||||
|
|||||||
@ -13,6 +13,12 @@ import { ParentsChildren } from 'src/entities/parents_children.entity';
|
|||||||
import { RoleType, Users } from 'src/entities/users.entity';
|
import { RoleType, Users } from 'src/entities/users.entity';
|
||||||
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
||||||
|
|
||||||
|
const STAFF_ROLES: RoleType[] = [
|
||||||
|
RoleType.GESTIONNAIRE,
|
||||||
|
RoleType.ADMINISTRATEUR,
|
||||||
|
RoleType.SUPER_ADMIN,
|
||||||
|
];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EnfantsService {
|
export class EnfantsService {
|
||||||
constructor(
|
constructor(
|
||||||
@ -24,20 +30,35 @@ export class EnfantsService {
|
|||||||
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
// Création d'un enfant
|
private isStaff(user: Users): boolean {
|
||||||
async create(dto: CreateEnfantsDto, currentUser: Users, photoFile?: Express.Multer.File): Promise<Children> {
|
return STAFF_ROLES.includes(user.role);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Création d'un enfant.
|
||||||
|
* - PARENT : rattache au parent connecté (multipart photo optionnel).
|
||||||
|
* - Staff : `parent_user_id` obligatoire ; JSON sans photo OK ;
|
||||||
|
* avec photo → multipart (même stockage `/uploads/photos/...`). Ticket #132.
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
|
dto: CreateEnfantsDto,
|
||||||
|
currentUser: Users,
|
||||||
|
photoFile?: Express.Multer.File,
|
||||||
|
): Promise<Children> {
|
||||||
|
const pivotUserId = this.resolvePivotParentUserId(dto, currentUser);
|
||||||
|
|
||||||
const parent = await this.parentsRepository.findOne({
|
const parent = await this.parentsRepository.findOne({
|
||||||
where: { user_id: currentUser.id },
|
where: { user_id: pivotUserId },
|
||||||
relations: ['co_parent'],
|
relations: ['co_parent'],
|
||||||
});
|
});
|
||||||
if (!parent) throw new NotFoundException('Parent introuvable');
|
if (!parent) throw new NotFoundException('Parent introuvable');
|
||||||
|
|
||||||
// Vérif métier simple
|
// Vérif métier simple (aligné comportement historique parent)
|
||||||
if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) {
|
if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) {
|
||||||
throw new BadRequestException('Un enfant né doit avoir une date de naissance');
|
throw new BadRequestException('Un enfant né doit avoir une date de naissance');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérif doublon éventuel (ex: même prénom + date de naissance pour ce parent)
|
// Vérif doublon éventuel (ex: même prénom + date de naissance)
|
||||||
const exist = await this.childrenRepository.findOne({
|
const exist = await this.childrenRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
first_name: dto.first_name,
|
first_name: dto.first_name,
|
||||||
@ -47,50 +68,108 @@ export class EnfantsService {
|
|||||||
});
|
});
|
||||||
if (exist) throw new ConflictException('Cet enfant existe déjà');
|
if (exist) throw new ConflictException('Cet enfant existe déjà');
|
||||||
|
|
||||||
// Gestion de la photo uploadée
|
// Gestion de la photo uploadée (multipart parent ou staff)
|
||||||
|
let photoUrl = dto.photo_url;
|
||||||
|
let consentAt: Date | undefined;
|
||||||
if (photoFile) {
|
if (photoFile) {
|
||||||
dto.photo_url = `/uploads/photos/${photoFile.filename}`;
|
photoUrl = `/uploads/photos/${photoFile.filename}`;
|
||||||
if (dto.consent_photo) {
|
if (dto.consent_photo) {
|
||||||
dto.consent_photo_at = new Date().toISOString();
|
consentAt = new Date();
|
||||||
}
|
}
|
||||||
|
} else if (dto.consent_photo) {
|
||||||
|
consentAt = dto.consent_photo_at
|
||||||
|
? new Date(dto.consent_photo_at)
|
||||||
|
: new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Création
|
const child = this.childrenRepository.create({
|
||||||
const child = this.childrenRepository.create(dto);
|
status: dto.status,
|
||||||
|
first_name: dto.first_name,
|
||||||
|
last_name: dto.last_name,
|
||||||
|
gender: dto.gender,
|
||||||
|
birth_date: dto.birth_date ? new Date(dto.birth_date) : undefined,
|
||||||
|
due_date: dto.due_date ? new Date(dto.due_date) : undefined,
|
||||||
|
photo_url: photoUrl,
|
||||||
|
consent_photo: !!dto.consent_photo,
|
||||||
|
consent_photo_at: consentAt,
|
||||||
|
is_multiple: !!dto.is_multiple,
|
||||||
|
});
|
||||||
await this.childrenRepository.save(child);
|
await this.childrenRepository.save(child);
|
||||||
|
|
||||||
// Lien parent-enfant (Parent 1)
|
// Lien parent-enfant (pivot)
|
||||||
const parentLink = this.parentsChildrenRepository.create({
|
await this.parentsChildrenRepository.save(
|
||||||
parentId: parent.user_id,
|
this.parentsChildrenRepository.create({
|
||||||
enfantId: child.id,
|
parentId: parent.user_id,
|
||||||
});
|
enfantId: child.id,
|
||||||
await this.parentsChildrenRepository.save(parentLink);
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Rattachement automatique au co-parent s'il existe
|
// Rattachement automatique au co-parent s'il existe
|
||||||
if (parent.co_parent) {
|
if (parent.co_parent) {
|
||||||
const coParentLink = this.parentsChildrenRepository.create({
|
await this.parentsChildrenRepository.save(
|
||||||
parentId: parent.co_parent.id,
|
this.parentsChildrenRepository.create({
|
||||||
enfantId: child.id,
|
parentId: parent.co_parent.id,
|
||||||
});
|
enfantId: child.id,
|
||||||
await this.parentsChildrenRepository.save(coParentLink);
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.findOne(child.id, currentUser);
|
return this.findOne(child.id, currentUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Liste des enfants (admin/gestionnaire)
|
private resolvePivotParentUserId(
|
||||||
async findAll(): Promise<Children[]> {
|
dto: CreateEnfantsDto,
|
||||||
return this.childrenRepository.find({
|
currentUser: Users,
|
||||||
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
): string {
|
||||||
order: { last_name: 'ASC', first_name: 'ASC' },
|
if (this.isStaff(currentUser)) {
|
||||||
|
const id = dto.parent_user_id?.trim();
|
||||||
|
if (!id) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'parent_user_id est obligatoire pour créer un enfant (staff)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUser.role === RoleType.PARENT) {
|
||||||
|
if (
|
||||||
|
dto.parent_user_id &&
|
||||||
|
dto.parent_user_id.trim() !== currentUser.id
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Un parent ne peut pas créer un enfant pour un autre compte',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return currentUser.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ForbiddenException('Accès interdit');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flag API #157 — true si aucun lien enfants_parents. */
|
||||||
|
private withSansResponsable(child: Children): Children & { sans_responsable: boolean } {
|
||||||
|
return Object.assign(child, {
|
||||||
|
sans_responsable: !child.parentLinks || child.parentLinks.length === 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Liste des enfants (admin/gestionnaire) — inclut les orphelins (parentLinks: [])
|
||||||
|
async findAll(): Promise<Array<Children & { sans_responsable: boolean }>> {
|
||||||
|
const children = await this.childrenRepository.find({
|
||||||
|
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
||||||
|
order: { last_name: 'ASC', first_name: 'ASC' },
|
||||||
|
});
|
||||||
|
return children.map((c) => this.withSansResponsable(c));
|
||||||
|
}
|
||||||
|
|
||||||
// Récupérer un enfant par id
|
// Récupérer un enfant par id
|
||||||
async findOne(id: string, currentUser: Users): Promise<Children> {
|
async findOne(
|
||||||
|
id: string,
|
||||||
|
currentUser: Users,
|
||||||
|
): Promise<Children & { sans_responsable: boolean }> {
|
||||||
const child = await this.childrenRepository.findOne({
|
const child = await this.childrenRepository.findOne({
|
||||||
where: { id },
|
where: { id },
|
||||||
relations: ['parentLinks'],
|
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
||||||
});
|
});
|
||||||
if (!child) throw new NotFoundException('Enfant introuvable');
|
if (!child) throw new NotFoundException('Enfant introuvable');
|
||||||
|
|
||||||
@ -104,14 +183,14 @@ export class EnfantsService {
|
|||||||
case RoleType.ADMINISTRATEUR:
|
case RoleType.ADMINISTRATEUR:
|
||||||
case RoleType.SUPER_ADMIN:
|
case RoleType.SUPER_ADMIN:
|
||||||
case RoleType.GESTIONNAIRE:
|
case RoleType.GESTIONNAIRE:
|
||||||
// accès complet
|
// accès complet (y compris orphelins)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new ForbiddenException('Accès interdit');
|
throw new ForbiddenException('Accès interdit');
|
||||||
}
|
}
|
||||||
|
|
||||||
return child;
|
return this.withSansResponsable(child);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -120,7 +199,14 @@ export class EnfantsService {
|
|||||||
const child = await this.childrenRepository.findOne({ where: { id } });
|
const child = await this.childrenRepository.findOne({ where: { id } });
|
||||||
if (!child) throw new NotFoundException('Enfant introuvable');
|
if (!child) throw new NotFoundException('Enfant introuvable');
|
||||||
|
|
||||||
await this.childrenRepository.update(id, dto);
|
const { parent_user_id: _ignored, ...rest } = dto;
|
||||||
|
const patch: Partial<Children> = { ...rest } as Partial<Children>;
|
||||||
|
if (dto.consent_photo !== undefined) {
|
||||||
|
patch.consent_photo = dto.consent_photo;
|
||||||
|
patch.consent_photo_at = dto.consent_photo ? new Date() : null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.childrenRepository.update(id, patch);
|
||||||
return this.findOne(id, currentUser);
|
return this.findOne(id, currentUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -138,7 +138,9 @@ export class ParentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Détacher un enfant d'un parent sans supprimer l'enfant. Ticket #115 / doc 28 §6.2.
|
* Détacher un enfant d'un parent sans supprimer l'enfant.
|
||||||
|
* Autorise le détachement du dernier responsable (#157) — l'enfant reste listé
|
||||||
|
* via GET /enfants avec parentLinks vides (alerte front).
|
||||||
*/
|
*/
|
||||||
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
||||||
await this.findOne(parentUserId);
|
await this.findOne(parentUserId);
|
||||||
@ -150,11 +152,6 @@ export class ParentsService {
|
|||||||
throw new NotFoundException('Lien parent-enfant introuvable');
|
throw new NotFoundException('Lien parent-enfant introuvable');
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalLinks = await this.parentsChildrenRepository.count({ where: { enfantId } });
|
|
||||||
if (totalLinks <= 1) {
|
|
||||||
throw new BadRequestException('Un enfant doit rester rattaché à au moins un responsable');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId });
|
await this.parentsChildrenRepository.delete({ parentId: parentUserId, enfantId });
|
||||||
return this.findOne(parentUserId);
|
return this.findOne(parentUserId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,15 +24,19 @@ export class RelaisController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
@ApiOperation({ summary: 'Lister tous les relais' })
|
@ApiOperation({
|
||||||
|
summary: 'Lister tous les relais',
|
||||||
|
description:
|
||||||
|
'Lecture ouverte aux gestionnaires (combobox fiches). CRUD write reste admin-only. Ticket #151.',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 200, description: 'Liste des relais.' })
|
@ApiResponse({ status: 200, description: 'Liste des relais.' })
|
||||||
findAll() {
|
findAll() {
|
||||||
return this.relaisService.findAll();
|
return this.relaisService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||||
@ApiOperation({ summary: 'Récupérer un relais par ID' })
|
@ApiOperation({ summary: 'Récupérer un relais par ID' })
|
||||||
@ApiResponse({ status: 200, description: 'Le relais trouvé.' })
|
@ApiResponse({ status: 200, description: 'Le relais trouvé.' })
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
|
|||||||
@ -14,6 +14,8 @@ class EnfantAdminModel {
|
|||||||
final bool consentPhoto;
|
final bool consentPhoto;
|
||||||
final bool isMultiple;
|
final bool isMultiple;
|
||||||
final List<EnfantParentLink> parentLinks;
|
final List<EnfantParentLink> parentLinks;
|
||||||
|
/// Flag API #157 (sinon déduit de [parentLinks]).
|
||||||
|
final bool? sansResponsable;
|
||||||
|
|
||||||
EnfantAdminModel({
|
EnfantAdminModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
@ -27,6 +29,7 @@ class EnfantAdminModel {
|
|||||||
this.consentPhoto = false,
|
this.consentPhoto = false,
|
||||||
this.isMultiple = false,
|
this.isMultiple = false,
|
||||||
this.parentLinks = const [],
|
this.parentLinks = const [],
|
||||||
|
this.sansResponsable,
|
||||||
});
|
});
|
||||||
|
|
||||||
String get fullName {
|
String get fullName {
|
||||||
@ -37,6 +40,42 @@ class EnfantAdminModel {
|
|||||||
return '$fn $ln';
|
return '$fn $ln';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Aucun lien parent valide — ticket #157.
|
||||||
|
bool get hasNoResponsable {
|
||||||
|
if (sansResponsable != null) return sansResponsable!;
|
||||||
|
return !parentLinks.any((l) => l.parentId.trim().isNotEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
EnfantAdminModel copyWith({
|
||||||
|
String? id,
|
||||||
|
String? firstName,
|
||||||
|
String? lastName,
|
||||||
|
String? gender,
|
||||||
|
String? birthDate,
|
||||||
|
String? dueDate,
|
||||||
|
String? status,
|
||||||
|
String? photoUrl,
|
||||||
|
bool? consentPhoto,
|
||||||
|
bool? isMultiple,
|
||||||
|
List<EnfantParentLink>? parentLinks,
|
||||||
|
bool? sansResponsable,
|
||||||
|
}) {
|
||||||
|
return EnfantAdminModel(
|
||||||
|
id: id ?? this.id,
|
||||||
|
firstName: firstName ?? this.firstName,
|
||||||
|
lastName: lastName ?? this.lastName,
|
||||||
|
gender: gender ?? this.gender,
|
||||||
|
birthDate: birthDate ?? this.birthDate,
|
||||||
|
dueDate: dueDate ?? this.dueDate,
|
||||||
|
status: status ?? this.status,
|
||||||
|
photoUrl: photoUrl ?? this.photoUrl,
|
||||||
|
consentPhoto: consentPhoto ?? this.consentPhoto,
|
||||||
|
isMultiple: isMultiple ?? this.isMultiple,
|
||||||
|
parentLinks: parentLinks ?? this.parentLinks,
|
||||||
|
sansResponsable: sansResponsable ?? this.sansResponsable,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
factory EnfantAdminModel.fromJson(Map<String, dynamic> json) {
|
factory EnfantAdminModel.fromJson(Map<String, dynamic> json) {
|
||||||
final linksRaw = json['parentLinks'] as List?;
|
final linksRaw = json['parentLinks'] as List?;
|
||||||
final links = <EnfantParentLink>[];
|
final links = <EnfantParentLink>[];
|
||||||
@ -48,18 +87,34 @@ class EnfantAdminModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final photoUrl = json['photo_url'] as String? ?? json['photoUrl'] as String?;
|
||||||
|
final consentPhoto = _parseBool(json['consent_photo']) ||
|
||||||
|
_parseBool(json['consentement_photo']) ||
|
||||||
|
_parseBool(json['consentPhoto']);
|
||||||
|
|
||||||
|
bool? sansResponsable;
|
||||||
|
if (json.containsKey('sans_responsable') ||
|
||||||
|
json.containsKey('sansResponsable')) {
|
||||||
|
sansResponsable = _parseBool(json['sans_responsable']) ||
|
||||||
|
_parseBool(json['sansResponsable']);
|
||||||
|
}
|
||||||
|
|
||||||
return EnfantAdminModel(
|
return EnfantAdminModel(
|
||||||
id: (json['id'] ?? '').toString(),
|
id: (json['id'] ?? '').toString(),
|
||||||
firstName: json['first_name'] as String?,
|
firstName: json['first_name'] as String? ?? json['prenom'] as String?,
|
||||||
lastName: json['last_name'] as String?,
|
lastName: json['last_name'] as String? ?? json['nom'] as String?,
|
||||||
gender: json['gender'] as String?,
|
gender: json['gender'] as String? ?? json['genre'] as String?,
|
||||||
birthDate: _dateString(json['birth_date']),
|
birthDate: _dateString(json['birth_date'] ?? json['date_naissance']),
|
||||||
dueDate: _dateString(json['due_date']),
|
dueDate: _dateString(json['due_date'] ?? json['date_prevue_naissance']),
|
||||||
status: normalizeEnfantStatus(json['status']?.toString()),
|
status: normalizeEnfantStatus(
|
||||||
photoUrl: json['photo_url'] as String?,
|
(json['status'] ?? json['statut'])?.toString(),
|
||||||
consentPhoto: json['consent_photo'] == true,
|
),
|
||||||
isMultiple: json['is_multiple'] == true,
|
photoUrl: photoUrl,
|
||||||
|
consentPhoto: consentPhoto,
|
||||||
|
isMultiple: _parseBool(json['is_multiple']) ||
|
||||||
|
_parseBool(json['est_multiple']),
|
||||||
parentLinks: links,
|
parentLinks: links,
|
||||||
|
sansResponsable: sansResponsable,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,6 +131,15 @@ class EnfantAdminModel {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool _parseBool(dynamic value) {
|
||||||
|
if (value == true || value == 1) return true;
|
||||||
|
if (value is String) {
|
||||||
|
final s = value.trim().toLowerCase();
|
||||||
|
return s == 'true' || s == '1' || s == 't' || s == 'yes';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
static String? _dateString(dynamic v) {
|
static String? _dateString(dynamic v) {
|
||||||
if (v == null) return null;
|
if (v == null) return null;
|
||||||
if (v is String) return v.split('T').first;
|
if (v is String) return v.split('T').first;
|
||||||
@ -89,6 +153,13 @@ class EnfantParentLink {
|
|||||||
|
|
||||||
EnfantParentLink({required this.parentId, this.parentName});
|
EnfantParentLink({required this.parentId, this.parentName});
|
||||||
|
|
||||||
|
EnfantParentLink copyWith({String? parentId, String? parentName}) {
|
||||||
|
return EnfantParentLink(
|
||||||
|
parentId: parentId ?? this.parentId,
|
||||||
|
parentName: parentName ?? this.parentName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
factory EnfantParentLink.fromJson(Map<String, dynamic> json) {
|
factory EnfantParentLink.fromJson(Map<String, dynamic> json) {
|
||||||
final parentId =
|
final parentId =
|
||||||
(json['parentId'] ?? json['id_parent'] ?? '').toString();
|
(json['parentId'] ?? json['id_parent'] ?? '').toString();
|
||||||
@ -99,6 +170,12 @@ class EnfantParentLink {
|
|||||||
if (user is Map<String, dynamic>) {
|
if (user is Map<String, dynamic>) {
|
||||||
final u = AppUser.fromJson(user);
|
final u = AppUser.fromJson(user);
|
||||||
name = u.fullName.isNotEmpty ? u.fullName : u.email;
|
name = u.fullName.isNotEmpty ? u.fullName : u.email;
|
||||||
|
} else {
|
||||||
|
// Parfois le parent est aplati (prenom/nom) sans nested user.
|
||||||
|
final prenom = (parent['prenom'] ?? parent['first_name'] ?? '').toString().trim();
|
||||||
|
final nom = (parent['nom'] ?? parent['last_name'] ?? '').toString().trim();
|
||||||
|
final flat = '$prenom $nom'.trim();
|
||||||
|
if (flat.isNotEmpty) name = flat;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return EnfantParentLink(
|
return EnfantParentLink(
|
||||||
|
|||||||
@ -62,4 +62,56 @@ 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import 'package:p_tits_pas/utils/email_utils.dart';
|
|||||||
import 'package:p_tits_pas/widgets/email_text_field.dart';
|
import 'package:p_tits_pas/widgets/email_text_field.dart';
|
||||||
import 'package:p_tits_pas/widgets/french_phone_field.dart';
|
import 'package:p_tits_pas/widgets/french_phone_field.dart';
|
||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
import 'package:p_tits_pas/services/auth_service.dart';
|
||||||
import 'package:p_tits_pas/services/relais_service.dart';
|
import 'package:p_tits_pas/services/relais_service.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
|
||||||
@ -41,9 +42,15 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
bool _isLoadingRelais = true;
|
bool _isLoadingRelais = true;
|
||||||
List<RelaisModel> _relais = [];
|
List<RelaisModel> _relais = [];
|
||||||
String? _selectedRelaisId;
|
String? _selectedRelaisId;
|
||||||
|
String? _currentUserId;
|
||||||
bool get _isEditMode => widget.initialUser != null;
|
bool get _isEditMode => widget.initialUser != null;
|
||||||
bool get _isSuperAdminTarget =>
|
bool get _isSuperAdminTarget =>
|
||||||
(widget.initialUser?.role ?? '').toLowerCase() == 'super_admin';
|
(widget.initialUser?.role ?? '').toLowerCase() == 'super_admin';
|
||||||
|
bool get _isSelfTarget =>
|
||||||
|
_isEditMode &&
|
||||||
|
_currentUserId != null &&
|
||||||
|
widget.initialUser!.id == _currentUserId;
|
||||||
|
bool get _canDeleteTarget => !_isSuperAdminTarget && !_isSelfTarget;
|
||||||
bool get _isLockedAdminIdentity =>
|
bool get _isLockedAdminIdentity =>
|
||||||
_isEditMode && widget.adminMode && _isSuperAdminTarget;
|
_isEditMode && widget.adminMode && _isSuperAdminTarget;
|
||||||
String get _targetRoleKey {
|
String get _targetRoleKey {
|
||||||
@ -109,6 +116,23 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
} else {
|
} else {
|
||||||
_isLoadingRelais = false;
|
_isLoadingRelais = false;
|
||||||
}
|
}
|
||||||
|
_loadCurrentUserId();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadCurrentUserId() async {
|
||||||
|
final cached = await AuthService.getCurrentUser();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (cached != null) {
|
||||||
|
setState(() {
|
||||||
|
_currentUserId = cached.id;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final refreshed = await AuthService.refreshCurrentUser();
|
||||||
|
if (!mounted || refreshed == null) return;
|
||||||
|
setState(() {
|
||||||
|
_currentUserId = refreshed.id;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -122,6 +146,21 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fallback si GET /relais échoue : conserve le relais déjà connu sur l'utilisateur.
|
||||||
|
List<RelaisModel> _fallbackRelaisFromUser() {
|
||||||
|
final id = _selectedRelaisId?.trim();
|
||||||
|
if (id == null || id.isEmpty) return const [];
|
||||||
|
final nom = (widget.initialUser?.relaisNom ?? '').trim();
|
||||||
|
return [
|
||||||
|
RelaisModel(
|
||||||
|
id: id,
|
||||||
|
nom: nom.isNotEmpty ? nom : 'Relais actuel',
|
||||||
|
adresse: '',
|
||||||
|
actif: true,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadRelais() async {
|
Future<void> _loadRelais() async {
|
||||||
try {
|
try {
|
||||||
final list = await RelaisService.getRelais();
|
final list = await RelaisService.getRelais();
|
||||||
@ -138,7 +177,8 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
if (selected != null) {
|
if (selected != null) {
|
||||||
filtered.add(selected);
|
filtered.add(selected);
|
||||||
} else {
|
} else {
|
||||||
_selectedRelaisId = null;
|
// Garder l'id sélectionné et afficher un item de secours (nom carte).
|
||||||
|
filtered.addAll(_fallbackRelaisFromUser());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -148,9 +188,9 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
// Ne pas nullifier _selectedRelaisId (#151) — la carte a déjà le bon libellé.
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedRelaisId = null;
|
_relais = _fallbackRelaisFromUser();
|
||||||
_relais = [];
|
|
||||||
_isLoadingRelais = false;
|
_isLoadingRelais = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -335,7 +375,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
|
|
||||||
Future<void> _delete() async {
|
Future<void> _delete() async {
|
||||||
if (widget.readOnly) return;
|
if (widget.readOnly) return;
|
||||||
if (_isSuperAdminTarget) return;
|
if (!_canDeleteTarget) return;
|
||||||
if (!_isEditMode || _isSubmitting) return;
|
if (!_isEditMode || _isSubmitting) return;
|
||||||
|
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
@ -462,7 +502,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|||||||
child: const Text('Fermer'),
|
child: const Text('Fermer'),
|
||||||
),
|
),
|
||||||
] else if (_isEditMode) ...[
|
] else if (_isEditMode) ...[
|
||||||
if (!_isSuperAdminTarget)
|
if (_canDeleteTarget)
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onPressed: _isSubmitting ? null : _delete,
|
onPressed: _isSubmitting ? null : _delete,
|
||||||
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
|
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http_parser/http_parser.dart';
|
||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||||
import 'package:p_tits_pas/models/parent_model.dart';
|
import 'package:p_tits_pas/models/parent_model.dart';
|
||||||
@ -8,6 +9,7 @@ import 'package:p_tits_pas/models/pending_family.dart';
|
|||||||
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||||
import 'package:p_tits_pas/services/api/tokenService.dart';
|
import 'package:p_tits_pas/services/api/tokenService.dart';
|
||||||
|
import 'package:p_tits_pas/utils/am_vigilance.dart';
|
||||||
|
|
||||||
class DocumentActifInfo {
|
class DocumentActifInfo {
|
||||||
final String id;
|
final String id;
|
||||||
@ -56,6 +58,33 @@ class UserService {
|
|||||||
return v.toString();
|
return v.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// MIME pour upload photo enfant (filtre Nest : jpg/jpeg/png/gif).
|
||||||
|
static MediaType _imageMediaType(String filename, List<int> bytes) {
|
||||||
|
if (bytes.length >= 3 &&
|
||||||
|
bytes[0] == 0xFF &&
|
||||||
|
bytes[1] == 0xD8 &&
|
||||||
|
bytes[2] == 0xFF) {
|
||||||
|
return MediaType('image', 'jpeg');
|
||||||
|
}
|
||||||
|
if (bytes.length >= 8 &&
|
||||||
|
bytes[0] == 0x89 &&
|
||||||
|
bytes[1] == 0x50 &&
|
||||||
|
bytes[2] == 0x4E &&
|
||||||
|
bytes[3] == 0x47) {
|
||||||
|
return MediaType('image', 'png');
|
||||||
|
}
|
||||||
|
if (bytes.length >= 6 &&
|
||||||
|
bytes[0] == 0x47 &&
|
||||||
|
bytes[1] == 0x49 &&
|
||||||
|
bytes[2] == 0x46) {
|
||||||
|
return MediaType('image', 'gif');
|
||||||
|
}
|
||||||
|
final lower = filename.toLowerCase();
|
||||||
|
if (lower.endsWith('.png')) return MediaType('image', 'png');
|
||||||
|
if (lower.endsWith('.gif')) return MediaType('image', 'gif');
|
||||||
|
return MediaType('image', 'jpeg');
|
||||||
|
}
|
||||||
|
|
||||||
static String _errMessage(dynamic err) {
|
static String _errMessage(dynamic err) {
|
||||||
if (err == null) return 'Erreur inconnue';
|
if (err == null) return 'Erreur inconnue';
|
||||||
if (err is String) return err;
|
if (err is String) return err;
|
||||||
@ -460,7 +489,40 @@ class UserService {
|
|||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
throw Exception(_extractErrorMessage(response.body, 'Erreur chargement enfant'));
|
throw Exception(_extractErrorMessage(response.body, 'Erreur chargement enfant'));
|
||||||
}
|
}
|
||||||
return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
|
final enfant = EnfantAdminModel.fromJson(
|
||||||
|
jsonDecode(response.body) as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
// GET /enfants/:id ne joint pas toujours parent.user → noms manquants.
|
||||||
|
return enrichEnfantParentNames(enfant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complète [parentName] via GET parent quand l'API n'a pas fourni le nested user.
|
||||||
|
static Future<EnfantAdminModel> enrichEnfantParentNames(
|
||||||
|
EnfantAdminModel enfant,
|
||||||
|
) async {
|
||||||
|
final links = enfant.parentLinks;
|
||||||
|
if (links.isEmpty) return enfant;
|
||||||
|
if (links.every((l) => (l.parentName ?? '').trim().isNotEmpty)) {
|
||||||
|
return enfant;
|
||||||
|
}
|
||||||
|
|
||||||
|
final enriched = await Future.wait(
|
||||||
|
links.map((link) async {
|
||||||
|
if ((link.parentName ?? '').trim().isNotEmpty) return link;
|
||||||
|
final id = link.parentId.trim();
|
||||||
|
if (id.isEmpty) return link;
|
||||||
|
try {
|
||||||
|
final parent = await getParent(id);
|
||||||
|
final name = parent.user.fullName.trim();
|
||||||
|
if (name.isEmpty) return link;
|
||||||
|
return link.copyWith(parentName: name);
|
||||||
|
} catch (_) {
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return enfant.copyWith(parentLinks: enriched);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<EnfantAdminModel> updateEnfant({
|
static Future<EnfantAdminModel> updateEnfant({
|
||||||
@ -475,7 +537,91 @@ class UserService {
|
|||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant'));
|
throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant'));
|
||||||
}
|
}
|
||||||
return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
|
final enfant = EnfantAdminModel.fromJson(
|
||||||
|
jsonDecode(response.body) as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
return enrichEnfantParentNames(enfant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Création enfant côté staff (#132) — nécessite `POST /enfants` étendu (voir mini-spec).
|
||||||
|
/// [parentUserId] : parent pivot du foyer (`parent_user_id`).
|
||||||
|
/// Avec [photoBytes] : `multipart/form-data` (champ fichier `photo`), sinon JSON.
|
||||||
|
static Future<EnfantAdminModel> createEnfant({
|
||||||
|
required String parentUserId,
|
||||||
|
required Map<String, dynamic> body,
|
||||||
|
List<int>? photoBytes,
|
||||||
|
String? photoFilename,
|
||||||
|
}) async {
|
||||||
|
final hasPhoto = photoBytes != null && photoBytes.isNotEmpty;
|
||||||
|
final http.Response response;
|
||||||
|
if (hasPhoto) {
|
||||||
|
final token = await TokenService.getToken();
|
||||||
|
final req = http.MultipartRequest(
|
||||||
|
'POST',
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
|
||||||
|
);
|
||||||
|
req.headers['Accept'] = 'application/json';
|
||||||
|
if (token != null) {
|
||||||
|
req.headers['Authorization'] = 'Bearer $token';
|
||||||
|
}
|
||||||
|
body.forEach((key, value) {
|
||||||
|
if (value == null) return;
|
||||||
|
req.fields[key] = value is bool
|
||||||
|
? (value ? 'true' : 'false')
|
||||||
|
: value.toString();
|
||||||
|
});
|
||||||
|
req.fields['parent_user_id'] = parentUserId;
|
||||||
|
final name = (photoFilename ?? '').trim();
|
||||||
|
final filename = name.isNotEmpty ? name : 'photo.jpg';
|
||||||
|
req.files.add(
|
||||||
|
http.MultipartFile.fromBytes(
|
||||||
|
'photo',
|
||||||
|
photoBytes,
|
||||||
|
filename: filename,
|
||||||
|
contentType: _imageMediaType(filename, photoBytes),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final streamed = await req.send();
|
||||||
|
response = await http.Response.fromStream(streamed);
|
||||||
|
} else {
|
||||||
|
final payload = <String, dynamic>{
|
||||||
|
...body,
|
||||||
|
'parent_user_id': parentUserId,
|
||||||
|
};
|
||||||
|
response = await http.post(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(payload),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||||
|
throw Exception(
|
||||||
|
_extractErrorMessage(response.body, 'Erreur création enfant'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final enfant = EnfantAdminModel.fromJson(
|
||||||
|
jsonDecode(response.body) as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
return enrichEnfantParentNames(enfant);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> deleteEnfant(String enfantId) async {
|
||||||
|
final response = await http.delete(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 204) {
|
||||||
|
throw Exception(_extractErrorMessage(response.body, 'Erreur suppression enfant'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AM dont la liste d'enfants actifs contient [enfantId] (API actuelle).
|
||||||
|
static Future<AssistanteMaternelleModel?> findAmForEnfant(String enfantId) async {
|
||||||
|
final ams = await getAssistantesMaternelles();
|
||||||
|
for (final am in ams) {
|
||||||
|
if (am.children.any((c) => c.id == enfantId)) return am;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static ParentModel _parentModelFromBody(String body) {
|
static ParentModel _parentModelFromBody(String body) {
|
||||||
@ -633,7 +779,24 @@ class UserService {
|
|||||||
if (response.statusCode != 200 && response.statusCode != 201) {
|
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||||
throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant'));
|
throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant'));
|
||||||
}
|
}
|
||||||
return _amModelFromBody(response.body);
|
final am = _amModelFromBody(response.body);
|
||||||
|
// Recalcule toujours places_available (capacité − enfants) après attachement.
|
||||||
|
return syncAmPlacesAvailable(am);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aligne `places_available` sur capacité − enfants rattachés.
|
||||||
|
static Future<AssistanteMaternelleModel> syncAmPlacesAvailable(
|
||||||
|
AssistanteMaternelleModel am,
|
||||||
|
) async {
|
||||||
|
final expected = amExpectedPlacesAvailable(
|
||||||
|
maxChildren: am.maxChildren,
|
||||||
|
childrenCount: am.children.length,
|
||||||
|
);
|
||||||
|
if (expected == null || am.placesAvailable == expected) return am;
|
||||||
|
return updateAmFiche(
|
||||||
|
amUserId: am.user.id,
|
||||||
|
body: {'places_available': expected},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<AssistanteMaternelleModel> detachEnfantFromAm({
|
static Future<AssistanteMaternelleModel> detachEnfantFromAm({
|
||||||
|
|||||||
@ -9,6 +9,18 @@ int? amExpectedPlacesAvailable({
|
|||||||
return (maxChildren - childrenCount).clamp(0, maxChildren);
|
return (maxChildren - childrenCount).clamp(0, maxChildren);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True s'il reste au moins une place d'accueil (capacité − enfants).
|
||||||
|
bool amHasFreePlace(AssistanteMaternelleModel am) {
|
||||||
|
final expected = amExpectedPlacesAvailable(
|
||||||
|
maxChildren: am.maxChildren,
|
||||||
|
childrenCount: am.children.length,
|
||||||
|
);
|
||||||
|
if (expected != null) return expected > 0;
|
||||||
|
final places = am.placesAvailable;
|
||||||
|
if (places != null) return places > 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// True si la valeur déclarée par l'AM ne correspond pas au calcul métier.
|
/// True si la valeur déclarée par l'AM ne correspond pas au calcul métier.
|
||||||
bool amHasPlacesInconsistency({
|
bool amHasPlacesInconsistency({
|
||||||
required int? maxChildren,
|
required int? maxChildren,
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||||
|
|
||||||
@ -11,10 +12,50 @@ String formatIsoDateFr(String? s, {String ifEmpty = '–'}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convertit `dd/MM/yyyy` (ou ISO) en `yyyy-MM-dd` pour l'API.
|
/// Affiche une date ISO en saisie guidée `jj / mm / aaaa`.
|
||||||
|
String formatIsoDateFrInput(String? s, {String ifEmpty = ''}) {
|
||||||
|
if (s == null || s.trim().isEmpty) return ifEmpty;
|
||||||
|
try {
|
||||||
|
final dt = DateTime.parse(s.trim());
|
||||||
|
return formatFrenchDateDigits(
|
||||||
|
DateFormat('ddMMyyyy').format(dt),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return formatFrenchDateDigits(s.replaceAll(RegExp(r'\D'), ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formate jusqu’à 8 chiffres en `jj / mm / aaaa`.
|
||||||
|
String formatFrenchDateDigits(String digits) {
|
||||||
|
final d = digits.replaceAll(RegExp(r'\D'), '');
|
||||||
|
final limited = d.length > 8 ? d.substring(0, 8) : d;
|
||||||
|
final buf = StringBuffer();
|
||||||
|
for (var i = 0; i < limited.length; i++) {
|
||||||
|
if (i == 2 || i == 4) buf.write(' / ');
|
||||||
|
buf.write(limited[i]);
|
||||||
|
}
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convertit `dd/MM/yyyy`, `dd / MM / yyyy`, 8 chiffres ou ISO en `yyyy-MM-dd`.
|
||||||
String? parseFrDateToIso(String text) {
|
String? parseFrDateToIso(String text) {
|
||||||
final t = text.trim();
|
final t = text.trim();
|
||||||
if (t.isEmpty) return null;
|
if (t.isEmpty) return null;
|
||||||
|
|
||||||
|
final digits = t.replaceAll(RegExp(r'\D'), '');
|
||||||
|
if (digits.length == 8) {
|
||||||
|
final dd = digits.substring(0, 2);
|
||||||
|
final mm = digits.substring(2, 4);
|
||||||
|
final yyyy = digits.substring(4, 8);
|
||||||
|
try {
|
||||||
|
return DateFormat('dd/MM/yyyy')
|
||||||
|
.parseStrict('$dd/$mm/$yyyy')
|
||||||
|
.toIso8601String()
|
||||||
|
.split('T')
|
||||||
|
.first;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return DateFormat('dd/MM/yyyy')
|
return DateFormat('dd/MM/yyyy')
|
||||||
.parseStrict(t)
|
.parseStrict(t)
|
||||||
@ -96,3 +137,43 @@ String formatChildAgeLabel({
|
|||||||
return 'Né le ${formatIsoDateFr(birthDate)}';
|
return 'Né le ${formatIsoDateFr(birthDate)}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Saisie date FR : 8 chiffres → `jj / mm / aaaa`.
|
||||||
|
class FrenchDateInputFormatter extends TextInputFormatter {
|
||||||
|
const FrenchDateInputFormatter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextEditingValue formatEditUpdate(
|
||||||
|
TextEditingValue oldValue,
|
||||||
|
TextEditingValue newValue,
|
||||||
|
) {
|
||||||
|
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
||||||
|
final limited = digits.length > 8 ? digits.substring(0, 8) : digits;
|
||||||
|
final formatted = formatFrenchDateDigits(limited);
|
||||||
|
|
||||||
|
final digitsBeforeCursor = newValue.text
|
||||||
|
.substring(0, newValue.selection.start.clamp(0, newValue.text.length))
|
||||||
|
.replaceAll(RegExp(r'\D'), '')
|
||||||
|
.length
|
||||||
|
.clamp(0, limited.length);
|
||||||
|
|
||||||
|
return TextEditingValue(
|
||||||
|
text: formatted,
|
||||||
|
selection: TextSelection.collapsed(
|
||||||
|
offset: _cursorOffset(formatted, digitsBeforeCursor),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _cursorOffset(String formatted, int digitsBeforeCursor) {
|
||||||
|
if (digitsBeforeCursor <= 0) return 0;
|
||||||
|
var seen = 0;
|
||||||
|
for (var i = 0; i < formatted.length; i++) {
|
||||||
|
if (RegExp(r'\d').hasMatch(formatted[i])) {
|
||||||
|
seen++;
|
||||||
|
if (seen >= digitsBeforeCursor) return i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return formatted.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -14,10 +14,10 @@ String normalizeEnfantStatus(String? raw) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
String _scolariseAccordeAuGenre(String? gender) {
|
String _accordeAuGenre(String masculin, String feminin, String? gender) {
|
||||||
final g = (gender ?? '').trim().toUpperCase();
|
final g = (gender ?? '').trim().toUpperCase();
|
||||||
if (g == 'F') return 'Scolarisée';
|
if (g == 'F') return feminin;
|
||||||
return 'Scolarisé';
|
return masculin;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Libellé affiché pour un statut enfant.
|
/// Libellé affiché pour un statut enfant.
|
||||||
@ -28,9 +28,9 @@ String enfantStatusLabel(String? status, {String? gender}) {
|
|||||||
case 'sans_garde':
|
case 'sans_garde':
|
||||||
return 'Sans garde';
|
return 'Sans garde';
|
||||||
case 'garde':
|
case 'garde':
|
||||||
return 'En garde';
|
return _accordeAuGenre('Gardé', 'Gardée', gender);
|
||||||
case 'scolarise':
|
case 'scolarise':
|
||||||
return _scolariseAccordeAuGenre(gender);
|
return _accordeAuGenre('Scolarisé', 'Scolarisée', gender);
|
||||||
default:
|
default:
|
||||||
return status?.trim().isNotEmpty == true ? status!.trim() : '–';
|
return status?.trim().isNotEmpty == true ? status!.trim() : '–';
|
||||||
}
|
}
|
||||||
|
|||||||
10
frontend/lib/utils/enfant_vigilance.dart
Normal file
10
frontend/lib/utils/enfant_vigilance.dart
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
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,3 +1,5 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
/// Formatage affichage prénom / nom (capitalisation par mot et segments après `-` ou `'`).
|
/// Formatage affichage prénom / nom (capitalisation par mot et segments après `-` ou `'`).
|
||||||
|
|
||||||
String formatPersonNameCase(String raw) {
|
String formatPersonNameCase(String raw) {
|
||||||
@ -9,6 +11,22 @@ String formatPersonNameCase(String raw) {
|
|||||||
return words.map(_capitalizeComposedWord).join(' ');
|
return words.map(_capitalizeComposedWord).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Variante saisie live : pas de majuscule tant que le mot 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;
|
||||||
@ -30,3 +48,26 @@ String _capitalizeComposedWord(String word) {
|
|||||||
}
|
}
|
||||||
return buffer.toString();
|
return buffer.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Formateur de saisie prénom / nom (capitalisation progressive).
|
||||||
|
class PersonNameInputFormatter extends TextInputFormatter {
|
||||||
|
const PersonNameInputFormatter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextEditingValue formatEditUpdate(
|
||||||
|
TextEditingValue oldValue,
|
||||||
|
TextEditingValue newValue,
|
||||||
|
) {
|
||||||
|
final formatted = formatPersonNameCaseTyping(newValue.text);
|
||||||
|
if (formatted == newValue.text) return newValue;
|
||||||
|
|
||||||
|
final sel = newValue.selection;
|
||||||
|
final offset = sel.isValid
|
||||||
|
? sel.baseOffset.clamp(0, formatted.length)
|
||||||
|
: formatted.length;
|
||||||
|
return TextEditingValue(
|
||||||
|
text: formatted,
|
||||||
|
selection: TextSelection.collapsed(offset: offset),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -167,6 +167,7 @@ class ParentRegistrationPayload {
|
|||||||
final map = <String, dynamic>{
|
final map = <String, dynamic>{
|
||||||
'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre',
|
'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre',
|
||||||
'grossesse_multiple': c.multipleBirth,
|
'grossesse_multiple': c.multipleBirth,
|
||||||
|
'consent_photo': c.photoConsent,
|
||||||
};
|
};
|
||||||
|
|
||||||
final prenom = c.firstName.trim();
|
final prenom = c.firstName.trim();
|
||||||
|
|||||||
@ -38,15 +38,12 @@ class RepriseMapper {
|
|||||||
? isoToDdMmYyyy(e.dueDate)
|
? isoToDdMmYyyy(e.dueDate)
|
||||||
: isoToDdMmYyyy(e.birthDate);
|
: isoToDdMmYyyy(e.birthDate);
|
||||||
final photo = e.photoUrl?.trim();
|
final photo = e.photoUrl?.trim();
|
||||||
final hasPhoto = photo != null && photo.isNotEmpty;
|
|
||||||
return ChildData(
|
return ChildData(
|
||||||
firstName: e.firstName ?? '',
|
firstName: e.firstName ?? '',
|
||||||
lastName: e.lastName ?? '',
|
lastName: e.lastName ?? '',
|
||||||
dob: dob,
|
dob: dob,
|
||||||
genre: e.gender ?? '',
|
genre: e.gender ?? '',
|
||||||
// Inscription initiale exigeait la coche pour envoyer la photo ; le back
|
photoConsent: e.consentPhoto,
|
||||||
// ne persistait pas toujours consent_photo — on pré-coche si photo en base.
|
|
||||||
photoConsent: e.consentPhoto || hasPhoto,
|
|
||||||
multipleBirth: e.estMultiple,
|
multipleBirth: e.estMultiple,
|
||||||
isUnbornChild: isUnborn,
|
isUnbornChild: isUnborn,
|
||||||
cardColor: _childCardColors[index % _childCardColors.length],
|
cardColor: _childCardColors[index % _childCardColors.length],
|
||||||
@ -200,7 +197,7 @@ class RepriseMapper {
|
|||||||
postalCode: dossier.codePostal ?? '',
|
postalCode: dossier.codePostal ?? '',
|
||||||
city: dossier.ville ?? '',
|
city: dossier.ville ?? '',
|
||||||
existingPhotoUrl: displayPhoto,
|
existingPhotoUrl: displayPhoto,
|
||||||
consentementPhoto: dossier.consentementPhoto || hasPhoto,
|
consentementPhoto: dossier.consentementPhoto,
|
||||||
dateOfBirth: parseIsoDate(dossier.dateNaissance),
|
dateOfBirth: parseIsoDate(dossier.dateNaissance),
|
||||||
birthCity: dossier.lieuNaissanceVille ?? '',
|
birthCity: dossier.lieuNaissanceVille ?? '',
|
||||||
birthCountry: dossier.lieuNaissancePays ?? '',
|
birthCountry: dossier.lieuNaissancePays ?? '',
|
||||||
|
|||||||
@ -20,6 +20,8 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
|
|||||||
final int capacity;
|
final int capacity;
|
||||||
final void Function(ParentChildSummary child) onOpen;
|
final void Function(ParentChildSummary child) onOpen;
|
||||||
final void Function(ParentChildSummary child) onDetach;
|
final void Function(ParentChildSummary child) onDetach;
|
||||||
|
/// Clic sur une case libre → même flux que « Rattacher un enfant » (#149).
|
||||||
|
final VoidCallback? onAttachEmpty;
|
||||||
|
|
||||||
const AdminAmChildrenCapacityGrid({
|
const AdminAmChildrenCapacityGrid({
|
||||||
super.key,
|
super.key,
|
||||||
@ -27,6 +29,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
|
|||||||
required this.capacity,
|
required this.capacity,
|
||||||
required this.onOpen,
|
required this.onOpen,
|
||||||
required this.onDetach,
|
required this.onDetach,
|
||||||
|
this.onAttachEmpty,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -78,7 +81,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (index < maxSlots) {
|
if (index < maxSlots) {
|
||||||
return const _EmptySlot();
|
return _EmptySlot(onTap: onAttachEmpty);
|
||||||
}
|
}
|
||||||
return const _UnavailableSlot();
|
return const _UnavailableSlot();
|
||||||
}
|
}
|
||||||
@ -129,18 +132,52 @@ class _UnavailableSlot extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EmptySlot extends StatelessWidget {
|
class _EmptySlot extends StatefulWidget {
|
||||||
const _EmptySlot();
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
const _EmptySlot({this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_EmptySlot> createState() => _EmptySlotState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmptySlotState extends State<_EmptySlot> {
|
||||||
|
bool _hovered = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return _SlotShell(
|
final clickable = widget.onTap != null;
|
||||||
backgroundColor: Colors.grey.shade50,
|
return MouseRegion(
|
||||||
borderColor: Colors.grey.shade300,
|
onEnter: clickable ? (_) => setState(() => _hovered = true) : null,
|
||||||
child: Center(
|
onExit: clickable ? (_) => setState(() => _hovered = false) : null,
|
||||||
child: Text(
|
cursor: clickable ? SystemMouseCursors.click : MouseCursor.defer,
|
||||||
'Place libre',
|
child: Material(
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
|
color: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: widget.onTap,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
hoverColor: const Color(0x149CC5C0),
|
||||||
|
child: _SlotShell(
|
||||||
|
backgroundColor: _hovered
|
||||||
|
? const Color(0xFFF3F0FA)
|
||||||
|
: Colors.grey.shade50,
|
||||||
|
borderColor: _hovered
|
||||||
|
? const Color(0xFFB8A4D4)
|
||||||
|
: Colors.grey.shade300,
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'Place libre',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: _hovered
|
||||||
|
? const Color(0xFF6B3FA0)
|
||||||
|
: Colors.grey.shade500,
|
||||||
|
fontWeight: _hovered ? FontWeight.w600 : FontWeight.w400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
||||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
|
||||||
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
||||||
import 'package:p_tits_pas/utils/am_vigilance.dart';
|
import 'package:p_tits_pas/utils/am_vigilance.dart';
|
||||||
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
||||||
@ -11,6 +10,7 @@ import 'package:p_tits_pas/services/user_service.dart';
|
|||||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_children_capacity_grid.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_am_children_capacity_grid.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
||||||
import 'package:p_tits_pas/widgets/common/identity_block.dart';
|
import 'package:p_tits_pas/widgets/common/identity_block.dart';
|
||||||
@ -55,6 +55,10 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
late List<ParentChildSummary> _children;
|
late List<ParentChildSummary> _children;
|
||||||
late Set<String> _baselineChildIds;
|
late Set<String> _baselineChildIds;
|
||||||
|
|
||||||
|
/// Enfants ajoutés localement qui étaient déjà chez une autre AM
|
||||||
|
/// (enfantId → amUserId d'origine). Au save : détacher puis rattacher.
|
||||||
|
final Map<String, String> _transferFromAmIds = {};
|
||||||
|
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
bool _dirty = false;
|
bool _dirty = false;
|
||||||
|
|
||||||
@ -234,6 +238,13 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
|
|
||||||
int? _capaciteMax() => _parseIntField(_capaciteCtrl);
|
int? _capaciteMax() => _parseIntField(_capaciteCtrl);
|
||||||
|
|
||||||
|
/// True si plus aucune place d'accueil (enfants ≥ capacité max).
|
||||||
|
bool get _capacityFull {
|
||||||
|
final max = _capaciteMax();
|
||||||
|
if (max == null) return false;
|
||||||
|
return _children.length >= max;
|
||||||
|
}
|
||||||
|
|
||||||
int? _computedPlacesAvailable() => amExpectedPlacesAvailable(
|
int? _computedPlacesAvailable() => amExpectedPlacesAvailable(
|
||||||
maxChildren: _capaciteMax(),
|
maxChildren: _capaciteMax(),
|
||||||
childrenCount: _children.length,
|
childrenCount: _children.length,
|
||||||
@ -311,6 +322,17 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (final id in currentIds.difference(_baselineChildIds)) {
|
for (final id in currentIds.difference(_baselineChildIds)) {
|
||||||
|
// Transfert : détacher l'ancienne AM sans recalculer ses places
|
||||||
|
// (le ! de vigilance apparaît côté liste AM).
|
||||||
|
final previousAmId = _transferFromAmIds[id] ??
|
||||||
|
(await UserService.findAmForEnfant(id))?.user.id;
|
||||||
|
if (previousAmId != null &&
|
||||||
|
previousAmId != widget.assistante.user.id) {
|
||||||
|
await UserService.detachEnfantFromAm(
|
||||||
|
amUserId: previousAmId,
|
||||||
|
enfantId: id,
|
||||||
|
);
|
||||||
|
}
|
||||||
await UserService.attachEnfantToAm(
|
await UserService.attachEnfantToAm(
|
||||||
amUserId: widget.assistante.user.id,
|
amUserId: widget.assistante.user.id,
|
||||||
enfantId: id,
|
enfantId: id,
|
||||||
@ -346,6 +368,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
_dirty = false;
|
_dirty = false;
|
||||||
_saving = false;
|
_saving = false;
|
||||||
_baselineChildIds = currentIds;
|
_baselineChildIds = currentIds;
|
||||||
|
_transferFromAmIds.clear();
|
||||||
});
|
});
|
||||||
widget.onSaved?.call();
|
widget.onSaved?.call();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@ -377,6 +400,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_children = kids;
|
_children = kids;
|
||||||
_baselineChildIds = kids.map((c) => c.id).toSet();
|
_baselineChildIds = kids.map((c) => c.id).toSet();
|
||||||
|
_transferFromAmIds.clear();
|
||||||
});
|
});
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
@ -422,8 +446,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('Détacher l\'enfant'),
|
title: const Text('Détacher l\'enfant'),
|
||||||
content: Text(
|
content: Text(
|
||||||
'Retirer ${child.fullName} de la fiche de cette assistante ?\n'
|
'Retirer ${child.fullName} de la fiche de cette assistante ?',
|
||||||
'(L\'enfant ne sera pas supprimé.)',
|
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
@ -442,55 +465,75 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_children = _children.where((c) => c.id != child.id).toList();
|
_children = _children.where((c) => c.id != child.id).toList();
|
||||||
|
_transferFromAmIds.remove(child.id);
|
||||||
_syncPlacesAfterChildrenChange();
|
_syncPlacesAfterChildrenChange();
|
||||||
_dirty = true;
|
_dirty = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _attachChild() async {
|
Future<void> _attachChild() async {
|
||||||
List<EnfantAdminModel> all;
|
if (_capacityFull || !mounted) return;
|
||||||
try {
|
final selected = await AdminSelectEnfantModal.show(
|
||||||
all = await UserService.getEnfants();
|
context,
|
||||||
} catch (e) {
|
excludeIds: _children.map((c) => c.id).toSet(),
|
||||||
if (!mounted) return;
|
title: 'Rattacher un enfant',
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showSansGardeFilter: true,
|
||||||
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final linkedIds = _children.map((c) => c.id).toSet();
|
|
||||||
final candidates = all.where((e) => !linkedIds.contains(e.id)).toList();
|
|
||||||
if (candidates.isEmpty) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Aucun enfant disponible à rattacher')),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
final selected = await showDialog<EnfantAdminModel>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => SimpleDialog(
|
|
||||||
title: const Text('Rattacher un enfant'),
|
|
||||||
children: candidates
|
|
||||||
.map(
|
|
||||||
(e) => SimpleDialogOption(
|
|
||||||
onPressed: () => Navigator.pop(ctx, e),
|
|
||||||
child: Text(e.fullName),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
if (selected == null || !mounted) return;
|
if (selected == null || !mounted) return;
|
||||||
|
|
||||||
|
AssistanteMaternelleModel? previousAm;
|
||||||
|
try {
|
||||||
|
previousAm = await UserService.findAmForEnfant(selected.id);
|
||||||
|
} catch (_) {
|
||||||
|
previousAm = null;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final previousAmId = previousAm?.user.id;
|
||||||
|
final isTransfer = previousAmId != null &&
|
||||||
|
previousAmId != widget.assistante.user.id;
|
||||||
|
|
||||||
|
if (isTransfer) {
|
||||||
|
final amName = previousAm!.user.fullName.trim().isNotEmpty
|
||||||
|
? previousAm.user.fullName.trim()
|
||||||
|
: 'une autre assistante maternelle';
|
||||||
|
final gardeLabel =
|
||||||
|
(selected.gender ?? '').trim().toUpperCase() == 'F'
|
||||||
|
? 'déjà gardée'
|
||||||
|
: 'déjà gardé';
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Changer d\'affectation'),
|
||||||
|
content: Text(
|
||||||
|
'${selected.fullName} est $gardeLabel par $amName.\n\n'
|
||||||
|
'Confirmer le transfert vers cette assistante ? '
|
||||||
|
'L\'enfant sera détaché de $amName.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
style: ValidationModalTheme.primaryElevatedStyle,
|
||||||
|
child: const Text('Confirmer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_children = [
|
_children = [
|
||||||
..._children,
|
..._children,
|
||||||
ParentChildSummary.fromEnfant(selected),
|
ParentChildSummary.fromEnfant(selected),
|
||||||
];
|
];
|
||||||
|
if (isTransfer) {
|
||||||
|
_transferFromAmIds[selected.id] = previousAmId!;
|
||||||
|
}
|
||||||
_syncPlacesAfterChildrenChange();
|
_syncPlacesAfterChildrenChange();
|
||||||
_dirty = true;
|
_dirty = true;
|
||||||
});
|
});
|
||||||
@ -701,6 +744,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
capacity: capacity,
|
capacity: capacity,
|
||||||
onOpen: _openChild,
|
onOpen: _openChild,
|
||||||
onDetach: _detachChild,
|
onDetach: _detachChild,
|
||||||
|
onAttachEmpty: _capacityFull ? null : _attachChild,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@ -708,6 +752,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
|
|
||||||
Widget _buildFooter() {
|
Widget _buildFooter() {
|
||||||
final isChildrenTab = _tabCtrl.index == 2;
|
final isChildrenTab = _tabCtrl.index == 2;
|
||||||
|
final canAttachChild = !_saving && !_capacityFull;
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
TextButton(
|
TextButton(
|
||||||
@ -716,10 +761,15 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
|
|||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (isChildrenTab)
|
if (isChildrenTab)
|
||||||
TextButton.icon(
|
Tooltip(
|
||||||
onPressed: _attachChild,
|
message: _capacityFull
|
||||||
icon: const Icon(Icons.link, size: 18),
|
? 'Capacité maximale atteinte'
|
||||||
label: const Text('Rattacher un enfant'),
|
: 'Rattacher un enfant',
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: canAttachChild ? _attachChild : null,
|
||||||
|
icon: const Icon(Icons.link, size: 18),
|
||||||
|
label: const Text('Rattacher un enfant'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (isChildrenTab) const SizedBox(width: 12),
|
if (isChildrenTab) const SizedBox(width: 12),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
|
|||||||
@ -1,14 +1,27 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||||
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
||||||
|
|
||||||
/// Cadre photo identité AM (35×45 mm) — même logique que [ValidationAmWizard].
|
/// Cadre photo identité AM / enfant (35×45 mm) — même logique que [ValidationAmWizard].
|
||||||
class AdminAmPhotoFrame extends StatelessWidget {
|
class AdminAmPhotoFrame extends StatelessWidget {
|
||||||
final String? photoUrl;
|
final String? photoUrl;
|
||||||
|
final Uint8List? imageBytes;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final VoidCallback? onClear;
|
||||||
|
final String emptyLabel;
|
||||||
|
|
||||||
static const double idPhotoAspectRatio = 35 / 45;
|
static const double idPhotoAspectRatio = 35 / 45;
|
||||||
|
|
||||||
const AdminAmPhotoFrame({super.key, this.photoUrl});
|
const AdminAmPhotoFrame({
|
||||||
|
super.key,
|
||||||
|
this.photoUrl,
|
||||||
|
this.imageBytes,
|
||||||
|
this.onTap,
|
||||||
|
this.onClear,
|
||||||
|
this.emptyLabel = 'Aucune photo',
|
||||||
|
});
|
||||||
|
|
||||||
/// Largeur colonne photo pour remplir [height] (cadre inclus).
|
/// Largeur colonne photo pour remplir [height] (cadre inclus).
|
||||||
static double columnWidthForHeight(double height) {
|
static double columnWidthForHeight(double height) {
|
||||||
@ -36,9 +49,12 @@ class AdminAmPhotoFrame extends StatelessWidget {
|
|||||||
ph = pw / ar;
|
ph = pw / ar;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final hasLocal = imageBytes != null && imageBytes!.isNotEmpty;
|
||||||
|
final showClear = onClear != null && hasLocal;
|
||||||
|
|
||||||
// Cadre gris = taille photo + padding uniforme ; centré dans la colonne
|
// Cadre gris = taille photo + padding uniforme ; centré dans la colonne
|
||||||
// (évite le vide blanc en bas quand le conteneur parent est plus haut).
|
// (évite le vide blanc en bas quand le conteneur parent est plus haut).
|
||||||
return Align(
|
Widget frame = Align(
|
||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@ -60,22 +76,81 @@ class AdminAmPhotoFrame extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (onTap != null) {
|
||||||
|
frame = MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: frame,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!showClear) return frame;
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
frame,
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: IconButton(
|
||||||
|
tooltip: 'Retirer la photo',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
minWidth: 32,
|
||||||
|
minHeight: 32,
|
||||||
|
),
|
||||||
|
icon: Icon(
|
||||||
|
Icons.cancel,
|
||||||
|
size: 22,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
onPressed: onClear,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _photoContent(String fullUrl, double pw, double ph) {
|
Widget _photoContent(String fullUrl, double pw, double ph) {
|
||||||
|
if (imageBytes != null && imageBytes!.isNotEmpty) {
|
||||||
|
return Image.memory(
|
||||||
|
imageBytes!,
|
||||||
|
width: pw,
|
||||||
|
height: ph,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (fullUrl.isEmpty) {
|
if (fullUrl.isEmpty) {
|
||||||
return ColoredBox(
|
return ColoredBox(
|
||||||
color: Colors.grey.shade200,
|
color: Colors.grey.shade200,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.person_off_outlined, size: 36, color: Colors.grey.shade400),
|
Icon(
|
||||||
|
onTap != null ? Icons.add_a_photo_outlined : Icons.person_off_outlined,
|
||||||
|
size: 36,
|
||||||
|
color: Colors.grey.shade400,
|
||||||
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Padding(
|
||||||
'Aucune photo',
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 11),
|
child: Text(
|
||||||
|
emptyLabel,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.grey.shade600, fontSize: 11),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -108,7 +183,11 @@ class AdminAmPhotoFrame extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
errorBuilder: (_, __, ___) => ColoredBox(
|
errorBuilder: (_, __, ___) => ColoredBox(
|
||||||
color: Colors.grey.shade200,
|
color: Colors.grey.shade200,
|
||||||
child: Icon(Icons.broken_image_outlined, size: 36, color: Colors.grey.shade400),
|
child: Icon(
|
||||||
|
Icons.broken_image_outlined,
|
||||||
|
size: 36,
|
||||||
|
color: Colors.grey.shade400,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -64,6 +64,7 @@ class AdminChildrenAffiliationPanel extends StatelessWidget {
|
|||||||
final c = children[i];
|
final c = children[i];
|
||||||
return AdminEnfantUserCard.fromSummary(
|
return AdminEnfantUserCard.fromSummary(
|
||||||
c,
|
c,
|
||||||
|
onCardTap: () => onOpen(c),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.visibility_outlined),
|
icon: const Icon(Icons.visibility_outlined),
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
|||||||
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
||||||
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
import 'package:p_tits_pas/utils/date_display_utils.dart';
|
||||||
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||||
|
import 'package:p_tits_pas/utils/enfant_vigilance.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||||
|
|
||||||
List<String> enfantAdminSubtitleLines({
|
List<String> enfantAdminSubtitleLines({
|
||||||
@ -34,6 +35,10 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
final List<String> subtitleLines;
|
final List<String> subtitleLines;
|
||||||
final List<Widget> actions;
|
final List<Widget> actions;
|
||||||
final VoidCallback? onCardTap;
|
final VoidCallback? onCardTap;
|
||||||
|
final EdgeInsetsGeometry? margin;
|
||||||
|
final EdgeInsetsGeometry? contentPadding;
|
||||||
|
final Color? borderColor;
|
||||||
|
final String? vigilanceTooltip;
|
||||||
|
|
||||||
const AdminEnfantUserCard({
|
const AdminEnfantUserCard({
|
||||||
super.key,
|
super.key,
|
||||||
@ -42,6 +47,10 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
required this.subtitleLines,
|
required this.subtitleLines,
|
||||||
this.actions = const [],
|
this.actions = const [],
|
||||||
this.onCardTap,
|
this.onCardTap,
|
||||||
|
this.margin,
|
||||||
|
this.contentPadding,
|
||||||
|
this.borderColor,
|
||||||
|
this.vigilanceTooltip,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory AdminEnfantUserCard.fromEnfant(
|
factory AdminEnfantUserCard.fromEnfant(
|
||||||
@ -49,10 +58,15 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
List<String> extraSubtitleLines = const [],
|
List<String> extraSubtitleLines = const [],
|
||||||
List<Widget> actions = const [],
|
List<Widget> actions = const [],
|
||||||
VoidCallback? onCardTap,
|
VoidCallback? onCardTap,
|
||||||
|
EdgeInsetsGeometry? margin,
|
||||||
|
EdgeInsetsGeometry? contentPadding,
|
||||||
|
Color? borderColor,
|
||||||
|
String? vigilanceTooltip,
|
||||||
}) {
|
}) {
|
||||||
final parents = enfant.parentLinks
|
final parents = enfant.parentLinks
|
||||||
.map((l) => l.parentName ?? 'Parent')
|
.map((l) => l.parentName ?? 'Parent')
|
||||||
.join(', ');
|
.join(', ');
|
||||||
|
final orphan = enfantHasNoResponsable(enfant);
|
||||||
return AdminEnfantUserCard(
|
return AdminEnfantUserCard(
|
||||||
title: enfant.fullName,
|
title: enfant.fullName,
|
||||||
photoUrl: enfant.photoUrl,
|
photoUrl: enfant.photoUrl,
|
||||||
@ -63,11 +77,18 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
gender: enfant.gender,
|
gender: enfant.gender,
|
||||||
extra: [
|
extra: [
|
||||||
if (parents.isNotEmpty) 'Responsables : $parents',
|
if (parents.isNotEmpty) 'Responsables : $parents',
|
||||||
|
if (orphan) 'Aucun responsable rattaché',
|
||||||
...extraSubtitleLines,
|
...extraSubtitleLines,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: actions,
|
actions: actions,
|
||||||
onCardTap: onCardTap,
|
onCardTap: onCardTap,
|
||||||
|
margin: margin,
|
||||||
|
contentPadding: contentPadding,
|
||||||
|
borderColor: borderColor ??
|
||||||
|
(orphan ? Colors.red.shade300 : null),
|
||||||
|
vigilanceTooltip: vigilanceTooltip ??
|
||||||
|
enfantSansResponsableVigilanceMessage(enfant),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,6 +97,8 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
List<String> extraSubtitleLines = const [],
|
List<String> extraSubtitleLines = const [],
|
||||||
List<Widget> actions = const [],
|
List<Widget> actions = const [],
|
||||||
VoidCallback? onCardTap,
|
VoidCallback? onCardTap,
|
||||||
|
EdgeInsetsGeometry? margin,
|
||||||
|
EdgeInsetsGeometry? contentPadding,
|
||||||
}) {
|
}) {
|
||||||
return AdminEnfantUserCard(
|
return AdminEnfantUserCard(
|
||||||
title: child.fullName,
|
title: child.fullName,
|
||||||
@ -88,6 +111,8 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
actions: actions,
|
actions: actions,
|
||||||
onCardTap: onCardTap,
|
onCardTap: onCardTap,
|
||||||
|
margin: margin,
|
||||||
|
contentPadding: contentPadding,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,6 +125,10 @@ class AdminEnfantUserCard extends StatelessWidget {
|
|||||||
subtitleLines: subtitleLines,
|
subtitleLines: subtitleLines,
|
||||||
actions: actions,
|
actions: actions,
|
||||||
onCardTap: onCardTap,
|
onCardTap: onCardTap,
|
||||||
|
margin: margin,
|
||||||
|
contentPadding: contentPadding,
|
||||||
|
borderColor: borderColor,
|
||||||
|
vigilanceTooltip: vigilanceTooltip,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
|
||||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||||
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
||||||
import 'package:p_tits_pas/models/parent_model.dart';
|
import 'package:p_tits_pas/models/parent_model.dart';
|
||||||
@ -7,6 +6,7 @@ import 'package:p_tits_pas/models/user.dart';
|
|||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
|
||||||
import 'package:p_tits_pas/widgets/common/identity_block.dart';
|
import 'package:p_tits_pas/widgets/common/identity_block.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
||||||
@ -113,10 +113,73 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
return '$fn $ln'.trim();
|
return '$fn $ln'.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _coParentSubtitle() {
|
String? _coParentName() {
|
||||||
final name = _coParent?.fullName.trim() ?? '';
|
final name = _coParent?.fullName.trim() ?? '';
|
||||||
if (name.isEmpty) return null;
|
return name.isEmpty ? null : name;
|
||||||
return 'Co-parent : $name';
|
}
|
||||||
|
|
||||||
|
Future<void> _openCoParent() async {
|
||||||
|
if (_saving) return;
|
||||||
|
final co = _coParent;
|
||||||
|
final id = (co?.id ?? '').trim();
|
||||||
|
if (co == null || id.isEmpty) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final parent = await UserService.getParent(id);
|
||||||
|
if (!mounted) return;
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AdminParentEditModal(
|
||||||
|
parent: parent,
|
||||||
|
onSaved: () async {
|
||||||
|
try {
|
||||||
|
final refreshed = await UserService.getParent(widget.parent.user.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _coParent = refreshed.coParent);
|
||||||
|
} catch (_) {}
|
||||||
|
widget.onSaved?.call();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget? _coParentSubtitle() {
|
||||||
|
final name = _coParentName();
|
||||||
|
if (name == null) return null;
|
||||||
|
|
||||||
|
return Wrap(
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Co-parent : ',
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.black54),
|
||||||
|
),
|
||||||
|
InkWell(
|
||||||
|
onTap: _saving ? null : _openCoParent,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 2),
|
||||||
|
child: Text(
|
||||||
|
name,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: ValidationModalTheme.primaryActionBackground,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
decorationColor: ValidationModalTheme.primaryActionBackground
|
||||||
|
.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
@ -220,8 +283,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('Détacher l\'enfant'),
|
title: const Text('Détacher l\'enfant'),
|
||||||
content: Text(
|
content: Text(
|
||||||
'Retirer ${child.fullName} de la fiche de ce parent ?\n'
|
'Retirer ${child.fullName} de la fiche de ce parent ?',
|
||||||
'(L\'enfant ne sera pas supprimé.)',
|
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
@ -258,41 +320,11 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _attachChild() async {
|
Future<void> _attachChild() async {
|
||||||
List<EnfantAdminModel> all;
|
|
||||||
try {
|
|
||||||
all = await UserService.getEnfants();
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final linkedIds = _children.map((c) => c.id).toSet();
|
|
||||||
final candidates = all.where((e) => !linkedIds.contains(e.id)).toList();
|
|
||||||
if (candidates.isEmpty) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Aucun enfant disponible à rattacher')),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final selected = await showDialog<EnfantAdminModel>(
|
final selected = await AdminSelectEnfantModal.show(
|
||||||
context: context,
|
context,
|
||||||
builder: (ctx) => SimpleDialog(
|
excludeIds: _children.map((c) => c.id).toSet(),
|
||||||
title: const Text('Rattacher un enfant'),
|
title: 'Rattacher un enfant',
|
||||||
children: candidates
|
|
||||||
.map(
|
|
||||||
(e) => SimpleDialogOption(
|
|
||||||
onPressed: () => Navigator.pop(ctx, e),
|
|
||||||
child: Text(e.fullName),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
if (selected == null || !mounted) return;
|
if (selected == null || !mounted) return;
|
||||||
|
|
||||||
@ -395,13 +427,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
|||||||
),
|
),
|
||||||
if (_coParentSubtitle() != null) ...[
|
if (_coParentSubtitle() != null) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
_coParentSubtitle()!,
|
||||||
_coParentSubtitle()!,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: Colors.black54,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
218
frontend/lib/widgets/admin/common/admin_select_am_modal.dart
Normal file
218
frontend/lib/widgets/admin/common/admin_select_am_modal.dart
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
|
||||||
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/utils/am_vigilance.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
||||||
|
|
||||||
|
List<String> _amSelectSubtitleLines(AssistanteMaternelleModel am) {
|
||||||
|
final lines = <String>[];
|
||||||
|
final zone = (am.residenceCity ?? '').trim();
|
||||||
|
if (zone.isNotEmpty) lines.add('Zone : $zone');
|
||||||
|
final agrement = (am.approvalNumber ?? '').trim();
|
||||||
|
if (agrement.isNotEmpty) lines.add('Agrément : $agrement');
|
||||||
|
final max = am.maxChildren;
|
||||||
|
final free = amExpectedPlacesAvailable(
|
||||||
|
maxChildren: max,
|
||||||
|
childrenCount: am.children.length,
|
||||||
|
) ??
|
||||||
|
am.placesAvailable;
|
||||||
|
if (free != null || max != null) {
|
||||||
|
lines.add('Places libres : ${free ?? '–'} / capa. ${max ?? '–'}');
|
||||||
|
}
|
||||||
|
lines.add('${am.children.length} enfant(s)');
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sélection d'une AM à rattacher (fiche enfant) — ticket #147.
|
||||||
|
/// S'appuie sur [AdminSelectListModal] (shell partagé avec #146).
|
||||||
|
class AdminSelectAmModal {
|
||||||
|
AdminSelectAmModal._();
|
||||||
|
|
||||||
|
static Future<AssistanteMaternelleModel?> show(
|
||||||
|
BuildContext context, {
|
||||||
|
Set<String> excludeIds = const {},
|
||||||
|
String title = 'Choisir une assistante maternelle',
|
||||||
|
}) {
|
||||||
|
return AdminSelectListModal.show<AssistanteMaternelleModel>(
|
||||||
|
context,
|
||||||
|
title: title,
|
||||||
|
searchHint: 'Rechercher par nom, prénom ou zone…',
|
||||||
|
emptyMessage: 'Aucune assistante maternelle disponible',
|
||||||
|
noResultsMessage: 'Aucune AM avec place libre pour cette recherche',
|
||||||
|
toggleFilter: const AdminSelectToggleFilter<AssistanteMaternelleModel>(
|
||||||
|
label: 'Libre',
|
||||||
|
initialValue: true,
|
||||||
|
whenEnabled: amHasFreePlace,
|
||||||
|
),
|
||||||
|
loadItems: () async {
|
||||||
|
final list = await UserService.getAssistantesMaternelles();
|
||||||
|
return list
|
||||||
|
.where((am) => !excludeIds.contains(am.user.id))
|
||||||
|
.toList()
|
||||||
|
..sort(
|
||||||
|
(a, b) => a.user.fullName
|
||||||
|
.toLowerCase()
|
||||||
|
.compareTo(b.user.fullName.toLowerCase()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
matchesQuery: (am, q) {
|
||||||
|
final u = am.user;
|
||||||
|
final name = u.fullName.toLowerCase();
|
||||||
|
final fn = (u.prenom ?? '').toLowerCase();
|
||||||
|
final ln = (u.nom ?? '').toLowerCase();
|
||||||
|
final zone = (am.residenceCity ?? '').toLowerCase();
|
||||||
|
final agrement = (am.approvalNumber ?? '').toLowerCase();
|
||||||
|
return name.contains(q) ||
|
||||||
|
fn.contains(q) ||
|
||||||
|
ln.contains(q) ||
|
||||||
|
zone.contains(q) ||
|
||||||
|
agrement.contains(q);
|
||||||
|
},
|
||||||
|
resolveSelect: (ctx, am, reload) =>
|
||||||
|
_resolveAmSelection(ctx, am, reload),
|
||||||
|
itemBuilder: (context, am, onSelect) {
|
||||||
|
final full = !amHasFreePlace(am);
|
||||||
|
return AdminUserCard(
|
||||||
|
title: am.user.fullName,
|
||||||
|
avatarUrl: am.user.photoUrl,
|
||||||
|
fallbackIcon: Icons.face,
|
||||||
|
subtitleLines: _amSelectSubtitleLines(am),
|
||||||
|
vigilanceTooltip: amPlacesVigilanceMessage(am),
|
||||||
|
onCardTap: onSelect,
|
||||||
|
margin: const EdgeInsets.only(bottom: 4),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 5,
|
||||||
|
),
|
||||||
|
backgroundColor: full ? const Color(0xFFFFEBEE) : null,
|
||||||
|
borderColor: full ? Colors.red.shade200 : null,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add_link),
|
||||||
|
tooltip: 'Rattacher',
|
||||||
|
onPressed: onSelect,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<AssistanteMaternelleModel?> _resolveAmSelection(
|
||||||
|
BuildContext context,
|
||||||
|
AssistanteMaternelleModel am,
|
||||||
|
Future<void> Function() reloadList,
|
||||||
|
) async {
|
||||||
|
if (amHasFreePlace(am)) return am;
|
||||||
|
|
||||||
|
final selected = await showDialog<AssistanteMaternelleModel>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => _AmNoPlaceWarningDialog(initialAm: am),
|
||||||
|
);
|
||||||
|
await reloadList();
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Avertissement AM saturée + lien vers la fiche pour ajuster les places.
|
||||||
|
class _AmNoPlaceWarningDialog extends StatefulWidget {
|
||||||
|
final AssistanteMaternelleModel initialAm;
|
||||||
|
|
||||||
|
const _AmNoPlaceWarningDialog({required this.initialAm});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AmNoPlaceWarningDialog> createState() =>
|
||||||
|
_AmNoPlaceWarningDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AmNoPlaceWarningDialogState extends State<_AmNoPlaceWarningDialog> {
|
||||||
|
late AssistanteMaternelleModel _am;
|
||||||
|
TapGestureRecognizer? _linkRecognizer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_am = widget.initialAm;
|
||||||
|
_linkRecognizer = TapGestureRecognizer()..onTap = _openAmFiche;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_linkRecognizer?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openAmFiche() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AdminAmEditModal(
|
||||||
|
assistante: _am,
|
||||||
|
onSaved: () async {
|
||||||
|
try {
|
||||||
|
final fresh =
|
||||||
|
await UserService.getAssistanteMaternelle(_am.user.id);
|
||||||
|
if (mounted) setState(() => _am = fresh);
|
||||||
|
} catch (_) {}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final fresh = await UserService.getAssistanteMaternelle(_am.user.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _am = fresh);
|
||||||
|
if (amHasFreePlace(fresh)) {
|
||||||
|
Navigator.of(context).pop(fresh);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final name = _am.user.fullName.trim().isNotEmpty
|
||||||
|
? _am.user.fullName.trim()
|
||||||
|
: 'Cette assistante maternelle';
|
||||||
|
const linkColor = ValidationModalTheme.primaryActionBackground;
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Plus de place disponible'),
|
||||||
|
content: Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
style: const TextStyle(fontSize: 14, color: Colors.black87, height: 1.4),
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: '$name n\'a plus de place libre pour accueillir '
|
||||||
|
'un enfant supplémentaire.\n\n',
|
||||||
|
),
|
||||||
|
const TextSpan(text: 'Vous pouvez '),
|
||||||
|
TextSpan(
|
||||||
|
text: 'ouvrir sa fiche',
|
||||||
|
style: TextStyle(
|
||||||
|
color: linkColor,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
recognizer: _linkRecognizer,
|
||||||
|
),
|
||||||
|
const TextSpan(
|
||||||
|
text: ' pour modifier la capacité ou les places, '
|
||||||
|
'puis la sélectionner si une place se libère.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Fermer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||||
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
|
||||||
|
|
||||||
|
/// Sélection d'un enfant à rattacher (fiche AM / fiche parent) — ticket #146.
|
||||||
|
/// S'appuie sur [AdminSelectListModal] (shell partagé avec #147).
|
||||||
|
class AdminSelectEnfantModal {
|
||||||
|
AdminSelectEnfantModal._();
|
||||||
|
|
||||||
|
static Future<EnfantAdminModel?> show(
|
||||||
|
BuildContext context, {
|
||||||
|
Set<String> excludeIds = const {},
|
||||||
|
String title = 'Rattacher un enfant',
|
||||||
|
/// Affiché uniquement depuis la fiche AM : filtre les enfants déjà en garde.
|
||||||
|
bool showSansGardeFilter = false,
|
||||||
|
}) {
|
||||||
|
return AdminSelectListModal.show<EnfantAdminModel>(
|
||||||
|
context,
|
||||||
|
title: title,
|
||||||
|
searchHint: 'Rechercher par nom ou prénom…',
|
||||||
|
emptyMessage: 'Aucun enfant disponible à rattacher',
|
||||||
|
noResultsMessage: showSansGardeFilter
|
||||||
|
? 'Aucun enfant sans garde pour cette recherche'
|
||||||
|
: 'Aucun résultat pour cette recherche',
|
||||||
|
toggleFilter: showSansGardeFilter
|
||||||
|
? AdminSelectToggleFilter<EnfantAdminModel>(
|
||||||
|
label: 'Sans garde',
|
||||||
|
initialValue: true,
|
||||||
|
whenEnabled: (e) =>
|
||||||
|
normalizeEnfantStatus(e.status) == 'sans_garde',
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
loadItems: () async {
|
||||||
|
final list = await UserService.getEnfants();
|
||||||
|
return list
|
||||||
|
.where((e) => !excludeIds.contains(e.id))
|
||||||
|
.toList()
|
||||||
|
..sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.fullName.toLowerCase().compareTo(b.fullName.toLowerCase()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
matchesQuery: (e, q) {
|
||||||
|
final name = e.fullName.toLowerCase();
|
||||||
|
final fn = (e.firstName ?? '').toLowerCase();
|
||||||
|
final ln = (e.lastName ?? '').toLowerCase();
|
||||||
|
return name.contains(q) || fn.contains(q) || ln.contains(q);
|
||||||
|
},
|
||||||
|
itemBuilder: (context, e, onSelect) {
|
||||||
|
return AdminEnfantUserCard.fromEnfant(
|
||||||
|
e,
|
||||||
|
onCardTap: onSelect,
|
||||||
|
margin: const EdgeInsets.only(bottom: 4),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 5,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add_link),
|
||||||
|
tooltip: 'Rattacher',
|
||||||
|
onPressed: onSelect,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,139 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/models/parent_model.dart';
|
||||||
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||||
|
|
||||||
|
/// Foyer / famille sélectionnable pour rattacher un nouvel enfant (#132 / #157).
|
||||||
|
class AdminFamilleFoyer {
|
||||||
|
/// Parent pivot pour `POST /enfants` (`parent_user_id`).
|
||||||
|
final String pivotParentUserId;
|
||||||
|
/// Co-parent éventuel (rattachement foyer #157).
|
||||||
|
final String? coParentUserId;
|
||||||
|
final String? numeroDossier;
|
||||||
|
final String displayTitle;
|
||||||
|
final List<String> parentNames;
|
||||||
|
|
||||||
|
const AdminFamilleFoyer({
|
||||||
|
required this.pivotParentUserId,
|
||||||
|
required this.displayTitle,
|
||||||
|
required this.parentNames,
|
||||||
|
this.coParentUserId,
|
||||||
|
this.numeroDossier,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Parents du foyer à lier à 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
367
frontend/lib/widgets/admin/common/admin_select_list_modal.dart
Normal file
367
frontend/lib/widgets/admin/common/admin_select_list_modal.dart
Normal file
@ -0,0 +1,367 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
||||||
|
|
||||||
|
/// Filtre optionnel (switch) sur la même ligne que la barre de recherche.
|
||||||
|
class AdminSelectToggleFilter<T> {
|
||||||
|
final String label;
|
||||||
|
final bool initialValue;
|
||||||
|
|
||||||
|
/// Si le switch est activé, ne garde que les éléments pour lesquels
|
||||||
|
/// [whenEnabled] renvoie `true`.
|
||||||
|
final bool Function(T item) whenEnabled;
|
||||||
|
|
||||||
|
const AdminSelectToggleFilter({
|
||||||
|
required this.label,
|
||||||
|
required this.whenEnabled,
|
||||||
|
this.initialValue = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shell générique « rechercher + liste + sélection » pour les modales admin.
|
||||||
|
/// Utilisé par la sélection d'enfant (#146) et la sélection d'AM (#147).
|
||||||
|
class AdminSelectListModal<T> extends StatefulWidget {
|
||||||
|
final String title;
|
||||||
|
final String searchHint;
|
||||||
|
final Future<List<T>> Function() loadItems;
|
||||||
|
final bool Function(T item, String query) matchesQuery;
|
||||||
|
final Widget Function(
|
||||||
|
BuildContext context,
|
||||||
|
T item,
|
||||||
|
VoidCallback onSelect,
|
||||||
|
) itemBuilder;
|
||||||
|
final String emptyMessage;
|
||||||
|
final String noResultsMessage;
|
||||||
|
final double modalWidth;
|
||||||
|
|
||||||
|
/// Hauteur d'une carte (pour dimensionner la liste à ≥ [minVisibleCards]).
|
||||||
|
final double cardExtent;
|
||||||
|
|
||||||
|
/// Nombre minimum de cartes visibles dans la zone scrollable.
|
||||||
|
final int minVisibleCards;
|
||||||
|
|
||||||
|
/// Switch optionnel à droite du champ de recherche (ex. « Sans garde », « Libre »).
|
||||||
|
final AdminSelectToggleFilter<T>? toggleFilter;
|
||||||
|
|
||||||
|
/// Si fourni, appelé avant de valider la sélection.
|
||||||
|
/// Retourne l'élément à pop (éventuellement rafraîchi), ou `null` pour annuler.
|
||||||
|
final Future<T?> Function(
|
||||||
|
BuildContext context,
|
||||||
|
T item,
|
||||||
|
Future<void> Function() reload,
|
||||||
|
)? resolveSelect;
|
||||||
|
|
||||||
|
const AdminSelectListModal({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.loadItems,
|
||||||
|
required this.matchesQuery,
|
||||||
|
required this.itemBuilder,
|
||||||
|
this.searchHint = 'Rechercher…',
|
||||||
|
this.emptyMessage = 'Aucun élément disponible',
|
||||||
|
this.noResultsMessage = 'Aucun résultat pour cette recherche',
|
||||||
|
this.modalWidth = 930,
|
||||||
|
this.cardExtent = 52,
|
||||||
|
this.minVisibleCards = 8,
|
||||||
|
this.toggleFilter,
|
||||||
|
this.resolveSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
static Future<T?> show<T>(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required Future<List<T>> Function() loadItems,
|
||||||
|
required bool Function(T item, String query) matchesQuery,
|
||||||
|
required Widget Function(
|
||||||
|
BuildContext context,
|
||||||
|
T item,
|
||||||
|
VoidCallback onSelect,
|
||||||
|
) itemBuilder,
|
||||||
|
String searchHint = 'Rechercher…',
|
||||||
|
String emptyMessage = 'Aucun élément disponible',
|
||||||
|
String noResultsMessage = 'Aucun résultat pour cette recherche',
|
||||||
|
double modalWidth = 930,
|
||||||
|
double cardExtent = 52,
|
||||||
|
int minVisibleCards = 8,
|
||||||
|
AdminSelectToggleFilter<T>? toggleFilter,
|
||||||
|
Future<T?> Function(
|
||||||
|
BuildContext context,
|
||||||
|
T item,
|
||||||
|
Future<void> Function() reload,
|
||||||
|
)? resolveSelect,
|
||||||
|
}) {
|
||||||
|
return showDialog<T>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AdminSelectListModal<T>(
|
||||||
|
title: title,
|
||||||
|
loadItems: loadItems,
|
||||||
|
matchesQuery: matchesQuery,
|
||||||
|
itemBuilder: itemBuilder,
|
||||||
|
searchHint: searchHint,
|
||||||
|
emptyMessage: emptyMessage,
|
||||||
|
noResultsMessage: noResultsMessage,
|
||||||
|
modalWidth: modalWidth,
|
||||||
|
cardExtent: cardExtent,
|
||||||
|
minVisibleCards: minVisibleCards,
|
||||||
|
toggleFilter: toggleFilter,
|
||||||
|
resolveSelect: resolveSelect,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AdminSelectListModal<T>> createState() =>
|
||||||
|
_AdminSelectListModalState<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminSelectListModalState<T> extends State<AdminSelectListModal<T>> {
|
||||||
|
final _searchCtrl = TextEditingController();
|
||||||
|
List<T> _all = [];
|
||||||
|
bool _loading = true;
|
||||||
|
String? _error;
|
||||||
|
late bool _toggleOn;
|
||||||
|
bool _resolving = false;
|
||||||
|
|
||||||
|
double get _listHeight =>
|
||||||
|
widget.cardExtent * widget.minVisibleCards;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_toggleOn = widget.toggleFilter?.initialValue ?? false;
|
||||||
|
_searchCtrl.addListener(() => setState(() {}));
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final list = await widget.loadItems();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_all = list;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_loading = false;
|
||||||
|
_error = e.toString().replaceFirst('Exception: ', '');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleSelect(T item) async {
|
||||||
|
if (_resolving) return;
|
||||||
|
final resolve = widget.resolveSelect;
|
||||||
|
if (resolve == null) {
|
||||||
|
Navigator.of(context).pop(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _resolving = true);
|
||||||
|
try {
|
||||||
|
final chosen = await resolve(context, item, _load);
|
||||||
|
if (!mounted || chosen == null) return;
|
||||||
|
Navigator.of(context).pop(chosen);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _resolving = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<T> get _filtered {
|
||||||
|
var list = _all;
|
||||||
|
final toggle = widget.toggleFilter;
|
||||||
|
if (toggle != null && _toggleOn) {
|
||||||
|
list = list.where(toggle.whenEnabled).toList();
|
||||||
|
}
|
||||||
|
final q = _searchCtrl.text.trim().toLowerCase();
|
||||||
|
if (q.isEmpty) return list;
|
||||||
|
return list.where((item) => widget.matchesQuery(item, q)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final toggle = widget.toggleFilter;
|
||||||
|
|
||||||
|
return Dialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxWidth: widget.modalWidth),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 16, 4, 0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
widget.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
tooltip: 'Fermer',
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchCtrl,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
hintText: widget.searchHint,
|
||||||
|
prefixIcon: const Icon(Icons.search, size: 20),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (toggle != null) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
toggle.label,
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Switch(
|
||||||
|
value: _toggleOn,
|
||||||
|
activeColor:
|
||||||
|
ValidationModalTheme.primaryActionBackground,
|
||||||
|
onChanged: (v) => setState(() => _toggleOn = v),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
height: _listHeight,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: _buildBody(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Spacer(),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody() {
|
||||||
|
if (_loading) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: ValidationModalTheme.primaryActionBackground,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_error != null) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, size: 40, color: Colors.red.shade400),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
_error!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.red.shade700),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _load,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Réessayer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final items = _filtered;
|
||||||
|
if (_all.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
widget.emptyMessage,
|
||||||
|
style: const TextStyle(fontSize: 14, color: Colors.black54),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
widget.noResultsMessage,
|
||||||
|
style: const TextStyle(fontSize: 14, color: Colors.black54),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
|
||||||
|
itemExtent: widget.cardExtent,
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final item = items[i];
|
||||||
|
return widget.itemBuilder(
|
||||||
|
context,
|
||||||
|
item,
|
||||||
|
() => _handleSelect(item),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,6 +14,8 @@ class AdminUserCard extends StatefulWidget {
|
|||||||
final Color? infoColor;
|
final Color? infoColor;
|
||||||
final String? vigilanceTooltip;
|
final String? vigilanceTooltip;
|
||||||
final VoidCallback? onCardTap;
|
final VoidCallback? onCardTap;
|
||||||
|
final EdgeInsetsGeometry? margin;
|
||||||
|
final EdgeInsetsGeometry? contentPadding;
|
||||||
|
|
||||||
const AdminUserCard({
|
const AdminUserCard({
|
||||||
super.key,
|
super.key,
|
||||||
@ -28,6 +30,8 @@ class AdminUserCard extends StatefulWidget {
|
|||||||
this.infoColor,
|
this.infoColor,
|
||||||
this.vigilanceTooltip,
|
this.vigilanceTooltip,
|
||||||
this.onCardTap,
|
this.onCardTap,
|
||||||
|
this.margin,
|
||||||
|
this.contentPadding,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -59,7 +63,7 @@ class _AdminUserCardState extends State<AdminUserCard> {
|
|||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
hoverColor: const Color(0x149CC5C0),
|
hoverColor: const Color(0x149CC5C0),
|
||||||
child: Card(
|
child: Card(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: widget.margin ?? const EdgeInsets.only(bottom: 12),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: widget.backgroundColor,
|
color: widget.backgroundColor,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@ -67,7 +71,8 @@ class _AdminUserCardState extends State<AdminUserCard> {
|
|||||||
side: BorderSide(color: widget.borderColor ?? Colors.grey.shade300),
|
side: BorderSide(color: widget.borderColor ?? Colors.grey.shade300),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
padding: widget.contentPadding ??
|
||||||
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
_buildAvatar(avatarUrl),
|
_buildAvatar(avatarUrl),
|
||||||
@ -86,7 +91,10 @@ class _AdminUserCardState extends State<AdminUserCard> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
// flex: 0 → largeur du nom ; le reste va aux infos
|
||||||
|
// (évite le partage 50/50 qui tronque « Responsables »).
|
||||||
Flexible(
|
Flexible(
|
||||||
|
flex: 0,
|
||||||
fit: FlexFit.loose,
|
fit: FlexFit.loose,
|
||||||
child: Text(
|
child: Text(
|
||||||
widget.title.isNotEmpty ? widget.title : 'Sans nom',
|
widget.title.isNotEmpty ? widget.title : 'Sans nom',
|
||||||
|
|||||||
@ -72,7 +72,14 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
|
|||||||
normalizeEnfantStatus(e.status) ==
|
normalizeEnfantStatus(e.status) ==
|
||||||
normalizeEnfantStatus(widget.statusFilter);
|
normalizeEnfantStatus(widget.statusFilter);
|
||||||
return matchesName && matchesStatus;
|
return matchesName && matchesStatus;
|
||||||
}).toList();
|
}).toList()
|
||||||
|
..sort((a, b) {
|
||||||
|
// Orphelins (#157) en tête, puis ordre alphabétique.
|
||||||
|
final ao = a.hasNoResponsable ? 0 : 1;
|
||||||
|
final bo = b.hasNoResponsable ? 0 : 1;
|
||||||
|
if (ao != bo) return ao.compareTo(bo);
|
||||||
|
return a.fullName.toLowerCase().compareTo(b.fullName.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
return UserList(
|
return UserList(
|
||||||
isLoading: _isLoading,
|
isLoading: _isLoading,
|
||||||
|
|||||||
@ -80,7 +80,7 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
|||||||
onCardTap: () => _openParentDetails(parent),
|
onCardTap: () => _openParentDetails(parent),
|
||||||
subtitleLines: [
|
subtitleLines: [
|
||||||
parent.user.email,
|
parent.user.email,
|
||||||
'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.children.isNotEmpty ? parent.children.length : parent.childrenCount}',
|
'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${ParentModel.foyerChildrenCount(parent, _parents)}',
|
||||||
],
|
],
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create
|
|||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
|
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
|
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
|
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart';
|
import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
|
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
|
||||||
@ -26,6 +27,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
int _subIndex = 0;
|
int _subIndex = 0;
|
||||||
int _gestionnaireRefreshTick = 0;
|
int _gestionnaireRefreshTick = 0;
|
||||||
int _adminRefreshTick = 0;
|
int _adminRefreshTick = 0;
|
||||||
|
int _enfantRefreshTick = 0;
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
final TextEditingController _amCapacityController = TextEditingController();
|
final TextEditingController _amCapacityController = TextEditingController();
|
||||||
String? _parentStatus;
|
String? _parentStatus;
|
||||||
@ -215,7 +217,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
value: 'garde',
|
value: 'garde',
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.only(left: 10),
|
padding: EdgeInsets.only(left: 10),
|
||||||
child: Text('En garde', style: TextStyle(fontSize: 12)),
|
child: Text('Gardé', style: TextStyle(fontSize: 12)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
DropdownMenuItem<String?>(
|
DropdownMenuItem<String?>(
|
||||||
@ -264,6 +266,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
);
|
);
|
||||||
case 1:
|
case 1:
|
||||||
return EnfantManagementWidget(
|
return EnfantManagementWidget(
|
||||||
|
key: ValueKey('enfants-$_enfantRefreshTick'),
|
||||||
searchQuery: _searchController.text,
|
searchQuery: _searchController.text,
|
||||||
statusFilter: _enfantStatus,
|
statusFilter: _enfantStatus,
|
||||||
);
|
);
|
||||||
@ -311,6 +314,23 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
|
|
||||||
Future<void> _handleAddPressed() async {
|
Future<void> _handleAddPressed() async {
|
||||||
final contentIndex = _subIndex - _contentIndexOffset;
|
final contentIndex = _subIndex - _contentIndexOffset;
|
||||||
|
|
||||||
|
if (contentIndex == 1) {
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return AdminChildDetailModal.create(
|
||||||
|
onSaved: () {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _enfantRefreshTick++);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (contentIndex == 3) {
|
if (contentIndex == 3) {
|
||||||
final created = await showDialog<bool>(
|
final created = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@ -206,7 +206,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.2"
|
version: "1.2.2"
|
||||||
http_parser:
|
http_parser:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http_parser
|
name: http_parser
|
||||||
sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
|
sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
|
||||||
|
|||||||
@ -21,6 +21,7 @@ dependencies:
|
|||||||
js: ^0.6.7
|
js: ^0.6.7
|
||||||
url_launcher: ^6.2.4
|
url_launcher: ^6.2.4
|
||||||
http: ^1.2.2
|
http: ^1.2.2
|
||||||
|
http_parser: ^4.0.2
|
||||||
# flutter_secure_storage: ^9.0.0
|
# flutter_secure_storage: ^9.0.0
|
||||||
pdfx: ^2.5.0
|
pdfx: ^2.5.0
|
||||||
universal_platform: ^1.1.0
|
universal_platform: ^1.1.0
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user