Compare commits

...

6 Commits

Author SHA1 Message Date
7a3d997ff9 fix(#132): UX création enfant — champs, genre et validation.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 18:51:00 +02:00
6b89d7b405 fix(#132): envoyer birth_date à la création (enfant né).
Le if imbriqué n’incluait birth_date que pour le cas à naître.

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 18:00:41 +02:00
12 changed files with 1177 additions and 118 deletions

View File

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

View File

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

View File

@ -13,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,37 +68,84 @@ 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(
this.parentsChildrenRepository.create({
parentId: parent.user_id, parentId: parent.user_id,
enfantId: child.id, enfantId: child.id,
}); }),
await this.parentsChildrenRepository.save(parentLink); );
// Rattachement automatique au co-parent s'il existe // Rattachement automatique au co-parent s'il existe
if (parent.co_parent) { if (parent.co_parent) {
const coParentLink = this.parentsChildrenRepository.create({ await this.parentsChildrenRepository.save(
this.parentsChildrenRepository.create({
parentId: parent.co_parent.id, parentId: parent.co_parent.id,
enfantId: child.id, enfantId: child.id,
}); }),
await this.parentsChildrenRepository.save(coParentLink); );
} }
return this.findOne(child.id, currentUser); return this.findOne(child.id, currentUser);
} }
private resolvePivotParentUserId(
dto: CreateEnfantsDto,
currentUser: Users,
): string {
if (this.isStaff(currentUser)) {
const id = dto.parent_user_id?.trim();
if (!id) {
throw new BadRequestException(
'parent_user_id est obligatoire pour créer un enfant (staff)',
);
}
return id;
}
if (currentUser.role === RoleType.PARENT) {
if (
dto.parent_user_id &&
dto.parent_user_id.trim() !== currentUser.id
) {
throw new ForbiddenException(
'Un parent ne peut pas créer un enfant pour un autre compte',
);
}
return currentUser.id;
}
throw new ForbiddenException('Accès interdit');
}
// Liste des enfants (admin/gestionnaire) // Liste des enfants (admin/gestionnaire)
async findAll(): Promise<Children[]> { async findAll(): Promise<Children[]> {
return this.childrenRepository.find({ return this.childrenRepository.find({
@ -90,7 +158,7 @@ export class EnfantsService {
async findOne(id: string, currentUser: Users): Promise<Children> { async findOne(id: string, currentUser: Users): Promise<Children> {
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');
@ -120,7 +188,8 @@ export class EnfantsService {
const child = await this.childrenRepository.findOne({ where: { id } }); const child = await this.childrenRepository.findOne({ where: { id } });
if (!child) throw new NotFoundException('Enfant introuvable'); if (!child) throw new NotFoundException('Enfant introuvable');
const patch: Partial<Children> = { ...dto } as Partial<Children>; const { parent_user_id: _ignored, ...rest } = dto;
const patch: Partial<Children> = { ...rest } as Partial<Children>;
if (dto.consent_photo !== undefined) { if (dto.consent_photo !== undefined) {
patch.consent_photo = dto.consent_photo; patch.consent_photo = dto.consent_photo;
patch.consent_photo_at = dto.consent_photo ? new Date() : null!; patch.consent_photo_at = dto.consent_photo ? new Date() : null!;

View File

@ -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';
@ -57,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;
@ -515,6 +543,68 @@ class UserService {
return enrichEnfantParentNames(enfant); return enrichEnfantParentNames(enfant);
} }
/// Création enfant côté staff (#132) nécessite `POST /enfants` étendu (voir mini-spec).
/// [parentUserId] : parent pivot du foyer (`parent_user_id`).
/// Avec [photoBytes] : `multipart/form-data` (champ fichier `photo`), sinon JSON.
static Future<EnfantAdminModel> createEnfant({
required String parentUserId,
required Map<String, dynamic> body,
List<int>? photoBytes,
String? photoFilename,
}) async {
final hasPhoto = photoBytes != null && photoBytes.isNotEmpty;
final http.Response response;
if (hasPhoto) {
final token = await TokenService.getToken();
final req = http.MultipartRequest(
'POST',
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
);
req.headers['Accept'] = 'application/json';
if (token != null) {
req.headers['Authorization'] = 'Bearer $token';
}
body.forEach((key, value) {
if (value == null) return;
req.fields[key] = value is bool
? (value ? 'true' : 'false')
: value.toString();
});
req.fields['parent_user_id'] = parentUserId;
final name = (photoFilename ?? '').trim();
final filename = name.isNotEmpty ? name : 'photo.jpg';
req.files.add(
http.MultipartFile.fromBytes(
'photo',
photoBytes,
filename: filename,
contentType: _imageMediaType(filename, photoBytes),
),
);
final streamed = await req.send();
response = await http.Response.fromStream(streamed);
} else {
final payload = <String, dynamic>{
...body,
'parent_user_id': parentUserId,
};
response = await http.post(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'),
headers: await _headers(),
body: jsonEncode(payload),
);
}
if (response.statusCode != 200 && response.statusCode != 201) {
throw Exception(
_extractErrorMessage(response.body, 'Erreur création enfant'),
);
}
final enfant = EnfantAdminModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
return enrichEnfantParentNames(enfant);
}
static Future<void> deleteEnfant(String enfantId) async { static Future<void> deleteEnfant(String enfantId) async {
final response = await http.delete( final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'), Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'),

View File

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

View File

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

View File

@ -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,23 +76,82 @@ class AdminAmPhotoFrame extends StatelessWidget {
), ),
), ),
); );
if (onTap != null) {
frame = MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: frame,
),
);
}
if (!showClear) return frame;
return Stack(
clipBehavior: Clip.none,
children: [
frame,
Positioned(
top: 0,
right: 0,
child: Material(
color: Colors.transparent,
child: IconButton(
tooltip: 'Retirer la photo',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
icon: Icon(
Icons.cancel,
size: 22,
color: Colors.grey.shade700,
),
onPressed: onClear,
),
),
),
],
);
}, },
); );
} }
Widget _photoContent(String fullUrl, double pw, double ph) { 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),
child: Text(
emptyLabel,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600, fontSize: 11), 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,
),
), ),
); );
} }

View File

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

View File

@ -0,0 +1,127 @@
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).
class AdminFamilleFoyer {
/// Parent pivot pour `POST /enfants` (`parent_user_id`).
final String pivotParentUserId;
final String? numeroDossier;
final String displayTitle;
final List<String> parentNames;
const AdminFamilleFoyer({
required this.pivotParentUserId,
required this.displayTitle,
required this.parentNames,
this.numeroDossier,
});
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,
numeroDossier: dossier.isNotEmpty ? dossier : null,
displayTitle: title,
parentNames: names,
),
);
}
foyers.sort(
(a, b) => a.displayTitle.toLowerCase().compareTo(b.displayTitle.toLowerCase()),
);
return foyers;
}
/// Sélection d'une famille / dossier pour créer un enfant — ticket #132.
class AdminSelectFamilleModal {
AdminSelectFamilleModal._();
static Future<AdminFamilleFoyer?> show(
BuildContext context, {
String title = 'Choisir une famille',
}) {
return AdminSelectListModal.show<AdminFamilleFoyer>(
context,
title: title,
searchHint: 'Rechercher par dossier, nom…',
emptyMessage: 'Aucune famille disponible',
noResultsMessage: 'Aucun résultat pour cette recherche',
loadItems: () async {
final parents = await UserService.getParents();
return buildFamilleFoyers(parents);
},
matchesQuery: (f, q) {
final dossier = (f.numeroDossier ?? '').toLowerCase();
final title = f.displayTitle.toLowerCase();
final names = f.parentNames.join(' ').toLowerCase();
return dossier.contains(q) || title.contains(q) || names.contains(q);
},
itemBuilder: (context, f, onSelect) {
return AdminUserCard(
title: f.displayTitle,
fallbackIcon: Icons.family_restroom,
subtitleLines: [
if (f.parentNames.isNotEmpty) f.parentNames.join(', '),
if ((f.numeroDossier ?? '').isNotEmpty)
'Dossier ${f.numeroDossier}',
],
onCardTap: onSelect,
margin: const EdgeInsets.only(bottom: 4),
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
actions: [
IconButton(
icon: const Icon(Icons.check_circle_outline),
tooltip: 'Choisir',
onPressed: onSelect,
),
],
);
},
);
}
}

View File

@ -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;
@ -264,6 +266,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
); );
case 1: case 1:
return EnfantManagementWidget( return EnfantManagementWidget(
key: ValueKey('enfants-$_enfantRefreshTick'),
searchQuery: _searchController.text, searchQuery: _searchController.text,
statusFilter: _enfantStatus, statusFilter: _enfantStatus,
); );
@ -311,6 +314,23 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
Future<void> _handleAddPressed() async { Future<void> _handleAddPressed() async {
final contentIndex = _subIndex - _contentIndexOffset; final contentIndex = _subIndex - _contentIndexOffset;
if (contentIndex == 1) {
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return AdminChildDetailModal.create(
onSaved: () {
if (!mounted) return;
setState(() => _enfantRefreshTick++);
},
);
},
);
return;
}
if (contentIndex == 3) { if (contentIndex == 3) {
final created = await showDialog<bool>( final created = await showDialog<bool>(
context: context, context: context,

View File

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

View File

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