diff --git a/backend/src/routes/enfants/dto/create_enfants.dto.ts b/backend/src/routes/enfants/dto/create_enfants.dto.ts index 7d69067..0d66d46 100644 --- a/backend/src/routes/enfants/dto/create_enfants.dto.ts +++ b/backend/src/routes/enfants/dto/create_enfants.dto.ts @@ -1,4 +1,5 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; import { IsBoolean, IsDateString, @@ -6,11 +7,23 @@ import { IsNotEmpty, IsOptional, IsString, + IsUUID, MaxLength, ValidateIf, } from 'class-validator'; import { GenreType, StatutEnfantType } from 'src/entities/children.entity'; +/** Multipart envoie des strings ("true"/"false") — JSON envoie déjà des booleans. */ +function toBoolean({ value }: { value: unknown }): boolean | unknown { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + const v = value.trim().toLowerCase(); + if (v === 'true' || v === '1') return true; + if (v === 'false' || v === '0' || v === '') return false; + } + return value; +} + export class CreateEnfantsDto { @ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE }) @IsEnum(StatutEnfantType) @@ -52,6 +65,7 @@ export class CreateEnfantsDto { photo_url?: string; @ApiProperty({ default: false }) + @Transform(toBoolean) @IsBoolean() consent_photo: boolean; @@ -61,6 +75,20 @@ export class CreateEnfantsDto { consent_photo_at?: string; @ApiProperty({ default: false }) + @Transform(toBoolean) @IsBoolean() is_multiple: boolean; + + /** + * Parent pivot du foyer — obligatoire pour staff (gestionnaire/admin). + * Ignoré / interdit en externe pour un PARENT (ticket #132). + */ + @ApiPropertyOptional({ + description: + 'UUID du parent pivot (staff only). Obligatoire pour GESTIONNAIRE / ADMIN / SUPER_ADMIN.', + format: 'uuid', + }) + @IsOptional() + @IsUUID('4') + parent_user_id?: string; } diff --git a/backend/src/routes/enfants/enfants.controller.ts b/backend/src/routes/enfants/enfants.controller.ts index 2579a45..492c885 100644 --- a/backend/src/routes/enfants/enfants.controller.ts +++ b/backend/src/routes/enfants/enfants.controller.ts @@ -1,20 +1,33 @@ import { Body, + CallHandler, Controller, Delete, + ExecutionContext, Get, + HttpCode, + HttpStatus, + Injectable, + NestInterceptor, Param, ParseUUIDPipe, Patch, Post, + UploadedFile, UseGuards, UseInterceptors, - UploadedFile, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; -import { ApiBearerAuth, ApiTags, ApiConsumes } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; import { diskStorage } from 'multer'; import { extname } from 'path'; +import { Observable } from 'rxjs'; import { EnfantsService } from './enfants.service'; import { CreateEnfantsDto } from './dto/create_enfants.dto'; import { UpdateEnfantsDto } from './dto/update_enfants.dto'; @@ -24,6 +37,47 @@ import { AuthGuard } from 'src/common/guards/auth.guard'; import { Roles } from 'src/common/decorators/roles.decorator'; 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 | Promise> { + 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) @@ -31,30 +85,27 @@ import { RolesGuard } from 'src/common/guards/roles.guard'; export class EnfantsController { constructor(private readonly enfantsService: EnfantsService) { } - @Roles(RoleType.PARENT) - @Post() - @ApiConsumes('multipart/form-data') - @UseInterceptors( - FileInterceptor('photo', { - 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, - }, - }), + @Roles( + RoleType.PARENT, + RoleType.GESTIONNAIRE, + RoleType.ADMINISTRATEUR, + RoleType.SUPER_ADMIN, ) + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'Créer un enfant', + description: + 'PARENT : multipart éventuel, rattache au compte connecté. ' + + 'Staff : parent_user_id obligatoire ; JSON sans photo OK ; avec photo → multipart (champ fichier `photo`, max 5 Mo). Ticket #132.', + }) + @ApiConsumes('application/json', 'multipart/form-data') + @ApiBody({ + description: + 'Champs métier (+ parent_user_id côté staff). Fichier optionnel `photo` en multipart.', + type: CreateEnfantsDto, + }) + @UseInterceptors(OptionalEnfantPhotoInterceptor) create( @Body() dto: CreateEnfantsDto, @UploadedFile() photo: Express.Multer.File, diff --git a/backend/src/routes/enfants/enfants.service.ts b/backend/src/routes/enfants/enfants.service.ts index 7f4552c..bd77d68 100644 --- a/backend/src/routes/enfants/enfants.service.ts +++ b/backend/src/routes/enfants/enfants.service.ts @@ -13,6 +13,12 @@ import { ParentsChildren } from 'src/entities/parents_children.entity'; import { RoleType, Users } from 'src/entities/users.entity'; import { CreateEnfantsDto } from './dto/create_enfants.dto'; +const STAFF_ROLES: RoleType[] = [ + RoleType.GESTIONNAIRE, + RoleType.ADMINISTRATEUR, + RoleType.SUPER_ADMIN, +]; + @Injectable() export class EnfantsService { constructor( @@ -24,20 +30,35 @@ export class EnfantsService { private readonly parentsChildrenRepository: Repository, ) { } - // Création d'un enfant - async create(dto: CreateEnfantsDto, currentUser: Users, photoFile?: Express.Multer.File): Promise { + private isStaff(user: Users): boolean { + return STAFF_ROLES.includes(user.role); + } + + /** + * Création d'un enfant. + * - PARENT : rattache au parent connecté (multipart photo optionnel). + * - Staff : `parent_user_id` obligatoire ; JSON sans photo OK ; + * avec photo → multipart (même stockage `/uploads/photos/...`). Ticket #132. + */ + async create( + dto: CreateEnfantsDto, + currentUser: Users, + photoFile?: Express.Multer.File, + ): Promise { + const pivotUserId = this.resolvePivotParentUserId(dto, currentUser); + const parent = await this.parentsRepository.findOne({ - where: { user_id: currentUser.id }, + where: { user_id: pivotUserId }, relations: ['co_parent'], }); if (!parent) throw new NotFoundException('Parent introuvable'); - // Vérif métier simple + // Vérif métier simple (aligné comportement historique parent) if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) { throw new BadRequestException('Un enfant né doit avoir une date de naissance'); } - // Vérif doublon éventuel (ex: même prénom + date de naissance pour ce parent) + // Vérif doublon éventuel (ex: même prénom + date de naissance) const exist = await this.childrenRepository.findOne({ where: { first_name: dto.first_name, @@ -47,37 +68,84 @@ export class EnfantsService { }); if (exist) throw new ConflictException('Cet enfant existe déjà'); - // Gestion de la photo uploadée + // Gestion de la photo uploadée (multipart parent ou staff) + let photoUrl = dto.photo_url; + let consentAt: Date | undefined; if (photoFile) { - dto.photo_url = `/uploads/photos/${photoFile.filename}`; + photoUrl = `/uploads/photos/${photoFile.filename}`; if (dto.consent_photo) { - dto.consent_photo_at = new Date().toISOString(); + consentAt = new Date(); } + } else if (dto.consent_photo) { + consentAt = dto.consent_photo_at + ? new Date(dto.consent_photo_at) + : new Date(); } - // Création - const child = this.childrenRepository.create(dto); + const child = this.childrenRepository.create({ + status: dto.status, + first_name: dto.first_name, + last_name: dto.last_name, + gender: dto.gender, + birth_date: dto.birth_date ? new Date(dto.birth_date) : undefined, + due_date: dto.due_date ? new Date(dto.due_date) : undefined, + photo_url: photoUrl, + consent_photo: !!dto.consent_photo, + consent_photo_at: consentAt, + is_multiple: !!dto.is_multiple, + }); await this.childrenRepository.save(child); - // Lien parent-enfant (Parent 1) - const parentLink = this.parentsChildrenRepository.create({ - parentId: parent.user_id, - enfantId: child.id, - }); - await this.parentsChildrenRepository.save(parentLink); + // Lien parent-enfant (pivot) + await this.parentsChildrenRepository.save( + this.parentsChildrenRepository.create({ + parentId: parent.user_id, + enfantId: child.id, + }), + ); // Rattachement automatique au co-parent s'il existe if (parent.co_parent) { - const coParentLink = this.parentsChildrenRepository.create({ - parentId: parent.co_parent.id, - enfantId: child.id, - }); - await this.parentsChildrenRepository.save(coParentLink); + await this.parentsChildrenRepository.save( + this.parentsChildrenRepository.create({ + parentId: parent.co_parent.id, + enfantId: child.id, + }), + ); } 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) async findAll(): Promise { return this.childrenRepository.find({ @@ -90,7 +158,7 @@ export class EnfantsService { async findOne(id: string, currentUser: Users): Promise { const child = await this.childrenRepository.findOne({ where: { id }, - relations: ['parentLinks'], + relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'], }); if (!child) throw new NotFoundException('Enfant introuvable'); @@ -120,7 +188,8 @@ export class EnfantsService { const child = await this.childrenRepository.findOne({ where: { id } }); if (!child) throw new NotFoundException('Enfant introuvable'); - const patch: Partial = { ...dto } as Partial; + const { parent_user_id: _ignored, ...rest } = dto; + const patch: Partial = { ...rest } as Partial; if (dto.consent_photo !== undefined) { patch.consent_photo = dto.consent_photo; patch.consent_photo_at = dto.consent_photo ? new Date() : null!; diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index b13d50b..86f5cad 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/models/parent_model.dart'; @@ -57,6 +58,33 @@ class UserService { return v.toString(); } + /// MIME pour upload photo enfant (filtre Nest : jpg/jpeg/png/gif). + static MediaType _imageMediaType(String filename, List bytes) { + if (bytes.length >= 3 && + bytes[0] == 0xFF && + bytes[1] == 0xD8 && + bytes[2] == 0xFF) { + return MediaType('image', 'jpeg'); + } + if (bytes.length >= 8 && + bytes[0] == 0x89 && + bytes[1] == 0x50 && + bytes[2] == 0x4E && + bytes[3] == 0x47) { + return MediaType('image', 'png'); + } + if (bytes.length >= 6 && + bytes[0] == 0x47 && + bytes[1] == 0x49 && + bytes[2] == 0x46) { + return MediaType('image', 'gif'); + } + final lower = filename.toLowerCase(); + if (lower.endsWith('.png')) return MediaType('image', 'png'); + if (lower.endsWith('.gif')) return MediaType('image', 'gif'); + return MediaType('image', 'jpeg'); + } + static String _errMessage(dynamic err) { if (err == null) return 'Erreur inconnue'; if (err is String) return err; @@ -515,6 +543,68 @@ class UserService { return enrichEnfantParentNames(enfant); } + /// Création enfant côté staff (#132) — nécessite `POST /enfants` étendu (voir mini-spec). + /// [parentUserId] : parent pivot du foyer (`parent_user_id`). + /// Avec [photoBytes] : `multipart/form-data` (champ fichier `photo`), sinon JSON. + static Future createEnfant({ + required String parentUserId, + required Map body, + List? 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 = { + ...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, + ); + return enrichEnfantParentNames(enfant); + } + static Future deleteEnfant(String enfantId) async { final response = await http.delete( Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'), diff --git a/frontend/lib/utils/date_display_utils.dart b/frontend/lib/utils/date_display_utils.dart index 61ecb5e..3b7c5bd 100644 --- a/frontend/lib/utils/date_display_utils.dart +++ b/frontend/lib/utils/date_display_utils.dart @@ -1,3 +1,4 @@ +import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart'; @@ -11,10 +12,50 @@ String formatIsoDateFr(String? s, {String ifEmpty = '–'}) { } } -/// Convertit `dd/MM/yyyy` (ou ISO) en `yyyy-MM-dd` pour l'API. +/// Affiche une date ISO en saisie guidée `jj / mm / aaaa`. +String formatIsoDateFrInput(String? s, {String ifEmpty = ''}) { + if (s == null || s.trim().isEmpty) return ifEmpty; + try { + final dt = DateTime.parse(s.trim()); + return formatFrenchDateDigits( + DateFormat('ddMMyyyy').format(dt), + ); + } catch (_) { + return formatFrenchDateDigits(s.replaceAll(RegExp(r'\D'), '')); + } +} + +/// Formate jusqu’à 8 chiffres en `jj / mm / aaaa`. +String formatFrenchDateDigits(String digits) { + final d = digits.replaceAll(RegExp(r'\D'), ''); + final limited = d.length > 8 ? d.substring(0, 8) : d; + final buf = StringBuffer(); + for (var i = 0; i < limited.length; i++) { + if (i == 2 || i == 4) buf.write(' / '); + buf.write(limited[i]); + } + return buf.toString(); +} + +/// Convertit `dd/MM/yyyy`, `dd / MM / yyyy`, 8 chiffres ou ISO en `yyyy-MM-dd`. String? parseFrDateToIso(String text) { final t = text.trim(); if (t.isEmpty) return null; + + final digits = t.replaceAll(RegExp(r'\D'), ''); + if (digits.length == 8) { + final dd = digits.substring(0, 2); + final mm = digits.substring(2, 4); + final yyyy = digits.substring(4, 8); + try { + return DateFormat('dd/MM/yyyy') + .parseStrict('$dd/$mm/$yyyy') + .toIso8601String() + .split('T') + .first; + } catch (_) {} + } + try { return DateFormat('dd/MM/yyyy') .parseStrict(t) @@ -96,3 +137,43 @@ String formatChildAgeLabel({ return 'Né le ${formatIsoDateFr(birthDate)}'; } } + +/// Saisie date FR : 8 chiffres → `jj / mm / aaaa`. +class FrenchDateInputFormatter extends TextInputFormatter { + const FrenchDateInputFormatter(); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final digits = newValue.text.replaceAll(RegExp(r'\D'), ''); + final limited = digits.length > 8 ? digits.substring(0, 8) : digits; + final formatted = formatFrenchDateDigits(limited); + + final digitsBeforeCursor = newValue.text + .substring(0, newValue.selection.start.clamp(0, newValue.text.length)) + .replaceAll(RegExp(r'\D'), '') + .length + .clamp(0, limited.length); + + return TextEditingValue( + text: formatted, + selection: TextSelection.collapsed( + offset: _cursorOffset(formatted, digitsBeforeCursor), + ), + ); + } + + static int _cursorOffset(String formatted, int digitsBeforeCursor) { + if (digitsBeforeCursor <= 0) return 0; + var seen = 0; + for (var i = 0; i < formatted.length; i++) { + if (RegExp(r'\d').hasMatch(formatted[i])) { + seen++; + if (seen >= digitsBeforeCursor) return i + 1; + } + } + return formatted.length; + } +} diff --git a/frontend/lib/utils/name_format_utils.dart b/frontend/lib/utils/name_format_utils.dart index feddec5..f09eea9 100644 --- a/frontend/lib/utils/name_format_utils.dart +++ b/frontend/lib/utils/name_format_utils.dart @@ -1,3 +1,5 @@ +import 'package:flutter/services.dart'; + /// Formatage affichage prénom / nom (capitalisation par mot et segments après `-` ou `'`). String formatPersonNameCase(String raw) { @@ -9,6 +11,22 @@ String formatPersonNameCase(String raw) { return words.map(_capitalizeComposedWord).join(' '); } +/// Variante saisie live : pas de majuscule tant que le mot 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) { if (word.isEmpty) { return word; @@ -30,3 +48,26 @@ String _capitalizeComposedWord(String word) { } return buffer.toString(); } + +/// Formateur de saisie prénom / nom (capitalisation progressive). +class PersonNameInputFormatter extends TextInputFormatter { + const PersonNameInputFormatter(); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final formatted = formatPersonNameCaseTyping(newValue.text); + if (formatted == newValue.text) return newValue; + + final sel = newValue.selection; + final offset = sel.isValid + ? sel.baseOffset.clamp(0, formatted.length) + : formatted.length; + return TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: offset), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart b/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart index 17ac62e..3da8051 100644 --- a/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart +++ b/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart @@ -1,14 +1,27 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; -/// Cadre photo identité AM (35×45 mm) — même logique que [ValidationAmWizard]. +/// Cadre photo identité AM / enfant (35×45 mm) — même logique que [ValidationAmWizard]. class AdminAmPhotoFrame extends StatelessWidget { final String? photoUrl; + final Uint8List? imageBytes; + final VoidCallback? onTap; + final VoidCallback? onClear; + final String emptyLabel; static const double idPhotoAspectRatio = 35 / 45; - const AdminAmPhotoFrame({super.key, this.photoUrl}); + const AdminAmPhotoFrame({ + super.key, + this.photoUrl, + this.imageBytes, + this.onTap, + this.onClear, + this.emptyLabel = 'Aucune photo', + }); /// Largeur colonne photo pour remplir [height] (cadre inclus). static double columnWidthForHeight(double height) { @@ -36,9 +49,12 @@ class AdminAmPhotoFrame extends StatelessWidget { ph = pw / ar; } + final hasLocal = imageBytes != null && imageBytes!.isNotEmpty; + final showClear = onClear != null && hasLocal; + // Cadre gris = taille photo + padding uniforme ; centré dans la colonne // (évite le vide blanc en bas quand le conteneur parent est plus haut). - return Align( + Widget frame = Align( alignment: Alignment.topCenter, child: Container( decoration: BoxDecoration( @@ -60,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) { + if (imageBytes != null && imageBytes!.isNotEmpty) { + return Image.memory( + imageBytes!, + width: pw, + height: ph, + fit: BoxFit.cover, + alignment: Alignment.topCenter, + ); + } if (fullUrl.isEmpty) { return ColoredBox( color: Colors.grey.shade200, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.person_off_outlined, size: 36, color: Colors.grey.shade400), + Icon( + onTap != null ? Icons.add_a_photo_outlined : Icons.person_off_outlined, + size: 36, + color: Colors.grey.shade400, + ), const SizedBox(height: 6), - Text( - 'Aucune photo', - style: TextStyle(color: Colors.grey.shade600, fontSize: 11), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + emptyLabel, + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey.shade600, fontSize: 11), + ), ), ], ), @@ -108,7 +183,11 @@ class AdminAmPhotoFrame extends StatelessWidget { }, errorBuilder: (_, __, ___) => ColoredBox( color: Colors.grey.shade200, - child: Icon(Icons.broken_image_outlined, size: 36, color: Colors.grey.shade400), + child: Icon( + Icons.broken_image_outlined, + size: 36, + color: Colors.grey.shade400, + ), ), ); } diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart index cae3ca8..1a8a6c7 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/services/api/api_config.dart'; @@ -6,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/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart'; +import 'package:p_tits_pas/utils/name_format_utils.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_select_am_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_select_famille_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; -/// Fiche enfant consultation / édition (ticket #138) — format paysage comme fiche AM. +/// Fiche enfant consultation / édition (#138) ou création (#132). class AdminChildDetailModal extends StatefulWidget { - final EnfantAdminModel enfant; + final EnfantAdminModel? enfant; final VoidCallback? onSaved; final VoidCallback? onDeleted; + final bool isCreating; const AdminChildDetailModal({ super.key, - required this.enfant, + required EnfantAdminModel this.enfant, this.onSaved, this.onDeleted, - }); + }) : isCreating = false; + + /// Création depuis l'onglet Enfants (#132). + const AdminChildDetailModal.create({ + super.key, + this.onSaved, + }) : enfant = null, + onDeleted = null, + isCreating = true; @override State createState() => _AdminChildDetailModalState(); @@ -37,7 +50,7 @@ class _AdminChildDetailModalState extends State { late final TextEditingController _birthCtrl; late final TextEditingController _dueCtrl; late String _status; - late String _gender; + late String? _gender; late bool _consentPhoto; late bool _isMultiple; bool _dirty = false; @@ -49,6 +62,13 @@ class _AdminChildDetailModalState extends State { /// AM rattachée au chargement (pour sync différé au Sauvegarder). 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 _mainRowHeight = 380; static const double _placementHeight = 96; @@ -74,44 +94,102 @@ class _AdminChildDetailModalState extends State { _isUnborn ? 'Date prévisionnelle' : 'Date de naissance'; bool get _canDelete => + !widget.isCreating && (_currentUserRole ?? '').toLowerCase() == 'super_admin'; bool get _busy => _saving || _deleting; + /// Création : tous les champs sauf photo / consentement ; édition : au moins une modif. + bool get _canSubmit { + if (_busy) return false; + if (widget.isCreating) return _isCreateFormComplete; + return _dirty; + } + + bool get _isCreateFormComplete { + if (_selectedFamily == null) return false; + if (formatPersonNameCase(_prenomCtrl.text).isEmpty) return false; + if (formatPersonNameCase(_nomCtrl.text).isEmpty) return false; + if (_dateToIso(_activeDateCtrl.text) == null) return false; + if (_gender == null || !_availableGenders.contains(_gender)) return false; + return true; + } + @override void initState() { super.initState(); final e = widget.enfant; - _prenomCtrl = TextEditingController(text: e.firstName ?? ''); - _nomCtrl = TextEditingController(text: e.lastName ?? ''); + _prenomCtrl = TextEditingController(text: e?.firstName ?? ''); + _nomCtrl = TextEditingController(text: e?.lastName ?? ''); _birthCtrl = TextEditingController( - text: formatIsoDateFr(e.birthDate, ifEmpty: ''), + text: formatIsoDateFrInput(e?.birthDate, ifEmpty: ''), ); _dueCtrl = TextEditingController( - text: formatIsoDateFr(e.dueDate, ifEmpty: ''), + text: formatIsoDateFrInput(e?.dueDate, ifEmpty: ''), ); - _status = normalizeEnfantStatus(e.status); + _status = normalizeEnfantStatus(e?.status); if (!enfantStatusValues.contains(_status)) { _status = 'sans_garde'; } - _gender = _normalizeGender(e.gender, allowUnknown: _isUnborn); - _consentPhoto = e.consentPhoto; - _isMultiple = e.isMultiple; - for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) { - c.addListener(_markDirty); + if (widget.isCreating) { + _gender = null; + } else { + _gender = _normalizeGender(e?.gender, allowUnknown: _isUnborn); + } + _consentPhoto = e?.consentPhoto ?? false; + _isMultiple = e?.isMultiple ?? false; + for (final c in [_prenomCtrl, _nomCtrl]) { + c.addListener(_onNameChanged); + } + for (final c in [_birthCtrl, _dueCtrl]) { + c.addListener(_onDateChanged); } - _prenomCtrl.addListener(_onNameChanged); - _nomCtrl.addListener(_onNameChanged); _loadCurrentUserRole(); - _loadLinkedAm(); + if (widget.isCreating) { + _loadingAm = false; + _dirty = true; + } else { + _prenomCtrl.addListener(_markDirty); + _nomCtrl.addListener(_markDirty); + _loadLinkedAm(); + } } void _onNameChanged() => setState(() {}); + void _onDateChanged() { + setState(() => _dirty = true); + } + + /// Date prévisionnelle strictement avant aujourd’hui (statut à naître). + bool get _dueDateInPast { + if (!_isUnborn) return false; + final iso = _dateToIso(_dueCtrl.text); + if (iso == null) return false; + try { + final due = DateTime.parse(iso); + final now = DateTime.now(); + final dueDay = DateTime(due.year, due.month, due.day); + final today = DateTime(now.year, now.month, now.day); + return dueDay.isBefore(today); + } catch (_) { + return false; + } + } + Future _loadLinkedAm() async { + if (widget.isCreating) { + setState(() => _loadingAm = false); + return; + } + final enfantId = widget.enfant?.id; + if (enfantId == null || enfantId.isEmpty) { + setState(() => _loadingAm = false); + return; + } setState(() => _loadingAm = true); try { - final am = await UserService.findAmForEnfant(widget.enfant.id); + final am = await UserService.findAmForEnfant(enfantId); if (!mounted) return; setState(() { _linkedAm = am; @@ -156,8 +234,8 @@ class _AdminChildDetailModalState extends State { } void _coerceGenderForStatus() { - if (!_availableGenders.contains(_gender)) { - _gender = 'H'; + if (_gender != null && !_availableGenders.contains(_gender)) { + _gender = null; } } @@ -168,18 +246,25 @@ class _AdminChildDetailModalState extends State { String? _dateToIso(String text) => parseFrDateToIso(text); String _headerTitle() { + if (widget.isCreating) { + final fn = _prenomCtrl.text.trim(); + final ln = _nomCtrl.text.trim(); + if (fn.isEmpty && ln.isEmpty) return 'Nouvel enfant'; + return '$fn $ln'.trim(); + } final fn = _prenomCtrl.text.trim(); final ln = _nomCtrl.text.trim(); if (fn.isEmpty && ln.isEmpty) { - final fallback = widget.enfant.fullName.trim(); + final fallback = widget.enfant?.fullName.trim() ?? ''; return fallback.isNotEmpty ? fallback : 'Enfant'; } return '$fn $ln'.trim(); } - List get _parentLinks => widget.enfant.parentLinks - .where((l) => l.parentId.trim().isNotEmpty) - .toList(); + List get _parentLinks => + (widget.enfant?.parentLinks ?? const []) + .where((l) => l.parentId.trim().isNotEmpty) + .toList(); Future _openParent(EnfantParentLink link) async { if (_busy) return; @@ -205,6 +290,17 @@ class _AdminChildDetailModalState extends State { } Widget? _parentsSubtitle() { + if (widget.isCreating) { + final family = _selectedFamily; + if (family == null) return null; + return Text( + family.parentNames.isNotEmpty + ? 'Famille : ${family.parentNames.join(', ')}' + : family.displayTitle, + style: const TextStyle(fontSize: 13, color: Colors.black54), + ); + } + final links = _parentLinks; if (links.isEmpty) return null; @@ -252,15 +348,24 @@ class _AdminChildDetailModalState extends State { Future _save() async { if (!_dirty) return; + + if (widget.isCreating) { + await _saveCreate(); + return; + } + + final enfantId = widget.enfant?.id; + if (enfantId == null || enfantId.isEmpty) return; + setState(() => _saving = true); try { await _syncAmPlacement(); await UserService.updateEnfant( - enfantId: widget.enfant.id, + enfantId: enfantId, body: { - 'first_name': _prenomCtrl.text.trim(), - 'last_name': _nomCtrl.text.trim(), + 'first_name': formatPersonNameCase(_prenomCtrl.text), + 'last_name': formatPersonNameCase(_nomCtrl.text), 'status': _status, 'gender': _gender, if (_isUnborn) @@ -291,21 +396,101 @@ class _AdminChildDetailModalState extends State { } } + Future _saveCreate() async { + final family = _selectedFamily; + if (family == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Choisissez une famille / un dossier avant d\'enregistrer'), + ), + ); + return; + } + final prenom = formatPersonNameCase(_prenomCtrl.text); + final nom = formatPersonNameCase(_nomCtrl.text); + if (prenom.isEmpty || nom.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Prénom et nom sont obligatoires')), + ); + return; + } + if (!_isUnborn && _dateToIso(_birthCtrl.text) == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Date de naissance invalide ou manquante')), + ); + return; + } + final hasPhoto = _photoBytes != null && _photoBytes!.isNotEmpty; + if (hasPhoto && !_consentPhoto) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Activez le consentement photo pour enregistrer une photo', + ), + ), + ); + return; + } + + setState(() => _saving = true); + try { + await UserService.createEnfant( + parentUserId: family.pivotParentUserId, + photoBytes: hasPhoto ? _photoBytes : null, + photoFilename: _photoFilename, + body: { + 'first_name': prenom, + 'last_name': nom, + 'status': _status, + 'gender': _gender, + if (_isUnborn) ...{ + if (_dateToIso(_dueCtrl.text) != null) + 'due_date': _dateToIso(_dueCtrl.text), + } else ...{ + if (_dateToIso(_birthCtrl.text) != null) + 'birth_date': _dateToIso(_birthCtrl.text), + }, + 'consent_photo': _consentPhoto, + 'is_multiple': _isMultiple, + }, + ); + if (!mounted) return; + setState(() { + _dirty = false; + _saving = false; + }); + widget.onSaved?.call(); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Enfant créé')), + ); + } catch (e) { + if (!mounted) return; + setState(() => _saving = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + /// Applique rattachement / détachement AM (différé jusqu'au Sauvegarder). Future _syncAmPlacement() async { + final enfantId = widget.enfant?.id; + if (enfantId == null || enfantId.isEmpty) return; + final baselineId = _baselineAmUserId; final currentId = _linkedAm?.user.id; if (baselineId != null && baselineId != currentId) { await UserService.detachEnfantFromAm( amUserId: baselineId, - enfantId: widget.enfant.id, + enfantId: enfantId, ); } if (currentId != null && currentId != baselineId) { await UserService.attachEnfantToAm( amUserId: currentId, - enfantId: widget.enfant.id, + enfantId: enfantId, ); } } @@ -342,7 +527,9 @@ class _AdminChildDetailModalState extends State { setState(() => _deleting = true); try { - await UserService.deleteEnfant(widget.enfant.id); + final id = widget.enfant?.id; + if (id == null || id.isEmpty) return; + await UserService.deleteEnfant(id); if (!mounted) return; widget.onDeleted?.call(); widget.onSaved?.call(); @@ -376,10 +563,12 @@ class _AdminChildDetailModalState extends State { /// Recharge AM + statut après une modale externe (ex. fiche AM). Future _reloadPlacementFromServer() async { + final id = widget.enfant?.id; + if (id == null || id.isEmpty) return; try { final results = await Future.wait([ - UserService.findAmForEnfant(widget.enfant.id), - UserService.getEnfant(widget.enfant.id), + UserService.findAmForEnfant(id), + UserService.getEnfant(id), ]); if (!mounted) return; final am = results[0] as AssistanteMaternelleModel?; @@ -476,70 +665,145 @@ class _AdminChildDetailModalState extends State { }); } - InputDecoration _inputDecoration({String? hint}) { - return ValidationFieldDecoration.input(hint: hint).copyWith( + /// Conserve la date saisie en basculant naissance ↔ prévisionnelle. + void _preserveDateOnStatusChange(String from, String to) { + final becomingUnborn = to == 'a_naitre' && from != 'a_naitre'; + final leavingUnborn = from == 'a_naitre' && to != 'a_naitre'; + if (becomingUnborn) { + if (_dueCtrl.text.trim().isEmpty && + _birthCtrl.text.trim().isNotEmpty) { + _dueCtrl.text = _birthCtrl.text; + } + } else if (leavingUnborn) { + if (_birthCtrl.text.trim().isEmpty && + _dueCtrl.text.trim().isNotEmpty) { + _birthCtrl.text = _dueCtrl.text; + } + } + } + + InputDecoration _inputDecoration({String? hint, bool error = false}) { + final base = ValidationFieldDecoration.input(hint: hint).copyWith( isDense: false, contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), ); + if (!error) return base; + final border = OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: Colors.red.shade400), + ); + return base.copyWith( + fillColor: Colors.red.shade50, + enabledBorder: border, + focusedBorder: border, + border: border, + ); } Widget _dropdownField({ Key? key, - required String value, + required String? value, required List> items, required ValueChanged onChanged, + String? hint, }) { final safeValue = - items.any((e) => e.key == value) ? value : items.first.key; + value != null && items.any((e) => e.key == value) ? value : null; return SizedBox( height: _fieldHeight, child: DropdownButtonFormField( key: key, value: safeValue, + hint: hint != null + ? Text(hint, style: TextStyle(color: Colors.grey.shade600)) + : null, isExpanded: true, decoration: _inputDecoration(), - items: items - .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) - .toList(), - onChanged: _busy ? null : (v) { - if (v != null) onChanged(v); - }, + items: items + .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) + .toList(), + onChanged: _busy + ? null + : (v) { + if (v != null) onChanged(v); + }, ), ); } - Widget _textField(TextEditingController controller, {String? hint, Key? key}) { + Widget _textField( + TextEditingController controller, { + String? hint, + Key? key, + TextInputType? keyboardType, + List? inputFormatters, + bool error = false, + Widget? suffixIcon, + }) { return SizedBox( height: _fieldHeight, child: TextField( key: key, controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, textAlignVertical: TextAlignVertical.center, - style: const TextStyle(color: Colors.black87, fontSize: 14), - decoration: _inputDecoration(hint: hint), + style: TextStyle( + color: error ? Colors.red.shade800 : Colors.black87, + fontSize: 14, + ), + decoration: _inputDecoration( + hint: hint, + error: error, + ).copyWith(suffixIcon: suffixIcon), ), ); } + Widget _dateField() { + final past = _dueDateInPast; + return _textField( + _activeDateCtrl, + hint: 'jj / mm / aaaa', + key: ValueKey(_status), + keyboardType: TextInputType.number, + inputFormatters: const [FrenchDateInputFormatter()], + error: past, + suffixIcon: past + ? Tooltip( + message: + 'Date prévisionnelle antérieure à aujourd’hui', + child: Icon( + Icons.info_outline, + size: 20, + color: Colors.red.shade700, + ), + ) + : null, + ); + } + Widget _fieldsGrid() { return ValidationEditableSection( rowLayout: _fieldsRowLayout, fields: [ ValidationLabeledField( label: 'Prénom', - field: _textField(_prenomCtrl), + field: _textField( + _prenomCtrl, + inputFormatters: const [PersonNameInputFormatter()], + ), ), ValidationLabeledField( label: 'Nom', - field: _textField(_nomCtrl), + field: _textField( + _nomCtrl, + inputFormatters: const [PersonNameInputFormatter()], + ), ), ValidationLabeledField( label: _dateFieldLabel, - field: _textField( - _activeDateCtrl, - hint: 'jj/mm/aaaa', - key: ValueKey(_status), - ), + field: _dateField(), ), ValidationLabeledField( label: 'Statut', @@ -555,6 +819,7 @@ class _AdminChildDetailModalState extends State { .toList(), onChanged: (v) { setState(() { + _preserveDateOnStatusChange(_status, v); _status = v; _coerceGenderForStatus(); _dirty = true; @@ -570,6 +835,7 @@ class _AdminChildDetailModalState extends State { field: _dropdownField( key: ValueKey('genre-$_status'), value: _gender, + hint: 'Veuillez sélectionner le genre', items: _availableGenders .map((g) => MapEntry(g, _genderLabel(g))) .toList(), @@ -603,6 +869,43 @@ class _AdminChildDetailModalState extends State { ); } + Future _pickPhoto() async { + if (_busy || !widget.isCreating) return; + try { + final picked = await ImagePicker().pickImage( + source: ImageSource.gallery, + imageQuality: 75, + maxWidth: 1200, + maxHeight: 1200, + ); + if (picked == null) return; + final bytes = await picked.readAsBytes(); + if (bytes.isEmpty) return; + final name = picked.name.trim(); + if (!mounted) return; + setState(() { + _photoBytes = bytes; + _photoFilename = name.isNotEmpty ? name : 'photo.jpg'; + _consentPhoto = true; + _dirty = true; + }); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Impossible de charger la photo')), + ); + } + } + + void _clearPhoto() { + if (_busy || !widget.isCreating) return; + setState(() { + _photoBytes = null; + _photoFilename = null; + _dirty = true; + }); + } + Widget _mainRow() { return LayoutBuilder( builder: (context, c) { @@ -625,7 +928,20 @@ class _AdminChildDetailModalState extends State { children: [ Expanded( child: AdminAmPhotoFrame( - photoUrl: widget.enfant.photoUrl, + photoUrl: widget.isCreating + ? null + : widget.enfant?.photoUrl, + imageBytes: widget.isCreating ? _photoBytes : null, + onTap: widget.isCreating && !_busy ? _pickPhoto : null, + onClear: widget.isCreating && + !_busy && + _photoBytes != null && + _photoBytes!.isNotEmpty + ? _clearPhoto + : null, + emptyLabel: widget.isCreating + ? 'Cliquer pour ajouter' + : 'Aucune photo', ), ), const SizedBox(height: 8), @@ -893,8 +1209,10 @@ class _AdminChildDetailModalState extends State { ); } - String get _placementTitle => - _isScolarise ? 'Scolarisation' : 'Assistante maternelle'; + String get _placementTitle { + if (widget.isCreating) return 'Sélection du dossier de la famille'; + return _isScolarise ? 'Scolarisation' : 'Assistante maternelle'; + } Widget _placementBlock() { return Column( @@ -910,11 +1228,161 @@ class _AdminChildDetailModalState extends State { ), ), const SizedBox(height: 8), - _placementSection(), + if (widget.isCreating) _familyPlacementSection() else _placementSection(), ], ); } + Future _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() { if (!_showsAmPlacement) return _scolariseCard(); @@ -966,7 +1434,7 @@ class _AdminChildDetailModalState extends State { const SizedBox(width: 12), ElevatedButton( style: ValidationModalTheme.primaryElevatedStyle, - onPressed: !_dirty || _busy ? null : _save, + onPressed: !_canSubmit || _busy ? null : _save, child: _saving ? const SizedBox( width: 18, @@ -976,7 +1444,11 @@ class _AdminChildDetailModalState extends State { color: Colors.white, ), ) - : Text(_dirty ? 'Sauvegarder' : 'Aucune modification'), + : Text( + widget.isCreating + ? 'Créer' + : (_dirty ? 'Sauvegarder' : 'Aucune modification'), + ), ), ], ); diff --git a/frontend/lib/widgets/admin/common/admin_select_famille_modal.dart b/frontend/lib/widgets/admin/common/admin_select_famille_modal.dart new file mode 100644 index 0000000..ebfc947 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_select_famille_modal.dart @@ -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 parentNames; + + const AdminFamilleFoyer({ + required this.pivotParentUserId, + required this.displayTitle, + required this.parentNames, + this.numeroDossier, + }); + + String get subtitle { + final parts = []; + 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 buildFamilleFoyers(List parents) { + final seenDossiers = {}; + final seenUserIds = {}; + final foyers = []; + + 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 = [ + 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 show( + BuildContext context, { + String title = 'Choisir une famille', + }) { + return AdminSelectListModal.show( + 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, + ), + ], + ); + }, + ); + } +} diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index d272864..3abcb4c 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -3,6 +3,7 @@ import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart'; @@ -26,6 +27,7 @@ class _UserManagementPanelState extends State { int _subIndex = 0; int _gestionnaireRefreshTick = 0; int _adminRefreshTick = 0; + int _enfantRefreshTick = 0; final TextEditingController _searchController = TextEditingController(); final TextEditingController _amCapacityController = TextEditingController(); String? _parentStatus; @@ -264,6 +266,7 @@ class _UserManagementPanelState extends State { ); case 1: return EnfantManagementWidget( + key: ValueKey('enfants-$_enfantRefreshTick'), searchQuery: _searchController.text, statusFilter: _enfantStatus, ); @@ -311,6 +314,23 @@ class _UserManagementPanelState extends State { Future _handleAddPressed() async { final contentIndex = _subIndex - _contentIndexOffset; + + if (contentIndex == 1) { + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return AdminChildDetailModal.create( + onSaved: () { + if (!mounted) return; + setState(() => _enfantRefreshTick++); + }, + ); + }, + ); + return; + } + if (contentIndex == 3) { final created = await showDialog( context: context, diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index 09ded02..3672396 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -206,7 +206,7 @@ packages: source: hosted version: "1.2.2" http_parser: - dependency: transitive + dependency: "direct main" description: name: http_parser sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index 0f2e0aa..b3f4ca8 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: js: ^0.6.7 url_launcher: ^6.2.4 http: ^1.2.2 + http_parser: ^4.0.2 # flutter_secure_storage: ^9.0.0 pdfx: ^2.5.0 universal_platform: ^1.1.0