From f749484731900273760d635b3da90ecd64c5634b Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 26 Feb 2026 19:10:58 +0100 Subject: [PATCH 01/13] =?UTF-8?q?test(inscription=20AM):=20Pr=C3=A9remplis?= =?UTF-8?q?sage=20donn=C3=A9es=20de=20test=20Marie=20DUBOIS=20(squash=20de?= =?UTF-8?q?velop)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Étapes 1 à 3 du formulaire d'inscription AM : données du jeu de test officiel (03_seed_test_data.sql) au lieu du générateur aléatoire. Made-with: Cursor --- .../auth/am_register_step1_screen.dart | 19 ++++++++----------- .../auth/am_register_step2_screen.dart | 9 ++++----- .../auth/am_register_step3_screen.dart | 4 ++-- 3 files changed, 14 insertions(+), 18 deletions(-) diff --git a/frontend/lib/screens/auth/am_register_step1_screen.dart b/frontend/lib/screens/auth/am_register_step1_screen.dart index 06a8fa2..14a4db1 100644 --- a/frontend/lib/screens/auth/am_register_step1_screen.dart +++ b/frontend/lib/screens/auth/am_register_step1_screen.dart @@ -3,7 +3,6 @@ import 'package:provider/provider.dart'; import 'package:go_router/go_router.dart'; import '../../models/am_registration_data.dart'; -import '../../utils/data_generator.dart'; import '../../widgets/personal_info_form_screen.dart'; import '../../models/card_assets.dart'; @@ -14,19 +13,17 @@ class AmRegisterStep1Screen extends StatelessWidget { Widget build(BuildContext context) { final registrationData = Provider.of(context, listen: false); - // Générer des données de test si vide + // Données de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data) PersonalInfoData initialData; if (registrationData.firstName.isEmpty) { - final genFirstName = DataGenerator.firstName(); - final genLastName = DataGenerator.lastName(); initialData = PersonalInfoData( - firstName: genFirstName, - lastName: genLastName, - phone: DataGenerator.phone(), - email: DataGenerator.email(genFirstName, genLastName), - address: DataGenerator.address(), - postalCode: DataGenerator.postalCode(), - city: DataGenerator.city(), + firstName: 'Marie', + lastName: 'DUBOIS', + phone: '0696345678', + email: 'marie.dubois@ptits-pas.fr', + address: '25 Rue de la République', + postalCode: '95870', + city: 'Bezons', ); } else { initialData = PersonalInfoData( diff --git a/frontend/lib/screens/auth/am_register_step2_screen.dart b/frontend/lib/screens/auth/am_register_step2_screen.dart index 447280a..bf354d4 100644 --- a/frontend/lib/screens/auth/am_register_step2_screen.dart +++ b/frontend/lib/screens/auth/am_register_step2_screen.dart @@ -6,7 +6,6 @@ import 'dart:io'; import '../../models/am_registration_data.dart'; import '../../models/card_assets.dart'; -import '../../utils/data_generator.dart'; import '../../widgets/professional_info_form_screen.dart'; class AmRegisterStep2Screen extends StatefulWidget { @@ -54,17 +53,17 @@ class _AmRegisterStep2ScreenState extends State { capacity: registrationData.capacity, ); - // Générer des données de test si les champs sont vides (NIR = Marie Dubois du seed, Corse 2A) + // Données de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data) if (registrationData.dateOfBirth == null && registrationData.nir.isEmpty) { initialData = ProfessionalInfoData( photoPath: 'assets/images/icon_assmat.png', photoConsent: true, dateOfBirth: DateTime(1980, 6, 8), - birthCity: 'Ajaccio', + birthCity: 'Bezons', birthCountry: 'France', nir: '280062A00100191', - agrementNumber: 'AM${DataGenerator.randomIntInRange(10000, 100000)}', - capacity: DataGenerator.randomIntInRange(1, 5), + agrementNumber: 'AGR-2019-095001', + capacity: 4, ); } diff --git a/frontend/lib/screens/auth/am_register_step3_screen.dart b/frontend/lib/screens/auth/am_register_step3_screen.dart index 1fff3cb..7bda43f 100644 --- a/frontend/lib/screens/auth/am_register_step3_screen.dart +++ b/frontend/lib/screens/auth/am_register_step3_screen.dart @@ -13,12 +13,12 @@ class AmRegisterStep3Screen extends StatelessWidget { Widget build(BuildContext context) { final data = Provider.of(context, listen: false); - // Générer un texte de test si vide + // Données de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data) String initialText = data.presentationText; bool initialCgu = data.cguAccepted; if (initialText.isEmpty) { - initialText = 'Disponible immédiatement, plus de 10 ans d\'expérience avec les tout-petits. Formation aux premiers secours à jour. Je dispose d\'un jardin sécurisé et d\'un espace de jeu adapté.'; + initialText = 'Assistante maternelle agréée depuis 2019. Spécialité bébés 0-18 mois. Accueil bienveillant et cadre sécurisant. 2 places disponibles.'; initialCgu = true; } From aefe590d2c669e789d2585bac6deb5557901daea Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 26 Feb 2026 21:21:17 +0100 Subject: [PATCH 02/13] =?UTF-8?q?Squash=20merge=20develop=20into=20master?= =?UTF-8?q?=20(c=C3=A2blage=20inscription=20AM=20#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- .../auth/am_register_step4_screen.dart | 39 ++++++++--- frontend/lib/services/auth_service.dart | 67 +++++++++++++++++++ frontend/lib/utils/nir_utils.dart | 10 +-- 3 files changed, 98 insertions(+), 18 deletions(-) diff --git a/frontend/lib/screens/auth/am_register_step4_screen.dart b/frontend/lib/screens/auth/am_register_step4_screen.dart index 6d97e45..93c0bf7 100644 --- a/frontend/lib/screens/auth/am_register_step4_screen.dart +++ b/frontend/lib/screens/auth/am_register_step4_screen.dart @@ -7,6 +7,7 @@ import 'dart:math' as math; import '../../models/am_registration_data.dart'; import '../../models/card_assets.dart'; import '../../config/display_config.dart'; +import '../../services/auth_service.dart'; import '../../widgets/hover_relief_widget.dart'; import '../../widgets/image_button.dart'; import '../../widgets/custom_navigation_button.dart'; @@ -22,6 +23,28 @@ class AmRegisterStep4Screen extends StatefulWidget { } class _AmRegisterStep4ScreenState extends State { + bool _isSubmitting = false; + + Future _submitAMRegistration(AmRegistrationData registrationData) async { + if (_isSubmitting) return; + setState(() => _isSubmitting = true); + try { + await AuthService.registerAM(registrationData); + if (!mounted) return; + _showConfirmationModal(context); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur lors de l\'inscription'), + backgroundColor: Colors.red.shade700, + ), + ); + } finally { + if (mounted) setState(() => _isSubmitting = false); + } + } + @override Widget build(BuildContext context) { final registrationData = Provider.of(context); @@ -90,12 +113,9 @@ class _AmRegisterStep4ScreenState extends State { Expanded( child: HoverReliefWidget( child: CustomNavigationButton( - text: 'Soumettre', + text: _isSubmitting ? 'Envoi...' : 'Soumettre', style: NavigationButtonStyle.green, - onPressed: () { - print("Données AM finales: ${registrationData.firstName} ${registrationData.lastName}"); - _showConfirmationModal(context); - }, + onPressed: () => _submitAMRegistration(registrationData), width: double.infinity, height: 50, fontSize: 16, @@ -106,17 +126,14 @@ class _AmRegisterStep4ScreenState extends State { ), ) else - ImageButton( + ImageButton( bg: 'assets/images/bg_green.png', - text: 'Soumettre ma demande', + text: _isSubmitting ? 'Envoi...' : 'Soumettre ma demande', textColor: const Color(0xFF2D6A4F), width: 350, height: 50, fontSize: 18, - onPressed: () { - print("Données AM finales: ${registrationData.firstName} ${registrationData.lastName}"); - _showConfirmationModal(context); - }, + onPressed: () => _submitAMRegistration(registrationData), ), ], ), diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart index 7a44678..f9b0e75 100644 --- a/frontend/lib/services/auth_service.dart +++ b/frontend/lib/services/auth_service.dart @@ -1,9 +1,12 @@ import 'dart:convert'; +import 'dart:io'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import '../models/user.dart'; +import '../models/am_registration_data.dart'; import 'api/api_config.dart'; import 'api/tokenService.dart'; +import '../utils/nir_utils.dart'; class AuthService { static const String _currentUserKey = 'current_user'; @@ -133,6 +136,70 @@ class AuthService { await prefs.setString(_currentUserKey, jsonEncode(user.toJson())); } + /// Inscription AM complète (POST /auth/register/am). + /// En cas de succès (201), aucune donnée utilisateur retournée ; rediriger vers login. + static Future registerAM(AmRegistrationData data) async { + String? photoBase64; + if (data.photoPath != null && data.photoPath!.isNotEmpty && !data.photoPath!.startsWith('assets/')) { + try { + final file = File(data.photoPath!); + if (await file.exists()) { + final bytes = await file.readAsBytes(); + photoBase64 = 'data:image/jpeg;base64,${base64Encode(bytes)}'; + } + } catch (_) {} + } + + final body = { + 'email': data.email, + 'prenom': data.firstName, + 'nom': data.lastName, + 'telephone': data.phone, + 'adresse': data.streetAddress.isNotEmpty ? data.streetAddress : null, + 'code_postal': data.postalCode.isNotEmpty ? data.postalCode : null, + 'ville': data.city.isNotEmpty ? data.city : null, + if (photoBase64 != null) 'photo_base64': photoBase64, + 'consentement_photo': data.photoConsent, + 'date_naissance': data.dateOfBirth != null + ? '${data.dateOfBirth!.year}-${data.dateOfBirth!.month.toString().padLeft(2, '0')}-${data.dateOfBirth!.day.toString().padLeft(2, '0')}' + : null, + 'lieu_naissance_ville': data.birthCity.isNotEmpty ? data.birthCity : null, + 'lieu_naissance_pays': data.birthCountry.isNotEmpty ? data.birthCountry : null, + 'nir': normalizeNir(data.nir), + 'numero_agrement': data.agrementNumber, + 'capacite_accueil': data.capacity ?? 1, + 'biographie': data.presentationText.isNotEmpty ? data.presentationText : null, + 'acceptation_cgu': data.cguAccepted, + 'acceptation_privacy': data.cguAccepted, + }; + + final response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.registerAM}'), + headers: ApiConfig.headers, + body: jsonEncode(body), + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + return; + } + + final decoded = response.body.isNotEmpty ? jsonDecode(response.body) : null; + final message = _extractErrorMessage(decoded, response.statusCode); + throw Exception(message); + } + + /// Extrait le message d'erreur des réponses NestJS (message string, array, ou objet). + static String _extractErrorMessage(dynamic decoded, int statusCode) { + const fallback = 'Erreur lors de l\'inscription'; + if (decoded == null || decoded is! Map) return '$fallback ($statusCode)'; + final msg = decoded['message']; + if (msg == null) return decoded['error'] as String? ?? '$fallback ($statusCode)'; + if (msg is String) return msg; + if (msg is List) return msg.map((e) => e.toString()).join('. ').trim(); + if (msg is Map && msg['message'] != null) return msg['message'].toString(); + return '$fallback ($statusCode)'; + } + /// Rafraîchit le profil utilisateur depuis l'API static Future refreshCurrentUser() async { final token = await TokenService.getToken(); diff --git a/frontend/lib/utils/nir_utils.dart b/frontend/lib/utils/nir_utils.dart index ea8d072..cfed04b 100644 --- a/frontend/lib/utils/nir_utils.dart +++ b/frontend/lib/utils/nir_utils.dart @@ -49,15 +49,11 @@ String nirToRaw(String normalized) { return s; } -/// Formate pour affichage : 1 12 34 56 789 012-34 ou 1 12 34 2A 789 012-34 +/// Formate pour affichage : 1 12 34 56 789 012-34 ou 1 12 34 2A 789 012-34 (Corse). String formatNir(String raw) { final r = nirToRaw(raw); if (r.length < 15) return r; - final dept = r.substring(5, 7); - final isCorsica = dept == '2A' || dept == '2B'; - if (isCorsica) { - return '${r.substring(0, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)}-${r.substring(13, 15)}'; - } + // Même structure pour tous : sexe + année + mois + département + commune + ordre-clé. return '${r.substring(0, 1)} ${r.substring(1, 3)} ${r.substring(3, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)}-${r.substring(13, 15)}'; } @@ -67,7 +63,7 @@ bool _isFormatValid(String raw) { final dept = raw.substring(5, 7); final restDigits = raw.substring(0, 5) + (dept == '2A' ? '19' : dept == '2B' ? '18' : dept) + raw.substring(7, 15); if (!RegExp(r'^[12]\d{12}\d{2}$').hasMatch(restDigits)) return false; - return RegExp(r'^[12]\d{4}(?:\d{2}|2A|2B)\d{6}$').hasMatch(raw); + return RegExp(r'^[12]\d{4}(?:\d{2}|2A|2B)\d{8}$').hasMatch(raw); } /// Calcule la clé de contrôle (97 - (NIR13 mod 97)). Pour 2A→19, 2B→18. From af489f39b4747267ca22bfefc0dfdeda548f6f9c Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 12 Mar 2026 22:14:21 +0100 Subject: [PATCH 03/13] =?UTF-8?q?Merge=20branch=20'develop'=20(squash)=20?= =?UTF-8?q?=E2=80=93=20Num=C3=A9ro=20de=20dossier=20#103=20et=20autres=20a?= =?UTF-8?q?vancements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- .../assistantes_maternelles.entity.ts | 3 + backend/src/entities/parents.entity.ts | 6 +- backend/src/entities/users.entity.ts | 4 + .../numero-dossier/numero-dossier.module.ts | 8 ++ .../numero-dossier/numero-dossier.service.ts | 55 ++++++++ backend/src/routes/auth/auth.module.ts | 2 + backend/src/routes/auth/auth.service.ts | 12 ++ .../user/dto/affecter-numero-dossier.dto.ts | 14 ++ backend/src/routes/user/user.controller.ts | 21 ++- backend/src/routes/user/user.service.ts | 58 +++++++++ database/BDD.sql | 14 ++ database/migrations/2026_numero_dossier.sql | 33 +++++ .../2026_numero_dossier_backfill.sql | 122 ++++++++++++++++++ docs/23_LISTE-TICKETS.md | 87 +++++++++++-- 14 files changed, 426 insertions(+), 13 deletions(-) create mode 100644 backend/src/modules/numero-dossier/numero-dossier.module.ts create mode 100644 backend/src/modules/numero-dossier/numero-dossier.service.ts create mode 100644 backend/src/routes/user/dto/affecter-numero-dossier.dto.ts create mode 100644 database/migrations/2026_numero_dossier.sql create mode 100644 database/migrations/2026_numero_dossier_backfill.sql diff --git a/backend/src/entities/assistantes_maternelles.entity.ts b/backend/src/entities/assistantes_maternelles.entity.ts index f8793e6..77db5f1 100644 --- a/backend/src/entities/assistantes_maternelles.entity.ts +++ b/backend/src/entities/assistantes_maternelles.entity.ts @@ -48,4 +48,7 @@ export class AssistanteMaternelle { @Column( { name: 'place_disponible', type: 'integer', nullable: true }) places_available?: number; + /** Numéro de dossier (format AAAA-NNNNNN), même valeur que sur utilisateurs (ticket #103) */ + @Column({ name: 'numero_dossier', length: 20, nullable: true }) + numero_dossier?: string; } diff --git a/backend/src/entities/parents.entity.ts b/backend/src/entities/parents.entity.ts index b66843c..5f55d44 100644 --- a/backend/src/entities/parents.entity.ts +++ b/backend/src/entities/parents.entity.ts @@ -1,5 +1,5 @@ import { - Entity, PrimaryColumn, OneToOne, JoinColumn, + Entity, PrimaryColumn, Column, OneToOne, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { Users } from './users.entity'; @@ -21,6 +21,10 @@ export class Parents { @JoinColumn({ name: 'id_co_parent', referencedColumnName: 'id' }) co_parent?: Users; + /** Numéro de dossier famille (format AAAA-NNNNNN), attribué à la soumission (ticket #103) */ + @Column({ name: 'numero_dossier', length: 20, nullable: true }) + numero_dossier?: string; + // Lien vers enfants via la table enfants_parents @OneToMany(() => ParentsChildren, pc => pc.parent) parentChildren: ParentsChildren[]; diff --git a/backend/src/entities/users.entity.ts b/backend/src/entities/users.entity.ts index 649ddda..ca5d2ef 100644 --- a/backend/src/entities/users.entity.ts +++ b/backend/src/entities/users.entity.ts @@ -152,6 +152,10 @@ export class Users { @Column({ nullable: true, name: 'relais_id' }) relaisId?: string; + /** Numéro de dossier (format AAAA-NNNNNN), attribué à la soumission (ticket #103) */ + @Column({ nullable: true, name: 'numero_dossier', length: 20 }) + numero_dossier?: string; + @ManyToOne(() => Relais, relais => relais.gestionnaires, { nullable: true }) @JoinColumn({ name: 'relais_id' }) relais?: Relais; diff --git a/backend/src/modules/numero-dossier/numero-dossier.module.ts b/backend/src/modules/numero-dossier/numero-dossier.module.ts new file mode 100644 index 0000000..a5724bc --- /dev/null +++ b/backend/src/modules/numero-dossier/numero-dossier.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { NumeroDossierService } from './numero-dossier.service'; + +@Module({ + providers: [NumeroDossierService], + exports: [NumeroDossierService], +}) +export class NumeroDossierModule {} diff --git a/backend/src/modules/numero-dossier/numero-dossier.service.ts b/backend/src/modules/numero-dossier/numero-dossier.service.ts new file mode 100644 index 0000000..2cbb2a2 --- /dev/null +++ b/backend/src/modules/numero-dossier/numero-dossier.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { EntityManager } from 'typeorm'; + +const FORMAT_MAX_SEQUENCE = 990000; + +/** + * Service de génération du numéro de dossier (ticket #103). + * Format AAAA-NNNNNN (année + 6 chiffres), séquence par année. + * Si séquence >= 990000, overflowWarning est true (alerte gestionnaire). + */ +@Injectable() +export class NumeroDossierService { + /** + * Génère le prochain numéro de dossier dans le cadre d'une transaction. + * À appeler avec le manager de la transaction pour garantir l'unicité. + */ + async getNextNumeroDossier(manager: EntityManager): Promise<{ + numero: string; + overflowWarning: boolean; + }> { + const year = new Date().getFullYear(); + + // Garantir l'existence de la ligne pour l'année + await manager.query( + `INSERT INTO numero_dossier_sequence (annee, prochain) + VALUES ($1, 1) + ON CONFLICT (annee) DO NOTHING`, + [year], + ); + + // Prendre le prochain numéro et incrémenter (FOR UPDATE pour concurrence) + const selectRows = await manager.query( + `SELECT prochain FROM numero_dossier_sequence WHERE annee = $1 FOR UPDATE`, + [year], + ); + const currentVal = selectRows?.[0]?.prochain ?? 1; + + await manager.query( + `UPDATE numero_dossier_sequence SET prochain = prochain + 1 WHERE annee = $1`, + [year], + ); + + const nextVal = currentVal; + const overflowWarning = nextVal >= FORMAT_MAX_SEQUENCE; + if (overflowWarning) { + // Log pour alerte gestionnaire (ticket #103) + console.warn( + `[NumeroDossierService] Séquence année ${year} >= ${FORMAT_MAX_SEQUENCE} (valeur ${nextVal}). Prévoir renouvellement ou format.`, + ); + } + + const numero = `${year}-${String(nextVal).padStart(6, '0')}`; + return { numero, overflowWarning }; + } +} diff --git a/backend/src/routes/auth/auth.module.ts b/backend/src/routes/auth/auth.module.ts index 3d15615..2a4513a 100644 --- a/backend/src/routes/auth/auth.module.ts +++ b/backend/src/routes/auth/auth.module.ts @@ -10,12 +10,14 @@ import { Parents } from 'src/entities/parents.entity'; import { Children } from 'src/entities/children.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { AppConfigModule } from 'src/modules/config'; +import { NumeroDossierModule } from 'src/modules/numero-dossier/numero-dossier.module'; @Module({ imports: [ TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]), forwardRef(() => UserModule), AppConfigModule, + NumeroDossierModule, JwtModule.registerAsync({ imports: [ConfigModule], useFactory: (config: ConfigService) => ({ diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index 76f2ddd..45e7ee5 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -24,6 +24,7 @@ import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entit import { LoginDto } from './dto/login.dto'; import { AppConfigService } from 'src/modules/config/config.service'; import { validateNir } from 'src/common/utils/nir.util'; +import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service'; @Injectable() export class AuthService { @@ -32,6 +33,7 @@ export class AuthService { private readonly jwtService: JwtService, private readonly configService: ConfigService, private readonly appConfigService: AppConfigService, + private readonly numeroDossierService: NumeroDossierService, @InjectRepository(Parents) private readonly parentsRepo: Repository, @InjectRepository(Users) @@ -194,6 +196,8 @@ export class AuthService { dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken); const resultat = await this.usersRepo.manager.transaction(async (manager) => { + const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager); + const parent1 = manager.create(Users, { email: dto.email, prenom: dto.prenom, @@ -206,6 +210,7 @@ export class AuthService { ville: dto.ville, token_creation_mdp: tokenCreationMdp, token_creation_mdp_expire_le: dateExpiration, + numero_dossier: numeroDossier, }); const parent1Enregistre = await manager.save(Users, parent1); @@ -230,6 +235,7 @@ export class AuthService { ville: dto.co_parent_meme_adresse ? dto.ville : dto.co_parent_ville, token_creation_mdp: tokenCoParent, token_creation_mdp_expire_le: dateExpirationCoParent, + numero_dossier: numeroDossier, }); parent2Enregistre = await manager.save(Users, parent2); @@ -237,6 +243,7 @@ export class AuthService { const entiteParent = manager.create(Parents, { user_id: parent1Enregistre.id, + numero_dossier: numeroDossier, }); entiteParent.user = parent1Enregistre; if (parent2Enregistre) { @@ -248,6 +255,7 @@ export class AuthService { if (parent2Enregistre) { const entiteCoParent = manager.create(Parents, { user_id: parent2Enregistre.id, + numero_dossier: numeroDossier, }); entiteCoParent.user = parent2Enregistre; entiteCoParent.co_parent = parent1Enregistre; @@ -360,6 +368,8 @@ export class AuthService { dto.consentement_photo ? new Date() : undefined; const resultat = await this.usersRepo.manager.transaction(async (manager) => { + const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager); + const user = manager.create(Users, { email: dto.email, prenom: dto.prenom, @@ -376,6 +386,7 @@ export class AuthService { consentement_photo: dto.consentement_photo, date_consentement_photo: dateConsentementPhoto, date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined, + numero_dossier: numeroDossier, }); const userEnregistre = await manager.save(Users, user); @@ -389,6 +400,7 @@ export class AuthService { residence_city: dto.ville ?? undefined, agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined, available: true, + numero_dossier: numeroDossier, }); await amRepo.save(am); diff --git a/backend/src/routes/user/dto/affecter-numero-dossier.dto.ts b/backend/src/routes/user/dto/affecter-numero-dossier.dto.ts new file mode 100644 index 0000000..5227823 --- /dev/null +++ b/backend/src/routes/user/dto/affecter-numero-dossier.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, Matches } from 'class-validator'; + +/** Format AAAA-NNNNNN (année + 6 chiffres) */ +const NUMERO_DOSSIER_REGEX = /^\d{4}-\d{6}$/; + +export class AffecterNumeroDossierDto { + @ApiProperty({ example: '2026-000004', description: 'Numéro de dossier (AAAA-NNNNNN)' }) + @IsNotEmpty({ message: 'Le numéro de dossier est requis' }) + @Matches(NUMERO_DOSSIER_REGEX, { + message: 'Le numéro de dossier doit être au format AAAA-NNNNNN (ex: 2026-000001)', + }) + numero_dossier: string; +} diff --git a/backend/src/routes/user/user.controller.ts b/backend/src/routes/user/user.controller.ts index 84682bc..d7df010 100644 --- a/backend/src/routes/user/user.controller.ts +++ b/backend/src/routes/user/user.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { AuthGuard } from 'src/common/guards/auth.guard'; +import { RolesGuard } from 'src/common/guards/roles.guard'; import { Roles } from 'src/common/decorators/roles.decorator'; import { User } from 'src/common/decorators/user.decorator'; import { RoleType, Users } from 'src/entities/users.entity'; @@ -8,10 +9,11 @@ import { UserService } from './user.service'; import { CreateUserDto } from './dto/create_user.dto'; import { CreateAdminDto } from './dto/create_admin.dto'; import { UpdateUserDto } from './dto/update_user.dto'; +import { AffecterNumeroDossierDto } from './dto/affecter-numero-dossier.dto'; @ApiTags('Utilisateurs') @ApiBearerAuth('access-token') -@UseGuards(AuthGuard) +@UseGuards(AuthGuard, RolesGuard) @Controller('users') export class UserController { constructor(private readonly userService: UserService) { } @@ -78,6 +80,23 @@ export class UserController { return this.userService.updateUser(id, dto, currentUser); } + @Patch(':id/numero-dossier') + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) + @ApiOperation({ + summary: 'Affecter un numéro de dossier à un utilisateur', + description: 'Permet de rapprocher deux dossiers ou d’attribuer un numéro existant à un parent/AM. Réservé aux gestionnaires et administrateurs.', + }) + @ApiParam({ name: 'id', description: "UUID de l'utilisateur (parent ou AM)" }) + @ApiResponse({ status: 200, description: 'Numéro de dossier affecté' }) + @ApiResponse({ status: 400, description: 'Format invalide, rôle non éligible, ou dossier déjà associé à 2 parents' }) + @ApiResponse({ status: 404, description: 'Utilisateur introuvable' }) + affecterNumeroDossier( + @Param('id') id: string, + @Body() dto: AffecterNumeroDossierDto, + ) { + return this.userService.affecterNumeroDossier(id, dto.numero_dossier); + } + @Patch(':id/valider') @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @ApiOperation({ summary: 'Valider un compte utilisateur' }) diff --git a/backend/src/routes/user/user.service.ts b/backend/src/routes/user/user.service.ts index 69ccaac..3ef45b3 100644 --- a/backend/src/routes/user/user.service.ts +++ b/backend/src/routes/user/user.service.ts @@ -270,6 +270,64 @@ export class UserService { await this.validationRepository.save(suspend); return savedUser; } + /** + * Affecter ou modifier le numéro de dossier d'un utilisateur (parent ou AM). + * Permet au gestionnaire/admin de rapprocher deux dossiers (même numéro pour plusieurs personnes). + * Garde-fou : au plus 2 parents par numéro de dossier (couple / co-parents). + */ + async affecterNumeroDossier(userId: string, numeroDossier: string): Promise { + const user = await this.usersRepository.findOne({ where: { id: userId } }); + if (!user) throw new NotFoundException('Utilisateur introuvable'); + + if (user.role !== RoleType.PARENT && user.role !== RoleType.ASSISTANTE_MATERNELLE) { + throw new BadRequestException( + 'Le numéro de dossier ne peut être affecté qu\'à un parent ou une assistante maternelle', + ); + } + + if (user.role === RoleType.PARENT) { + const uneAMALe = await this.assistantesRepository.count({ + where: { numero_dossier: numeroDossier }, + }); + if (uneAMALe > 0) { + throw new BadRequestException( + 'Ce numéro de dossier est celui d\'une assistante maternelle. Un numéro AM ne peut pas être affecté à un parent.', + ); + } + const parentsAvecCeNumero = await this.parentsRepository.count({ + where: { numero_dossier: numeroDossier }, + }); + const userADejaCeNumero = user.numero_dossier === numeroDossier; + if (!userADejaCeNumero && parentsAvecCeNumero >= 2) { + throw new BadRequestException( + 'Un numéro de dossier ne peut être associé qu\'à 2 parents au maximum (couple / co-parents). Ce dossier a déjà 2 parents.', + ); + } + } + + if (user.role === RoleType.ASSISTANTE_MATERNELLE) { + const unParentLA = await this.parentsRepository.count({ + where: { numero_dossier: numeroDossier }, + }); + if (unParentLA > 0) { + throw new BadRequestException( + 'Ce numéro de dossier est celui d\'une famille (parent). Un numéro famille ne peut pas être affecté à une assistante maternelle.', + ); + } + } + + user.numero_dossier = numeroDossier; + const savedUser = await this.usersRepository.save(user); + + if (user.role === RoleType.PARENT) { + await this.parentsRepository.update({ user_id: userId }, { numero_dossier: numeroDossier }); + } else { + await this.assistantesRepository.update({ user_id: userId }, { numero_dossier: numeroDossier }); + } + + return savedUser; + } + async remove(id: string, currentUser: Users): Promise { if (currentUser.role !== RoleType.SUPER_ADMIN) { throw new ForbiddenException('Accès réservé aux super admins'); diff --git a/database/BDD.sql b/database/BDD.sql index 46a741e..fc04af8 100644 --- a/database/BDD.sql +++ b/database/BDD.sql @@ -355,6 +355,20 @@ ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS privacy_acceptee_le TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS relais_id UUID REFERENCES relais(id) ON DELETE SET NULL; +-- ========================================================== +-- Ticket #103 : Numéro de dossier (AAAA-NNNNNN, séquence par année) +-- ========================================================== +CREATE TABLE IF NOT EXISTS numero_dossier_sequence ( + annee INT PRIMARY KEY, + prochain INT NOT NULL DEFAULT 1 +); +ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL; +ALTER TABLE assistantes_maternelles ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL; +ALTER TABLE parents ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL; +CREATE INDEX IF NOT EXISTS idx_utilisateurs_numero_dossier ON utilisateurs(numero_dossier) WHERE numero_dossier IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_assistantes_maternelles_numero_dossier ON assistantes_maternelles(numero_dossier) WHERE numero_dossier IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_parents_numero_dossier ON parents(numero_dossier) WHERE numero_dossier IS NOT NULL; + -- ========================================================== -- Seed : Documents légaux génériques v1 -- ========================================================== diff --git a/database/migrations/2026_numero_dossier.sql b/database/migrations/2026_numero_dossier.sql new file mode 100644 index 0000000..4af0866 --- /dev/null +++ b/database/migrations/2026_numero_dossier.sql @@ -0,0 +1,33 @@ +-- Migration #103 : Numéro de dossier (format AAAA-NNNNNN, séquence par année) +-- Colonnes sur utilisateurs, assistantes_maternelles, parents. +-- Table de séquence par année pour génération unique. + +BEGIN; + +-- Table de séquence : une ligne par année, prochain = prochain numéro à attribuer (1..999999) +CREATE TABLE IF NOT EXISTS numero_dossier_sequence ( + annee INT PRIMARY KEY, + prochain INT NOT NULL DEFAULT 1 +); + +-- Colonne sur utilisateurs (AM et parents : numéro attribué à la soumission) +ALTER TABLE utilisateurs + ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL; + +-- Colonne sur assistantes_maternelles (redondant avec users pour accès direct) +ALTER TABLE assistantes_maternelles + ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL; + +-- Colonne sur parents (un numéro par famille, même valeur sur les deux lignes si co-parent) +ALTER TABLE parents + ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL; + +-- Index pour recherche par numéro +CREATE INDEX IF NOT EXISTS idx_utilisateurs_numero_dossier + ON utilisateurs(numero_dossier) WHERE numero_dossier IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_assistantes_maternelles_numero_dossier + ON assistantes_maternelles(numero_dossier) WHERE numero_dossier IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_parents_numero_dossier + ON parents(numero_dossier) WHERE numero_dossier IS NOT NULL; + +COMMIT; diff --git a/database/migrations/2026_numero_dossier_backfill.sql b/database/migrations/2026_numero_dossier_backfill.sql new file mode 100644 index 0000000..ef2590e --- /dev/null +++ b/database/migrations/2026_numero_dossier_backfill.sql @@ -0,0 +1,122 @@ +-- Backfill #103 : attribuer un numero_dossier aux entrées existantes (NULL) +-- Famille = lien co_parent OU partage d'au moins un enfant (même dossier). +-- Ordre : par année, AM puis familles (une entrée par famille), séquence 000001, 000002... +-- À exécuter après 2026_numero_dossier.sql + +DO $$ +DECLARE + yr INT; + seq INT; + num TEXT; + r RECORD; + family_user_ids UUID[]; +BEGIN + -- Réinitialiser pour rejouer le backfill (cohérence AM + familles) + UPDATE parents SET numero_dossier = NULL; + UPDATE utilisateurs SET numero_dossier = NULL + WHERE role IN ('parent', 'assistante_maternelle'); + UPDATE assistantes_maternelles SET numero_dossier = NULL; + + FOR yr IN + SELECT DISTINCT EXTRACT(YEAR FROM u.cree_le)::INT + FROM utilisateurs u + WHERE ( + (u.role = 'assistante_maternelle' AND u.numero_dossier IS NULL) + OR EXISTS (SELECT 1 FROM parents p WHERE p.id_utilisateur = u.id AND p.numero_dossier IS NULL) + ) + ORDER BY 1 + LOOP + seq := 0; + + -- 1) AM : par ordre de création + FOR r IN + SELECT u.id + FROM utilisateurs u + WHERE u.role = 'assistante_maternelle' + AND u.numero_dossier IS NULL + AND EXTRACT(YEAR FROM u.cree_le) = yr + ORDER BY u.cree_le + LOOP + seq := seq + 1; + num := yr || '-' || LPAD(seq::TEXT, 6, '0'); + UPDATE utilisateurs SET numero_dossier = num WHERE id = r.id; + UPDATE assistantes_maternelles SET numero_dossier = num WHERE id_utilisateur = r.id; + END LOOP; + + -- 2) Familles : une entrée par "dossier" (co_parent OU enfants partagés) + -- family_rep = min(id) de la composante connexe (lien co_parent + partage d'enfants) + FOR r IN + WITH RECURSIVE + links AS ( + SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL + UNION ALL + SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL + UNION ALL + SELECT ep1.id_parent AS p1, ep2.id_parent AS p2 + FROM enfants_parents ep1 + JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent + UNION ALL + SELECT ep2.id_parent AS p1, ep1.id_parent AS p2 + FROM enfants_parents ep1 + JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent + ), + rec AS ( + SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents + UNION + SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1 + ), + family_rep AS ( + SELECT id, MIN(rep::text)::uuid AS rep FROM rec GROUP BY id + ), + fam_ordered AS ( + SELECT fr.rep AS family_rep, MIN(u.cree_le) AS cree_le + FROM family_rep fr + JOIN parents p ON p.id_utilisateur = fr.id + JOIN utilisateurs u ON u.id = p.id_utilisateur + WHERE p.numero_dossier IS NULL + AND EXTRACT(YEAR FROM u.cree_le) = yr + GROUP BY fr.rep + ORDER BY MIN(u.cree_le) + ) + SELECT fo.family_rep + FROM fam_ordered fo + LOOP + seq := seq + 1; + num := yr || '-' || LPAD(seq::TEXT, 6, '0'); + + WITH RECURSIVE + links AS ( + SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL + UNION ALL + SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL + UNION ALL + SELECT ep1.id_parent AS p1, ep2.id_parent AS p2 + FROM enfants_parents ep1 + JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent + UNION ALL + SELECT ep2.id_parent AS p1, ep1.id_parent AS p2 + FROM enfants_parents ep1 + JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent + ), + rec AS ( + SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents + UNION + SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1 + ), + family_rep AS ( + SELECT id, MIN(rep::text)::uuid AS rep FROM rec GROUP BY id + ) + SELECT array_agg(DISTINCT fr.id) INTO family_user_ids + FROM family_rep fr + WHERE fr.rep = r.family_rep; + + UPDATE utilisateurs SET numero_dossier = num WHERE id = ANY(family_user_ids); + UPDATE parents SET numero_dossier = num WHERE id_utilisateur = ANY(family_user_ids); + END LOOP; + + INSERT INTO numero_dossier_sequence (annee, prochain) + VALUES (yr, seq + 1) + ON CONFLICT (annee) DO UPDATE + SET prochain = GREATEST(numero_dossier_sequence.prochain, seq + 1); + END LOOP; +END $$; diff --git a/docs/23_LISTE-TICKETS.md b/docs/23_LISTE-TICKETS.md index 5a879ac..4e31cfd 100644 --- a/docs/23_LISTE-TICKETS.md +++ b/docs/23_LISTE-TICKETS.md @@ -1,7 +1,7 @@ # 🎫 Liste Complète des Tickets - Projet P'titsPas -**Version** : 1.5 -**Date** : 24 Février 2026 +**Version** : 1.6 +**Date** : 25 Février 2026 **Auteur** : Équipe PtitsPas **Estimation totale** : ~208h @@ -9,7 +9,7 @@ ## 🔗 Liste des tickets Gitea -**Les numéros de section dans ce document = numéros d’issues Gitea.** Ticket #14 dans le doc = issue Gitea #14, etc. Source : dépôt `jmartin/petitspas` (état au 9 février 2026). +**Les numéros de section dans ce document = numéros d’issues Gitea.** Ticket #14 dans le doc = issue Gitea #14, etc. Source : dépôt `jmartin/petitspas` (état au 25 février 2026). | Gitea # | Titre (dépôt) | Statut | |--------|----------------|--------| @@ -25,21 +25,86 @@ | 12 | [Backend] Guard Configuration Initiale | ✅ Fermé | | 13 | [Backend] Adaptation MailService pour config dynamique | ✅ Fermé | | 14 | [Frontend] Panneau Paramètres / Configuration (première config + accès permanent) | Ouvert | -| 15 | [Frontend] Écran Paramètres (accès permanent) | Ouvert | +| 15 | [Frontend] Écran Paramètres (accès permanent) / Intégration panneau | Ouvert | | 16 | [Doc] Documentation configuration on-premise | Ouvert | | 17 | [Backend] API Création gestionnaire | ✅ Terminé | +| 18 | [Backend] API Inscription Parent - REFONTE (Workflow complet 6 étapes) | ✅ Terminé | +| 19 | [Backend] API Inscription Parent (étape 2 - Parent 2) | ✅ Terminé | +| 20 | [Backend] API Inscription Parent (étape 3 - Enfants) | ✅ Terminé | +| 21 | [Backend] API Inscription Parent (étape 4-6 - Finalisation) | ✅ Terminé | +| 24 | [Backend] API Création mot de passe | Ouvert | +| 25 | [Backend] API Liste comptes en attente | Ouvert | +| 26 | [Backend] API Validation/Refus comptes | Ouvert | +| 27 | [Backend] Service Email - Installation Nodemailer | Ouvert | +| 28 | [Backend] Templates Email - Validation | Ouvert | +| 29 | [Backend] Templates Email - Refus | Ouvert | +| 30 | [Backend] Connexion - Vérification statut | Ouvert | +| 31 | [Backend] Changement MDP obligatoire première connexion | Ouvert | +| 32 | [Backend] Service Documents Légaux | Ouvert | +| 33 | [Backend] API Documents Légaux | Ouvert | +| 34 | [Backend] Traçabilité acceptations documents | Ouvert | +| 35 | [Frontend] Écran Création Gestionnaire | Ouvert | +| 36 | [Frontend] Inscription Parent - Étape 1 (Parent 1) | ✅ Terminé | +| 37 | [Frontend] Inscription Parent - Étape 2 (Parent 2) | Ouvert | +| 38 | [Frontend] Inscription Parent - Étape 3 (Enfants) | ✅ Terminé | +| 39 | [Frontend] Inscription Parent - Étapes 4-6 (Finalisation) | ✅ Terminé | +| 40 | [Frontend] Inscription AM - Panneau 1 (Identité) | ✅ Terminé | +| 41 | [Frontend] Inscription AM - Panneau 2 (Infos pro) | ✅ Terminé | +| 42 | [Frontend] Inscription AM - Finalisation | ✅ Terminé | +| 43 | [Frontend] Écran Création Mot de Passe | Ouvert | +| 44 | [Frontend] Dashboard Gestionnaire - Structure | ✅ Terminé | +| 45 | [Frontend] Dashboard Gestionnaire - Liste Parents | Ouvert | +| 46 | [Frontend] Dashboard Gestionnaire - Liste AM | Ouvert | +| 47 | [Frontend] Écran Changement MDP Obligatoire | Ouvert | +| 48 | [Frontend] Gestion Erreurs & Messages | Ouvert | +| 49 | [Frontend] Écran Gestion Documents Légaux (Admin) | Ouvert | +| 50 | [Frontend] Affichage dynamique CGU lors inscription | Ouvert | +| 51 | [Frontend] Écran Logs Admin (optionnel v1.1) | Ouvert | +| 52 | [Tests] Tests unitaires Backend | Ouvert | +| 53 | [Tests] Tests intégration Backend | Ouvert | +| 54 | [Tests] Tests E2E Frontend | Ouvert | +| 55 | [Doc] Documentation API OpenAPI/Swagger | Ouvert | +| 56 | [Backend] Service Upload & Stockage fichiers | Ouvert | +| 58 | [Backend] Service Logging (Winston) | Ouvert | +| 59 | [Infra] Volume Docker pour uploads | Ouvert | +| 60 | [Infra] Volume Docker pour documents légaux | Ouvert | +| 61 | [Doc] Guide installation & configuration | Ouvert | +| 62 | [Doc] Amendement CDC v1.4 - Suppression SMS | Ouvert | +| 63 | [Doc] Rédaction CGU/Privacy génériques v1 | Ouvert | +| 78 | [Frontend] Refonte Infrastructure Formulaires Multi-modes | ✅ Terminé | +| 79 | [Frontend] Renommer "Nanny" en "Assistante Maternelle" (AM) | ✅ Terminé | +| 81 | [Frontend] Corrections suite refactoring widgets | ✅ Terminé | +| 83 | [Frontend] Adapter RegisterChoiceScreen pour mobile | ✅ Terminé | +| 86 / 88 | Doublons fermés (voir #12, #14, #15) | ✅ Fermé | +| 89 | Log des appels API en mode debug | Ouvert | | 91 | [Frontend] Inscription AM – Branchement soumission formulaire à l'API | Ouvert | -| 101 | [Frontend] Inscription Parent – Branchement soumission formulaire à l'API | Ouvert | | 92 | [Frontend] Dashboard Admin - Données réelles et branchement API | ✅ Terminé | -| 93 | [Frontend] Panneau Admin - Homogeneiser la presentation des onglets | ✅ Fermé | -| 94 | [Backend] Relais - modele, API CRUD et liaison gestionnaire | ✅ Terminé | -| 95 | [Frontend] Admin - gestion des relais et rattachement gestionnaire | ✅ Fermé | +| 93 | [Frontend] Panneau Admin - Homogénéisation des onglets | ✅ Fermé | +| 94 | [Backend] Relais - Modèle, API CRUD et liaison gestionnaire | ✅ Terminé | +| 95 | [Frontend] Admin - Gestion des Relais et rattachement gestionnaire | ✅ Fermé | | 96 | [Frontend] Admin - Création administrateur via modale (sans relais) | ✅ Terminé | | 97 | [Backend] Harmoniser API création administrateur avec le contrat frontend | ✅ Terminé | -| 89 | Log des appels API en mode debug | Ouvert | +| 101 | [Frontend] Inscription Parent – Branchement soumission formulaire à l'API | Ouvert | +| 103 | Numéro de dossier – backend | Ouvert | +| 104 | Numéro de dossier – frontend | Ouvert | +| 105 | Statut « refusé » | Ouvert | +| 106 | Liste familles en attente | Ouvert | +| 107 | Onglet « À valider » + listes | Ouvert | +| 108 | Validation dossier famille | Ouvert | +| 109 | Modale de validation | Ouvert | +| 110 | Refus sans suppression | Ouvert | +| 111 | Reprise après refus – backend | Ouvert | +| 112 | Reprise après refus – frontend | Ouvert | +| 113 | Doublons à l'inscription | Ouvert | +| 114 | Doublons – alerte gestionnaire | Ouvert | +| 115 | Rattachement parent – backend | Ouvert | +| 116 | Rattachement parent – frontend | Ouvert | +| 117 | Évolution du cahier des charges | Ouvert | *Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues* +*Tickets #103 à #117 = périmètre « Validation des nouveaux comptes par le gestionnaire » (plan + sync via `backend/scripts/sync-gitea-validation-tickets.js`).* + --- ## 📊 Vue d'ensemble @@ -1412,7 +1477,7 @@ Rédiger les documents légaux génériques (CGU et Politique de confidentialit --- -**Dernière mise à jour** : 24 Février 2026 +**Dernière mise à jour** : 25 Février 2026 **Version** : 1.6 -**Statut** : ✅ Aligné avec le dépôt Gitea +**Statut** : ✅ Aligné avec le dépôt Gitea (tickets #103-#117 créés) From 94c8a0d97aaa777e10d64a9a9aa83511e90d15e7 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 12 Mar 2026 22:28:13 +0100 Subject: [PATCH 04/13] =?UTF-8?q?Merge=20branch=20'develop'=20(squash)=20?= =?UTF-8?q?=E2=80=93=20Statut=20refus=C3=A9=20#105,=20script=20Gitea=20fal?= =?UTF-8?q?lback=20~/.bashrc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- backend/src/entities/users.entity.ts | 1 + backend/src/routes/auth/auth.service.ts | 6 +++ backend/src/routes/user/user.controller.ts | 22 ++++++++++ backend/src/routes/user/user.service.ts | 41 ++++++++++++++++++- database/BDD.sql | 2 +- .../2026_statut_utilisateur_refuse.sql | 4 ++ scripts/gitea-close-issue-with-comment.sh | 11 ++++- 7 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 database/migrations/2026_statut_utilisateur_refuse.sql diff --git a/backend/src/entities/users.entity.ts b/backend/src/entities/users.entity.ts index ca5d2ef..d44726e 100644 --- a/backend/src/entities/users.entity.ts +++ b/backend/src/entities/users.entity.ts @@ -29,6 +29,7 @@ export enum StatutUtilisateurType { EN_ATTENTE = 'en_attente', ACTIF = 'actif', SUSPENDU = 'suspendu', + REFUSE = 'refuse', } export enum SituationFamilialeType { diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index 45e7ee5..edaba73 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -96,6 +96,12 @@ export class AuthService { throw new UnauthorizedException('Votre compte a été suspendu. Contactez un administrateur.'); } + if (user.statut === StatutUtilisateurType.REFUSE) { + throw new UnauthorizedException( + 'Votre compte a été refusé. Vous pouvez corriger votre dossier et le soumettre à nouveau ; un gestionnaire pourra le réexaminer.', + ); + } + return this.generateTokens(user.id, user.email, user.role); } diff --git a/backend/src/routes/user/user.controller.ts b/backend/src/routes/user/user.controller.ts index d7df010..90b8535 100644 --- a/backend/src/routes/user/user.controller.ts +++ b/backend/src/routes/user/user.controller.ts @@ -50,6 +50,16 @@ export class UserController { return this.userService.findPendingUsers(role); } + // Lister les comptes refusés (à corriger / reprise) + @Get('reprise') + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) + @ApiOperation({ summary: 'Lister les comptes refusés (reprise)' }) + findRefusedUsers( + @Query('role') role?: RoleType + ) { + return this.userService.findRefusedUsers(role); + } + // Lister tous les utilisateurs (super_admin uniquement) @Get() @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) @@ -112,6 +122,18 @@ export class UserController { return this.userService.validateUser(id, currentUser, comment); } + @Patch(':id/refuser') + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) + @ApiOperation({ summary: 'Refuser un compte (à corriger)' }) + @ApiParam({ name: 'id', description: "UUID de l'utilisateur" }) + refuse( + @Param('id') id: string, + @User() currentUser: Users, + @Body('comment') comment?: string, + ) { + return this.userService.refuseUser(id, currentUser, comment); + } + @Patch(':id/suspendre') @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @ApiOperation({ summary: 'Suspendre un compte utilisateur' }) diff --git a/backend/src/routes/user/user.service.ts b/backend/src/routes/user/user.service.ts index 3ef45b3..e6c270c 100644 --- a/backend/src/routes/user/user.service.ts +++ b/backend/src/routes/user/user.service.ts @@ -140,6 +140,15 @@ export class UserService { return this.usersRepository.find({ where }); } + /** Comptes refusés (à corriger) : liste pour reprise par le gestionnaire */ + async findRefusedUsers(role?: RoleType): Promise { + const where: any = { statut: StatutUtilisateurType.REFUSE }; + if (role) { + where.role = role; + } + return this.usersRepository.find({ where }); + } + async findAll(): Promise { return this.usersRepository.find(); } @@ -214,7 +223,7 @@ export class UserService { return this.usersRepository.save(user); } - // Valider un compte utilisateur + // Valider un compte utilisateur (en_attente ou refuse -> actif) async validateUser(user_id: string, currentUser: Users, comment?: string): Promise { if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) { throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires'); @@ -222,7 +231,11 @@ export class UserService { const user = await this.usersRepository.findOne({ where: { id: user_id } }); if (!user) throw new NotFoundException('Utilisateur introuvable'); - + + if (user.statut !== StatutUtilisateurType.EN_ATTENTE && user.statut !== StatutUtilisateurType.REFUSE) { + throw new BadRequestException('Seuls les comptes en attente ou refusés (à corriger) peuvent être validés.'); + } + user.statut = StatutUtilisateurType.ACTIF; const savedUser = await this.usersRepository.save(user); if (user.role === RoleType.PARENT) { @@ -270,6 +283,30 @@ export class UserService { await this.validationRepository.save(suspend); return savedUser; } + + /** Refuser un compte (en_attente -> refuse) ; tracé dans validations */ + async refuseUser(user_id: string, currentUser: Users, comment?: string): Promise { + if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) { + throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires'); + } + const user = await this.usersRepository.findOne({ where: { id: user_id } }); + if (!user) throw new NotFoundException('Utilisateur introuvable'); + if (user.statut !== StatutUtilisateurType.EN_ATTENTE) { + throw new BadRequestException('Seul un compte en attente peut être refusé.'); + } + user.statut = StatutUtilisateurType.REFUSE; + const savedUser = await this.usersRepository.save(user); + const validation = this.validationRepository.create({ + user: savedUser, + type: 'refus_compte', + status: StatutValidationType.REFUSE, + validated_by: currentUser, + comment, + }); + await this.validationRepository.save(validation); + return savedUser; + } + /** * Affecter ou modifier le numéro de dossier d'un utilisateur (parent ou AM). * Permet au gestionnaire/admin de rapprocher deux dossiers (même numéro pour plusieurs personnes). diff --git a/database/BDD.sql b/database/BDD.sql index fc04af8..57d67ee 100644 --- a/database/BDD.sql +++ b/database/BDD.sql @@ -11,7 +11,7 @@ DO $$ BEGIN CREATE TYPE genre_type AS ENUM ('H', 'F'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_utilisateur_type') THEN - CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente','actif','suspendu'); + CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente','actif','suspendu','refuse'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_enfant_type') THEN CREATE TYPE statut_enfant_type AS ENUM ('a_naitre','actif','scolarise'); diff --git a/database/migrations/2026_statut_utilisateur_refuse.sql b/database/migrations/2026_statut_utilisateur_refuse.sql new file mode 100644 index 0000000..0ce2b78 --- /dev/null +++ b/database/migrations/2026_statut_utilisateur_refuse.sql @@ -0,0 +1,4 @@ +-- Migration #105 : Statut utilisateur « refusé » (à corriger) +-- Ajout de la valeur 'refuse' à l'enum statut_utilisateur_type. + +ALTER TYPE statut_utilisateur_type ADD VALUE IF NOT EXISTS 'refuse'; diff --git a/scripts/gitea-close-issue-with-comment.sh b/scripts/gitea-close-issue-with-comment.sh index ad545b8..0c1e799 100644 --- a/scripts/gitea-close-issue-with-comment.sh +++ b/scripts/gitea-close-issue-with-comment.sh @@ -15,9 +15,18 @@ if [ -z "$GITEA_TOKEN" ]; then GITEA_TOKEN=$(cat .gitea-token) fi fi +if [ -z "$GITEA_TOKEN" ] && [ -f ~/.bashrc ]; then + eval "$(grep '^export GITEA_TOKEN=' ~/.bashrc 2>/dev/null)" || true +fi +if [ -z "$GITEA_TOKEN" ] && [ -f docs/BRIEFING-FRONTEND.md ]; then + token_from_briefing=$(sed -n 's/.*Token: *\(giteabu_[a-f0-9]*\).*/\1/p' docs/BRIEFING-FRONTEND.md 2>/dev/null | head -1) + if [ -n "$token_from_briefing" ]; then + GITEA_TOKEN="$token_from_briefing" + fi +fi if [ -z "$GITEA_TOKEN" ]; then - echo "Définir GITEA_TOKEN ou créer .gitea-token avec votre token Gitea." + echo "Définir GITEA_TOKEN ou créer .gitea-token avec votre token Gitea (voir docs/PROCEDURE-API-GITEA.md)." exit 1 fi From a92447aaf06b89aaa14b8f4e59de246b88cb1369 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 12 Mar 2026 22:37:41 +0100 Subject: [PATCH 05/13] =?UTF-8?q?Merge=20branch=20'develop'=20(squash)=20?= =?UTF-8?q?=E2=80=93=20Liste=20familles=20en=20attente=20#106?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- .../routes/parents/dto/pending-family.dto.ts | 20 ++++++++ .../src/routes/parents/parents.controller.ts | 16 ++++++- backend/src/routes/parents/parents.service.ts | 48 +++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 backend/src/routes/parents/dto/pending-family.dto.ts diff --git a/backend/src/routes/parents/dto/pending-family.dto.ts b/backend/src/routes/parents/dto/pending-family.dto.ts new file mode 100644 index 0000000..e7706d3 --- /dev/null +++ b/backend/src/routes/parents/dto/pending-family.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class PendingFamilyDto { + @ApiProperty({ example: 'Famille Dupont', description: 'Libellé affiché pour la famille' }) + libelle: string; + + @ApiProperty({ + type: [String], + example: ['uuid-parent-1', 'uuid-parent-2'], + description: 'IDs utilisateur des parents de la famille', + }) + parentIds: string[]; + + @ApiProperty({ + nullable: true, + example: '2026-000001', + description: 'Numéro de dossier famille (format AAAA-NNNNNN)', + }) + numero_dossier: string | null; +} diff --git a/backend/src/routes/parents/parents.controller.ts b/backend/src/routes/parents/parents.controller.ts index e4a9ee2..a6ed455 100644 --- a/backend/src/routes/parents/parents.controller.ts +++ b/backend/src/routes/parents/parents.controller.ts @@ -6,20 +6,34 @@ import { Param, Patch, Post, + UseGuards, } from '@nestjs/common'; import { ParentsService } from './parents.service'; import { Parents } from 'src/entities/parents.entity'; import { Roles } from 'src/common/decorators/roles.decorator'; import { RoleType } from 'src/entities/users.entity'; -import { ApiBody, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { CreateParentDto } from '../user/dto/create_parent.dto'; import { UpdateParentsDto } from '../user/dto/update_parent.dto'; +import { AuthGuard } from 'src/common/guards/auth.guard'; +import { RolesGuard } from 'src/common/guards/roles.guard'; +import { PendingFamilyDto } from './dto/pending-family.dto'; @ApiTags('Parents') @Controller('parents') +@UseGuards(AuthGuard, RolesGuard) export class ParentsController { constructor(private readonly parentsService: ParentsService) {} + @Get('pending-families') + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) + @ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' }) + @ApiResponse({ status: 200, description: 'Liste des familles (libellé, parentIds, numero_dossier)', type: [PendingFamilyDto] }) + @ApiResponse({ status: 403, description: 'Accès refusé' }) + getPendingFamilies(): Promise { + return this.parentsService.getPendingFamilies(); + } + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Get() @ApiResponse({ status: 200, type: [Parents], description: 'Liste des parents' }) diff --git a/backend/src/routes/parents/parents.service.ts b/backend/src/routes/parents/parents.service.ts index d2cafee..e2c50e6 100644 --- a/backend/src/routes/parents/parents.service.ts +++ b/backend/src/routes/parents/parents.service.ts @@ -10,6 +10,7 @@ import { Parents } from 'src/entities/parents.entity'; import { RoleType, Users } from 'src/entities/users.entity'; import { CreateParentDto } from '../user/dto/create_parent.dto'; import { UpdateParentsDto } from '../user/dto/update_parent.dto'; +import { PendingFamilyDto } from './dto/pending-family.dto'; @Injectable() export class ParentsService { @@ -71,4 +72,51 @@ export class ParentsService { await this.parentsRepository.update(id, dto); return this.findOne(id); } + + /** + * Liste des familles en attente (une entrée par famille). + * Famille = lien co_parent ou partage d'enfants (même logique que backfill #103). + * Uniquement les parents dont l'utilisateur a statut = en_attente. + */ + async getPendingFamilies(): Promise { + const raw = await this.parentsRepository.query(` + WITH RECURSIVE + links AS ( + SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL + UNION ALL + SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL + UNION ALL + SELECT ep1.id_parent AS p1, ep2.id_parent AS p2 + FROM enfants_parents ep1 + JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent + UNION ALL + SELECT ep2.id_parent AS p1, ep1.id_parent AS p2 + FROM enfants_parents ep1 + JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent + ), + rec AS ( + SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents + UNION + SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1 + ), + family_rep AS ( + SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id + ) + SELECT + 'Famille ' || string_agg(u.nom, ' - ' ORDER BY u.nom, u.prenom) AS libelle, + array_agg(DISTINCT p.id_utilisateur ORDER BY p.id_utilisateur) AS "parentIds", + (array_agg(p.numero_dossier))[1] AS numero_dossier + FROM family_rep fr + JOIN parents p ON p.id_utilisateur = fr.id + JOIN utilisateurs u ON u.id = p.id_utilisateur + WHERE u.role = 'parent' AND u.statut = 'en_attente' + GROUP BY fr.rep + ORDER BY libelle + `); + return raw.map((r: { libelle: string; parentIds: unknown; numero_dossier: string | null }) => ({ + libelle: r.libelle, + parentIds: Array.isArray(r.parentIds) ? r.parentIds.map(String) : [], + numero_dossier: r.numero_dossier ?? null, + })); + } } From aa4e240ad1b3eaf124069f17329f2c4c0e8728f1 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 12 Mar 2026 22:47:41 +0100 Subject: [PATCH 06/13] =?UTF-8?q?Merge=20branch=20'develop'=20(squash)=20?= =?UTF-8?q?=E2=80=93=20Validation=20dossier=20famille=20#108?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- .../src/routes/parents/parents.controller.ts | 36 +++++++++++++-- backend/src/routes/parents/parents.module.ts | 8 +++- backend/src/routes/parents/parents.service.ts | 45 +++++++++++++++++++ 3 files changed, 83 insertions(+), 6 deletions(-) diff --git a/backend/src/routes/parents/parents.controller.ts b/backend/src/routes/parents/parents.controller.ts index a6ed455..edadf2b 100644 --- a/backend/src/routes/parents/parents.controller.ts +++ b/backend/src/routes/parents/parents.controller.ts @@ -1,7 +1,6 @@ import { Body, Controller, - Delete, Get, Param, Patch, @@ -9,21 +8,27 @@ import { UseGuards, } from '@nestjs/common'; import { ParentsService } from './parents.service'; +import { UserService } from '../user/user.service'; import { Parents } from 'src/entities/parents.entity'; +import { Users } from 'src/entities/users.entity'; import { Roles } from 'src/common/decorators/roles.decorator'; -import { RoleType } from 'src/entities/users.entity'; -import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity'; +import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { CreateParentDto } from '../user/dto/create_parent.dto'; import { UpdateParentsDto } from '../user/dto/update_parent.dto'; import { AuthGuard } from 'src/common/guards/auth.guard'; import { RolesGuard } from 'src/common/guards/roles.guard'; +import { User } from 'src/common/decorators/user.decorator'; import { PendingFamilyDto } from './dto/pending-family.dto'; @ApiTags('Parents') @Controller('parents') @UseGuards(AuthGuard, RolesGuard) export class ParentsController { - constructor(private readonly parentsService: ParentsService) {} + constructor( + private readonly parentsService: ParentsService, + private readonly userService: UserService, + ) {} @Get('pending-families') @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) @@ -34,6 +39,29 @@ export class ParentsController { return this.parentsService.getPendingFamilies(); } + @Post(':parentId/valider-dossier') + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) + @ApiOperation({ summary: 'Valider tout le dossier famille (les 2 parents en une fois)' }) + @ApiParam({ name: 'parentId', description: "UUID d'un des parents (user_id)" }) + @ApiResponse({ status: 200, description: 'Utilisateurs validés (famille)' }) + @ApiResponse({ status: 404, description: 'Parent introuvable' }) + @ApiResponse({ status: 403, description: 'Accès refusé' }) + async validerDossierFamille( + @Param('parentId') parentId: string, + @User() currentUser: Users, + @Body('comment') comment?: string, + ): Promise { + const familyIds = await this.parentsService.getFamilyUserIds(parentId); + const validated: Users[] = []; + for (const userId of familyIds) { + const user = await this.userService.findOne(userId); + if (user.statut !== StatutUtilisateurType.EN_ATTENTE && user.statut !== StatutUtilisateurType.REFUSE) continue; + const saved = await this.userService.validateUser(userId, currentUser, comment); + validated.push(saved); + } + return validated; + } + @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Get() @ApiResponse({ status: 200, type: [Parents], description: 'Liste des parents' }) diff --git a/backend/src/routes/parents/parents.module.ts b/backend/src/routes/parents/parents.module.ts index dc57fe6..6cb557b 100644 --- a/backend/src/routes/parents/parents.module.ts +++ b/backend/src/routes/parents/parents.module.ts @@ -1,12 +1,16 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Parents } from 'src/entities/parents.entity'; import { ParentsController } from './parents.controller'; import { ParentsService } from './parents.service'; import { Users } from 'src/entities/users.entity'; +import { UserModule } from '../user/user.module'; @Module({ - imports: [TypeOrmModule.forFeature([Parents, Users])], + imports: [ + TypeOrmModule.forFeature([Parents, Users]), + forwardRef(() => UserModule), + ], controllers: [ParentsController], providers: [ParentsService], exports: [ParentsService, diff --git a/backend/src/routes/parents/parents.service.ts b/backend/src/routes/parents/parents.service.ts index e2c50e6..3aa174d 100644 --- a/backend/src/routes/parents/parents.service.ts +++ b/backend/src/routes/parents/parents.service.ts @@ -119,4 +119,49 @@ export class ParentsService { numero_dossier: r.numero_dossier ?? null, })); } + + /** + * Retourne les user_id de tous les parents de la même famille (co_parent ou enfants partagés). + * @throws NotFoundException si parentId n'est pas un parent + */ + async getFamilyUserIds(parentId: string): Promise { + const raw = await this.parentsRepository.query( + ` + WITH RECURSIVE + links AS ( + SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL + UNION ALL + SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL + UNION ALL + SELECT ep1.id_parent AS p1, ep2.id_parent AS p2 + FROM enfants_parents ep1 + JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent + UNION ALL + SELECT ep2.id_parent AS p1, ep1.id_parent AS p2 + FROM enfants_parents ep1 + JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent + ), + rec AS ( + SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents + UNION + SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1 + ), + family_rep AS ( + SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id + ), + input_rep AS ( + SELECT rep FROM family_rep WHERE id = $1::uuid LIMIT 1 + ) + SELECT fr.id::text AS id + FROM family_rep fr + CROSS JOIN input_rep ir + WHERE fr.rep = ir.rep + `, + [parentId], + ); + if (!raw || raw.length === 0) { + throw new NotFoundException('Parent introuvable ou pas encore enregistré en base.'); + } + return raw.map((r: { id: string }) => r.id); + } } From 7e32eef0a726dcc86866462f18aee258602d5532 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 12 Mar 2026 22:57:45 +0100 Subject: [PATCH 07/13] =?UTF-8?q?Merge=20branch=20'develop'=20(squash)=20?= =?UTF-8?q?=E2=80=93=20Refus=20sans=20suppression=20#110?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- backend/src/entities/users.entity.ts | 7 ++++ backend/src/modules/mail/mail.service.ts | 37 +++++++++++++++++++ backend/src/routes/user/user.module.ts | 2 + backend/src/routes/user/user.service.ts | 33 +++++++++++++++-- database/BDD.sql | 7 ++++ .../migrations/2026_token_reprise_refus.sql | 10 +++++ 6 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 database/migrations/2026_token_reprise_refus.sql diff --git a/backend/src/entities/users.entity.ts b/backend/src/entities/users.entity.ts index d44726e..94d12bb 100644 --- a/backend/src/entities/users.entity.ts +++ b/backend/src/entities/users.entity.ts @@ -119,6 +119,13 @@ export class Users { @Column({ type: 'timestamptz', nullable: true, name: 'token_creation_mdp_expire_le' }) token_creation_mdp_expire_le?: Date; + /** Token pour reprise après refus (lien email), ticket #110 */ + @Column({ nullable: true, name: 'token_reprise', length: 255 }) + token_reprise?: string; + + @Column({ type: 'timestamptz', nullable: true, name: 'token_reprise_expire_le' }) + token_reprise_expire_le?: Date; + @Column({ nullable: true, name: 'ville' }) ville?: string; diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index c064915..6a1e872 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -97,4 +97,41 @@ export class MailService { await this.sendEmail(to, subject, html); } + + /** + * Email de refus de dossier avec lien reprise (token). + * Ticket #110 – Refus sans suppression + */ + async sendRefusEmail( + to: string, + prenom: string, + nom: string, + comment: string | undefined, + token: string, + ): Promise { + const appName = this.configService.get('app_name', "P'titsPas"); + const appUrl = this.configService.get('app_url', 'https://app.ptits-pas.fr'); + const repriseLink = `${appUrl}/reprise?token=${encodeURIComponent(token)}`; + + const subject = `Votre dossier – compléments demandés`; + const commentBlock = comment + ? `

Message du gestionnaire :

${comment.replace(//g, '>')}

` + : ''; + const html = ` +
+

Bonjour ${prenom} ${nom},

+

Votre dossier d'inscription sur ${appName} n'a pas pu être validé en l'état.

+ ${commentBlock} +

Vous pouvez corriger les éléments indiqués et soumettre à nouveau votre dossier en cliquant sur le lien ci-dessous.

+ +

Ce lien est valable 7 jours. Si vous n'avez pas demandé cette reprise, vous pouvez ignorer cet email.

+
+

Cet email a été envoyé automatiquement. Merci de ne pas y répondre.

+
+ `; + + await this.sendEmail(to, subject, html); + } } diff --git a/backend/src/routes/user/user.module.ts b/backend/src/routes/user/user.module.ts index 4d5d7cc..2924082 100644 --- a/backend/src/routes/user/user.module.ts +++ b/backend/src/routes/user/user.module.ts @@ -10,6 +10,7 @@ import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entit import { AssistantesMaternellesModule } from '../assistantes_maternelles/assistantes_maternelles.module'; import { Parents } from 'src/entities/parents.entity'; import { GestionnairesModule } from './gestionnaires/gestionnaires.module'; +import { MailModule } from 'src/modules/mail/mail.module'; @Module({ imports: [TypeOrmModule.forFeature( @@ -22,6 +23,7 @@ import { GestionnairesModule } from './gestionnaires/gestionnaires.module'; ParentsModule, AssistantesMaternellesModule, GestionnairesModule, + MailModule, ], controllers: [UserController], providers: [UserService], diff --git a/backend/src/routes/user/user.service.ts b/backend/src/routes/user/user.service.ts index e6c270c..83b6abb 100644 --- a/backend/src/routes/user/user.service.ts +++ b/backend/src/routes/user/user.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common"; +import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity"; import { In, Repository } from "typeorm"; @@ -9,9 +9,13 @@ import * as bcrypt from 'bcrypt'; import { StatutValidationType, Validation } from "src/entities/validations.entity"; import { Parents } from "src/entities/parents.entity"; import { AssistanteMaternelle } from "src/entities/assistantes_maternelles.entity"; +import { MailService } from "src/modules/mail/mail.service"; +import * as crypto from 'crypto'; @Injectable() export class UserService { + private readonly logger = new Logger(UserService.name); + constructor( @InjectRepository(Users) private readonly usersRepository: Repository, @@ -23,7 +27,9 @@ export class UserService { private readonly parentsRepository: Repository, @InjectRepository(AssistanteMaternelle) - private readonly assistantesRepository: Repository + private readonly assistantesRepository: Repository, + + private readonly mailService: MailService, ) { } async createUser(dto: CreateUserDto, currentUser?: Users): Promise { @@ -284,7 +290,7 @@ export class UserService { return savedUser; } - /** Refuser un compte (en_attente -> refuse) ; tracé dans validations */ + /** Refuser un compte (en_attente -> refuse) ; tracé validations, token reprise, email. Ticket #110 */ async refuseUser(user_id: string, currentUser: Users, comment?: string): Promise { if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) { throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires'); @@ -294,8 +300,16 @@ export class UserService { if (user.statut !== StatutUtilisateurType.EN_ATTENTE) { throw new BadRequestException('Seul un compte en attente peut être refusé.'); } + + const tokenReprise = crypto.randomUUID(); + const expireLe = new Date(); + expireLe.setDate(expireLe.getDate() + 7); + user.statut = StatutUtilisateurType.REFUSE; + user.token_reprise = tokenReprise; + user.token_reprise_expire_le = expireLe; const savedUser = await this.usersRepository.save(user); + const validation = this.validationRepository.create({ user: savedUser, type: 'refus_compte', @@ -304,6 +318,19 @@ export class UserService { comment, }); await this.validationRepository.save(validation); + + try { + await this.mailService.sendRefusEmail( + savedUser.email, + savedUser.prenom ?? '', + savedUser.nom ?? '', + comment, + tokenReprise, + ); + } catch (err) { + this.logger.warn(`Envoi email refus échoué pour ${savedUser.email}`, err); + } + return savedUser; } diff --git a/database/BDD.sql b/database/BDD.sql index 57d67ee..7a7ab17 100644 --- a/database/BDD.sql +++ b/database/BDD.sql @@ -369,6 +369,13 @@ CREATE INDEX IF NOT EXISTS idx_utilisateurs_numero_dossier ON utilisateurs(numer CREATE INDEX IF NOT EXISTS idx_assistantes_maternelles_numero_dossier ON assistantes_maternelles(numero_dossier) WHERE numero_dossier IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_parents_numero_dossier ON parents(numero_dossier) WHERE numero_dossier IS NOT NULL; +-- ========================================================== +-- Ticket #110 : Token reprise après refus (lien email) +-- ========================================================== +ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise VARCHAR(255) NULL; +ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL; +CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise ON utilisateurs(token_reprise) WHERE token_reprise IS NOT NULL; + -- ========================================================== -- Seed : Documents légaux génériques v1 -- ========================================================== diff --git a/database/migrations/2026_token_reprise_refus.sql b/database/migrations/2026_token_reprise_refus.sql new file mode 100644 index 0000000..f805840 --- /dev/null +++ b/database/migrations/2026_token_reprise_refus.sql @@ -0,0 +1,10 @@ +-- Migration #110 : Token reprise après refus (lien email) +-- Permet à l'utilisateur refusé de corriger et resoumettre via un lien sécurisé. + +ALTER TABLE utilisateurs + ADD COLUMN IF NOT EXISTS token_reprise VARCHAR(255) NULL, + ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL; + +CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise + ON utilisateurs(token_reprise) + WHERE token_reprise IS NOT NULL; From 060e610a75959f5b10e1a10f6c9d3d206672c25b Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 12 Mar 2026 23:06:04 +0100 Subject: [PATCH 08/13] =?UTF-8?q?Merge=20branch=20'develop'=20(squash)=20?= =?UTF-8?q?=E2=80=93=20Reprise=20apr=C3=A8s=20refus=20#111?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- backend/src/routes/auth/auth.controller.ts | 36 +++++++++++++- backend/src/routes/auth/auth.service.ts | 46 +++++++++++++++++ .../routes/auth/dto/reprise-dossier.dto.ts | 44 +++++++++++++++++ .../routes/auth/dto/reprise-identify.dto.ts | 23 +++++++++ .../auth/dto/resoumettre-reprise.dto.ts | 49 +++++++++++++++++++ backend/src/routes/user/user.service.ts | 48 +++++++++++++++++- 6 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 backend/src/routes/auth/dto/reprise-dossier.dto.ts create mode 100644 backend/src/routes/auth/dto/reprise-identify.dto.ts create mode 100644 backend/src/routes/auth/dto/resoumettre-reprise.dto.ts diff --git a/backend/src/routes/auth/auth.controller.ts b/backend/src/routes/auth/auth.controller.ts index 2eeaf98..258d853 100644 --- a/backend/src/routes/auth/auth.controller.ts +++ b/backend/src/routes/auth/auth.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Post, Req, UnauthorizedException, BadRequestException, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Patch, Post, Query, Req, UnauthorizedException, BadRequestException, UseGuards } from '@nestjs/common'; import { LoginDto } from './dto/login.dto'; import { AuthService } from './auth.service'; import { Public } from 'src/common/decorators/public.decorator'; @@ -6,14 +6,17 @@ import { RegisterDto } from './dto/register.dto'; import { RegisterParentCompletDto } from './dto/register-parent-complet.dto'; import { RegisterAMCompletDto } from './dto/register-am-complet.dto'; import { ChangePasswordRequiredDto } from './dto/change-password.dto'; -import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { AuthGuard } from 'src/common/guards/auth.guard'; import type { Request } from 'express'; import { UserService } from '../user/user.service'; import { ProfileResponseDto } from './dto/profile_response.dto'; import { RefreshTokenDto } from './dto/refresh_token.dto'; +import { ResoumettreRepriseDto } from './dto/resoumettre-reprise.dto'; +import { RepriseIdentifyBodyDto } from './dto/reprise-identify.dto'; import { User } from 'src/common/decorators/user.decorator'; import { Users } from 'src/entities/users.entity'; +import { RepriseDossierDto } from './dto/reprise-dossier.dto'; @ApiTags('Authentification') @Controller('auth') @@ -65,6 +68,35 @@ export class AuthController { return this.authService.inscrireAMComplet(dto); } + @Public() + @Get('reprise-dossier') + @ApiOperation({ summary: 'Dossier pour reprise (token seul)' }) + @ApiQuery({ name: 'token', required: true, description: 'Token reprise (lien email)' }) + @ApiResponse({ status: 200, description: 'Données dossier pour préremplir', type: RepriseDossierDto }) + @ApiResponse({ status: 404, description: 'Token invalide ou expiré' }) + async getRepriseDossier(@Query('token') token: string): Promise { + return this.authService.getRepriseDossier(token); + } + + @Public() + @Patch('reprise-resoumettre') + @ApiOperation({ summary: 'Resoumettre le dossier (mise à jour + statut en_attente, invalide le token)' }) + @ApiResponse({ status: 200, description: 'Dossier resoumis' }) + @ApiResponse({ status: 404, description: 'Token invalide ou expiré' }) + async resoumettreReprise(@Body() dto: ResoumettreRepriseDto) { + const { token, ...fields } = dto; + return this.authService.resoumettreReprise(token, fields); + } + + @Public() + @Post('reprise-identify') + @ApiOperation({ summary: 'Modale reprise : numéro + email → type + token' }) + @ApiResponse({ status: 201, description: 'type (parent/AM) + token pour GET reprise-dossier / PUT reprise-resoumettre' }) + @ApiResponse({ status: 404, description: 'Aucun dossier en reprise pour ce numéro et email' }) + async repriseIdentify(@Body() dto: RepriseIdentifyBodyDto) { + return this.authService.identifyReprise(dto.numero_dossier, dto.email); + } + @Public() @Post('refresh') @ApiBearerAuth('refresh_token') diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index edaba73..c6fe021 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -1,6 +1,7 @@ import { ConflictException, Injectable, + NotFoundException, UnauthorizedException, BadRequestException, } from '@nestjs/common'; @@ -22,6 +23,8 @@ import { Children, StatutEnfantType } from 'src/entities/children.entity'; import { ParentsChildren } from 'src/entities/parents_children.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { LoginDto } from './dto/login.dto'; +import { RepriseDossierDto } from './dto/reprise-dossier.dto'; +import { RepriseIdentifyResponseDto } from './dto/reprise-identify.dto'; import { AppConfigService } from 'src/modules/config/config.service'; import { validateNir } from 'src/common/utils/nir.util'; import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service'; @@ -501,4 +504,47 @@ export class AuthService { async logout(userId: string) { return { success: true, message: 'Deconnexion'} } + + /** GET dossier reprise – token seul. Ticket #111 */ + async getRepriseDossier(token: string): Promise { + const user = await this.usersService.findByTokenReprise(token); + if (!user) { + throw new NotFoundException('Token reprise invalide ou expiré.'); + } + return { + id: user.id, + email: user.email, + prenom: user.prenom, + nom: user.nom, + telephone: user.telephone, + adresse: user.adresse, + ville: user.ville, + code_postal: user.code_postal, + numero_dossier: user.numero_dossier, + role: user.role, + photo_url: user.photo_url, + genre: user.genre, + situation_familiale: user.situation_familiale, + }; + } + + /** PUT resoumission reprise. Ticket #111 */ + async resoumettreReprise( + token: string, + dto: { prenom?: string; nom?: string; telephone?: string; adresse?: string; ville?: string; code_postal?: string; photo_url?: string }, + ): Promise { + return this.usersService.resoumettreReprise(token, dto); + } + + /** POST reprise-identify : numero_dossier + email → type + token. Ticket #111 */ + async identifyReprise(numero_dossier: string, email: string): Promise { + const user = await this.usersService.findByNumeroDossierAndEmailForReprise(numero_dossier, email); + if (!user || !user.token_reprise) { + throw new NotFoundException('Aucun dossier en reprise trouvé pour ce numéro et cet email.'); + } + return { + type: user.role === RoleType.PARENT ? 'parent' : 'assistante_maternelle', + token: user.token_reprise, + }; + } } diff --git a/backend/src/routes/auth/dto/reprise-dossier.dto.ts b/backend/src/routes/auth/dto/reprise-dossier.dto.ts new file mode 100644 index 0000000..e81c6e4 --- /dev/null +++ b/backend/src/routes/auth/dto/reprise-dossier.dto.ts @@ -0,0 +1,44 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { RoleType } from 'src/entities/users.entity'; + +/** Réponse GET /auth/reprise-dossier – données dossier pour préremplir le formulaire reprise. Ticket #111 */ +export class RepriseDossierDto { + @ApiProperty() + id: string; + + @ApiProperty() + email: string; + + @ApiProperty({ required: false }) + prenom?: string; + + @ApiProperty({ required: false }) + nom?: string; + + @ApiProperty({ required: false }) + telephone?: string; + + @ApiProperty({ required: false }) + adresse?: string; + + @ApiProperty({ required: false }) + ville?: string; + + @ApiProperty({ required: false }) + code_postal?: string; + + @ApiProperty({ required: false }) + numero_dossier?: string; + + @ApiProperty({ enum: RoleType }) + role: RoleType; + + @ApiProperty({ required: false, description: 'Pour AM' }) + photo_url?: string; + + @ApiProperty({ required: false }) + genre?: string; + + @ApiProperty({ required: false }) + situation_familiale?: string; +} diff --git a/backend/src/routes/auth/dto/reprise-identify.dto.ts b/backend/src/routes/auth/dto/reprise-identify.dto.ts new file mode 100644 index 0000000..f37c483 --- /dev/null +++ b/backend/src/routes/auth/dto/reprise-identify.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsString, MaxLength } from 'class-validator'; + +/** Body POST /auth/reprise-identify – numéro + email pour obtenir token reprise. Ticket #111 */ +export class RepriseIdentifyBodyDto { + @ApiProperty({ example: '2026-000001' }) + @IsString() + @MaxLength(20) + numero_dossier: string; + + @ApiProperty({ example: 'parent@example.com' }) + @IsEmail() + email: string; +} + +/** Réponse POST /auth/reprise-identify */ +export class RepriseIdentifyResponseDto { + @ApiProperty({ enum: ['parent', 'assistante_maternelle'] }) + type: 'parent' | 'assistante_maternelle'; + + @ApiProperty({ description: 'Token à utiliser pour GET reprise-dossier et PUT reprise-resoumettre' }) + token: string; +} diff --git a/backend/src/routes/auth/dto/resoumettre-reprise.dto.ts b/backend/src/routes/auth/dto/resoumettre-reprise.dto.ts new file mode 100644 index 0000000..efec456 --- /dev/null +++ b/backend/src/routes/auth/dto/resoumettre-reprise.dto.ts @@ -0,0 +1,49 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength, IsUUID } from 'class-validator'; + +/** Body PUT /auth/reprise-resoumettre – token + champs modifiables. Ticket #111 */ +export class ResoumettreRepriseDto { + @ApiProperty({ description: 'Token reprise (reçu par email)' }) + @IsUUID() + token: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(100) + prenom?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(100) + nom?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(20) + telephone?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + adresse?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(150) + ville?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + @MaxLength(10) + code_postal?: string; + + @ApiProperty({ required: false, description: 'Pour AM' }) + @IsOptional() + @IsString() + photo_url?: string; +} diff --git a/backend/src/routes/user/user.service.ts b/backend/src/routes/user/user.service.ts index 83b6abb..7e44081 100644 --- a/backend/src/routes/user/user.service.ts +++ b/backend/src/routes/user/user.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity"; -import { In, Repository } from "typeorm"; +import { In, MoreThan, Repository } from "typeorm"; import { CreateUserDto } from "./dto/create_user.dto"; import { CreateAdminDto } from "./dto/create_admin.dto"; import { UpdateUserDto } from "./dto/update_user.dto"; @@ -392,6 +392,52 @@ export class UserService { return savedUser; } + /** Trouve un user par token reprise valide (non expiré). Ticket #111 */ + async findByTokenReprise(token: string): Promise { + return this.usersRepository.findOne({ + where: { + token_reprise: token, + statut: StatutUtilisateurType.REFUSE, + token_reprise_expire_le: MoreThan(new Date()), + }, + }); + } + + /** Resoumission reprise : met à jour les champs autorisés, passe en en_attente, invalide le token. Ticket #111 */ + async resoumettreReprise( + token: string, + dto: { prenom?: string; nom?: string; telephone?: string; adresse?: string; ville?: string; code_postal?: string; photo_url?: string }, + ): Promise { + const user = await this.findByTokenReprise(token); + if (!user) { + throw new NotFoundException('Token reprise invalide ou expiré.'); + } + if (dto.prenom !== undefined) user.prenom = dto.prenom; + if (dto.nom !== undefined) user.nom = dto.nom; + if (dto.telephone !== undefined) user.telephone = dto.telephone; + if (dto.adresse !== undefined) user.adresse = dto.adresse; + if (dto.ville !== undefined) user.ville = dto.ville; + if (dto.code_postal !== undefined) user.code_postal = dto.code_postal; + if (dto.photo_url !== undefined) user.photo_url = dto.photo_url; + user.statut = StatutUtilisateurType.EN_ATTENTE; + user.token_reprise = undefined; + user.token_reprise_expire_le = undefined; + return this.usersRepository.save(user); + } + + /** Pour modale reprise : numero_dossier + email → user en REFUSE avec token valide. Ticket #111 */ + async findByNumeroDossierAndEmailForReprise(numero_dossier: string, email: string): Promise { + const user = await this.usersRepository.findOne({ + where: { + email: email.trim().toLowerCase(), + numero_dossier: numero_dossier.trim(), + statut: StatutUtilisateurType.REFUSE, + token_reprise_expire_le: MoreThan(new Date()), + }, + }); + return user ?? null; + } + async remove(id: string, currentUser: Users): Promise { if (currentUser.role !== RoleType.SUPER_ADMIN) { throw new ForbiddenException('Accès réservé aux super admins'); From cde676c4f943a2e60be898735d3c394cdbe92c41 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 26 Mar 2026 00:20:47 +0100 Subject: [PATCH 09/13] feat: alignement master sur develop (squash) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dossiers unifiés #119, pending-families enrichi, validation admin (wizards) - Front: modèles dossier_unifie / pending_family, NIR, auth - Migrations dossier_famille, scripts de test API - Résolution conflits: parents.*, docs tickets, auth_service, nir_utils Made-with: Cursor --- backend/package.json | 3 +- backend/scripts/test-api-dossiers.js | 122 +++ backend/scripts/test-pending-api.js | 110 +++ .../update-gitea-issue-119-dossiers.js | 97 +++ backend/src/app.module.ts | 2 + .../src/entities/dossier_famille.entity.ts | 65 ++ backend/src/routes/auth/auth.service.ts | 28 + .../routes/dossiers/dossiers.controller.ts | 26 + .../src/routes/dossiers/dossiers.module.ts | 26 +- .../src/routes/dossiers/dossiers.service.ts | 81 ++ .../dossiers/dto/dossier-am-complet.dto.ts | 58 ++ .../routes/dossiers/dto/dossier-unifie.dto.ts | 14 + .../dto/dossier-famille-complet.dto.ts | 57 ++ .../routes/parents/dto/pending-family.dto.ts | 45 +- .../src/routes/parents/parents.controller.ts | 19 +- backend/src/routes/parents/parents.module.ts | 13 +- backend/src/routes/parents/parents.service.ts | 251 +++++- database/migrations/2026_dossier_famille.sql | 27 + .../2026_dossier_famille_simplifier.sql | 5 + docs/23_LISTE-TICKETS.md | 29 +- frontend/lib/models/dossier_unifie.dart | 210 +++++ frontend/lib/models/pending_family.dart | 232 ++++++ frontend/lib/models/user.dart | 56 +- .../creation/admin_create.dart | 15 +- .../creation/gestionnaires_create.dart | 61 +- frontend/lib/screens/auth/login_screen.dart | 36 +- frontend/lib/services/api/api_config.dart | 1 + frontend/lib/services/auth_service.dart | 5 +- frontend/lib/services/user_service.dart | 141 ++++ frontend/lib/utils/nir_utils.dart | 8 +- frontend/lib/utils/phone_utils.dart | 57 ++ .../admin/admin_management_widget.dart | 9 +- ...sistante_maternelle_management_widget.dart | 3 +- .../common/validation_detail_section.dart | 130 ++++ .../lib/widgets/admin/dashboard_admin.dart | 19 +- .../admin/parent_managmant_widget.dart | 3 +- .../admin/pending_validation_widget.dart | 354 +++++++++ .../admin/relais_management_panel.dart | 27 +- .../widgets/admin/user_management_panel.dart | 91 ++- .../widgets/admin/validation_am_wizard.dart | 507 ++++++++++++ .../admin/validation_dossier_modal.dart | 173 +++++ .../admin/validation_family_wizard.dart | 730 ++++++++++++++++++ .../widgets/admin/validation_modal_theme.dart | 18 + .../widgets/admin/validation_refus_form.dart | 123 +++ .../validation_valider_confirm_dialog.dart | 32 + .../widgets/dashboard/dashboard_bandeau.dart | 4 +- frontend/lib/widgets/nir_text_field.dart | 4 +- .../widgets/personal_info_form_screen.dart | 27 +- .../professional_info_form_screen.dart | 2 +- 49 files changed, 3934 insertions(+), 222 deletions(-) create mode 100644 backend/scripts/test-api-dossiers.js create mode 100644 backend/scripts/test-pending-api.js create mode 100644 backend/scripts/update-gitea-issue-119-dossiers.js create mode 100644 backend/src/entities/dossier_famille.entity.ts create mode 100644 backend/src/routes/dossiers/dossiers.controller.ts create mode 100644 backend/src/routes/dossiers/dossiers.service.ts create mode 100644 backend/src/routes/dossiers/dto/dossier-am-complet.dto.ts create mode 100644 backend/src/routes/dossiers/dto/dossier-unifie.dto.ts create mode 100644 backend/src/routes/parents/dto/dossier-famille-complet.dto.ts create mode 100644 database/migrations/2026_dossier_famille.sql create mode 100644 database/migrations/2026_dossier_famille_simplifier.sql create mode 100644 frontend/lib/models/dossier_unifie.dart create mode 100644 frontend/lib/models/pending_family.dart create mode 100644 frontend/lib/utils/phone_utils.dart create mode 100644 frontend/lib/widgets/admin/common/validation_detail_section.dart create mode 100644 frontend/lib/widgets/admin/pending_validation_widget.dart create mode 100644 frontend/lib/widgets/admin/validation_am_wizard.dart create mode 100644 frontend/lib/widgets/admin/validation_dossier_modal.dart create mode 100644 frontend/lib/widgets/admin/validation_family_wizard.dart create mode 100644 frontend/lib/widgets/admin/validation_modal_theme.dart create mode 100644 frontend/lib/widgets/admin/validation_refus_form.dart create mode 100644 frontend/lib/widgets/admin/validation_valider_confirm_dialog.dart diff --git a/backend/package.json b/backend/package.json index 0b68ed1..cb149bf 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,7 +19,8 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "test:api-dossiers": "node scripts/test-api-dossiers.js" }, "dependencies": { "@nestjs/common": "^11.1.6", diff --git a/backend/scripts/test-api-dossiers.js b/backend/scripts/test-api-dossiers.js new file mode 100644 index 0000000..1e48703 --- /dev/null +++ b/backend/scripts/test-api-dossiers.js @@ -0,0 +1,122 @@ +#!/usr/bin/env node +/** + * Test API GET /dossiers/:numeroDossier (dossier unifié AM ou famille). + * + * Prérequis : backend démarré (npm run start:dev dans backend/). + * + * Usage: + * node scripts/test-api-dossiers.js + * NUMERO_DOSSIER=2026-000001 node scripts/test-api-dossiers.js + * BASE_URL=https://app.ptits-pas.fr/api/v1 TEST_EMAIL=xxx TEST_PASSWORD=yyy NUMERO_DOSSIER=2026-000001 node scripts/test-api-dossiers.js + * + * Sans TEST_EMAIL/TEST_PASSWORD : 401 sur les routes protégées. + * NUMERO_DOSSIER : optionnel ; si absent, utilise le premier numero_dossier de pending-families (avec token). + */ + +const BASE_URL = process.env.BASE_URL || 'http://localhost:3000/api/v1'; +const TEST_EMAIL = process.env.TEST_EMAIL; +const TEST_PASSWORD = process.env.TEST_PASSWORD; +const NUMERO_DOSSIER = process.env.NUMERO_DOSSIER; + +async function request(method, path, body = null, token = null) { + const url = path.startsWith('http') ? path : `${BASE_URL}${path}`; + const opts = { + method, + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + }; + if (token) opts.headers.Authorization = `Bearer ${token}`; + if (body) opts.body = JSON.stringify(body); + const res = await fetch(url, opts); + const text = await res.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch (_) { + data = text; + } + return { status: res.status, data }; +} + +async function main() { + console.log('Base URL:', BASE_URL); + console.log('Numéro dossier (env):', NUMERO_DOSSIER ?? '(sera déduit si token fourni)'); + console.log(''); + + let token = null; + if (TEST_EMAIL && TEST_PASSWORD) { + console.log('1. Login...'); + const loginRes = await request('POST', '/auth/login', { + email: TEST_EMAIL, + password: TEST_PASSWORD, + }); + if (loginRes.status !== 200 && loginRes.status !== 201) { + console.log(' Échec login:', loginRes.status, loginRes.data); + process.exit(1); + } + token = loginRes.data?.access_token ?? loginRes.data?.accessToken ?? null; + if (!token) { + console.log(' Réponse login sans token:', JSON.stringify(loginRes.data, null, 2)); + process.exit(1); + } + console.log(' OK, token reçu.'); + console.log(''); + } else { + console.log('TEST_EMAIL / TEST_PASSWORD non définis : GET /dossiers/:numero nécessite un token (401 attendu).'); + console.log(''); + } + + let numeroDossier = NUMERO_DOSSIER; + if (!numeroDossier && token) { + console.log('2. Récupération d\'un numéro de dossier (GET /parents/pending-families)...'); + const pendingRes = await request('GET', '/parents/pending-families', null, token); + if (pendingRes.status === 200 && Array.isArray(pendingRes.data) && pendingRes.data.length > 0) { + numeroDossier = pendingRes.data[0].numero_dossier || null; + console.log(' Premier numero_dossier:', numeroDossier); + } else { + console.log(' Aucune famille en attente ou erreur. Utilisez NUMERO_DOSSIER=2026-000001'); + } + console.log(''); + } + + if (!numeroDossier) { + numeroDossier = '2026-000001'; + console.log('2. Pas de numéro fourni, test avec numéro par défaut:', numeroDossier); + } else { + console.log('2. GET /dossiers/' + encodeURIComponent(numeroDossier)); + } + + const dossierRes = await request( + 'GET', + '/dossiers/' + encodeURIComponent(numeroDossier), + null, + token + ); + + console.log(' Status:', dossierRes.status); + if (dossierRes.status === 200 && dossierRes.data) { + const d = dossierRes.data; + console.log(' type:', d.type); + console.log(' dossier (clés):', d.dossier ? Object.keys(d.dossier) : '-'); + if (d.dossier && Array.isArray(d.dossier.enfants)) { + console.log(' enfants:', d.dossier.enfants.length); + d.dossier.enfants.forEach((e, i) => { + console.log( + ` [${i + 1}] id=${e.id} first_name=${e.first_name} last_name=${e.last_name} birth_date=${e.birth_date} gender=${e.gender} genre=${e.genre} status=${e.status}` + ); + }); + } + console.log(''); + console.log('Réponse brute (dossier):'); + console.log(JSON.stringify(d.dossier, null, 2)); + } else { + console.log(' Réponse:', JSON.stringify(dossierRes.data, null, 2)); + } + + console.log(''); + console.log('Fin du test.'); +} + +main().catch((err) => { + console.error('Erreur:', err.message || err); + process.exit(1); +}); diff --git a/backend/scripts/test-pending-api.js b/backend/scripts/test-pending-api.js new file mode 100644 index 0000000..fb24b96 --- /dev/null +++ b/backend/scripts/test-pending-api.js @@ -0,0 +1,110 @@ +#!/usr/bin/env node +/** + * Test des endpoints "comptes en attente" (ticket #107). + * + * Prérequis : backend démarré (npm run start:dev dans backend/). + * + * Usage: + * node scripts/test-pending-api.js + * TEST_EMAIL=xxx TEST_PASSWORD=yyy node scripts/test-pending-api.js + * BASE_URL=https://app.ptits-pas.fr/api/v1 TEST_EMAIL=xxx TEST_PASSWORD=yyy node scripts/test-pending-api.js + * + * Sans TEST_EMAIL/TEST_PASSWORD : les GET protégés renverront 401 (normal). + * Avec un compte gestionnaire ou admin : affiche les listes en attente. + */ + +const BASE_URL = process.env.BASE_URL || 'http://localhost:3000/api/v1'; +const TEST_EMAIL = process.env.TEST_EMAIL; +const TEST_PASSWORD = process.env.TEST_PASSWORD; + +async function request(method, path, body = null, token = null) { + const url = path.startsWith('http') ? path : `${BASE_URL}${path}`; + const opts = { + method, + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + }; + if (token) opts.headers.Authorization = `Bearer ${token}`; + if (body) opts.body = JSON.stringify(body); + const res = await fetch(url, opts); + const text = await res.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch (_) { + data = text; + } + return { status: res.status, data }; +} + +async function main() { + console.log('Base URL:', BASE_URL); + console.log(''); + + let token = null; + if (TEST_EMAIL && TEST_PASSWORD) { + console.log('1. Login...'); + const loginRes = await request('POST', '/auth/login', { + email: TEST_EMAIL, + password: TEST_PASSWORD, + }); + if (loginRes.status !== 200 && loginRes.status !== 201) { + console.log(' Échec login:', loginRes.status, loginRes.data); + process.exit(1); + } + token = loginRes.data?.access_token ?? loginRes.data?.accessToken ?? null; + if (!token) { + console.log(' Réponse login sans token:', JSON.stringify(loginRes.data, null, 2)); + process.exit(1); + } + console.log(' OK, token reçu.'); + console.log(''); + } else { + console.log('TEST_EMAIL / TEST_PASSWORD non définis : les appels protégés vont renvoyer 401.'); + console.log('Exemple: TEST_EMAIL=admin@example.com TEST_PASSWORD=xxx node scripts/test-pending-api.js'); + console.log(''); + } + + console.log('2. GET /users/pending?role=assistante_maternelle'); + const pendingUsersRes = await request( + 'GET', + '/users/pending?role=assistante_maternelle', + null, + token + ); + console.log(' Status:', pendingUsersRes.status); + if (pendingUsersRes.status === 200) { + const list = Array.isArray(pendingUsersRes.data) ? pendingUsersRes.data : []; + console.log(' Nombre d\'utilisateurs en attente (AM):', list.length); + list.forEach((u, i) => { + console.log( + ` [${i + 1}] id=${u.id} email=${u.email} role=${u.role} statut=${u.statut} numero_dossier=${u.numero_dossier ?? '-'}` + ); + }); + } else { + console.log(' Réponse:', JSON.stringify(pendingUsersRes.data, null, 2)); + } + console.log(''); + + console.log('3. GET /parents/pending-families'); + const pendingFamiliesRes = await request('GET', '/parents/pending-families', null, token); + console.log(' Status:', pendingFamiliesRes.status); + if (pendingFamiliesRes.status === 200) { + const list = Array.isArray(pendingFamiliesRes.data) ? pendingFamiliesRes.data : []; + console.log(' Nombre de familles en attente:', list.length); + list.forEach((f, i) => { + console.log( + ` [${i + 1}] libelle=${f.libelle} parentIds=${JSON.stringify(f.parentIds)} numero_dossier=${f.numero_dossier ?? '-'}` + ); + }); + } else { + console.log(' Réponse:', JSON.stringify(pendingFamiliesRes.data, null, 2)); + } + + console.log(''); + console.log('Fin du test.'); +} + +main().catch((err) => { + console.error('Erreur:', err.message || err); + process.exit(1); +}); diff --git a/backend/scripts/update-gitea-issue-119-dossiers.js b/backend/scripts/update-gitea-issue-119-dossiers.js new file mode 100644 index 0000000..225dbcb --- /dev/null +++ b/backend/scripts/update-gitea-issue-119-dossiers.js @@ -0,0 +1,97 @@ +/** + * Met à jour l'issue Gitea #119 : endpoint unifié GET /dossiers/:numeroDossier (option A) + * Usage: node backend/scripts/update-gitea-issue-119-dossiers.js + * Token : .gitea-token (racine), GITEA_TOKEN, ou docs/BRIEFING-FRONTEND.md + */ +const https = require('https'); +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.join(__dirname, '../..'); +let token = process.env.GITEA_TOKEN; +if (!token) { + try { + const tokenFile = path.join(repoRoot, '.gitea-token'); + if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim(); + } catch (_) {} +} +if (!token) { + try { + const briefing = fs.readFileSync(path.join(repoRoot, 'docs/BRIEFING-FRONTEND.md'), 'utf8'); + const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/); + if (m) token = m[1].trim(); + } catch (_) {} +} +if (!token) { + console.error('Token non trouvé'); + process.exit(1); +} + +const body = `## Besoin + +Un **seul** endpoint **GET par numéro de dossier** qui renvoie le dossier complet, **AM ou famille** selon le numéro. Clé unique = numéro de dossier (usage : modale de validation, consultation gestionnaire, reprise, etc.). + +**Option A – Endpoint unifié** + +- **Route** : \`GET /api/v1/dossiers/:numeroDossier\` (ou \`GET /dossiers/:numeroDossier\` selon préfixe API). +- Le backend détermine si le numéro appartient à une **AM** ou à une **famille** (ex. lookup \`users\` / \`parents\` / \`assistantes_maternelles\`). +- **Réponse** avec discriminent : + - \`{ type: 'family', dossier: { numero_dossier, parents, enfants, presentation } }\` + - \`{ type: 'am', dossier: { numero_dossier, user, ... } }\` (fiche AM complète, champs utiles sans secrets) +- **Rôles** : SUPER_ADMIN, ADMINISTRATEUR, GESTIONNAIRE. +- **Réponses** : 200 (dossier), 403, 404 (numéro inconnu). + +Aucun filtre par statut : on renvoie le dossier s'il existe ; le front affiche Valider/Refuser selon le statut. + +**Labels suggérés** : backend, api, dossiers, gestionnaire + +--- + +## Implémentation + +- **Nouveau module ou route** : \`GET /dossiers/:numeroDossier\`. +- **Service** : trouver qui possède ce \`numero_dossier\` (famille → \`parents\`, AM → \`users\` + \`assistantes_maternelles\`). Appeler la logique existante dossier-famille ou construire le payload AM, puis retourner \`{ type, dossier }\`. +- **Réutiliser** : la logique actuelle \`GET /parents/dossier-famille/:numeroDossier\` peut être appelée en interne pour \`type: 'family'\` ; ajouter une branche \`type: 'am'\` avec un DTO « dossier AM complet ». +- DTO(s) : garder \`DossierFamilleCompletDto\` pour la famille ; ajouter un DTO pour le dossier AM (user sans secrets + infos AM). Réponse unifiée : \`{ type: 'am' | 'family', dossier: ... }\`.`; + +const payload = JSON.stringify({ + title: 'Endpoint unifié GET /dossiers/:numeroDossier (AM ou famille)', + body, +}); + +const opts = { + hostname: 'git.ptits-pas.fr', + path: '/api/v1/repos/jmartin/petitspas/issues/119', + method: 'PATCH', + headers: { + Authorization: 'token ' + token, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + }, +}; + +const req = https.request(opts, (res) => { + let d = ''; + res.on('data', (c) => (d += c)); + res.on('end', () => { + try { + const o = JSON.parse(d); + if (o.number || o.id) { + console.log('Issue #119 mise à jour.'); + console.log('URL:', o.html_url || 'https://git.ptits-pas.fr/jmartin/petitspas/issues/119'); + } else { + console.error('Erreur API:', o.message || d); + process.exit(1); + } + } catch (e) { + console.error('Réponse:', d); + process.exit(1); + } + }); +}); +req.on('error', (e) => { + console.error(e); + process.exit(1); +}); +req.write(payload); +req.end(); diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 0197cd2..ce8dbc2 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -17,6 +17,7 @@ import { EnfantsModule } from './routes/enfants/enfants.module'; import { AppConfigModule } from './modules/config/config.module'; import { DocumentsLegauxModule } from './modules/documents-legaux'; import { RelaisModule } from './routes/relais/relais.module'; +import { DossiersModule } from './routes/dossiers/dossiers.module'; @Module({ imports: [ @@ -55,6 +56,7 @@ import { RelaisModule } from './routes/relais/relais.module'; AppConfigModule, DocumentsLegauxModule, RelaisModule, + DossiersModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/entities/dossier_famille.entity.ts b/backend/src/entities/dossier_famille.entity.ts new file mode 100644 index 0000000..92082ed --- /dev/null +++ b/backend/src/entities/dossier_famille.entity.ts @@ -0,0 +1,65 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + OneToMany, + CreateDateColumn, + UpdateDateColumn, + JoinColumn, +} from 'typeorm'; +import { Parents } from './parents.entity'; +import { Children } from './children.entity'; +import { StatutDossierType } from './dossiers.entity'; + +/** Un dossier = une famille, N enfants (texte de motivation unique, liste d'enfants). */ +@Entity('dossier_famille') +export class DossierFamille { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'numero_dossier', length: 20 }) + numero_dossier: string; + + @ManyToOne(() => Parents, { onDelete: 'CASCADE', nullable: false }) + @JoinColumn({ name: 'id_parent', referencedColumnName: 'user_id' }) + parent: Parents; + + @Column({ type: 'text', nullable: true }) + presentation?: string; + + @Column({ + type: 'enum', + enum: StatutDossierType, + enumName: 'statut_dossier_type', + default: StatutDossierType.ENVOYE, + name: 'statut', + }) + statut: StatutDossierType; + + @CreateDateColumn({ name: 'cree_le', type: 'timestamptz' }) + cree_le: Date; + + @UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' }) + modifie_le: Date; + + @OneToMany(() => DossierFamilleEnfant, (dfe) => dfe.dossier_famille) + enfants: DossierFamilleEnfant[]; +} + +@Entity('dossier_famille_enfants') +export class DossierFamilleEnfant { + @Column({ name: 'id_dossier_famille', primary: true }) + id_dossier_famille: string; + + @Column({ name: 'id_enfant', primary: true }) + id_enfant: string; + + @ManyToOne(() => DossierFamille, (df) => df.enfants, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'id_dossier_famille' }) + dossier_famille: DossierFamille; + + @ManyToOne(() => Children, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'id_enfant' }) + enfant: Children; +} diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index c6fe021..5d7d943 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -43,6 +43,8 @@ export class AuthService { private readonly usersRepo: Repository, @InjectRepository(Children) private readonly childrenRepo: Repository, + @InjectRepository(AssistanteMaternelle) + private readonly assistantesMaternellesRepo: Repository, ) { } /** @@ -189,6 +191,11 @@ export class AuthService { } if (dto.co_parent_email) { + if (dto.email.trim().toLowerCase() === dto.co_parent_email.trim().toLowerCase()) { + throw new BadRequestException( + 'L\'email du parent et du co-parent doivent être différents.', + ); + } const coParentExiste = await this.usersService.findByEmailOrNull(dto.co_parent_email); if (coParentExiste) { throw new ConflictException('L\'email du co-parent est déjà utilisé'); @@ -360,6 +367,27 @@ export class AuthService { throw new ConflictException('Un compte avec cet email existe déjà'); } + const nirDejaUtilise = await this.assistantesMaternellesRepo.findOne({ + where: { nir: nirNormalized }, + }); + if (nirDejaUtilise) { + throw new ConflictException( + 'Un compte assistante maternelle avec ce numéro NIR existe déjà.', + ); + } + + const numeroAgrement = (dto.numero_agrement || '').trim(); + if (numeroAgrement) { + const agrementDejaUtilise = await this.assistantesMaternellesRepo.findOne({ + where: { approval_number: numeroAgrement }, + }); + if (agrementDejaUtilise) { + throw new ConflictException( + 'Un compte assistante maternelle avec ce numéro d\'agrément existe déjà.', + ); + } + } + const joursExpirationToken = await this.appConfigService.get( 'password_reset_token_expiry_days', 7, diff --git a/backend/src/routes/dossiers/dossiers.controller.ts b/backend/src/routes/dossiers/dossiers.controller.ts new file mode 100644 index 0000000..2f3f677 --- /dev/null +++ b/backend/src/routes/dossiers/dossiers.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get, Param, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Roles } from 'src/common/decorators/roles.decorator'; +import { RoleType } from 'src/entities/users.entity'; +import { AuthGuard } from 'src/common/guards/auth.guard'; +import { RolesGuard } from 'src/common/guards/roles.guard'; +import { DossiersService } from './dossiers.service'; +import { DossierUnifieDto } from './dto/dossier-unifie.dto'; + +@ApiTags('Dossiers') +@Controller('dossiers') +@UseGuards(AuthGuard, RolesGuard) +export class DossiersController { + constructor(private readonly dossiersService: DossiersService) {} + + @Get(':numeroDossier') + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) + @ApiOperation({ summary: 'Dossier complet par numéro (AM ou famille) – Ticket #119' }) + @ApiParam({ name: 'numeroDossier', description: 'Numéro de dossier (ex: 2026-000001)' }) + @ApiResponse({ status: 200, description: 'Dossier famille ou AM', type: DossierUnifieDto }) + @ApiResponse({ status: 404, description: 'Aucun dossier pour ce numéro' }) + @ApiResponse({ status: 403, description: 'Accès refusé' }) + getDossier(@Param('numeroDossier') numeroDossier: string): Promise { + return this.dossiersService.getDossierByNumero(numeroDossier); + } +} diff --git a/backend/src/routes/dossiers/dossiers.module.ts b/backend/src/routes/dossiers/dossiers.module.ts index 66026d2..e6ba196 100644 --- a/backend/src/routes/dossiers/dossiers.module.ts +++ b/backend/src/routes/dossiers/dossiers.module.ts @@ -1,4 +1,28 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; +import { Parents } from 'src/entities/parents.entity'; +import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; +import { ParentsModule } from '../parents/parents.module'; +import { DossiersController } from './dossiers.controller'; +import { DossiersService } from './dossiers.service'; -@Module({}) +@Module({ + imports: [ + TypeOrmModule.forFeature([Parents, AssistanteMaternelle]), + ParentsModule, + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: (config: ConfigService) => ({ + secret: config.get('jwt.accessSecret'), + signOptions: { expiresIn: config.get('jwt.accessExpiresIn') }, + }), + inject: [ConfigService], + }), + ], + controllers: [DossiersController], + providers: [DossiersService], + exports: [DossiersService], +}) export class DossiersModule {} diff --git a/backend/src/routes/dossiers/dossiers.service.ts b/backend/src/routes/dossiers/dossiers.service.ts new file mode 100644 index 0000000..59d9f47 --- /dev/null +++ b/backend/src/routes/dossiers/dossiers.service.ts @@ -0,0 +1,81 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Parents } from 'src/entities/parents.entity'; +import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; +import { ParentsService } from '../parents/parents.service'; +import { DossierUnifieDto } from './dto/dossier-unifie.dto'; +import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto'; + +/** + * Endpoint unifié GET /dossiers/:numeroDossier – AM ou famille. Ticket #119. + */ +@Injectable() +export class DossiersService { + constructor( + @InjectRepository(Parents) + private readonly parentsRepository: Repository, + @InjectRepository(AssistanteMaternelle) + private readonly amRepository: Repository, + private readonly parentsService: ParentsService, + ) {} + + async getDossierByNumero(numeroDossier: string): Promise { + const num = numeroDossier?.trim(); + if (!num) { + throw new NotFoundException('Numéro de dossier requis.'); + } + + // 1) Famille : un parent a ce numéro ? + const parentWithNum = await this.parentsRepository.findOne({ + where: { numero_dossier: num }, + select: ['user_id'], + }); + if (parentWithNum) { + const dossier = await this.parentsService.getDossierFamilleByNumero(num); + return { type: 'family', dossier }; + } + + // 2) AM : une assistante maternelle a ce numéro ? + const am = await this.amRepository.findOne({ + where: { numero_dossier: num }, + relations: ['user'], + }); + if (am?.user) { + const dossier: DossierAmCompletDto = { + numero_dossier: num, + user: this.toDossierAmUserDto(am.user), + numero_agrement: am.approval_number, + nir: am.nir, + biographie: am.biography, + disponible: am.available, + ville_residence: am.residence_city, + date_agrement: am.agreement_date, + annees_experience: am.years_experience, + specialite: am.specialty, + nb_max_enfants: am.max_children, + place_disponible: am.places_available, + }; + return { type: 'am', dossier }; + } + + throw new NotFoundException('Aucun dossier trouvé pour ce numéro.'); + } + + private toDossierAmUserDto(user: { id: string; email: string; prenom?: string; nom?: string; telephone?: string; adresse?: string; ville?: string; code_postal?: string; profession?: string; date_naissance?: Date; photo_url?: string; statut: any }): DossierAmUserDto { + return { + id: user.id, + email: user.email, + prenom: user.prenom, + nom: user.nom, + telephone: user.telephone, + adresse: user.adresse, + ville: user.ville, + code_postal: user.code_postal, + profession: user.profession, + date_naissance: user.date_naissance, + photo_url: user.photo_url, + statut: user.statut, + }; + } +} diff --git a/backend/src/routes/dossiers/dto/dossier-am-complet.dto.ts b/backend/src/routes/dossiers/dto/dossier-am-complet.dto.ts new file mode 100644 index 0000000..0ddc8a1 --- /dev/null +++ b/backend/src/routes/dossiers/dto/dossier-am-complet.dto.ts @@ -0,0 +1,58 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; + +/** Utilisateur AM sans données sensibles (pour dossier AM complet). Ticket #119 */ +export class DossierAmUserDto { + @ApiProperty() + id: string; + @ApiProperty() + email: string; + @ApiProperty({ required: false }) + prenom?: string; + @ApiProperty({ required: false }) + nom?: string; + @ApiProperty({ required: false }) + telephone?: string; + @ApiProperty({ required: false }) + adresse?: string; + @ApiProperty({ required: false }) + ville?: string; + @ApiProperty({ required: false }) + code_postal?: string; + @ApiProperty({ required: false }) + profession?: string; + @ApiProperty({ required: false }) + date_naissance?: Date; + @ApiProperty({ required: false }) + photo_url?: string; + @ApiProperty({ enum: StatutUtilisateurType }) + statut: StatutUtilisateurType; +} + +/** Dossier AM complet (fiche AM sans secrets). Ticket #119 */ +export class DossierAmCompletDto { + @ApiProperty({ example: '2026-000003', description: 'Numéro de dossier AM' }) + numero_dossier: string; + @ApiProperty({ type: DossierAmUserDto, description: 'Utilisateur (sans mot de passe ni tokens)' }) + user: DossierAmUserDto; + @ApiProperty({ required: false }) + numero_agrement?: string; + @ApiProperty({ required: false }) + nir?: string; + @ApiProperty({ required: false }) + biographie?: string; + @ApiProperty({ required: false }) + disponible?: boolean; + @ApiProperty({ required: false }) + ville_residence?: string; + @ApiProperty({ required: false }) + date_agrement?: Date; + @ApiProperty({ required: false }) + annees_experience?: number; + @ApiProperty({ required: false }) + specialite?: string; + @ApiProperty({ required: false }) + nb_max_enfants?: number; + @ApiProperty({ required: false }) + place_disponible?: number; +} diff --git a/backend/src/routes/dossiers/dto/dossier-unifie.dto.ts b/backend/src/routes/dossiers/dto/dossier-unifie.dto.ts new file mode 100644 index 0000000..a51ae1b --- /dev/null +++ b/backend/src/routes/dossiers/dto/dossier-unifie.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { DossierFamilleCompletDto } from '../../parents/dto/dossier-famille-complet.dto'; +import { DossierAmCompletDto } from './dossier-am-complet.dto'; + +/** Réponse unifiée GET /dossiers/:numeroDossier – AM ou famille. Ticket #119 */ +export class DossierUnifieDto { + @ApiProperty({ enum: ['family', 'am'], description: 'Type de dossier' }) + type: 'family' | 'am'; + + @ApiProperty({ + description: 'Dossier famille (si type=family) ou dossier AM (si type=am)', + }) + dossier: DossierFamilleCompletDto | DossierAmCompletDto; +} diff --git a/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts b/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts new file mode 100644 index 0000000..29437cf --- /dev/null +++ b/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts @@ -0,0 +1,57 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { StatutUtilisateurType } from 'src/entities/users.entity'; +import { StatutEnfantType, GenreType } from 'src/entities/children.entity'; + +/** Parent dans le dossier famille (infos utilisateur + parent) */ +export class DossierFamilleParentDto { + @ApiProperty() + user_id: string; + @ApiProperty() + email: string; + @ApiProperty({ required: false }) + prenom?: string; + @ApiProperty({ required: false }) + nom?: string; + @ApiProperty({ required: false }) + telephone?: string; + @ApiProperty({ required: false }) + adresse?: string; + @ApiProperty({ required: false }) + ville?: string; + @ApiProperty({ required: false }) + code_postal?: string; + @ApiProperty({ enum: StatutUtilisateurType }) + statut: StatutUtilisateurType; + @ApiProperty({ required: false, description: 'Id du co-parent si couple' }) + co_parent_id?: string; +} + +/** Enfant dans le dossier famille */ +export class DossierFamilleEnfantDto { + @ApiProperty() + id: string; + @ApiProperty({ required: false }) + first_name?: string; + @ApiProperty({ required: false }) + last_name?: string; + @ApiProperty({ required: false, enum: GenreType }) + genre?: GenreType; + @ApiProperty({ required: false }) + birth_date?: Date; + @ApiProperty({ required: false }) + due_date?: Date; + @ApiProperty({ enum: StatutEnfantType }) + status: StatutEnfantType; +} + +/** Réponse GET /parents/dossier-famille/:numeroDossier – dossier famille complet. Ticket #119 */ +export class DossierFamilleCompletDto { + @ApiProperty({ example: '2026-000001', description: 'Numéro de dossier famille' }) + numero_dossier: string; + @ApiProperty({ type: [DossierFamilleParentDto] }) + parents: DossierFamilleParentDto[]; + @ApiProperty({ type: [DossierFamilleEnfantDto], description: 'Enfants de la famille' }) + enfants: DossierFamilleEnfantDto[]; + @ApiProperty({ required: false, description: 'Texte de présentation / motivation (un seul par famille)' }) + texte_motivation?: string; +} diff --git a/backend/src/routes/parents/dto/pending-family.dto.ts b/backend/src/routes/parents/dto/pending-family.dto.ts index e7706d3..86389b2 100644 --- a/backend/src/routes/parents/dto/pending-family.dto.ts +++ b/backend/src/routes/parents/dto/pending-family.dto.ts @@ -1,4 +1,21 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ParentPendingSummaryDto { + @ApiProperty({ description: 'UUID utilisateur' }) + id: string; + + @ApiProperty() + email: string; + + @ApiPropertyOptional({ nullable: true }) + telephone?: string | null; + + @ApiPropertyOptional({ nullable: true }) + code_postal?: string | null; + + @ApiPropertyOptional({ nullable: true }) + ville?: string | null; +} export class PendingFamilyDto { @ApiProperty({ example: 'Famille Dupont', description: 'Libellé affiché pour la famille' }) @@ -17,4 +34,30 @@ export class PendingFamilyDto { description: 'Numéro de dossier famille (format AAAA-NNNNNN)', }) numero_dossier: string | null; + + @ApiProperty({ + nullable: true, + example: '2026-01-12T10:00:00.000Z', + description: 'Date de référence dossier soumis / en attente : MIN(cree_le) des parents en_attente du groupe (ISO 8601)', + }) + date_soumission: string | null; + + @ApiProperty({ + example: 3, + description: 'Nombre d’enfants distincts liés aux parents de la famille (enfants_parents)', + }) + nombre_enfants: number; + + @ApiPropertyOptional({ + type: [String], + example: ['parent1@example.com', 'parent2@example.com'], + description: 'Emails des parents du groupe (ordre stable : nom, prénom)', + }) + emails?: string[]; + + @ApiPropertyOptional({ + type: [ParentPendingSummaryDto], + description: 'Résumé des parents (ordre stable, aligné sur parentIds/emails)', + }) + parents?: ParentPendingSummaryDto[]; } diff --git a/backend/src/routes/parents/parents.controller.ts b/backend/src/routes/parents/parents.controller.ts index edadf2b..261091f 100644 --- a/backend/src/routes/parents/parents.controller.ts +++ b/backend/src/routes/parents/parents.controller.ts @@ -20,6 +20,7 @@ import { AuthGuard } from 'src/common/guards/auth.guard'; import { RolesGuard } from 'src/common/guards/roles.guard'; import { User } from 'src/common/decorators/user.decorator'; import { PendingFamilyDto } from './dto/pending-family.dto'; +import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto'; @ApiTags('Parents') @Controller('parents') @@ -33,12 +34,28 @@ export class ParentsController { @Get('pending-families') @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) @ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' }) - @ApiResponse({ status: 200, description: 'Liste des familles (libellé, parentIds, numero_dossier)', type: [PendingFamilyDto] }) + @ApiResponse({ + status: 200, + description: + 'Liste des familles (libellé, parentIds, numero_dossier, date_soumission, nombre_enfants, emails, parents)', + type: [PendingFamilyDto], + }) @ApiResponse({ status: 403, description: 'Accès refusé' }) getPendingFamilies(): Promise { return this.parentsService.getPendingFamilies(); } + @Get('dossier-famille/:numeroDossier') + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) + @ApiOperation({ summary: 'Dossier famille complet par numéro de dossier (Ticket #119)' }) + @ApiParam({ name: 'numeroDossier', description: 'Numéro de dossier (ex: 2026-000001)' }) + @ApiResponse({ status: 200, description: 'Dossier famille (numero_dossier, parents, enfants, presentation)', type: DossierFamilleCompletDto }) + @ApiResponse({ status: 404, description: 'Aucun dossier pour ce numéro' }) + @ApiResponse({ status: 403, description: 'Accès refusé' }) + getDossierFamille(@Param('numeroDossier') numeroDossier: string): Promise { + return this.parentsService.getDossierFamilleByNumero(numeroDossier); + } + @Post(':parentId/valider-dossier') @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) @ApiOperation({ summary: 'Valider tout le dossier famille (les 2 parents en une fois)' }) diff --git a/backend/src/routes/parents/parents.module.ts b/backend/src/routes/parents/parents.module.ts index 6cb557b..7506259 100644 --- a/backend/src/routes/parents/parents.module.ts +++ b/backend/src/routes/parents/parents.module.ts @@ -1,6 +1,9 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; import { Parents } from 'src/entities/parents.entity'; +import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity'; import { ParentsController } from './parents.controller'; import { ParentsService } from './parents.service'; import { Users } from 'src/entities/users.entity'; @@ -8,8 +11,16 @@ import { UserModule } from '../user/user.module'; @Module({ imports: [ - TypeOrmModule.forFeature([Parents, Users]), + TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant]), forwardRef(() => UserModule), + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: (config: ConfigService) => ({ + secret: config.get('jwt.accessSecret'), + signOptions: { expiresIn: config.get('jwt.accessExpiresIn') }, + }), + inject: [ConfigService], + }), ], controllers: [ParentsController], providers: [ParentsService], diff --git a/backend/src/routes/parents/parents.service.ts b/backend/src/routes/parents/parents.service.ts index 3aa174d..f4d6f53 100644 --- a/backend/src/routes/parents/parents.service.ts +++ b/backend/src/routes/parents/parents.service.ts @@ -5,12 +5,18 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { Parents } from 'src/entities/parents.entity'; +import { DossierFamille } from 'src/entities/dossier_famille.entity'; import { RoleType, Users } from 'src/entities/users.entity'; import { CreateParentDto } from '../user/dto/create_parent.dto'; import { UpdateParentsDto } from '../user/dto/update_parent.dto'; import { PendingFamilyDto } from './dto/pending-family.dto'; +import { + DossierFamilleCompletDto, + DossierFamilleParentDto, + DossierFamilleEnfantDto, +} from './dto/dossier-famille-complet.dto'; @Injectable() export class ParentsService { @@ -19,6 +25,8 @@ export class ParentsService { private readonly parentsRepository: Repository, @InjectRepository(Users) private readonly usersRepository: Repository, + @InjectRepository(DossierFamille) + private readonly dossierFamilleRepository: Repository, ) {} // Création d’un parent @@ -79,47 +87,214 @@ export class ParentsService { * Uniquement les parents dont l'utilisateur a statut = en_attente. */ async getPendingFamilies(): Promise { - const raw = await this.parentsRepository.query(` - WITH RECURSIVE - links AS ( - SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL - UNION ALL - SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL - UNION ALL - SELECT ep1.id_parent AS p1, ep2.id_parent AS p2 - FROM enfants_parents ep1 - JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent - UNION ALL - SELECT ep2.id_parent AS p1, ep1.id_parent AS p2 - FROM enfants_parents ep1 - JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent - ), - rec AS ( - SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents - UNION - SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1 - ), - family_rep AS ( - SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id - ) - SELECT - 'Famille ' || string_agg(u.nom, ' - ' ORDER BY u.nom, u.prenom) AS libelle, - array_agg(DISTINCT p.id_utilisateur ORDER BY p.id_utilisateur) AS "parentIds", - (array_agg(p.numero_dossier))[1] AS numero_dossier - FROM family_rep fr - JOIN parents p ON p.id_utilisateur = fr.id - JOIN utilisateurs u ON u.id = p.id_utilisateur - WHERE u.role = 'parent' AND u.statut = 'en_attente' - GROUP BY fr.rep - ORDER BY libelle - `); - return raw.map((r: { libelle: string; parentIds: unknown; numero_dossier: string | null }) => ({ - libelle: r.libelle, - parentIds: Array.isArray(r.parentIds) ? r.parentIds.map(String) : [], + let raw: { + libelle: string; + parentIds: unknown; + numero_dossier: string | null; + date_soumission: Date | string | null; + nombre_enfants: string | number | null; + emails: unknown; + parents: unknown; + }[]; + try { + raw = await this.parentsRepository.query(` + WITH RECURSIVE + links AS ( + SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL + UNION ALL + SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL + UNION ALL + SELECT ep1.id_parent AS p1, ep2.id_parent AS p2 + FROM enfants_parents ep1 + JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent + UNION ALL + SELECT ep2.id_parent AS p1, ep1.id_parent AS p2 + FROM enfants_parents ep1 + JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent + ), + rec AS ( + SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents + UNION + SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1 + ), + family_rep AS ( + SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id + ) + SELECT + 'Famille ' || string_agg(u.nom, ' - ' ORDER BY u.nom, u.prenom) AS libelle, + array_agg(p.id_utilisateur ORDER BY u.nom, u.prenom, u.id) AS "parentIds", + (array_agg(p.numero_dossier))[1] AS numero_dossier, + MIN(u.cree_le) AS date_soumission, + COALESCE(( + SELECT COUNT(DISTINCT ep.id_enfant)::int + FROM enfants_parents ep + WHERE ep.id_parent IN ( + SELECT frx.id FROM family_rep frx WHERE frx.rep = fr.rep + ) + ), 0) AS nombre_enfants, + array_agg(u.email ORDER BY u.nom, u.prenom, u.id) AS emails, + json_agg( + json_build_object( + 'id', u.id::text, + 'email', u.email, + 'telephone', u.telephone, + 'code_postal', u.code_postal, + 'ville', u.ville + ) + ORDER BY u.nom, u.prenom, u.id + ) AS parents + FROM family_rep fr + JOIN parents p ON p.id_utilisateur = fr.id + JOIN utilisateurs u ON u.id = p.id_utilisateur + WHERE u.role = 'parent' AND u.statut = 'en_attente' + GROUP BY fr.rep + ORDER BY libelle + `); + } catch (err) { + throw err; + } + if (!Array.isArray(raw)) return []; + return raw.map((r) => ({ + libelle: r.libelle ?? '', + parentIds: this.normalizeParentIds(r.parentIds), numero_dossier: r.numero_dossier ?? null, + date_soumission: this.toIsoDateTimeOrNull(r.date_soumission), + nombre_enfants: this.normalizeNombreEnfants(r.nombre_enfants), + emails: this.normalizeEmails(r.emails), + parents: this.normalizeParents(r.parents), })); } + private toIsoDateTimeOrNull(value: Date | string | null | undefined): string | null { + if (value == null) return null; + if (value instanceof Date) return value.toISOString(); + const d = new Date(value); + return Number.isNaN(d.getTime()) ? null : d.toISOString(); + } + + private normalizeNombreEnfants(v: string | number | null | undefined): number { + if (v == null) return 0; + const n = typeof v === 'number' ? v : parseInt(String(v), 10); + return Number.isFinite(n) && n >= 0 ? n : 0; + } + + private normalizeEmails(emails: unknown): string[] { + if (Array.isArray(emails)) return emails.map(String); + if (typeof emails === 'string') { + const s = emails.replace(/^\{|\}$/g, '').trim(); + return s ? s.split(',').map((x) => x.trim()) : []; + } + return []; + } + + private normalizeParents(parents: unknown): { id: string; email: string; telephone: string | null; code_postal: string | null; ville: string | null }[] { + if (Array.isArray(parents)) { + return parents.map((p: any) => ({ + id: String(p?.id ?? ''), + email: String(p?.email ?? ''), + telephone: p?.telephone != null ? String(p.telephone) : null, + code_postal: p?.code_postal != null ? String(p.code_postal) : null, + ville: p?.ville != null ? String(p.ville) : null, + })); + } + if (typeof parents === 'string') { + try { + const parsed = JSON.parse(parents); + return this.normalizeParents(parsed); + } catch { + return []; + } + } + return []; + } + + /** Convertit parentIds (array ou chaîne PG) en string[] pour éviter 500 si le driver renvoie une chaîne. */ + private normalizeParentIds(parentIds: unknown): string[] { + if (Array.isArray(parentIds)) return parentIds.map(String); + if (typeof parentIds === 'string') { + const s = parentIds.replace(/^\{|\}$/g, '').trim(); + return s ? s.split(',').map((x) => x.trim()) : []; + } + return []; + } + + /** + * Dossier famille complet par numéro de dossier. Ticket #119. + * Rôles : admin, gestionnaire. + * @throws NotFoundException si aucun parent avec ce numéro de dossier + */ + async getDossierFamilleByNumero(numeroDossier: string): Promise { + const num = numeroDossier?.trim(); + if (!num) { + throw new NotFoundException('Numéro de dossier requis.'); + } + const firstParent = await this.parentsRepository.findOne({ + where: { numero_dossier: num }, + relations: ['user'], + }); + if (!firstParent || !firstParent.user) { + throw new NotFoundException('Aucun dossier famille trouvé pour ce numéro.'); + } + const familyUserIds = await this.getFamilyUserIds(firstParent.user_id); + const parents = await this.parentsRepository.find({ + where: { user_id: In(familyUserIds) }, + relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers', 'dossiers.child'], + }); + const enfantsMap = new Map(); + let texte_motivation: string | undefined; + + // Un dossier = une famille, un seul texte de motivation + const dossierFamille = await this.dossierFamilleRepository.findOne({ + where: { numero_dossier: num }, + relations: ['parent', 'enfants', 'enfants.enfant'], + }); + if (dossierFamille?.presentation) { + texte_motivation = dossierFamille.presentation; + } + + for (const p of parents) { + // Enfants via parentChildren + if (p.parentChildren) { + for (const pc of p.parentChildren) { + if (pc.child && !enfantsMap.has(pc.child.id)) { + enfantsMap.set(pc.child.id, { + id: pc.child.id, + first_name: pc.child.first_name, + last_name: pc.child.last_name, + genre: pc.child.gender, + birth_date: pc.child.birth_date, + due_date: pc.child.due_date, + status: pc.child.status, + }); + } + } + } + // Fallback : anciens dossiers (un texte, on prend le premier) + if (texte_motivation == null && p.dossiers?.length) { + texte_motivation = p.dossiers[0].presentation ?? undefined; + } + } + + const parentsDto: DossierFamilleParentDto[] = parents.map((p) => ({ + user_id: p.user_id, + email: p.user.email, + prenom: p.user.prenom, + nom: p.user.nom, + telephone: p.user.telephone, + adresse: p.user.adresse, + ville: p.user.ville, + code_postal: p.user.code_postal, + statut: p.user.statut, + co_parent_id: p.co_parent?.id, + })); + return { + numero_dossier: num, + parents: parentsDto, + enfants: Array.from(enfantsMap.values()), + texte_motivation, + }; + } + /** * Retourne les user_id de tous les parents de la même famille (co_parent ou enfants partagés). * @throws NotFoundException si parentId n'est pas un parent diff --git a/database/migrations/2026_dossier_famille.sql b/database/migrations/2026_dossier_famille.sql new file mode 100644 index 0000000..4afaa5e --- /dev/null +++ b/database/migrations/2026_dossier_famille.sql @@ -0,0 +1,27 @@ +-- Un dossier = une famille, N enfants. Ticket #119 évolution. +-- Table: un enregistrement par famille (lien via numero_dossier / id_parent). +CREATE TABLE IF NOT EXISTS dossier_famille ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + numero_dossier VARCHAR(20) NOT NULL, + id_parent UUID NOT NULL REFERENCES parents(id_utilisateur) ON DELETE CASCADE, + presentation TEXT, + type_contrat VARCHAR(50), + repas BOOLEAN NOT NULL DEFAULT false, + budget NUMERIC(10,2), + planning_souhaite JSONB, + statut statut_dossier_type NOT NULL DEFAULT 'envoye', + cree_le TIMESTAMPTZ NOT NULL DEFAULT now(), + modifie_le TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_dossier_famille_numero ON dossier_famille(numero_dossier); +CREATE INDEX IF NOT EXISTS idx_dossier_famille_id_parent ON dossier_famille(id_parent); + +-- Enfants concernés par ce dossier famille (N par dossier). +CREATE TABLE IF NOT EXISTS dossier_famille_enfants ( + id_dossier_famille UUID NOT NULL REFERENCES dossier_famille(id) ON DELETE CASCADE, + id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE, + PRIMARY KEY (id_dossier_famille, id_enfant) +); + +CREATE INDEX IF NOT EXISTS idx_dossier_famille_enfants_enfant ON dossier_famille_enfants(id_enfant); diff --git a/database/migrations/2026_dossier_famille_simplifier.sql b/database/migrations/2026_dossier_famille_simplifier.sql new file mode 100644 index 0000000..809dc1c --- /dev/null +++ b/database/migrations/2026_dossier_famille_simplifier.sql @@ -0,0 +1,5 @@ +-- Dossier famille = inscription uniquement, pas les données de dossier de garde (repas, type_contrat, budget, etc.) +ALTER TABLE dossier_famille DROP COLUMN IF EXISTS repas; +ALTER TABLE dossier_famille DROP COLUMN IF EXISTS type_contrat; +ALTER TABLE dossier_famille DROP COLUMN IF EXISTS budget; +ALTER TABLE dossier_famille DROP COLUMN IF EXISTS planning_souhaite; diff --git a/docs/23_LISTE-TICKETS.md b/docs/23_LISTE-TICKETS.md index 4e31cfd..b9eb010 100644 --- a/docs/23_LISTE-TICKETS.md +++ b/docs/23_LISTE-TICKETS.md @@ -33,13 +33,13 @@ | 20 | [Backend] API Inscription Parent (étape 3 - Enfants) | ✅ Terminé | | 21 | [Backend] API Inscription Parent (étape 4-6 - Finalisation) | ✅ Terminé | | 24 | [Backend] API Création mot de passe | Ouvert | -| 25 | [Backend] API Liste comptes en attente | Ouvert | -| 26 | [Backend] API Validation/Refus comptes | Ouvert | -| 27 | [Backend] Service Email - Installation Nodemailer | Ouvert | +| 25 | [Backend] API Liste comptes en attente | ✅ Fermé (obsolète, couvert #103-#111) | +| 26 | [Backend] API Validation/Refus comptes | ✅ Fermé (obsolète, couvert #103-#111) | +| 27 | [Backend] Service Email - Installation Nodemailer | ✅ Fermé (obsolète, couvert #103-#111) | | 28 | [Backend] Templates Email - Validation | Ouvert | -| 29 | [Backend] Templates Email - Refus | Ouvert | -| 30 | [Backend] Connexion - Vérification statut | Ouvert | -| 31 | [Backend] Changement MDP obligatoire première connexion | Ouvert | +| 29 | [Backend] Templates Email - Refus | ✅ Fermé (obsolète, couvert #103-#111) | +| 30 | [Backend] Connexion - Vérification statut | ✅ Fermé (obsolète, couvert #103-#111) | +| 31 | [Backend] Changement MDP obligatoire première connexion | ✅ Terminé | | 32 | [Backend] Service Documents Légaux | Ouvert | | 33 | [Backend] API Documents Légaux | Ouvert | | 34 | [Backend] Traçabilité acceptations documents | Ouvert | @@ -53,8 +53,8 @@ | 42 | [Frontend] Inscription AM - Finalisation | ✅ Terminé | | 43 | [Frontend] Écran Création Mot de Passe | Ouvert | | 44 | [Frontend] Dashboard Gestionnaire - Structure | ✅ Terminé | -| 45 | [Frontend] Dashboard Gestionnaire - Liste Parents | Ouvert | -| 46 | [Frontend] Dashboard Gestionnaire - Liste AM | Ouvert | +| 45 | [Frontend] Dashboard Gestionnaire - Liste Parents | ✅ Fermé (obsolète, couvert #103-#111) | +| 46 | [Frontend] Dashboard Gestionnaire - Liste AM | ✅ Fermé (obsolète, couvert #103-#111) | | 47 | [Frontend] Écran Changement MDP Obligatoire | Ouvert | | 48 | [Frontend] Gestion Erreurs & Messages | Ouvert | | 49 | [Frontend] Écran Gestion Documents Légaux (Admin) | Ouvert | @@ -103,7 +103,7 @@ *Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues* -*Tickets #103 à #117 = périmètre « Validation des nouveaux comptes par le gestionnaire » (plan + sync via `backend/scripts/sync-gitea-validation-tickets.js`).* +*Tickets #103 à #117 = périmètre « Validation des nouveaux comptes par le gestionnaire » (voir plan de spec).* --- @@ -641,17 +641,18 @@ Modifier l'endpoint de connexion pour bloquer les comptes en attente ou suspendu --- -### Ticket #31 : [Backend] Changement MDP obligatoire première connexion +### Ticket #31 : [Backend] Changement MDP obligatoire première connexion ✅ **Estimation** : 2h -**Labels** : `backend`, `p2`, `auth`, `security` +**Labels** : `backend`, `p2`, `auth`, `security` +**Statut** : ✅ TERMINÉ **Description** : Implémenter le changement de mot de passe obligatoire pour les gestionnaires/admins à la première connexion. **Tâches** : -- [ ] Endpoint `POST /api/v1/auth/change-password-required` -- [ ] Vérification flag `changement_mdp_obligatoire` -- [ ] Mise à jour flag après changement +- [x] Endpoint `POST /api/v1/auth/change-password-required` +- [x] Vérification flag `changement_mdp_obligatoire` +- [x] Mise à jour flag après changement - [ ] Tests unitaires --- diff --git a/frontend/lib/models/dossier_unifie.dart b/frontend/lib/models/dossier_unifie.dart new file mode 100644 index 0000000..83e3450 --- /dev/null +++ b/frontend/lib/models/dossier_unifie.dart @@ -0,0 +1,210 @@ +import 'package:p_tits_pas/models/user.dart'; + +/// Réponse unifiée GET /dossiers/:numeroDossier. Ticket #119, #107. +class DossierUnifie { + final String type; // 'am' | 'family' + final dynamic dossier; // DossierAM | DossierFamille + + DossierUnifie({required this.type, required this.dossier}); + + bool get isAm => type == 'am'; + bool get isFamily => type == 'family'; + + DossierAM get asAm => dossier as DossierAM; + DossierFamille get asFamily => dossier as DossierFamille; + + factory DossierUnifie.fromJson(Map json) { + final t = json['type']; + final raw = t is String ? t : 'family'; + final typeStr = raw.toLowerCase(); + final d = json['dossier']; + if (d == null || d is! Map) { + throw FormatException('dossier manquant ou invalide'); + } + final dossierMap = Map.from(d as Map); + // Seul `am` (casse tolérée) charge le dossier AM ; le reste = famille (API : type "family"). + final isAm = typeStr == 'am'; + final dossier = isAm ? DossierAM.fromJson(dossierMap) : DossierFamille.fromJson(dossierMap); + return DossierUnifie(type: isAm ? 'am' : 'family', dossier: dossier); + } +} + +/// Dossier AM (type: 'am'). Champs alignés API. +class DossierAM { + final String? numeroDossier; + final AppUser user; + final String? numeroAgrement; + final String? nir; + final String? presentation; + final String? dateAgrement; + final int? nbMaxEnfants; + final int? placesDisponibles; + final String? villeResidence; + + DossierAM({ + this.numeroDossier, + required this.user, + this.numeroAgrement, + this.nir, + this.presentation, + this.dateAgrement, + this.nbMaxEnfants, + this.placesDisponibles, + this.villeResidence, + }); + + factory DossierAM.fromJson(Map json) { + final userJson = json['user']; + final userMap = userJson is Map + ? userJson + : {}; + final nbMax = json['nb_max_enfants']; + final places = json['place_disponible']; + return DossierAM( + numeroDossier: json['numero_dossier']?.toString(), + user: AppUser.fromJson(Map.from(userMap)), + numeroAgrement: json['numero_agrement']?.toString(), + nir: json['nir']?.toString(), + presentation: (json['biographie'] ?? json['presentation'])?.toString(), + dateAgrement: json['date_agrement']?.toString(), + nbMaxEnfants: nbMax is int ? nbMax : (nbMax is num ? nbMax.toInt() : null), + placesDisponibles: places is int ? places : (places is num ? places.toInt() : null), + villeResidence: json['ville_residence']?.toString(), + ); + } +} + +/// Dossier famille (type: 'family'). Champs alignés API. +class DossierFamille { + final String? numeroDossier; + final List parents; + final List enfants; + final String? presentation; + + DossierFamille({ + this.numeroDossier, + required this.parents, + required this.enfants, + this.presentation, + }); + + factory DossierFamille.fromJson(Map json) { + final parentsRaw = json['parents']; + final parentsList = parentsRaw is List + ? (parentsRaw) + .where((e) => e is Map) + .map((e) => ParentDossier.fromJson(Map.from(e as Map))) + .toList() + : []; + final enfantsRaw = json['enfants']; + final enfantsList = enfantsRaw is List + ? (enfantsRaw) + .where((e) => e is Map) + .map((e) => EnfantDossier.fromJson(Map.from(e as Map))) + .toList() + : []; + return DossierFamille( + numeroDossier: json['numero_dossier']?.toString(), + parents: parentsList, + enfants: enfantsList, + presentation: (json['texte_motivation'] ?? json['presentation'])?.toString(), + ); + } + + bool get isEnAttente => + parents.any((p) => p.statut == 'en_attente'); +} + +/// Parent dans un dossier famille (champs user exposés). +class ParentDossier { + final String id; + final String email; + final String? prenom; + final String? nom; + final String? telephone; + final String? adresse; + final String? ville; + final String? codePostal; + final String? dateNaissance; + final String? genre; + final String? situationFamiliale; + final String? creeLe; + final String? statut; + + ParentDossier({ + required this.id, + required this.email, + this.prenom, + this.nom, + this.telephone, + this.adresse, + this.ville, + this.codePostal, + this.dateNaissance, + this.genre, + this.situationFamiliale, + this.creeLe, + this.statut, + }); + + String get fullName => '${prenom ?? ''} ${nom ?? ''}'.trim(); + + factory ParentDossier.fromJson(Map json) { + return ParentDossier( + id: json['id']?.toString() ?? '', + email: json['email']?.toString() ?? '', + prenom: json['prenom']?.toString(), + nom: json['nom']?.toString(), + telephone: json['telephone']?.toString(), + adresse: json['adresse']?.toString(), + ville: json['ville']?.toString(), + codePostal: json['code_postal']?.toString(), + dateNaissance: json['date_naissance']?.toString(), + genre: json['genre']?.toString(), + situationFamiliale: json['situation_familiale']?.toString(), + creeLe: json['cree_le']?.toString(), + statut: json['statut']?.toString(), + ); + } +} + +/// Enfant dans un dossier famille. +class EnfantDossier { + final String id; + final String? firstName; + final String? lastName; + final String? birthDate; + final String? gender; + final String? status; + final String? dueDate; + final String? photoUrl; + final bool consentPhoto; + + EnfantDossier({ + required this.id, + this.firstName, + this.lastName, + this.birthDate, + this.gender, + this.status, + this.dueDate, + this.photoUrl, + this.consentPhoto = false, + }); + + String get fullName => '${firstName ?? ''} ${lastName ?? ''}'.trim(); + + factory EnfantDossier.fromJson(Map json) { + return EnfantDossier( + id: json['id']?.toString() ?? '', + firstName: (json['first_name'] ?? json['prenom'])?.toString(), + lastName: (json['last_name'] ?? json['nom'])?.toString(), + birthDate: json['birth_date']?.toString(), + gender: (json['gender'] ?? json['genre'])?.toString(), + status: json['status']?.toString(), + dueDate: json['due_date']?.toString(), + photoUrl: json['photo_url']?.toString(), + consentPhoto: json['consent_photo'] == true, + ); + } +} diff --git a/frontend/lib/models/pending_family.dart b/frontend/lib/models/pending_family.dart new file mode 100644 index 0000000..a5273b7 --- /dev/null +++ b/frontend/lib/models/pending_family.dart @@ -0,0 +1,232 @@ +/// Résumé affichable pour un parent (liste pending-families). +class PendingParentLine { + final String? email; + final String? telephone; + final String? codePostal; + final String? ville; + + const PendingParentLine({ + this.email, + this.telephone, + this.codePostal, + this.ville, + }); + + bool get isEmpty { + final e = email?.trim(); + final t = telephone?.trim(); + final loc = _locationTrimmed; + return (e == null || e.isEmpty) && + (t == null || t.isEmpty) && + (loc == null || loc.isEmpty); + } + + String? get _locationTrimmed { + final cp = codePostal?.trim(); + final v = ville?.trim(); + final loc = [if (cp != null && cp.isNotEmpty) cp, if (v != null && v.isNotEmpty) v] + .join(' ') + .trim(); + return loc.isEmpty ? null : loc; + } +} + +/// Famille en attente de validation (GET /parents/pending-families). Ticket #107. +/// +/// Contrat API : `libelle`, `parentIds`, `numero_dossier`, `date_soumission`, +/// `nombre_enfants`, `emails`, éventuellement `parents` / tableaux parallèles. +class PendingFamily { + final String libelle; + final List parentIds; + final String? numeroDossier; + + /// Date affichée : `date_soumission` (ISO), sinon alias `cree_le` / etc. + final DateTime? dateSoumission; + + /// Emails seuls (API) — le sous-titre utilise de préférence [parentLines]. + final List emails; + + /// Une entrée par parent : email, tél., CP ville (si fournis par l’API). + final List parentLines; + + final int nombreEnfants; + + /// Compat : premier email. + final String? email; + + PendingFamily({ + required this.libelle, + required this.parentIds, + this.numeroDossier, + this.dateSoumission, + this.emails = const [], + this.parentLines = const [], + this.nombreEnfants = 0, + this.email, + }); + + static DateTime? _parseDate(dynamic v) { + if (v == null) return null; + if (v is DateTime) return v; + if (v is String) { + return DateTime.tryParse(v); + } + return null; + } + + static List _parseStringList(dynamic raw) { + if (raw is! List) return []; + return raw + .map((e) => e?.toString().trim() ?? '') + .where((s) => s.isNotEmpty) + .toList(); + } + + static List _parseParentLinesFromMaps(dynamic raw) { + if (raw is! List) return []; + final out = []; + for (final e in raw) { + if (e is! Map) continue; + final m = Map.from(e); + final em = m['email']?.toString().trim(); + final tel = m['telephone']?.toString().trim(); + final cp = (m['code_postal'] ?? m['codePostal'])?.toString().trim(); + final ville = m['ville']?.toString().trim(); + out.add(PendingParentLine( + email: em != null && em.isNotEmpty ? em : null, + telephone: tel != null && tel.isNotEmpty ? tel : null, + codePostal: cp != null && cp.isNotEmpty ? cp : null, + ville: ville != null && ville.isNotEmpty ? ville : null, + )); + } + return out; + } + + /// Construit [parentLines] : objets `parents`, tableaux parallèles, ou emails + champs racine. + static List _buildParentLines( + Map json, + List emails, + ) { + final fromMaps = _parseParentLinesFromMaps( + json['parents'] ?? json['resume_parents'] ?? json['parent_summaries'] ?? json['parent_lines'], + ); + if (fromMaps.isNotEmpty) { + return fromMaps; + } + + List? parallel(dynamic keySingular, dynamic keyPlural) { + final pl = json[keyPlural]; + if (pl is List) return _parseStringList(pl); + final s = json[keySingular]; + if (s is String && s.trim().isNotEmpty) return [s.trim()]; + return null; + } + + final tels = parallel('telephone', 'telephones'); + final cps = parallel('code_postal', 'code_postaux') ?? parallel('codePostal', 'codes_postaux'); + final villes = parallel('ville', 'villes'); + + if (emails.isNotEmpty && + ((tels?.isNotEmpty ?? false) || + (cps?.isNotEmpty ?? false) || + (villes?.isNotEmpty ?? false))) { + return List.generate(emails.length, (i) { + return PendingParentLine( + email: emails[i], + telephone: tels != null && i < tels.length ? tels[i] : null, + codePostal: cps != null && i < cps.length ? cps[i] : null, + ville: villes != null && i < villes.length ? villes[i] : null, + ); + }); + } + + final rootTel = json['telephone']?.toString().trim(); + final rootTelOk = rootTel != null && rootTel.isNotEmpty ? rootTel : null; + final rootCp = (json['code_postal'] ?? json['codePostal'])?.toString().trim(); + final rootCpOk = rootCp != null && rootCp.isNotEmpty ? rootCp : null; + final rootVille = json['ville']?.toString().trim(); + final rootVilleOk = rootVille != null && rootVille.isNotEmpty ? rootVille : null; + + if (emails.isNotEmpty) { + return List.generate(emails.length, (i) { + return PendingParentLine( + email: emails[i], + telephone: i == 0 ? rootTelOk : null, + codePostal: i == 0 ? rootCpOk : null, + ville: i == 0 ? rootVilleOk : null, + ); + }); + } + + if (rootTelOk != null || rootCpOk != null || rootVilleOk != null) { + final em = json['email']?.toString().trim(); + return [ + PendingParentLine( + email: em != null && em.isNotEmpty ? em : null, + telephone: rootTelOk, + codePostal: rootCpOk, + ville: rootVilleOk, + ), + ]; + } + + return []; + } + + factory PendingFamily.fromJson(Map json) { + final parentIdsRaw = json['parentIds'] ?? json['parent_ids']; + final List ids = parentIdsRaw is List + ? (parentIdsRaw).map((e) => e?.toString() ?? '').where((s) => s.isNotEmpty).toList() + : []; + final libelle = json['libelle']; + final libelleStr = libelle is String ? libelle : (libelle?.toString() ?? 'Famille'); + final nd = json['numero_dossier'] ?? json['numeroDossier']; + final numeroDossier = (nd is String && nd.isNotEmpty) ? nd : null; + + DateTime? dateSoumission = _parseDate( + json['date_soumission'] ?? json['dateSoumission'], + ); + dateSoumission ??= _parseDate( + json['cree_le'] ?? json['creeLe'] ?? json['date_inscription'], + ); + + List emails = _parseStringList(json['emails']); + if (emails.isEmpty) { + final emailRaw = json['email']; + if (emailRaw is String && emailRaw.trim().isNotEmpty) { + emails = [emailRaw.trim()]; + } + } + final String? emailCompat = emails.isNotEmpty + ? emails.first + : (json['email'] is String && (json['email'] as String).trim().isNotEmpty + ? (json['email'] as String).trim() + : null); + + final nbRaw = json['nombre_enfants'] ?? json['nombreEnfants']; + int nombreEnfants = 0; + if (nbRaw is int) { + nombreEnfants = nbRaw; + } else if (nbRaw is num) { + nombreEnfants = nbRaw.toInt(); + } else if (nbRaw != null) { + nombreEnfants = int.tryParse(nbRaw.toString()) ?? 0; + } + + var parentLines = _buildParentLines(json, emails); + if (parentLines.isEmpty && emails.isNotEmpty) { + parentLines = emails.map((e) => PendingParentLine(email: e)).toList(); + } + + return PendingFamily( + libelle: libelleStr.isEmpty ? 'Famille' : libelleStr, + parentIds: ids, + numeroDossier: numeroDossier, + dateSoumission: dateSoumission, + emails: emails, + parentLines: parentLines, + nombreEnfants: nombreEnfants, + email: emailCompat, + ); + } +} diff --git a/frontend/lib/models/user.dart b/frontend/lib/models/user.dart index 01b89ce..fc407d1 100644 --- a/frontend/lib/models/user.dart +++ b/frontend/lib/models/user.dart @@ -15,6 +15,7 @@ class AppUser { final String? codePostal; final String? relaisId; final String? relaisNom; + final String? numeroDossier; AppUser({ required this.id, @@ -33,40 +34,50 @@ class AppUser { this.codePostal, this.relaisId, this.relaisNom, + this.numeroDossier, }); + static String _str(dynamic v) { + if (v == null) return ''; + if (v is String) return v; + return v.toString(); + } + + static DateTime _date(dynamic v) { + if (v == null) return DateTime.now(); + if (v is DateTime) return v; + try { + return DateTime.parse(v.toString()); + } catch (_) { + return DateTime.now(); + } + } + factory AppUser.fromJson(Map json) { final relaisJson = json['relais']; final relaisMap = relaisJson is Map ? relaisJson : {}; return AppUser( - id: (json['id'] as String?) ?? '', - email: (json['email'] as String?) ?? '', - role: (json['role'] as String?) ?? '', - createdAt: json['cree_le'] != null - ? DateTime.parse(json['cree_le'] as String) - : (json['createdAt'] != null - ? DateTime.parse(json['createdAt'] as String) - : DateTime.now()), - updatedAt: json['modifie_le'] != null - ? DateTime.parse(json['modifie_le'] as String) - : (json['updatedAt'] != null - ? DateTime.parse(json['updatedAt'] as String) - : DateTime.now()), + id: _str(json['id']), + email: _str(json['email']), + role: _str(json['role']), + createdAt: _date(json['cree_le'] ?? json['createdAt']), + updatedAt: _date(json['modifie_le'] ?? json['updatedAt']), changementMdpObligatoire: - json['changement_mdp_obligatoire'] as bool? ?? false, - nom: json['nom'] as String?, - prenom: json['prenom'] as String?, - statut: json['statut'] as String?, - telephone: json['telephone'] as String?, - photoUrl: json['photo_url'] as String?, - adresse: json['adresse'] as String?, - ville: json['ville'] as String?, - codePostal: json['code_postal'] as String?, + json['changement_mdp_obligatoire'] == true, + nom: json['nom'] is String ? json['nom'] as String : null, + prenom: json['prenom'] is String ? json['prenom'] as String : null, + statut: json['statut'] is String ? json['statut'] as String : null, + telephone: json['telephone'] is String ? json['telephone'] as String : null, + photoUrl: json['photo_url'] is String ? json['photo_url'] as String : null, + adresse: json['adresse'] is String ? json['adresse'] as String : null, + ville: json['ville'] is String ? json['ville'] as String : null, + codePostal: json['code_postal'] is String ? json['code_postal'] as String : null, relaisId: (json['relaisId'] ?? json['relais_id'] ?? relaisMap['id']) ?.toString(), relaisNom: relaisMap['nom']?.toString(), + numeroDossier: json['numero_dossier'] is String ? json['numero_dossier'] as String : null, ); } @@ -88,6 +99,7 @@ class AppUser { 'code_postal': codePostal, 'relais_id': relaisId, 'relais_nom': relaisNom, + 'numero_dossier': numeroDossier, }; } diff --git a/frontend/lib/screens/administrateurs/creation/admin_create.dart b/frontend/lib/screens/administrateurs/creation/admin_create.dart index e57ac71..ae1477c 100644 --- a/frontend/lib/screens/administrateurs/creation/admin_create.dart +++ b/frontend/lib/screens/administrateurs/creation/admin_create.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/user_service.dart'; @@ -34,7 +36,7 @@ class _AdminCreateDialogState extends State { _nomController.text = user.nom ?? ''; _prenomController.text = user.prenom ?? ''; _emailController.text = user.email; - _telephoneController.text = user.telephone ?? ''; + _telephoneController.text = formatPhoneForDisplay(user.telephone ?? ''); // En édition, on ne préremplit jamais le mot de passe. _passwordController.clear(); } @@ -91,7 +93,7 @@ class _AdminCreateDialogState extends State { nom: _nomController.text.trim(), prenom: _prenomController.text.trim(), email: _emailController.text.trim(), - telephone: _telephoneController.text.trim(), + telephone: normalizePhone(_telephoneController.text), password: _passwordController.text.trim().isEmpty ? null : _passwordController.text, @@ -102,7 +104,7 @@ class _AdminCreateDialogState extends State { prenom: _prenomController.text.trim(), email: _emailController.text.trim(), password: _passwordController.text, - telephone: _telephoneController.text.trim(), + telephone: normalizePhone(_telephoneController.text), ); } if (!mounted) return; @@ -347,8 +349,13 @@ class _AdminCreateDialogState extends State { return TextFormField( controller: _telephoneController, keyboardType: TextInputType.phone, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(10), + FrenchPhoneNumberFormatter(), + ], decoration: const InputDecoration( - labelText: 'Téléphone', + labelText: 'Téléphone (ex: 06 12 34 56 78)', border: OutlineInputBorder(), ), validator: (v) => _required(v, 'Téléphone'), diff --git a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart index 3a2ce05..8dbed7e 100644 --- a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart +++ b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:p_tits_pas/models/relais_model.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/relais_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; @@ -40,12 +41,12 @@ class _AdminUserFormDialogState extends State { String? _selectedRelaisId; bool get _isEditMode => widget.initialUser != null; bool get _isSuperAdminTarget => - widget.initialUser?.role.toLowerCase() == 'super_admin'; + (widget.initialUser?.role ?? '').toLowerCase() == 'super_admin'; bool get _isLockedAdminIdentity => _isEditMode && widget.adminMode && _isSuperAdminTarget; String get _targetRoleKey { if (widget.initialUser != null) { - return widget.initialUser!.role.toLowerCase(); + return (widget.initialUser!.role).toLowerCase(); } return widget.adminMode ? 'administrateur' : 'gestionnaire'; } @@ -92,7 +93,7 @@ class _AdminUserFormDialogState extends State { _nomController.text = user.nom ?? ''; _prenomController.text = user.prenom ?? ''; _emailController.text = user.email; - _telephoneController.text = _formatPhoneForDisplay(user.telephone ?? ''); + _telephoneController.text = formatPhoneForDisplay(user.telephone ?? ''); // En édition, on ne préremplit jamais le mot de passe. _passwordController.clear(); final initialRelaisId = user.relaisId?.trim(); @@ -185,7 +186,7 @@ class _AdminUserFormDialogState extends State { } final base = _required(value, 'Téléphone'); if (base != null) return base; - final digits = _normalizePhone(value!); + final digits = normalizePhone(value!); if (digits.length != 10) { return 'Le téléphone doit contenir 10 chiffres'; } @@ -195,22 +196,6 @@ class _AdminUserFormDialogState extends State { return null; } - String _normalizePhone(String raw) { - return raw.replaceAll(RegExp(r'\D'), ''); - } - - String _formatPhoneForDisplay(String raw) { - final normalized = _normalizePhone(raw); - final digits = - normalized.length > 10 ? normalized.substring(0, 10) : normalized; - final buffer = StringBuffer(); - for (var i = 0; i < digits.length; i++) { - if (i > 0 && i.isEven) buffer.write(' '); - buffer.write(digits[i]); - } - return buffer.toString(); - } - String _toTitleCase(String raw) { final trimmed = raw.trim(); if (trimmed.isEmpty) return trimmed; @@ -251,7 +236,7 @@ class _AdminUserFormDialogState extends State { try { final normalizedNom = _toTitleCase(_nomController.text); final normalizedPrenom = _toTitleCase(_prenomController.text); - final normalizedPhone = _normalizePhone(_telephoneController.text); + final normalizedPhone = normalizePhone(_telephoneController.text); final passwordProvided = _passwordController.text.trim().isNotEmpty; if (_isEditMode) { @@ -264,7 +249,7 @@ class _AdminUserFormDialogState extends State { prenom: _isLockedAdminIdentity ? lockedPrenom : normalizedPrenom, email: _emailController.text.trim(), telephone: normalizedPhone.isEmpty - ? _normalizePhone(widget.initialUser!.telephone ?? '') + ? normalizePhone(widget.initialUser!.telephone ?? '') : normalizedPhone, password: passwordProvided ? _passwordController.text : null, ); @@ -273,7 +258,7 @@ class _AdminUserFormDialogState extends State { final initialNom = _toTitleCase(currentUser.nom ?? ''); final initialPrenom = _toTitleCase(currentUser.prenom ?? ''); final initialEmail = currentUser.email.trim(); - final initialPhone = _normalizePhone(currentUser.telephone ?? ''); + final initialPhone = normalizePhone(currentUser.telephone ?? ''); final onlyRelaisChanged = normalizedNom == initialNom && @@ -306,7 +291,7 @@ class _AdminUserFormDialogState extends State { prenom: normalizedPrenom, email: _emailController.text.trim(), password: _passwordController.text, - telephone: _normalizePhone(_telephoneController.text), + telephone: normalizePhone(_telephoneController.text), ); } else { await UserService.createGestionnaire( @@ -314,7 +299,7 @@ class _AdminUserFormDialogState extends State { prenom: normalizedPrenom, email: _emailController.text.trim(), password: _passwordController.text, - telephone: _normalizePhone(_telephoneController.text), + telephone: normalizePhone(_telephoneController.text), relaisId: _selectedRelaisId, ); } @@ -608,7 +593,7 @@ class _AdminUserFormDialogState extends State { : [ FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(10), - _FrenchPhoneNumberFormatter(), + FrenchPhoneNumberFormatter(), ], decoration: const InputDecoration( labelText: 'Téléphone (ex: 06 12 34 56 78)', @@ -662,27 +647,3 @@ class _AdminUserFormDialogState extends State { ); } } - -class _FrenchPhoneNumberFormatter extends TextInputFormatter { - const _FrenchPhoneNumberFormatter(); - - @override - TextEditingValue formatEditUpdate( - TextEditingValue oldValue, - TextEditingValue newValue, - ) { - final digits = newValue.text.replaceAll(RegExp(r'\D'), ''); - final normalized = digits.length > 10 ? digits.substring(0, 10) : digits; - final buffer = StringBuffer(); - for (var i = 0; i < normalized.length; i++) { - if (i > 0 && i.isEven) buffer.write(' '); - buffer.write(normalized[i]); - } - final formatted = buffer.toString(); - - return TextEditingValue( - text: formatted, - selection: TextSelection.collapsed(offset: formatted.length), - ); - } -} \ No newline at end of file diff --git a/frontend/lib/screens/auth/login_screen.dart b/frontend/lib/screens/auth/login_screen.dart index 96064e7..2814b72 100644 --- a/frontend/lib/screens/auth/login_screen.dart +++ b/frontend/lib/screens/auth/login_screen.dart @@ -17,7 +17,7 @@ class LoginScreen extends StatefulWidget { State createState() => _LoginPageState(); } -class _LoginPageState extends State with WidgetsBindingObserver { +class _LoginPageState extends State { final _formKey = GlobalKey(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); @@ -27,31 +27,30 @@ class _LoginPageState extends State with WidgetsBindingObserver { static const double _mobileBreakpoint = 900.0; + /// Une seule fois : évite de relancer `_getImageDimensions()` à chaque rebuild (sinon sur web, + /// tout événement de layout / métriques recréait un Future et pouvait provoquer des erreurs DWDS + /// ou des comportements bizarres pendant la saisie dans les champs). + late final Future _desktopRiverLogoDimensionsFuture; + @override void initState() { super.initState(); - WidgetsBinding.instance.addObserver(this); + _desktopRiverLogoDimensionsFuture = _getImageDimensions(); } @override void dispose() { - WidgetsBinding.instance.removeObserver(this); _emailController.dispose(); _passwordController.dispose(); super.dispose(); } - @override - void didChangeMetrics() { - super.didChangeMetrics(); - if (mounted) setState(() {}); - } - String? _validateEmail(String? value) { - if (value == null || value.isEmpty) { + final v = value ?? ''; + if (v.isEmpty) { return 'Veuillez entrer votre email'; } - if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) { + if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(v)) { return 'Veuillez entrer un email valide'; } return null; @@ -116,7 +115,7 @@ class _LoginPageState extends State with WidgetsBindingObserver { TextInput.finishAutofillContext(shouldSave: true); // Rediriger selon le rôle de l'utilisateur - _redirectUserByRole(user.role); + _redirectUserByRole(user.role.isEmpty ? null : user.role); } catch (e) { setState(() { _isLoading = false; @@ -126,9 +125,10 @@ class _LoginPageState extends State with WidgetsBindingObserver { } /// Redirige l'utilisateur selon son rôle (GoRouter : context.go). - void _redirectUserByRole(String role) { + void _redirectUserByRole(String? role) { setState(() => _isLoading = false); - switch (role.toLowerCase()) { + final r = (role ?? '').toLowerCase(); + switch (r) { case 'super_admin': case 'administrateur': context.go('/admin-dashboard'); @@ -162,8 +162,8 @@ class _LoginPageState extends State with WidgetsBindingObserver { builder: (context, constraints) { final w = constraints.maxWidth; final h = constraints.maxHeight; - return FutureBuilder( - future: _getImageDimensions(), + return FutureBuilder( + future: _desktopRiverLogoDimensionsFuture, builder: (context, snapshot) { if (!snapshot.hasData) { return const Center(child: CircularProgressIndicator()); @@ -203,7 +203,7 @@ class _LoginPageState extends State with WidgetsBindingObserver { bottom: 0, width: w * 0.6, // 60% de la largeur de l'écran height: h * 0.5, // 50% de la hauteur de l'écran - child: Padding( + child: Padding( padding: EdgeInsets.all(w * 0.02), // 2% de padding child: AutofillGroup( child: Form( @@ -240,7 +240,7 @@ class _LoginPageState extends State with WidgetsBindingObserver { hintText: 'Votre mot de passe', obscureText: true, autofillHints: const [ - AutofillHints.password + AutofillHints.password, ], textInputAction: TextInputAction.done, onFieldSubmitted: diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index 613146d..a219a2d 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -19,6 +19,7 @@ class ApiConfig { static const String parents = '/parents'; static const String assistantesMaternelles = '/assistantes-maternelles'; static const String relais = '/relais'; + static const String dossiers = '/dossiers'; // Configuration (admin) static const String configuration = '/configuration'; diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart index f9b0e75..c995b9f 100644 --- a/frontend/lib/services/auth_service.dart +++ b/frontend/lib/services/auth_service.dart @@ -193,7 +193,10 @@ class AuthService { const fallback = 'Erreur lors de l\'inscription'; if (decoded == null || decoded is! Map) return '$fallback ($statusCode)'; final msg = decoded['message']; - if (msg == null) return decoded['error'] as String? ?? '$fallback ($statusCode)'; + if (msg == null) { + final err = decoded['error']; + return (err is String ? err : err?.toString()) ?? '$fallback ($statusCode)'; + } if (msg is String) return msg; if (msg is List) return msg.map((e) => e.toString()).join('. ').trim(); if (msg is Map && msg['message'] != null) return msg['message'].toString(); diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index ab8e5a7..a70d011 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -3,6 +3,8 @@ import 'package:http/http.dart' as http; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/parent_model.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; +import 'package:p_tits_pas/models/pending_family.dart'; +import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/services/api/tokenService.dart'; @@ -20,6 +22,145 @@ class UserService { return v.toString(); } + static String _errMessage(dynamic err) { + if (err == null) return 'Erreur inconnue'; + if (err is String) return err; + if (err is Map) { + final m = err['message']; + if (m is String) return m; + if (m is Map && m['message'] is String) return m['message'] as String; + if (m != null) return _toStr(m) ?? 'Erreur inconnue'; + } + return _toStr(err) ?? 'Erreur inconnue'; + } + + /// Utilisateurs en attente de validation (GET /users/pending). Ticket #107. + static Future> getPendingUsers({String? role}) async { + final query = role != null ? '?role=$role' : ''; + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/pending$query'), + headers: await _headers(), + ); + if (response.statusCode != 200) { + try { + final err = jsonDecode(response.body); + throw Exception(_errMessage(err is Map ? err['message'] : err)); + } catch (e) { + if (e is Exception) rethrow; + throw Exception('Erreur chargement comptes en attente (${response.statusCode})'); + } + } + try { + final decoded = jsonDecode(response.body); + final data = decoded is List ? decoded as List : []; + return data + .where((e) => e is Map) + .map((e) => AppUser.fromJson(Map.from(e as Map))) + .toList(); + } catch (e) { + throw Exception('Réponse invalide (comptes en attente): ${e is Exception ? e.toString() : "format inattendu"}'); + } + } + + /// Familles en attente (une entrée par famille). GET /parents/pending-families. Ticket #107. + static Future> getPendingFamilies() async { + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/pending-families'), + headers: await _headers(), + ); + if (response.statusCode != 200) { + try { + final err = jsonDecode(response.body); + throw Exception(_errMessage(err is Map ? err['message'] : err)); + } catch (e) { + if (e is Exception) rethrow; + throw Exception('Erreur chargement familles en attente (${response.statusCode})'); + } + } + try { + final decoded = jsonDecode(response.body); + final data = decoded is List ? decoded as List : []; + return data + .where((e) => e is Map) + .map((e) => PendingFamily.fromJson(Map.from(e as Map))) + .toList(); + } catch (e) { + throw Exception('Réponse invalide (familles en attente): ${e is Exception ? e.toString() : "format inattendu"}'); + } + } + + /// Dossier unifié par numéro (AM ou famille). GET /dossiers/:numeroDossier. Ticket #119, #107. + static Future getDossier(String numeroDossier) async { + final encoded = Uri.encodeComponent(numeroDossier); + final response = await http.get( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'), + headers: await _headers(), + ); + if (response.statusCode == 404) { + throw Exception('Aucun dossier avec ce numéro.'); + } + if (response.statusCode != 200) { + try { + final err = jsonDecode(response.body); + throw Exception(_errMessage(err is Map ? err['message'] : err)); + } catch (e) { + if (e is Exception) rethrow; + throw Exception('Erreur chargement dossier (${response.statusCode})'); + } + } + try { + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw FormatException('Réponse invalide'); + } + return DossierUnifie.fromJson(Map.from(decoded)); + } catch (e) { + if (e is FormatException) rethrow; + throw Exception('Réponse invalide (dossier): ${e is Exception ? e.toString() : "format inattendu"}'); + } + } + + /// Valider un utilisateur (AM). PATCH /users/:id/valider. Ticket #108. + static Future validateUser(String userId, {String? comment}) async { + final response = await http.patch( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId/valider'), + headers: await _headers(), + body: jsonEncode(comment != null ? {'comment': comment} : {}), + ); + if (response.statusCode != 200) { + try { + final err = jsonDecode(response.body); + throw Exception(_errMessage(err is Map ? err['message'] : err)); + } catch (e) { + if (e is Exception) rethrow; + throw Exception('Erreur validation (${response.statusCode})'); + } + } + final data = jsonDecode(response.body); + return AppUser.fromJson(Map.from(data is Map ? data : {})); + } + + /// Valider tout le dossier famille. POST /parents/:parentId/valider-dossier. Ticket #108. + static Future> validerDossierFamille(String parentId, {String? comment}) async { + final response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$parentId/valider-dossier'), + headers: await _headers(), + body: jsonEncode(comment != null ? {'comment': comment} : {}), + ); + if (response.statusCode != 200) { + try { + final err = jsonDecode(response.body); + throw Exception(_errMessage(err is Map ? err['message'] : err)); + } catch (e) { + if (e is Exception) rethrow; + throw Exception('Erreur validation dossier famille (${response.statusCode})'); + } + } + final data = jsonDecode(response.body); + final list = data is List ? data : []; + return list.map((e) => AppUser.fromJson(Map.from(e as Map))).toList(); + } + // Récupérer la liste des gestionnaires (endpoint dédié) static Future> getGestionnaires() async { final response = await http.get( diff --git a/frontend/lib/utils/nir_utils.dart b/frontend/lib/utils/nir_utils.dart index cfed04b..0ca0324 100644 --- a/frontend/lib/utils/nir_utils.dart +++ b/frontend/lib/utils/nir_utils.dart @@ -49,12 +49,12 @@ String nirToRaw(String normalized) { return s; } -/// Formate pour affichage : 1 12 34 56 789 012-34 ou 1 12 34 2A 789 012-34 (Corse). +/// Formate pour affichage : 1 12 34 56 789 012 - 34 ou 1 12 34 2A 789 012 - 34 (Corse). String formatNir(String raw) { final r = nirToRaw(raw); if (r.length < 15) return r; // Même structure pour tous : sexe + année + mois + département + commune + ordre-clé. - return '${r.substring(0, 1)} ${r.substring(1, 3)} ${r.substring(3, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)}-${r.substring(13, 15)}'; + return '${r.substring(0, 1)} ${r.substring(1, 3)} ${r.substring(3, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)} - ${r.substring(13, 15)}'; } /// Vérifie le format : 15 caractères, structure 1+2+2+2+3+3+2, département 2A/2B autorisé. @@ -82,7 +82,7 @@ String? validateNir(String? value) { if (value == null || value.isEmpty) return 'NIR requis'; final raw = nirToRaw(value).toUpperCase(); if (raw.length != 15) return 'Le NIR doit contenir 15 caractères (chiffres, ou 2A/2B pour la Corse)'; - if (!_isFormatValid(raw)) return 'Format NIR invalide (ex. 1 12 34 56 789 012-34 ou 2A pour la Corse)'; + if (!_isFormatValid(raw)) return 'Format NIR invalide (ex. 1 12 34 56 789 012 - 34 ou 2A pour la Corse)'; final key = _controlKey(raw.substring(0, 13)); final keyStr = key >= 0 && key <= 99 ? key.toString().padLeft(2, '0') : ''; final expectedKey = raw.substring(13, 15); @@ -90,7 +90,7 @@ String? validateNir(String? value) { return null; } -/// Formateur de saisie : affiche le NIR formaté (1 12 34 56 789 012-34) et limite à 15 caractères utiles. +/// Formateur de saisie : affiche le NIR formaté (1 12 34 56 789 012 - 34) et limite à 15 caractères utiles. class NirInputFormatter extends TextInputFormatter { @override TextEditingValue formatEditUpdate( diff --git a/frontend/lib/utils/phone_utils.dart b/frontend/lib/utils/phone_utils.dart new file mode 100644 index 0000000..2a87b8c --- /dev/null +++ b/frontend/lib/utils/phone_utils.dart @@ -0,0 +1,57 @@ +import 'package:flutter/services.dart'; + +/// Format français : 10 chiffres affichés par paires (ex. 06 12 34 56 78). + +/// Ne garde que les chiffres (max 10). +String normalizePhone(String raw) { + final digits = raw.replaceAll(RegExp(r'\D'), ''); + return digits.length > 10 ? digits.substring(0, 10) : digits; +} + +/// Retourne le numéro formaté pour l'affichage (ex. "06 12 34 56 78"). +/// Si [raw] est vide après normalisation, retourne [raw] tel quel (pour afficher "–" etc.). +String formatPhoneForDisplay(String raw) { + if (raw.trim().isEmpty) return raw; + final normalized = normalizePhone(raw); + if (normalized.isEmpty) return raw; + final buffer = StringBuffer(); + for (var i = 0; i < normalized.length; i++) { + if (i > 0 && i.isEven) buffer.write(' '); + buffer.write(normalized[i]); + } + return buffer.toString(); +} + +/// Formatter de saisie : uniquement chiffres, espaces automatiques toutes les 2 chiffres, max 10 chiffres. +class FrenchPhoneNumberFormatter extends TextInputFormatter { + const FrenchPhoneNumberFormatter(); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final digits = newValue.text.replaceAll(RegExp(r'\D'), ''); + final normalized = digits.length > 10 ? digits.substring(0, 10) : digits; + final buffer = StringBuffer(); + for (var i = 0; i < normalized.length; i++) { + if (i > 0 && i.isEven) buffer.write(' '); + buffer.write(normalized[i]); + } + final formatted = buffer.toString(); + + // Conserver la position du curseur : compter les chiffres avant la sélection + final sel = newValue.selection; + final digitsBeforeCursor = newValue.text + .substring(0, sel.start.clamp(0, newValue.text.length)) + .replaceAll(RegExp(r'\D'), '') + .length; + final newOffset = digitsBeforeCursor + (digitsBeforeCursor > 0 ? digitsBeforeCursor ~/ 2 : 0); + final clampedOffset = newOffset.clamp(0, formatted.length); + + return TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: clampedOffset), + ); + } +} diff --git a/frontend/lib/widgets/admin/admin_management_widget.dart b/frontend/lib/widgets/admin/admin_management_widget.dart index be51365..01f74d9 100644 --- a/frontend/lib/widgets/admin/admin_management_widget.dart +++ b/frontend/lib/widgets/admin/admin_management_widget.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart'; import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; @@ -60,18 +61,18 @@ class _AdminManagementWidgetState extends State { if (!mounted) return; if (cached != null) { setState(() { - _currentUserRole = cached.role.toLowerCase(); + _currentUserRole = (cached.role).toLowerCase(); }); return; } final refreshed = await AuthService.refreshCurrentUser(); if (!mounted || refreshed == null) return; setState(() { - _currentUserRole = refreshed.role.toLowerCase(); + _currentUserRole = (refreshed.role).toLowerCase(); }); } - bool _isSuperAdmin(AppUser user) => user.role.toLowerCase() == 'super_admin'; + bool _isSuperAdmin(AppUser user) => (user.role).toLowerCase() == 'super_admin'; bool _canEditAdmin(AppUser target) { if (!_isSuperAdmin(target)) return true; @@ -123,7 +124,7 @@ class _AdminManagementWidgetState extends State { : Icons.manage_accounts_outlined, subtitleLines: [ user.email, - 'Téléphone : ${user.telephone?.trim().isNotEmpty == true ? user.telephone : 'Non renseigné'}', + 'Téléphone : ${user.telephone?.trim().isNotEmpty == true ? formatPhoneForDisplay(user.telephone!) : 'Non renseigné'}', ], avatarUrl: user.photoUrl, borderColor: isSuperAdmin diff --git a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart index d655055..432a782 100644 --- a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart +++ b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; @@ -126,7 +127,7 @@ class _AssistanteMaternelleManagementWidgetState ), AdminDetailField( label: 'Telephone', - value: _v(assistante.user.telephone), + value: _v(assistante.user.telephone) != '–' ? formatPhoneForDisplay(_v(assistante.user.telephone)) : '–', ), AdminDetailField(label: 'Adresse', value: _v(assistante.user.adresse)), AdminDetailField(label: 'Ville', value: _v(assistante.user.ville)), diff --git a/frontend/lib/widgets/admin/common/validation_detail_section.dart b/frontend/lib/widgets/admin/common/validation_detail_section.dart new file mode 100644 index 0000000..deae30c --- /dev/null +++ b/frontend/lib/widgets/admin/common/validation_detail_section.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'admin_detail_modal.dart'; + +/// Bloc type formulaire (titre de section + champs read-only) pour les modales de validation. +/// [rowLayout] : même disposition que la création de compte, ex. [2, 2, 1, 2] = ligne de 2, ligne de 2, plein largeur, ligne de 2. +/// [rowFlex] : flex par index de ligne (optionnel). Ex. {3: [2, 5]} = 4e ligne : code postal étroit (2), ville large (5). +class ValidationDetailSection extends StatelessWidget { + final String title; + final List fields; + + /// Nombre de champs par ligne (1 = plein largeur, 2 = deux côte à côte). Ex. [2, 2, 1, 2] pour identité. + final List? rowLayout; + + /// Flex par ligne (index de ligne -> [flex1, flex2, ...]). Ex. {3: [2, 5]} pour Code postal | Ville. + final Map>? rowFlex; + + const ValidationDetailSection({ + super.key, + required this.title, + required this.fields, + this.rowLayout, + this.rowFlex, + }); + + @override + Widget build(BuildContext context) { + final layout = rowLayout ?? List.filled(fields.length, 1); + int index = 0; + int rowIndex = 0; + final rows = []; + for (final count in layout) { + if (index >= fields.length) break; + final rowFields = fields.skip(index).take(count).toList(); + index += count; + if (rowFields.isEmpty) continue; + final flexForRow = rowFlex?[rowIndex]; + rowIndex++; + if (count == 1) { + rows.add(Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _buildFieldCell(rowFields.first), + )); + } else { + rows.add(Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < rowFields.length; i++) ...[ + if (i > 0) const SizedBox(width: 16), + Expanded( + flex: (flexForRow != null && i < flexForRow.length) + ? flexForRow[i] + : 1, + child: _buildFieldCell(rowFields[i]), + ), + ], + ], + ), + )); + } + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const SizedBox(height: 12), + ...rows, + ], + ); + } + + Widget _buildFieldCell(AdminDetailField field) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + field.label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Colors.grey.shade700, + ), + ), + const SizedBox(height: 4), + ValidationReadOnlyField(value: field.value), + ], + ); + } +} + +/// Champ texte en lecture seule, style formulaire (fond gris léger, bordure). Réutilisable en éditable plus tard. +class ValidationReadOnlyField extends StatelessWidget { + final String value; + final int? maxLines; + + const ValidationReadOnlyField({ + super.key, + required this.value, + this.maxLines = 1, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.grey.shade300), + ), + child: Text( + value, + style: const TextStyle(color: Colors.black87, fontSize: 14), + maxLines: maxLines, + overflow: TextOverflow.ellipsis, + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/dashboard_admin.dart b/frontend/lib/widgets/admin/dashboard_admin.dart index 55c99be..47c0983 100644 --- a/frontend/lib/widgets/admin/dashboard_admin.dart +++ b/frontend/lib/widgets/admin/dashboard_admin.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; -/// Sous-barre : Gestionnaires | Parents | Assistantes maternelles | [Administrateurs]. -/// [subTabCount] = 3 pour masquer l'onglet Administrateurs (dashboard gestionnaire). +/// Sous-barre : [À valider] | Gestionnaires | Parents | Assistantes maternelles | [Administrateurs]. +/// [tabLabels] : liste des libellés d'onglets (ex. avec « À valider » en premier si dossiers en attente). +/// [subTabCount] = 3 pour masquer Administrateurs (dashboard gestionnaire). class DashboardUserManagementSubBar extends StatelessWidget { final int selectedSubIndex; final ValueChanged onSubTabChange; @@ -11,8 +12,10 @@ class DashboardUserManagementSubBar extends StatelessWidget { final VoidCallback? onAddPressed; final String addLabel; final int subTabCount; + /// Si non null, utilisé à la place des labels par défaut (ex. ['À valider', 'Parents', ...]). + final List? tabLabels; - static const List _tabLabels = [ + static const List _defaultTabLabels = [ 'Gestionnaires', 'Parents', 'Assistantes maternelles', @@ -29,10 +32,14 @@ class DashboardUserManagementSubBar extends StatelessWidget { this.onAddPressed, this.addLabel = '+ Ajouter', this.subTabCount = 4, + this.tabLabels, }) : super(key: key); + List get _labels => tabLabels ?? _defaultTabLabels.sublist(0, subTabCount.clamp(1, _defaultTabLabels.length)); + @override Widget build(BuildContext context) { + final labels = _labels; return Container( height: 56, decoration: BoxDecoration( @@ -42,9 +49,9 @@ class DashboardUserManagementSubBar extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6), child: Row( children: [ - for (int i = 0; i < subTabCount; i++) ...[ + for (int i = 0; i < labels.length; i++) ...[ if (i > 0) const SizedBox(width: 12), - _buildSubNavItem(context, _tabLabels[i], i), + _buildSubNavItem(context, labels[i], i), ], const SizedBox(width: 36), _pillField( @@ -68,7 +75,7 @@ class DashboardUserManagementSubBar extends StatelessWidget { _pillField(width: 150, child: filterControl!), ], const Spacer(), - _buildAddButton(), + if (onAddPressed != null) _buildAddButton(), ], ), ); diff --git a/frontend/lib/widgets/admin/parent_managmant_widget.dart b/frontend/lib/widgets/admin/parent_managmant_widget.dart index 5b8e20a..f4c362d 100644 --- a/frontend/lib/widgets/admin/parent_managmant_widget.dart +++ b/frontend/lib/widgets/admin/parent_managmant_widget.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/parent_model.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; @@ -122,7 +123,7 @@ class _ParentManagementWidgetState extends State { ), AdminDetailField( label: 'Telephone', - value: _v(parent.user.telephone), + value: _v(parent.user.telephone) != '–' ? formatPhoneForDisplay(_v(parent.user.telephone)) : '–', ), AdminDetailField(label: 'Adresse', value: _v(parent.user.adresse)), AdminDetailField(label: 'Ville', value: _v(parent.user.ville)), diff --git a/frontend/lib/widgets/admin/pending_validation_widget.dart b/frontend/lib/widgets/admin/pending_validation_widget.dart new file mode 100644 index 0000000..c1dac5b --- /dev/null +++ b/frontend/lib/widgets/admin/pending_validation_widget.dart @@ -0,0 +1,354 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/models/pending_family.dart'; +import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart'; + +/// Onglet « À valider » : deux listes (AM en attente, familles en attente). Ticket #107. +class PendingValidationWidget extends StatefulWidget { + final VoidCallback? onRefresh; + + const PendingValidationWidget({super.key, this.onRefresh}); + + @override + State createState() => _PendingValidationWidgetState(); +} + +class _PendingValidationWidgetState extends State { + bool _isLoading = true; + String? _error; + List _pendingAM = []; + List _pendingFamilies = []; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _isLoading = true; + _error = null; + }); + try { + final am = await UserService.getPendingUsers(role: 'assistante_maternelle'); + final families = await UserService.getPendingFamilies(); + if (!mounted) return; + setState(() { + _pendingAM = am; + _pendingFamilies = families; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur inconnue'; + _isLoading = false; + }); + } + } + + void _onOpenValidation({String? type, String? id, String? numeroDossier}) { + final num = numeroDossier?.trim(); + if (num == null || num.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Numéro de dossier manquant.')), + ); + return; + } + showDialog( + context: context, + builder: (context) => ValidationDossierModal( + numeroDossier: num, + onClose: () => Navigator.of(context).pop(), + onSuccess: () { + Navigator.of(context).pop(); + _load(); + widget.onRefresh?.call(); + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (_error != null && _error!.isNotEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_error!, style: const TextStyle(color: Colors.red)), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _load, + child: const Text('Réessayer'), + ), + ], + ), + ), + ); + } + + final hasAM = _pendingAM.isNotEmpty; + final hasFamilies = _pendingFamilies.isNotEmpty; + if (!hasAM && !hasFamilies) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle_outline, size: 64, color: Colors.grey.shade400), + const SizedBox(height: 16), + Text( + 'Aucun dossier en attente de validation', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.grey.shade600, + ), + ), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: () async { + await _load(); + widget.onRefresh?.call(); + }, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (hasAM) ...[ + _sectionTitle('Assistantes maternelles en attente'), + const SizedBox(height: 8), + ..._pendingAM.map((u) => _buildAMCard(u)), + const SizedBox(height: 24), + ], + if (hasFamilies) ...[ + _sectionTitle('Familles en attente'), + const SizedBox(height: 8), + ..._pendingFamilies.map((f) => _buildFamilyCard(f)), + ], + ], + ), + ), + ); + } + + Widget _sectionTitle(String title) { + return Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ); + } + + /// Ligne commune : icône | titre (+ sous-titre) | bouton Ouvrir. + /// [titleWidget] remplace [title] si les deux sont fournis : priorité à [titleWidget]. + Widget _buildPendingRow({ + required IconData icon, + String? title, + Widget? titleWidget, + String? subtitle, + TextStyle? subtitleStyle, + required VoidCallback onOpen, + }) { + assert(title != null || titleWidget != null); + final titleChild = titleWidget ?? + Text( + title!, + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ); + final subStyle = subtitleStyle ?? + TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ); + return Card( + margin: const EdgeInsets.only(bottom: 12), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide(color: Colors.grey.shade300), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: Colors.grey.shade600, size: 28), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + titleChild, + if (subtitle != null && subtitle.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + subtitle, + style: subStyle, + ), + ], + ], + ), + ), + ElevatedButton.icon( + onPressed: onOpen, + icon: const Icon(Icons.open_in_new, size: 18), + label: const Text('Ouvrir'), + ), + ], + ), + ), + ); + } + + /// Sous-titre AM : `email - date • tél. • CP ville` (plan affichage lignes À valider). + String _amSubtitleLine(AppUser user) { + final email = user.email.trim(); + final bits = []; + bits.add(DateFormat('dd/MM/yyyy').format(user.createdAt.toLocal())); + final tel = user.telephone?.trim(); + if (tel != null && tel.isNotEmpty) { + bits.add(formatPhoneForDisplay(tel)); + } + final cp = user.codePostal?.trim(); + final ville = user.ville?.trim(); + final loc = [if (cp != null && cp.isNotEmpty) cp, if (ville != null && ville.isNotEmpty) ville] + .join(' ') + .trim(); + if (loc.isNotEmpty) bits.add(loc); + final infos = bits.join(' • '); + if (email.isEmpty) return infos; + return '$email - $infos'; + } + + Widget _buildAMCard(AppUser user) { + final numDossier = user.numeroDossier ?? '–'; + final nameBold = + user.fullName.isNotEmpty ? user.fullName : (user.email.isNotEmpty ? user.email : '–'); + return _buildPendingRow( + icon: Icons.person_outline, + titleWidget: Text.rich( + TextSpan( + style: const TextStyle(fontSize: 14, color: Colors.black87), + children: [ + TextSpan( + text: nameBold, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + TextSpan( + text: ' - $numDossier', + style: const TextStyle(fontWeight: FontWeight.w400), + ), + ], + ), + ), + subtitle: _amSubtitleLine(user), + subtitleStyle: TextStyle( + fontSize: 12, + fontStyle: FontStyle.italic, + color: Colors.grey.shade600, + ), + onOpen: () => _onOpenValidation( + type: 'AM', + id: user.id, + numeroDossier: user.numeroDossier, + ), + ); + } + + /// `email, tél., localisation` par parent, puis `date soumission`, puis `nb enfants`. + String _familyParentSegment(PendingParentLine p) { + final parts = []; + final e = p.email?.trim(); + if (e != null && e.isNotEmpty) parts.add(e); + final t = p.telephone?.trim(); + if (t != null && t.isNotEmpty) parts.add(formatPhoneForDisplay(t)); + final cp = p.codePostal?.trim(); + final v = p.ville?.trim(); + final loc = [if (cp != null && cp.isNotEmpty) cp, if (v != null && v.isNotEmpty) v] + .join(' ') + .trim(); + if (loc.isNotEmpty) parts.add(loc); + return parts.join(', '); + } + + String _familySubtitleLine(PendingFamily family) { + final blocks = family.parentLines + .map(_familyParentSegment) + .where((s) => s.isNotEmpty) + .join(' - '); + + final tail = []; + final date = family.dateSoumission; + if (date != null) { + tail.add(DateFormat('dd/MM/yyyy').format(date.toLocal())); + } + if (family.nombreEnfants > 0) { + tail.add( + family.nombreEnfants > 1 + ? '${family.nombreEnfants} enfants' + : '1 enfant', + ); + } + final right = tail.join(' - '); + + if (blocks.isEmpty && right.isEmpty) return ''; + if (blocks.isEmpty) return right; + if (right.isEmpty) return blocks; + return '$blocks - $right'; + } + + Widget _buildFamilyCard(PendingFamily family) { + final numDossier = family.numeroDossier ?? '–'; + final nameBold = family.libelle.isNotEmpty ? family.libelle : 'Famille'; + return _buildPendingRow( + icon: Icons.family_restroom_outlined, + titleWidget: Text.rich( + TextSpan( + style: const TextStyle(fontSize: 14, color: Colors.black87), + children: [ + TextSpan( + text: nameBold, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + TextSpan( + text: ' - $numDossier', + style: const TextStyle(fontWeight: FontWeight.w400), + ), + ], + ), + ), + subtitle: _familySubtitleLine(family), + subtitleStyle: TextStyle( + fontSize: 12, + fontStyle: FontStyle.italic, + color: Colors.grey.shade600, + ), + onOpen: () => _onOpenValidation( + type: 'famille', + id: family.parentIds.isNotEmpty ? family.parentIds.first : null, + numeroDossier: family.numeroDossier, + ), + ); + } +} + diff --git a/frontend/lib/widgets/admin/relais_management_panel.dart b/frontend/lib/widgets/admin/relais_management_panel.dart index 0940dcd..12327b7 100644 --- a/frontend/lib/widgets/admin/relais_management_panel.dart +++ b/frontend/lib/widgets/admin/relais_management_panel.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:p_tits_pas/models/relais_model.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/services/relais_service.dart'; class RelaisManagementPanel extends StatefulWidget { @@ -723,7 +724,7 @@ class _RelaisFormDialogState extends State<_RelaisFormDialog> { inputFormatters: [ FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(10), - _FrenchPhoneNumberFormatter(), + FrenchPhoneNumberFormatter(), ], decoration: const InputDecoration( labelText: 'Ligne fixe', @@ -991,30 +992,6 @@ class _RelaisFormDialogState extends State<_RelaisFormDialog> { } } -class _FrenchPhoneNumberFormatter extends TextInputFormatter { - @override - TextEditingValue formatEditUpdate( - TextEditingValue oldValue, - TextEditingValue newValue, - ) { - final digits = newValue.text.replaceAll(RegExp(r'\D'), ''); - final buffer = StringBuffer(); - - for (var i = 0; i < digits.length; i++) { - if (i > 0 && i.isEven) { - buffer.write(' '); - } - buffer.write(digits[i]); - } - - final formatted = buffer.toString(); - return TextEditingValue( - text: formatted, - selection: TextSelection.collapsed(offset: formatted.length), - ); - } -} - class _RelaisAddressFields extends StatelessWidget { final TextEditingController streetController; final TextEditingController postalCodeController; diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index 18ba940..21958d3 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.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/assistante_maternelle_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart'; +import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart'; class UserManagementPanel extends StatefulWidget { /// Afficher l'onglet Administrateurs (sinon 3 onglets : Gestionnaires, Parents, AM). @@ -26,12 +28,41 @@ class _UserManagementPanelState extends State { final TextEditingController _searchController = TextEditingController(); final TextEditingController _amCapacityController = TextEditingController(); String? _parentStatus; + bool _hasPending = false; + bool _pendingLoading = true; @override void initState() { super.initState(); _searchController.addListener(_onFilterChanged); _amCapacityController.addListener(_onFilterChanged); + _loadPending(); + } + + Future _loadPending() async { + try { + final am = await UserService.getPendingUsers(role: 'assistante_maternelle'); + final families = await UserService.getPendingFamilies(); + if (!mounted) return; + final hasPending = am.isNotEmpty || families.isNotEmpty; + setState(() { + final hadPending = _hasPending; + _hasPending = hasPending; + _pendingLoading = false; + // Si on passe à "plus de dossiers", recaler l'index (onglet À valider disparaît). + if (hadPending && !hasPending) { + _subIndex = (_subIndex > 0 ? _subIndex - 1 : 0).clamp(0, _tabLabels.length - 1); + } else if (!hadPending && hasPending) { + _subIndex = 0; // Afficher l'onglet À valider + } + }); + } catch (_) { + if (!mounted) return; + setState(() { + _hasPending = false; + _pendingLoading = false; + }); + } } @override @@ -48,8 +79,17 @@ class _UserManagementPanelState extends State { setState(() {}); } + List get _tabLabels { + const base = ['Parents', 'Assistantes maternelles', 'Gestionnaires']; + final withAdmin = [...base, 'Administrateurs']; + final list = widget.showAdministrateursTab ? withAdmin : base; + // Onglet « À valider » visible seulement s'il y a des dossiers en attente (ticket #107). + if (!_pendingLoading && _hasPending) return ['À valider', ...list]; + return list; + } + void _onSubTabChange(int index) { - final maxIndex = widget.showAdministrateursTab ? 3 : 2; + final maxIndex = _tabLabels.length - 1; setState(() { _subIndex = index.clamp(0, maxIndex); _searchController.clear(); @@ -58,14 +98,20 @@ class _UserManagementPanelState extends State { }); } + /// Index du contenu : -1 = À valider (si visible), 0 = Parents, 1 = AM, 2 = Gestionnaires, 3 = Admin. + int get _contentIndexOffset => (_hasPending && !_pendingLoading) ? 1 : 0; + String _searchHintForTab() { - switch (_subIndex) { + final contentIndex = _subIndex - _contentIndexOffset; + switch (contentIndex) { + case -1: + return 'À valider (pas de recherche)'; case 0: - return 'Rechercher un gestionnaire...'; - case 1: return 'Rechercher un parent...'; - case 2: + case 1: return 'Rechercher une assistante...'; + case 2: + return 'Rechercher un gestionnaire...'; case 3: return 'Rechercher un administrateur...'; default: @@ -74,7 +120,7 @@ class _UserManagementPanelState extends State { } Widget? _subBarFilterControl() { - if (_subIndex == 1) { + if (_subIndex == _contentIndexOffset + 0) { return DropdownButtonHideUnderline( child: DropdownButton( value: _parentStatus, @@ -122,7 +168,7 @@ class _UserManagementPanelState extends State { ); } - if (_subIndex == 2) { + if (_subIndex == _contentIndexOffset + 1) { return TextField( controller: _amCapacityController, decoration: const InputDecoration( @@ -139,22 +185,26 @@ class _UserManagementPanelState extends State { } Widget _buildBody() { - switch (_subIndex) { + final contentIndex = _subIndex - _contentIndexOffset; + if (_hasPending && !_pendingLoading && contentIndex == -1) { + return PendingValidationWidget(onRefresh: _loadPending); + } + switch (contentIndex) { case 0: - return GestionnaireManagementWidget( - key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'), - searchQuery: _searchController.text, - ); - case 1: return ParentManagementWidget( searchQuery: _searchController.text, statusFilter: _parentStatus, ); - case 2: + case 1: return AssistanteMaternelleManagementWidget( searchQuery: _searchController.text, capacityMin: int.tryParse(_amCapacityController.text), ); + case 2: + return GestionnaireManagementWidget( + key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'), + searchQuery: _searchController.text, + ); case 3: return AdminManagementWidget( key: ValueKey('admins-$_adminRefreshTick'), @@ -167,7 +217,8 @@ class _UserManagementPanelState extends State { @override Widget build(BuildContext context) { - final subTabCount = widget.showAdministrateursTab ? 4 : 3; + final labels = _tabLabels; + final isAValiderTab = _hasPending && !_pendingLoading && _subIndex == 0; return Column( children: [ DashboardUserManagementSubBar( @@ -176,9 +227,10 @@ class _UserManagementPanelState extends State { searchController: _searchController, searchHint: _searchHintForTab(), filterControl: _subBarFilterControl(), - onAddPressed: _handleAddPressed, + onAddPressed: isAValiderTab ? null : _handleAddPressed, addLabel: 'Ajouter', - subTabCount: subTabCount, + subTabCount: labels.length, + tabLabels: labels, ), Expanded(child: _buildBody()), ], @@ -186,7 +238,8 @@ class _UserManagementPanelState extends State { } Future _handleAddPressed() async { - if (_subIndex == 0) { + final contentIndex = _subIndex - _contentIndexOffset; + if (contentIndex == 2) { final created = await showDialog( context: context, barrierDismissible: false, @@ -204,7 +257,7 @@ class _UserManagementPanelState extends State { return; } - if (_subIndex == 3) { + if (contentIndex == 3) { final created = await showDialog( context: context, barrierDismissible: false, diff --git a/frontend/lib/widgets/admin/validation_am_wizard.dart b/frontend/lib/widgets/admin/validation_am_wizard.dart new file mode 100644 index 0000000..e15bae5 --- /dev/null +++ b/frontend/lib/widgets/admin/validation_am_wizard.dart @@ -0,0 +1,507 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/dossier_unifie.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/utils/nir_utils.dart'; +import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; +import 'validation_modal_theme.dart'; +import 'validation_refus_form.dart'; +import 'validation_valider_confirm_dialog.dart'; + +/// Wizard de validation dossier AM : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107. +class ValidationAmWizard extends StatefulWidget { + final DossierAM dossier; + final VoidCallback onClose; + final VoidCallback onSuccess; + final void Function(int step, int total)? onStepChanged; + + const ValidationAmWizard({ + super.key, + required this.dossier, + required this.onClose, + required this.onSuccess, + this.onStepChanged, + }); + + @override + State createState() => _ValidationAmWizardState(); +} + +class _ValidationAmWizardState extends State { + int _step = 0; + bool _showRefusForm = false; + bool _submitting = false; + + static const int _stepCount = 3; + + bool get _isEnAttente => widget.dossier.user.statut == 'en_attente'; + + static String _v(String? s) => + (s != null && s.trim().isNotEmpty) ? s.trim() : '–'; + + /// Présentation lisible : `1 12 34 56 789 012 - 34` (15 caractères utiles requis). + static String _formatNirForDisplay(String? nir) { + final v = _v(nir); + if (v == '–') return v; + final raw = nirToRaw(v).toUpperCase(); + return raw.length == 15 ? formatNir(raw) : v; + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep()); + } + + void _emitStep() => widget.onStepChanged?.call(_step, _stepCount); + + /// Même ordre et disposition que le formulaire de création de compte (Nom/Prénom, Tél/Email, Adresse, CP/Ville). + List _personalFields(AppUser u) => [ + AdminDetailField(label: 'Nom', value: _v(u.nom)), + AdminDetailField(label: 'Prénom', value: _v(u.prenom)), + AdminDetailField( + label: 'Téléphone', + value: _v(u.telephone) != '–' + ? formatPhoneForDisplay(_v(u.telephone)) + : '–'), + AdminDetailField(label: 'Email', value: _v(u.email)), + AdminDetailField(label: 'Adresse (N° et Rue)', value: _v(u.adresse)), + AdminDetailField(label: 'Code postal', value: _v(u.codePostal)), + AdminDetailField(label: 'Ville', value: _v(u.ville)), + ]; + + /// Informations professionnelles : N° Agrément|Date agrément, NIR, Capacité|Places, Ville. + List _proFields(DossierAM d) => [ + AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)), + AdminDetailField( + label: 'Date d’agrément', + value: d.dateAgrement != null && d.dateAgrement!.trim().isNotEmpty + ? d.dateAgrement!.trim() + : '–', + ), + AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)), + AdminDetailField( + label: 'Capacité max (enfants)', + value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '–', + ), + AdminDetailField( + label: 'Places disponibles', + value: d.placesDisponibles != null + ? d.placesDisponibles.toString() + : '–', + ), + AdminDetailField( + label: 'Ville de résidence', value: _v(d.villeResidence)), + ]; + + static const List _personalRowLayout = [2, 2, 1, 2]; + static const Map> _personalRowFlex = { + 3: [2, 5] + }; // Code postal étroit, Ville large + + /// Proportion photo d’identité (35×45 mm). + static const double _idPhotoAspectRatio = 35 / 45; + + static const double _photoProGap = 24; + /// Largeur mini réservée aux champs (évite une colonne photo trop gourmande). + static const double _proColumnMinWidth = 260; + static const double _photoColumnMinWidth = 160; + + /// URL complète pour la photo : si relatif, on préfixe par l’origine de l’API. + static String _fullPhotoUrl(String? url) { + if (url == null || url.trim().isEmpty) return ''; + final u = url.trim(); + if (u.startsWith('http://') || u.startsWith('https://')) return u; + final base = ApiConfig.baseUrl; + final origin = base.replaceAll(RegExp(r'/api/v1.*'), ''); + return u.startsWith('/') ? '$origin$u' : '$origin/$u'; + } + + Widget _buildPhotoSection(AppUser u) { + final photoUrl = _fullPhotoUrl(u.photoUrl); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Photo de profil', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const SizedBox(height: 12), + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: LayoutBuilder( + builder: (context, c) { + // Cadre clair : une seule épaisseur partout (photo + padding identique haut/bas/gauche/droite). + const uniformFrame = 8.0; + final maxPhotoW = + (c.maxWidth - 2 * uniformFrame).clamp(0.0, double.infinity); + final maxPhotoH = + (c.maxHeight - 2 * uniformFrame).clamp(0.0, double.infinity); + const ar = _idPhotoAspectRatio; + double ph = maxPhotoH; + double pw = ph * ar; + if (pw > maxPhotoW) { + pw = maxPhotoW; + ph = pw / ar; + } + return Align( + alignment: Alignment.center, + child: Container( + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(uniformFrame), + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SizedBox( + width: pw, + height: ph, + child: photoUrl.isEmpty + ? ColoredBox( + color: Colors.grey.shade200, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.person_off_outlined, + size: 40, + color: Colors.grey.shade400), + const SizedBox(height: 8), + Text( + 'Aucune photo fournie', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 12), + ), + ], + ), + ) + : Image.network( + photoUrl, + fit: BoxFit.cover, + width: pw, + height: ph, + loadingBuilder: (_, child, progress) { + if (progress == null) return child; + return ColoredBox( + color: Colors.grey.shade200, + child: Center( + child: CircularProgressIndicator( + value: progress.expectedTotalBytes != + null + ? progress.cumulativeBytesLoaded / + (progress.expectedTotalBytes!) + : null, + ), + ), + ); + }, + errorBuilder: (_, __, ___) => ColoredBox( + color: Colors.grey.shade200, + child: Column( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Icon(Icons.broken_image_outlined, + size: 40, + color: Colors.grey.shade400), + const SizedBox(height: 8), + Text( + 'Impossible de charger la photo', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 12), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + }, + ), + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + if (_showRefusForm) { + return _buildRefusPage(); + } + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 4), + Expanded(child: _buildStepContent()), + const SizedBox(height: 24), + _buildNavigation(), + ], + ), + ); + } + + Widget _buildStepContent() { + final d = widget.dossier; + final u = d.user; + switch (_step) { + case 0: + return LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + child: ValidationDetailSection( + title: 'Informations personnelles', + fields: _personalFields(u), + rowLayout: _personalRowLayout, + rowFlex: _personalRowFlex, + ), + ), + ); + }, + ); + case 1: + // Pas de SingleChildScrollView sur la Row (hauteur non bornée). Défilement à droite. + // Largeur photo ≈ ratio × hauteur utile, plafonnée pour laisser au moins [_proColumnMinWidth] aux champs. + return LayoutBuilder( + builder: (context, c) { + final maxRowW = c.maxWidth; + final maxRowH = c.maxHeight; + // Titre « Photo de profil » + espacement (~52 px) : hauteur dispo pour le cadre photo. + const photoHeaderH = 52.0; + final bodyH = (maxRowH - photoHeaderH).clamp(0.0, double.infinity); + final idealPhotoW = + bodyH * _idPhotoAspectRatio + 16; // marge approx. cadre clair + final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth) + .clamp(0.0, double.infinity); + var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 360.0); + if (photoW > maxPhotoW) photoW = maxPhotoW; + photoW = photoW.clamp(0.0, maxRowW - _photoProGap); + + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: photoW, + child: _buildPhotoSection(u), + ), + const SizedBox(width: _photoProGap), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: constraints.maxWidth), + child: ValidationDetailSection( + title: 'Informations professionnelles', + fields: _proFields(d), + rowLayout: const [ + 2, + 1, + 2, + 1 + ], // N° Agrément|Date agrément, NIR, Capacité|Places, Ville + ), + ), + ); + }, + ), + ), + ], + ); + }, + ); + case 2: + final presentation = + (d.presentation != null && d.presentation!.trim().isNotEmpty) + ? d.presentation! + : '–'; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Présentation', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87), + ), + const SizedBox(height: 12), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: + BoxConstraints(minHeight: constraints.maxHeight), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.grey.shade300), + ), + child: SelectableText( + presentation, + style: const TextStyle( + color: Colors.black87, fontSize: 14), + ), + ), + ), + ); + }, + ), + ), + ], + ); + default: + return const SizedBox(); + } + } + + Widget _buildNavigation() { + if (_step == 2) { + return Row( + children: [ + TextButton(onPressed: widget.onClose, child: const Text('Annuler')), + const Spacer(), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: () { + setState(() => _step = 1); + _emitStep(); + }, + child: const Text('Précédent'), + ), + const SizedBox(width: 8), + if (_isEnAttente) ...[ + OutlinedButton( + onPressed: _submitting ? null : _refuser, + child: const Text('Refuser')), + const SizedBox(width: 12), + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: _submitting ? null : _onValiderPressed, + child: Text(_submitting ? 'Envoi...' : 'Valider'), + ), + ] else + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: widget.onClose, + child: const Text('Fermer'), + ), + ], + ), + ], + ); + } + return Row( + children: [ + TextButton(onPressed: widget.onClose, child: const Text('Annuler')), + const Spacer(), + if (_step > 0) ...[ + TextButton( + onPressed: () { + setState(() => _step--); + _emitStep(); + }, + child: const Text('Précédent'), + ), + const SizedBox(width: 8), + ], + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: () { + setState(() => _step++); + _emitStep(); + }, + child: const Text('Suivant'), + ), + ], + ); + } + + Future _onValiderPressed() async { + if (_submitting) return; + final ok = await showValidationValiderConfirmDialog( + context, + body: + 'Voulez-vous valider le dossier de cette assistante maternelle ? Cette action confirme le compte.', + ); + if (!mounted || !ok) return; + await _valider(); + } + + Future _valider() async { + if (_submitting) return; + setState(() => _submitting = true); + try { + await UserService.validateUser(widget.dossier.user.id); + if (!mounted) return; + widget.onSuccess(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur'), + backgroundColor: Colors.red.shade700, + ), + ); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + void _refuser() => setState(() => _showRefusForm = true); + + Widget _buildRefusPage() { + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: ValidationRefusForm( + onCancel: widget.onClose, + onPrevious: () => setState(() => _showRefusForm = false), + onSubmit: (comment) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Refus (à brancher sur l’API refus)')), + ); + widget.onClose(); + }, + ), + ), + ], + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/validation_dossier_modal.dart b/frontend/lib/widgets/admin/validation_dossier_modal.dart new file mode 100644 index 0000000..88e2937 --- /dev/null +++ b/frontend/lib/widgets/admin/validation_dossier_modal.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/dossier_unifie.dart'; +import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/widgets/admin/validation_am_wizard.dart'; +import 'package:p_tits_pas/widgets/admin/validation_family_wizard.dart'; + +/// Modale (dialog) : charge le dossier par numéro puis affiche le wizard AM ou Famille. Ticket #107, #119. +class ValidationDossierModal extends StatefulWidget { + final String numeroDossier; + final VoidCallback onClose; + final VoidCallback? onSuccess; + + const ValidationDossierModal({ + super.key, + required this.numeroDossier, + required this.onClose, + this.onSuccess, + }); + + @override + State createState() => _ValidationDossierModalState(); +} + +class _ValidationDossierModalState extends State { + bool _loading = true; + String? _error; + DossierUnifie? _dossier; + int? _stepIndex; + int? _stepTotal; + + void _onStepChanged(int step, int total) { + // step = 0-based dans les wizards, affichage 1-based dans l'en-tête. + if (!mounted) return; + setState(() { + _stepIndex = step; + _stepTotal = total; + }); + } + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + _dossier = null; + _stepIndex = null; + _stepTotal = null; + }); + try { + final d = await UserService.getDossier(widget.numeroDossier); + if (!mounted) return; + setState(() { + _dossier = d; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur inconnue'; + _loading = false; + }); + } + } + + void _onSuccess() { + widget.onSuccess?.call(); + // La modale est fermée par l’appelant dans onSuccess (Navigator.pop). + } + + /// Largeur modale = 1,5 × 620. + static const double _modalWidth = 930; // 620 * 1.5 + // Hauteur uniforme (ajustée +5px pour éviter l'overflow des étapes parents sans scroll). + static const double _bodyHeight = 435; + + @override + Widget build(BuildContext context) { + final maxH = MediaQuery.of(context).size.height * 0.85; + final showStep = + _stepIndex != null && _stepTotal != null && (_stepTotal ?? 0) > 0; + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: _modalWidth, maxHeight: maxH), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(18, 18, 0, 12), + child: Text( + 'Dossier ${widget.numeroDossier}', + style: const TextStyle( + fontSize: 18, fontWeight: FontWeight.w700), + ), + ), + const Spacer(), + if (showStep) ...[ + Text( + 'Étape ${(_stepIndex ?? 0) + 1}/${_stepTotal ?? 1}', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Colors.black54, + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(width: 8), + ], + IconButton( + icon: const Icon(Icons.close), + onPressed: widget.onClose, + tooltip: 'Fermer', + ), + ], + ), + const Divider(height: 1), + SizedBox( + height: _bodyHeight, + child: _buildBody(), + ), + ], + ), + ), + ); + } + + Widget _buildBody() { + if (_loading) { + return const Padding( + padding: EdgeInsets.all(48), + child: Center(child: CircularProgressIndicator()), + ); + } + if (_error != null && _error!.isNotEmpty) { + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_error!, + style: const TextStyle(color: Colors.red), + textAlign: TextAlign.center), + const SizedBox(height: 16), + ElevatedButton(onPressed: _load, child: const Text('Réessayer')), + const SizedBox(height: 8), + TextButton(onPressed: widget.onClose, child: const Text('Fermer')), + ], + ), + ); + } + final d = _dossier!; + if (d.isAm) { + return ValidationAmWizard( + dossier: d.asAm, + onClose: widget.onClose, + onSuccess: _onSuccess, + onStepChanged: _onStepChanged, + ); + } + return ValidationFamilyWizard( + dossier: d.asFamily, + onClose: widget.onClose, + onSuccess: _onSuccess, + onStepChanged: _onStepChanged, + ); + } +} diff --git a/frontend/lib/widgets/admin/validation_family_wizard.dart b/frontend/lib/widgets/admin/validation_family_wizard.dart new file mode 100644 index 0000000..de89bd9 --- /dev/null +++ b/frontend/lib/widgets/admin/validation_family_wizard.dart @@ -0,0 +1,730 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:intl/intl.dart'; +import 'package:p_tits_pas/models/dossier_unifie.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; +import 'validation_modal_theme.dart'; +import 'validation_refus_form.dart'; +import 'validation_valider_confirm_dialog.dart'; + +/// Wizard de validation dossier famille : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107. +class ValidationFamilyWizard extends StatefulWidget { + final DossierFamille dossier; + final VoidCallback onClose; + final VoidCallback onSuccess; + final void Function(int step, int total)? onStepChanged; + + const ValidationFamilyWizard({ + super.key, + required this.dossier, + required this.onClose, + required this.onSuccess, + this.onStepChanged, + }); + + @override + State createState() => _ValidationFamilyWizardState(); +} + +class _ValidationFamilyWizardState extends State { + int _step = 0; + bool _showRefusForm = false; + bool _submitting = false; + final ScrollController _enfantsScrollController = ScrollController(); + + /// Même logique que [ParentRegisterStep3Screen] : masque alpha sur les bords (ShaderMask dstIn). + bool _enfantsIsScrollable = false; + bool _enfantsFadeLeft = false; + bool _enfantsFadeRight = false; + + /// Fraction de la largeur du viewport pour le fondu (identique inscription étape 3). + static const double _enfantsFadeExtent = 0.05; + + int get _stepCount => 4; + + @override + void initState() { + super.initState(); + _enfantsScrollController.addListener(_syncEnfantsScrollFades); + WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep()); + } + + @override + void dispose() { + _enfantsScrollController.removeListener(_syncEnfantsScrollFades); + _enfantsScrollController.dispose(); + super.dispose(); + } + + void _emitStep() => widget.onStepChanged?.call(_step, _stepCount); + + void _syncEnfantsScrollFades() { + if (!mounted) return; + if (!_enfantsScrollController.hasClients) { + if (_enfantsFadeLeft || _enfantsFadeRight || _enfantsIsScrollable) { + setState(() { + _enfantsIsScrollable = false; + _enfantsFadeLeft = false; + _enfantsFadeRight = false; + }); + } + return; + } + final p = _enfantsScrollController.position; + final scrollable = p.maxScrollExtent > 0; + final left = scrollable && + p.pixels > (p.viewportDimension * _enfantsFadeExtent / 2); + final right = scrollable && + p.pixels < + (p.maxScrollExtent - + (p.viewportDimension * _enfantsFadeExtent / 2)); + if (scrollable != _enfantsIsScrollable || + left != _enfantsFadeLeft || + right != _enfantsFadeRight) { + setState(() { + _enfantsIsScrollable = scrollable; + _enfantsFadeLeft = left; + _enfantsFadeRight = right; + }); + } + } + + bool get _isEnAttente => widget.dossier.isEnAttente; + + String? get _firstParentId => widget.dossier.parents.isNotEmpty + ? widget.dossier.parents.first.id + : null; + + static String _v(String? s) => + (s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini'; + + /// Date de naissance en jour/mois/année (dd/MM/yyyy). + static String _formatBirthDate(String? s) { + if (s == null || s.trim().isEmpty) return 'Non défini'; + try { + final d = DateTime.parse(s.trim()); + return DateFormat('dd/MM/yyyy').format(d); + } catch (_) { + return s.trim(); + } + } + + /// Même ordre et disposition que le formulaire de création (Nom/Prénom, Tél/Email, Adresse, CP/Ville). + List _parentFields(ParentDossier p) => [ + AdminDetailField(label: 'Nom', value: _v(p.nom)), + AdminDetailField(label: 'Prénom', value: _v(p.prenom)), + AdminDetailField( + label: 'Téléphone', + value: _v(p.telephone) != 'Non défini' + ? formatPhoneForDisplay(_v(p.telephone)) + : 'Non défini'), + AdminDetailField(label: 'Email', value: _v(p.email)), + AdminDetailField(label: 'Adresse (N° et Rue)', value: _v(p.adresse)), + AdminDetailField(label: 'Code postal', value: _v(p.codePostal)), + AdminDetailField(label: 'Ville', value: _v(p.ville)), + ]; + + static const List _parentRowLayout = [2, 2, 1, 2]; + static const Map> _parentRowFlex = { + 3: [2, 5] + }; // Code postal étroit, Ville large + + static String _fullPhotoUrl(String? url) { + if (url == null || url.trim().isEmpty) return ''; + final u = url.trim(); + if (u.startsWith('http://') || u.startsWith('https://')) return u; + final base = ApiConfig.baseUrl; + final origin = base.replaceAll(RegExp(r'/api/v1.*'), ''); + return u.startsWith('/') ? '$origin$u' : '$origin/$u'; + } + + @override + Widget build(BuildContext context) { + if (_showRefusForm) { + return _buildRefusPage(); + } + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 4), + Expanded(child: _buildStepContent()), + const SizedBox(height: 24), + _buildNavigation(), + ], + ), + ); + } + + Widget _buildStepContent() { + final d = widget.dossier; + switch (_step) { + case 0: + return ValidationDetailSection( + title: 'Parent principal', + fields: _parentFields(d.parents.first), + rowLayout: _parentRowLayout, + rowFlex: _parentRowFlex, + ); + case 1: + return _buildParent2Step(); + case 2: + return _buildEnfantsStep(); + case 3: + return _buildPresentationStep(); + default: + return const SizedBox(); + } + } + + Widget _buildParent2Step() { + if (widget.dossier.parents.length < 2) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Text('Un seul parent pour ce dossier.', + style: TextStyle(color: Colors.black87)), + ); + } + return ValidationDetailSection( + title: 'Deuxième parent', + fields: _parentFields(widget.dossier.parents[1]), + rowLayout: _parentRowLayout, + rowFlex: _parentRowFlex, + ); + } + + static const double _idPhotoAspectRatio = 35 / 45; + + Widget _buildEnfantsStep() { + final enfants = widget.dossier.enfants; + if (enfants.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Text('Aucun enfant renseigné.', + style: TextStyle(color: Colors.black87)), + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Enfants', + style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87), + ), + const SizedBox(height: 16), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final cardHeight = constraints.maxHeight; + // Carte large : 1/3 photo + 2/3 champs (scroll horizontal si plusieurs enfants). + final cardWidth = (cardHeight * 1.72).clamp(500.0, 700.0); + return NotificationListener( + onNotification: (_) { + _syncEnfantsScrollFades(); + return false; + }, + child: ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (Rect bounds) { + final stops = [ + 0.0, + _enfantsFadeExtent, + 1.0 - _enfantsFadeExtent, + 1.0, + ]; + if (!_enfantsIsScrollable) { + return LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: const [ + Colors.black, + Colors.black, + Colors.black, + Colors.black, + ], + stops: stops, + ).createShader(bounds); + } + final leftMask = + _enfantsFadeLeft ? Colors.transparent : Colors.black; + final rightMask = + _enfantsFadeRight ? Colors.transparent : Colors.black; + return LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + leftMask, + Colors.black, + Colors.black, + rightMask, + ], + stops: stops, + ).createShader(bounds); + }, + child: Listener( + onPointerSignal: (event) { + if (event is PointerScrollEvent && + _enfantsScrollController.hasClients) { + final offset = _enfantsScrollController.offset + + event.scrollDelta.dy; + _enfantsScrollController.jumpTo(offset.clamp( + _enfantsScrollController.position.minScrollExtent, + _enfantsScrollController.position.maxScrollExtent, + )); + } + }, + child: ListView.builder( + controller: _enfantsScrollController, + scrollDirection: Axis.horizontal, + itemCount: enfants.length, + itemBuilder: (_, i) => Padding( + padding: EdgeInsets.only( + right: i < enfants.length - 1 ? 16 : 0), + child: SizedBox( + width: cardWidth, + height: cardHeight, + child: _buildEnfantCard(enfants[i]), + ), + ), + ), + ), + ), + ); + }, + ), + ), + ], + ); + } + + /// Fond carte enfant : teintes très pastel ; bordure discrète ; accent léger (barre). + static const Color _enfantCardBoyBg = Color(0xFFF0F7FB); + static const Color _enfantCardBoyBorder = Color(0xFFE3EDF4); + static const Color _enfantCardGirlBg = Color(0xFFFCF5F8); + static const Color _enfantCardGirlBorder = Color(0xFFEAE3E7); + + static const double _enfantCardRadius = 12; + + static List _enfantCardShadows() => [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.06), + blurRadius: 14, + offset: const Offset(0, 4), + ), + ]; + + static BoxDecoration _enfantCardDecoration(String? gender) { + final g = (gender ?? '').trim().toUpperCase(); + if (g == 'H') { + return BoxDecoration( + color: _enfantCardBoyBg, + borderRadius: BorderRadius.circular(_enfantCardRadius), + border: Border.all(color: _enfantCardBoyBorder, width: 1), + boxShadow: _enfantCardShadows(), + ); + } + if (g == 'F') { + return BoxDecoration( + color: _enfantCardGirlBg, + borderRadius: BorderRadius.circular(_enfantCardRadius), + border: Border.all(color: _enfantCardGirlBorder, width: 1), + boxShadow: _enfantCardShadows(), + ); + } + return BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(_enfantCardRadius), + border: Border.all(color: Colors.grey.shade300), + boxShadow: _enfantCardShadows(), + ); + } + + /// Carte enfant : prénom pleine largeur, puis ligne photo 1/3 + colonne 2/3 (champs + statut hors TF si besoin). + Widget _buildEnfantCard(EnfantDossier e) { + final photoUrl = _fullPhotoUrl(e.photoUrl); + final columnStatusLabel = _enfantColumnStatusLabel(e); + return ClipRRect( + borderRadius: BorderRadius.circular(_enfantCardRadius), + child: Container( + decoration: _enfantCardDecoration(e.gender), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), + child: _enfantLabeledField('Prénom', _v(e.firstName)), + ), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 1, + child: LayoutBuilder( + builder: (context, c) { + // Même marge gauche que le bloc « Prénom » (12) ; droite / haut / bas 8. + const padL = 12.0; + const padR = 8.0; + const padV = 8.0; + final maxW = + (c.maxWidth - padL - padR).clamp(0.0, double.infinity); + final maxH = + (c.maxHeight - 2 * padV).clamp(0.0, double.infinity); + const ar = _idPhotoAspectRatio; + double ph = maxH; + double pw = ph * ar; + if (pw > maxW) { + pw = maxW; + ph = pw / ar; + } + return Padding( + padding: const EdgeInsets.fromLTRB(padL, padV, padR, padV), + child: Align( + alignment: Alignment.centerLeft, + child: _buildEnfantPhotoSlot(photoUrl, pw, ph), + ), + ); + }, + ), + ), + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsets.fromLTRB(4, 4, 14, 12), + child: columnStatusLabel == null + ? SingleChildScrollView( + child: _buildEnfantInfoFields(e), + ) + : CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: _buildEnfantInfoFields(e), + ), + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Text( + columnStatusLabel, + textAlign: TextAlign.center, + style: GoogleFonts.merienda( + fontSize: 14, + fontStyle: FontStyle.italic, + fontWeight: FontWeight.w600, + color: Colors.grey.shade800, + ), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + /// « Scolarisé » / « Scolarisée » selon le genre enfant (`F` / sinon masculin par défaut). + static String _scolariseAccordeAuGenre(String? gender) { + final g = (gender ?? '').trim().toUpperCase(); + if (g == 'F') return 'Scolarisée'; + return 'Scolarisé'; + } + + /// Statut dans la colonne 2/3 uniquement (pas de [ValidationReadOnlyField]) : scolarisé·e ou « À naître ». + /// `actif` : pas de ligne statut. + String? _enfantColumnStatusLabel(EnfantDossier e) { + final s = (e.status ?? '').trim().toLowerCase(); + if (s == 'a_naitre') return 'À naître'; + if (s == 'scolarise') return _scolariseAccordeAuGenre(e.gender); + return null; + } + + /// Nom ; date de naissance et genre sur une ligne (prénom au-dessus, pleine largeur). + Widget _buildEnfantInfoFields(EnfantDossier e) { + final isANaitre = (e.status ?? '').trim().toLowerCase() == 'a_naitre'; + final dueDateRenseignee = e.dueDate != null && e.dueDate!.trim().isNotEmpty; + final dateValue = isANaitre + ? (dueDateRenseignee ? '${_formatBirthDate(e.dueDate)} (P)' : '– (P)') + : _formatBirthDate(e.birthDate); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _enfantLabeledField('Nom', _formatNom(e.lastName)), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: _enfantLabeledField('Date de naissance', dateValue), + ), + const SizedBox(width: 16), + Expanded( + flex: 2, + child: _enfantLabeledField( + 'Genre', + _genreEnfantLabel(e.gender, e.status), + ), + ), + ], + ), + ], + ); + } + + Widget _enfantLabeledField(String label, String value) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Colors.grey.shade700, + ), + ), + const SizedBox(height: 4), + ValidationReadOnlyField(value: value), + ], + ); + } + + Widget _buildEnfantPhotoSlot(String photoUrl, double width, double height) { + return Container( + width: width, + height: height, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.black.withValues(alpha: 0.08)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: photoUrl.isEmpty + ? ColoredBox( + color: Colors.grey.shade100, + child: Center( + child: Icon(Icons.person_outline, size: 32, color: Colors.grey.shade400), + ), + ) + : Image.network( + photoUrl, + fit: BoxFit.contain, + width: width, + height: height, + errorBuilder: (_, __, ___) => ColoredBox( + color: Colors.grey.shade100, + child: Center( + child: Icon(Icons.broken_image_outlined, size: 32, color: Colors.grey.shade400), + ), + ), + ), + ); + } + + static String _formatNom(String? lastName) { + final n = (lastName ?? '').trim().toUpperCase(); + return n.isEmpty ? 'Non défini' : n; + } + + /// Genre enfant : Garçon, Fille, ou "Non connu" (uniquement si l'enfant est à naître). + static String _genreEnfantLabel(String? gender, String? status) { + final g = (gender ?? '').trim().toUpperCase(); + final isANaitre = (status ?? '').trim().toLowerCase() == 'a_naitre'; + if (g == 'H') return 'Garçon'; + if (g == 'F') return 'Fille'; + if (isANaitre) return 'Non connu'; + if (g.isEmpty) return 'Non défini'; + return (gender ?? '').trim(); + } + + Widget _buildPresentationStep() { + final p = widget.dossier.presentation ?? ''; + final text = p.trim().isEmpty ? 'Non défini' : p; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Présentation / Motivation', + style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87), + ), + const SizedBox(height: 12), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.grey.shade300), + ), + child: SelectableText( + text, + style: + const TextStyle(color: Colors.black87, fontSize: 14), + ), + ), + ), + ); + }, + ), + ), + ], + ); + } + + Widget _buildNavigation() { + if (_step == 3) { + return Row( + children: [ + TextButton(onPressed: widget.onClose, child: const Text('Annuler')), + const Spacer(), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: () { + setState(() => _step = 2); + _emitStep(); + }, + child: const Text('Précédent'), + ), + const SizedBox(width: 8), + if (_isEnAttente && _firstParentId != null) ...[ + OutlinedButton( + onPressed: _submitting ? null : _refuser, + child: const Text('Refuser')), + const SizedBox(width: 12), + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: _submitting ? null : _onValiderPressed, + child: Text(_submitting ? 'Envoi...' : 'Valider'), + ), + ] else if (!_isEnAttente) + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: widget.onClose, + child: const Text('Fermer'), + ), + ], + ), + ], + ); + } + return Row( + children: [ + TextButton(onPressed: widget.onClose, child: const Text('Annuler')), + const Spacer(), + if (_step > 0) ...[ + TextButton( + onPressed: () { + setState(() => _step--); + _emitStep(); + }, + child: const Text('Précédent'), + ), + const SizedBox(width: 8), + ], + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: () { + setState(() => _step++); + _emitStep(); + }, + child: const Text('Suivant'), + ), + ], + ); + } + + Future _onValiderPressed() async { + if (_submitting || _firstParentId == null) return; + final ok = await showValidationValiderConfirmDialog( + context, + body: + 'Voulez-vous valider ce dossier famille ? Les comptes parents concernés seront confirmés.', + ); + if (!mounted || !ok) return; + await _valider(); + } + + Future _valider() async { + if (_submitting || _firstParentId == null) return; + setState(() => _submitting = true); + try { + await UserService.validerDossierFamille(_firstParentId!); + if (!mounted) return; + widget.onSuccess(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur'), + backgroundColor: Colors.red.shade700, + ), + ); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + void _refuser() => setState(() => _showRefusForm = true); + + Widget _buildRefusPage() { + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: ValidationRefusForm( + onCancel: widget.onClose, + onPrevious: () => setState(() => _showRefusForm = false), + onSubmit: (comment) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Refus (à brancher sur l’API refus – ticket #110)')), + ); + widget.onClose(); + }, + ), + ), + ], + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/validation_modal_theme.dart b/frontend/lib/widgets/admin/validation_modal_theme.dart new file mode 100644 index 0000000..aaeba43 --- /dev/null +++ b/frontend/lib/widgets/admin/validation_modal_theme.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +/// Couleurs / styles communs aux modales de validation (cohérent avec le violet / lavande admin). +abstract final class ValidationModalTheme { + /// Violet pastel foncé (proche des cartes admin, ex. `0xFF6D4EA1`). + static const Color primaryActionBackground = Color(0xFF6D4EA1); + static const Color primaryActionForeground = Colors.white; + + static ButtonStyle get primaryElevatedStyle { + return ElevatedButton.styleFrom( + backgroundColor: primaryActionBackground, + foregroundColor: primaryActionForeground, + disabledBackgroundColor: primaryActionBackground.withOpacity(0.45), + disabledForegroundColor: primaryActionForeground.withOpacity(0.7), + ); + } +} + diff --git a/frontend/lib/widgets/admin/validation_refus_form.dart b/frontend/lib/widgets/admin/validation_refus_form.dart new file mode 100644 index 0000000..e2d6285 --- /dev/null +++ b/frontend/lib/widgets/admin/validation_refus_form.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'validation_modal_theme.dart'; + +/// Page « Motifs du refus » : champ libre + Annuler (ferme la modale), Précédent (retour au choix Valider/Refuser), Envoyer. Ticket #107. +class ValidationRefusForm extends StatefulWidget { + /// Ferme la modale (abandon du flux). + final VoidCallback onCancel; + /// Retour à l’étape précédente du wizard (écran avec Valider / Refuser). + final VoidCallback onPrevious; + final ValueChanged onSubmit; + + const ValidationRefusForm({ + super.key, + required this.onCancel, + required this.onPrevious, + required this.onSubmit, + }); + + @override + State createState() => _ValidationRefusFormState(); +} + +class _ValidationRefusFormState extends State { + final _controller = TextEditingController(); + final _formKey = GlobalKey(); + + static const int _minLength = 20; + + String? _validateMotifs(String? value) { + final t = value?.trim() ?? ''; + if (t.isEmpty) return 'Les motifs du refus sont obligatoires.'; + if (t.length < _minLength) { + return 'Veuillez indiquer au moins $_minLength caractères (${t.length}/$_minLength).'; + } + return null; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Indiquez les motifs du refus', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + return Container( + constraints: BoxConstraints.tight(Size(constraints.maxWidth, constraints.maxHeight)), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.grey.shade400), + ), + child: TextFormField( + controller: _controller, + maxLines: null, + minLines: 1, + validator: _validateMotifs, + decoration: InputDecoration( + hintText: 'Saisissez les raisons du refus (minimum $_minLength caractères)', + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + alignLabelWithHint: true, + filled: false, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + ), + ), + ); + }, + ), + ), + const SizedBox(height: 24), + Row( + children: [ + TextButton( + onPressed: widget.onCancel, + child: const Text('Annuler'), + ), + const Spacer(), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: widget.onPrevious, + child: const Text('Précédent'), + ), + const SizedBox(width: 8), + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: () { + if (_formKey.currentState!.validate()) { + widget.onSubmit(_controller.text.trim()); + } + }, + child: const Text('Envoyer'), + ), + ], + ), + ], + ), + ], + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/validation_valider_confirm_dialog.dart b/frontend/lib/widgets/admin/validation_valider_confirm_dialog.dart new file mode 100644 index 0000000..56b7867 --- /dev/null +++ b/frontend/lib/widgets/admin/validation_valider_confirm_dialog.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'validation_modal_theme.dart'; + +/// Affiche une confirmation avant d’appeler l’API de validation du dossier. +/// Retourne `true` si l’utilisateur confirme. +Future showValidationValiderConfirmDialog( + BuildContext context, { + required String body, +}) async { + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return AlertDialog( + title: const Text('Confirmer la validation'), + content: Text(body), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Annuler'), + ), + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Confirmer'), + ), + ], + ); + }, + ); + return result == true; +} diff --git a/frontend/lib/widgets/dashboard/dashboard_bandeau.dart b/frontend/lib/widgets/dashboard/dashboard_bandeau.dart index 6ac6560..1075288 100644 --- a/frontend/lib/widgets/dashboard/dashboard_bandeau.dart +++ b/frontend/lib/widgets/dashboard/dashboard_bandeau.dart @@ -15,8 +15,8 @@ class DashboardTabItem { /// Icône associée au rôle utilisateur (alignée sur le panneau admin). IconData _iconForRole(String? role) { - if (role == null || role.isEmpty) return Icons.person_outline; - final r = role.toLowerCase(); + final r = (role ?? '').trim().toLowerCase(); + if (r.isEmpty) return Icons.person_outline; if (r == 'super_admin') return Icons.verified_user_outlined; if (r == 'admin' || r == 'administrateur') return Icons.manage_accounts_outlined; if (r == 'gestionnaire') return Icons.assignment_ind_outlined; diff --git a/frontend/lib/widgets/nir_text_field.dart b/frontend/lib/widgets/nir_text_field.dart index e1beca1..7222811 100644 --- a/frontend/lib/widgets/nir_text_field.dart +++ b/frontend/lib/widgets/nir_text_field.dart @@ -4,7 +4,7 @@ import '../utils/nir_utils.dart'; import 'custom_app_text_field.dart'; /// Champ de saisie dédié au NIR (Numéro d'Inscription au Répertoire – 15 caractères). -/// Format affiché : 1 12 34 56 789 012-34 ou 1 12 34 2A 789 012-34 pour la Corse. +/// Format affiché : 1 12 34 56 789 012 - 34 ou 1 12 34 2A 789 012 - 34 pour la Corse. /// La valeur envoyée au [controller] est formatée ; utiliser [normalizeNir](controller.text) à la soumission. class NirTextField extends StatelessWidget { final TextEditingController controller; @@ -23,7 +23,7 @@ class NirTextField extends StatelessWidget { super.key, required this.controller, this.labelText = 'N° Sécurité Sociale (NIR)', - this.hintText = '15 car. (ex. 1 12 34 56 789 012-34 ou 2A Corse)', + this.hintText = '15 car. (ex. 1 12 34 56 789 012 - 34 ou 2A Corse)', this.validator, this.fieldWidth = double.infinity, this.fieldHeight = 53.0, diff --git a/frontend/lib/widgets/personal_info_form_screen.dart b/frontend/lib/widgets/personal_info_form_screen.dart index 3ae6454..7e19729 100644 --- a/frontend/lib/widgets/personal_info_form_screen.dart +++ b/frontend/lib/widgets/personal_info_form_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:go_router/go_router.dart'; import 'dart:math' as math; @@ -92,7 +94,7 @@ class _PersonalInfoFormScreenState extends State { super.initState(); _lastNameController = TextEditingController(text: widget.initialData.lastName); _firstNameController = TextEditingController(text: widget.initialData.firstName); - _phoneController = TextEditingController(text: widget.initialData.phone); + _phoneController = TextEditingController(text: formatPhoneForDisplay(widget.initialData.phone)); _emailController = TextEditingController(text: widget.initialData.email); _addressController = TextEditingController(text: widget.initialData.address); _postalCodeController = TextEditingController(text: widget.initialData.postalCode); @@ -134,7 +136,7 @@ class _PersonalInfoFormScreenState extends State { final data = PersonalInfoData( firstName: _firstNameController.text, lastName: _lastNameController.text, - phone: _phoneController.text, + phone: normalizePhone(_phoneController.text), email: _emailController.text, address: _addressController.text, postalCode: _postalCodeController.text, @@ -631,6 +633,7 @@ class _PersonalInfoFormScreenState extends State { hint: 'Votre numéro de téléphone', keyboardType: TextInputType.phone, enabled: _fieldsEnabled, + inputFormatters: _phoneInputFormatters, ), ), const SizedBox(width: 20), @@ -732,7 +735,9 @@ class _PersonalInfoFormScreenState extends State { child: _buildDisplayFieldValue( context, 'Téléphone :', - _phoneController.text, + _phoneController.text.trim().isNotEmpty + ? formatPhoneForDisplay(_phoneController.text) + : _phoneController.text, labelFontSize: labelFontSize, valueFontSize: valueFontSize, ), @@ -868,6 +873,7 @@ class _PersonalInfoFormScreenState extends State { hint: 'Votre numéro de téléphone', keyboardType: TextInputType.phone, enabled: _fieldsEnabled, + inputFormatters: _phoneInputFormatters, ), const SizedBox(height: 12), @@ -923,13 +929,17 @@ class _PersonalInfoFormScreenState extends State { String? hint, TextInputType? keyboardType, bool enabled = true, + List? inputFormatters, }) { if (config.isReadonly) { - // Mode readonly : utiliser FormFieldWrapper + // Mode readonly : utiliser FormFieldWrapper (téléphone formaté pour affichage) + final displayValue = label == 'Téléphone' && controller.text.trim().isNotEmpty + ? formatPhoneForDisplay(controller.text) + : controller.text; return FormFieldWrapper( config: config, label: label, - value: controller.text, + value: displayValue, ); } else { // Mode éditable : style adapté mobile/desktop @@ -944,10 +954,17 @@ class _PersonalInfoFormScreenState extends State { inputFontSize: config.isMobile ? 14.0 : 20.0, keyboardType: keyboardType ?? TextInputType.text, enabled: enabled, + inputFormatters: inputFormatters, ); } } + static final _phoneInputFormatters = [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(10), + FrenchPhoneNumberFormatter(), + ]; + /// Retourne l'asset de carte vertical correspondant à la couleur String _getVerticalCardAsset() { switch (widget.cardColor) { diff --git a/frontend/lib/widgets/professional_info_form_screen.dart b/frontend/lib/widgets/professional_info_form_screen.dart index 1923b96..b50273b 100644 --- a/frontend/lib/widgets/professional_info_form_screen.dart +++ b/frontend/lib/widgets/professional_info_form_screen.dart @@ -529,7 +529,7 @@ class _ProfessionalInfoFormScreenState extends State ); } - /// NIR formaté pour affichage (1 12 34 56 789 012-34 ou 2A pour la Corse). + /// NIR formaté pour affichage (1 12 34 56 789 012 - 34 ou 2A pour la Corse). String _formatNirForDisplay(String value) { final raw = nirToRaw(value); return raw.length == 15 ? formatNir(raw) : value; From fdd1e06e77702477624aee19dc425127c92a98cd Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Sat, 11 Apr 2026 18:07:24 +0200 Subject: [PATCH 10/13] =?UTF-8?q?[#101]=20[Frontend]=20Inscription=20paren?= =?UTF-8?q?t=20=E2=80=94=20API,=20soumission=20et=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squash merge de develop vers master. Livrables principaux (ticket #101 et mise au point associée) : - Branchement du formulaire d'inscription parent sur POST /api/v1/auth/register/parent - Payload DTO (parents, enfants, photos base64, CGU) et services Auth - Parcours gestionnaire : cartes dossiers, wizard validation famille, images authentifiées - Scripts d'inscription test (Martin, Durand/Rousseau, Lecomte) ; .gitignore .cursor/ Inclut également les ajustements develop fusionnés dans ce lot (inscription AM, champs relais, etc.). Closes #101 Made-with: Cursor --- .gitignore | 1 + backend/.env.example | 8 + backend/src/main.ts | 73 ++- backend/src/modules/mail/mail.service.ts | 38 ++ backend/src/routes/auth/auth.module.ts | 2 + backend/src/routes/auth/auth.service.ts | 98 +++- .../dto/dossier-famille-complet.dto.ts | 12 + backend/src/routes/parents/parents.service.ts | 35 +- database/BDD.sql | 2 +- docker-compose.yml | 11 + docs/23_LISTE-TICKETS.md | 13 +- frontend/assets/images/photo_frame.png | Bin 0 -> 160958 bytes frontend/lib/models/dossier_unifie.dart | 35 +- .../lib/models/user_registration_data.dart | 37 +- .../creation/admin_create.dart | 47 +- .../creation/gestionnaires_create.dart | 47 +- .../auth/am_register_step1_screen.dart | 32 +- .../auth/am_register_step2_screen.dart | 33 +- .../auth/am_register_step3_screen.dart | 13 +- frontend/lib/screens/auth/login_screen.dart | 239 ++++++--- .../auth/parent_register_step1_screen.dart | 35 +- .../auth/parent_register_step2_screen.dart | 21 +- .../auth/parent_register_step3_screen.dart | 189 ++++--- .../auth/parent_register_step4_screen.dart | 14 +- .../auth/parent_register_step5_screen.dart | 73 ++- frontend/lib/services/api/api_config.dart | 45 +- frontend/lib/services/auth_service.dart | 131 ++++- frontend/lib/services/user_service.dart | 23 +- frontend/lib/utils/data_generator.dart | 70 --- frontend/lib/utils/email_utils.dart | 57 ++ frontend/lib/utils/name_format_utils.dart | 32 ++ .../utils/parent_registration_payload.dart | 284 ++++++++++ frontend/lib/utils/phone_utils.dart | 124 ++++- frontend/lib/utils/postal_utils.dart | 19 + .../lib/widgets/admin/parametres_panel.dart | 16 +- .../admin/relais_management_panel.dart | 9 +- .../widgets/admin/validation_am_wizard.dart | 16 +- .../admin/validation_family_wizard.dart | 27 +- frontend/lib/widgets/app_custom_checkbox.dart | 87 ++-- frontend/lib/widgets/child_card_widget.dart | 490 +++++++++++++----- .../widgets/common/auth_network_image.dart | 130 +++++ .../lib/widgets/custom_app_text_field.dart | 117 +++-- frontend/lib/widgets/email_text_field.dart | 219 ++++++++ frontend/lib/widgets/french_phone_field.dart | 127 +++++ frontend/lib/widgets/hover_relief_widget.dart | 7 +- .../widgets/personal_info_form_screen.dart | 489 +++++++++++++---- .../register-parent-durand-rousseau-test.mjs | 156 ++++++ scripts/register-parent-lecomte-test.mjs | 102 ++++ scripts/register-parent-martin-test.mjs | 126 +++++ 49 files changed, 3181 insertions(+), 830 deletions(-) create mode 100644 frontend/assets/images/photo_frame.png delete mode 100644 frontend/lib/utils/data_generator.dart create mode 100644 frontend/lib/utils/email_utils.dart create mode 100644 frontend/lib/utils/name_format_utils.dart create mode 100644 frontend/lib/utils/parent_registration_payload.dart create mode 100644 frontend/lib/utils/postal_utils.dart create mode 100644 frontend/lib/widgets/common/auth_network_image.dart create mode 100644 frontend/lib/widgets/email_text_field.dart create mode 100644 frontend/lib/widgets/french_phone_field.dart create mode 100644 scripts/register-parent-durand-rousseau-test.mjs create mode 100644 scripts/register-parent-lecomte-test.mjs create mode 100644 scripts/register-parent-martin-test.mjs diff --git a/.gitignore b/.gitignore index 175b613..fb0f402 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ dist/ .env.*.local # IDE +.cursor/ .idea/ .vscode/ *.swp diff --git a/backend/.env.example b/backend/.env.example index 67081bd..cfd6a65 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -22,5 +22,13 @@ JWT_EXPIRATION_TIME=7d # Environnement NODE_ENV=development +# Photos inscription (fichiers écrits depuis base64). Préférer un chemin ABSOLU. +# Local : laisser vide → ./uploads/photos (relatif au cwd du processus). +# Docker (docker-compose) : UPLOAD_PHOTOS_DIR=/app/uploads/photos + volume nommé (voir docker-compose.yml). +# UPLOAD_PHOTOS_DIR= +# +# Reverse proxy : si Nginx devant l’API, augmenter la taille du corps (ex. client_max_body_size 16m;). +# Traefik en reverse proxy simple ne limite en général pas le corps ; si middleware buffering, prévoir ~16 Mo+. + # Log de chaque appel API (mode debug) — mettre à true pour tracer les requêtes front # LOG_API_REQUESTS=true diff --git a/backend/src/main.ts b/backend/src/main.ts index 6c62287..c5754bf 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,26 +1,87 @@ import { NestFactory } from '@nestjs/core'; +import { NestExpressApplication } from '@nestjs/platform-express'; import { AppModule } from './app.module'; import { ConfigService } from '@nestjs/config'; import { SwaggerModule } from '@nestjs/swagger/dist/swagger-module'; import { DocumentBuilder } from '@nestjs/swagger'; import { ValidationPipe } from '@nestjs/common'; import { LogRequestInterceptor } from './common/interceptors/log-request.interceptor'; +import * as path from 'path'; +import * as express from 'express'; + +/** GET/HEAD photos : CORS + CORP pour Flutter web (cross-origin `Image.network`). Pas de cookie sur ces URLs. */ +function setStaticImageCorsHeaders(res: express.Response): void { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); +} + +const staticImageServeOptions = { + index: false, + fallthrough: true, + setHeaders: (res: express.Response) => setStaticImageCorsHeaders(res), +}; + +/** Préflight si le navigateur interroge OPTIONS sur les médias. */ +function uploadsCorsPreflight( + req: express.Request, + res: express.Response, + next: express.NextFunction, +): void { + if (req.method === 'OPTIONS') { + setStaticImageCorsHeaders(res); + res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS'); + res.setHeader('Access-Control-Max-Age', '86400'); + res.status(204).end(); + return; + } + next(); +} + +/** Répertoire disque contenant le dossier `photos` (ex. /app/uploads si photos dans /app/uploads/photos). */ +function resolveUploadsFilesystemRoot(): string { + const raw = process.env.UPLOAD_PHOTOS_DIR?.trim(); + const photosDir = raw + ? path.isAbsolute(raw) + ? raw + : path.resolve(process.cwd(), raw) + : path.join(process.cwd(), 'uploads', 'photos'); + return path.dirname(photosDir); +} async function bootstrap() { - const app = await NestFactory.create(AppModule, - { logger: ['error', 'warn', 'log', 'debug', 'verbose'] }); + const app = await NestFactory.create(AppModule, { + logger: ['error', 'warn', 'log', 'debug', 'verbose'], + }); - // Log de chaque appel API si LOG_API_REQUESTS=true (mode debug) - app.useGlobalInterceptors(new LogRequestInterceptor()); + // Inscription (photos base64 dans le JSON) : sans limite > défaut Express (~100 ko) → 413/500 ou corps tronqué. + app.useBodyParser('json', { limit: '15mb' }); + app.useBodyParser('urlencoded', { extended: true, limit: '15mb' }); - // Configuration CORS pour autoriser les requêtes depuis localhost (dev) et production + // CORS global **avant** les fichiers statiques pour que les réponses API et idéalement la chaîne Express restent cohérentes. app.enableCors({ - origin: true, // Autorise toutes les origines (dev) - à restreindre en prod + origin: true, // Reflète l’Origin (dev / prod) — routes API + cookies JWT methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'Accept'], credentials: true, }); + const expressApp = app.getHttpAdapter().getInstance(); + const uploadsRoot = resolveUploadsFilesystemRoot(); + + // Photos : chemins stockés en base type /uploads/photos/... + // En-têtes explicites sur les réponses fichier (les statics peuvent répondre sans repasser par la même couche que les contrôleurs). + expressApp.use('/uploads', uploadsCorsPreflight); + expressApp.use('/api/v1/uploads', uploadsCorsPreflight); + + app.useStaticAssets(uploadsRoot, { + prefix: '/uploads/', + ...staticImageServeOptions, + }); + expressApp.use('/api/v1/uploads', express.static(uploadsRoot, staticImageServeOptions)); + + // Log de chaque appel API si LOG_API_REQUESTS=true (mode debug) + app.useGlobalInterceptors(new LogRequestInterceptor()); + app.useGlobalPipes( new ValidationPipe({ whitelist: true, diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index 6a1e872..df5eec9 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -98,6 +98,44 @@ export class MailService { await this.sendEmail(to, subject, html); } + /** + * Accusé de réception inscription parent : demande enregistrée + n° de dossier. + * L’utilisateur est en attente de validation ; pas de lien création MDP à ce stade. + */ + async sendParentRegistrationPendingEmail( + to: string, + prenom: string, + nom: string, + numeroDossier: string, + ): Promise { + const appName = this.configService.get('app_name', "P'titsPas"); + const appUrl = this.configService.get('app_url', 'https://app.ptits-pas.fr'); + + const safe = (s: string) => + (s || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + + const subject = `Votre demande d'inscription sur ${appName} — dossier ${safe(numeroDossier)}`; + const html = ` +
+

Bonjour ${safe(prenom)} ${safe(nom)},

+

Nous avons bien enregistré votre demande de création de compte sur ${safe(appName)}.

+

Numéro de dossier : ${safe(numeroDossier)}

+

Votre dossier est en attente de validation par notre équipe. Vous recevrez un email lorsqu’il aura été traité.

+ +
+

Cet email a été envoyé automatiquement. Merci de ne pas y répondre.

+
+ `; + + await this.sendEmail(to, subject, html); + } + /** * Email de refus de dossier avec lien reprise (token). * Ticket #110 – Refus sans suppression diff --git a/backend/src/routes/auth/auth.module.ts b/backend/src/routes/auth/auth.module.ts index 2a4513a..34d4631 100644 --- a/backend/src/routes/auth/auth.module.ts +++ b/backend/src/routes/auth/auth.module.ts @@ -11,12 +11,14 @@ import { Children } from 'src/entities/children.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { AppConfigModule } from 'src/modules/config'; import { NumeroDossierModule } from 'src/modules/numero-dossier/numero-dossier.module'; +import { MailModule } from 'src/modules/mail/mail.module'; @Module({ imports: [ TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]), forwardRef(() => UserModule), AppConfigModule, + MailModule, NumeroDossierModule, JwtModule.registerAsync({ imports: [ConfigModule], diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index 5d7d943..2665721 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -1,12 +1,13 @@ import { ConflictException, Injectable, + Logger, NotFoundException, UnauthorizedException, BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { QueryFailedError, Repository } from 'typeorm'; import { UserService } from '../user/user.service'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; @@ -22,20 +23,26 @@ import { Parents } from 'src/entities/parents.entity'; import { Children, StatutEnfantType } from 'src/entities/children.entity'; import { ParentsChildren } from 'src/entities/parents_children.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; +import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity'; +import { StatutDossierType } from 'src/entities/dossiers.entity'; import { LoginDto } from './dto/login.dto'; import { RepriseDossierDto } from './dto/reprise-dossier.dto'; import { RepriseIdentifyResponseDto } from './dto/reprise-identify.dto'; import { AppConfigService } from 'src/modules/config/config.service'; import { validateNir } from 'src/common/utils/nir.util'; import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service'; +import { MailService } from 'src/modules/mail/mail.service'; @Injectable() export class AuthService { + private readonly logger = new Logger(AuthService.name); + constructor( private readonly usersService: UserService, private readonly jwtService: JwtService, private readonly configService: ConfigService, private readonly appConfigService: AppConfigService, + private readonly mailService: MailService, private readonly numeroDossierService: NumeroDossierService, @InjectRepository(Parents) private readonly parentsRepo: Repository, @@ -211,7 +218,16 @@ export class AuthService { const dateExpiration = new Date(); dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken); - const resultat = await this.usersRepo.manager.transaction(async (manager) => { + let resultat: { + parent1: Users; + parent2: Users | null; + enfants: Children[]; + tokenCreationMdp: string; + tokenCoParent: string | null; + }; + + try { + resultat = await this.usersRepo.manager.transaction(async (manager) => { const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager); const parent1 = manager.create(Users, { @@ -321,6 +337,25 @@ export class AuthService { } } + // Dossier famille : motivation (texte_motivation côté GET) + liaisons enfants (ticket #119) + const presentationTrim = dto.presentation_dossier?.trim(); + const dossierFamilleEnt = manager.create(DossierFamille, { + numero_dossier: numeroDossier, + presentation: presentationTrim || undefined, + statut: StatutDossierType.ENVOYE, + parent: entiteParent, + }); + const dossierFamilleSaved = await manager.save(DossierFamille, dossierFamilleEnt); + for (const enfantEnregistre of enfantsEnregistres) { + await manager.save( + DossierFamilleEnfant, + manager.create(DossierFamilleEnfant, { + id_dossier_famille: dossierFamilleSaved.id, + id_enfant: enfantEnregistre.id, + }), + ); + } + return { parent1: parent1Enregistre, parent2: parent2Enregistre, @@ -329,6 +364,38 @@ export class AuthService { tokenCoParent, }; }); + } catch (err) { + if (this.isPostgresUniqueViolation(err)) { + throw new ConflictException( + 'Un compte avec cet email existe déjà (contrainte unique en base).', + ); + } + throw err; + } + + const numeroDossier = resultat.parent1.numero_dossier ?? ''; + + try { + await this.mailService.sendParentRegistrationPendingEmail( + resultat.parent1.email, + resultat.parent1.prenom ?? '', + resultat.parent1.nom ?? '', + numeroDossier, + ); + if (resultat.parent2) { + await this.mailService.sendParentRegistrationPendingEmail( + resultat.parent2.email, + resultat.parent2.prenom ?? '', + resultat.parent2.nom ?? '', + numeroDossier, + ); + } + } catch (err) { + this.logger.error( + "[inscrireParentComplet] Échec envoi email d'accusé de réception (inscription conservée)", + err instanceof Error ? err.stack : String(err), + ); + } return { message: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.', @@ -336,6 +403,7 @@ export class AuthService { co_parent_id: resultat.parent2?.id, enfants_ids: resultat.enfants.map(e => e.id), statut: StatutUtilisateurType.EN_ATTENTE, + numero_dossier: numeroDossier, }; } @@ -404,7 +472,9 @@ export class AuthService { const dateConsentementPhoto = dto.consentement_photo ? new Date() : undefined; - const resultat = await this.usersRepo.manager.transaction(async (manager) => { + let resultat: { user: Users }; + try { + resultat = await this.usersRepo.manager.transaction(async (manager) => { const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager); const user = manager.create(Users, { @@ -443,6 +513,12 @@ export class AuthService { return { user: userEnregistre }; }); + } catch (err) { + if (this.isPostgresUniqueViolation(err)) { + throw new ConflictException('Un compte avec cet email existe déjà (contrainte unique en base).'); + } + throw err; + } return { message: @@ -464,7 +540,12 @@ export class AuthService { const extension = correspondances[1]; const tamponImage = Buffer.from(correspondances[2], 'base64'); - const dossierUpload = '/app/uploads/photos'; + const rawDir = process.env.UPLOAD_PHOTOS_DIR?.trim(); + const dossierUpload = rawDir + ? path.isAbsolute(rawDir) + ? rawDir + : path.resolve(process.cwd(), rawDir) + : path.join(process.cwd(), 'uploads', 'photos'); await fs.mkdir(dossierUpload, { recursive: true }); const nomFichierUnique = `${Date.now()}-${crypto.randomUUID()}.${extension}`; @@ -575,4 +656,13 @@ export class AuthService { token: user.token_reprise, }; } + + /** Violation unique PostgreSQL (ex. email déjà présent malgré course entre requêtes). */ + private isPostgresUniqueViolation(err: unknown): boolean { + if (!(err instanceof QueryFailedError)) { + return false; + } + const code = (err.driverError as { code?: string } | undefined)?.code; + return code === '23505'; + } } diff --git a/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts b/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts index 29437cf..d051b0d 100644 --- a/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts +++ b/backend/src/routes/parents/dto/dossier-famille-complet.dto.ts @@ -42,6 +42,18 @@ export class DossierFamilleEnfantDto { due_date?: Date; @ApiProperty({ enum: StatutEnfantType }) status: StatutEnfantType; + + @ApiProperty({ + required: false, + description: 'Chemin ou URL de la photo (souvent relatif, ex. /uploads/photos/...)', + }) + photo_url?: string; + + @ApiProperty({ + required: false, + description: 'Consentement affichage photo (colonne consentement_photo)', + }) + consent_photo?: boolean; } /** Réponse GET /parents/dossier-famille/:numeroDossier – dossier famille complet. Ticket #119 */ diff --git a/backend/src/routes/parents/parents.service.ts b/backend/src/routes/parents/parents.service.ts index f4d6f53..ae9bedd 100644 --- a/backend/src/routes/parents/parents.service.ts +++ b/backend/src/routes/parents/parents.service.ts @@ -17,6 +17,7 @@ import { DossierFamilleParentDto, DossierFamilleEnfantDto, } from './dto/dossier-famille-complet.dto'; +import { Children } from 'src/entities/children.entity'; @Injectable() export class ParentsService { @@ -209,6 +210,20 @@ export class ParentsService { } /** Convertit parentIds (array ou chaîne PG) en string[] pour éviter 500 si le driver renvoie une chaîne. */ + private childToDossierFamilleEnfantDto(child: Children): DossierFamilleEnfantDto { + return { + id: child.id, + first_name: child.first_name, + last_name: child.last_name, + genre: child.gender, + birth_date: child.birth_date, + due_date: child.due_date, + status: child.status, + photo_url: child.photo_url ?? undefined, + consent_photo: child.consent_photo, + }; + } + private normalizeParentIds(parentIds: unknown): string[] { if (Array.isArray(parentIds)) return parentIds.map(String); if (typeof parentIds === 'string') { @@ -257,15 +272,7 @@ export class ParentsService { if (p.parentChildren) { for (const pc of p.parentChildren) { if (pc.child && !enfantsMap.has(pc.child.id)) { - enfantsMap.set(pc.child.id, { - id: pc.child.id, - first_name: pc.child.first_name, - last_name: pc.child.last_name, - genre: pc.child.gender, - birth_date: pc.child.birth_date, - due_date: pc.child.due_date, - status: pc.child.status, - }); + enfantsMap.set(pc.child.id, this.childToDossierFamilleEnfantDto(pc.child)); } } } @@ -275,6 +282,16 @@ export class ParentsService { } } + // Enfants uniquement liés via dossier_famille_enfants (legacy / parcours alternatif) + if (dossierFamille?.enfants?.length) { + for (const dfe of dossierFamille.enfants) { + const c = dfe.enfant; + if (c && !enfantsMap.has(c.id)) { + enfantsMap.set(c.id, this.childToDossierFamilleEnfantDto(c)); + } + } + } + const parentsDto: DossierFamilleParentDto[] = parents.map((p) => ({ user_id: p.user_id, email: p.user.email, diff --git a/database/BDD.sql b/database/BDD.sql index 7a7ab17..670336f 100644 --- a/database/BDD.sql +++ b/database/BDD.sql @@ -8,7 +8,7 @@ DO $$ BEGIN CREATE TYPE role_type AS ENUM ('parent', 'gestionnaire', 'super_admin', 'administrateur', 'assistante_maternelle'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'genre_type') THEN - CREATE TYPE genre_type AS ENUM ('H', 'F'); + CREATE TYPE genre_type AS ENUM ('H', 'F', 'Autre'); END IF; IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_utilisateur_type') THEN CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente','actif','suspendu','refuse'); diff --git a/docker-compose.yml b/docker-compose.yml index a4fad4d..dcfa74b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,6 +57,10 @@ services: NODE_ENV: ${NODE_ENV} LOG_API_REQUESTS: ${LOG_API_REQUESTS:-false} CONFIG_ENCRYPTION_KEY: ${CONFIG_ENCRYPTION_KEY} + # Photos inscription (base64) — chemin absolu dans le conteneur (voir volume backend_uploads) + UPLOAD_PHOTOS_DIR: ${UPLOAD_PHOTOS_DIR:-/app/uploads/photos} + volumes: + - backend_uploads:/app/uploads/photos depends_on: - database labels: @@ -66,6 +70,12 @@ services: - "traefik.http.routers.ptitspas-api.tls.certresolver=leresolver" - "traefik.http.routers.ptitspas-api.priority=20" - "traefik.http.services.ptitspas-api.loadbalancer.server.port=3000" + # Fichiers statiques photos (URL relatives /uploads/...) — priorité > front (10) + - "traefik.http.routers.ptitspas-uploads.rule=Host(\"app.ptits-pas.fr\") && PathPrefix(\"/uploads\")" + - "traefik.http.routers.ptitspas-uploads.entrypoints=websecure" + - "traefik.http.routers.ptitspas-uploads.tls.certresolver=leresolver" + - "traefik.http.routers.ptitspas-uploads.priority=18" + - "traefik.http.routers.ptitspas-uploads.service=ptitspas-api" networks: - ptitspas_network - proxy_network @@ -94,6 +104,7 @@ services: volumes: postgres_data: + backend_uploads: networks: ptitspas_network: diff --git a/docs/23_LISTE-TICKETS.md b/docs/23_LISTE-TICKETS.md index b9eb010..9f12ad3 100644 --- a/docs/23_LISTE-TICKETS.md +++ b/docs/23_LISTE-TICKETS.md @@ -1,7 +1,7 @@ # 🎫 Liste Complète des Tickets - Projet P'titsPas -**Version** : 1.6 -**Date** : 25 Février 2026 +**Version** : 1.7 +**Date** : 25 Mars 2026 **Auteur** : Équipe PtitsPas **Estimation totale** : ~208h @@ -9,7 +9,7 @@ ## 🔗 Liste des tickets Gitea -**Les numéros de section dans ce document = numéros d’issues Gitea.** Ticket #14 dans le doc = issue Gitea #14, etc. Source : dépôt `jmartin/petitspas` (état au 25 février 2026). +**Les numéros de section dans ce document = numéros d’issues Gitea.** Ticket #14 dans le doc = issue Gitea #14, etc. Source : dépôt `jmartin/petitspas` (état au 25 mars 2026 ; #106, #107, #109, #119 fermés via API). | Gitea # | Titre (dépôt) | Statut | |--------|----------------|--------| @@ -88,10 +88,10 @@ | 103 | Numéro de dossier – backend | Ouvert | | 104 | Numéro de dossier – frontend | Ouvert | | 105 | Statut « refusé » | Ouvert | -| 106 | Liste familles en attente | Ouvert | -| 107 | Onglet « À valider » + listes | Ouvert | +| 106 | Liste familles en attente | ✅ Fermé | +| 107 | Onglet « À valider » + listes | ✅ Fermé | | 108 | Validation dossier famille | Ouvert | -| 109 | Modale de validation | Ouvert | +| 109 | Modale de validation | ✅ Fermé | | 110 | Refus sans suppression | Ouvert | | 111 | Reprise après refus – backend | Ouvert | | 112 | Reprise après refus – frontend | Ouvert | @@ -100,6 +100,7 @@ | 115 | Rattachement parent – backend | Ouvert | | 116 | Rattachement parent – frontend | Ouvert | | 117 | Évolution du cahier des charges | Ouvert | +| 119 | Endpoint unifié GET /dossiers/:numeroDossier (AM ou famille) | ✅ Fermé | *Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues* diff --git a/frontend/assets/images/photo_frame.png b/frontend/assets/images/photo_frame.png new file mode 100644 index 0000000000000000000000000000000000000000..9d9beebb515ac3de08a79f6acd688c16c9a2756a GIT binary patch literal 160958 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

1u0hi30nXY-`IH85a{VJLDj(U{Fdz9rtZ?!f&h^ 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 json) { return ParentDossier( - id: json['id']?.toString() ?? '', + id: json['user_id']?.toString() ?? json['id']?.toString() ?? '', email: json['email']?.toString() ?? '', prenom: json['prenom']?.toString(), nom: json['nom']?.toString(), @@ -194,7 +200,27 @@ class EnfantDossier { String get fullName => '${firstName ?? ''} ${lastName ?? ''}'.trim(); + static String? _optionalPhotoUrl(dynamic v) { + if (v == null) return null; + if (v is String) { + final s = v.trim(); + return s.isEmpty ? null : s; + } + final s = v.toString().trim(); + return s.isEmpty ? null : s; + } + factory EnfantDossier.fromJson(Map json) { + final rawPhoto = json['photo_url'] ?? json['photoUrl']; + final resolvedPhoto = _optionalPhotoUrl(rawPhoto); + if (kDebugMode) { + debugPrint( + '[PetitsPas/dossier] EnfantDossier.fromJson id=${json['id']} ' + 'prénom=${json['first_name'] ?? json['prenom']} | ' + 'photo_url brute=$rawPhoto (${rawPhoto?.runtimeType}) → ' + 'résolu=${resolvedPhoto ?? "∅"}', + ); + } return EnfantDossier( id: json['id']?.toString() ?? '', firstName: (json['first_name'] ?? json['prenom'])?.toString(), @@ -203,8 +229,9 @@ class EnfantDossier { gender: (json['gender'] ?? json['genre'])?.toString(), status: json['status']?.toString(), dueDate: json['due_date']?.toString(), - photoUrl: json['photo_url']?.toString(), - consentPhoto: json['consent_photo'] == true, + photoUrl: resolvedPhoto, + consentPhoto: + json['consent_photo'] == true || json['consentPhoto'] == true, ); } } diff --git a/frontend/lib/models/user_registration_data.dart b/frontend/lib/models/user_registration_data.dart index 9687898..5e8892f 100644 --- a/frontend/lib/models/user_registration_data.dart +++ b/frontend/lib/models/user_registration_data.dart @@ -1,7 +1,6 @@ import 'dart:io'; // Pour File import '../models/card_assets.dart'; // Import de l'enum CardColorVertical import 'package:flutter/foundation.dart'; -import 'package:intl/intl.dart'; // import 'package:p_tits_pas/models/child.dart'; // Commenté car fichier non trouvé class ParentData { @@ -29,25 +28,61 @@ class ParentData { } class ChildData { + static const Object _unsetImage = Object(); + static const Object _unsetImageBytes = Object(); + String firstName; String lastName; String dob; // Date de naissance ou prévisionnelle + /// Valeurs API : `H`, `F`, `Autre` (GenreType backend). Vide tant que non choisi. + String genre; bool photoConsent; bool multipleBirth; bool isUnbornChild; File? imageFile; + /// Octets de la photo (fiable à l’envoi API ; [imageFile] peut être absent sur le web). + Uint8List? imageBytes; CardColorVertical cardColor; // Nouveau champ pour la couleur de la carte ChildData({ this.firstName = '', this.lastName = '', this.dob = '', + this.genre = '', this.photoConsent = false, this.multipleBirth = false, this.isUnbornChild = false, this.imageFile, + this.imageBytes, required this.cardColor, // Rendre requis dans le constructeur }); + + ChildData copyWith({ + String? firstName, + String? lastName, + String? dob, + String? genre, + bool? photoConsent, + bool? multipleBirth, + bool? isUnbornChild, + Object? imageFile = _unsetImage, + Object? imageBytes = _unsetImageBytes, + CardColorVertical? cardColor, + }) { + return ChildData( + firstName: firstName ?? this.firstName, + lastName: lastName ?? this.lastName, + dob: dob ?? this.dob, + genre: genre ?? this.genre, + photoConsent: photoConsent ?? this.photoConsent, + multipleBirth: multipleBirth ?? this.multipleBirth, + isUnbornChild: isUnbornChild ?? this.isUnbornChild, + imageFile: identical(imageFile, _unsetImage) ? this.imageFile : imageFile as File?, + imageBytes: + identical(imageBytes, _unsetImageBytes) ? this.imageBytes : imageBytes as Uint8List?, + cardColor: cardColor ?? this.cardColor, + ); + } } // Nouvelle classe pour les détails bancaires diff --git a/frontend/lib/screens/administrateurs/creation/admin_create.dart b/frontend/lib/screens/administrateurs/creation/admin_create.dart index ae1477c..1db28b2 100644 --- a/frontend/lib/screens/administrateurs/creation/admin_create.dart +++ b/frontend/lib/screens/administrateurs/creation/admin_create.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/utils/email_utils.dart'; +import 'package:p_tits_pas/widgets/email_text_field.dart'; +import 'package:p_tits_pas/widgets/french_phone_field.dart'; class AdminCreateDialog extends StatefulWidget { final AppUser? initialUser; @@ -61,11 +63,10 @@ class _AdminCreateDialogState extends State { String? _validateEmail(String? value) { final base = _required(value, 'Email'); - if (base != null) return base; - final email = value!.trim(); - final ok = RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(email); - if (!ok) return 'Format email invalide'; - return null; + if (base != null) { + return base; + } + return validateEmail(value, allowEmpty: true); } String? _validatePassword(String? value) { @@ -78,6 +79,17 @@ class _AdminCreateDialogState extends State { return null; } + String? _validateTelephone(String? value) { + if (_isEditMode && (value == null || value.trim().isEmpty)) { + return null; + } + final base = _required(value, 'Téléphone'); + if (base != null) { + return base; + } + return validateFrenchNationalPhone(value, allowEmpty: false); + } + Future _submit() async { if (_isSubmitting) return; if (!_formKey.currentState!.validate()) return; @@ -305,13 +317,9 @@ class _AdminCreateDialogState extends State { } Widget _buildEmailField() { - return TextFormField( + return EmailTextFormField( controller: _emailController, - keyboardType: TextInputType.emailAddress, - decoration: const InputDecoration( - labelText: 'Email', - border: OutlineInputBorder(), - ), + label: 'Email', validator: _validateEmail, ); } @@ -346,19 +354,10 @@ class _AdminCreateDialogState extends State { } Widget _buildTelephoneField() { - return TextFormField( + return FrenchPhoneTextFormField( controller: _telephoneController, - keyboardType: TextInputType.phone, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(10), - FrenchPhoneNumberFormatter(), - ], - decoration: const InputDecoration( - labelText: 'Téléphone (ex: 06 12 34 56 78)', - border: OutlineInputBorder(), - ), - validator: (v) => _required(v, 'Téléphone'), + label: 'Téléphone (ex: 06 12 34 56 78)', + validator: _validateTelephone, ); } } diff --git a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart index 8dbed7e..a1b5f92 100644 --- a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart +++ b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:p_tits_pas/models/relais_model.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/utils/email_utils.dart'; +import 'package:p_tits_pas/widgets/email_text_field.dart'; +import 'package:p_tits_pas/widgets/french_phone_field.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/relais_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; @@ -163,11 +165,10 @@ class _AdminUserFormDialogState extends State { String? _validateEmail(String? value) { final base = _required(value, 'Email'); - if (base != null) return base; - final email = value!.trim(); - final ok = RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(email); - if (!ok) return 'Format email invalide'; - return null; + if (base != null) { + return base; + } + return validateEmail(value, allowEmpty: true); } String? _validatePassword(String? value) { @@ -185,15 +186,10 @@ class _AdminUserFormDialogState extends State { return null; } final base = _required(value, 'Téléphone'); - if (base != null) return base; - final digits = normalizePhone(value!); - if (digits.length != 10) { - return 'Le téléphone doit contenir 10 chiffres'; + if (base != null) { + return base; } - if (!digits.startsWith('0')) { - return 'Le téléphone doit commencer par 0'; - } - return null; + return validateFrenchNationalPhone(value, allowEmpty: false); } String _toTitleCase(String raw) { @@ -536,14 +532,10 @@ class _AdminUserFormDialogState extends State { } Widget _buildEmailField() { - return TextFormField( + return EmailTextFormField( controller: _emailController, readOnly: widget.readOnly, - keyboardType: TextInputType.emailAddress, - decoration: const InputDecoration( - labelText: 'Email', - border: OutlineInputBorder(), - ), + label: 'Email', validator: widget.readOnly ? null : _validateEmail, ); } @@ -584,21 +576,10 @@ class _AdminUserFormDialogState extends State { } Widget _buildTelephoneField() { - return TextFormField( + return FrenchPhoneTextFormField( controller: _telephoneController, readOnly: widget.readOnly, - keyboardType: TextInputType.phone, - inputFormatters: widget.readOnly - ? null - : [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(10), - FrenchPhoneNumberFormatter(), - ], - decoration: const InputDecoration( - labelText: 'Téléphone (ex: 06 12 34 56 78)', - border: OutlineInputBorder(), - ), + label: 'Téléphone (ex: 06 12 34 56 78)', validator: widget.readOnly ? null : _validatePhone, ); } diff --git a/frontend/lib/screens/auth/am_register_step1_screen.dart b/frontend/lib/screens/auth/am_register_step1_screen.dart index 14a4db1..86df609 100644 --- a/frontend/lib/screens/auth/am_register_step1_screen.dart +++ b/frontend/lib/screens/auth/am_register_step1_screen.dart @@ -13,29 +13,15 @@ class AmRegisterStep1Screen extends StatelessWidget { Widget build(BuildContext context) { final registrationData = Provider.of(context, listen: false); - // Données de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data) - PersonalInfoData initialData; - if (registrationData.firstName.isEmpty) { - initialData = PersonalInfoData( - firstName: 'Marie', - lastName: 'DUBOIS', - phone: '0696345678', - email: 'marie.dubois@ptits-pas.fr', - address: '25 Rue de la République', - postalCode: '95870', - city: 'Bezons', - ); - } else { - initialData = PersonalInfoData( - firstName: registrationData.firstName, - lastName: registrationData.lastName, - phone: registrationData.phone, - email: registrationData.email, - address: registrationData.streetAddress, - postalCode: registrationData.postalCode, - city: registrationData.city, - ); - } + final initialData = PersonalInfoData( + firstName: registrationData.firstName, + lastName: registrationData.lastName, + phone: registrationData.phone, + email: registrationData.email, + address: registrationData.streetAddress, + postalCode: registrationData.postalCode, + city: registrationData.city, + ); return PersonalInfoFormScreen( stepText: 'Étape 1/4', diff --git a/frontend/lib/screens/auth/am_register_step2_screen.dart b/frontend/lib/screens/auth/am_register_step2_screen.dart index bf354d4..362a317 100644 --- a/frontend/lib/screens/auth/am_register_step2_screen.dart +++ b/frontend/lib/screens/auth/am_register_step2_screen.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:go_router/go_router.dart'; -import 'package:intl/intl.dart'; -import 'dart:io'; import '../../models/am_registration_data.dart'; import '../../models/card_assets.dart'; @@ -17,24 +15,9 @@ class AmRegisterStep2Screen extends StatefulWidget { class _AmRegisterStep2ScreenState extends State { String? _photoPathFramework; - File? _photoFile; Future _pickPhoto() async { - // TODO: Remplacer par la vraie logique ImagePicker - // final imagePicker = ImagePicker(); - // final pickedFile = await imagePicker.pickImage(source: ImageSource.gallery); - // if (pickedFile != null) { - // setState(() { - // _photoFile = File(pickedFile.path); - // _photoPathFramework = pickedFile.path; - // }); - // } else { - setState(() { - _photoPathFramework = 'assets/images/icon_assmat.png'; - _photoFile = null; - }); - // } - print("Photo sélectionnée: $_photoPathFramework"); + // TODO: brancher ImagePicker ; ne pas préremplir de chemin factice } @override @@ -53,20 +36,6 @@ class _AmRegisterStep2ScreenState extends State { capacity: registrationData.capacity, ); - // Données de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data) - if (registrationData.dateOfBirth == null && registrationData.nir.isEmpty) { - initialData = ProfessionalInfoData( - photoPath: 'assets/images/icon_assmat.png', - photoConsent: true, - dateOfBirth: DateTime(1980, 6, 8), - birthCity: 'Bezons', - birthCountry: 'France', - nir: '280062A00100191', - agrementNumber: 'AGR-2019-095001', - capacity: 4, - ); - } - return ProfessionalInfoFormScreen( stepText: 'Étape 2/4', title: 'Vos informations professionnelles', diff --git a/frontend/lib/screens/auth/am_register_step3_screen.dart b/frontend/lib/screens/auth/am_register_step3_screen.dart index 7bda43f..3d77845 100644 --- a/frontend/lib/screens/auth/am_register_step3_screen.dart +++ b/frontend/lib/screens/auth/am_register_step3_screen.dart @@ -13,22 +13,13 @@ class AmRegisterStep3Screen extends StatelessWidget { Widget build(BuildContext context) { final data = Provider.of(context, listen: false); - // Données de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data) - String initialText = data.presentationText; - bool initialCgu = data.cguAccepted; - - if (initialText.isEmpty) { - initialText = 'Assistante maternelle agréée depuis 2019. Spécialité bébés 0-18 mois. Accueil bienveillant et cadre sécurisant. 2 places disponibles.'; - initialCgu = true; - } - return PresentationFormScreen( stepText: 'Étape 3/4', title: 'Présentation et Conditions', cardColor: CardColorHorizontal.peach, textFieldHint: 'Ex: Disponible immédiatement, 10 ans d\'expérience, formation premiers secours...', - initialText: initialText, - initialCguAccepted: initialCgu, + initialText: data.presentationText, + initialCguAccepted: data.cguAccepted, previousRoute: '/am-register-step2', onSubmit: (text, cguAccepted) { data.updatePresentationAndCgu( diff --git a/frontend/lib/screens/auth/login_screen.dart b/frontend/lib/screens/auth/login_screen.dart index 2814b72..16ee6d9 100644 --- a/frontend/lib/screens/auth/login_screen.dart +++ b/frontend/lib/screens/auth/login_screen.dart @@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:go_router/go_router.dart'; import 'package:p_tits_pas/services/bug_report_service.dart'; +import 'package:p_tits_pas/utils/email_utils.dart'; import '../../widgets/image_button.dart'; import '../../widgets/custom_app_text_field.dart'; import '../../services/auth_service.dart'; @@ -21,6 +22,10 @@ class _LoginPageState extends State { final _formKey = GlobalKey(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); + final GlobalKey> _emailFormKey = + GlobalKey>(); + late final FocusNode _emailFocus; + late final FocusNode _passwordFocus; bool _isLoading = false; String? _errorMessage; @@ -36,22 +41,49 @@ class _LoginPageState extends State { void initState() { super.initState(); _desktopRiverLogoDimensionsFuture = _getImageDimensions(); + _emailFocus = FocusNode(); + _passwordFocus = FocusNode(); + _emailFocus.addListener(_onEmailFocusChange); + } + + void _onEmailFocusChange() { + if (_emailFocus.hasFocus) { + return; + } + // Reporter au frame suivant : une mise à jour synchrone du controller pendant + // un transfert de focus (Tab) peut casser la navigation au clavier. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _emailFocus.hasFocus) { + return; + } + final normalized = normalizeEmailText(_emailController.text); + if (normalized != _emailController.text) { + _emailController.value = TextEditingValue( + text: normalized, + selection: TextSelection.collapsed(offset: normalized.length), + ); + } + _emailFormKey.currentState?.validate(); + }); } @override void dispose() { + _emailFocus.removeListener(_onEmailFocusChange); + _emailFocus.dispose(); + _passwordFocus.dispose(); _emailController.dispose(); _passwordController.dispose(); super.dispose(); } String? _validateEmail(String? value) { - final v = value ?? ''; + final v = value?.trim() ?? ''; if (v.isEmpty) { - return 'Veuillez entrer votre email'; + return 'Veuillez entrer votre adresse e-mail.'; } - if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(v)) { - return 'Veuillez entrer un email valide'; + if (!isValidEmailFormat(v)) { + return 'L’adresse e-mail n’est pas valide.'; } return null; } @@ -205,53 +237,75 @@ class _LoginPageState extends State { height: h * 0.5, // 50% de la hauteur de l'écran child: Padding( padding: EdgeInsets.all(w * 0.02), // 2% de padding - child: AutofillGroup( + child: AutofillGroup( child: Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Champs côte à côte - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: CustomAppTextField( - controller: _emailController, - labelText: 'Email', - hintText: 'Votre adresse email', - keyboardType: TextInputType.emailAddress, - autofillHints: const [ - AutofillHints.username, - AutofillHints.email, - ], - textInputAction: TextInputAction.next, - validator: _validateEmail, - style: CustomAppTextFieldStyle.lavande, - fieldHeight: 53, - fieldWidth: double.infinity, + FocusTraversalGroup( + policy: OrderedTraversalPolicy(), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: FocusTraversalOrder( + order: const NumericFocusOrder(1), + child: CustomAppTextField( + formFieldKey: _emailFormKey, + controller: _emailController, + focusNode: _emailFocus, + labelText: 'Email', + hintText: 'Votre adresse email', + keyboardType: + TextInputType.emailAddress, + autocorrect: false, + enableSuggestions: false, + autofillHints: const [ + AutofillHints.username, + AutofillHints.email, + ], + inputFormatters: const [ + EmailMaxLengthFormatter(), + ], + textInputAction: TextInputAction.next, + onFieldSubmitted: (_) => + _passwordFocus.requestFocus(), + validator: _validateEmail, + style: + CustomAppTextFieldStyle.lavande, + fieldHeight: 53, + fieldWidth: double.infinity, + ), + ), ), - ), - const SizedBox(width: 20), - Expanded( - child: CustomAppTextField( - controller: _passwordController, - labelText: 'Mot de passe', - hintText: 'Votre mot de passe', - obscureText: true, - autofillHints: const [ - AutofillHints.password, - ], - textInputAction: TextInputAction.done, - onFieldSubmitted: - _handlePasswordSubmitted, - validator: _validatePassword, - style: CustomAppTextFieldStyle.jaune, - fieldHeight: 53, - fieldWidth: double.infinity, + const SizedBox(width: 20), + Expanded( + child: FocusTraversalOrder( + order: const NumericFocusOrder(2), + child: CustomAppTextField( + controller: _passwordController, + focusNode: _passwordFocus, + labelText: 'Mot de passe', + hintText: 'Votre mot de passe', + obscureText: true, + autofillHints: const [ + AutofillHints.password, + ], + textInputAction: TextInputAction.done, + onFieldSubmitted: + _handlePasswordSubmitted, + validator: _validatePassword, + style: + CustomAppTextFieldStyle.jaune, + fieldHeight: 53, + fieldWidth: double.infinity, + ), + ), ), - ), - ], + ], + ), ), const SizedBox(height: 20), @@ -461,46 +515,68 @@ class _LoginPageState extends State { horizontal: 24, vertical: 20), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 400), - child: AutofillGroup( + child: AutofillGroup( child: Form( key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 16), - CustomAppTextField( - controller: _emailController, - labelText: 'Email', - showLabel: false, - hintText: 'Votre adresse email', - keyboardType: TextInputType.emailAddress, - autofillHints: const [ - AutofillHints.username, - AutofillHints.email, - ], - textInputAction: TextInputAction.next, - validator: _validateEmail, - style: CustomAppTextFieldStyle.lavande, - fieldHeight: 48, - fieldWidth: double.infinity, - ), - const SizedBox(height: 12), - CustomAppTextField( - controller: _passwordController, - labelText: 'Mot de passe', - showLabel: false, - hintText: 'Votre mot de passe', - obscureText: true, - autofillHints: const [ - AutofillHints.password - ], - textInputAction: TextInputAction.done, - onFieldSubmitted: _handlePasswordSubmitted, - validator: _validatePassword, - style: CustomAppTextFieldStyle.jaune, - fieldHeight: 48, - fieldWidth: double.infinity, - ), + child: FocusTraversalGroup( + policy: OrderedTraversalPolicy(), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 16), + FocusTraversalOrder( + order: const NumericFocusOrder(1), + child: CustomAppTextField( + formFieldKey: _emailFormKey, + controller: _emailController, + focusNode: _emailFocus, + labelText: 'Email', + showLabel: false, + hintText: 'Votre adresse email', + keyboardType: + TextInputType.emailAddress, + autocorrect: false, + enableSuggestions: false, + autofillHints: const [ + AutofillHints.username, + AutofillHints.email, + ], + inputFormatters: const [ + EmailMaxLengthFormatter(), + ], + textInputAction: TextInputAction.next, + onFieldSubmitted: (_) => + _passwordFocus.requestFocus(), + validator: _validateEmail, + style: + CustomAppTextFieldStyle.lavande, + fieldHeight: 48, + fieldWidth: double.infinity, + ), + ), + const SizedBox(height: 12), + FocusTraversalOrder( + order: const NumericFocusOrder(2), + child: CustomAppTextField( + controller: _passwordController, + focusNode: _passwordFocus, + labelText: 'Mot de passe', + showLabel: false, + hintText: 'Votre mot de passe', + obscureText: true, + autofillHints: const [ + AutofillHints.password + ], + textInputAction: TextInputAction.done, + onFieldSubmitted: + _handlePasswordSubmitted, + validator: _validatePassword, + style: + CustomAppTextFieldStyle.jaune, + fieldHeight: 48, + fieldWidth: double.infinity, + ), + ), if (_errorMessage != null) ...[ const SizedBox(height: 12), Container( @@ -571,6 +647,7 @@ class _LoginPageState extends State { ), ), ), + ), Padding( padding: const EdgeInsets.only(bottom: 12, top: 8), child: Wrap( diff --git a/frontend/lib/screens/auth/parent_register_step1_screen.dart b/frontend/lib/screens/auth/parent_register_step1_screen.dart index bfabab3..becad34 100644 --- a/frontend/lib/screens/auth/parent_register_step1_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step1_screen.dart @@ -3,7 +3,6 @@ import 'package:provider/provider.dart'; import 'package:go_router/go_router.dart'; import '../../models/user_registration_data.dart'; -import '../../utils/data_generator.dart'; import '../../widgets/personal_info_form_screen.dart'; import '../../models/card_assets.dart'; @@ -15,31 +14,15 @@ class ParentRegisterStep1Screen extends StatelessWidget { final registrationData = Provider.of(context, listen: false); final parent1 = registrationData.parent1; - // Générer des données de test si vide - PersonalInfoData initialData; - if (parent1.firstName.isEmpty) { - final genFirstName = DataGenerator.firstName(); - final genLastName = DataGenerator.lastName(); - initialData = PersonalInfoData( - firstName: genFirstName, - lastName: genLastName, - phone: DataGenerator.phone(), - email: DataGenerator.email(genFirstName, genLastName), - address: DataGenerator.address(), - postalCode: DataGenerator.postalCode(), - city: DataGenerator.city(), - ); - } else { - initialData = PersonalInfoData( - firstName: parent1.firstName, - lastName: parent1.lastName, - phone: parent1.phone, - email: parent1.email, - address: parent1.address, - postalCode: parent1.postalCode, - city: parent1.city, - ); - } + final initialData = PersonalInfoData( + firstName: parent1.firstName, + lastName: parent1.lastName, + phone: parent1.phone, + email: parent1.email, + address: parent1.address, + postalCode: parent1.postalCode, + city: parent1.city, + ); return PersonalInfoFormScreen( stepText: 'Étape 1/5', diff --git a/frontend/lib/screens/auth/parent_register_step2_screen.dart b/frontend/lib/screens/auth/parent_register_step2_screen.dart index efa3277..af9b9a2 100644 --- a/frontend/lib/screens/auth/parent_register_step2_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step2_screen.dart @@ -3,7 +3,6 @@ import 'package:provider/provider.dart'; import 'package:go_router/go_router.dart'; import '../../models/user_registration_data.dart'; -import '../../utils/data_generator.dart'; import '../../widgets/personal_info_form_screen.dart'; import '../../models/card_assets.dart'; @@ -19,21 +18,17 @@ class ParentRegisterStep2Screen extends StatelessWidget { bool hasParent2 = parent2 != null; bool sameAddress = false; - // Générer des données de test si vide PersonalInfoData initialData; if (parent2 == null || parent2.firstName.isEmpty) { - final genFirstName = DataGenerator.firstName(); - final genLastName = DataGenerator.lastName(); - sameAddress = DataGenerator.boolean(); - + sameAddress = false; initialData = PersonalInfoData( - firstName: genFirstName, - lastName: genLastName, - phone: DataGenerator.phone(), - email: DataGenerator.email(genFirstName, genLastName), - address: sameAddress ? parent1.address : DataGenerator.address(), - postalCode: sameAddress ? parent1.postalCode : DataGenerator.postalCode(), - city: sameAddress ? parent1.city : DataGenerator.city(), + firstName: parent2?.firstName ?? '', + lastName: parent2?.lastName ?? '', + phone: parent2?.phone ?? '', + email: parent2?.email ?? '', + address: parent2?.address ?? '', + postalCode: parent2?.postalCode ?? '', + city: parent2?.city ?? '', ); } else { sameAddress = (parent2.address == parent1.address && diff --git a/frontend/lib/screens/auth/parent_register_step3_screen.dart b/frontend/lib/screens/auth/parent_register_step3_screen.dart index 12e472b..e13b3a9 100644 --- a/frontend/lib/screens/auth/parent_register_step3_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step3_screen.dart @@ -3,15 +3,16 @@ import 'package:google_fonts/google_fonts.dart'; import 'dart:math' as math; // Pour la rotation du chevron import 'package:image_picker/image_picker.dart'; import 'dart:io' show File; +import 'package:flutter/foundation.dart' show kIsWeb; import '../../widgets/hover_relief_widget.dart'; import '../../widgets/child_card_widget.dart'; import '../../widgets/custom_navigation_button.dart'; import '../../models/user_registration_data.dart'; -import '../../utils/data_generator.dart'; import '../../models/card_assets.dart'; import '../../config/display_config.dart'; import 'package:provider/provider.dart'; import 'package:go_router/go_router.dart'; +import 'package:p_tits_pas/utils/name_format_utils.dart'; class ParentRegisterStep3Screen extends StatefulWidget { // final UserRegistrationData registrationData; // Supprimé @@ -75,6 +76,22 @@ class _ParentRegisterStep3ScreenState extends State { super.dispose(); } + /// Même logique que nom / prénom parent : normalisation avant passage à l’étape suivante. + void _normalizeChildrenNamesAndGoToStep4( + BuildContext context, + UserRegistrationData registrationData, + ) { + for (var i = 0; i < registrationData.children.length; i++) { + final c = registrationData.children[i]; + final fn = formatPersonNameCase(c.firstName); + final ln = formatPersonNameCase(c.lastName); + if (fn != c.firstName || ln != c.lastName) { + registrationData.updateChild(i, c.copyWith(firstName: fn, lastName: ln)); + } + } + context.go('/parent-register-step4'); + } + void _scrollListener() { if (!_scrollController.hasClients) return; final position = _scrollController.position; @@ -92,8 +109,6 @@ class _ParentRegisterStep3ScreenState extends State { void _addChild(UserRegistrationData registrationData) { // Prend registrationData setState(() { - bool isUnborn = DataGenerator.boolean(); - // Trouver la première couleur non utilisée CardColorVertical cardColor = _childCardColors.firstWhere( (color) => !_usedColors.contains(color), @@ -102,11 +117,11 @@ class _ParentRegisterStep3ScreenState extends State { final newChild = ChildData( lastName: registrationData.parent1.lastName, - firstName: DataGenerator.firstName(), - dob: DataGenerator.dob(isUnborn: isUnborn), - isUnbornChild: isUnborn, - photoConsent: DataGenerator.boolean(), - multipleBirth: DataGenerator.boolean(), + firstName: '', + dob: '', + isUnbornChild: false, + photoConsent: false, + multipleBirth: false, cardColor: cardColor, ); registrationData.addChild(newChild); @@ -134,23 +149,27 @@ class _ParentRegisterStep3ScreenState extends State { final ImagePicker picker = ImagePicker(); try { final XFile? pickedFile = await picker.pickImage( - source: ImageSource.gallery, imageQuality: 70, maxWidth: 1024, maxHeight: 1024); + source: ImageSource.gallery, + imageQuality: 60, + maxWidth: 900, + maxHeight: 900, + ); if (pickedFile != null) { if (childIndex < registrationData.children.length) { final oldChild = registrationData.children[childIndex]; - final updatedChild = ChildData( - firstName: oldChild.firstName, - lastName: oldChild.lastName, - dob: oldChild.dob, - photoConsent: oldChild.photoConsent, - multipleBirth: oldChild.multipleBirth, - isUnbornChild: oldChild.isUnbornChild, - imageFile: File(pickedFile.path), - cardColor: oldChild.cardColor, - ); + final bytes = await pickedFile.readAsBytes(); + if (bytes.isEmpty) return; + File? file; + if (!kIsWeb) { + try { + final f = File(pickedFile.path); + if (await f.exists()) file = f; + } catch (_) {} + } + final updatedChild = oldChild.copyWith(imageBytes: bytes, imageFile: file); registrationData.updateChild(childIndex, updatedChild); } - } + } } catch (e) { print("Erreur image: $e"); } } @@ -188,15 +207,8 @@ class _ParentRegisterStep3ScreenState extends State { ); if (picked != null) { final oldChild = registrationData.children[childIndex]; - final updatedChild = ChildData( - firstName: oldChild.firstName, - lastName: oldChild.lastName, - dob: "${picked.day.toString().padLeft(2, '0')}/${picked.month.toString().padLeft(2, '0')}/${picked.year}", - photoConsent: oldChild.photoConsent, - multipleBirth: oldChild.multipleBirth, - isUnbornChild: oldChild.isUnbornChild, - imageFile: oldChild.imageFile, - cardColor: oldChild.cardColor, + final updatedChild = oldChild.copyWith( + dob: "${picked.day.toString().padLeft(2, '0')}/${picked.month.toString().padLeft(2, '0')}/${picked.year}", ); registrationData.updateChild(childIndex, updatedChild); } @@ -287,40 +299,42 @@ class _ParentRegisterStep3ScreenState extends State { // Générer les cartes enfants for (int index = 0; index < registrationData.children.length; index++) ...[ ChildCardWidget( - key: ValueKey(registrationData.children[index].hashCode), + key: ValueKey('parent_register_child_$index'), childData: registrationData.children[index], childIndex: index, onPickImage: () => _pickImage(index, registrationData), + onClearImage: () => setState(() { + final c = registrationData.children[index]; + registrationData.updateChild( + index, c.copyWith(imageFile: null, imageBytes: null)); + }), onDateSelect: () => _selectDate(context, index, registrationData), - onFirstNameChanged: (value) => setState(() => registrationData.updateChild(index, ChildData( - firstName: value, lastName: registrationData.children[index].lastName, dob: registrationData.children[index].dob, photoConsent: registrationData.children[index].photoConsent, - multipleBirth: registrationData.children[index].multipleBirth, isUnbornChild: registrationData.children[index].isUnbornChild, imageFile: registrationData.children[index].imageFile, cardColor: registrationData.children[index].cardColor - ))), - onLastNameChanged: (value) => setState(() => registrationData.updateChild(index, ChildData( - firstName: registrationData.children[index].firstName, lastName: value, dob: registrationData.children[index].dob, photoConsent: registrationData.children[index].photoConsent, - multipleBirth: registrationData.children[index].multipleBirth, isUnbornChild: registrationData.children[index].isUnbornChild, imageFile: registrationData.children[index].imageFile, cardColor: registrationData.children[index].cardColor - ))), + onFirstNameChanged: (value) => setState(() { + final c = registrationData.children[index]; + registrationData.updateChild(index, c.copyWith(firstName: value)); + }), + onLastNameChanged: (value) => setState(() { + final c = registrationData.children[index]; + registrationData.updateChild(index, c.copyWith(lastName: value)); + }), + onGenreChanged: (value) => setState(() { + final c = registrationData.children[index]; + registrationData.updateChild(index, c.copyWith(genre: value)); + }), onTogglePhotoConsent: (newValue) { final oldChild = registrationData.children[index]; - registrationData.updateChild(index, ChildData( - firstName: oldChild.firstName, lastName: oldChild.lastName, dob: oldChild.dob, photoConsent: newValue, - multipleBirth: oldChild.multipleBirth, isUnbornChild: oldChild.isUnbornChild, imageFile: oldChild.imageFile, cardColor: oldChild.cardColor - )); - }, - onToggleMultipleBirth: (newValue) { - final oldChild = registrationData.children[index]; - registrationData.updateChild(index, ChildData( - firstName: oldChild.firstName, lastName: oldChild.lastName, dob: oldChild.dob, photoConsent: oldChild.photoConsent, - multipleBirth: newValue, isUnbornChild: oldChild.isUnbornChild, imageFile: oldChild.imageFile, cardColor: oldChild.cardColor - )); + registrationData.updateChild(index, oldChild.copyWith(photoConsent: newValue)); }, onToggleIsUnborn: (newValue) { final oldChild = registrationData.children[index]; - registrationData.updateChild(index, ChildData( - firstName: oldChild.firstName, lastName: oldChild.lastName, dob: DataGenerator.dob(isUnborn: newValue), - photoConsent: oldChild.photoConsent, multipleBirth: oldChild.multipleBirth, isUnbornChild: newValue, - imageFile: oldChild.imageFile, cardColor: oldChild.cardColor - )); + var g = oldChild.genre; + if (!newValue && g == 'Autre') { + g = ''; + } + registrationData.updateChild( + index, + oldChild.copyWith(isUnbornChild: newValue, genre: g), + ); }, onRemove: () => _removeChild(index, registrationData), canBeRemoved: registrationData.children.length > 1, @@ -343,7 +357,7 @@ class _ParentRegisterStep3ScreenState extends State { const SizedBox(height: 30), // Boutons navigation en bas du scroll - _buildMobileButtons(context, config, screenSize), + _buildMobileButtons(context, config, screenSize, registrationData), const SizedBox(height: 20), ], ), @@ -393,41 +407,43 @@ class _ParentRegisterStep3ScreenState extends State { return Padding( padding: const EdgeInsets.only(right: 20.0), child: ChildCardWidget( - key: ValueKey(registrationData.children[index].hashCode), // Utiliser une clé basée sur les données + key: ValueKey('parent_register_child_$index'), childData: registrationData.children[index], childIndex: index, onPickImage: () => _pickImage(index, registrationData), + onClearImage: () => setState(() { + final c = registrationData.children[index]; + registrationData.updateChild( + index, c.copyWith(imageFile: null, imageBytes: null)); + }), onDateSelect: () => _selectDate(context, index, registrationData), - onFirstNameChanged: (value) => setState(() => registrationData.updateChild(index, ChildData( - firstName: value, lastName: registrationData.children[index].lastName, dob: registrationData.children[index].dob, photoConsent: registrationData.children[index].photoConsent, - multipleBirth: registrationData.children[index].multipleBirth, isUnbornChild: registrationData.children[index].isUnbornChild, imageFile: registrationData.children[index].imageFile, cardColor: registrationData.children[index].cardColor - ))), - onLastNameChanged: (value) => setState(() => registrationData.updateChild(index, ChildData( - firstName: registrationData.children[index].firstName, lastName: value, dob: registrationData.children[index].dob, photoConsent: registrationData.children[index].photoConsent, - multipleBirth: registrationData.children[index].multipleBirth, isUnbornChild: registrationData.children[index].isUnbornChild, imageFile: registrationData.children[index].imageFile, cardColor: registrationData.children[index].cardColor - ))), + onFirstNameChanged: (value) => setState(() { + final c = registrationData.children[index]; + registrationData.updateChild(index, c.copyWith(firstName: value)); + }), + onLastNameChanged: (value) => setState(() { + final c = registrationData.children[index]; + registrationData.updateChild(index, c.copyWith(lastName: value)); + }), + onGenreChanged: (value) => setState(() { + final c = registrationData.children[index]; + registrationData.updateChild(index, c.copyWith(genre: value)); + }), onTogglePhotoConsent: (newValue) { final oldChild = registrationData.children[index]; - registrationData.updateChild(index, ChildData( - firstName: oldChild.firstName, lastName: oldChild.lastName, dob: oldChild.dob, photoConsent: newValue, - multipleBirth: oldChild.multipleBirth, isUnbornChild: oldChild.isUnbornChild, imageFile: oldChild.imageFile, cardColor: oldChild.cardColor - )); - }, - onToggleMultipleBirth: (newValue) { - final oldChild = registrationData.children[index]; - registrationData.updateChild(index, ChildData( - firstName: oldChild.firstName, lastName: oldChild.lastName, dob: oldChild.dob, photoConsent: oldChild.photoConsent, - multipleBirth: newValue, isUnbornChild: oldChild.isUnbornChild, imageFile: oldChild.imageFile, cardColor: oldChild.cardColor - )); + registrationData.updateChild(index, oldChild.copyWith(photoConsent: newValue)); }, onToggleIsUnborn: (newValue) { - final oldChild = registrationData.children[index]; - registrationData.updateChild(index, ChildData( - firstName: oldChild.firstName, lastName: oldChild.lastName, dob: DataGenerator.dob(isUnborn: newValue), - photoConsent: oldChild.photoConsent, multipleBirth: oldChild.multipleBirth, isUnbornChild: newValue, - imageFile: oldChild.imageFile, cardColor: oldChild.cardColor - )); - }, + final oldChild = registrationData.children[index]; + var g = oldChild.genre; + if (!newValue && g == 'Autre') { + g = ''; + } + registrationData.updateChild( + index, + oldChild.copyWith(isUnbornChild: newValue, genre: g), + ); + }, onRemove: () => _removeChild(index, registrationData), canBeRemoved: registrationData.children.length > 1, ), @@ -455,7 +471,12 @@ class _ParentRegisterStep3ScreenState extends State { } /// Boutons navigation mobile - Widget _buildMobileButtons(BuildContext context, DisplayConfig config, Size screenSize) { + Widget _buildMobileButtons( + BuildContext context, + DisplayConfig config, + Size screenSize, + UserRegistrationData registrationData, + ) { return Row( children: [ Expanded( @@ -483,7 +504,7 @@ class _ParentRegisterStep3ScreenState extends State { text: 'Suivant', style: NavigationButtonStyle.green, onPressed: () { - context.go('/parent-register-step4'); + _normalizeChildrenNamesAndGoToStep4(context, registrationData); }, width: double.infinity, height: 50, diff --git a/frontend/lib/screens/auth/parent_register_step4_screen.dart b/frontend/lib/screens/auth/parent_register_step4_screen.dart index 10ae74d..f5a4ec8 100644 --- a/frontend/lib/screens/auth/parent_register_step4_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step4_screen.dart @@ -5,7 +5,6 @@ import 'package:go_router/go_router.dart'; import '../../models/user_registration_data.dart'; import '../../widgets/presentation_form_screen.dart'; import '../../models/card_assets.dart'; -import '../../utils/data_generator.dart'; class ParentRegisterStep4Screen extends StatelessWidget { const ParentRegisterStep4Screen({super.key}); @@ -14,22 +13,13 @@ class ParentRegisterStep4Screen extends StatelessWidget { Widget build(BuildContext context) { final registrationData = Provider.of(context, listen: false); - // Générer un texte de test si vide - String initialText = registrationData.motivationText; - bool initialCgu = registrationData.cguAccepted; - - if (initialText.isEmpty) { - initialText = DataGenerator.motivation(); - initialCgu = true; - } - return PresentationFormScreen( stepText: 'Étape 4/5', title: 'Motivation de votre demande', cardColor: CardColorHorizontal.green, textFieldHint: 'Écrivez ici pour motiver votre demande...', - initialText: initialText, - initialCguAccepted: initialCgu, + initialText: registrationData.motivationText, + initialCguAccepted: registrationData.cguAccepted, previousRoute: '/parent-register-step3', onSubmit: (text, cguAccepted) { registrationData.updateMotivation(text); diff --git a/frontend/lib/screens/auth/parent_register_step5_screen.dart b/frontend/lib/screens/auth/parent_register_step5_screen.dart index 71047e1..883f9ee 100644 --- a/frontend/lib/screens/auth/parent_register_step5_screen.dart +++ b/frontend/lib/screens/auth/parent_register_step5_screen.dart @@ -13,6 +13,7 @@ import '../../widgets/custom_navigation_button.dart'; import '../../widgets/personal_info_form_screen.dart'; import '../../widgets/child_card_widget.dart'; import '../../widgets/presentation_form_screen.dart'; +import '../../services/auth_service.dart'; class ParentRegisterStep5Screen extends StatefulWidget { const ParentRegisterStep5Screen({super.key}); @@ -22,6 +23,36 @@ class ParentRegisterStep5Screen extends StatefulWidget { } class _ParentRegisterStep5ScreenState extends State { + bool _isSubmitting = false; + + Future _submitRegistration(BuildContext context, UserRegistrationData data) async { + if (_isSubmitting) return; + setState(() => _isSubmitting = true); + try { + await AuthService.registerParent(data); + if (!context.mounted) return; + _showSuccessModal(context); + } catch (e) { + if (!context.mounted) return; + final msg = e.toString().replaceAll('Exception: ', ''); + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text('Envoi impossible', style: GoogleFonts.merienda(fontWeight: FontWeight.bold)), + content: Text(msg, style: GoogleFonts.merienda(fontSize: 14)), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text('OK', style: GoogleFonts.merienda(fontWeight: FontWeight.bold)), + ), + ], + ), + ); + } finally { + if (mounted) setState(() => _isSubmitting = false); + } + } + @override Widget build(BuildContext context) { final registrationData = Provider.of(context); @@ -102,12 +133,11 @@ class _ParentRegisterStep5ScreenState extends State { Expanded( child: HoverReliefWidget( child: CustomNavigationButton( - text: 'Soumettre', + text: _isSubmitting ? 'Envoi…' : 'Soumettre', style: NavigationButtonStyle.green, - onPressed: () { - print("Données finales: ${registrationData.parent1.firstName}, Enfant(s): ${registrationData.children.length}"); - _showConfirmationModal(context); - }, + onPressed: _isSubmitting + ? () {} + : () => _submitRegistration(context, registrationData), width: double.infinity, height: 50, fontSize: 16, @@ -118,18 +148,20 @@ class _ParentRegisterStep5ScreenState extends State { ), ) else - ImageButton( - bg: 'assets/images/bg_green.png', - text: 'Soumettre ma demande', - textColor: const Color(0xFF2D6A4F), - width: 350, - height: 50, - fontSize: 18, - onPressed: () { - print("Données finales: ${registrationData.parent1.firstName}, Enfant(s): ${registrationData.children.length}"); - _showConfirmationModal(context); - }, - ), + _isSubmitting + ? const Padding( + padding: EdgeInsets.all(16), + child: CircularProgressIndicator(), + ) + : ImageButton( + bg: 'assets/images/bg_green.png', + text: 'Soumettre ma demande', + textColor: const Color(0xFF2D6A4F), + width: 350, + height: 50, + fontSize: 18, + onPressed: () => _submitRegistration(context, registrationData), + ), ], ), ), @@ -216,11 +248,12 @@ class _ParentRegisterStep5ScreenState extends State { childIndex: index, mode: DisplayMode.readonly, onPickImage: () {}, + onClearImage: () {}, onDateSelect: () {}, onFirstNameChanged: (v) {}, onLastNameChanged: (v) {}, + onGenreChanged: (v) {}, onTogglePhotoConsent: (v) {}, - onToggleMultipleBirth: (v) {}, onToggleIsUnborn: (v) {}, onRemove: () {}, canBeRemoved: false, @@ -239,14 +272,14 @@ class _ParentRegisterStep5ScreenState extends State { cardColor: CardColorHorizontal.green, // Changé de pink à green textFieldHint: '', initialText: data.motivationText, - initialCguAccepted: true, // Toujours true ici car déjà passé + initialCguAccepted: data.cguAccepted, previousRoute: '', onSubmit: (t, c) {}, onEdit: () => context.go('/parent-register-step4'), ); } - void _showConfirmationModal(BuildContext context) { + void _showSuccessModal(BuildContext context) { showDialog( context: context, barrierDismissible: false, diff --git a/frontend/lib/services/api/api_config.dart b/frontend/lib/services/api/api_config.dart index a219a2d..9d605dd 100644 --- a/frontend/lib/services/api/api_config.dart +++ b/frontend/lib/services/api/api_config.dart @@ -1,6 +1,47 @@ +import 'package:flutter/foundation.dart'; +import 'package:p_tits_pas/config/env.dart'; + class ApiConfig { - // static const String baseUrl = 'http://localhost:3000/api/v1/'; - static const String baseUrl = 'https://app.ptits-pas.fr/api/v1'; + /// Aligné sur [Env.apiBaseUrl] (`--dart-define=API_BASE_URL=...`) pour que les images `/uploads/...` visent le même hôte que l’API. + static String get baseUrl { + final root = Env.apiBaseUrl.replaceAll(RegExp(r'/+$'), ''); + return '$root/api/v1'; + } + + /// Origine (schéma + hôte + port) dérivée de [baseUrl], pour préfixer les chemins `/uploads/...`. + static String get apiOrigin { + final uri = Uri.parse(baseUrl); + if (uri.hasScheme && uri.host.isNotEmpty) { + return '${uri.scheme}://${uri.authority}'; + } + return baseUrl.replaceAll(RegExp(r'/api/v1/?.*'), ''); + } + + /// URL absolue pour une image renvoyée par l’API (chemin type `/uploads/...`). + /// On préfixe avec [baseUrl] (…/api/v1), pas seulement l’hôte : Traefik n’expose souvent que `/api`. + static String absoluteMediaUrl(String? pathOrUrl) { + if (pathOrUrl == null || pathOrUrl.trim().isEmpty) { + if (kDebugMode) { + debugPrint('[PetitsPas/media] absoluteMediaUrl: entrée vide (null ou "")'); + } + return ''; + } + final u = pathOrUrl.trim(); + if (u.startsWith('http://') || u.startsWith('https://')) { + if (kDebugMode) { + debugPrint('[PetitsPas/media] absoluteMediaUrl: déjà absolu → $u'); + } + return u; + } + final base = baseUrl.replaceAll(RegExp(r'/+$'), ''); + final out = u.startsWith('/') ? '$base$u' : '$base/$u'; + if (kDebugMode) { + debugPrint( + '[PetitsPas/media] absoluteMediaUrl: base=$base | chemin brut="$u" → "$out"', + ); + } + return out; + } // Auth endpoints static const String login = '/auth/login'; diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart index c995b9f..ef4c32d 100644 --- a/frontend/lib/services/auth_service.dart +++ b/frontend/lib/services/auth_service.dart @@ -4,6 +4,8 @@ import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import '../models/user.dart'; import '../models/am_registration_data.dart'; +import '../models/user_registration_data.dart'; +import '../utils/parent_registration_payload.dart'; import 'api/api_config.dart'; import 'api/tokenService.dart'; import '../utils/nir_utils.dart'; @@ -183,26 +185,137 @@ class AuthService { return; } - final decoded = response.body.isNotEmpty ? jsonDecode(response.body) : null; + final decoded = _tryDecodeJsonMap(response.body); final message = _extractErrorMessage(decoded, response.statusCode); throw Exception(message); } + /// Inscription parent complète (POST /auth/register/parent). + /// Succès : 201, pas de session — rediriger vers le login. + static Future registerParent(UserRegistrationData data) async { + final validationError = ParentRegistrationPayload.validateForApi(data); + if (validationError != null) { + throw Exception(validationError); + } + + final body = ParentRegistrationPayload.toJson(data); + final String encodedBody; + try { + encodedBody = jsonEncode(body); + } catch (_) { + throw Exception( + 'Impossible de préparer l’envoi (données trop volumineuses ou invalides). ' + 'Réessayez avec une photo plus légère ou sans caractères inhabituels.', + ); + } + + late final http.Response response; + try { + response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.registerParent}'), + headers: ApiConfig.headers, + body: encodedBody, + ); + } on http.ClientException { + throw Exception( + 'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau, ' + 'un pare-feu ou un bloqueur, puis réessayez.', + ); + } + + if (response.statusCode == 200 || response.statusCode == 201) { + return; + } + + final decoded = _tryDecodeJsonMap(response.body); + var message = _extractErrorMessage(decoded, response.statusCode); + message = _humanizeParentRegistrationHttpError(message, response.statusCode); + throw Exception(message); + } + + static Map? _tryDecodeJsonMap(String body) { + if (body.isEmpty) return null; + try { + final decoded = jsonDecode(body); + if (decoded is Map) { + return Map.from(decoded); + } + return null; + } catch (_) { + return null; + } + } + + /// Aplatit `message` (string, liste, ou objet Nest / AllExceptionsFilter). + static String? _flattenApiMessageField(dynamic field) { + if (field == null) return null; + if (field is String) { + final t = field.trim(); + return t.isEmpty ? null : t; + } + if (field is List) { + final parts = field + .map(_flattenApiMessageField) + .whereType() + .where((s) => s.isNotEmpty) + .toList(); + if (parts.isEmpty) return null; + return parts.join('. '); + } + if (field is Map) { + if (field['message'] != null) { + final inner = _flattenApiMessageField(field['message']); + if (inner != null) return inner; + } + final err = field['error']; + if (err is String && err.trim().isNotEmpty) return err.trim(); + } + return null; + } + /// Extrait le message d'erreur des réponses NestJS (message string, array, ou objet). static String _extractErrorMessage(dynamic decoded, int statusCode) { const fallback = 'Erreur lors de l\'inscription'; - if (decoded == null || decoded is! Map) return '$fallback ($statusCode)'; - final msg = decoded['message']; - if (msg == null) { - final err = decoded['error']; - return (err is String ? err : err?.toString()) ?? '$fallback ($statusCode)'; + if (decoded == null || decoded is! Map) { + return '$fallback ($statusCode)'; } - if (msg is String) return msg; - if (msg is List) return msg.map((e) => e.toString()).join('. ').trim(); - if (msg is Map && msg['message'] != null) return msg['message'].toString(); + final map = decoded; + final fromMessage = _flattenApiMessageField(map['message']); + if (fromMessage != null) return fromMessage; + final err = map['error']; + if (err is String && err.trim().isNotEmpty) return err.trim(); return '$fallback ($statusCode)'; } + /// Remplace « Internal server error » (souvent contrainte SQL / 500) par un texte utile à l’inscription. + static String _humanizeParentRegistrationHttpError(String msg, int statusCode) { + if (statusCode == 409) return msg; + + final lower = msg.toLowerCase().trim(); + final looksLikeGenericServerError = lower == 'internal server error' || + lower == 'internal server error.' || + lower.contains('internal server error'); + + if (statusCode >= 500 && looksLikeGenericServerError) { + return 'Impossible d\'enregistrer votre demande pour le moment. ' + 'Souvent, cela signifie que cette adresse e-mail est déjà utilisée : ' + 'connectez-vous ou utilisez une autre adresse pour le parent principal ' + '(et pour le co-parent si vous en indiquez un). ' + 'Si le problème continue, réessayez plus tard ou contactez le support.'; + } + + if (statusCode >= 500) { + if (lower.contains('duplicate') || + lower.contains('unique constraint') || + lower.contains('23505') || + lower.contains('already exist')) { + return 'Cette adresse e-mail semble déjà enregistrée. ' + 'Essayez de vous connecter ou modifiez l\'adresse du parent ou du co-parent.'; + } + } + return msg; + } + /// Rafraîchit le profil utilisateur depuis l'API static Future refreshCurrentUser() async { final token = await TokenService.getToken(); diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index a70d011..c0ab07e 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/parent_model.dart'; @@ -92,8 +93,12 @@ class UserService { /// Dossier unifié par numéro (AM ou famille). GET /dossiers/:numeroDossier. Ticket #119, #107. static Future getDossier(String numeroDossier) async { final encoded = Uri.encodeComponent(numeroDossier); + final uri = Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'); + if (kDebugMode) { + debugPrint('[PetitsPas/dossier] GET $uri'); + } final response = await http.get( - Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'), + uri, headers: await _headers(), ); if (response.statusCode == 404) { @@ -113,7 +118,21 @@ class UserService { if (decoded is! Map) { throw FormatException('Réponse invalide'); } - return DossierUnifie.fromJson(Map.from(decoded)); + final dossier = DossierUnifie.fromJson(Map.from(decoded)); + if (kDebugMode) { + debugPrint( + '[PetitsPas/dossier] réponse OK type=${dossier.type} | ' + 'ApiConfig.baseUrl=${ApiConfig.baseUrl} | apiOrigin=${ApiConfig.apiOrigin}', + ); + if (dossier.isFamily) { + final f = dossier.asFamily; + debugPrint( + '[PetitsPas/dossier] famille ${f.numeroDossier} | ' + '${f.enfants.length} enfant(s)', + ); + } + } + return dossier; } catch (e) { if (e is FormatException) rethrow; throw Exception('Réponse invalide (dossier): ${e is Exception ? e.toString() : "format inattendu"}'); diff --git a/frontend/lib/utils/data_generator.dart b/frontend/lib/utils/data_generator.dart deleted file mode 100644 index d7ffde5..0000000 --- a/frontend/lib/utils/data_generator.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'dart:math'; - -class DataGenerator { - static final Random _random = Random(); - - // Méthodes publiques pour la génération de nombres aléatoires - static int randomInt(int max) => _random.nextInt(max); - static int randomIntInRange(int min, int max) => min + _random.nextInt(max - min); - static bool randomBool() => _random.nextBool(); - - static final List _firstNames = [ - 'Alice', 'Bob', 'Charlie', 'David', 'Eva', 'Félix', 'Gabrielle', 'Hugo', 'Inès', 'Jules', - 'Léa', 'Manon', 'Nathan', 'Oscar', 'Pauline', 'Quentin', 'Raphaël', 'Sophie', 'Théo', 'Victoire' - ]; - - static final List _lastNames = [ - 'Martin', 'Bernard', 'Dubois', 'Thomas', 'Robert', 'Richard', 'Petit', 'Durand', 'Leroy', 'Moreau', - 'Simon', 'Laurent', 'Lefebvre', 'Michel', 'Garcia', 'David', 'Bertrand', 'Roux', 'Vincent', 'Fournier' - ]; - - static final List _addressSuffixes = [ - 'Rue de la Paix', 'Boulevard des Rêves', 'Avenue du Soleil', 'Place des Étoiles', 'Chemin des Champs' - ]; - - static final List _motivationSnippets = [ - 'Nous cherchons une personne de confiance.', - 'Nos horaires sont atypiques.', - 'Notre enfant est plein de vie.', - 'Nous souhaitons une garde à temps plein.', - 'Une adaptation en douceur est primordiale pour nous.', - 'Nous avons hâte de vous rencontrer.', - 'La pédagogie Montessori nous intéresse.' - ]; - - static String firstName() => _firstNames[_random.nextInt(_firstNames.length)]; - static String lastName() => _lastNames[_random.nextInt(_lastNames.length)]; - static String address() => "${_random.nextInt(100) + 1} ${_addressSuffixes[_random.nextInt(_addressSuffixes.length)]}"; - static String postalCode() => "750${_random.nextInt(10)}${_random.nextInt(10)}"; - static String city() => "Paris"; - static String phone() => "06${_random.nextInt(10)}${_random.nextInt(10)}${_random.nextInt(10)}${_random.nextInt(10)}${_random.nextInt(10)}${_random.nextInt(10)}${_random.nextInt(10)}${_random.nextInt(10)}"; - static String email(String firstName, String lastName) => "${firstName.toLowerCase()}.${lastName.toLowerCase()}@example.com"; - static String password() => "password123"; // Simple pour le test - - static String dob({bool isUnborn = false}) { - final now = DateTime.now(); - if (isUnborn) { - final provisionalDate = now.add(Duration(days: _random.nextInt(180) + 30)); // Entre 1 et 7 mois dans le futur - return "${provisionalDate.day.toString().padLeft(2, '0')}/${provisionalDate.month.toString().padLeft(2, '0')}/${provisionalDate.year}"; - } else { - final birthYear = now.year - _random.nextInt(3); // Enfants de 0 à 2 ans - final birthMonth = _random.nextInt(12) + 1; - final birthDay = _random.nextInt(28) + 1; // Simple, évite les pbs de jours/mois - return "${birthDay.toString().padLeft(2, '0')}/${birthMonth.toString().padLeft(2, '0')}/${birthYear}"; - } - } - - static bool boolean() => _random.nextBool(); - - static String motivation() { - int count = _random.nextInt(3) + 2; // 2 à 4 phrases - List chosenSnippets = []; - while(chosenSnippets.length < count) { - String snippet = _motivationSnippets[_random.nextInt(_motivationSnippets.length)]; - if (!chosenSnippets.contains(snippet)) { - chosenSnippets.add(snippet); - } - } - return chosenSnippets.join(' '); - } -} \ No newline at end of file diff --git a/frontend/lib/utils/email_utils.dart b/frontend/lib/utils/email_utils.dart new file mode 100644 index 0000000..a6ac257 --- /dev/null +++ b/frontend/lib/utils/email_utils.dart @@ -0,0 +1,57 @@ +import 'package:flutter/services.dart'; + +/// Longueur maximale d’une adresse e-mail (RFC 5321). +const int kEmailMaxLength = 254; + +/// Trim + minuscules (usage à la perte de focus et avant validation côté API). +String normalizeEmailText(String raw) { + return raw.trim().toLowerCase(); +} + +/// Motif volontairement simple pour l’UI (pas une validation RFC complète). +/// Local + @ + domaine avec au moins un point ; pas d’espaces. +final RegExp kAppEmailPattern = RegExp( + r'^[a-zA-Z0-9._%+\-]+@([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,63}$', +); + +/// Indique si [trimmed] est un e-mail plausible (insensible à la casse). +bool isValidEmailFormat(String trimmed) { + final s = normalizeEmailText(trimmed); + if (s.isEmpty || s.length > kEmailMaxLength) { + return false; + } + return kAppEmailPattern.hasMatch(s); +} + +/// Validation centralisée pour les champs e-mail. +/// +/// Si [allowEmpty] est `true`, une chaîne vide (après trim) est acceptée. +String? validateEmail(String? raw, {bool allowEmpty = false}) { + final s = normalizeEmailText(raw ?? ''); + if (s.isEmpty) { + return allowEmpty ? null : 'L’adresse e-mail est obligatoire.'; + } + if (s.length > kEmailMaxLength) { + return 'L’adresse e-mail est trop longue ($kEmailMaxLength caractères maximum).'; + } + if (!kAppEmailPattern.hasMatch(s)) { + return 'Le format de l’adresse e-mail est incorrect.'; + } + return null; +} + +/// Limite la longueur saisie dans un champ e-mail ([kEmailMaxLength]). +class EmailMaxLengthFormatter extends TextInputFormatter { + const EmailMaxLengthFormatter(); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + if (newValue.text.length <= kEmailMaxLength) { + return newValue; + } + return oldValue; + } +} diff --git a/frontend/lib/utils/name_format_utils.dart b/frontend/lib/utils/name_format_utils.dart new file mode 100644 index 0000000..feddec5 --- /dev/null +++ b/frontend/lib/utils/name_format_utils.dart @@ -0,0 +1,32 @@ +/// Formatage affichage prénom / nom (capitalisation par mot et segments après `-` ou `'`). + +String formatPersonNameCase(String raw) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) { + return trimmed; + } + final words = trimmed.split(RegExp(r'\s+')); + return words.map(_capitalizeComposedWord).join(' '); +} + +String _capitalizeComposedWord(String word) { + if (word.isEmpty) { + return word; + } + final lower = word.toLowerCase(); + const separators = {'-', "'", '’'}; + final buffer = StringBuffer(); + var capitalizeNext = true; + + for (var i = 0; i < lower.length; i++) { + final char = lower[i]; + if (capitalizeNext && RegExp(r'[a-zà-öø-ÿ]').hasMatch(char)) { + buffer.write(char.toUpperCase()); + capitalizeNext = false; + } else { + buffer.write(char); + capitalizeNext = separators.contains(char); + } + } + return buffer.toString(); +} diff --git a/frontend/lib/utils/parent_registration_payload.dart b/frontend/lib/utils/parent_registration_payload.dart new file mode 100644 index 0000000..05f8d79 --- /dev/null +++ b/frontend/lib/utils/parent_registration_payload.dart @@ -0,0 +1,284 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import '../models/user_registration_data.dart'; +import 'email_utils.dart'; + +/// Construction du body `POST /auth/register/parent` à partir du state d'inscription. +/// Aligné sur [RegisterParentCompletDto] (backend). +class ParentRegistrationPayload { + ParentRegistrationPayload._(); + + static final RegExp _phoneFr = RegExp(r'^(\+33|0)[1-9](\d{2}){4}$'); + /// Aligné sur `GenreType` (backend) : JSON exact `H`, `F`, `Autre`. + static const Set apiGenres = {'H', 'F', 'Autre'}; + + /// Retourne un message d'erreur utilisateur, ou `null` si le formulaire est cohérent. + static String? validateForApi(UserRegistrationData d) { + final p1 = d.parent1; + final p1Email = normalizeEmailText(p1.email); + if (p1Email.isEmpty) { + return 'L’email du parent principal est requis.'; + } + if (!isValidEmailFormat(p1Email)) { + return 'L’email du parent principal n’est pas au bon format.'; + } + if (p1.firstName.trim().length < 2) { + return 'Le prénom du parent principal doit contenir au moins 2 caractères.'; + } + if (p1.lastName.trim().length < 2) { + return 'Le nom du parent principal doit contenir au moins 2 caractères.'; + } + final tel = _normalizePhone(p1.phone); + if (!_phoneFr.hasMatch(tel)) { + return 'Le numéro de téléphone du parent principal n’est pas valide (ex. 0612345678).'; + } + if (!d.cguAccepted) { + return 'Vous devez accepter les CGU et la politique de confidentialité.'; + } + if (d.children.isEmpty) { + return 'Au moins un enfant est requis.'; + } + + final p2 = d.parent2; + if (p2 != null) { + final any = p2.email.trim().isNotEmpty || + p2.firstName.trim().isNotEmpty || + p2.lastName.trim().isNotEmpty || + p2.phone.trim().isNotEmpty; + if (any) { + final p2Email = normalizeEmailText(p2.email); + if (p2Email.isEmpty || + p2.firstName.trim().length < 2 || + p2.lastName.trim().length < 2) { + return 'Les informations du co-parent sont incomplètes (email, prénom et nom requis).'; + } + if (!isValidEmailFormat(p2Email)) { + return 'L’email du co-parent n’est pas au bon format.'; + } + if (p2Email == p1Email) { + return 'L’email du co-parent doit être différent de celui du parent principal.'; + } + final tel2 = _normalizePhone(p2.phone); + if (tel2.isNotEmpty && !_phoneFr.hasMatch(tel2)) { + return 'Le numéro de téléphone du co-parent n’est pas valide.'; + } + } + } + + for (var i = 0; i < d.children.length; i++) { + final c = d.children[i]; + final label = 'Enfant ${i + 1}'; + if (!c.photoConsent) { + return '$label : le consentement photo est obligatoire.'; + } + if (c.isUnbornChild) { + final due = _ddMmYyyyToIso(c.dob); + if (due == null) { + return '$label : indiquez une date de naissance prévisionnelle.'; + } + if (!apiGenres.contains(c.genre)) { + return '$label : indiquez Fille, Garçon ou Inconnu.'; + } + } else { + if (c.firstName.trim().length < 2) { + return '$label : le prénom doit contenir au moins 2 caractères.'; + } + final birth = _ddMmYyyyToIso(c.dob); + if (birth == null) { + return '$label : indiquez une date de naissance valide.'; + } + if (c.genre != 'H' && c.genre != 'F') { + return '$label : indiquez Fille ou Garçon.'; + } + } + } + + return null; + } + + static Map toJson(UserRegistrationData d) { + final p1 = d.parent1; + final tel = _normalizePhone(p1.phone); + + final body = { + 'email': normalizeEmailText(p1.email), + 'prenom': p1.firstName.trim(), + 'nom': p1.lastName.trim(), + 'telephone': tel, + 'acceptation_cgu': d.cguAccepted, + 'acceptation_privacy': d.cguAccepted, + }; + + _putIfNonEmpty(body, 'adresse', p1.address.trim()); + _putIfNonEmpty(body, 'code_postal', p1.postalCode.trim()); + _putIfNonEmpty(body, 'ville', p1.city.trim()); + + final p2 = d.parent2; + if (p2 != null && + p2.email.trim().isNotEmpty && + p2.firstName.trim().length >= 2 && + p2.lastName.trim().length >= 2) { + final tel2 = _normalizePhone(p2.phone); + final sameAddr = p2.address.trim() == p1.address.trim() && + p2.postalCode.trim() == p1.postalCode.trim() && + p2.city.trim() == p1.city.trim(); + + body['co_parent_email'] = normalizeEmailText(p2.email); + body['co_parent_prenom'] = p2.firstName.trim(); + body['co_parent_nom'] = p2.lastName.trim(); + body['co_parent_meme_adresse'] = sameAddr; + if (tel2.isNotEmpty) { + body['co_parent_telephone'] = tel2; + } + if (!sameAddr) { + _putIfNonEmpty(body, 'co_parent_adresse', p2.address.trim()); + _putIfNonEmpty(body, 'co_parent_code_postal', p2.postalCode.trim()); + _putIfNonEmpty(body, 'co_parent_ville', p2.city.trim()); + } + } + + if (d.motivationText.trim().isNotEmpty) { + body['presentation_dossier'] = d.motivationText.trim(); + } + + body['enfants'] = d.children.asMap().entries.map((e) { + return _childToJson(e.value, e.key, d.parent1.lastName.trim()); + }).toList(); + + return body; + } + + static Map _childToJson(ChildData c, int index, String parentNom) { + final map = { + 'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre', + 'grossesse_multiple': c.multipleBirth, + }; + + final prenom = c.firstName.trim(); + if (prenom.length >= 2) { + map['prenom'] = prenom; + } + + final nom = c.lastName.trim(); + if (nom.length >= 2) { + map['nom'] = nom; + } else if (parentNom.length >= 2) { + map['nom'] = parentNom; + } + + if (c.isUnbornChild) { + final due = _ddMmYyyyToIso(c.dob); + if (due != null) { + map['date_previsionnelle_naissance'] = due; + } + } else { + final birth = _ddMmYyyyToIso(c.dob); + if (birth != null) { + map['date_naissance'] = birth; + } + } + + final photo = _childPhotoBase64(c, index, prenom); + if (photo != null) { + map['photo_base64'] = photo.$1; + map['photo_filename'] = photo.$2; + } + + return map; + } + + /// Sous-type `image/…` pour data-URL et extension côté API (`jpeg`, `png`, `gif`, `heic`, …). + static String _imageMimeFromMagicBytes(Uint8List bytes) { + if (bytes.length >= 8) { + if (bytes[0] == 0x89 && + bytes[1] == 0x50 && + bytes[2] == 0x4E && + bytes[3] == 0x47) { + return 'png'; + } + if (bytes[0] == 0xFF && bytes[1] == 0xD8) { + return 'jpeg'; + } + if (bytes.length >= 12 && + bytes[0] == 0x52 && + bytes[1] == 0x49 && + bytes[2] == 0x46 && + bytes[3] == 0x46) { + return 'webp'; + } + if (bytes.length >= 6 && + bytes[0] == 0x47 && + bytes[1] == 0x49 && + bytes[2] == 0x46 && + bytes[3] == 0x38 && + (bytes[4] == 0x37 || bytes[4] == 0x39) && + bytes[5] == 0x61) { + return 'gif'; + } + if (bytes.length >= 12 && + bytes[4] == 0x66 && + bytes[5] == 0x74 && + bytes[6] == 0x79 && + bytes[7] == 0x70) { + final a = bytes[8], b = bytes[9], c = bytes[10], d = bytes[11]; + if ((a == 0x68 && b == 0x65 && c == 0x69 && d == 0x63) || + (a == 0x68 && b == 0x65 && c == 0x69 && d == 0x78) || + (a == 0x6d && b == 0x69 && c == 0x66 && d == 0x31) || + (a == 0x6d && b == 0x73 && c == 0x66 && d == 0x31)) { + return 'heic'; + } + } + } + return 'jpeg'; + } + + /// (`dataUrl`, `filename`) ou `null` si pas de fichier lisible. + static (String, String)? _childPhotoBase64(ChildData c, int index, String prenom) { + Uint8List? bytes = c.imageBytes; + if (bytes == null || bytes.isEmpty) { + final file = c.imageFile; + if (file == null) return null; + try { + if (!file.existsSync()) return null; + bytes = file.readAsBytesSync(); + } catch (_) { + return null; + } + } + if (bytes.isEmpty) return null; + final b64 = base64Encode(bytes); + final mime = _imageMimeFromMagicBytes(bytes); + final safeName = prenom.isNotEmpty + ? '${prenom.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_')}.$mime' + : 'enfant_${index + 1}.$mime'; + return ('data:image/$mime;base64,$b64', safeName); + } + + static void _putIfNonEmpty(Map m, String key, String value) { + if (value.isNotEmpty) { + m[key] = value; + } + } + + static String _normalizePhone(String raw) { + var s = raw.replaceAll(RegExp(r'\s'), ''); + if (s.startsWith('0033')) { + s = '+33${s.substring(4)}'; + } + return s; + } + + /// `jj/mm/aaaa` → `aaaa-mm-jj`, ou `null` si invalide. + static String? _ddMmYyyyToIso(String ddMmYyyy) { + if (ddMmYyyy.isEmpty) return null; + final parts = ddMmYyyy.split('/'); + if (parts.length != 3) return null; + final day = int.tryParse(parts[0]); + final month = int.tryParse(parts[1]); + final year = int.tryParse(parts[2]); + if (day == null || month == null || year == null) return null; + if (month < 1 || month > 12 || day < 1 || day > 31) return null; + return '${year.toString().padLeft(4, '0')}-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}'; + } +} diff --git a/frontend/lib/utils/phone_utils.dart b/frontend/lib/utils/phone_utils.dart index 2a87b8c..ba5bde7 100644 --- a/frontend/lib/utils/phone_utils.dart +++ b/frontend/lib/utils/phone_utils.dart @@ -8,46 +8,127 @@ String normalizePhone(String raw) { return digits.length > 10 ? digits.substring(0, 10) : digits; } +/// Indique si [digitsOnly] (déjà normalisé par [normalizePhone]) commence par `0`. +/// Chaîne vide : `true` (pas encore de saisie). +bool frenchNationalPhoneStartsWithZero(String digitsOnly) { + if (digitsOnly.isEmpty) { + return true; + } + return digitsOnly.startsWith('0'); +} + +/// Validation téléphone France : 10 chiffres, commence par `0`, 2ᵉ chiffre 1–9 (format national). +/// +/// Utiliser sur tout champ « numéro français » (inscription, admin, relais, etc.). +/// Si [allowEmpty] est `true`, une valeur vide ou blanche est acceptée. +String? validateFrenchNationalPhone(String? raw, {bool allowEmpty = false}) { + final trimmed = raw?.trim() ?? ''; + if (trimmed.isEmpty) { + return allowEmpty ? null : 'Le numéro de téléphone est obligatoire.'; + } + final digits = normalizePhone(trimmed); + if (digits.isEmpty) { + return allowEmpty ? null : 'Le numéro de téléphone est obligatoire.'; + } + if (!frenchNationalPhoneStartsWithZero(digits)) { + return 'En France, le numéro doit commencer par 0 (ex. 06 12 34 56 78).'; + } + if (digits.length < 10) { + return 'Le numéro doit contenir 10 chiffres.'; + } + if (digits.length > 10) { + return 'Le numéro ne peut pas dépasser 10 chiffres.'; + } + if (!RegExp(r'^0[1-9]\d{8}$').hasMatch(digits)) { + return 'Numéro de téléphone invalide.'; + } + return null; +} + /// Retourne le numéro formaté pour l'affichage (ex. "06 12 34 56 78"). /// Si [raw] est vide après normalisation, retourne [raw] tel quel (pour afficher "–" etc.). String formatPhoneForDisplay(String raw) { - if (raw.trim().isEmpty) return raw; + if (raw.trim().isEmpty) { + return raw; + } final normalized = normalizePhone(raw); - if (normalized.isEmpty) return raw; + if (normalized.isEmpty) { + return raw; + } + return formatFrenchPhoneDigits(normalized); +} + +/// Affiche les chiffres par paires (ex. `0612345678` → `06 12 34 56 78`). +String formatFrenchPhoneDigits(String normalizedDigits) { + if (normalizedDigits.isEmpty) { + return ''; + } final buffer = StringBuffer(); - for (var i = 0; i < normalized.length; i++) { - if (i > 0 && i.isEven) buffer.write(' '); - buffer.write(normalized[i]); + for (var i = 0; i < normalizedDigits.length; i++) { + if (i > 0 && i.isEven) { + buffer.write(' '); + } + buffer.write(normalizedDigits[i]); } return buffer.toString(); } -/// Formatter de saisie : uniquement chiffres, espaces automatiques toutes les 2 chiffres, max 10 chiffres. +int _cursorOffsetInFormattedPhone(String normalized, int digitCountBeforeCursor) { + final k = digitCountBeforeCursor.clamp(0, normalized.length); + if (k == 0) { + return 0; + } + return formatFrenchPhoneDigits(normalized.substring(0, k)).length; +} + +/// Formatter de saisie : chiffres uniquement, paires espacées, max 10 chiffres. +/// +/// - Si le premier chiffre est **1 à 7**, un **0** est ajouté automatiquement devant (ex. `6` → `06`). +/// - Si l’utilisateur tape **8** ou **9** en premier, la saisie est ignorée (numéros spéciaux `08` / `09`). +/// - S’il commence déjà par **0**, aucun préfixe n’est ajouté. class FrenchPhoneNumberFormatter extends TextInputFormatter { const FrenchPhoneNumberFormatter(); + static const String _autoPrefixFirstDigits = '1234567'; + @override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue, ) { - final digits = newValue.text.replaceAll(RegExp(r'\D'), ''); - final normalized = digits.length > 10 ? digits.substring(0, 10) : digits; - final buffer = StringBuffer(); - for (var i = 0; i < normalized.length; i++) { - if (i > 0 && i.isEven) buffer.write(' '); - buffer.write(normalized[i]); - } - final formatted = buffer.toString(); + var digits = newValue.text.replaceAll(RegExp(r'\D'), ''); + var didPrependZero = false; + + if (digits.isNotEmpty) { + final first = digits[0]; + if (first == '8' || first == '9') { + return oldValue; + } + if (first != '0' && _autoPrefixFirstDigits.contains(first)) { + digits = '0$digits'; + didPrependZero = true; + if (digits.length > 10) { + digits = digits.substring(0, 10); + } + } else if (first != '0') { + return oldValue; + } + } + + final normalized = digits.length > 10 ? digits.substring(0, 10) : digits; + final formatted = formatFrenchPhoneDigits(normalized); - // Conserver la position du curseur : compter les chiffres avant la sélection final sel = newValue.selection; - final digitsBeforeCursor = newValue.text + var digitsBeforeCursor = newValue.text .substring(0, sel.start.clamp(0, newValue.text.length)) .replaceAll(RegExp(r'\D'), '') .length; - final newOffset = digitsBeforeCursor + (digitsBeforeCursor > 0 ? digitsBeforeCursor ~/ 2 : 0); - final clampedOffset = newOffset.clamp(0, formatted.length); + if (didPrependZero) { + digitsBeforeCursor = (digitsBeforeCursor + 1).clamp(0, normalized.length); + } + + final clampedOffset = + _cursorOffsetInFormattedPhone(normalized, digitsBeforeCursor).clamp(0, formatted.length); return TextEditingValue( text: formatted, @@ -55,3 +136,10 @@ class FrenchPhoneNumberFormatter extends TextInputFormatter { ); } } + +/// Formatters à réutiliser sur tout champ téléphone France ([TextFormField], [CustomAppTextField], etc.). +final List frenchPhoneInputFormatters = [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(10), + const FrenchPhoneNumberFormatter(), +]; diff --git a/frontend/lib/utils/postal_utils.dart b/frontend/lib/utils/postal_utils.dart new file mode 100644 index 0000000..d3dfb4d --- /dev/null +++ b/frontend/lib/utils/postal_utils.dart @@ -0,0 +1,19 @@ +import 'package:flutter/services.dart'; + +/// Saisie code postal français : uniquement des chiffres, au plus 5. +final List kFrenchPostalCodeInputFormatters = [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(5), +]; + +/// Valide un code postal français (exactement 5 chiffres). +String? validateFrenchPostalCode(String? raw, {bool allowEmpty = false}) { + final s = raw?.trim() ?? ''; + if (s.isEmpty) { + return allowEmpty ? null : 'Ce champ est obligatoire'; + } + if (s.length != 5 || int.tryParse(s) == null) { + return 'Le code postal doit comporter 5 chiffres.'; + } + return null; +} diff --git a/frontend/lib/widgets/admin/parametres_panel.dart b/frontend/lib/widgets/admin/parametres_panel.dart index 6c445d8..b99fd50 100644 --- a/frontend/lib/widgets/admin/parametres_panel.dart +++ b/frontend/lib/widgets/admin/parametres_panel.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:p_tits_pas/services/configuration_service.dart'; +import 'package:p_tits_pas/utils/email_utils.dart'; import 'package:p_tits_pas/widgets/admin/relais_management_panel.dart'; /// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé. @@ -182,6 +183,9 @@ class _ParametresPanelState extends State { hintText: 'admin@example.com', ), keyboardType: TextInputType.emailAddress, + autocorrect: false, + enableSuggestions: false, + inputFormatters: const [EmailMaxLengthFormatter()], ), actions: [ TextButton( @@ -191,7 +195,17 @@ class _ParametresPanelState extends State { FilledButton( onPressed: () { final t = c.text.trim(); - if (t.isNotEmpty) Navigator.pop(ctx, t); + if (t.isEmpty) { + return; + } + final err = validateEmail(t, allowEmpty: true); + if (err != null) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar(content: Text(err)), + ); + return; + } + Navigator.pop(ctx, t); }, child: const Text('Envoyer'), ), diff --git a/frontend/lib/widgets/admin/relais_management_panel.dart b/frontend/lib/widgets/admin/relais_management_panel.dart index 12327b7..614ecee 100644 --- a/frontend/lib/widgets/admin/relais_management_panel.dart +++ b/frontend/lib/widgets/admin/relais_management_panel.dart @@ -482,6 +482,9 @@ class _RelaisFormDialogState extends State<_RelaisFormDialog> { if (!_isValidPostalCode(_postalCodeCtrl.text.trim())) { return false; } + if (validateFrenchNationalPhone(_ligneFixeCtrl.text, allowEmpty: true) != null) { + return false; + } for (final day in _days) { final ferme = _closedByDay[day] ?? false; @@ -721,11 +724,7 @@ class _RelaisFormDialogState extends State<_RelaisFormDialog> { TextField( controller: _ligneFixeCtrl, keyboardType: TextInputType.phone, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(10), - FrenchPhoneNumberFormatter(), - ], + inputFormatters: frenchPhoneInputFormatters, decoration: const InputDecoration( labelText: 'Ligne fixe', hintText: '01 23 45 67 89', diff --git a/frontend/lib/widgets/admin/validation_am_wizard.dart b/frontend/lib/widgets/admin/validation_am_wizard.dart index e15bae5..5a18332 100644 --- a/frontend/lib/widgets/admin/validation_am_wizard.dart +++ b/frontend/lib/widgets/admin/validation_am_wizard.dart @@ -7,6 +7,7 @@ import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'validation_modal_theme.dart'; import 'validation_refus_form.dart'; import 'validation_valider_confirm_dialog.dart'; @@ -110,15 +111,8 @@ class _ValidationAmWizardState extends State { static const double _proColumnMinWidth = 260; static const double _photoColumnMinWidth = 160; - /// URL complète pour la photo : si relatif, on préfixe par l’origine de l’API. - static String _fullPhotoUrl(String? url) { - if (url == null || url.trim().isEmpty) return ''; - final u = url.trim(); - if (u.startsWith('http://') || u.startsWith('https://')) return u; - final base = ApiConfig.baseUrl; - final origin = base.replaceAll(RegExp(r'/api/v1.*'), ''); - return u.startsWith('/') ? '$origin$u' : '$origin/$u'; - } + /// URL complète pour la photo : si relatif, préfixe [ApiConfig.baseUrl] (ex. `/api/v1/uploads/...`). + static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url); Widget _buildPhotoSection(AppUser u) { final photoUrl = _fullPhotoUrl(u.photoUrl); @@ -187,8 +181,8 @@ class _ValidationAmWizardState extends State { ], ), ) - : Image.network( - photoUrl, + : AuthNetworkImage( + url: photoUrl, fit: BoxFit.cover, width: pw, height: ph, diff --git a/frontend/lib/widgets/admin/validation_family_wizard.dart b/frontend/lib/widgets/admin/validation_family_wizard.dart index de89bd9..4354ab6 100644 --- a/frontend/lib/widgets/admin/validation_family_wizard.dart +++ b/frontend/lib/widgets/admin/validation_family_wizard.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/gestures.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -8,6 +9,7 @@ import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'validation_modal_theme.dart'; import 'validation_refus_form.dart'; import 'validation_valider_confirm_dialog.dart'; @@ -134,14 +136,7 @@ class _ValidationFamilyWizardState extends State { 3: [2, 5] }; // Code postal étroit, Ville large - static String _fullPhotoUrl(String? url) { - if (url == null || url.trim().isEmpty) return ''; - final u = url.trim(); - if (u.startsWith('http://') || u.startsWith('https://')) return u; - final base = ApiConfig.baseUrl; - final origin = base.replaceAll(RegExp(r'/api/v1.*'), ''); - return u.startsWith('/') ? '$origin$u' : '$origin/$u'; - } + static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url); @override Widget build(BuildContext context) { @@ -349,6 +344,12 @@ class _ValidationFamilyWizardState extends State { /// Carte enfant : prénom pleine largeur, puis ligne photo 1/3 + colonne 2/3 (champs + statut hors TF si besoin). Widget _buildEnfantCard(EnfantDossier e) { final photoUrl = _fullPhotoUrl(e.photoUrl); + if (kDebugMode) { + debugPrint( + '[PetitsPas/validation-famille] carte enfant id=${e.id} prénom=${e.firstName} | ' + 'photoUrl modèle=${e.photoUrl ?? "∅"} | url affichée=${photoUrl.isEmpty ? "∅" : photoUrl}', + ); + } final columnStatusLabel = _enfantColumnStatusLabel(e); return ClipRRect( borderRadius: BorderRadius.circular(_enfantCardRadius), @@ -507,12 +508,12 @@ class _ValidationFamilyWizardState extends State { } Widget _buildEnfantPhotoSlot(String photoUrl, double width, double height) { + const photoRadius = 8.0; return Container( width: width, height: height, decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(photoRadius), border: Border.all(color: Colors.black.withValues(alpha: 0.08)), boxShadow: [ BoxShadow( @@ -530,9 +531,9 @@ class _ValidationFamilyWizardState extends State { child: Icon(Icons.person_outline, size: 32, color: Colors.grey.shade400), ), ) - : Image.network( - photoUrl, - fit: BoxFit.contain, + : AuthNetworkImage( + url: photoUrl, + fit: BoxFit.cover, width: width, height: height, errorBuilder: (_, __, ___) => ColoredBox( diff --git a/frontend/lib/widgets/app_custom_checkbox.dart b/frontend/lib/widgets/app_custom_checkbox.dart index a52640c..5331ab0 100644 --- a/frontend/lib/widgets/app_custom_checkbox.dart +++ b/frontend/lib/widgets/app_custom_checkbox.dart @@ -8,6 +8,8 @@ class AppCustomCheckbox extends StatelessWidget { final double checkboxSize; final double checkmarkSizeFactor; final double fontSize; + /// Survol (desktop) ou appui long (mobile) pour afficher un texte d’aide. + final String? tooltip; const AppCustomCheckbox({ super.key, @@ -17,48 +19,67 @@ class AppCustomCheckbox extends StatelessWidget { this.checkboxSize = 20.0, this.checkmarkSizeFactor = 1.4, this.fontSize = 16.0, + this.tooltip, }); @override Widget build(BuildContext context) { - return GestureDetector( - onTap: () => onChanged(!value), // Inverse la valeur au clic - behavior: HitTestBehavior.opaque, // Pour s'assurer que toute la zone du Row est cliquable - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: checkboxSize, - height: checkboxSize, - child: Stack( - alignment: Alignment.center, - clipBehavior: Clip.none, - children: [ - Image.asset( - 'assets/images/square.png', - height: checkboxSize, - width: checkboxSize, + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: () => onChanged(!value), + behavior: HitTestBehavior.opaque, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: checkboxSize, + height: checkboxSize, + child: Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + Image.asset( + 'assets/images/square.png', + height: checkboxSize, + width: checkboxSize, + ), + if (value) + Image.asset( + 'assets/images/coche.png', + height: checkboxSize * checkmarkSizeFactor, + width: checkboxSize * checkmarkSizeFactor, + ), + ], ), - if (value) - Image.asset( - 'assets/images/coche.png', - height: checkboxSize * checkmarkSizeFactor, - width: checkboxSize * checkmarkSizeFactor, - ), - ], - ), + ), + const SizedBox(width: 10), + Flexible( + child: Text( + label, + style: GoogleFonts.merienda(fontSize: fontSize), + overflow: TextOverflow.ellipsis, + ), + ), + ], ), - const SizedBox(width: 10), - // Utiliser Flexible pour que le texte ne cause pas d'overflow si trop long - Flexible( - child: Text( - label, - style: GoogleFonts.merienda(fontSize: fontSize), - overflow: TextOverflow.ellipsis, // Gérer le texte long + ), + if (tooltip != null && tooltip!.trim().isNotEmpty) ...[ + const SizedBox(width: 6), + Tooltip( + message: tooltip!.trim(), + waitDuration: const Duration(milliseconds: 300), + triggerMode: TooltipTriggerMode.tap, + showDuration: const Duration(seconds: 6), + child: Icon( + Icons.info_outline, + size: fontSize * 1.2, + color: Colors.black54, ), ), ], - ), + ], ); } } \ No newline at end of file diff --git a/frontend/lib/widgets/child_card_widget.dart b/frontend/lib/widgets/child_card_widget.dart index 18bdf72..7d93ecc 100644 --- a/frontend/lib/widgets/child_card_widget.dart +++ b/frontend/lib/widgets/child_card_widget.dart @@ -1,14 +1,41 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'dart:io' show File; import 'package:flutter/foundation.dart' show kIsWeb; import '../models/user_registration_data.dart'; import '../models/card_assets.dart'; import 'custom_app_text_field.dart'; import 'form_field_wrapper.dart'; -import 'app_custom_checkbox.dart'; import 'hover_relief_widget.dart'; import '../config/display_config.dart'; +import 'package:p_tits_pas/utils/name_format_utils.dart'; + +const String _photoConsentTooltip = + 'Obligatoire : cochez cette case pour autoriser l’utilisation de la photo de l’enfant.\n' + 'Suivi du dossier et organisation de l’accueil (affichage interne, outils pédagogiques).\n' + 'Dans le respect de la politique de confidentialité.'; + +/// Cadre photo (centre transparent), même dossier que `photo.png`. +const String _photoSketchFrameAsset = 'assets/images/photo_frame.png'; + +bool _hasChildPhoto(ChildData c) { + final b = c.imageBytes; + if (b != null && b.isNotEmpty) return true; + return c.imageFile != null; +} + +Widget _buildChildPhotoImage(ChildData c, {required BoxFit fit}) { + final bytes = c.imageBytes; + if (bytes != null && bytes.isNotEmpty) { + return Image.memory(bytes, fit: fit); + } + final f = c.imageFile; + if (f != null) { + return kIsWeb ? Image.network(f.path, fit: fit) : Image.file(f, fit: fit); + } + return Image.asset('assets/images/photo.png', fit: BoxFit.contain); +} /// Widget pour afficher et éditer une carte enfant /// Utilisé dans le workflow d'inscription des parents @@ -16,11 +43,14 @@ class ChildCardWidget extends StatefulWidget { final ChildData childData; final int childIndex; final VoidCallback onPickImage; + /// Retire la photo sélectionnée (placeholder à la place). + final VoidCallback onClearImage; final VoidCallback onDateSelect; final ValueChanged onFirstNameChanged; final ValueChanged onLastNameChanged; + /// `H`, `F` ou `Autre` (API). « Inconnu » à l’UI = `Autre`. + final ValueChanged onGenreChanged; final ValueChanged onTogglePhotoConsent; - final ValueChanged onToggleMultipleBirth; final ValueChanged onToggleIsUnborn; final VoidCallback onRemove; final bool canBeRemoved; @@ -32,11 +62,12 @@ class ChildCardWidget extends StatefulWidget { required this.childData, required this.childIndex, required this.onPickImage, + required this.onClearImage, required this.onDateSelect, required this.onFirstNameChanged, required this.onLastNameChanged, + required this.onGenreChanged, required this.onTogglePhotoConsent, - required this.onToggleMultipleBirth, required this.onToggleIsUnborn, required this.onRemove, required this.canBeRemoved, @@ -49,10 +80,24 @@ class ChildCardWidget extends StatefulWidget { } class _ChildCardWidgetState extends State { + /// Largeur desktop : proportionnelle au côté du carré photo (512×1024 sur les PNG → portrait ~1:2 ; même idée qu’avant #78 : 345×1,1 pour 200 px de côté). + static double _desktopEditableCardWidth({ + required double photoSide, + required double maxWidth, + required double scaleFactor, + }) { + final fromPhoto = photoSide * (345.0 * 1.1 / 200.0); + final minForFields = 300.0 + 44.0 * scaleFactor; + return math.min(maxWidth, math.max(minForFields, fromPhoto)); + } + late TextEditingController _firstNameController; late TextEditingController _lastNameController; late TextEditingController _dobController; + FocusNode? _firstNameFocus; + FocusNode? _lastNameFocus; + @override void initState() { super.initState(); @@ -65,6 +110,38 @@ class _ChildCardWidgetState extends State { _firstNameController.addListener(() => widget.onFirstNameChanged(_firstNameController.text)); _lastNameController.addListener(() => widget.onLastNameChanged(_lastNameController.text)); // Pour dob, la mise à jour se fait via _selectDate, pas besoin de listener ici + + if (widget.mode == DisplayMode.editable) { + _firstNameFocus = FocusNode(); + _lastNameFocus = FocusNode(); + _firstNameFocus!.addListener(_onFirstNameFocusChange); + _lastNameFocus!.addListener(_onLastNameFocusChange); + } + } + + void _onFirstNameFocusChange() { + if (_firstNameFocus == null || _firstNameFocus!.hasFocus) { + return; + } + _applyPersonNameFormat(_firstNameController); + } + + void _onLastNameFocusChange() { + if (_lastNameFocus == null || _lastNameFocus!.hasFocus) { + return; + } + _applyPersonNameFormat(_lastNameController); + } + + void _applyPersonNameFormat(TextEditingController controller) { + final formatted = formatPersonNameCase(controller.text); + if (formatted == controller.text) { + return; + } + controller.value = TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); } @override @@ -85,6 +162,10 @@ class _ChildCardWidgetState extends State { @override void dispose() { + _firstNameFocus?.removeListener(_onFirstNameFocusChange); + _lastNameFocus?.removeListener(_onLastNameFocusChange); + _firstNameFocus?.dispose(); + _lastNameFocus?.dispose(); _firstNameController.dispose(); _lastNameController.dispose(); _dobController.dispose(); @@ -110,16 +191,25 @@ class _ChildCardWidgetState extends State { ); } - final File? currentChildImage = widget.childData.imageFile; - // ... (reste du code existant pour mobile/editable) final Color baseCardColorForShadow = widget.childData.cardColor == CardColorVertical.lavender ? Colors.purple.shade200 : (widget.childData.cardColor == CardColorVertical.pink ? Colors.pink.shade200 : Colors.grey.shade200); - final Color initialPhotoShadow = baseCardColorForShadow.withAlpha(90); - final Color hoverPhotoShadow = baseCardColorForShadow.withAlpha(130); + final Color initialPhotoShadow = + Color.alphaBlend(Colors.black.withValues(alpha: 0.22), baseCardColorForShadow); + final Color hoverPhotoShadow = + Color.alphaBlend(Colors.black.withValues(alpha: 0.32), baseCardColorForShadow); + + final double photoSide = 200.0 * (config.isMobile ? 0.8 : 1.0); + final double editableCardWidth = config.isMobile + ? double.infinity + : _desktopEditableCardWidth( + photoSide: photoSide, + maxWidth: screenSize.width * 0.92, + scaleFactor: scaleFactor, + ); return Container( - width: config.isMobile ? double.infinity : screenSize.width * 0.6, + width: editableCardWidth, // On retire la hauteur fixe pour laisser le contenu définir la taille, comme les autres cartes // height: config.isMobile ? null : 600.0 * scaleFactor, padding: EdgeInsets.all(22.0 * scaleFactor), @@ -132,24 +222,20 @@ class _ChildCardWidgetState extends State { Column( mainAxisSize: MainAxisSize.min, children: [ - // ... (contenu existant) - HoverReliefWidget( - onPressed: config.isReadonly ? null : widget.onPickImage, - borderRadius: BorderRadius.circular(10), - initialShadowColor: initialPhotoShadow, - hoverShadowColor: hoverPhotoShadow, - child: SizedBox( - height: 200.0 * (config.isMobile ? 0.8 : 1.0), - width: 200.0 * (config.isMobile ? 0.8 : 1.0), - child: Center( - child: Padding( - padding: EdgeInsets.all(5.0 * scaleFactor), - child: currentChildImage != null - ? ClipRRect(borderRadius: BorderRadius.circular(10 * scaleFactor), child: kIsWeb ? Image.network(currentChildImage.path, fit: BoxFit.cover) : Image.file(currentChildImage, fit: BoxFit.cover)) - : Image.asset('assets/images/photo.png', fit: BoxFit.contain), - ), - ), - ), + _buildEditablePhotoArea( + context: context, + config: config, + scaleFactor: scaleFactor, + photoSide: photoSide, + childData: widget.childData, + initialPhotoShadow: initialPhotoShadow, + hoverPhotoShadow: hoverPhotoShadow, + ), + SizedBox(height: 8.0 * scaleFactor), + _buildPhotoConsentRow( + context: context, + config: config, + labelFontSize: config.isMobile ? 13.0 : 16.0, ), SizedBox(height: 10.0 * scaleFactor), Row( @@ -173,13 +259,16 @@ class _ChildCardWidgetState extends State { ], ), SizedBox(height: 8.0 * scaleFactor), + _buildGenreSegment(context, config, scaleFactor), + SizedBox(height: 8.0 * scaleFactor), _buildField( config: config, scaleFactor: scaleFactor, label: 'Prénom', controller: _firstNameController, - hint: 'Facultatif si à naître', + hint: widget.childData.isUnbornChild ? 'Facultatif' : 'Prénom', isRequired: !widget.childData.isUnbornChild, + focusNode: _firstNameFocus, ), SizedBox(height: 5.0 * scaleFactor), _buildField( @@ -188,6 +277,7 @@ class _ChildCardWidgetState extends State { label: 'Nom', controller: _lastNameController, hint: 'Nom de l\'enfant', + focusNode: _lastNameFocus, ), SizedBox(height: 8.0 * scaleFactor), _buildField( @@ -200,27 +290,6 @@ class _ChildCardWidgetState extends State { onTap: config.isReadonly ? null : widget.onDateSelect, suffixIcon: Icons.calendar_today, ), - SizedBox(height: 10.0 * scaleFactor), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppCustomCheckbox( - label: 'Consentement photo', - value: widget.childData.photoConsent, - onChanged: config.isReadonly ? (v) {} : widget.onTogglePhotoConsent, - checkboxSize: config.isMobile ? 20.0 : 22.0 * scaleFactor, - fontSize: config.isMobile ? 13.0 : 16.0, - ), - SizedBox(height: 5.0 * scaleFactor), - AppCustomCheckbox( - label: 'Naissance multiple', - value: widget.childData.multipleBirth, - onChanged: config.isReadonly ? (v) {} : widget.onToggleMultipleBirth, - checkboxSize: config.isMobile ? 20.0 : 22.0 * scaleFactor, - fontSize: config.isMobile ? 13.0 : 16.0, - ), - ], - ), ], ), if (widget.canBeRemoved && !config.isReadonly) @@ -230,7 +299,7 @@ class _ChildCardWidgetState extends State { onTap: widget.onRemove, customBorder: const CircleBorder(), child: Image.asset( - 'assets/images/red_cross2.png', + 'assets/images/cross.png', width: config.isMobile ? 30 : 36, height: config.isMobile ? 30 : 36, fit: BoxFit.contain, @@ -252,6 +321,96 @@ class _ChildCardWidgetState extends State { ); } + Widget _buildEditablePhotoArea({ + required BuildContext context, + required DisplayConfig config, + required double scaleFactor, + required double photoSide, + required ChildData childData, + required Color initialPhotoShadow, + required Color hoverPhotoShadow, + }) { + final canInteract = !config.isReadonly; + final hasPhoto = _hasChildPhoto(childData); + final outerRadius = BorderRadius.circular(10 * scaleFactor); + // Arrondi photo (sous le cadre) : proportionnel au carré, plus fort qu’avant (~10×scaleFactor). + final photoClipRadius = BorderRadius.circular(photoSide * 0.14); + + return Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + HoverReliefWidget( + onPressed: canInteract ? widget.onPickImage : null, + borderRadius: outerRadius, + initialElevation: 10, + hoverElevation: 16, + initialShadowColor: initialPhotoShadow, + hoverShadowColor: hoverPhotoShadow, + // Pas de clip Material sur la pile : l’arrondi de la photo est géré par ClipRRect (plus marqué). + clipBehavior: hasPhoto ? Clip.none : Clip.antiAlias, + child: SizedBox( + width: photoSide, + height: photoSide, + child: !hasPhoto + ? ClipRRect( + borderRadius: outerRadius, + child: Center( + child: Image.asset( + 'assets/images/photo.png', + fit: BoxFit.contain, + width: photoSide, + height: photoSide, + ), + ), + ) + : Stack( + fit: StackFit.expand, + children: [ + // Pas de fond opaque : les coins hors ClipRRect laissent voir la carte (aquarelle). + Positioned.fill( + child: ClipRRect( + borderRadius: photoClipRadius, + child: _buildChildPhotoImage(childData, fit: BoxFit.cover), + ), + ), + Positioned.fill( + child: Image.asset( + _photoSketchFrameAsset, + fit: BoxFit.fill, + errorBuilder: (context, error, stackTrace) => + const SizedBox.shrink(), + ), + ), + ], + ), + ), + ), + if (hasPhoto && canInteract) + Positioned( + top: 8 * scaleFactor, + right: 8 * scaleFactor, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: widget.onClearImage, + customBorder: const CircleBorder(), + child: Padding( + padding: const EdgeInsets.all(4), + child: Image.asset( + 'assets/images/cross.png', + width: config.isMobile ? 26 : 30, + height: config.isMobile ? 26 : 30, + fit: BoxFit.contain, + ), + ), + ), + ), + ), + ], + ); + } + /// Layout SPÉCIAL Readonly Desktop (Ancien Design Horizontal) Widget _buildReadonlyDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) { // Convertir la couleur verticale (pour mobile) en couleur horizontale (pour desktop/récap) @@ -266,8 +425,7 @@ class _ChildCardWidgetState extends State { else if (widget.childData.cardColor.path.contains('peach')) horizontalCardAsset = CardColorHorizontal.peach.path; else if (widget.childData.cardColor.path.contains('pink')) horizontalCardAsset = CardColorHorizontal.pink.path; else if (widget.childData.cardColor.path.contains('red')) horizontalCardAsset = CardColorHorizontal.red.path; - - final File? currentChildImage = widget.childData.imageFile; + final cardWidth = screenSize.width / 2.0; return SizedBox( @@ -310,32 +468,41 @@ class _ChildCardWidgetState extends State { child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - // PHOTO (1/3) + // PHOTO (1/3) + consentement sous la photo Expanded( flex: 1, child: Center( - child: AspectRatio( - aspectRatio: 1, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(18), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 10, - offset: const Offset(0, 5), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AspectRatio( + aspectRatio: 1, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 5), + ), + ], ), - ], + child: ClipRRect( + borderRadius: BorderRadius.circular(18), + child: _hasChildPhoto(widget.childData) + ? _buildChildPhotoImage(widget.childData, fit: BoxFit.cover) + : Image.asset('assets/images/photo.png', fit: BoxFit.contain), + ), + ), ), - child: ClipRRect( - borderRadius: BorderRadius.circular(18), - child: currentChildImage != null - ? (kIsWeb - ? Image.network(currentChildImage.path, fit: BoxFit.cover) - : Image.file(currentChildImage, fit: BoxFit.cover)) - : Image.asset('assets/images/photo.png', fit: BoxFit.contain), + const SizedBox(height: 12), + _buildPhotoConsentRow( + context: context, + config: config, + labelFontSize: 16.0, ), - ), + ], ), ), ), @@ -356,35 +523,14 @@ class _ChildCardWidgetState extends State { widget.childData.isUnbornChild ? 'Date prévisionnelle :' : 'Date de naissance :', _dobController.text ), + const SizedBox(height: 12), + _buildReadonlyField('Genre :', _genreDisplayLabel(widget.childData.genre)), ], ), ), ], ), ), - const SizedBox(height: 18), - - // Consentements - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AppCustomCheckbox( - label: 'Consentement photo', - value: widget.childData.photoConsent, - onChanged: (v) {}, // Readonly - checkboxSize: 22.0, - fontSize: 16.0, - ), - const SizedBox(width: 32), - AppCustomCheckbox( - label: 'Naissance multiple', - value: widget.childData.multipleBirth, - onChanged: (v) {}, // Readonly - checkboxSize: 22.0, - fontSize: 16.0, - ), - ], - ), ], ), ), @@ -396,8 +542,6 @@ class _ChildCardWidgetState extends State { /// Carte en mode readonly MOBILE avec hauteur adaptative Widget _buildReadonlyMobileCard(BuildContext context, DisplayConfig config) { - final File? currentChildImage = widget.childData.imageFile; - return Container( width: double.infinity, // Pas de height fixe @@ -452,14 +596,18 @@ class _ChildCardWidgetState extends State { ), child: ClipRRect( borderRadius: BorderRadius.circular(15), - child: currentChildImage != null - ? (kIsWeb - ? Image.network(currentChildImage.path, fit: BoxFit.cover) - : Image.file(currentChildImage, fit: BoxFit.cover)) + child: _hasChildPhoto(widget.childData) + ? _buildChildPhotoImage(widget.childData, fit: BoxFit.cover) : Image.asset('assets/images/photo.png', fit: BoxFit.contain), ), ), ), + const SizedBox(height: 10), + _buildPhotoConsentRow( + context: context, + config: config, + labelFontSize: 14.0, + ), const SizedBox(height: 16), // Champs @@ -471,37 +619,8 @@ class _ChildCardWidgetState extends State { widget.childData.isUnbornChild ? 'Date prévisionnelle :' : 'Date de naissance :', _dobController.text ), - const SizedBox(height: 16), - - // Consentements - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - AppCustomCheckbox( - label: 'Consentement photo', - value: widget.childData.photoConsent, - onChanged: (v) {}, - checkboxSize: 20.0, - fontSize: 14.0, - ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - AppCustomCheckbox( - label: 'Naissance multiple', - value: widget.childData.multipleBirth, - onChanged: (v) {}, - checkboxSize: 20.0, - fontSize: 14.0, - ), - ], - ), - ], - ), + const SizedBox(height: 8), + _buildReadonlyField('Genre :', _genreDisplayLabel(widget.childData.genre)), ], ), ), @@ -525,6 +644,111 @@ class _ChildCardWidgetState extends State { ); } + Widget _buildPhotoConsentRow({ + required BuildContext context, + required DisplayConfig config, + required double labelFontSize, + }) { + final readonly = config.isReadonly; + final primary = Theme.of(context).colorScheme.primary; + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Checkbox( + value: widget.childData.photoConsent, + onChanged: readonly + ? null + : (v) { + if (v != null) widget.onTogglePhotoConsent(v); + }, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + activeColor: primary, + ), + Flexible( + child: Text( + 'Consentement photo', + style: GoogleFonts.merienda(fontSize: labelFontSize), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 6), + Tooltip( + message: _photoConsentTooltip, + waitDuration: const Duration(milliseconds: 300), + triggerMode: TooltipTriggerMode.tap, + showDuration: const Duration(seconds: 6), + child: Icon( + Icons.info_outline, + size: labelFontSize * 1.2, + color: Colors.black54, + ), + ), + ], + ); + } + + static String _genreDisplayLabel(String genre) { + switch (genre) { + case 'F': + return 'Fille'; + case 'H': + return 'Garçon'; + case 'Autre': + return 'Inconnu'; + default: + return '—'; + } + } + + /// Fille / Garçon ; « Inconnu » (`Autre`) uniquement si enfant à naître. + Widget _buildGenreSegment(BuildContext context, DisplayConfig config, double scaleFactor) { + if (config.isReadonly) { + return const SizedBox.shrink(); + } + final selected = widget.childData.genre; + final isUnborn = widget.childData.isUnbornChild; + final fontSize = config.isMobile ? 13.0 : 14.0 * scaleFactor; + final padV = config.isMobile ? 6.0 : 8.0 * scaleFactor; + + Widget seg(String label, String api) { + final on = selected == api; + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 3), + child: OutlinedButton( + onPressed: () => widget.onGenreChanged(api), + style: OutlinedButton.styleFrom( + padding: EdgeInsets.symmetric(vertical: padV), + visualDensity: VisualDensity.compact, + backgroundColor: on ? Colors.black.withValues(alpha: 0.08) : Colors.transparent, + foregroundColor: Colors.black87, + side: BorderSide( + width: on ? 2 : 1, + color: on ? Theme.of(context).primaryColor : Colors.black38, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + child: Text( + label, + style: GoogleFonts.merienda(fontSize: fontSize, fontWeight: on ? FontWeight.w700 : FontWeight.w500), + textAlign: TextAlign.center, + ), + ), + ), + ); + } + + return Row( + children: [ + seg('Fille', 'F'), + seg('Garçon', 'H'), + if (isUnborn) seg('Inconnu', 'Autre'), + ], + ); + } + /// Helper pour champ Readonly style "Beige" Widget _buildReadonlyField(String label, String value) { return Column( @@ -566,6 +790,7 @@ class _ChildCardWidgetState extends State { bool readOnly = false, VoidCallback? onTap, IconData? suffixIcon, + FocusNode? focusNode, }) { if (config.isReadonly) { return FormFieldWrapper( @@ -576,6 +801,7 @@ class _ChildCardWidgetState extends State { } else { return CustomAppTextField( controller: controller, + focusNode: focusNode, labelText: label, hintText: hint ?? label, isRequired: isRequired, diff --git a/frontend/lib/widgets/common/auth_network_image.dart b/frontend/lib/widgets/common/auth_network_image.dart new file mode 100644 index 0000000..8562963 --- /dev/null +++ b/frontend/lib/widgets/common/auth_network_image.dart @@ -0,0 +1,130 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/services/api/tokenService.dart'; + +/// [Image.network] avec en-tête `Authorization` uniquement si l’URL n’est pas un fichier statique public. +/// +/// Les chemins `/uploads/...` sont servis sans auth (voir back + Traefik) : ne pas envoyer de Bearer, +/// sinon en **cross-origin** (ex. admin Flutter web en local, API en prod) le navigateur peut bloquer +/// la requête (CORS) et afficher l’icône « image cassée ». +class AuthNetworkImage extends StatefulWidget { + const AuthNetworkImage({ + super.key, + required this.url, + this.width, + this.height, + this.fit = BoxFit.cover, + this.loadingBuilder, + this.errorBuilder, + }); + + final String url; + final double? width; + final double? height; + final BoxFit fit; + final ImageLoadingBuilder? loadingBuilder; + final ImageErrorWidgetBuilder? errorBuilder; + + static bool isPublicUploadUrl(String url) { + final u = url.toLowerCase(); + return u.contains('/uploads/'); + } + + @override + State createState() => _AuthNetworkImageState(); +} + +class _AuthNetworkImageState extends State { + late final Future?> _headersFuture; + + @override + void initState() { + super.initState(); + final isPublic = AuthNetworkImage.isPublicUploadUrl(widget.url); + _headersFuture = + isPublic ? Future?>.value(null) : _loadHeaders(); + if (kDebugMode) { + debugPrint( + '[PetitsPas/image] préparation chargement url=${widget.url} | ' + 'uploadPublic=$isPublic | avecBearer=${!isPublic}', + ); + } + } + + static Future?> _loadHeaders() async { + final t = await TokenService.getToken(); + if (t == null || t.isEmpty) return null; + return {'Authorization': 'Bearer $t'}; + } + + ImageErrorWidgetBuilder _wrapErrorBuilder() { + return (BuildContext context, Object error, StackTrace? stackTrace) { + if (kDebugMode) { + debugPrint( + '[PetitsPas/image] ❌ échec chargement url=${widget.url} | erreur=$error', + ); + } + final inner = widget.errorBuilder; + if (inner != null) { + return inner(context, error, stackTrace); + } + return ColoredBox( + color: Colors.grey.shade200, + child: Center( + child: Icon(Icons.broken_image_outlined, color: Colors.grey.shade500), + ), + ); + }; + } + + @override + Widget build(BuildContext context) { + final err = _wrapErrorBuilder(); + + if (AuthNetworkImage.isPublicUploadUrl(widget.url)) { + return Image.network( + widget.url, + width: widget.width, + height: widget.height, + fit: widget.fit, + loadingBuilder: widget.loadingBuilder, + errorBuilder: err, + ); + } + + return FutureBuilder?>( + future: _headersFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return SizedBox( + width: widget.width, + height: widget.height, + child: ColoredBox( + color: Colors.grey.shade200, + child: const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ), + ); + } + final headers = snapshot.data; + if (kDebugMode && headers != null) { + debugPrint('[PetitsPas/image] requête avec en-tête Authorization (Bearer présent)'); + } + return Image.network( + widget.url, + width: widget.width, + height: widget.height, + fit: widget.fit, + headers: headers, + loadingBuilder: widget.loadingBuilder, + errorBuilder: err, + ); + }, + ); + } +} diff --git a/frontend/lib/widgets/custom_app_text_field.dart b/frontend/lib/widgets/custom_app_text_field.dart index 967865a..2db1ab3 100644 --- a/frontend/lib/widgets/custom_app_text_field.dart +++ b/frontend/lib/widgets/custom_app_text_field.dart @@ -32,6 +32,9 @@ class CustomAppTextField extends StatefulWidget { final TextInputAction? textInputAction; final ValueChanged? onFieldSubmitted; final List? inputFormatters; + final bool autocorrect; + final bool enableSuggestions; + final GlobalKey>? formFieldKey; const CustomAppTextField({ super.key, @@ -57,6 +60,9 @@ class CustomAppTextField extends StatefulWidget { this.textInputAction, this.onFieldSubmitted, this.inputFormatters, + this.autocorrect = true, + this.enableSuggestions = true, + this.formFieldKey, }); @override @@ -78,9 +84,13 @@ class _CustomAppTextFieldState extends State { @override Widget build(BuildContext context) { - const double fontHeightMultiplier = 1.2; - const double internalVerticalPadding = 16.0; final double dynamicFieldHeight = widget.fieldHeight; + // Indication « non éditable » : libellé + hint en gris. + final Color labelColor = + widget.enabled ? Colors.black87 : Colors.grey; + final Color hintColor = widget.enabled + ? Colors.black54.withValues(alpha: 0.7) + : Colors.grey; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -91,16 +101,18 @@ class _CustomAppTextFieldState extends State { widget.labelText, style: GoogleFonts.merienda( fontSize: widget.labelFontSize, - color: Colors.black87, + color: labelColor, fontWeight: FontWeight.w500, ), ), const SizedBox(height: 6), ], + // Pas de hauteur fixe sur le TextFormField : le message d’erreur du + // validateur s’affiche en dessous ; un SizedBox fixe le masquait. SizedBox( width: widget.fieldWidth, - height: dynamicFieldHeight, child: Stack( + clipBehavior: Clip.none, alignment: Alignment.centerLeft, children: [ Positioned.fill( @@ -112,48 +124,63 @@ class _CustomAppTextFieldState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 18.0, vertical: 8.0), - child: TextFormField( - controller: widget.controller, - focusNode: widget.focusNode, - obscureText: widget.obscureText, - keyboardType: widget.keyboardType, - inputFormatters: widget.inputFormatters, - autofillHints: widget.autofillHints, - textInputAction: widget.textInputAction, - onFieldSubmitted: widget.onFieldSubmitted, - enabled: widget.enabled, - readOnly: widget.readOnly, - onTap: widget.onTap, - style: GoogleFonts.merienda( - fontSize: widget.inputFontSize, - color: widget.enabled ? Colors.black87 : Colors.grey), - validator: widget.validator ?? - (value) { - if (!widget.enabled || widget.readOnly) return null; - if (widget.isRequired && - (value == null || value.isEmpty)) { - return 'Ce champ est obligatoire'; - } - return null; - }, - decoration: InputDecoration( - hintText: widget.hintText, - hintStyle: GoogleFonts.merienda( - fontSize: widget.inputFontSize, - color: Colors.black54.withOpacity(0.7)), - border: InputBorder.none, - contentPadding: EdgeInsets.zero, - suffixIcon: widget.suffixIcon != null - ? Padding( - padding: const EdgeInsets.only(right: 0.0), - child: Icon(widget.suffixIcon, - color: Colors.black54, - size: widget.inputFontSize * 1.1), - ) - : null, - isDense: true, + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: + (dynamicFieldHeight - 16.0).clamp(24.0, double.infinity), + ), + child: TextFormField( + key: widget.formFieldKey, + controller: widget.controller, + focusNode: widget.focusNode, + obscureText: widget.obscureText, + keyboardType: widget.keyboardType, + autocorrect: widget.autocorrect, + enableSuggestions: widget.enableSuggestions, + inputFormatters: widget.inputFormatters, + autofillHints: widget.autofillHints, + textInputAction: widget.textInputAction, + onFieldSubmitted: widget.onFieldSubmitted, + enabled: widget.enabled, + readOnly: widget.readOnly, + onTap: widget.onTap, + style: GoogleFonts.merienda( + fontSize: widget.inputFontSize, + color: Colors.black87), + validator: widget.validator ?? + (value) { + if (!widget.enabled || widget.readOnly) return null; + if (widget.isRequired && + (value == null || value.isEmpty)) { + return 'Ce champ est obligatoire'; + } + return null; + }, + decoration: InputDecoration( + hintText: widget.hintText, + hintStyle: GoogleFonts.merienda( + fontSize: widget.inputFontSize, + color: hintColor), + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + isDense: true, + errorStyle: GoogleFonts.merienda( + fontSize: widget.inputFontSize * 0.75, + color: Colors.red.shade800, + height: 1.2, + ), + errorMaxLines: 3, + suffixIcon: widget.suffixIcon != null + ? Padding( + padding: const EdgeInsets.only(right: 0.0), + child: Icon(widget.suffixIcon, + color: Colors.black54, + size: widget.inputFontSize * 1.1), + ) + : null, + ), + textAlignVertical: TextAlignVertical.center, ), - textAlignVertical: TextAlignVertical.center, ), ), ], diff --git a/frontend/lib/widgets/email_text_field.dart b/frontend/lib/widgets/email_text_field.dart new file mode 100644 index 0000000..2792702 --- /dev/null +++ b/frontend/lib/widgets/email_text_field.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../utils/email_utils.dart'; +import 'custom_app_text_field.dart'; + +/// [TextFormField] e-mail : à la perte de focus → minuscules + validation immédiate. +class EmailTextFormField extends StatefulWidget { + const EmailTextFormField({ + super.key, + required this.controller, + this.decoration, + this.label, + this.hint, + this.allowEmpty = false, + this.readOnly = false, + this.focusNode, + this.autovalidateMode, + this.validator, + }); + + final TextEditingController controller; + final InputDecoration? decoration; + final String? label; + final String? hint; + final bool allowEmpty; + final bool readOnly; + final FocusNode? focusNode; + final AutovalidateMode? autovalidateMode; + final String? Function(String?)? validator; + + @override + State createState() => _EmailTextFormFieldState(); +} + +class _EmailTextFormFieldState extends State { + final GlobalKey> _fieldKey = + GlobalKey>(); + late final FocusNode _focusNode; + late final bool _ownsFocusNode; + + @override + void initState() { + super.initState(); + _ownsFocusNode = widget.focusNode == null; + _focusNode = widget.focusNode ?? FocusNode(); + _focusNode.addListener(_onFocusChange); + } + + void _onFocusChange() { + if (_focusNode.hasFocus || widget.readOnly) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _focusNode.hasFocus) { + return; + } + final c = widget.controller; + final normalized = normalizeEmailText(c.text); + if (normalized != c.text) { + c.value = TextEditingValue( + text: normalized, + selection: TextSelection.collapsed(offset: normalized.length), + ); + } + _fieldKey.currentState?.validate(); + }); + } + + @override + void dispose() { + _focusNode.removeListener(_onFocusChange); + if (_ownsFocusNode) { + _focusNode.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final deco = widget.decoration ?? + InputDecoration( + labelText: widget.label, + hintText: widget.hint, + border: const OutlineInputBorder(), + ); + + return TextFormField( + key: _fieldKey, + controller: widget.controller, + focusNode: _focusNode, + readOnly: widget.readOnly, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + autocorrect: false, + enableSuggestions: false, + autofillHints: const [AutofillHints.email], + inputFormatters: const [EmailMaxLengthFormatter()], + autovalidateMode: widget.autovalidateMode, + decoration: deco, + validator: widget.readOnly + ? null + : (widget.validator ?? + (value) => validateEmail(value, allowEmpty: widget.allowEmpty)), + ); + } +} + +/// Même logique que [EmailTextFormField] avec le style [CustomAppTextField]. +class EmailCustomTextField extends StatefulWidget { + const EmailCustomTextField({ + super.key, + required this.controller, + required this.labelText, + this.hintText = '', + this.focusNode, + this.fieldWidth = double.infinity, + this.fieldHeight = 53.0, + this.labelFontSize = 22.0, + this.inputFontSize = 20.0, + this.enabled = true, + this.readOnly = false, + this.allowEmpty = false, + this.style = CustomAppTextFieldStyle.beige, + this.validator, + this.textInputAction, + this.onFieldSubmitted, + }); + + final TextEditingController controller; + final String labelText; + final String hintText; + final FocusNode? focusNode; + final double fieldWidth; + final double fieldHeight; + final double labelFontSize; + final double inputFontSize; + final bool enabled; + final bool readOnly; + final bool allowEmpty; + final CustomAppTextFieldStyle style; + final String? Function(String?)? validator; + final TextInputAction? textInputAction; + final ValueChanged? onFieldSubmitted; + + @override + State createState() => _EmailCustomTextFieldState(); +} + +class _EmailCustomTextFieldState extends State { + final GlobalKey> _fieldKey = + GlobalKey>(); + late final FocusNode _focusNode; + late final bool _ownsFocusNode; + + @override + void initState() { + super.initState(); + _ownsFocusNode = widget.focusNode == null; + _focusNode = widget.focusNode ?? FocusNode(); + _focusNode.addListener(_onFocusChange); + } + + void _onFocusChange() { + if (_focusNode.hasFocus || widget.readOnly || !widget.enabled) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _focusNode.hasFocus) { + return; + } + final c = widget.controller; + final normalized = normalizeEmailText(c.text); + if (normalized != c.text) { + c.value = TextEditingValue( + text: normalized, + selection: TextSelection.collapsed(offset: normalized.length), + ); + } + _fieldKey.currentState?.validate(); + }); + } + @override + void dispose() { + _focusNode.removeListener(_onFocusChange); + if (_ownsFocusNode) { + _focusNode.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return CustomAppTextField( + formFieldKey: _fieldKey, + controller: widget.controller, + focusNode: _focusNode, + labelText: widget.labelText, + hintText: widget.hintText, + style: widget.style, + fieldWidth: widget.fieldWidth, + fieldHeight: widget.fieldHeight, + labelFontSize: widget.labelFontSize, + inputFontSize: widget.inputFontSize, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + enableSuggestions: false, + enabled: widget.enabled, + readOnly: widget.readOnly, + autofillHints: const [AutofillHints.email], + textInputAction: widget.textInputAction ?? TextInputAction.next, + onFieldSubmitted: widget.onFieldSubmitted, + inputFormatters: const [EmailMaxLengthFormatter()], + validator: widget.validator ?? + ((v) => validateEmail(v, allowEmpty: widget.allowEmpty)), + isRequired: false, + ); + } +} diff --git a/frontend/lib/widgets/french_phone_field.dart b/frontend/lib/widgets/french_phone_field.dart new file mode 100644 index 0000000..011150e --- /dev/null +++ b/frontend/lib/widgets/french_phone_field.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; + +import '../utils/phone_utils.dart'; +import 'custom_app_text_field.dart'; + +/// [TextFormField] téléphone France : formatage (0 automatique si 1–7, etc.) + validation optionnelle. +/// +/// Préférer ce widget ou [frenchPhoneInputFormatters] + [validateFrenchNationalPhone] pour tout nouveau formulaire. +class FrenchPhoneTextFormField extends StatelessWidget { + const FrenchPhoneTextFormField({ + super.key, + required this.controller, + this.decoration, + this.label, + this.hint, + this.allowEmpty = false, + this.readOnly = false, + this.focusNode, + this.autovalidateMode, + this.validator, + }); + + final TextEditingController controller; + final InputDecoration? decoration; + final String? label; + final String? hint; + final bool allowEmpty; + final bool readOnly; + final FocusNode? focusNode; + final AutovalidateMode? autovalidateMode; + final String? Function(String?)? validator; + + @override + Widget build(BuildContext context) { + final deco = decoration ?? + InputDecoration( + labelText: label, + hintText: hint, + border: const OutlineInputBorder(), + ); + + return TextFormField( + controller: controller, + focusNode: focusNode, + readOnly: readOnly, + keyboardType: TextInputType.phone, + inputFormatters: frenchPhoneInputFormatters, + autovalidateMode: autovalidateMode, + decoration: deco, + validator: readOnly + ? null + : (validator ?? + (value) => validateFrenchNationalPhone(value, allowEmpty: allowEmpty)), + ); + } +} + +/// Même logique que [FrenchPhoneTextFormField], avec le rendu [CustomAppTextField] (inscription, cartes, etc.). +class FrenchPhoneCustomTextField extends StatelessWidget { + const FrenchPhoneCustomTextField({ + super.key, + required this.controller, + required this.labelText, + this.hintText = '', + this.focusNode, + this.fieldWidth = double.infinity, + this.fieldHeight = 53.0, + this.labelFontSize = 22.0, + this.inputFontSize = 20.0, + this.enabled = true, + this.readOnly = false, + this.allowEmpty = false, + this.style = CustomAppTextFieldStyle.beige, + this.validator, + this.suffixIcon, + this.onTap, + this.autofillHints, + this.textInputAction, + this.onFieldSubmitted, + }); + + final TextEditingController controller; + final String labelText; + final String hintText; + final FocusNode? focusNode; + final double fieldWidth; + final double fieldHeight; + final double labelFontSize; + final double inputFontSize; + final bool enabled; + final bool readOnly; + final bool allowEmpty; + final CustomAppTextFieldStyle style; + final String? Function(String?)? validator; + final IconData? suffixIcon; + final VoidCallback? onTap; + final Iterable? autofillHints; + final TextInputAction? textInputAction; + final ValueChanged? onFieldSubmitted; + + @override + Widget build(BuildContext context) { + return CustomAppTextField( + controller: controller, + focusNode: focusNode, + labelText: labelText, + hintText: hintText, + style: style, + fieldWidth: fieldWidth, + fieldHeight: fieldHeight, + labelFontSize: labelFontSize, + inputFontSize: inputFontSize, + keyboardType: TextInputType.phone, + enabled: enabled, + readOnly: readOnly, + onTap: onTap, + suffixIcon: suffixIcon, + inputFormatters: frenchPhoneInputFormatters, + autofillHints: autofillHints, + textInputAction: textInputAction, + onFieldSubmitted: onFieldSubmitted, + validator: validator ?? + ((v) => validateFrenchNationalPhone(v, allowEmpty: allowEmpty)), + isRequired: false, + ); + } +} diff --git a/frontend/lib/widgets/hover_relief_widget.dart b/frontend/lib/widgets/hover_relief_widget.dart index cee1f79..caa67e8 100644 --- a/frontend/lib/widgets/hover_relief_widget.dart +++ b/frontend/lib/widgets/hover_relief_widget.dart @@ -10,6 +10,8 @@ class HoverReliefWidget extends StatefulWidget { final bool enableHoverEffect; // Pour activer/désactiver l'effet de survol final Color initialShadowColor; // Nouveau paramètre final Color hoverShadowColor; // Nouveau paramètre + /// `Clip.none` : ne pas découper le child (ex. trou du cadre PNG par-dessus la photo). + final Clip clipBehavior; const HoverReliefWidget({ required this.child, @@ -21,6 +23,7 @@ class HoverReliefWidget extends StatefulWidget { this.enableHoverEffect = true, // Par défaut, l'effet est activé this.initialShadowColor = const Color(0x26000000), // Default: Colors.black.withOpacity(0.15) this.hoverShadowColor = const Color(0x4D000000), // Default: Colors.black.withOpacity(0.3) + this.clipBehavior = Clip.antiAlias, super.key, }); @@ -49,7 +52,7 @@ class _HoverReliefWidgetState extends State { elevation: elevation, shadowColor: shadowColor, borderRadius: widget.borderRadius, - clipBehavior: Clip.antiAlias, + clipBehavior: widget.clipBehavior, child: widget.child, ), ); @@ -62,7 +65,7 @@ class _HoverReliefWidgetState extends State { elevation: widget.initialElevation, // Utilise l'élévation initiale shadowColor: widget.initialShadowColor, // Appliqué ici pour l'état non cliquable borderRadius: widget.borderRadius, - clipBehavior: Clip.antiAlias, + clipBehavior: widget.clipBehavior, child: widget.child, ); } diff --git a/frontend/lib/widgets/personal_info_form_screen.dart b/frontend/lib/widgets/personal_info_form_screen.dart index 7e19729..181cc82 100644 --- a/frontend/lib/widgets/personal_info_form_screen.dart +++ b/frontend/lib/widgets/personal_info_form_screen.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/utils/name_format_utils.dart'; +import 'package:p_tits_pas/utils/email_utils.dart'; +import 'package:p_tits_pas/utils/postal_utils.dart'; import 'package:go_router/go_router.dart'; import 'dart:math' as math; @@ -76,6 +79,19 @@ class PersonalInfoFormScreen extends StatefulWidget { } class _PersonalInfoFormScreenState extends State { + /// Ordre de tabulation explicite (étape 1 / parent 2, etc.) + static const double _focusSecondPersonToggle = 1; + static const double _focusSameAddressToggle = 2; + static const double _focusLastName = 10; + static const double _focusFirstName = 11; + static const double _focusPhone = 12; + static const double _focusEmail = 13; + static const double _focusAddress = 14; + static const double _focusPostal = 15; + static const double _focusCity = 16; + static const double _focusNavPrevious = 100; + static const double _focusNavNext = 101; + final _formKey = GlobalKey(); late TextEditingController _lastNameController; late TextEditingController _firstNameController; @@ -89,13 +105,22 @@ class _PersonalInfoFormScreenState extends State { bool _sameAddress = false; bool _fieldsEnabled = true; + FocusNode? _lastNameFocus; + FocusNode? _firstNameFocus; + FocusNode? _cityFocus; + FocusNode? _emailFocus; + final GlobalKey> _emailFormKey = + GlobalKey>(); + @override void initState() { super.initState(); _lastNameController = TextEditingController(text: widget.initialData.lastName); _firstNameController = TextEditingController(text: widget.initialData.firstName); _phoneController = TextEditingController(text: formatPhoneForDisplay(widget.initialData.phone)); - _emailController = TextEditingController(text: widget.initialData.email); + _emailController = TextEditingController( + text: normalizeEmailText(widget.initialData.email), + ); _addressController = TextEditingController(text: widget.initialData.address); _postalCodeController = TextEditingController(text: widget.initialData.postalCode); _cityController = TextEditingController(text: widget.initialData.city); @@ -109,10 +134,147 @@ class _PersonalInfoFormScreenState extends State { _sameAddress = widget.initialSameAddress ?? false; _updateAddressFields(); } + + if (widget.mode == DisplayMode.editable) { + _lastNameFocus = FocusNode(); + _firstNameFocus = FocusNode(); + _cityFocus = FocusNode(); + _emailFocus = FocusNode(); + _lastNameFocus!.addListener(_onLastNameFocusChange); + _firstNameFocus!.addListener(_onFirstNameFocusChange); + _cityFocus!.addListener(_onCityFocusChange); + _emailFocus!.addListener(_onEmailFocusChange); + } + } + + void _onLastNameFocusChange() { + if (_lastNameFocus == null || _lastNameFocus!.hasFocus) { + return; + } + _applyPersonNameFormat(_lastNameController); + } + + void _onFirstNameFocusChange() { + if (_firstNameFocus == null || _firstNameFocus!.hasFocus) { + return; + } + _applyPersonNameFormat(_firstNameController); + } + + void _onCityFocusChange() { + if (_cityFocus == null || _cityFocus!.hasFocus) { + return; + } + _applyPersonNameFormat(_cityController); + } + + void _onEmailFocusChange() { + if (_emailFocus == null || _emailFocus!.hasFocus) { + return; + } + if (widget.showSecondPersonToggle && !_fieldsEnabled) { + return; + } + // Reporter normalisation + validate au frame suivant pour ne pas casser Tab. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _emailFocus == null || _emailFocus!.hasFocus) { + return; + } + if (widget.showSecondPersonToggle && !_fieldsEnabled) { + return; + } + final normalized = normalizeEmailText(_emailController.text); + if (normalized != _emailController.text) { + _emailController.value = TextEditingValue( + text: normalized, + selection: TextSelection.collapsed(offset: normalized.length), + ); + } + // Pas d’erreur « obligatoire » au blur si le champ est encore vide : + // évite un message dès l’activation du parent 2 (focus / rebuild) et + // la soumission du formulaire valide toujours. + if (normalizeEmailText(_emailController.text).isEmpty) { + return; + } + _emailFormKey.currentState?.validate(); + }); + } + + String? _validateRequiredFrenchPhone(String? value) { + if (value == null || value.trim().isEmpty) { + return 'Ce champ est obligatoire'; + } + return validateFrenchNationalPhone(value, allowEmpty: false); + } + + String? _validateRequiredEmail(String? value) { + if (value == null || value.trim().isEmpty) { + return 'Ce champ est obligatoire.'; + } + return validateEmail(value, allowEmpty: true); + } + + /// Téléphone / e-mail du parent 2 : pas de validation si « Ajouter Parent 2 » est désactivé. + String? _validateFrenchPhoneIfSecondParentNeeded(String? value) { + if (widget.showSecondPersonToggle && !_fieldsEnabled) { + return null; + } + return _validateRequiredFrenchPhone(value); + } + + String? _validateEmailIfSecondParentNeeded(String? value) { + if (widget.showSecondPersonToggle && !_fieldsEnabled) { + return null; + } + return _validateRequiredEmail(value); + } + + /// Adresse / code postal / ville : pas de contrôle si « Même adresse » (valeurs prises du parent 1). + String? _validateAddressLineIfManualEntry(String? value) { + if (!_fieldsEnabled) { + return null; + } + if (widget.showSameAddressCheckbox && _sameAddress) { + return null; + } + if (value == null || value.trim().isEmpty) { + return 'Ce champ est obligatoire'; + } + return null; + } + + /// Code postal : 5 chiffres ; ignoré si parent 2 désactivé ou « Même adresse ». + String? _validatePostalCodeField(String? value) { + if (!_fieldsEnabled) { + return null; + } + if (widget.showSameAddressCheckbox && _sameAddress) { + return null; + } + return validateFrenchPostalCode(value, allowEmpty: false); + } + + void _applyPersonNameFormat(TextEditingController controller) { + final formatted = formatPersonNameCase(controller.text); + if (formatted == controller.text) { + return; + } + controller.value = TextEditingValue( + text: formatted, + selection: TextSelection.collapsed(offset: formatted.length), + ); } @override void dispose() { + _lastNameFocus?.removeListener(_onLastNameFocusChange); + _firstNameFocus?.removeListener(_onFirstNameFocusChange); + _cityFocus?.removeListener(_onCityFocusChange); + _emailFocus?.removeListener(_onEmailFocusChange); + _lastNameFocus?.dispose(); + _firstNameFocus?.dispose(); + _cityFocus?.dispose(); + _emailFocus?.dispose(); _lastNameController.dispose(); _firstNameController.dispose(); _phoneController.dispose(); @@ -131,13 +293,50 @@ class _PersonalInfoFormScreenState extends State { } } + Widget _wrapScrollBodyIfEditable({ + required DisplayConfig config, + required Widget child, + }) { + if (!config.isEditable) return child; + return FocusTraversalGroup( + policy: OrderedTraversalPolicy(), + child: child, + ); + } + + Widget _orderedFocus({ + required bool enabled, + required double order, + required Widget child, + }) { + if (!enabled) return child; + return FocusTraversalOrder( + order: NumericFocusOrder(order), + child: child, + ); + } + void _handleSubmit() { + if (widget.mode == DisplayMode.editable) { + _applyPersonNameFormat(_lastNameController); + _applyPersonNameFormat(_firstNameController); + _applyPersonNameFormat(_cityController); + } + // Parent 2 désactivé : pas de contrôle sur les champs (désactivés à l’écran). + if (widget.showSecondPersonToggle && !_hasSecondPerson) { + widget.onSubmit( + PersonalInfoData(), + hasSecondPerson: false, + sameAddress: widget.showSameAddressCheckbox ? _sameAddress : null, + ); + return; + } if (widget.mode == DisplayMode.readonly || _formKey.currentState!.validate()) { final data = PersonalInfoData( firstName: _firstNameController.text, lastName: _lastNameController.text, phone: normalizePhone(_phoneController.text), - email: _emailController.text, + email: normalizeEmailText(_emailController.text), address: _addressController.text, postalCode: _postalCodeController.text, city: _cityController.text, @@ -169,75 +368,84 @@ class _PersonalInfoFormScreenState extends State { Center( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(vertical: 40.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - widget.stepText, - style: GoogleFonts.merienda( - fontSize: config.isMobile ? 13 : 16, - color: Colors.black54, - ), - ), - SizedBox(height: config.isMobile ? 6 : 10), - Text( - widget.title, - style: GoogleFonts.merienda( - fontSize: config.isMobile ? 18 : 24, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), - textAlign: TextAlign.center, - ), - SizedBox(height: config.isMobile ? 16 : 30), - _buildCard(context, config, screenSize), - - // Boutons mobile sous la carte (dans le scroll) - if (config.isMobile) ...[ - const SizedBox(height: 20), - Padding( - padding: EdgeInsets.symmetric( - horizontal: screenSize.width * 0.05, // Même marge que la carte (0.9 = 0.05 de chaque côté) - ), - child: Row( - children: [ - Expanded( - child: HoverReliefWidget( - child: CustomNavigationButton( - text: 'Précédent', - style: NavigationButtonStyle.purple, - onPressed: () { - if (context.canPop()) { - context.pop(); - } else { - context.go(widget.previousRoute); - } - }, - width: double.infinity, - height: 50, - fontSize: 16, - ), - ), - ), - const SizedBox(width: 16), // Écart entre les boutons - Expanded( - child: HoverReliefWidget( - child: CustomNavigationButton( - text: 'Suivant', - style: NavigationButtonStyle.green, - onPressed: _handleSubmit, - width: double.infinity, - height: 50, - fontSize: 16, - ), - ), - ), - ], + child: _wrapScrollBodyIfEditable( + config: config, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + widget.stepText, + style: GoogleFonts.merienda( + fontSize: config.isMobile ? 13 : 16, + color: Colors.black54, ), ), - const SizedBox(height: 10), + SizedBox(height: config.isMobile ? 6 : 10), + Text( + widget.title, + style: GoogleFonts.merienda( + fontSize: config.isMobile ? 18 : 24, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: config.isMobile ? 16 : 30), + _buildCard(context, config, screenSize), + if (config.isMobile) ...[ + const SizedBox(height: 20), + Padding( + padding: EdgeInsets.symmetric( + horizontal: screenSize.width * 0.05, + ), + child: Row( + children: [ + Expanded( + child: _orderedFocus( + enabled: config.isEditable, + order: _focusNavPrevious, + child: HoverReliefWidget( + child: CustomNavigationButton( + text: 'Précédent', + style: NavigationButtonStyle.purple, + onPressed: () { + if (context.canPop()) { + context.pop(); + } else { + context.go(widget.previousRoute); + } + }, + width: double.infinity, + height: 50, + fontSize: 16, + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: _orderedFocus( + enabled: config.isEditable, + order: _focusNavNext, + child: HoverReliefWidget( + child: CustomNavigationButton( + text: 'Suivant', + style: NavigationButtonStyle.green, + onPressed: _handleSubmit, + width: double.infinity, + height: 50, + fontSize: 16, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 10), + ], ], - ], + ), ), ), ), @@ -522,17 +730,34 @@ class _PersonalInfoFormScreenState extends State { overflow: TextOverflow.ellipsis, ), ), - Transform.scale( - scale: config.isMobile ? 0.85 : 1.0, - child: Switch( - value: _hasSecondPerson, - onChanged: (value) { - setState(() { - _hasSecondPerson = value; - _fieldsEnabled = value; - }); - }, - activeColor: Theme.of(context).primaryColor, + _orderedFocus( + enabled: config.isEditable, + order: _focusSecondPersonToggle, + child: Transform.scale( + scale: config.isMobile ? 0.85 : 1.0, + child: Switch( + value: _hasSecondPerson, + onChanged: (value) { + setState(() { + _hasSecondPerson = value; + _fieldsEnabled = value; + if (value && _sameAddress) { + _updateAddressFields(); + } + }); + if (!value) { + WidgetsBinding.instance.addPostFrameCallback((_) { + // Si l’utilisateur a déjà recoché Parent 2, ne pas valider : + // sinon des champs vides affichent une erreur tout de suite. + if (!mounted || _hasSecondPerson) { + return; + } + _formKey.currentState?.validate(); + }); + } + }, + activeColor: Theme.of(context).primaryColor, + ), ), ), ], @@ -558,17 +783,21 @@ class _PersonalInfoFormScreenState extends State { overflow: TextOverflow.ellipsis, ), ), - Transform.scale( - scale: config.isMobile ? 0.85 : 1.0, - child: Switch( - value: _sameAddress, - onChanged: _fieldsEnabled ? (value) { - setState(() { - _sameAddress = value; - _updateAddressFields(); - }); - } : null, - activeColor: Theme.of(context).primaryColor, + _orderedFocus( + enabled: config.isEditable, + order: _focusSameAddressToggle, + child: Transform.scale( + scale: config.isMobile ? 0.85 : 1.0, + child: Switch( + value: _sameAddress, + onChanged: _fieldsEnabled ? (value) { + setState(() { + _sameAddress = value; + _updateAddressFields(); + }); + } : null, + activeColor: Theme.of(context).primaryColor, + ), ), ), ], @@ -606,6 +835,8 @@ class _PersonalInfoFormScreenState extends State { controller: _lastNameController, hint: 'Votre nom de famille', enabled: _fieldsEnabled, + focusOrder: _focusLastName, + focusNode: _lastNameFocus, ), ), const SizedBox(width: 20), @@ -616,6 +847,8 @@ class _PersonalInfoFormScreenState extends State { controller: _firstNameController, hint: 'Votre prénom', enabled: _fieldsEnabled, + focusOrder: _focusFirstName, + focusNode: _firstNameFocus, ), ), ], @@ -633,7 +866,9 @@ class _PersonalInfoFormScreenState extends State { hint: 'Votre numéro de téléphone', keyboardType: TextInputType.phone, enabled: _fieldsEnabled, - inputFormatters: _phoneInputFormatters, + inputFormatters: frenchPhoneInputFormatters, + focusOrder: _focusPhone, + fieldValidator: _validateFrenchPhoneIfSecondParentNeeded, ), ), const SizedBox(width: 20), @@ -645,6 +880,11 @@ class _PersonalInfoFormScreenState extends State { hint: 'Votre adresse e-mail', keyboardType: TextInputType.emailAddress, enabled: _fieldsEnabled, + focusOrder: _focusEmail, + fieldValidator: _validateEmailIfSecondParentNeeded, + inputFormatters: const [EmailMaxLengthFormatter()], + focusNode: _emailFocus, + formFieldKey: _emailFormKey, ), ), ], @@ -658,6 +898,10 @@ class _PersonalInfoFormScreenState extends State { controller: _addressController, hint: 'Numéro et nom de votre rue', enabled: _fieldsEnabled && !_sameAddress, + focusOrder: _focusAddress, + fieldValidator: widget.showSameAddressCheckbox + ? _validateAddressLineIfManualEntry + : null, ), SizedBox(height: verticalSpacing), @@ -670,9 +914,12 @@ class _PersonalInfoFormScreenState extends State { config: config, label: 'Code Postal', controller: _postalCodeController, - hint: 'Code postal', + hint: '5 chiffres', keyboardType: TextInputType.number, enabled: _fieldsEnabled && !_sameAddress, + focusOrder: _focusPostal, + fieldValidator: _validatePostalCodeField, + inputFormatters: kFrenchPostalCodeInputFormatters, ), ), const SizedBox(width: 20), @@ -684,6 +931,11 @@ class _PersonalInfoFormScreenState extends State { controller: _cityController, hint: 'Votre ville', enabled: _fieldsEnabled && !_sameAddress, + focusOrder: _focusCity, + focusNode: _cityFocus, + fieldValidator: widget.showSameAddressCheckbox + ? _validateAddressLineIfManualEntry + : null, ), ), ], @@ -852,6 +1104,8 @@ class _PersonalInfoFormScreenState extends State { controller: _lastNameController, hint: 'Votre nom de famille', enabled: _fieldsEnabled, + focusOrder: _focusLastName, + focusNode: _lastNameFocus, ), const SizedBox(height: 12), @@ -862,6 +1116,8 @@ class _PersonalInfoFormScreenState extends State { controller: _firstNameController, hint: 'Votre prénom', enabled: _fieldsEnabled, + focusOrder: _focusFirstName, + focusNode: _firstNameFocus, ), const SizedBox(height: 12), @@ -873,7 +1129,9 @@ class _PersonalInfoFormScreenState extends State { hint: 'Votre numéro de téléphone', keyboardType: TextInputType.phone, enabled: _fieldsEnabled, - inputFormatters: _phoneInputFormatters, + inputFormatters: frenchPhoneInputFormatters, + focusOrder: _focusPhone, + fieldValidator: _validateFrenchPhoneIfSecondParentNeeded, ), const SizedBox(height: 12), @@ -885,6 +1143,11 @@ class _PersonalInfoFormScreenState extends State { hint: 'Votre adresse e-mail', keyboardType: TextInputType.emailAddress, enabled: _fieldsEnabled, + focusOrder: _focusEmail, + fieldValidator: _validateEmailIfSecondParentNeeded, + inputFormatters: const [EmailMaxLengthFormatter()], + focusNode: _emailFocus, + formFieldKey: _emailFormKey, ), const SizedBox(height: 12), @@ -895,6 +1158,10 @@ class _PersonalInfoFormScreenState extends State { controller: _addressController, hint: 'Numéro et nom de votre rue', enabled: _fieldsEnabled && !_sameAddress, + focusOrder: _focusAddress, + fieldValidator: widget.showSameAddressCheckbox + ? _validateAddressLineIfManualEntry + : null, ), const SizedBox(height: 12), @@ -903,9 +1170,12 @@ class _PersonalInfoFormScreenState extends State { config: config, label: 'Code Postal', controller: _postalCodeController, - hint: 'Code postal', + hint: '5 chiffres', keyboardType: TextInputType.number, enabled: _fieldsEnabled && !_sameAddress, + focusOrder: _focusPostal, + fieldValidator: _validatePostalCodeField, + inputFormatters: kFrenchPostalCodeInputFormatters, ), const SizedBox(height: 12), @@ -916,6 +1186,11 @@ class _PersonalInfoFormScreenState extends State { controller: _cityController, hint: 'Votre ville', enabled: _fieldsEnabled && !_sameAddress, + focusOrder: _focusCity, + focusNode: _cityFocus, + fieldValidator: widget.showSameAddressCheckbox + ? _validateAddressLineIfManualEntry + : null, ), ], ); @@ -930,6 +1205,10 @@ class _PersonalInfoFormScreenState extends State { TextInputType? keyboardType, bool enabled = true, List? inputFormatters, + double? focusOrder, + FocusNode? focusNode, + GlobalKey>? formFieldKey, + String? Function(String?)? fieldValidator, }) { if (config.isReadonly) { // Mode readonly : utiliser FormFieldWrapper (téléphone formaté pour affichage) @@ -942,9 +1221,12 @@ class _PersonalInfoFormScreenState extends State { value: displayValue, ); } else { - // Mode éditable : style adapté mobile/desktop - return CustomAppTextField( + final effectiveKeyboardType = keyboardType ?? TextInputType.text; + final isEmail = effectiveKeyboardType == TextInputType.emailAddress; + Widget field = CustomAppTextField( + formFieldKey: formFieldKey, controller: controller, + focusNode: focusNode, labelText: label, hintText: hint ?? label, style: CustomAppTextFieldStyle.beige, @@ -952,10 +1234,23 @@ class _PersonalInfoFormScreenState extends State { fieldHeight: config.isMobile ? 45.0 : 53.0, labelFontSize: config.isMobile ? 15.0 : 22.0, inputFontSize: config.isMobile ? 14.0 : 20.0, - keyboardType: keyboardType ?? TextInputType.text, + keyboardType: effectiveKeyboardType, + autocorrect: !isEmail, + enableSuggestions: !isEmail, + autofillHints: isEmail ? const [AutofillHints.email] : null, + textInputAction: isEmail ? TextInputAction.next : null, enabled: enabled, inputFormatters: inputFormatters, + validator: fieldValidator, + isRequired: fieldValidator == null, ); + if (focusOrder != null) { + field = FocusTraversalOrder( + order: NumericFocusOrder(focusOrder), + child: field, + ); + } + return field; } } diff --git a/scripts/register-parent-durand-rousseau-test.mjs b/scripts/register-parent-durand-rousseau-test.mjs new file mode 100644 index 0000000..ece7507 --- /dev/null +++ b/scripts/register-parent-durand-rousseau-test.mjs @@ -0,0 +1,156 @@ +/** + * POST /api/v1/auth/register/parent — jeu de test officiel couple DURAND / ROUSSEAU (docs/test-data + seed). + * Emails : amelie.durand@ptits-pas.fr, julien.rousseau@ptits-pas.fr + * + * Les PNG dans ressources/Photos dépassent la limite JSON (~15 Mo) du serveur : réduction locale + * via npx sharp-cli (resize 600 + JPEG q80) avant encodage base64. + * + * Usage : node scripts/register-parent-durand-rousseau-test.mjs [BASE_URL] + */ + +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import https from 'https'; +import http from 'http'; +import { execSync } from 'child_process'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.join(__dirname, '..'); +const photosDir = path.join(repoRoot, 'ressources', 'Photos'); + +function shrinkToJpeg(inputPath, label) { + const out = path.join(os.tmpdir(), `ptitspas-${label}-${Date.now()}.jpg`); + const cmd = `npx --yes sharp-cli -i ${JSON.stringify(inputPath)} -o ${JSON.stringify(out)} -mq80 resize 600 600`; + execSync(cmd, { cwd: repoRoot, stdio: 'inherit', shell: true }); + return out; +} + +function toDataUri(filePath) { + const buf = fs.readFileSync(filePath); + const ext = path.extname(filePath).toLowerCase(); + const mime = + ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : 'image/png'; + return `data:${mime};base64,${buf.toString('base64')}`; +} + +const presentationDossier = + "Nous sommes Amélie DURAND et Julien ROUSSEAU, parents de Chloé et Hugo. " + + "Nous sommes divorcés ; Amélie assure la garde principale et nous pratiquons la garde alternée un week-end sur deux. " + + "Nous recherchons une assistante maternelle à Bezons pour accueillir nos enfants dans un cadre bienveillant et stable. " + + "Merci pour l'étude de notre dossier."; + +const chloeSrc = path.join(photosDir, 'G_Chloé.png'); +const hugoSrc = path.join(photosDir, 'G_Hugo.png'); + +let chloeJpg; +let hugoJpg; +try { + console.error('Réduction des photos (sharp-cli)…'); + chloeJpg = shrinkToJpeg(chloeSrc, 'chloe'); + hugoJpg = shrinkToJpeg(hugoSrc, 'hugo'); + + const body = { + email: 'amelie.durand@ptits-pas.fr', + prenom: 'Amélie', + nom: 'DURAND', + telephone: '0667788990', + adresse: '23 Rue Victor Hugo', + code_postal: '95870', + ville: 'Bezons', + co_parent_email: 'julien.rousseau@ptits-pas.fr', + co_parent_prenom: 'Julien', + co_parent_nom: 'ROUSSEAU', + co_parent_telephone: '0656677889', + co_parent_meme_adresse: false, + co_parent_adresse: '14 Rue Pasteur', + co_parent_code_postal: '95870', + co_parent_ville: 'Bezons', + enfants: [ + { + prenom: 'Chloé', + nom: 'ROUSSEAU', + date_naissance: '2022-04-20', + genre: 'F', + photo_base64: toDataUri(chloeJpg), + photo_filename: 'chloe_rousseau.jpg', + grossesse_multiple: false, + }, + { + prenom: 'Hugo', + nom: 'ROUSSEAU', + date_naissance: '2024-03-10', + genre: 'H', + photo_base64: toDataUri(hugoJpg), + photo_filename: 'hugo_rousseau.jpg', + grossesse_multiple: false, + }, + ], + presentation_dossier: presentationDossier, + acceptation_cgu: true, + acceptation_privacy: true, + }; + + const json = JSON.stringify(body); + const baseArg = process.argv[2] || 'https://app.ptits-pas.fr'; + const base = new URL(baseArg.endsWith('/') ? baseArg.slice(0, -1) : baseArg); + const url = new URL('/api/v1/auth/register/parent', `${base.protocol}//${base.host}`); + + const opts = { + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'Content-Length': Buffer.byteLength(json, 'utf8'), + }, + }; + + const lib = url.protocol === 'https:' ? https : http; + + console.error(`POST ${url.href} (payload ~${Math.round(json.length / 1024)} Ko)`); + + const req = lib.request(opts, (res) => { + let data = ''; + res.on('data', (c) => { + data += c; + }); + res.on('end', () => { + console.log('HTTP', res.statusCode); + try { + const j = JSON.parse(data); + console.log(JSON.stringify(j, null, 2)); + } catch { + console.log(data.slice(0, 4000)); + } + }); + }); + + req.on('error', (e) => { + console.error('Erreur réseau:', e.message); + process.exit(1); + }); + + req.setTimeout(120000, () => { + req.destroy(); + console.error('Timeout 120s'); + process.exit(1); + }); + + req.write(json); + req.end(); +} finally { + try { + if (chloeJpg) fs.unlinkSync(chloeJpg); + } catch { + /* ignore */ + } + try { + if (hugoJpg) fs.unlinkSync(hugoJpg); + } catch { + /* ignore */ + } +} diff --git a/scripts/register-parent-lecomte-test.mjs b/scripts/register-parent-lecomte-test.mjs new file mode 100644 index 0000000..5f9c3f3 --- /dev/null +++ b/scripts/register-parent-lecomte-test.mjs @@ -0,0 +1,102 @@ +/** + * POST /api/v1/auth/register/parent — jeu de test officiel David LECOMTE (père isolé). + * Email : david.lecomte@ptits-pas.fr + * + * Usage : node scripts/register-parent-lecomte-test.mjs [BASE_URL] + */ + +import fs from 'fs'; +import path from 'path'; +import https from 'https'; +import http from 'http'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.join(__dirname, '..'); +const photosDir = path.join(repoRoot, 'ressources', 'Photos'); + +function toDataUri(filePath) { + const buf = fs.readFileSync(filePath); + return `data:image/png;base64,${buf.toString('base64')}`; +} + +const presentationDossier = + "Je suis David LECOMTE, père isolé de Maxime. J'ai la garde complète de mon fils. " + + "Je recherche une assistante maternelle bienveillante à Bezons. " + + "En cas d'urgence, la personne à contacter est sa grand-mère paternelle. " + + "Merci pour l'étude de notre dossier."; + +const body = { + email: 'david.lecomte@ptits-pas.fr', + prenom: 'David', + nom: 'LECOMTE', + telephone: '0645566778', + adresse: '31 Rue Émile Zola', + code_postal: '95870', + ville: 'Bezons', + enfants: [ + { + prenom: 'Maxime', + nom: 'LECOMTE', + date_naissance: '2023-04-15', + genre: 'H', + photo_base64: toDataUri(path.join(photosDir, 'C_Maxime.png')), + photo_filename: 'maxime_lecomte.png', + grossesse_multiple: false, + }, + ], + presentation_dossier: presentationDossier, + acceptation_cgu: true, + acceptation_privacy: true, +}; + +const json = JSON.stringify(body); +const baseArg = process.argv[2] || 'https://app.ptits-pas.fr'; +const base = new URL(baseArg.endsWith('/') ? baseArg.slice(0, -1) : baseArg); +const url = new URL('/api/v1/auth/register/parent', `${base.protocol}//${base.host}`); + +const opts = { + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'Content-Length': Buffer.byteLength(json, 'utf8'), + }, +}; + +const lib = url.protocol === 'https:' ? https : http; + +console.error(`POST ${url.href} (payload ~${Math.round(json.length / 1024)} Ko)`); + +const req = lib.request(opts, (res) => { + let data = ''; + res.on('data', (c) => { + data += c; + }); + res.on('end', () => { + console.log('HTTP', res.statusCode); + try { + const j = JSON.parse(data); + console.log(JSON.stringify(j, null, 2)); + } catch { + console.log(data.slice(0, 4000)); + } + }); +}); + +req.on('error', (e) => { + console.error('Erreur réseau:', e.message); + process.exit(1); +}); + +req.setTimeout(120000, () => { + req.destroy(); + console.error('Timeout 120s'); + process.exit(1); +}); + +req.write(json); +req.end(); diff --git a/scripts/register-parent-martin-test.mjs b/scripts/register-parent-martin-test.mjs new file mode 100644 index 0000000..a1253eb --- /dev/null +++ b/scripts/register-parent-martin-test.mjs @@ -0,0 +1,126 @@ +/** + * POST /api/v1/auth/register/parent — jeu de test officiel famille MARTIN (docs/test-data + seed). + * Emails canoniques : claire.martin@ptits-pas.fr, thomas.martin@ptits-pas.fr + * + * Usage : node scripts/register-parent-martin-test.mjs [BASE_URL] + * Ex. : node scripts/register-parent-martin-test.mjs https://app.ptits-pas.fr + */ + +import fs from 'fs'; +import path from 'path'; +import https from 'https'; +import http from 'http'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.join(__dirname, '..'); +const photosDir = path.join(repoRoot, 'ressources', 'Photos'); + +function toDataUri(filePath) { + const buf = fs.readFileSync(filePath); + return `data:image/png;base64,${buf.toString('base64')}`; +} + +const presentationDossier = + "Nous sommes Claire et Thomas MARTIN, parents d'Emma, Noah et Léa, triplés nés le 15 février 2023. " + + "Nous recherchons une assistante maternelle bienveillante et structurée pour accueillir nos trois enfants, " + + "avec une capacité d'accueil adaptée à la garde de plusieurs nourrissons. " + + "Nous habitons à Bezons ; nous tenons à remercier le service pour l'étude de notre dossier."; + +const body = { + email: 'claire.martin@ptits-pas.fr', + prenom: 'Claire', + nom: 'MARTIN', + telephone: '0689567890', + adresse: '5 Avenue du Général de Gaulle', + code_postal: '95870', + ville: 'Bezons', + co_parent_email: 'thomas.martin@ptits-pas.fr', + co_parent_prenom: 'Thomas', + co_parent_nom: 'MARTIN', + co_parent_telephone: '0678456789', + co_parent_meme_adresse: true, + enfants: [ + { + prenom: 'Emma', + nom: 'MARTIN', + date_naissance: '2023-02-15', + genre: 'F', + photo_base64: toDataUri(path.join(photosDir, 'C_Emma MARTIN.png')), + photo_filename: 'emma_martin.png', + grossesse_multiple: true, + }, + { + prenom: 'Noah', + nom: 'MARTIN', + date_naissance: '2023-02-15', + genre: 'H', + photo_base64: toDataUri(path.join(photosDir, 'C_Noah MARTIN_2.png')), + photo_filename: 'noah_martin.png', + grossesse_multiple: true, + }, + { + prenom: 'Léa', + nom: 'MARTIN', + date_naissance: '2023-02-15', + genre: 'F', + photo_base64: toDataUri(path.join(photosDir, 'C_Léa MMARTIN.png')), + photo_filename: 'lea_martin.png', + grossesse_multiple: true, + }, + ], + presentation_dossier: presentationDossier, + acceptation_cgu: true, + acceptation_privacy: true, +}; + +const json = JSON.stringify(body); +const baseArg = process.argv[2] || 'https://app.ptits-pas.fr'; +const base = new URL(baseArg.endsWith('/') ? baseArg.slice(0, -1) : baseArg); +const url = new URL('/api/v1/auth/register/parent', `${base.protocol}//${base.host}`); + +const opts = { + hostname: url.hostname, + port: url.port || (url.protocol === 'https:' ? 443 : 80), + path: url.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'Content-Length': Buffer.byteLength(json, 'utf8'), + }, +}; + +const lib = url.protocol === 'https:' ? https : http; + +console.error(`POST ${url.href} (payload ~${Math.round(json.length / 1024)} Ko)`); + +const req = lib.request(opts, (res) => { + let data = ''; + res.on('data', (c) => { + data += c; + }); + res.on('end', () => { + console.log('HTTP', res.statusCode); + try { + const j = JSON.parse(data); + console.log(JSON.stringify(j, null, 2)); + } catch { + console.log(data.slice(0, 4000)); + } + }); +}); + +req.on('error', (e) => { + console.error('Erreur réseau:', e.message); + process.exit(1); +}); + +req.setTimeout(120000, () => { + req.destroy(); + console.error('Timeout 120s'); + process.exit(1); +}); + +req.write(json); +req.end(); From dc04e04598ba407bbe95b0b0412b228f5dee019d Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Sat, 11 Apr 2026 18:08:04 +0200 Subject: [PATCH 11/13] =?UTF-8?q?chore(master):=20alignement=20post-squash?= =?UTF-8?q?=20avec=20develop=20(t=C3=A9l=C3=A9phone,=20script=20Gitea)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- frontend/lib/widgets/personal_info_form_screen.dart | 6 ------ scripts/gitea-close-issue-with-comment.sh | 11 +---------- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/frontend/lib/widgets/personal_info_form_screen.dart b/frontend/lib/widgets/personal_info_form_screen.dart index 181cc82..320ffd3 100644 --- a/frontend/lib/widgets/personal_info_form_screen.dart +++ b/frontend/lib/widgets/personal_info_form_screen.dart @@ -1254,12 +1254,6 @@ class _PersonalInfoFormScreenState extends State { } } - static final _phoneInputFormatters = [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(10), - FrenchPhoneNumberFormatter(), - ]; - /// Retourne l'asset de carte vertical correspondant à la couleur String _getVerticalCardAsset() { switch (widget.cardColor) { diff --git a/scripts/gitea-close-issue-with-comment.sh b/scripts/gitea-close-issue-with-comment.sh index 0c1e799..ad545b8 100644 --- a/scripts/gitea-close-issue-with-comment.sh +++ b/scripts/gitea-close-issue-with-comment.sh @@ -15,18 +15,9 @@ if [ -z "$GITEA_TOKEN" ]; then GITEA_TOKEN=$(cat .gitea-token) fi fi -if [ -z "$GITEA_TOKEN" ] && [ -f ~/.bashrc ]; then - eval "$(grep '^export GITEA_TOKEN=' ~/.bashrc 2>/dev/null)" || true -fi -if [ -z "$GITEA_TOKEN" ] && [ -f docs/BRIEFING-FRONTEND.md ]; then - token_from_briefing=$(sed -n 's/.*Token: *\(giteabu_[a-f0-9]*\).*/\1/p' docs/BRIEFING-FRONTEND.md 2>/dev/null | head -1) - if [ -n "$token_from_briefing" ]; then - GITEA_TOKEN="$token_from_briefing" - fi -fi if [ -z "$GITEA_TOKEN" ]; then - echo "Définir GITEA_TOKEN ou créer .gitea-token avec votre token Gitea (voir docs/PROCEDURE-API-GITEA.md)." + echo "Définir GITEA_TOKEN ou créer .gitea-token avec votre token Gitea." exit 1 fi From 518b3f84df3a0657bfed3c3a1edc758ec683f1b8 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Sat, 11 Apr 2026 18:08:36 +0200 Subject: [PATCH 12/13] =?UTF-8?q?Revert=20"chore(master):=20alignement=20p?= =?UTF-8?q?ost-squash=20avec=20develop=20(t=C3=A9l=C3=A9phone,=20script=20?= =?UTF-8?q?Gitea)"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit dc04e04598ba407bbe95b0b0412b228f5dee019d. --- frontend/lib/widgets/personal_info_form_screen.dart | 6 ++++++ scripts/gitea-close-issue-with-comment.sh | 11 ++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/frontend/lib/widgets/personal_info_form_screen.dart b/frontend/lib/widgets/personal_info_form_screen.dart index 320ffd3..181cc82 100644 --- a/frontend/lib/widgets/personal_info_form_screen.dart +++ b/frontend/lib/widgets/personal_info_form_screen.dart @@ -1254,6 +1254,12 @@ class _PersonalInfoFormScreenState extends State { } } + static final _phoneInputFormatters = [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(10), + FrenchPhoneNumberFormatter(), + ]; + /// Retourne l'asset de carte vertical correspondant à la couleur String _getVerticalCardAsset() { switch (widget.cardColor) { diff --git a/scripts/gitea-close-issue-with-comment.sh b/scripts/gitea-close-issue-with-comment.sh index ad545b8..0c1e799 100644 --- a/scripts/gitea-close-issue-with-comment.sh +++ b/scripts/gitea-close-issue-with-comment.sh @@ -15,9 +15,18 @@ if [ -z "$GITEA_TOKEN" ]; then GITEA_TOKEN=$(cat .gitea-token) fi fi +if [ -z "$GITEA_TOKEN" ] && [ -f ~/.bashrc ]; then + eval "$(grep '^export GITEA_TOKEN=' ~/.bashrc 2>/dev/null)" || true +fi +if [ -z "$GITEA_TOKEN" ] && [ -f docs/BRIEFING-FRONTEND.md ]; then + token_from_briefing=$(sed -n 's/.*Token: *\(giteabu_[a-f0-9]*\).*/\1/p' docs/BRIEFING-FRONTEND.md 2>/dev/null | head -1) + if [ -n "$token_from_briefing" ]; then + GITEA_TOKEN="$token_from_briefing" + fi +fi if [ -z "$GITEA_TOKEN" ]; then - echo "Définir GITEA_TOKEN ou créer .gitea-token avec votre token Gitea." + echo "Définir GITEA_TOKEN ou créer .gitea-token avec votre token Gitea (voir docs/PROCEDURE-API-GITEA.md)." exit 1 fi From 4dedd9b87a0e5e7e309e12697e84800162245d00 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Sun, 12 Apr 2026 21:11:06 +0200 Subject: [PATCH 13/13] =?UTF-8?q?fix(web):=20withOpacity=20=C3=A0=20la=20p?= =?UTF-8?q?lace=20de=20withValues=20(Flutter=203.19=20/=20dart2js)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- frontend/lib/widgets/admin/validation_family_wizard.dart | 6 +++--- frontend/lib/widgets/child_card_widget.dart | 6 +++--- frontend/lib/widgets/custom_app_text_field.dart | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/lib/widgets/admin/validation_family_wizard.dart b/frontend/lib/widgets/admin/validation_family_wizard.dart index 4354ab6..07bff2a 100644 --- a/frontend/lib/widgets/admin/validation_family_wizard.dart +++ b/frontend/lib/widgets/admin/validation_family_wizard.dart @@ -309,7 +309,7 @@ class _ValidationFamilyWizardState extends State { static List _enfantCardShadows() => [ BoxShadow( - color: Colors.black.withValues(alpha: 0.06), + color: Colors.black.withOpacity(0.06), blurRadius: 14, offset: const Offset(0, 4), ), @@ -514,10 +514,10 @@ class _ValidationFamilyWizardState extends State { height: height, decoration: BoxDecoration( borderRadius: BorderRadius.circular(photoRadius), - border: Border.all(color: Colors.black.withValues(alpha: 0.08)), + border: Border.all(color: Colors.black.withOpacity(0.08)), boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.05), + color: Colors.black.withOpacity(0.05), blurRadius: 6, offset: const Offset(0, 2), ), diff --git a/frontend/lib/widgets/child_card_widget.dart b/frontend/lib/widgets/child_card_widget.dart index 7d93ecc..afad14e 100644 --- a/frontend/lib/widgets/child_card_widget.dart +++ b/frontend/lib/widgets/child_card_widget.dart @@ -195,9 +195,9 @@ class _ChildCardWidgetState extends State { ? Colors.purple.shade200 : (widget.childData.cardColor == CardColorVertical.pink ? Colors.pink.shade200 : Colors.grey.shade200); final Color initialPhotoShadow = - Color.alphaBlend(Colors.black.withValues(alpha: 0.22), baseCardColorForShadow); + Color.alphaBlend(Colors.black.withOpacity(0.22), baseCardColorForShadow); final Color hoverPhotoShadow = - Color.alphaBlend(Colors.black.withValues(alpha: 0.32), baseCardColorForShadow); + Color.alphaBlend(Colors.black.withOpacity(0.32), baseCardColorForShadow); final double photoSide = 200.0 * (config.isMobile ? 0.8 : 1.0); final double editableCardWidth = config.isMobile @@ -722,7 +722,7 @@ class _ChildCardWidgetState extends State { style: OutlinedButton.styleFrom( padding: EdgeInsets.symmetric(vertical: padV), visualDensity: VisualDensity.compact, - backgroundColor: on ? Colors.black.withValues(alpha: 0.08) : Colors.transparent, + backgroundColor: on ? Colors.black.withOpacity(0.08) : Colors.transparent, foregroundColor: Colors.black87, side: BorderSide( width: on ? 2 : 1, diff --git a/frontend/lib/widgets/custom_app_text_field.dart b/frontend/lib/widgets/custom_app_text_field.dart index 2db1ab3..dae6364 100644 --- a/frontend/lib/widgets/custom_app_text_field.dart +++ b/frontend/lib/widgets/custom_app_text_field.dart @@ -89,7 +89,7 @@ class _CustomAppTextFieldState extends State { final Color labelColor = widget.enabled ? Colors.black87 : Colors.grey; final Color hintColor = widget.enabled - ? Colors.black54.withValues(alpha: 0.7) + ? Colors.black54.withOpacity(0.7) : Colors.grey; return Column(